Improve movement flow layout: smaller hover portraits, on-band labels, and clear lineage corridors.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-07-27 13:16:20 +03:00
co-authored by Cursor
parent 89e39d408a
commit 8d3ebcce60
3 changed files with 535 additions and 155 deletions
+31 -16
View File
@@ -28,7 +28,7 @@
.movements-flow-canvas {
--stream-stroke: 54px;
--portrait-size: 52px;
--portrait-size: 26px;
position: relative;
flex: 1;
min-height: 0;
@@ -173,7 +173,29 @@
inset: 0;
}
.movements-flow-hits {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
}
.movement-stream-hit {
position: absolute;
height: var(--stream-stroke);
transform: translateY(-50%);
pointer-events: auto;
}
.movements-flow-artists {
opacity: 0;
pointer-events: none;
transition: opacity 0.18s ease;
z-index: 5;
}
.movements-flow-canvas.movements-flow-band-hover .movements-flow-artists {
opacity: 1;
pointer-events: auto;
}
@@ -239,23 +261,16 @@
padding: 2px 6px;
pointer-events: none;
z-index: 6;
}
.movement-flow-label-above {
transform: translate(-4px, -100%);
}
.movement-flow-label-below {
transform: translate(-4px, 0);
transform: translate(-50%, -50%);
}
.movement-flow-label-btn {
pointer-events: auto;
border: none;
background: rgba(12, 10, 18, 0.55);
background: rgba(12, 10, 18, 0.72);
border-radius: 4px;
cursor: pointer;
text-align: left;
text-align: center;
transition: background 0.2s, box-shadow 0.2s;
}
@@ -290,8 +305,8 @@
background: none;
border: 2px solid rgba(232, 213, 181, 0.75);
border-radius: 50%;
width: var(--portrait-size, 52px);
height: var(--portrait-size, 52px);
width: var(--portrait-size, 26px);
height: var(--portrait-size, 26px);
padding: 0;
cursor: pointer;
overflow: visible;
@@ -335,7 +350,7 @@
@media (max-width: 768px) {
.movements-flow-canvas {
--stream-stroke: 44px;
--portrait-size: 44px;
--portrait-size: 22px;
}
.movements-flow-caption {
@@ -343,8 +358,8 @@
}
.artist-portrait {
width: 44px;
height: 44px;
width: var(--portrait-size, 22px);
height: var(--portrait-size, 22px);
}
.movement-flow-label {
+502 -137
View File
@@ -44,6 +44,8 @@ interface BranchSegment {
const MAX_STREAM_STROKE_PX = 54;
const MIN_STREAM_STROKE_PX = 28;
/** When many lanes must fit, stroke may shrink below MIN_STREAM_STROKE_PX down to this. */
const ABS_MIN_STREAM_STROKE_PX = 10;
const LANE_GAP_PX = 10;
const CANVAS_TOP_PAD = 38;
const CANVAS_BOTTOM_PAD = 24;
@@ -362,6 +364,264 @@ function assignTemporalLanes(
return laneById;
}
function spansOverlap(a0: number, a1: number, b0: number, b1: number, tol = 0.15): boolean {
return a0 < b1 - tol && b0 < a1 - tol;
}
function yearSpansConflict(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
return aStart < bEnd && bStart < aEnd;
}
/** Approximate branch X span (same origin/target X as branch assembly), padded for thick strokes. */
function branchCorridorXRange(
parentXStart: number,
parentXEnd: number,
childXStart: number,
childXEnd: number,
childIndex: number,
childCount: number
): { x0: number; x1: number } {
const t = childCount === 1 ? 0.5 : (childIndex + 1) / (childCount + 1);
const originX = parentXStart + t * (parentXEnd - parentXStart);
const targetX = (childXStart + childXEnd) / 2;
const x0 = Math.min(originX, targetX);
const x1 = Math.max(originX, targetX);
// Near-vertical transitions still have a wide SVG stroke — pad so overlapping streams register.
const pad = Math.max(4, (x1 - x0) * 0.35);
return { x0: x0 - pad, x1: x1 + pad };
}
function clippedMovementSpan(
movement: ArtMovement,
viewStart: number,
viewEnd: number
): { start: number; end: number; xStart: number; xEnd: number } | null {
const start = Math.max(movement.start_year, viewStart);
const end = Math.min(movement.end_year, viewEnd);
if (end <= start) return null;
return {
start,
end,
xStart: yearToPercent(start, viewStart, viewEnd),
xEnd: yearToPercent(end, viewStart, viewEnd),
};
}
function laneHasYearConflict(
laneById: Map<number, number>,
movementsById: Map<number, ArtMovement>,
movementId: number,
targetLane: number,
viewStart: number,
viewEnd: number
): boolean {
const me = movementsById.get(movementId);
if (!me) return true;
const mySpan = clippedMovementSpan(me, viewStart, viewEnd);
if (!mySpan) return true;
for (const [otherId, lane] of laneById) {
if (otherId === movementId || lane !== targetLane) continue;
const other = movementsById.get(otherId);
if (!other) continue;
const otherSpan = clippedMovementSpan(other, viewStart, viewEnd);
if (!otherSpan) continue;
if (yearSpansConflict(mySpan.start, mySpan.end, otherSpan.start, otherSpan.end)) return true;
}
return false;
}
function maxLaneIndex(laneById: Map<number, number>): number {
let max = -1;
for (const lane of laneById.values()) max = Math.max(max, lane);
return max;
}
function findFreeLaneForMovement(
laneById: Map<number, number>,
movementsById: Map<number, ArtMovement>,
movementId: number,
viewStart: number,
viewEnd: number,
preferOutside?: { lo: number; hi: number },
excludeLane?: number
): number {
const maxLane = maxLaneIndex(laneById);
const candidates: number[] = [];
for (let lane = 0; lane <= maxLane; lane++) candidates.push(lane);
candidates.push(maxLane + 1);
const ranked = [...candidates].sort((a, b) => {
const aInside =
preferOutside != null && a > preferOutside.lo && a < preferOutside.hi ? 1 : 0;
const bInside =
preferOutside != null && b > preferOutside.lo && b < preferOutside.hi ? 1 : 0;
if (aInside !== bInside) return aInside - bInside;
return a - b;
});
for (const lane of ranked) {
if (excludeLane != null && lane === excludeLane) continue;
if (!laneHasYearConflict(laneById, movementsById, movementId, lane, viewStart, viewEnd)) {
return lane;
}
}
return maxLane + 1;
}
/**
* After temporal packing, pull lineage parent/child pairs onto nearby (or shared)
* lanes, then displace unrelated movements out of remaining branch corridors.
* Attract runs before clear, and never again afterward — mixing them caused
* Gothic to leapfrog downward while Early/Northern stayed in the Byzantine→Gothic
* transition (see High Renaissance stuck on lane 0 vs Early on lane 2).
*/
function refineLanesForLineageCorridors(
laneById: Map<number, number>,
movements: ArtMovement[],
lineageParents: Map<number, number[]>,
viewStart: number,
viewEnd: number
): void {
if (movements.length === 0 || lineageParents.size === 0) return;
const movementsById = new Map(movements.map((m) => [m.id, m]));
const MAX_ATTRACT_ITERS = 8;
const MAX_CLEAR_ITERS = 8;
const tryMoveToLane = (movementId: number, desired: number): boolean => {
if (desired < 0) return false;
const current = laneById.get(movementId);
if (current === desired) return false;
if (laneHasYearConflict(laneById, movementsById, movementId, desired, viewStart, viewEnd)) {
return false;
}
laneById.set(movementId, desired);
return true;
};
/** Pull endpoints within one lane (including sharing a lane when years allow). */
const tryAttractPair = (parentId: number, childId: number): boolean => {
const parentLane = laneById.get(parentId);
const childLane = laneById.get(childId);
if (parentLane == null || childLane == null) return false;
if (Math.abs(parentLane - childLane) <= 1) return false;
// Prefer landing on the partner's lane first (covers High→Early same-lane case).
const childTargets = [parentLane, parentLane + 1, parentLane - 1];
for (const desired of childTargets) {
if (tryMoveToLane(childId, desired)) return true;
}
const parentTargets = [childLane, childLane + 1, childLane - 1];
for (const desired of parentTargets) {
if (tryMoveToLane(parentId, desired)) return true;
}
return false;
};
// Phase A: attract linked pairs only.
for (let iter = 0; iter < MAX_ATTRACT_ITERS; iter++) {
let changed = false;
for (const [childId, parentIds] of lineageParents) {
if (!laneById.has(childId)) continue;
for (const parentId of parentIds) {
if (!laneById.has(parentId)) continue;
if (tryAttractPair(parentId, childId)) changed = true;
}
}
if (!changed) break;
}
// Phase B: displace corridor foreigners only.
for (let iter = 0; iter < MAX_CLEAR_ITERS; iter++) {
let changed = false;
const childIdsByParent = new Map<number, number[]>();
for (const [childId, parentIds] of lineageParents) {
if (!laneById.has(childId)) continue;
for (const parentId of parentIds) {
if (!laneById.has(parentId)) continue;
const list = childIdsByParent.get(parentId) || [];
list.push(childId);
childIdsByParent.set(parentId, list);
}
}
for (const children of childIdsByParent.values()) {
children.sort((a, b) => {
const ma = movementsById.get(a)!;
const mb = movementsById.get(b)!;
return ma.start_year - mb.start_year || a - b;
});
}
for (const [childId, parentIds] of lineageParents) {
const child = movementsById.get(childId);
const childSpan = child ? clippedMovementSpan(child, viewStart, viewEnd) : null;
const childLane = laneById.get(childId);
if (!child || !childSpan || childLane == null) continue;
for (const parentId of parentIds) {
const parent = movementsById.get(parentId);
const parentSpan = parent ? clippedMovementSpan(parent, viewStart, viewEnd) : null;
const parentLane = laneById.get(parentId);
if (!parent || !parentSpan || parentLane == null) continue;
const children = childIdsByParent.get(parentId) || [childId];
const childIndex = Math.max(0, children.indexOf(childId));
const { x0, x1 } = branchCorridorXRange(
parentSpan.xStart,
parentSpan.xEnd,
childSpan.xStart,
childSpan.xEnd,
childIndex,
children.length
);
const lo = Math.min(parentLane, childLane);
const hi = Math.max(parentLane, childLane);
if (hi - lo <= 1) continue;
for (const movement of movements) {
if (movement.id === parentId || movement.id === childId) continue;
const lane = laneById.get(movement.id);
if (lane == null || lane <= lo || lane >= hi) continue;
const span = clippedMovementSpan(movement, viewStart, viewEnd);
if (!span || !spansOverlap(span.xStart, span.xEnd, x0, x1)) continue;
const nextLane = findFreeLaneForMovement(
laneById,
movementsById,
movement.id,
viewStart,
viewEnd,
{ lo, hi },
lane
);
if (nextLane !== lane) {
laneById.set(movement.id, nextLane);
changed = true;
}
}
}
}
if (!changed) break;
}
}
/** Renumber lanes to dense 0..n-1 so empty gaps do not waste vertical space. */
function compactLanes(laneById: Map<number, number>): void {
const used = [...new Set(laneById.values())].sort((a, b) => a - b);
if (used.length === 0) return;
const remap = new Map(used.map((lane, index) => [lane, index]));
for (const [id, lane] of laneById) {
laneById.set(id, remap.get(lane) ?? 0);
}
}
function streamPath(
xStart: number,
xEnd: number,
@@ -405,7 +665,6 @@ interface LabelPlacement {
layout: MovementLayout;
leftPct: number;
topPct: number;
above: boolean;
}
interface PixelRect {
@@ -416,9 +675,9 @@ interface PixelRect {
}
const LABEL_MAX_WIDTH_PX = 160;
const LABEL_GAP_PX = 8;
const LABEL_PORTRAIT_PAD_PX = 6;
const LABEL_MIN_SEPARATION_PX = 4;
const LABEL_PORTRAIT_PAD_PX = 8;
const LABEL_MIN_SEPARATION_PX = 6;
const LABEL_BAND_PAD_PX = 4;
function estimateLabelWidthPx(name: string): number {
const textWidth = name.length * 7.2 + 14;
@@ -426,7 +685,7 @@ function estimateLabelWidthPx(name: string): number {
}
function labelHeightPx(hasEra: boolean): number {
return hasEra ? 36 : 22;
return hasEra ? 34 : 20;
}
function rectsOverlap(a: PixelRect, b: PixelRect, pad = LABEL_MIN_SEPARATION_PX): boolean {
@@ -438,6 +697,15 @@ function rectsOverlap(a: PixelRect, b: PixelRect, pad = LABEL_MIN_SEPARATION_PX)
);
}
function overlapArea(a: PixelRect, b: PixelRect, pad = LABEL_MIN_SEPARATION_PX): number {
const left = Math.max(a.left - pad, b.left - pad);
const right = Math.min(a.right + pad, b.right + pad);
const top = Math.max(a.top - pad, b.top - pad);
const bottom = Math.min(a.bottom + pad, b.bottom + pad);
if (right <= left || bottom <= top) return 0;
return (right - left) * (bottom - top);
}
function portraitObstacleRect(
portraitX: number,
y: number,
@@ -452,43 +720,66 @@ function portraitObstacleRect(
return { left: cx - half, top: cy - half, right: cx + half, bottom: cy + half };
}
function streamObstacleRect(
streamY: number,
streamStrokePx: number,
layoutHeight: number,
canvasHeight: number,
leftPx: number,
widthPx: number
): PixelRect {
const cy = (streamY / layoutHeight) * canvasHeight;
const half = streamStrokePx / 2 + 2;
return {
left: leftPx,
top: cy - half,
right: leftPx + widthPx,
bottom: cy + half,
};
}
function labelObstacleRect(
leftPct: number,
streamY: number,
above: boolean,
widthPx: number,
heightPx: number,
/** Full movement stream as an obstacle (for avoiding other bands). */
function streamBandObstacleRect(
layout: MovementLayout,
streamStrokePx: number,
layoutHeight: number,
canvasWidth: number,
canvasHeight: number
): PixelRect {
const anchorX = (leftPct / 100) * canvasWidth - 4;
const streamYPx = (streamY / layoutHeight) * canvasHeight;
if (above) {
const bottom = streamYPx - LABEL_GAP_PX;
return { left: anchorX, top: bottom - heightPx, right: anchorX + widthPx, bottom };
}
const top = streamYPx + streamStrokePx / 2 + LABEL_GAP_PX;
return { left: anchorX, top, right: anchorX + widthPx, bottom: top + heightPx };
const left = (layout.xStart / 100) * canvasWidth;
const right = (layout.xEnd / 100) * canvasWidth;
const y1 = (layout.y / layoutHeight) * canvasHeight;
const y2 = (layout.yEnd / layoutHeight) * canvasHeight;
const half = streamStrokePx / 2 + LABEL_BAND_PAD_PX;
return {
left,
top: Math.min(y1, y2) - half,
right,
bottom: Math.max(y1, y2) + half,
};
}
/** Label centered on the stream (same horizontal line as the band). */
function labelOnBandRect(
leftPct: number,
streamY: number,
widthPx: number,
heightPx: number,
layoutHeight: number,
canvasWidth: number,
canvasHeight: number
): PixelRect {
const cx = (leftPct / 100) * canvasWidth;
const cy = (streamY / layoutHeight) * canvasHeight;
return {
left: cx - widthPx / 2,
top: cy - heightPx / 2,
right: cx + widthPx / 2,
bottom: cy + heightPx / 2,
};
}
/** Thick lineage branch corridor as a label obstacle (AABB of endpoints). */
function branchCorridorObstacleRect(
branch: BranchSegment,
streamStrokePx: number,
layoutHeight: number,
canvasWidth: number,
canvasHeight: number
): PixelRect {
const x0 = Math.min(branch.x1, branch.x2);
const x1 = Math.max(branch.x1, branch.x2);
const y0 = Math.min(branch.y1, branch.y2);
const y1 = Math.max(branch.y1, branch.y2);
const half = streamStrokePx / 2 + LABEL_BAND_PAD_PX;
return {
left: (x0 / 100) * canvasWidth,
top: (y0 / layoutHeight) * canvasHeight - half,
right: (x1 / 100) * canvasWidth,
bottom: (y1 / layoutHeight) * canvasHeight + half,
};
}
function clampLabelLeftPct(leftPct: number, widthPx: number, canvasWidth: number): number {
@@ -496,17 +787,6 @@ function clampLabelLeftPct(leftPct: number, widthPx: number, canvasWidth: number
return Math.min(Math.max(leftPct, halfWidthPct + 0.5), 100 - halfWidthPct - 0.5);
}
function labelAnchorTopPct(
streamY: number,
above: boolean,
streamStrokePx: number,
layoutHeight: number
): number {
const gap = above ? LABEL_GAP_PX : streamStrokePx / 2 + LABEL_GAP_PX;
const y = above ? streamY - gap : streamY + gap;
return (y / layoutHeight) * 100;
}
function buildLabelPlacements(
layouts: MovementLayout[],
artistPlacements: ArtistPlacement[],
@@ -514,7 +794,8 @@ function buildLabelPlacements(
canvasWidth: number,
canvasHeight: number,
streamStrokePx: number,
portraitSizePx: number
portraitSizePx: number,
branches: BranchSegment[] = []
): LabelPlacement[] {
if (layouts.length === 0 || canvasWidth <= 0 || canvasHeight <= 0) return [];
@@ -522,6 +803,26 @@ function buildLabelPlacements(
portraitObstacleRect(p.portraitX, p.y, portraitSizePx, layoutHeight, canvasWidth, canvasHeight)
);
const bandObstacles = layouts.map((layout) => ({
id: layout.movement.id,
rect: streamBandObstacleRect(layout, streamStrokePx, layoutHeight, canvasWidth, canvasHeight),
}));
const branchObstacles = branches.map((branch) => {
const [fromId, toId] = branch.key.split('-').map(Number);
return {
fromId,
toId,
rect: branchCorridorObstacleRect(
branch,
streamStrokePx,
layoutHeight,
canvasWidth,
canvasHeight
),
};
});
const placed: LabelPlacement[] = [];
const placedLabelRects: PixelRect[] = [];
@@ -532,10 +833,12 @@ function buildLabelPlacements(
const widthPx = estimateLabelWidthPx(layout.movement.name);
const heightPx = labelHeightPx(hasEra);
const span = Math.max(layout.xEnd - layout.xStart, 0.5);
const preferredX = layout.xStart + span * 0.5;
const xCandidates = new Set<number>();
for (const t of [0, 0.2, 0.4, 0.5, 0.6, 0.8, 1]) {
xCandidates.add(layout.xStart + span * t);
const steps = Math.max(12, Math.ceil(span * 2));
for (let i = 0; i <= steps; i++) {
xCandidates.add(layout.xStart + span * (i / steps));
}
const movementPortraits = artistPlacements
@@ -543,93 +846,82 @@ function buildLabelPlacements(
.sort((a, b) => a.portraitX - b.portraitX);
if (movementPortraits.length === 0) {
xCandidates.add(layout.xStart + span * 0.5);
xCandidates.add(preferredX);
} else {
const first = movementPortraits[0].portraitX;
const last = movementPortraits[movementPortraits.length - 1].portraitX;
if (first - layout.xStart > 4) xCandidates.add(layout.xStart + (first - layout.xStart) * 0.45);
if (layout.xEnd - last > 4) xCandidates.add(last + (layout.xEnd - last) * 0.55);
if (first - layout.xStart > 2) xCandidates.add(layout.xStart + (first - layout.xStart) * 0.45);
if (layout.xEnd - last > 2) xCandidates.add(last + (layout.xEnd - last) * 0.55);
for (let i = 0; i < movementPortraits.length - 1; i++) {
xCandidates.add((movementPortraits[i].portraitX + movementPortraits[i + 1].portraitX) / 2);
}
}
const orderedX = [...xCandidates].sort(
(a, b) => Math.abs(a - (layout.xStart + span * 0.5)) - Math.abs(b - (layout.xStart + span * 0.5))
);
const orderedX = [...xCandidates].sort((a, b) => Math.abs(a - preferredX) - Math.abs(b - preferredX));
const otherBands = bandObstacles.filter((b) => b.id !== layout.movement.id).map((b) => b.rect);
const otherBranches = branchObstacles
.filter((b) => b.fromId !== layout.movement.id && b.toId !== layout.movement.id)
.map((b) => b.rect);
const scoreRect = (rect: PixelRect) => {
let score = 0;
for (const o of portraitObstacles) score += overlapArea(rect, o) * 4;
for (const o of placedLabelRects) score += overlapArea(rect, o) * 5;
for (const o of otherBands) score += overlapArea(rect, o) * 3;
for (const o of otherBranches) score += overlapArea(rect, o) * 4;
return score;
};
const isClear = (rect: PixelRect) =>
!portraitObstacles.some((o) => rectsOverlap(rect, o)) &&
!placedLabelRects.some((o) => rectsOverlap(rect, o)) &&
!otherBands.some((o) => rectsOverlap(rect, o)) &&
!otherBranches.some((o) => rectsOverlap(rect, o));
let chosen: LabelPlacement | null = null;
let chosenRect: PixelRect | null = null;
let bestFallback: { placement: LabelPlacement; rect: PixelRect; score: number } | null = null;
for (const above of [true, false] as const) {
for (const rawX of orderedX) {
const leftPct = clampLabelLeftPct(rawX, widthPx, canvasWidth);
const streamY = yOnStream(layout, leftPct);
const rect = labelObstacleRect(
leftPct,
streamY,
above,
widthPx,
heightPx,
streamStrokePx,
layoutHeight,
canvasWidth,
canvasHeight
);
const streamRect = streamObstacleRect(
streamY,
streamStrokePx,
layoutHeight,
canvasHeight,
rect.left,
widthPx
);
const blocked =
portraitObstacles.some((o) => rectsOverlap(rect, o)) ||
placedLabelRects.some((o) => rectsOverlap(rect, o)) ||
(above && rectsOverlap(rect, streamRect));
if (!blocked) {
chosen = {
layout,
leftPct,
topPct: labelAnchorTopPct(streamY, above, streamStrokePx, layoutHeight),
above,
};
placedLabelRects.push(rect);
break;
}
}
if (chosen) break;
}
if (!chosen) {
const stackIndex = placed.filter((p) => p.layout.depth === layout.depth).length;
const streamY = layout.y;
const liftPx = LABEL_GAP_PX + stackIndex * (heightPx + 6);
const leftPct = clampLabelLeftPct(layout.xStart + span * 0.08, widthPx, canvasWidth);
chosen = {
for (const rawX of orderedX) {
const leftPct = clampLabelLeftPct(rawX, widthPx, canvasWidth);
const streamY = yOnStream(layout, leftPct);
const rect = labelOnBandRect(
leftPct,
streamY,
widthPx,
heightPx,
layoutHeight,
canvasWidth,
canvasHeight
);
const placement: LabelPlacement = {
layout,
leftPct,
topPct: ((streamY - liftPx) / layoutHeight) * 100,
above: true,
topPct: (streamY / layoutHeight) * 100,
};
placedLabelRects.push(
labelObstacleRect(
leftPct,
streamY,
true,
widthPx,
heightPx,
streamStrokePx,
layoutHeight,
canvasWidth,
canvasHeight
)
);
if (isClear(rect)) {
chosen = placement;
chosenRect = rect;
break;
}
const score = scoreRect(rect) + Math.abs(leftPct - preferredX) * 0.5;
if (!bestFallback || score < bestFallback.score) {
bestFallback = { placement, rect, score };
}
}
placed.push(chosen);
if (!chosen && bestFallback) {
chosen = bestFallback.placement;
chosenRect = bestFallback.rect;
}
if (chosen && chosenRect) {
placed.push(chosen);
placedLabelRects.push(chosenRect);
}
}
return placed;
@@ -793,6 +1085,33 @@ export default function MovementBands({
const [canvasWidth, setCanvasWidth] = useState(800);
const [hoveredArtistKey, setHoveredArtistKey] = useState<string | null>(null);
const [hoveredMovementId, setHoveredMovementId] = useState<number | null>(null);
const movementHoverClearTimer = useRef<number | null>(null);
const clearMovementHoverSoon = useCallback(() => {
if (movementHoverClearTimer.current != null) {
window.clearTimeout(movementHoverClearTimer.current);
}
movementHoverClearTimer.current = window.setTimeout(() => {
movementHoverClearTimer.current = null;
setHoveredMovementId(null);
}, 60);
}, []);
const setMovementHover = useCallback((movementId: number) => {
if (movementHoverClearTimer.current != null) {
window.clearTimeout(movementHoverClearTimer.current);
movementHoverClearTimer.current = null;
}
setHoveredMovementId(movementId);
}, []);
useEffect(() => {
return () => {
if (movementHoverClearTimer.current != null) {
window.clearTimeout(movementHoverClearTimer.current);
}
};
}, []);
const panStart = useRef({ x: 0, viewStart: 0, viewEnd: 0 });
const interactionTimer = useRef<number | null>(null);
const viewRef = useRef({ viewStart, viewEnd });
@@ -964,8 +1283,18 @@ export default function MovementBands({
const usableHeight = layoutHeight - CANVAS_TOP_PAD - CANVAS_BOTTOM_PAD;
// Pack all visible movements into shared horizontal lanes whenever their
// clipped time spans do not overlap (lineage depth is kept for branch curves only).
// clipped time spans do not overlap, then clear unrelated streams out of
// lineage branch corridors (parent/child adjacency preferred).
const laneIndex = assignTemporalLanes(visibleMovements, viewStart, viewEnd);
refineLanesForLineageCorridors(
laneIndex,
visibleMovements,
lineageParents,
viewStart,
viewEnd
);
compactLanes(laneIndex);
let maxLanes = 0;
const laneOccupancy = new Map<number, number>();
for (const movement of visibleMovements) {
@@ -975,14 +1304,20 @@ export default function MovementBands({
}
maxLanes = Math.max(1, maxLanes);
const minLaneStep = MIN_STREAM_STROKE_PX + LANE_GAP_PX;
const laneStep = Math.max(minLaneStep, usableHeight / maxLanes);
let streamStrokePx = Math.min(MAX_STREAM_STROKE_PX, laneStep - LANE_GAP_PX);
streamStrokePx = Math.max(MIN_STREAM_STROKE_PX, streamStrokePx);
const portraitSizePx = Math.max(28, Math.min(52, streamStrokePx * 0.96));
// Always fit every lane inside the canvas — never floor laneStep to min stroke
// (that pushed lower streams/branches past the bottom edge).
const laneStep = usableHeight / maxLanes;
let streamStrokePx = Math.min(MAX_STREAM_STROKE_PX, laneStep - Math.min(LANE_GAP_PX, laneStep * 0.25));
streamStrokePx = Math.max(ABS_MIN_STREAM_STROKE_PX, streamStrokePx);
if (laneStep >= MIN_STREAM_STROKE_PX + LANE_GAP_PX) {
streamStrokePx = Math.max(MIN_STREAM_STROKE_PX, streamStrokePx);
}
const portraitSizePx = Math.max(14, Math.min(26, streamStrokePx * 0.48));
const streamCurveOffset = Math.min(12, Math.max(2, (laneStep - streamStrokePx) * 0.35));
const yMax = layoutHeight - CANVAS_BOTTOM_PAD - streamStrokePx / 2;
const yMin = CANVAS_TOP_PAD + streamStrokePx / 2;
const layoutById = new Map<number, MovementLayout>();
const branchList: BranchSegment[] = [];
const childIdsByParent = new Map<number, number[]>();
@@ -994,9 +1329,11 @@ export default function MovementBands({
const depth = depths.get(movement.id) ?? 0;
const lane = laneIndex.get(movement.id) ?? 0;
const y = CANVAS_TOP_PAD + lane * laneStep + laneStep / 2;
const yRaw = CANVAS_TOP_PAD + lane * laneStep + laneStep / 2;
const y = Math.min(yMax, Math.max(yMin, yRaw));
const allowDrift = (laneOccupancy.get(lane) ?? 0) === 1 && laneStep >= 80;
const yEnd = y + (allowDrift ? organicDrift(movement.id + 1000) * 0.22 : 0);
const drift = allowDrift ? organicDrift(movement.id + 1000) * 0.22 : 0;
const yEnd = Math.min(yMax, Math.max(yMin, y + drift));
const parentIds = lineageParents.get(movement.id) || [];
layoutById.set(movement.id, {
@@ -1087,7 +1424,8 @@ export default function MovementBands({
canvasWidth,
canvasHeight,
streamStrokePx,
portraitSizePx
portraitSizePx,
branches
),
[
layouts,
@@ -1097,6 +1435,7 @@ export default function MovementBands({
canvasHeight,
streamStrokePx,
portraitSizePx,
branches,
]
);
@@ -1119,6 +1458,7 @@ export default function MovementBands({
const layoutById = new Map(layouts.map((l) => [l.movement.id, l]));
const fastGraphics = interacting || panning;
const showPortraits = hoveredMovementId != null || hoveredArtistKey != null;
return (
<div className="movements-flow">
@@ -1128,13 +1468,22 @@ export default function MovementBands({
<div
ref={canvasRef}
className={`movements-flow-canvas${panning ? ' movements-flow-panning' : ''}${fastGraphics ? ' movements-flow-interacting' : ''}${hoveredMovementId != null ? ' movements-flow-movement-hover' : ''}`}
className={`movements-flow-canvas${panning ? ' movements-flow-panning' : ''}${fastGraphics ? ' movements-flow-interacting' : ''}${hoveredMovementId != null ? ' movements-flow-movement-hover' : ''}${showPortraits ? ' movements-flow-band-hover' : ''}`}
style={{
['--stream-stroke' as string]: `${streamStrokePx}px`,
['--portrait-size' as string]: `${portraitSizePx}px`,
}}
data-movement-hover={hoveredMovementId ?? undefined}
onMouseDown={handlePanStart}
onMouseLeave={() => {
if (movementHoverClearTimer.current != null) {
window.clearTimeout(movementHoverClearTimer.current);
movementHoverClearTimer.current = null;
}
setHoveredMovementId(null);
setHoveredArtistKey(null);
onArtistHover?.(null);
}}
>
<svg
className="movements-flow-svg"
@@ -1314,20 +1663,36 @@ export default function MovementBands({
{!fastGraphics && (
<>
<div className="movements-flow-hits" aria-hidden>
{layouts.map((layout) => (
<div
key={`hit-${layout.movement.id}`}
className="movement-stream-hit"
style={{
left: `${layout.xStart}%`,
width: `${Math.max(layout.xEnd - layout.xStart, 0.5)}%`,
top: `${(((layout.y + layout.yEnd) / 2) / layoutHeight) * 100}%`,
}}
onMouseEnter={() => setMovementHover(layout.movement.id)}
onMouseLeave={clearMovementHoverSoon}
/>
))}
</div>
<div className="movements-flow-labels">
{labelPlacements.map(({ layout, leftPct, topPct, above }) => (
{labelPlacements.map(({ layout, leftPct, topPct }) => (
<button
key={`label-${layout.movement.id}`}
type="button"
className={`movement-flow-label movement-flow-label-btn${above ? ' movement-flow-label-above' : ' movement-flow-label-below'}`}
className="movement-flow-label movement-flow-label-btn"
style={{
left: `${leftPct}%`,
top: `${topPct}%`,
}}
title={`Open ${layout.movement.name} gallery hall`}
onClick={() => onMovementClick?.(layout.movement.id)}
onMouseEnter={() => setHoveredMovementId(layout.movement.id)}
onMouseLeave={() => setHoveredMovementId(null)}
onMouseEnter={() => setMovementHover(layout.movement.id)}
onMouseLeave={clearMovementHoverSoon}
onMouseDown={(e) => e.stopPropagation()}
>
<span className="movement-name">{layout.movement.name}</span>