import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react'; import type { ArtMovement } from '../types'; import { MOVEMENT_LINEAGE } from '../data/movement-lineage'; import { panTimelineView, zoomTimelineView } from '../utils/timelineView'; import { vividMovementColor } from '../utils/movementColor'; import './VerticalMovementBands.css'; interface Props { movements: ArtMovement[]; viewStart: number; viewEnd: number; absoluteMin: number; absoluteMax: number; onViewChange: (start: number, end: number) => void; onMovementClick?: (movementId: number) => void; } interface VLayout { movement: ArtMovement; /** Year span as % from bottom (earlier = lower). */ yStart: number; yEnd: number; /** Lane center as % from left. */ x: number; strokePx: number; displayColor: string; parentIds: number[]; } interface VBranch { key: string; d: string; colorFrom: string; colorTo: string; strokePx: number; fromId: number; toId: number; x1: number; y1: number; x2: number; y2: number; } const MAX_STROKE = 108; // ~300% of prior max (36) const MIN_STROKE = 54; // ~300% of prior min (18) const SIDE_PAD = 24; /** Preferred centre-to-centre spacing: stroke + small gap (keeps columns tight). */ const PREFERRED_LANE_PITCH = MAX_STROKE + 16; const LANE_MIN_GAP_YEARS = 2; function yearToBottomPercent(year: number, start: number, end: number): number { return ((year - start) / (end - start)) * 100; } function buildLineageParentMap( visible: ArtMovement[], nameToId: Map ): Map { const parents = new Map(); const visibleIds = new Set(visible.map((m) => m.id)); for (const [parentName, childName] of MOVEMENT_LINEAGE) { const parentId = nameToId.get(parentName); const childId = nameToId.get(childName); if (parentId == null || childId == null) continue; if (!visibleIds.has(parentId) || !visibleIds.has(childId)) continue; const list = parents.get(childId) || []; if (!list.includes(parentId)) list.push(parentId); parents.set(childId, list); } return parents; } function assignDepths( group: ArtMovement[], lineageParents: Map ): Map { const depths = new Map(); const visiting = new Set(); const visit = (id: number): number => { if (depths.has(id)) return depths.get(id)!; if (visiting.has(id)) return 0; visiting.add(id); const parents = lineageParents.get(id) || []; const d = parents.length ? 1 + Math.max(...parents.map(visit)) : 0; visiting.delete(id); depths.set(id, d); return d; }; for (const m of group) visit(m.id); return depths; } /** Prefer center, then alternate right / left: 0, +1, -1, +2, -2, … */ function centerOutOffsets(max = 64): number[] { const out = [0]; for (let d = 1; d <= max; d++) out.push(d, -d); return out; } function pickNearestFreeLane(preferred: number, isFree: (lane: number) => boolean): number { for (const delta of centerOutOffsets()) { const lane = preferred + delta; if (isFree(lane)) return lane; } return preferred; } /** Collapse signed lane indices to contiguous 0..n-1 (left → right). */ function compactSignedLanes(laneById: Map): void { const usedSorted = [...new Set(laneById.values())].sort((a, b) => a - b); const remap = new Map(usedSorted.map((lane, index) => [lane, index])); for (const [id, lane] of laneById) { laneById.set(id, remap.get(lane) ?? 0); } } function assignTemporalLanes( group: ArtMovement[], viewStart: number, viewEnd: number, 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.depth - b.depth || a.start - b.start || a.end - b.end); /** Signed lane → year when that lane frees up. */ const laneEnds = new Map(); const laneById = new Map(); for (const span of spans) { const parentLanes = (lineageParents.get(span.id) || []) .map((pid) => laneById.get(pid)) .filter((lane): lane is number => lane != null); const preferred = parentLanes.length > 0 ? Math.round(parentLanes.reduce((s, l) => s + l, 0) / parentLanes.length) : 0; const lane = pickNearestFreeLane(preferred, (candidate) => { const end = laneEnds.get(candidate); return end == null || end + LANE_MIN_GAP_YEARS <= span.start; }); laneEnds.set(lane, span.end); laneById.set(span.id, lane); } compactSignedLanes(laneById); return laneById; } /** * Prefer one column per movement when the canvas is wide enough, so streams * do not stack in the same vertical lane. Fall back to temporal packing only * when there is not enough horizontal room. * * Lanes grow from the center outward (0, +1, −1, …) so the layout reads as a tree. */ function assignVerticalLanes( group: ArtMovement[], viewStart: number, viewEnd: number, lineageParents: Map, canvasWidth: number ): Map { const usable = Math.max(200, canvasWidth - SIDE_PAD * 2); const minLanePx = PREFERRED_LANE_PITCH; const maxExclusive = Math.max(1, Math.floor(usable / minLanePx)); if (group.length <= maxExclusive) { const depths = assignDepths(group, lineageParents); // Roots first so the trunk claims center; children then fan around parents. const sorted = [...group].sort( (a, b) => (depths.get(a.id) ?? 0) - (depths.get(b.id) ?? 0) || a.start_year - b.start_year || a.name.localeCompare(b.name) ); const laneById = new Map(); const used = new Set(); for (const m of sorted) { const parentLanes = (lineageParents.get(m.id) || []) .map((pid) => laneById.get(pid)) .filter((lane): lane is number => lane != null); const preferred = parentLanes.length > 0 ? Math.round(parentLanes.reduce((s, l) => s + l, 0) / parentLanes.length) : 0; const lane = pickNearestFreeLane(preferred, (candidate) => !used.has(candidate)); used.add(lane); laneById.set(m.id, lane); } compactSignedLanes(laneById); return laneById; } return assignTemporalLanes(group, viewStart, viewEnd, lineageParents); } function influenceCount(m: ArtMovement): number { const n = m.influence_link_count; return typeof n === 'number' && Number.isFinite(n) ? Math.max(0, n) : 0; } function pctToSvg(xPct: number, yBottomPct: number, widthPx: number, heightPx: number) { return { x: (xPct / 100) * widthPx, y: ((100 - yBottomPct) / 100) * heightPx, }; } /** Vertical stream path: time along Y (SVG y grows down → invert bottom%). */ function verticalStreamPath( xPct: number, yStartPct: number, yEndPct: number, heightPx: number, widthPx: number ): string { const start = pctToSvg(xPct, yEndPct, widthPx, heightPx); const end = pctToSvg(xPct, yStartPct, widthPx, heightPx); const midY = (start.y + end.y) / 2; const bulge = Math.min(18, Math.abs(end.y - start.y) * 0.06); return `M ${start.x} ${start.y} C ${start.x + bulge} ${midY}, ${end.x - bulge} ${midY}, ${end.x} ${end.y}`; } /** Absolute-year anchors so pan/scroll keeps a constant connection angle. */ function branchAnchorYears( parent: ArtMovement, child: ArtMovement, childIndex: number, childCount: number ): { originYear: number; targetYear: number } | null { const parentSpan = parent.end_year - parent.start_year; if (parentSpan <= 0) return null; const tBase = childCount === 1 ? 0.38 : 0.28 + (childIndex / Math.max(1, childCount - 1)) * 0.22; let originYear = parent.start_year + parentSpan * tBase; const targetYear = child.start_year; if (originYear >= targetYear) { originYear = Math.min(parent.start_year + parentSpan * 0.2, targetYear - 1); } originYear = Math.max(parent.start_year, Math.min(parent.end_year, originYear)); if (originYear >= targetYear) return null; return { originYear, targetYear }; } function branchPath(x1: number, y1: number, x2: number, y2: number): string { const dx = x2 - x1; const dy = y2 - y1; // Pull control points along the diagonal so the curve reads as a waterfall, not an L-stair. const c1x = x1 + dx * 0.35; const c1y = y1 + dy * 0.55; const c2x = x2 - dx * 0.25; const c2y = y2 - dy * 0.2; return `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`; } /** Exponential chase rate (1/s) so lane / view shifts read as motion, not snaps. */ const LAYOUT_ANIM_RATE = 14; const LAYOUT_ANIM_EPS = 0.06; interface AnimatedVFlow { layouts: VLayout[]; branches: VBranch[]; } function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; } function geomSettled(a: number, b: number, eps = LAYOUT_ANIM_EPS): boolean { return Math.abs(a - b) <= eps; } function lerpVLayout(from: VLayout, to: VLayout, t: number): VLayout { return { ...to, yStart: lerp(from.yStart, to.yStart, t), yEnd: lerp(from.yEnd, to.yEnd, t), x: lerp(from.x, to.x, t), strokePx: lerp(from.strokePx, to.strokePx, t), }; } function vLayoutSettled(a: VLayout, b: VLayout): boolean { return ( geomSettled(a.yStart, b.yStart) && geomSettled(a.yEnd, b.yEnd) && geomSettled(a.x, b.x) && geomSettled(a.strokePx, b.strokePx, 0.35) ); } function lerpVBranch(from: VBranch, to: VBranch, t: number): VBranch { const x1 = lerp(from.x1, to.x1, t); const y1 = lerp(from.y1, to.y1, t); const x2 = lerp(from.x2, to.x2, t); const y2 = lerp(from.y2, to.y2, t); const strokePx = lerp(from.strokePx, to.strokePx, t); return { ...to, x1, y1, x2, y2, strokePx, d: branchPath(x1, y1, x2, y2), }; } function vBranchSettled(a: VBranch, b: VBranch): boolean { return ( geomSettled(a.x1, b.x1) && geomSettled(a.y1, b.y1) && geomSettled(a.x2, b.x2) && geomSettled(a.y2, b.y2) && geomSettled(a.strokePx, b.strokePx, 0.35) ); } function blendVFlow( from: AnimatedVFlow, to: AnimatedVFlow, t: number ): { next: AnimatedVFlow; settled: boolean } { const fromLayouts = new Map(from.layouts.map((l) => [l.movement.id, l])); const fromBranches = new Map(from.branches.map((b) => [b.key, b])); let settled = true; const layouts = to.layouts.map((target) => { const prev = fromLayouts.get(target.movement.id); if (!prev) return target; if (vLayoutSettled(prev, target)) return target; settled = false; return lerpVLayout(prev, target, t); }); const branches = to.branches.map((target) => { const prev = fromBranches.get(target.key); if (!prev) return target; if (vBranchSettled(prev, target)) return target; settled = false; return lerpVBranch(prev, target, t); }); return { next: { layouts, branches }, settled }; } export default function VerticalMovementBands({ movements, viewStart, viewEnd, absoluteMin, absoluteMax, onViewChange, onMovementClick, }: Props) { const canvasRef = useRef(null); const [canvasSize, setCanvasSize] = useState({ w: 800, h: 600 }); const [panning, setPanning] = useState(false); const [hoveredMovementId, setHoveredMovementId] = useState(null); const [flowVisual, setFlowVisual] = useState(null); const panStart = useRef({ y: 0, viewStart: 0, viewEnd: 0 }); const viewRef = useRef({ viewStart, viewEnd }); const onViewChangeRef = useRef(onViewChange); const flowVisualRef = useRef(null); const flowTargetRef = useRef(null); const flowRafRef = useRef(null); const flowLastTsRef = useRef(0); viewRef.current = { viewStart, viewEnd }; onViewChangeRef.current = onViewChange; useLayoutEffect(() => { const el = canvasRef.current; if (!el) return; const measure = () => { const rect = el.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { setCanvasSize({ w: Math.round(rect.width), h: Math.round(rect.height) }); } }; measure(); const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, []); const visibleMovements = useMemo( () => movements.filter((m) => m.end_year > viewStart && m.start_year < viewEnd), [movements, viewStart, viewEnd] ); const { layouts, branches } = useMemo(() => { if (visibleMovements.length === 0) { return { layouts: [] as VLayout[], branches: [] as VBranch[] }; } const nameToId = new Map(movements.map((m) => [m.name, m.id])); const lineageParents = buildLineageParentMap(visibleMovements, nameToId); const laneIndex = assignVerticalLanes( visibleMovements, viewStart, viewEnd, lineageParents, canvasSize.w ); let maxLanes = 0; for (const m of visibleMovements) { const lane = laneIndex.get(m.id) ?? 0; maxLanes = Math.max(maxLanes, lane + 1); } maxLanes = Math.max(1, maxLanes); const usable = Math.max(200, canvasSize.w - SIDE_PAD * 2); // Pack columns tightly; only stretch if the canvas is narrower than the preferred cluster. const lanePitch = Math.min(usable / maxLanes, PREFERRED_LANE_PITCH); const clusterWidth = lanePitch * maxLanes; const startX = SIDE_PAD + Math.max(0, (usable - clusterWidth) / 2); const laneCentersPct: number[] = []; for (let lane = 0; lane < maxLanes; lane++) { const centerPx = startX + lanePitch * lane + lanePitch / 2; laneCentersPct[lane] = (centerPx / Math.max(1, canvasSize.w)) * 100; } const maxInf = Math.max(1, ...visibleMovements.map(influenceCount)); const layoutById = new Map(); for (const m of visibleMovements) { const yStart = yearToBottomPercent(Math.max(m.start_year, viewStart), viewStart, viewEnd); const yEnd = yearToBottomPercent(Math.min(m.end_year, viewEnd), viewStart, viewEnd); if (yEnd <= yStart) continue; const lane = laneIndex.get(m.id) ?? 0; const baseStroke = MIN_STROKE + (influenceCount(m) / maxInf) * (MAX_STROKE - MIN_STROKE); // Allow nearly full preferred stroke; only shrink if the pitch is forced smaller. const strokePx = Math.min(MAX_STROKE, Math.max(MIN_STROKE, baseStroke), lanePitch * 0.88); layoutById.set(m.id, { movement: m, yStart, yEnd, x: laneCentersPct[lane] ?? 50, strokePx, displayColor: vividMovementColor(m.color), parentIds: lineageParents.get(m.id) || [], }); } const childIdsByParent = new Map(); for (const layout of layoutById.values()) { for (const parentId of layout.parentIds) { if (!layoutById.has(parentId)) continue; const children = childIdsByParent.get(parentId) || []; children.push(layout.movement.id); childIdsByParent.set(parentId, children); } } for (const children of childIdsByParent.values()) { children.sort((a, b) => { const la = layoutById.get(a)!; const lb = layoutById.get(b)!; return la.x - lb.x || la.movement.start_year - lb.movement.start_year; }); } const branchList: VBranch[] = []; for (const layout of layoutById.values()) { for (const parentId of layout.parentIds) { const parent = layoutById.get(parentId); if (!parent) continue; const children = childIdsByParent.get(parentId) || [layout.movement.id]; const childIndex = children.indexOf(layout.movement.id); const anchors = branchAnchorYears( parent.movement, layout.movement, childIndex, children.length ); if (!anchors) continue; // Map fixed calendar years → current view % so pan keeps dx/dy (and angle) stable. const originY = yearToBottomPercent(anchors.originYear, viewStart, viewEnd); const targetY = yearToBottomPercent(anchors.targetYear, viewStart, viewEnd); const from = pctToSvg(parent.x, originY, canvasSize.w, canvasSize.h); const to = pctToSvg(layout.x, targetY, canvasSize.w, canvasSize.h); branchList.push({ key: `${parentId}-${layout.movement.id}`, d: branchPath(from.x, from.y, to.x, to.y), colorFrom: parent.displayColor, colorTo: layout.displayColor, strokePx: Math.max(14, Math.min(parent.strokePx, layout.strokePx) * 0.55), fromId: parentId, toId: layout.movement.id, x1: from.x, y1: from.y, x2: to.x, y2: to.y, }); } } return { layouts: [...layoutById.values()].sort((a, b) => a.movement.start_year - b.movement.start_year), branches: branchList, }; }, [visibleMovements, movements, viewStart, viewEnd, canvasSize.w, canvasSize.h]); useLayoutEffect(() => { const target: AnimatedVFlow = { layouts, branches }; flowTargetRef.current = target; if (!flowVisualRef.current) { flowVisualRef.current = target; setFlowVisual(target); return; } if (flowRafRef.current != null) return; flowLastTsRef.current = performance.now(); const step = (now: number) => { const prev = flowVisualRef.current; const goal = flowTargetRef.current; if (!prev || !goal) { flowRafRef.current = null; return; } const dt = Math.min(0.048, Math.max(0, (now - flowLastTsRef.current) / 1000)); flowLastTsRef.current = now; const t = 1 - Math.exp(-LAYOUT_ANIM_RATE * dt); const { next, settled } = blendVFlow(prev, goal, t); flowVisualRef.current = settled ? goal : next; setFlowVisual(flowVisualRef.current); if (settled) { flowRafRef.current = null; return; } flowRafRef.current = requestAnimationFrame(step); }; flowRafRef.current = requestAnimationFrame(step); }, [layouts, branches]); useEffect(() => { return () => { if (flowRafRef.current != null) { cancelAnimationFrame(flowRafRef.current); flowRafRef.current = null; } }; }, []); useEffect(() => { const el = canvasRef.current; if (!el) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); const rect = el.getBoundingClientRect(); const { viewStart: vs, viewEnd: ve } = viewRef.current; const invertedClientY = rect.bottom - (e.clientY - rect.top); const next = zoomTimelineView( invertedClientY, 0, rect.height, e.deltaY, vs, ve, absoluteMin, absoluteMax ); onViewChangeRef.current(next.start, next.end); }; el.addEventListener('wheel', onWheel, { passive: false, capture: true }); return () => el.removeEventListener('wheel', onWheel, { capture: true }); }, [absoluteMin, absoluteMax]); const handlePanStart = useCallback( (e: React.MouseEvent) => { if (e.button !== 0) return; e.preventDefault(); setPanning(true); panStart.current = { y: e.clientY, viewStart, viewEnd }; }, [viewStart, viewEnd] ); useEffect(() => { if (!panning) return; const onMove = (e: MouseEvent) => { const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; const dy = e.clientY - panStart.current.y; const next = panTimelineView( -dy, rect.height, panStart.current.viewStart, panStart.current.viewEnd, absoluteMin, absoluteMax ); onViewChangeRef.current(next.start, next.end); }; const onUp = () => setPanning(false); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; }, [panning, absoluteMin, absoluteMax]); if (visibleMovements.length === 0) { return (

No art movements in this time range. Zoom out to explore more periods.

); } const displayLayouts = flowVisual?.layouts ?? layouts; const displayBranches = flowVisual?.branches ?? branches; return (

Bottom → top through history · scroll to zoom · drag to pan · click a stream

{displayBranches.map((branch) => ( ))} {displayLayouts.map((layout) => { const d = verticalStreamPath( layout.x, layout.yStart, layout.yEnd, canvasSize.h, canvasSize.w ); const highlighted = hoveredMovementId === layout.movement.id; return ( setHoveredMovementId(layout.movement.id)} onMouseLeave={() => setHoveredMovementId(null)} onClick={(e) => { e.stopPropagation(); onMovementClick?.(layout.movement.id); }} /> ); })} {displayBranches.map((branch) => { const highlighted = hoveredMovementId != null && (branch.fromId === hoveredMovementId || branch.toId === hoveredMovementId); return ( ); })} {displayLayouts.map((layout) => { const midY = (layout.yStart + layout.yEnd) / 2; return ( ); })}
); }