import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { ArtMovement } from '../types'; import { branchOriginYear, buildMovementTree, limbXAtYear, type MovementTreeNode, } from '../utils/movementTree'; import { shadeMovementColor, vividMovementColor } from '../utils/movementColor'; import { panTimelineView, zoomTimelineView } from '../utils/timelineView'; import './MovementTree.css'; interface Props { movements: ArtMovement[]; viewStart: number; viewEnd: number; absoluteMin: number; absoluteMax: number; onViewChange: (start: number, end: number) => void; onMovementClick?: (movementId: number) => void; } interface Pt { x: number; y: number; } const SIDE_PAD = 56; /** * Horizontal spread is tied to the zoom, not to the canvas. * * 90 % of the catalogue lives in the last 15 % of the time axis, so a tree * stretched to full width with all of history in view is one long trunk under a * flat bar. Instead the whole-history view draws a narrow tree, and every zoom * step fans the crown out — the chart grows as you walk up it. */ const FULL_VIEW_WIDTH_SHARE = 0.52; const ZOOM_SPREAD_EXPONENT = 0.45; /** How far past "everything fits" the tree may be blown up when zoomed in. */ const MAX_FIT_BOOST = 2.4; /** Exponential chase rate (1/s) for the horizontal fit, so zoom reads as growth. */ const FIT_ANIM_RATE = 9; const RIBBON_SAMPLES = 18; /** Vertical room a label needs. */ const MIN_LABEL_HEIGHT_PX = 20; /** * Readability floors. A 30-year movement is 8 px tall when the whole of * history is on screen; without these the modern crown fuses into one bar. */ const MIN_LIMB_RISE_PX = 30; const MIN_JUNCTION_RISE_PX = 38; /** A limb is never drawn thicker than this share of its own length. */ const MAX_THICKNESS_OF_LENGTH = 0.55; function cubicAt(p0: Pt, c1: Pt, c2: Pt, p3: Pt, t: number): Pt { const u = 1 - t; const a = u * u * u; const b = 3 * u * u * t; const c = 3 * u * t * t; const d = t * t * t; return { x: a * p0.x + b * c1.x + c * c2.x + d * p3.x, y: a * p0.y + b * c1.y + c * c2.y + d * p3.y, }; } function cubicTangent(p0: Pt, c1: Pt, c2: Pt, p3: Pt, t: number): Pt { const u = 1 - t; const a = 3 * u * u; const b = 6 * u * t; const c = 3 * t * t; return { x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p3.x - c2.x), y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y), }; } /** * Filled ribbon of varying width along a cubic. Offsetting along the curve * normal (rather than horizontally) keeps branch junctions solid even when a * zoomed-out view squeezes them almost flat. */ function ribbonPath(p0: Pt, c1: Pt, c2: Pt, p3: Pt, w0: number, w1: number): string { const left: Pt[] = []; const right: Pt[] = []; for (let i = 0; i <= RIBBON_SAMPLES; i++) { const t = i / RIBBON_SAMPLES; const p = cubicAt(p0, c1, c2, p3, t); const d = cubicTangent(p0, c1, c2, p3, t); const len = Math.hypot(d.x, d.y) || 1; const nx = -d.y / len; const ny = d.x / len; const half = (w0 + (w1 - w0) * t) / 2; left.push({ x: p.x + nx * half, y: p.y + ny * half }); right.push({ x: p.x - nx * half, y: p.y - ny * half }); } const fmt = (pt: Pt) => `${pt.x.toFixed(2)} ${pt.y.toFixed(2)}`; const forward = left.map((pt, i) => `${i === 0 ? 'M' : 'L'} ${fmt(pt)}`).join(' '); const back = right .slice() .reverse() .map((pt) => `L ${fmt(pt)}`) .join(' '); return `${forward} ${back} Z`; } interface LimbShape { id: number; name: string; color: string; shade: string; /** Trunk / limb body. */ d: string; /** Junction ribbon growing out of the structural parent (may be empty). */ junction: string; /** Rounded tip cap. */ tip: Pt & { r: number }; /** Root flare under a tree root, drawn only when the base is on screen. */ roots: string[]; labelX: number; labelY: number; labelVisible: boolean; yearRange: string; depth: number; inView: boolean; } interface GraftShape { key: string; d: string; color: string; fromId: number; toId: number; } function formatYear(year: number): string { return year < 0 ? `${Math.abs(year)} BCE` : `${year}`; } /** * Greedy declutter: closer to the trunk wins. Every visible movement asks for a * name, and the ones that would collide with an already-placed name — or fall * off the canvas — stay anonymous until you zoom in on them. */ function hideOverlappingLabels(limbs: LimbShape[], width: number, height: number): void { const placed: { x0: number; y0: number; x1: number; y1: number }[] = []; const candidates = limbs .map((limb, index) => ({ limb, index })) .filter(({ limb }) => limb.labelVisible) .sort((a, b) => a.limb.depth - b.limb.depth || b.limb.labelY - a.limb.labelY); for (const { limb } of candidates) { const halfW = (limb.name.length * 6.6 + 16) / 2; const halfH = MIN_LABEL_HEIGHT_PX / 2; const box = { x0: limb.labelX - halfW, y0: limb.labelY - halfH, x1: limb.labelX + halfW, y1: limb.labelY + halfH, }; const offCanvas = box.x0 < 2 || box.x1 > width - 2 || box.y0 < 2 || box.y1 > height - 2; const collides = placed.some( (p) => box.x0 < p.x1 && box.x1 > p.x0 && box.y0 < p.y1 && box.y1 > p.y0 ); if (offCanvas || collides) { limb.labelVisible = false; continue; } placed.push(box); } } export default function MovementTree({ movements, viewStart, viewEnd, absoluteMin, absoluteMax, onViewChange, onMovementClick, }: Props) { const { t } = useTranslation('home'); const canvasRef = useRef(null); const [canvas, setCanvas] = useState({ w: 900, h: 600 }); const [panning, setPanning] = useState(false); const [hoveredId, setHoveredId] = useState(null); const [fitScale, setFitScale] = useState(1); const panStart = useRef({ y: 0, viewStart: 0, viewEnd: 0 }); const viewRef = useRef({ viewStart, viewEnd }); const onViewChangeRef = useRef(onViewChange); const fitRef = useRef(1); const fitReadyRef = useRef(false); const fitTargetRef = useRef(1); const fitRafRef = useRef(null); const fitLastTsRef = 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) { setCanvas({ w: Math.round(rect.width), h: Math.round(rect.height) }); } }; measure(); const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, []); // Structure is catalogue-wide and view-independent: zooming must not reshape // the tree, only travel along it. const tree = useMemo(() => buildMovementTree(movements), [movements]); const visibleIds = useMemo(() => { const ids = new Set(); for (const node of tree.nodes.values()) { const { start_year: s, end_year: e } = node.movement; if (e > viewStart && s < viewEnd) ids.add(node.movement.id); } return ids; }, [tree, viewStart, viewEnd]); const targetScale = useMemo(() => { const usable = Math.max(240, canvas.w - SIDE_PAD * 2); const fitAll = usable / (2 * Math.max(1, tree.halfSpan)); const visibleSpan = Math.max(1, viewEnd - viewStart); const totalSpan = Math.max(visibleSpan, absoluteMax - absoluteMin); const zoomSpread = Math.pow(totalSpan / visibleSpan, ZOOM_SPREAD_EXPONENT); const spread = Math.min( MAX_FIT_BOOST, Math.max(FULL_VIEW_WIDTH_SHARE, FULL_VIEW_WIDTH_SHARE * zoomSpread) ); let visibleHalfSpan = 0; for (const id of visibleIds) { const node = tree.nodes.get(id); if (!node) continue; visibleHalfSpan = Math.max( visibleHalfSpan, Math.abs(node.x) + Math.abs(node.lean) + node.baseWidth / 2 ); } // Never let what is on screen spill off the canvas. const overflowCap = visibleHalfSpan > 0 ? usable / (2 * visibleHalfSpan) : Infinity; return Math.min(fitAll * spread, overflowCap); }, [canvas.w, tree, visibleIds, viewStart, viewEnd, absoluteMin, absoluteMax]); useLayoutEffect(() => { fitTargetRef.current = targetScale; if (!fitReadyRef.current) { // First measured layout — adopt it instead of animating in from nothing. fitReadyRef.current = true; fitRef.current = targetScale; setFitScale(targetScale); return; } if (fitRafRef.current != null) return; fitLastTsRef.current = performance.now(); const step = (now: number) => { const dt = Math.min(0.05, Math.max(0, (now - fitLastTsRef.current) / 1000)); fitLastTsRef.current = now; const t = 1 - Math.exp(-FIT_ANIM_RATE * dt); const next = fitRef.current + (fitTargetRef.current - fitRef.current) * t; if (Math.abs(fitTargetRef.current - next) < 0.002) { fitRef.current = fitTargetRef.current; setFitScale(fitTargetRef.current); fitRafRef.current = null; return; } fitRef.current = next; setFitScale(next); fitRafRef.current = requestAnimationFrame(step); }; fitRafRef.current = requestAnimationFrame(step); }, [targetScale]); useEffect( () => () => { if (fitRafRef.current != null) cancelAnimationFrame(fitRafRef.current); }, [] ); const { limbs, grafts } = useMemo(() => { const span = viewEnd - viewStart || 1; const centerX = canvas.w / 2; const widthScale = Math.min(1.7, Math.max(0.55, fitScale)); const sx = (treeX: number) => centerX + treeX * fitScale; const sy = (year: number) => canvas.h - ((year - viewStart) / span) * canvas.h; const limbList: LimbShape[] = []; const graftList: GraftShape[] = []; /** * Pass 1 — screen geometry per movement. * * Two readability floors apply here, and only here: the structure and the * dates stay untouched. A limb is drawn at least `MIN_LIMB_RISE_PX` long, * and never thicker than it is long, so 2 500 years of trunk and 30 years * of Fauvism can share one linear axis without the modern crown fusing * into a solid bar. */ const drawn = new Map< number, { base: Pt; c1: Pt; c2: Pt; tip: Pt; wBase: number; wTip: number } >(); for (const id of tree.drawOrder) { const node = tree.nodes.get(id)!; const { start_year: start, end_year: end } = node.movement; if (end <= start) continue; const base: Pt = { x: sx(limbXAtYear(node, start)), y: sy(start) }; const trueTipY = sy(end); const tip: Pt = { x: sx(limbXAtYear(node, end)), y: Math.min(trueTipY, base.y - MIN_LIMB_RISE_PX), }; const lengthPx = Math.hypot(tip.x - base.x, tip.y - base.y); const cap = Math.max(3, lengthPx * MAX_THICKNESS_OF_LENGTH); const wBase = Math.min(node.baseWidth * widthScale, cap); const wTip = Math.min(node.tipWidth * widthScale, cap * 0.82); const dy = tip.y - base.y; drawn.set(id, { base, c1: { x: base.x, y: base.y + dy * 0.42 }, c2: { x: tip.x, y: tip.y - dy * 0.34 }, tip, wBase, wTip, }); } /** Point on a drawn limb at the screen height closest to `targetY`. */ const pointOnLimb = (parentId: number, targetY: number) => { const p = drawn.get(parentId)!; const total = p.base.y - p.tip.y || 1; const t = Math.min(1, Math.max(0, (p.base.y - targetY) / total)); return { pt: cubicAt(p.base, p.c1, p.c2, p.tip, t), width: p.wBase + (p.wTip - p.wBase) * t, }; }; // Pass 2 — ribbons. for (const id of tree.drawOrder) { const node = tree.nodes.get(id)!; const shape = drawn.get(id); if (!shape) continue; const { base, c1, c2, tip, wBase, wTip } = shape; const { start_year: start, end_year: end } = node.movement; const color = vividMovementColor(node.movement.color); const d = ribbonPath(base, c1, c2, tip, wBase, wTip); // Junction: the limb grows out of its parent a little before its own // date, and always climbs far enough to read as a fork. let junction = ''; const parent = node.parentId != null ? tree.nodes.get(node.parentId) : null; if (parent && drawn.has(parent.movement.id)) { const byDate = sy(branchOriginYear(parent, node)); const origin = pointOnLimb( parent.movement.id, Math.max(byDate, base.y + MIN_JUNCTION_RISE_PX) ); const from = origin.pt; const jdy = base.y - from.y; const jLength = Math.hypot(base.x - from.x, jdy); const jCap = Math.max(3, jLength * MAX_THICKNESS_OF_LENGTH); const wFrom = Math.min(origin.width * 0.92, wBase * 1.25, jCap); const jc1: Pt = { x: from.x, y: from.y + jdy * 0.45 }; const jc2: Pt = { x: base.x, y: base.y - jdy * 0.45 }; junction = ribbonPath(from, jc1, jc2, base, wFrom, Math.min(wBase, jCap)); } // Roots: only the bottom of a tree, and only when that bottom is in frame. const roots: string[] = []; if (!parent && base.y > -canvas.h && base.y < canvas.h * 2) { const flare = Math.max(22, wBase * 1.4); for (const dir of [-1, -0.35, 0.35, 1]) { const endPt: Pt = { x: base.x + dir * flare, y: base.y + flare * 0.72 }; const rc1: Pt = { x: base.x + dir * flare * 0.2, y: base.y + flare * 0.34 }; const rc2: Pt = { x: base.x + dir * flare * 0.8, y: base.y + flare * 0.5 }; roots.push(ribbonPath(base, rc1, rc2, endPt, wBase * 0.42, 1.5)); } } const inView = visibleIds.has(id); const clampedStart = Math.max(start, viewStart); const clampedEnd = Math.min(end, viewEnd); const midYear = (clampedStart + clampedEnd) / 2; const labelY = Math.min( Math.max(sy(midYear), tip.y + MIN_LABEL_HEIGHT_PX / 2), base.y ); limbList.push({ id, name: node.movement.name, color, shade: shadeMovementColor(color), d, junction, tip: { x: tip.x, y: tip.y, r: Math.max(1.5, wTip / 2) }, roots, labelX: sx(limbXAtYear(node, midYear)), labelY, labelVisible: inView, yearRange: `${formatYear(start)} – ${formatYear(end)}`, depth: node.depth, inView, }); for (const graftId of node.graftParentIds) { const graftParent = tree.nodes.get(graftId); if (!graftParent || !drawn.has(graftId)) continue; const byDate = sy(branchOriginYear(graftParent, node)); const origin = pointOnLimb(graftId, Math.max(byDate, base.y + MIN_JUNCTION_RISE_PX)); const from = origin.pt; const gdy = base.y - from.y; const gWidth = Math.max(2.5, Math.min(wBase * 0.3, 9)); graftList.push({ key: `${graftId}-${id}`, d: ribbonPath( from, { x: from.x, y: from.y + gdy * 0.55 }, { x: base.x, y: base.y - gdy * 0.3 }, base, gWidth * 0.7, gWidth ), color: vividMovementColor(graftParent.movement.color), fromId: graftId, toId: id, }); } } hideOverlappingLabels(limbList, canvas.w, canvas.h); return { limbs: limbList, grafts: graftList }; }, [tree, viewStart, viewEnd, canvas.w, canvas.h, fitScale, visibleIds]); /** A hovered movement lights up its whole descent line back to the root. */ const lineageIds = useMemo(() => { const ids = new Set(); if (hoveredId == null) return ids; let cursor: number | null = hoveredId; let guard = 0; while (cursor != null && guard++ < 64) { ids.add(cursor); const node: MovementTreeNode | undefined = tree.nodes.get(cursor); if (!node) break; for (const graftId of node.graftParentIds) ids.add(graftId); cursor = node.parentId; } return ids; }, [hoveredId, tree]); 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; // Bottom = oldest, so invert the pointer offset before reusing the shared // left-to-right zoom math. const invertedY = rect.height - (e.clientY - rect.top); const next = zoomTimelineView( invertedY, 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 (movements.length === 0) { return (

No art movements to grow a tree from yet.

); } const anyInView = limbs.some((limb) => limb.inView); return (

{t('captionTreeFlow')}

{limbs.map((limb) => ( ))} {grafts.map((graft) => ( ))} {limbs.map((limb) => { const lit = lineageIds.has(limb.id); const dim = hoveredId != null && !lit; return ( setHoveredId(limb.id)} onMouseLeave={() => setHoveredId(null)} onClick={(e) => { e.stopPropagation(); onMovementClick?.(limb.id); }} > {`${limb.name} · ${limb.yearRange}`} {limb.roots.map((d, i) => ( ))} {limb.junction && ( )} ); })} {limbs .filter((limb) => limb.labelVisible) .map((limb) => ( ))} {!anyInView && (

No movements in this time range — zoom out to see the whole tree.

)}
); }