Add Tree of Art start page with fixed lineage geometry and shareable layout links.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila KhodjaefandCursor committed 2026-08-13 12:44:08 +03:00
1 parent 33b8ae5a5f
commit 44092d102b
10 files changed
+1313 -23

No files matched your search

+644
View File
@@ -0,0 +1,644 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
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 canvasRef = useRef<HTMLDivElement>(null);
const [canvas, setCanvas] = useState({ w: 900, h: 600 });
const [panning, setPanning] = useState(false);
const [hoveredId, setHoveredId] = useState<number | null>(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<number | null>(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<number>();
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<number>();
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 (
<div className="mtree-empty">
<p>No art movements to grow a tree from yet.</p>
</div>
);
}
const anyInView = limbs.some((limb) => limb.inView);
return (
<div className="mtree">
<p className="mtree-caption">
Roots at the bottom, living movements at the crown · scroll to zoom · drag to
pan · click a branch to enter its gallery
</p>
<div
ref={canvasRef}
className={`mtree-canvas${panning ? ' mtree-panning' : ''}`}
onMouseDown={handlePanStart}
>
<svg
className="mtree-svg"
viewBox={`0 0 ${canvas.w} ${canvas.h}`}
preserveAspectRatio="none"
>
<defs>
{limbs.map((limb) => (
<linearGradient
key={`grad-${limb.id}`}
id={`mtree-limb-${limb.id}`}
gradientUnits="objectBoundingBox"
x1="0"
y1="0"
x2="1"
y2="0"
>
<stop offset="0%" stopColor={limb.shade} />
<stop offset="45%" stopColor={limb.color} />
<stop offset="100%" stopColor={limb.shade} />
</linearGradient>
))}
</defs>
<g className="mtree-grafts">
{grafts.map((graft) => (
<path
key={graft.key}
d={graft.d}
fill={graft.color}
className={`mtree-graft${
lineageIds.has(graft.toId) && lineageIds.has(graft.fromId)
? ' mtree-graft-lit'
: ''
}`}
/>
))}
</g>
{limbs.map((limb) => {
const lit = lineageIds.has(limb.id);
const dim = hoveredId != null && !lit;
return (
<g
key={limb.id}
className={`mtree-limb${lit ? ' mtree-limb-lit' : ''}${
dim ? ' mtree-limb-dim' : ''
}`}
onMouseEnter={() => setHoveredId(limb.id)}
onMouseLeave={() => setHoveredId(null)}
onClick={(e) => {
e.stopPropagation();
onMovementClick?.(limb.id);
}}
>
<title>{`${limb.name} · ${limb.yearRange}`}</title>
{limb.roots.map((d, i) => (
<path key={`root-${i}`} d={d} fill={limb.shade} className="mtree-root" />
))}
{limb.junction && (
<path d={limb.junction} fill={`url(#mtree-limb-${limb.id})`} />
)}
<path d={limb.d} fill={`url(#mtree-limb-${limb.id})`} />
<circle cx={limb.tip.x} cy={limb.tip.y} r={limb.tip.r} fill={limb.color} />
</g>
);
})}
</svg>
{limbs
.filter((limb) => limb.labelVisible)
.map((limb) => (
<button
key={`label-${limb.id}`}
type="button"
className={`mtree-label${
hoveredId != null && !lineageIds.has(limb.id) ? ' mtree-label-dim' : ''
}`}
style={{ left: `${limb.labelX}px`, top: `${limb.labelY}px` }}
title={`${limb.name} · ${limb.yearRange}`}
onMouseEnter={() => setHoveredId(limb.id)}
onMouseLeave={() => setHoveredId(null)}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onMovementClick?.(limb.id);
}}
>
{limb.name}
</button>
))}
{!anyInView && (
<div className="mtree-out-of-range">
<p>No movements in this time range — zoom out to see the whole tree.</p>
</div>
)}
</div>
</div>
);
}