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 <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-07-29 12:13:55 +03:00
co-authored by Cursor
parent 8111cd0163
commit ad1572b3aa
3 changed files with 602 additions and 125 deletions
+4 -3
View File
@@ -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 movements 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 artists lifespan on the era bar and brightens their segment on the movement stream.
+5
View File
@@ -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;
+578 -107
View File
@@ -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<number, numb
/** Same-lane movements must leave at least this many years between one end and the next start. */
const LANE_MIN_GAP_YEARS = 10;
/** Pack movements into lanes — overlapping or closer than LANE_MIN_GAP_YEARS need separate lanes. */
/** Pack movements into lanes — overlapping or closer than LANE_MIN_GAP_YEARS need separate lanes.
* Movements whose label is wider than the band never share a lane (exclusive row).
* Among valid lanes, pick the one closest to lineage parents to keep branch connectors short. */
function assignTemporalLanes(
group: ArtMovement[],
viewStart: number,
viewEnd: number
viewEnd: number,
labelFitsById: Map<number, boolean>,
lineageParents: Map<number, number[]>
): Map<number, number> {
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<number, number>();
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<number, number>,
lineageParents: Map<number, number[]>
): 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<number, number>,
lineageParents: Map<number, number[]>
): 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<number, number>,
movements: ArtMovement[],
lineageParents: Map<number, number[]>,
viewStart: number,
viewEnd: number,
labelFitsById: Map<number, boolean>
): 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<number, number>,
movementId: number,
targetLane: number,
labelFitsById: Map<number, boolean>
): 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<number, number>,
movementsById: Map<number, ArtMovement>,
movementId: number,
targetLane: number,
viewStart: number,
viewEnd: number
viewEnd: number,
labelFitsById?: Map<number, boolean>
): 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, boolean>
): 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<number, number>,
movements: ArtMovement[],
lineageParents: Map<number, number[]>,
viewStart: number,
viewEnd: number
viewEnd: number,
labelFitsById: Map<number, boolean>
): 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<number, boolean> {
const map = new Map<number, boolean>();
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<number>();
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<number, number>();
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 (
<div className="movements-flow">
<p className="movements-flow-caption">
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
</p>
<div
@@ -1813,54 +2331,6 @@ export default function MovementBands({
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0.55} />
</linearGradient>
))}
{!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 (
<linearGradient
key={`grad-${layout.movement.id}`}
id={`stream-grad-${layout.movement.id}`}
gradientUnits="userSpaceOnUse"
x1={layout.xStart}
y1={layout.y}
x2={layout.xEnd}
y2={layout.yEnd}
>
{primaryParent ? (
<>
<stop offset="0%" stopColor={parentColor} stopOpacity={0.48} />
<stop offset="16%" stopColor={layout.displayColor} stopOpacity={0.58} />
<stop offset="32%" stopColor={layout.displayColor} stopOpacity={0.64} />
</>
) : (
<stop
offset="0%"
stopColor={layout.displayColor}
stopOpacity={layout.movement.start_definite ? 0.5 : 0.18}
/>
)}
<stop offset="50%" stopColor={layout.displayColor} stopOpacity={0.64} />
{hasChildren ? (
<>
<stop offset="68%" stopColor={layout.displayColor} stopOpacity={0.64} />
<stop offset="84%" stopColor={layout.displayColor} stopOpacity={0.4} />
<stop offset="100%" stopColor={layout.displayColor} stopOpacity={0} />
</>
) : (
<stop
offset="100%"
stopColor={layout.displayColor}
stopOpacity={layout.movement.end_definite ? 0.5 : 0.18}
/>
)}
</linearGradient>
);
})}
</defs>
{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,11 +2462,14 @@ export default function MovementBands({
</div>
<div className="movements-flow-labels">
{labelPlacements.map(({ layout, leftPct, topPct }) => (
{labelPlacements.map(({ layout, leftPct, topPct, fitsInBand }) => {
const showLabel = fitsInBand || hoveredMovementId === layout.movement.id;
if (!showLabel) return null;
return (
<button
key={`label-${layout.movement.id}`}
type="button"
className="movement-flow-label movement-flow-label-btn"
className={`movement-flow-label movement-flow-label-btn${fitsInBand ? '' : ' movement-flow-label-hover-reveal'}`}
style={{
left: `${leftPct}%`,
top: `${topPct}%`,
@@ -2008,11 +2481,9 @@ export default function MovementBands({
onMouseDown={(e) => e.stopPropagation()}
>
<span className="movement-name">{layout.movement.name}</span>
{layout.movement.era_name && (
<span className="movement-era">{layout.movement.era_name}</span>
)}
</button>
))}
);
})}
</div>
<div className="movements-flow-artists">