From ad1572b3aaa92e04fbb85c300f164fdafd75b512 Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Wed, 29 Jul 2026 12:13:55 +0300 Subject: [PATCH] Improve movement label placement and lane corridor clearing Label priority: center, right-end inside, overflow right/left (prefer side with fewer transition-line overlaps), adjacent gap, hover-only. Exclude own portraits from collision. Min stream height 25px. Corridor clearing: 20 passes, prefer above, re-attract after move. Co-authored-by: Cursor --- Documentation/basics.md | 7 +- client/src/components/MovementBands.css | 5 + client/src/components/MovementBands.tsx | 715 ++++++++++++++++++++---- 3 files changed, 602 insertions(+), 125 deletions(-) diff --git a/Documentation/basics.md b/Documentation/basics.md index ee05a44..82e8d25 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -241,16 +241,17 @@ Major world events appear on the era bar as pin markers (single years) or shaded ### Movement flow -Each visible movement is drawn as a **portrait-width curved stream** (~54 px stroke, compressed when many rows are visible) from its start year to its end year. +Each visible movement is drawn as a **portrait-width curved stream** (~54 px stroke, compressed when many rows are visible) in a **solid vivid colour** from its start year to its end year. | Feature | Implementation | |---------|----------------| | Lineage layout | `client/src/data/movement-lineage.ts` — curated predecessor→successor pairs (Met / ArtStory / museum essays); multiple parents allowed | -| Vertical lanes | Movements share a horizontal lane only when one ends at least **10 years** before the next starts (in the current zoom); closer or overlapping 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 | +| Vertical lanes | Movements share a horizontal lane only when one ends at least **10 years** before the next starts (in the current zoom); closer or overlapping spans stack into extra rows (`assignTemporalLanes`). Lane choice **minimizes vertical branch length**: children prefer parent lanes (same row when years allow), corridor clearing (up to 20 passes) evicts unrelated streams preferring **rows above** the parent/child strip, then re-attracts the child toward the parent | | Branch connectors | Smooth curves from fan-out points along a parent stream to the **left edge (start)** of each child stream; origin X is always strictly left of the target (time-forward only — never right→left); stroke thickness matches the **target** (child) band height; color gradients from parent → child at constant opacity; a stream-shaped mask hides branch ink under movements so translucent overlaps do not brighten the bands; pan/zoom layout changes **animate** (streams + branches chase new geometry) so shifts stay trackable | | Band thickness | Stream height is **proportional to `influence_link_count`** (edges on paintings by artists in that movement): thicker bands for denser influence graphs; lane rows share vertical space weighted by the thickest band in each lane | | 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 | +| Name labels | On-band **movement name** only. Label placement priority: **(1)** center of movement if label fits and no portrait collision; **(2)** right end inside band borders; **(3)** overflow right or left (up to 100% outside the band border), choosing the side with less transition-line overlap; **(4)** adjacent free gap before/after the band; **(5)** hidden until hover. Collision checks exclude the movement's own portraits (labels may overlap their own band's artists). Movements with overflow labels are packed onto **exclusive horizontal lanes** before vertical stacking so they never share a row with other movements. Minimum movement height = 125% of label height | ### Artists on movement streams @@ -273,7 +274,7 @@ Each artist appears as a **portrait circle** on their movement’s stream row: | Click portrait | Open artist biography | | Click **movement name** (label on stream) | Open **movement gallery** for that movement | -**Zoom/pan performance:** The movement canvas uses a **capture-phase** wheel listener so scroll zoom works even when the cursor is over a portrait. While scrolling or dragging, a short **interaction mode** (~120 ms after the last input) draws simplified solid SVG strokes and temporarily hides portrait/label DOM so zoom stays responsive; full gradients and portraits return when you stop. +**Zoom/pan performance:** The movement canvas uses a **capture-phase** wheel listener so scroll zoom works even when the cursor is over a portrait. While scrolling or dragging, a short **interaction mode** (~120 ms after the last input) draws simplified solid SVG strokes and temporarily hides portrait/label DOM so zoom stays responsive; full stream styling and portraits return when you stop. Only **mousedown** on portraits and movement labels stops propagation (so drag-to-pan does not start when clicking them). Hovering a portrait highlights the artist’s lifespan on the era bar and brightens their segment on the movement stream. diff --git a/client/src/components/MovementBands.css b/client/src/components/MovementBands.css index 56b9b0e..0db3948 100644 --- a/client/src/components/MovementBands.css +++ b/client/src/components/MovementBands.css @@ -287,6 +287,11 @@ outline-offset: 2px; } +.movement-flow-label-hover-reveal { + z-index: 8; + box-shadow: 0 0 0 1px rgba(255, 220, 160, 0.4), 0 4px 18px rgba(0, 0, 0, 0.45); +} + .movement-name { display: block; font-family: 'Georgia', serif; diff --git a/client/src/components/MovementBands.tsx b/client/src/components/MovementBands.tsx index a8178f5..4e009dd 100644 --- a/client/src/components/MovementBands.tsx +++ b/client/src/components/MovementBands.tsx @@ -32,8 +32,8 @@ interface MovementLayout { /** Band thickness in CSS pixels (proportional to influence_link_count). */ strokePx: number; portraitSizePx: number; - /** Saturated display color for the dark flow canvas. */ - displayColor: string; + /** True when the on-band name label is wider than the visible movement span. */ + labelFitsInBand: boolean; } interface BranchSegment { @@ -51,7 +51,7 @@ 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 ABS_MIN_STREAM_STROKE_PX = 25; const LANE_GAP_PX = 10; const CANVAS_TOP_PAD = 38; const CANVAS_BOTTOM_PAD = 24; @@ -374,38 +374,167 @@ function assignDepths(movements: ArtMovement[], lineageParents: Map, + lineageParents: Map ): Map { + const depths = assignDepths(group, lineageParents); const spans = group .map((m) => ({ id: m.id, start: Math.max(m.start_year, viewStart), end: Math.min(m.end_year, viewEnd), + depth: depths.get(m.id) ?? 0, })) .filter((s) => s.end > s.start) - .sort((a, b) => a.start - b.start || a.end - b.end); + .sort((a, b) => a.depth - b.depth || a.start - b.start || a.end - b.end); const laneEnds: number[] = []; + /** Lanes that hold (or held) a movement whose label overflows its band — no sharing. */ + const laneLabelExclusive: boolean[] = []; const laneById = new Map(); for (const span of spans) { - let lane = laneEnds.findIndex((endYear) => endYear + LANE_MIN_GAP_YEARS <= span.start); + const labelFits = labelFitsById.get(span.id) ?? true; + const parentLanes = (lineageParents.get(span.id) || []) + .map((pid) => laneById.get(pid)) + .filter((lane): lane is number => lane != null); + const partnerDistance = (lane: number) => + parentLanes.length === 0 + ? 0 + : Math.min(...parentLanes.map((pl) => Math.abs(lane - pl))); + + let lane = -1; + if (labelFits) { + const candidates: number[] = []; + for (let i = 0; i < laneEnds.length; i++) { + if (laneEnds[i] + LANE_MIN_GAP_YEARS <= span.start && !laneLabelExclusive[i]) { + candidates.push(i); + } + } + if (candidates.length > 0) { + candidates.sort((a, b) => partnerDistance(a) - partnerDistance(b) || a - b); + lane = candidates[0]; + } + } if (lane === -1) { lane = laneEnds.length; laneEnds.push(span.end); + laneLabelExclusive.push(!labelFits); } else { laneEnds[lane] = span.end; } + if (!labelFits) laneLabelExclusive[lane] = true; laneById.set(span.id, lane); } return laneById; } +/** Lanes occupied by lineage partners (parents and children) of a movement. */ +function lineagePartnerLanes( + movementId: number, + laneById: Map, + lineageParents: Map +): number[] { + const lanes: number[] = []; + for (const parentId of lineageParents.get(movementId) || []) { + const lane = laneById.get(parentId); + if (lane != null) lanes.push(lane); + } + for (const [childId, parentIds] of lineageParents) { + if (!parentIds.includes(movementId)) continue; + const lane = laneById.get(childId); + if (lane != null) lanes.push(lane); + } + return lanes; +} + +/** Max vertical lane gap between a movement and any lineage partner. */ +function maxLineageLaneGap( + movementId: number, + lane: number, + laneById: Map, + lineageParents: Map +): number { + const partners = lineagePartnerLanes(movementId, laneById, lineageParents); + if (partners.length === 0) return 0; + return Math.max(...partners.map((pl) => Math.abs(lane - pl))); +} + +/** Nudge movements onto lanes that minimize branch vertical span. */ +function minimizeLineageVerticalSpan( + laneById: Map, + movements: ArtMovement[], + lineageParents: Map, + viewStart: number, + viewEnd: number, + labelFitsById: Map +): void { + if (lineageParents.size === 0) return; + + const movementsById = new Map(movements.map((m) => [m.id, m])); + const maxLane = () => maxLaneIndex(laneById); + + for (let iter = 0; iter < 16; iter++) { + let changed = false; + const order = [...movements].sort( + (a, b) => + (lineagePartnerLanes(b.id, laneById, lineageParents).length > 0 ? 1 : 0) - + (lineagePartnerLanes(a.id, laneById, lineageParents).length > 0 ? 1 : 0) || + a.start_year - b.start_year + ); + + for (const movement of order) { + const current = laneById.get(movement.id); + if (current == null) continue; + const partners = lineagePartnerLanes(movement.id, laneById, lineageParents); + if (partners.length === 0) continue; + + const currentGap = maxLineageLaneGap(movement.id, current, laneById, lineageParents); + if (currentGap === 0) continue; + + const candidates: number[] = []; + for (let lane = 0; lane <= maxLane() + 1; lane++) { + if ( + !laneHasYearConflict( + laneById, + movementsById, + movement.id, + lane, + viewStart, + viewEnd, + labelFitsById + ) + ) { + candidates.push(lane); + } + } + + candidates.sort((a, b) => { + const ga = maxLineageLaneGap(movement.id, a, laneById, lineageParents); + const gb = maxLineageLaneGap(movement.id, b, laneById, lineageParents); + if (ga !== gb) return ga - gb; + return Math.abs(a - current) - Math.abs(b - current); + }); + + const best = candidates[0]; + if (best != null && best !== current && maxLineageLaneGap(movement.id, best, laneById, lineageParents) < currentGap) { + laneById.set(movement.id, best); + changed = true; + } + } + + if (!changed) break; + } +} + function spansOverlap(a0: number, a1: number, b0: number, b1: number, tol = 0.15): boolean { return a0 < b1 - tol && b0 < a1 - tol; } @@ -474,19 +603,39 @@ function clippedMovementSpan( }; } +function laneHasLabelShareConflict( + laneById: Map, + movementId: number, + targetLane: number, + labelFitsById: Map +): boolean { + const meFits = labelFitsById.get(movementId) ?? true; + for (const [otherId, lane] of laneById) { + if (otherId === movementId || lane !== targetLane) continue; + const otherFits = labelFitsById.get(otherId) ?? true; + if (!meFits || !otherFits) return true; + } + return false; +} + function laneHasYearConflict( laneById: Map, movementsById: Map, movementId: number, targetLane: number, viewStart: number, - viewEnd: number + viewEnd: number, + labelFitsById?: Map ): boolean { const me = movementsById.get(movementId); if (!me) return true; const mySpan = clippedMovementSpan(me, viewStart, viewEnd); if (!mySpan) return true; + if (labelFitsById && laneHasLabelShareConflict(laneById, movementId, targetLane, labelFitsById)) { + return true; + } + for (const [otherId, lane] of laneById) { if (otherId === movementId || lane !== targetLane) continue; const other = movementsById.get(otherId); @@ -511,7 +660,8 @@ function findFreeLaneForMovement( viewStart: number, viewEnd: number, preferOutside?: { lo: number; hi: number }, - excludeLane?: number + excludeLane?: number, + labelFitsById?: Map ): number { const maxLane = maxLaneIndex(laneById); const candidates: number[] = []; @@ -519,17 +669,34 @@ function findFreeLaneForMovement( 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; + if (preferOutside != null) { + const aInside = a > preferOutside.lo && a < preferOutside.hi; + const bInside = b > preferOutside.lo && b < preferOutside.hi; + if (aInside !== bInside) return aInside ? 1 : -1; + + const aAbove = a < preferOutside.lo; + const bAbove = b < preferOutside.lo; + if (aAbove !== bAbove) return aAbove ? -1 : 1; + + if (aAbove && bAbove) return b - a; + if (a > preferOutside.hi && b > preferOutside.hi) return a - b; + } return a - b; }); for (const lane of ranked) { if (excludeLane != null && lane === excludeLane) continue; - if (!laneHasYearConflict(laneById, movementsById, movementId, lane, viewStart, viewEnd)) { + if ( + !laneHasYearConflict( + laneById, + movementsById, + movementId, + lane, + viewStart, + viewEnd, + labelFitsById + ) + ) { return lane; } } @@ -539,57 +706,77 @@ function findFreeLaneForMovement( /** * 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). + * Phase A attracts pairs; phase B clears foreigners (preferring rows above the + * corridor) and re-attracts child toward parent after each displacement. */ function refineLanesForLineageCorridors( laneById: Map, movements: ArtMovement[], lineageParents: Map, viewStart: number, - viewEnd: number + viewEnd: number, + labelFitsById: Map ): 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 MAX_CLEAR_ITERS = 20; 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)) { + if ( + laneHasYearConflict( + laneById, + movementsById, + movementId, + desired, + viewStart, + viewEnd, + labelFitsById + ) + ) { return false; } laneById.set(movementId, desired); return true; }; - /** Pull endpoints within one lane (including sharing a lane when years allow). */ + /** Pull endpoints as close as possible (same 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; + if (parentLane === childLane) return false; - // Prefer landing on the partner's lane first (covers High→Early same-lane case). - const childTargets = [parentLane, parentLane + 1, parentLane - 1]; + const dist = Math.abs(parentLane - childLane); + const towardParent = childLane > parentLane ? 1 : -1; + + const childTargets = + dist > 1 + ? [parentLane, parentLane + towardParent, parentLane - towardParent] + : [parentLane]; for (const desired of childTargets) { if (tryMoveToLane(childId, desired)) return true; } - const parentTargets = [childLane, childLane + 1, childLane - 1]; + const towardChild = parentLane > childLane ? 1 : -1; + const parentTargets = + dist > 1 + ? [childLane, childLane - towardChild, childLane + towardChild] + : [childLane]; for (const desired of parentTargets) { if (tryMoveToLane(parentId, desired)) return true; } + if (dist > 1 && tryMoveToLane(childId, childLane - towardParent)) return true; + return false; }; - // Phase A: attract linked pairs only. - for (let iter = 0; iter < MAX_ATTRACT_ITERS; iter++) { + // Phase A: attract linked pairs — minimize vertical branch length. + for (let iter = 0; iter < 16; iter++) { let changed = false; for (const [childId, parentIds] of lineageParents) { if (!laneById.has(childId)) continue; @@ -666,11 +853,13 @@ function refineLanesForLineageCorridors( viewStart, viewEnd, { lo, hi }, - lane + lane, + labelFitsById ); if (nextLane !== lane) { laneById.set(movement.id, nextLane); changed = true; + if (tryAttractPair(parentId, childId)) changed = true; } } } @@ -678,6 +867,15 @@ function refineLanesForLineageCorridors( if (!changed) break; } + + minimizeLineageVerticalSpan( + laneById, + movements, + lineageParents, + viewStart, + viewEnd, + labelFitsById + ); } /** Renumber lanes to dense 0..n-1 so empty gaps do not waste vertical space. */ @@ -839,6 +1037,8 @@ interface LabelPlacement { layout: MovementLayout; leftPct: number; topPct: number; + /** False when the label is wider than the visible movement band — hide until hover or adjacent gap. */ + fitsInBand: boolean; } interface PixelRect { @@ -858,8 +1058,39 @@ function estimateLabelWidthPx(name: string): number { return Math.min(LABEL_MAX_WIDTH_PX, Math.max(52, textWidth)); } -function labelHeightPx(hasEra: boolean): number { - return hasEra ? 34 : 20; +/** Whether the movement name label fits inside its clipped horizontal band. */ +function movementLabelFitsInBand( + movement: ArtMovement, + viewStart: number, + viewEnd: number, + canvasWidth: number +): boolean { + if (canvasWidth <= 0) return true; + const clip = clippedMovementSpan(movement, viewStart, viewEnd); + if (!clip) return true; + const bandWidthPct = Math.max(clip.xEnd - clip.xStart, 0.5); + const bandWidthPx = (bandWidthPct / 100) * canvasWidth; + return estimateLabelWidthPx(movement.name) <= bandWidthPx; +} + +function buildLabelFitsById( + group: ArtMovement[], + viewStart: number, + viewEnd: number, + canvasWidth: number +): Map { + const map = new Map(); + for (const movement of group) { + map.set( + movement.id, + movementLabelFitsInBand(movement, viewStart, viewEnd, canvasWidth) + ); + } + return map; +} + +function labelHeightPx(): number { + return 20; } function rectsOverlap(a: PixelRect, b: PixelRect, pad = LABEL_MIN_SEPARATION_PX): boolean { @@ -961,6 +1192,68 @@ function clampLabelLeftPct(leftPct: number, widthPx: number, canvasWidth: number return Math.min(Math.max(leftPct, halfWidthPct + 0.5), 100 - halfWidthPct - 0.5); } +function streamsOverlapVertically(a: MovementLayout, b: MovementLayout): boolean { + return Math.min(a.yEnd, b.yEnd) > Math.max(a.y, b.y); +} + +/** Horizontal gap (in % of canvas width) before/after a movement, ignoring non-overlapping rows. */ +function horizontalGapsBesideMovement( + layout: MovementLayout, + layouts: MovementLayout[] +): { beforePct: number; afterPct: number } { + let leftBound = 0; + let rightBound = 100; + for (const other of layouts) { + if (other.movement.id === layout.movement.id) continue; + if (!streamsOverlapVertically(layout, other)) continue; + if (other.xEnd <= layout.xStart) leftBound = Math.max(leftBound, other.xEnd); + if (other.xStart >= layout.xEnd) rightBound = Math.min(rightBound, other.xStart); + } + return { beforePct: layout.xStart - leftBound, afterPct: rightBound - layout.xEnd }; +} + +function tryPlaceAdjacentLabel( + layout: MovementLayout, + widthPx: number, + heightPx: number, + side: 'before' | 'after', + layoutHeight: number, + canvasWidth: number, + canvasHeight: number, + isClear: (rect: PixelRect) => boolean +): { placement: LabelPlacement; rect: PixelRect } | null { + const padPct = (LABEL_BAND_PAD_PX / canvasWidth) * 100; + const halfWidthPct = ((widthPx / canvasWidth) * 100) / 2; + const leftPct = + side === 'before' + ? layout.xStart - padPct - halfWidthPct + : layout.xEnd + padPct + halfWidthPct; + + if (leftPct - halfWidthPct < 0.5 || leftPct + halfWidthPct > 99.5) return null; + + const streamY = yOnStream(layout, leftPct); + const rect = labelOnBandRect( + leftPct, + streamY, + widthPx, + heightPx, + layoutHeight, + canvasWidth, + canvasHeight + ); + if (!isClear(rect)) return null; + + return { + placement: { + layout, + leftPct, + topPct: (streamY / layoutHeight) * 100, + fitsInBand: true, + }, + rect, + }; +} + function buildLabelPlacements( layouts: MovementLayout[], artistPlacements: ArtistPlacement[], @@ -971,16 +1264,19 @@ function buildLabelPlacements( ): LabelPlacement[] { if (layouts.length === 0 || canvasWidth <= 0 || canvasHeight <= 0) return []; - const portraitObstacles = artistPlacements.map((p) => - portraitObstacleRect( + const portraitObstacles = artistPlacements.map((p) => ({ + movementId: p.layout.movement.id, + rect: portraitObstacleRect( p.portraitX, p.y, p.layout.portraitSizePx, layoutHeight, canvasWidth, canvasHeight - ) - ); + ), + })); + + const allPortraitRects = portraitObstacles.map((p) => p.rect); const bandObstacles = layouts.map((layout) => ({ id: layout.movement.id, @@ -1005,15 +1301,225 @@ function buildLabelPlacements( const placed: LabelPlacement[] = []; const placedLabelRects: PixelRect[] = []; + const makeIsClear = + (movementId: number) => + (rect: PixelRect): boolean => { + const otherPortraits = portraitObstacles.filter((p) => p.movementId !== movementId).map((p) => p.rect); + const otherBands = bandObstacles.filter((b) => b.id !== movementId).map((b) => b.rect); + const otherBranches = branchObstacles + .filter((b) => b.fromId !== movementId && b.toId !== movementId) + .map((b) => b.rect); + return ( + !otherPortraits.some((o) => rectsOverlap(rect, o)) && + !placedLabelRects.some((o) => rectsOverlap(rect, o)) && + !otherBands.some((o) => rectsOverlap(rect, o)) && + !otherBranches.some((o) => rectsOverlap(rect, o)) + ); + }; + + const makeIsClearRelaxed = + (movementId: number) => + (rect: PixelRect): boolean => { + const otherPortraits = portraitObstacles.filter((p) => p.movementId !== movementId).map((p) => p.rect); + const otherBranches = branchObstacles + .filter((b) => b.fromId !== movementId && b.toId !== movementId) + .map((b) => b.rect); + return ( + !otherPortraits.some((o) => rectsOverlap(rect, o)) && + !placedLabelRects.some((o) => rectsOverlap(rect, o, 0)) && + !otherBranches.some((o) => rectsOverlap(rect, o)) + ); + }; + const sortedLayouts = [...layouts].sort((a, b) => a.y - b.y || a.xStart - b.xStart); for (const layout of sortedLayouts) { - const hasEra = !!layout.movement.era_name; const widthPx = estimateLabelWidthPx(layout.movement.name); - const heightPx = labelHeightPx(hasEra); + const heightPx = labelHeightPx(); const span = Math.max(layout.xEnd - layout.xStart, 0.5); + const fitsInBand = layout.labelFitsInBand; const preferredX = layout.xStart + span * 0.5; + const isClear = makeIsClear(layout.movement.id); + const isClearRelaxed = makeIsClearRelaxed(layout.movement.id); + if (!fitsInBand) { + const widthPct = (widthPx / canvasWidth) * 100; + const padPct = (LABEL_BAND_PAD_PX / canvasWidth) * 100; + const halfWidthPct = widthPct / 2; + + // Priority 1: center of movement (label fits and no portrait overlap) + const centerX = layout.xStart + span * 0.5; + const centerStreamY = yOnStream(layout, centerX); + const centerRect = labelOnBandRect(centerX, centerStreamY, widthPx, heightPx, layoutHeight, canvasWidth, canvasHeight); + if (widthPx <= (span / 100) * canvasWidth && isClear(centerRect)) { + placed.push({ layout, leftPct: centerX, topPct: (centerStreamY / layoutHeight) * 100, fitsInBand: true }); + placedLabelRects.push(centerRect); + continue; + } + + // Priority 2: right end of movement, fully inside band borders + const endInsideX = layout.xEnd - halfWidthPct - padPct; + if (endInsideX - halfWidthPct >= layout.xStart) { + const clampedEnd = clampLabelLeftPct(endInsideX, widthPx, canvasWidth); + const endStreamY = yOnStream(layout, clampedEnd); + const endRect = labelOnBandRect(clampedEnd, endStreamY, widthPx, heightPx, layoutHeight, canvasWidth, canvasHeight); + if (isClear(endRect)) { + placed.push({ layout, leftPct: clampedEnd, topPct: (endStreamY / layoutHeight) * 100, fitsInBand: true }); + placedLabelRects.push(endRect); + continue; + } + } + + // Priority 3: overflow right or left (up to 100%), prefer side with fewer transition line overlaps + const maxOverflowPct = widthPct; + const overflowRightX = layout.xEnd + maxOverflowPct - halfWidthPct; + const clampedOverflowR = clampLabelLeftPct(overflowRightX, widthPx, canvasWidth); + const overflowRStreamY = yOnStream(layout, Math.min(clampedOverflowR, layout.xEnd)); + const overflowRRect = labelOnBandRect(clampedOverflowR, overflowRStreamY, widthPx, heightPx, layoutHeight, canvasWidth, canvasHeight); + const rightClear = isClearRelaxed(overflowRRect); + + const overflowLeftX = layout.xStart - maxOverflowPct + halfWidthPct; + const clampedOverflowL = clampLabelLeftPct(overflowLeftX, widthPx, canvasWidth); + const overflowLStreamY = yOnStream(layout, Math.max(clampedOverflowL, layout.xStart)); + const overflowLRect = labelOnBandRect(clampedOverflowL, overflowLStreamY, widthPx, heightPx, layoutHeight, canvasWidth, canvasHeight); + const leftClear = isClearRelaxed(overflowLRect); + + if (rightClear && leftClear) { + const allBranchRects = branchObstacles.map((b) => b.rect); + const rightBranchOverlap = allBranchRects.reduce((sum, o) => sum + overlapArea(overflowRRect, o, 0), 0); + const leftBranchOverlap = allBranchRects.reduce((sum, o) => sum + overlapArea(overflowLRect, o, 0), 0); + if (leftBranchOverlap <= rightBranchOverlap) { + placed.push({ layout, leftPct: clampedOverflowL, topPct: (overflowLStreamY / layoutHeight) * 100, fitsInBand: true }); + placedLabelRects.push(overflowLRect); + } else { + placed.push({ layout, leftPct: clampedOverflowR, topPct: (overflowRStreamY / layoutHeight) * 100, fitsInBand: true }); + placedLabelRects.push(overflowRRect); + } + continue; + } + if (rightClear) { + placed.push({ layout, leftPct: clampedOverflowR, topPct: (overflowRStreamY / layoutHeight) * 100, fitsInBand: true }); + placedLabelRects.push(overflowRRect); + continue; + } + if (leftClear) { + placed.push({ layout, leftPct: clampedOverflowL, topPct: (overflowLStreamY / layoutHeight) * 100, fitsInBand: true }); + placedLabelRects.push(overflowLRect); + continue; + } + + // Priority 4: adjacent free space before/after + const minGapPct = widthPct + padPct * 2; + const { beforePct, afterPct } = horizontalGapsBesideMovement(layout, layouts); + + const sides: ('before' | 'after')[] = []; + if (beforePct >= minGapPct) sides.push('before'); + if (afterPct >= minGapPct) sides.push('after'); + sides.sort((a, b) => { + const gapA = a === 'before' ? beforePct : afterPct; + const gapB = b === 'before' ? beforePct : afterPct; + return gapB - gapA; + }); + + let adjacent: { placement: LabelPlacement; rect: PixelRect } | null = null; + for (const side of sides) { + adjacent = tryPlaceAdjacentLabel( + layout, + widthPx, + heightPx, + side, + layoutHeight, + canvasWidth, + canvasHeight, + isClearRelaxed + ); + if (adjacent) break; + } + + if (adjacent) { + placed.push(adjacent.placement); + placedLabelRects.push(adjacent.rect); + continue; + } + + // Fallback: hidden until hover + const bandWidthPx = (span / 100) * canvasWidth; + const leftPct = clampLabelLeftPct(preferredX, Math.min(widthPx, bandWidthPx || widthPx), canvasWidth); + const streamY = yOnStream(layout, leftPct); + placed.push({ + layout, + leftPct, + topPct: (streamY / layoutHeight) * 100, + fitsInBand: false, + }); + continue; + } + + // Priority 1: center of band if no portrait overlap + const centerX = preferredX; + const centerStreamY = yOnStream(layout, centerX); + const centerRect = labelOnBandRect(centerX, centerStreamY, widthPx, heightPx, layoutHeight, canvasWidth, canvasHeight); + if (isClear(centerRect)) { + placed.push({ layout, leftPct: centerX, topPct: (centerStreamY / layoutHeight) * 100, fitsInBand: true }); + placedLabelRects.push(centerRect); + continue; + } + + // Priority 2: right end inside band + const widthPct = (widthPx / canvasWidth) * 100; + const halfWidthPct = widthPct / 2; + const padPct = (LABEL_BAND_PAD_PX / canvasWidth) * 100; + const endInsideX = layout.xEnd - halfWidthPct - padPct; + if (endInsideX - halfWidthPct >= layout.xStart) { + const clampedEnd = clampLabelLeftPct(endInsideX, widthPx, canvasWidth); + const endStreamY = yOnStream(layout, clampedEnd); + const endRect = labelOnBandRect(clampedEnd, endStreamY, widthPx, heightPx, layoutHeight, canvasWidth, canvasHeight); + if (isClear(endRect)) { + placed.push({ layout, leftPct: clampedEnd, topPct: (endStreamY / layoutHeight) * 100, fitsInBand: true }); + placedLabelRects.push(endRect); + continue; + } + } + + // Priority 3: overflow right or left (up to 100%), prefer side with fewer transition line overlaps + const maxOverflowPct = widthPct; + const overflowX = layout.xEnd + maxOverflowPct - halfWidthPct; + const clampedOverflow = clampLabelLeftPct(overflowX, widthPx, canvasWidth); + const overflowStreamY = yOnStream(layout, Math.min(clampedOverflow, layout.xEnd)); + const overflowRect = labelOnBandRect(clampedOverflow, overflowStreamY, widthPx, heightPx, layoutHeight, canvasWidth, canvasHeight); + const rightClear = isClearRelaxed(overflowRect); + + const overflowLeftX = layout.xStart - maxOverflowPct + halfWidthPct; + const clampedOverflowL = clampLabelLeftPct(overflowLeftX, widthPx, canvasWidth); + const overflowLStreamY = yOnStream(layout, Math.max(clampedOverflowL, layout.xStart)); + const overflowLRect = labelOnBandRect(clampedOverflowL, overflowLStreamY, widthPx, heightPx, layoutHeight, canvasWidth, canvasHeight); + const leftClear = isClearRelaxed(overflowLRect); + + if (rightClear && leftClear) { + const allBranchRects = branchObstacles.map((b) => b.rect); + const rightBranchOverlap = allBranchRects.reduce((sum, o) => sum + overlapArea(overflowRect, o, 0), 0); + const leftBranchOverlap = allBranchRects.reduce((sum, o) => sum + overlapArea(overflowLRect, o, 0), 0); + if (leftBranchOverlap <= rightBranchOverlap) { + placed.push({ layout, leftPct: clampedOverflowL, topPct: (overflowLStreamY / layoutHeight) * 100, fitsInBand: true }); + placedLabelRects.push(overflowLRect); + } else { + placed.push({ layout, leftPct: clampedOverflow, topPct: (overflowStreamY / layoutHeight) * 100, fitsInBand: true }); + placedLabelRects.push(overflowRect); + } + continue; + } + if (rightClear) { + placed.push({ layout, leftPct: clampedOverflow, topPct: (overflowStreamY / layoutHeight) * 100, fitsInBand: true }); + placedLabelRects.push(overflowRect); + continue; + } + if (leftClear) { + placed.push({ layout, leftPct: clampedOverflowL, topPct: (overflowLStreamY / layoutHeight) * 100, fitsInBand: true }); + placedLabelRects.push(overflowLRect); + continue; + } + + // Priority 4: scan candidates (existing fallback) const xCandidates = new Set(); const steps = Math.max(12, Math.ceil(span * 2)); for (let i = 0; i <= steps; i++) { @@ -1045,19 +1551,13 @@ function buildLabelPlacements( const scoreRect = (rect: PixelRect) => { let score = 0; - for (const o of portraitObstacles) score += overlapArea(rect, o) * 4; + for (const o of allPortraitRects) 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; @@ -1078,6 +1578,7 @@ function buildLabelPlacements( layout, leftPct, topPct: (streamY / layoutHeight) * 100, + fitsInBand: true, }; if (isClear(rect)) { @@ -1484,16 +1985,31 @@ export default function MovementBands({ ); } + // Label fit is decided before lane packing — overflow names get an exclusive row. + const labelFitsById = buildLabelFitsById( + visibleMovements, + viewStart, + viewEnd, + canvasWidth + ); + // Pack all visible movements into shared horizontal lanes whenever their // 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); + const laneIndex = assignTemporalLanes( + visibleMovements, + viewStart, + viewEnd, + labelFitsById, + lineageParents + ); refineLanesForLineageCorridors( laneIndex, visibleMovements, lineageParents, viewStart, - viewEnd + viewEnd, + labelFitsById ); compactLanes(laneIndex); @@ -1527,16 +2043,17 @@ export default function MovementBands({ let maxStrokePx = ABS_MIN_STREAM_STROKE_PX; const strokeById = new Map(); + const scaledMinStroke = Math.max(10, ABS_MIN_STREAM_STROKE_PX * Math.min(1, fitScale)); for (const movement of visibleMovements) { const lane = laneIndex.get(movement.id) ?? 0; const raw = (desiredStrokeById.get(movement.id) ?? ABS_MIN_STREAM_STROKE_PX) * fitScale; const laneCap = Math.max( - ABS_MIN_STREAM_STROKE_PX, + scaledMinStroke, laneHeights[lane] - LANE_GAP_PX * Math.min(1, fitScale) ); const strokePx = Math.min( MAX_STREAM_STROKE_PX, - Math.max(ABS_MIN_STREAM_STROKE_PX, Math.min(raw, laneCap)) + Math.max(scaledMinStroke, Math.min(raw, laneCap)) ); strokeById.set(movement.id, strokePx); maxStrokePx = Math.max(maxStrokePx, strokePx); @@ -1582,6 +2099,7 @@ export default function MovementBands({ strokePx, portraitSizePx: portraitSizeForStroke(strokePx), displayColor: vividMovementColor(movement.color), + labelFitsInBand: labelFitsById.get(movement.id) ?? true, }); } @@ -1635,7 +2153,7 @@ export default function MovementBands({ portraitSizePx, streamCurveOffset, }; - }, [visibleMovements, movements, viewStart, viewEnd, canvasHeight]); + }, [visibleMovements, movements, viewStart, viewEnd, canvasHeight, canvasWidth]); useLayoutEffect(() => { const target: AnimatedFlowGeometry = { layouts, branches, streamCurveOffset }; @@ -1743,7 +2261,7 @@ export default function MovementBands({ return (

- Scroll to zoom · drag to pan · streams blend from movement to movement through history + Scroll to zoom · drag to pan · each movement stream is a solid colour band through history

))} - - {!fastGraphics && - drawLayouts.map((layout) => { - const primaryParent = - layout.parentIds.length > 0 ? layoutById.get(layout.parentIds[0]) : null; - const hasChildren = (childIdsByParent.get(layout.movement.id)?.length ?? 0) > 0; - const parentColor = primaryParent?.displayColor ?? layout.displayColor; - - return ( - - {primaryParent ? ( - <> - - - - - ) : ( - - )} - - {hasChildren ? ( - <> - - - - - ) : ( - - )} - - ); - })} {fastGraphics ? ( @@ -1920,9 +2390,9 @@ export default function MovementBands({ key={`stream-${layout.movement.id}`} d={d} className={`movement-stream-fill${streamHighlighted ? ' movement-stream-highlighted' : ''}`} - stroke={`url(#stream-grad-${layout.movement.id})`} + stroke={layout.displayColor} fill="none" - style={{ strokeWidth: layout.strokePx }} + style={{ strokeWidth: layout.strokePx, strokeOpacity: 0.64 }} /> ); })} @@ -1992,27 +2462,28 @@ export default function MovementBands({
- {labelPlacements.map(({ layout, leftPct, topPct }) => ( - - ))} + {labelPlacements.map(({ layout, leftPct, topPct, fitsInBand }) => { + const showLabel = fitsInBand || hoveredMovementId === layout.movement.id; + if (!showLabel) return null; + return ( + + ); + })}