diff --git a/Documentation/basics.md b/Documentation/basics.md index 0f91ec2..cafb454 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -246,8 +246,8 @@ Each visible movement is drawn as a **portrait-width curved stream** (~54 px str | Feature | Implementation | |---------|----------------| | Lineage layout | `client/src/data/movement-lineage.ts` — curated predecessor→successor pairs (Met / ArtStory / museum essays); multiple parents allowed | -| Vertical lanes | Movements whose time spans do **not** overlap (in the current zoom) share a horizontal lane; only concurrent spans stack into extra rows (`assignTemporalLanes` in `MovementBands.tsx`). Lineage depth still drives branch curves, not exclusive vertical bands | -| Branch connectors | Smooth curves from the **centre** of a parent stream to the **centre** of each child stream (siblings fan out along the parent’s length) | +| Vertical lanes | Movements whose time spans do **not** overlap (in the current zoom) share a horizontal lane; only concurrent spans stack into extra rows (`assignTemporalLanes`). A follow-up pass (`refineLanesForLineageCorridors`) pulls linked parent/child movements onto nearby or shared lanes when years allow, then displaces unrelated streams out of thick lineage branch corridors | +| Branch connectors | Smooth curves from fan-out points along a parent stream to the centre of each child stream; lanes are packed so those transitions do not cross through unconnected movements | | Visual blending | Path-aligned SVG gradients with transparent fades at stream ends and branch junctions; streams draw on top of branches so overlap brightness stays uniform | | Filtering | A movement is drawn when its **span overlaps** the visible year range **and** it has at least one catalogued artist — artists whose lifespan falls outside the window still keep their movement visible (their portraits simply do not render). Filtered client-side after initial load | | Viewport layout | Row height and stream width scale from measured canvas size so every visible movement row fits in the remaining screen space | diff --git a/client/src/components/MovementBands.css b/client/src/components/MovementBands.css index d19878b..ec4e9d5 100644 --- a/client/src/components/MovementBands.css +++ b/client/src/components/MovementBands.css @@ -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 { diff --git a/client/src/components/MovementBands.tsx b/client/src/components/MovementBands.tsx index 4f1a566..dda9807 100644 --- a/client/src/components/MovementBands.tsx +++ b/client/src/components/MovementBands.tsx @@ -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, + movementsById: Map, + 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 { + let max = -1; + for (const lane of laneById.values()) max = Math.max(max, lane); + return max; +} + +function findFreeLaneForMovement( + laneById: Map, + movementsById: Map, + 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, + movements: ArtMovement[], + lineageParents: Map, + 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(); + 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): 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(); - 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(null); const [hoveredMovementId, setHoveredMovementId] = useState(null); + const movementHoverClearTimer = useRef(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(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(); 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(); const branchList: BranchSegment[] = []; const childIdsByParent = new Map(); @@ -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 (
@@ -1128,13 +1468,22 @@ export default function MovementBands({
{ + if (movementHoverClearTimer.current != null) { + window.clearTimeout(movementHoverClearTimer.current); + movementHoverClearTimer.current = null; + } + setHoveredMovementId(null); + setHoveredArtistKey(null); + onArtistHover?.(null); + }} > +
+ {layouts.map((layout) => ( +
setMovementHover(layout.movement.id)} + onMouseLeave={clearMovementHoverSoon} + /> + ))} +
+
- {labelPlacements.map(({ layout, leftPct, topPct, above }) => ( + {labelPlacements.map(({ layout, leftPct, topPct }) => (