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