diff --git a/Documentation/basics.md b/Documentation/basics.md index ddd20df..a3db093 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -6,8 +6,8 @@ Interactive virtual museum spanning art history: zoomable timeline with event gu The app is organised as a **drill-down hierarchy**: -1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries; classic left→right or vertical bottom→up layout (header link). -2. **Movement flow** — art movements as SVG streams on the same year axis; documented predecessor→successor branches (classic); portrait thumbnails along each stream. +1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries; classic left→right, vertical bottom→up, or **tree** bottom→up layout (header links). +2. **Movement flow** — art movements as SVG streams on the same year axis; documented predecessor→successor branches (classic); portrait thumbnails along each stream. The tree layout redraws the same lineage as a growing tree — see [movement-tree.md](movement-tree.md). 3. **3D gallery** — one personal hall per artist, a **movement gallery** (click a movement name → artist filter → hall), or a **guided tour** hall (timeline → **Tours**): period-themed or tour wings of up to ~55 works, U-shaped hang (left → end wall → right). 4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **curator notes** and **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography. 5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**). @@ -49,6 +49,7 @@ Gallery/ │ │ ├── components/VerticalTimeline.tsx # Bottom-up vertical era rail │ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches) │ │ ├── components/VerticalMovementBands.tsx # Bottom-up movement streams +│ │ ├── components/MovementTree.tsx # Bottom-up movement tree (alternative start page) │ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow │ │ ├── components/CatalogSearchBar.tsx # Timeline header catalog search │ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail @@ -179,13 +180,16 @@ The home page shows two linked views over the **same year window** (`viewStart` |--------|----------|---------------|----------------| | **Classic** (default) | `Timeline.tsx` (top bar) | `MovementBands.tsx` | Left → right | | **Vertical** | `VerticalTimeline.tsx` (left rail) | `VerticalMovementBands.tsx` (streams + lineage branches; no portraits) | Bottom → top | +| **Tree of art** | `VerticalTimeline.tsx` (left rail) | `MovementTree.tsx` (lineage as a growing tree; no portraits) | Bottom → top | + +Deep-link: `?layout=tree` (or `vertical` / `classic`) is read on load and updated when switching layouts. | View | Component | Purpose | |------|-----------|---------| | Era bar / rail | `Timeline.tsx` / `VerticalTimeline.tsx` | Historical eras, major event markers, click-to-zoom | | Movement flow | `MovementBands.tsx` / `VerticalMovementBands.tsx` | Streams per movement, artist portraits (classic also draws lineage branches) | -Both layouts share zoom/pan behaviour via `client/src/utils/timelineView.ts` (`zoomTimelineView`, `panTimelineView`, `chooseTimelineTickInterval`, `createViewChangeScheduler`). The home page uses a **fixed viewport** (`100vh`). Classic stacks timeline above movements; vertical places the year rail beside the flow (`home-timeline-stack-vertical`). +All three layouts share zoom/pan behaviour via `client/src/utils/timelineView.ts` (`zoomTimelineView`, `panTimelineView`, `chooseTimelineTickInterval`, `createViewChangeScheduler`). The home page uses a **fixed viewport** (`100vh`). Classic stacks timeline above movements; vertical and tree place the year rail beside the flow (`home-timeline-stack-vertical`). The tree layout keeps its horizontal geometry fixed across zoom — rules in [movement-tree.md](movement-tree.md). ### Catalog search (timeline header) diff --git a/Documentation/movement-tree.md b/Documentation/movement-tree.md new file mode 100644 index 0000000..3071fee --- /dev/null +++ b/Documentation/movement-tree.md @@ -0,0 +1,98 @@ +# Tree of Art — alternative start page + +An alternative landing layout for the timeline: the year axis runs **bottom → top** +and the art movements are drawn as a **growing tree** instead of parallel streams. +Reached from the **🌳 Tree of art** link at the top-left of every timeline page +(`layoutTree`); the classic and vertical layouts stay untouched and are one click away. +Shareable URL: `?layout=tree` (also `vertical` / omit or `classic` for the other layouts). + +| | Classic | Vertical | **Tree** | +|---|---|---|---| +| Component | [`MovementBands`](../client/src/components/MovementBands.tsx) | [`VerticalMovementBands`](../client/src/components/VerticalMovementBands.tsx) | [`MovementTree`](../client/src/components/MovementTree.tsx) | +| Time axis | left → right | bottom → top | bottom → top | +| Layout | temporal lanes | centre-out lanes | spanning tree | +| Recomputed on zoom | yes | yes | **no — structure is fixed** | +| Artist portraits | yes | no | no | + +Both bottom-up layouts share the [`VerticalTimeline`](../client/src/components/VerticalTimeline.tsx) +axis, so eras, event marks, zoom and pan behave identically across them. + +## Layout rules + +The geometry splits in two: [`utils/movementTree.ts`](../client/src/utils/movementTree.ts) +decides the **shape of the tree** (horizontal, view-independent), and `MovementTree` +maps that shape onto the **current year window** (vertical) each frame. + +### Structure — `buildMovementTree()` + +1. **Time grows upward.** Oldest movements at the bottom, newest at the crown. + Y is a plain `year → pixel` mapping; the layout engine never touches it. +2. **One trunk, at the centre.** `MOVEMENT_LINEAGE` is a DAG, so it is reduced to a + spanning tree: each movement keeps its **most immediate predecessor** (the parent + with the latest start year that still precedes it) as its structural parent. + Ranking by start year first makes cycles impossible by construction. +3. **Extra parents become grafts.** The predecessors that lost step 2 are still drawn — + as thin, low-opacity limbs behind the tree — so `Post-Impressionism → Cubism` + survives even though Cubism hangs structurally off Fauvism. +4. **Children split the parent's slot.** Each node reserves a slot as wide as its whole + subtree (`max(own limb, Σ children)`); children are packed side by side and centred + on the parent. A single-child chain inherits the parent's x exactly — the trunk stays + straight until it forks, forks spread symmetrically, and later generations land + further from the centre. +5. **Leonardo's rule for thickness.** A limb is as thick as the limbs it carries: + `base² = own² + Σ child.base²`. The trunk is the thickest thing on screen and every + branch tapers as it rises and sheds children. A movement's *own* thickness comes from + its `influence_link_count`. +6. **Branches lean outward** across their own lifespan, by at most the slack left inside + their slot — organic, and collision-free by construction. +7. **Unlinked movements are saplings.** A movement with no lineage edge is its own root; + extra roots are planted alternately right and left of the trunk, widest subtree first, + so the main trunk keeps x = 0 (canvas centre). Roots get a small root flare. + +Because the structure is built from the **whole catalogue**, zooming never reshuffles the +tree — you keep your bearings, unlike the lane-packed layouts which re-pack on every view +change. + +### Rendering — `MovementTree` + +8. **Spread follows zoom, not 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. The whole-history view draws the tree at + `FULL_VIEW_WIDTH_SHARE` (52 %) of the available width, and each zoom step fans the + crown out (`(totalSpan / visibleSpan) ^ 0.45`, capped at `MAX_FIT_BOOST`). The chart + grows as you walk up it. Whatever is on screen is always clamped to fit the canvas. +9. **Readability floors, never date changes.** A 30-year movement is ~8 px tall with 2 900 + years in view. So a limb is drawn at least `MIN_LIMB_RISE_PX` long, a junction climbs at + least `MIN_JUNCTION_RISE_PX` before it spreads sideways, and **a limb is never thicker + than 55 % of its own length**. Positions still come from real years; only the drawn + length and thickness have a floor, and the junction slides down the parent limb (never + off it) to find its rise. +10. **Ribbons, not strokes.** Limbs are filled ribbons sampled along a cubic and offset + along the curve *normal*, so a junction stays solid even when a zoomed-out view + squeezes it almost flat. Shading runs dark → colour → dark across each limb for a + rounded, woody read. +11. **Greedy label declutter.** Every visible movement asks for a name; closest to the + trunk wins, and names that would collide with a placed one — or fall off the canvas — + stay hidden until you zoom in on them. +12. **Hover lights the descent line.** Hovering a movement brightens its whole path back + to the root (grafts included) and dims the rest — the fastest way to read "where did + this come from". + +Clicking any limb or label opens the movement's artist picker and then its 3D movement +gallery, exactly as the other two layouts do. + +## Tuning + +All constants sit at the top of the two files and are safe to tune: + +| Constant | File | Effect | +|---|---|---| +| `LEAF_SLOT_PX`, `LIMB_GAP_PX` | `movementTree.ts` | how far apart branches sit | +| `MIN_LIMB_PX`, `MAX_OWN_LIMB_PX`, `MAX_TRUNK_PX` | `movementTree.ts` | thickness range | +| `LEAN_SLACK`, `MAX_LEAN_PX` | `movementTree.ts` | how much limbs bend outward | +| `FULL_VIEW_WIDTH_SHARE`, `ZOOM_SPREAD_EXPONENT`, `MAX_FIT_BOOST` | `MovementTree.tsx` | crown spread vs. zoom | +| `MIN_LIMB_RISE_PX`, `MIN_JUNCTION_RISE_PX`, `MAX_THICKNESS_OF_LENGTH` | `MovementTree.tsx` | crown legibility at full zoom-out | + +New lineage edges only need adding to +[`client/src/data/movement-lineage.ts`](../client/src/data/movement-lineage.ts) — the tree +picks up parents, grafts, thickness and spacing from there automatically. diff --git a/README.md b/README.md index bfb5c51..b0ced12 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Interactive virtual art gallery: zoomable historical timeline with era click-to- | Document | Purpose | |----------|---------| | [Documentation/basics.md](Documentation/basics.md) | Architecture, layout, user flow | +| [Documentation/movement-tree.md](Documentation/movement-tree.md) | **Tree of Art** start page — tree layout rules | | [Documentation/FAC.md](Documentation/FAC.md) | **Command cheat sheet** — start/stop, import, deploy | | [Documentation/environments.md](Documentation/environments.md) | Dev/prod URLs, DB split, deploy, sync | | [Documentation/setup.md](Documentation/setup.md) | Install, env, npm scripts | diff --git a/client/src/components/MovementTree.css b/client/src/components/MovementTree.css new file mode 100644 index 0000000..950d335 --- /dev/null +++ b/client/src/components/MovementTree.css @@ -0,0 +1,125 @@ +.mtree { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding: 8px 12px 12px; +} + +.mtree-caption { + margin: 0 0 8px; + text-align: center; + font-family: 'Georgia', serif; + font-size: 13px; + font-style: italic; + color: rgba(201, 169, 110, 0.65); + flex-shrink: 0; +} + +.mtree-empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 48px; + text-align: center; + color: rgba(201, 169, 110, 0.6); + font-family: 'Georgia', serif; +} + +.mtree-canvas { + position: relative; + flex: 1; + min-height: 0; + width: 100%; + border-radius: 8px; + background: + radial-gradient(ellipse 60% 45% at 50% 100%, rgba(201, 169, 110, 0.1), transparent 72%), + radial-gradient(ellipse 80% 60% at 50% 0%, rgba(120, 150, 190, 0.08), transparent 70%), + rgba(0, 0, 0, 0.28); + border: 1px solid rgba(201, 169, 110, 0.12); + overflow: hidden; + cursor: grab; + touch-action: none; +} + +.mtree-canvas.mtree-panning { + cursor: grabbing; +} + +.mtree-svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 1; +} + +.mtree-limb { + cursor: pointer; + opacity: 0.9; + transition: opacity 0.18s ease, filter 0.18s ease; +} + +.mtree-limb-lit { + opacity: 1; + filter: brightness(1.22) drop-shadow(0 0 6px rgba(255, 226, 170, 0.35)); +} + +.mtree-limb-dim { + opacity: 0.34; +} + +.mtree-root { + opacity: 0.72; +} + +.mtree-graft { + opacity: 0.3; + pointer-events: none; + transition: opacity 0.18s ease; +} + +.mtree-graft-lit { + opacity: 0.85; +} + +.mtree-label { + position: absolute; + z-index: 4; + transform: translate(-50%, -50%); + margin: 0; + padding: 2px 8px; + border: none; + border-radius: 4px; + background: rgba(12, 12, 22, 0.74); + color: rgba(245, 230, 200, 0.95); + font-family: 'Georgia', serif; + font-size: 12px; + white-space: nowrap; + cursor: pointer; + pointer-events: auto; + transition: opacity 0.18s ease; +} + +.mtree-label:hover { + background: rgba(30, 28, 40, 0.92); + color: #fff6e0; +} + +.mtree-label-dim { + opacity: 0.35; +} + +.mtree-out-of-range { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + text-align: center; + color: rgba(201, 169, 110, 0.7); + font-family: 'Georgia', serif; +} diff --git a/client/src/components/MovementTree.tsx b/client/src/components/MovementTree.tsx new file mode 100644 index 0000000..d63e611 --- /dev/null +++ b/client/src/components/MovementTree.tsx @@ -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(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 ( +
+

+ Roots at the bottom, living movements at the crown · scroll to zoom · drag to + pan · click a branch to enter its gallery +

+
+ + + {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.

+
+ )} +
+
+ ); +} diff --git a/client/src/locales/en/home.json b/client/src/locales/en/home.json index 34f346a..d3df91d 100644 --- a/client/src/locales/en/home.json +++ b/client/src/locales/en/home.json @@ -21,6 +21,7 @@ "audit": "Activity", "layoutHorizontal": "Classic timeline →", "layoutVertical": "↑ Vertical timeline", + "layoutTree": "🌳 Tree of art", "openingTourGallery": "Opening guided tour…", "tourEmpty": "This tour has no paintings yet.", "tourLoadFailed": "Failed to load the tour.", diff --git a/client/src/locales/ru/home.json b/client/src/locales/ru/home.json index 37e0267..c58a596 100644 --- a/client/src/locales/ru/home.json +++ b/client/src/locales/ru/home.json @@ -21,6 +21,7 @@ "audit": "Активность", "layoutHorizontal": "Классическая шкала →", "layoutVertical": "↑ Вертикальная шкала", + "layoutTree": "🌳 Древо искусства", "openingTourGallery": "Открытие экскурсии…", "tourEmpty": "В этой экскурсии пока нет картин.", "tourLoadFailed": "Не удалось загрузить экскурсию.", diff --git a/client/src/pages/HomePage.css b/client/src/pages/HomePage.css index 3bf901e..2dcec01 100644 --- a/client/src/pages/HomePage.css +++ b/client/src/pages/HomePage.css @@ -44,6 +44,9 @@ top: 16px; left: 16px; z-index: 120; + display: flex; + gap: 8px; + align-items: center; } .site-layout-link { @@ -63,6 +66,17 @@ color: #f5e6c8; } +/* Link to the alternative tree start page — the one worth noticing. */ +.site-layout-link-feature { + border-color: rgba(201, 169, 110, 0.7); + background: rgba(201, 169, 110, 0.16); + color: #f5e6c8; +} + +.site-layout-link-feature:hover { + background: rgba(201, 169, 110, 0.3); +} + .gallery-session-suspended { position: fixed; inset: 0; diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index 72bc00d..bc433c1 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -5,6 +5,7 @@ import TimelineEventGuides from '../components/TimelineEventGuides'; import MovementBands from '../components/MovementBands'; import VerticalTimeline from '../components/VerticalTimeline'; import VerticalMovementBands from '../components/VerticalMovementBands'; +import MovementTree from '../components/MovementTree'; import VirtualGallery from '../components/VirtualGallery'; import PaintingDetailView from '../components/PaintingDetail'; import ArtistBio from '../components/ArtistBio'; @@ -50,6 +51,7 @@ import './HomePage.css'; type View = | { type: 'timeline' } | { type: 'timeline-vertical' } + | { type: 'timeline-tree' } | { type: 'checkup' } | { type: 'translations' } | { type: 'influences' } @@ -157,6 +159,37 @@ function catalogNavigateTarget( return idx < remaining.length ? remaining[idx].id : remaining[remaining.length - 1].id; } +/** Shareable timeline layout via `?layout=classic|vertical|tree` (classic may omit the param). */ +type TimelineLayoutId = 'classic' | 'vertical' | 'tree'; + +function parseTimelineLayoutParam(raw: string | null): TimelineLayoutId { + if (raw === 'vertical' || raw === 'tree') return raw; + if (raw === 'classic' || raw === 'horizontal') return 'classic'; + return 'classic'; +} + +function timelineViewFromLayout(layout: TimelineLayoutId): View { + if (layout === 'vertical') return { type: 'timeline-vertical' }; + if (layout === 'tree') return { type: 'timeline-tree' }; + return { type: 'timeline' }; +} + +function writeTimelineLayoutParam(layout: TimelineLayoutId) { + const url = new URL(window.location.href); + if (layout === 'classic') url.searchParams.delete('layout'); + else url.searchParams.set('layout', layout); + const next = `${url.pathname}${url.search}${url.hash}`; + const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (next !== current) window.history.replaceState(null, '', next); +} + +function readInitialTimelineView(): View { + if (typeof window === 'undefined') return { type: 'timeline' }; + return timelineViewFromLayout( + parseTimelineLayoutParam(new URLSearchParams(window.location.search).get('layout')) + ); +} + export default function HomePage() { const { t } = useTranslation('home'); const { isCurator, isAdmin, username, login, logout, can } = useAuth(); @@ -167,7 +200,7 @@ export default function HomePage() { const canInfluences = can('influences'); const canTours = can('tours'); const canUsers = can('users'); - const [view, setView] = useState({ type: 'timeline' }); + const [view, setView] = useState(readInitialTimelineView); const [gallerySession, setGallerySession] = useState(null); const [bounds, setBounds] = useState({ min: -800, max: 2025 }); const [viewStart, setViewStart] = useState(-800); @@ -210,7 +243,11 @@ export default function HomePage() { setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data }); } else if (view.type === 'tour-gallery') { setGallerySession({ kind: 'tour', tourId: view.tourId, data: view.data }); - } else if (view.type === 'timeline' || view.type === 'timeline-vertical') { + } else if ( + view.type === 'timeline' || + view.type === 'timeline-vertical' || + view.type === 'timeline-tree' + ) { setGallerySession(null); } }, [view]); @@ -271,19 +308,33 @@ export default function HomePage() { setViewStart(bounds.min); setViewEnd(bounds.max); setGalleryRevision((revision) => revision + 1); + writeTimelineLayoutParam('classic'); setView({ type: 'timeline' }); }, [bounds.min, bounds.max]); const openHorizontalTimeline = () => { + writeTimelineLayoutParam('classic'); setView({ type: 'timeline' }); }; const openVerticalTimeline = () => { + writeTimelineLayoutParam('vertical'); setView({ type: 'timeline-vertical' }); }; - const isTimelineHome = view.type === 'timeline' || view.type === 'timeline-vertical'; + const openTreeTimeline = () => { + writeTimelineLayoutParam('tree'); + setView({ type: 'timeline-tree' }); + }; + + const isTimelineHome = + view.type === 'timeline' || + view.type === 'timeline-vertical' || + view.type === 'timeline-tree'; const isVerticalTimeline = view.type === 'timeline-vertical'; + const isTreeTimeline = view.type === 'timeline-tree'; + /** Both alternative layouts run the year axis bottom → top beside the chart. */ + const isVerticalLayout = isVerticalTimeline || isTreeTimeline; const toggleDebugMode = () => { setDebugMode((prev) => { @@ -1222,16 +1273,17 @@ export default function HomePage() {
- {isVerticalTimeline ? ( + {!isTreeTimeline && ( - ) : ( + )} + {!isVerticalTimeline && ( + )}
{isCurator ? ( @@ -1369,7 +1431,7 @@ export default function HomePage() { />
-
+
{loading && ( )} @@ -1380,7 +1442,7 @@ export default function HomePage() { )} - {isVerticalTimeline ? ( + {isVerticalLayout ? ( <> {error}
} {!loading && (
- + {isTreeTimeline ? ( + + ) : ( + + )}
)} diff --git a/client/src/utils/movementTree.ts b/client/src/utils/movementTree.ts new file mode 100644 index 0000000..08dc8cd --- /dev/null +++ b/client/src/utils/movementTree.ts @@ -0,0 +1,328 @@ +/** + * Tree layout rules for the "Tree of Art" start page. + * + * The classic flow chart packs movements into lanes and lets the lanes drift as + * you pan. A tree needs the opposite: a shape you can recognise again after a + * zoom. So the horizontal geometry here is computed **once from the whole + * catalogue** and never depends on the visible year window — only the vertical + * (time) axis reacts to pan/zoom. + * + * Rules + * ----- + * 1. **Time grows upward.** The oldest movements sit at the bottom, the newest + * at the top. Y is purely `year → pixel`; this file never computes it. + * 2. **One trunk, at the centre.** `MOVEMENT_LINEAGE` is a DAG, so it is first + * reduced to a spanning tree: each movement keeps its *most immediate + * predecessor* (the parent with the latest start year that still precedes + * it) as its structural parent. Remaining parents survive as **grafts** — + * thin secondary limbs the renderer draws behind the tree. + * 3. **Children split the parent's slot.** Every node reserves a horizontal + * slot as wide as its whole subtree (`max(own limb, Σ children)`), and its + * children are packed side by side and centred on the parent. A single-child + * chain therefore inherits the parent's x exactly — the trunk stays straight + * until it actually forks, and every fork spreads symmetrically, so later + * generations end up further from the centre. + * 4. **Leonardo's rule for thickness.** A limb is as thick as the limbs it + * carries: `base² = own² + Σ child.base²`. The trunk at the bottom is the + * thickest thing on screen and every branch tapers as it rises and sheds + * children. A movement's *own* thickness comes from its influence-link count. + * 5. **Branches lean outward.** A limb drifts sideways across its own lifespan, + * away from its parent, by at most the slack left inside its slot — so limbs + * look grown rather than extruded, and can never collide with a sibling. + * 6. **Unlinked movements are saplings.** A movement with no lineage edge is its + * own root; extra roots are planted alternately right and left of the trunk, + * widest subtree first, so the main trunk keeps x = 0 (canvas centre). + */ +import type { ArtMovement } from '../types'; +import { MOVEMENT_LINEAGE } from '../data/movement-lineage'; + +/** All values are tree-space pixels; the renderer scales them to the canvas. */ +export const TREE_LAYOUT = { + /** Horizontal room a childless limb claims. */ + LEAF_SLOT_PX: 104, + /** Clear space kept around a limb inside its own slot. */ + LIMB_GAP_PX: 34, + /** Thinnest a limb may be drawn. */ + MIN_LIMB_PX: 13, + /** Thickest a limb can get from its own influence count alone. */ + MAX_OWN_LIMB_PX: 36, + /** Ceiling for the accumulated (Leonardo) thickness of the trunk. */ + MAX_TRUNK_PX: 96, + /** How much of the slack inside a slot a limb may lean into. */ + LEAN_SLACK: 0.55, + MAX_LEAN_PX: 28, + /** A limb ends its life this much thinner than it started it. */ + TIP_TAPER: 0.66, +} as const; + +export interface MovementTreeNode { + movement: ArtMovement; + /** Structural parent in the spanning tree (`null` for roots). */ + parentId: number | null; + /** Documented predecessors that lost to the structural parent. */ + graftParentIds: number[]; + childIds: number[]; + depth: number; + descendants: number; + /** Thickness the movement earns on its own (influence links). */ + ownWidth: number; + /** Thickness where the limb leaves its parent — carries every descendant. */ + baseWidth: number; + /** Thickness where the limb ends. */ + tipWidth: number; + /** Horizontal slot reserved for this node and everything under it. */ + subtreeWidth: number; + /** Tree-space x of the limb base. The main trunk sits at 0. */ + x: number; + /** Lateral drift from base to tip, px (signed). */ + lean: number; + side: -1 | 0 | 1; +} + +export interface MovementTree { + nodes: Map; + rootIds: number[]; + /** Ids ordered thickest-first, so thin branches paint over the trunk. */ + drawOrder: number[]; + /** Half the horizontal extent actually occupied, px (>= 1). */ + halfSpan: number; +} + +/** Stable per-id value in [-1, 1] — organic drift without randomness. */ +function idDrift(id: number): number { + const n = Math.sin(id * 12.9898) * 43758.5453; + return (n - Math.floor(n)) * 2 - 1; +} + +function influenceCount(m: ArtMovement): number { + const n = m.influence_link_count; + return typeof n === 'number' && Number.isFinite(n) ? Math.max(0, n) : 0; +} + +/** child id → documented parent ids, restricted to movements in the catalogue. */ +function buildParentMap(movements: ArtMovement[]): Map { + const nameToId = new Map(movements.map((m) => [m.name, m.id])); + const parents = new Map(); + for (const [parentName, childName] of MOVEMENT_LINEAGE) { + const parentId = nameToId.get(parentName); + const childId = nameToId.get(childName); + if (parentId == null || childId == null || parentId === childId) continue; + const list = parents.get(childId) ?? []; + if (!list.includes(parentId)) list.push(parentId); + parents.set(childId, list); + } + return parents; +} + +/** + * Reduce the lineage DAG to a spanning tree. Ranking every movement by start + * year first means a parent is always strictly earlier in the ranking than its + * child, so the result cannot contain a cycle. + */ +function chooseStructuralParents( + movements: ArtMovement[], + parentMap: Map +): Map { + const ranked = [...movements].sort( + (a, b) => a.start_year - b.start_year || a.id - b.id + ); + const rank = new Map(ranked.map((m, index) => [m.id, index])); + + const chosen = new Map(); + for (const m of movements) { + const candidates = (parentMap.get(m.id) ?? []).filter( + (pid) => (rank.get(pid) ?? Infinity) < (rank.get(m.id) ?? -Infinity) + ); + if (candidates.length === 0) { + chosen.set(m.id, { parentId: null, grafts: [] }); + continue; + } + // Most immediate predecessor carries the branch; older ones become grafts. + const sorted = [...candidates].sort( + (a, b) => (rank.get(b) ?? 0) - (rank.get(a) ?? 0) + ); + chosen.set(m.id, { parentId: sorted[0], grafts: sorted.slice(1) }); + } + return chosen; +} + +export function buildMovementTree(movements: ArtMovement[]): MovementTree { + const nodes = new Map(); + if (movements.length === 0) { + return { nodes, rootIds: [], drawOrder: [], halfSpan: 1 }; + } + + const parentMap = buildParentMap(movements); + const structure = chooseStructuralParents(movements, parentMap); + const maxInfluence = Math.max(0, ...movements.map(influenceCount)); + + for (const movement of movements) { + const { parentId, grafts } = structure.get(movement.id) ?? { + parentId: null, + grafts: [], + }; + const ownWidth = + maxInfluence > 0 + ? TREE_LAYOUT.MIN_LIMB_PX + + (influenceCount(movement) / maxInfluence) * + (TREE_LAYOUT.MAX_OWN_LIMB_PX - TREE_LAYOUT.MIN_LIMB_PX) + : (TREE_LAYOUT.MIN_LIMB_PX + TREE_LAYOUT.MAX_OWN_LIMB_PX) / 2; + + nodes.set(movement.id, { + movement, + parentId, + graftParentIds: grafts, + childIds: [], + depth: 0, + descendants: 0, + ownWidth, + baseWidth: ownWidth, + tipWidth: Math.max(TREE_LAYOUT.MIN_LIMB_PX * 0.6, ownWidth * TREE_LAYOUT.TIP_TAPER), + subtreeWidth: TREE_LAYOUT.LEAF_SLOT_PX, + x: 0, + lean: 0, + side: 0, + }); + } + + const rootIds: number[] = []; + for (const node of nodes.values()) { + if (node.parentId != null && nodes.has(node.parentId)) { + nodes.get(node.parentId)!.childIds.push(node.movement.id); + } else { + node.parentId = null; + rootIds.push(node.movement.id); + } + } + + for (const node of nodes.values()) { + node.childIds.sort((a, b) => { + const ma = nodes.get(a)!.movement; + const mb = nodes.get(b)!.movement; + return ma.start_year - mb.start_year || ma.name.localeCompare(mb.name); + }); + node.graftParentIds = node.graftParentIds.filter((id) => nodes.has(id)); + } + + // Post-order: depth, descendant count, Leonardo thickness, slot width. + const measure = (id: number, depth: number): void => { + const node = nodes.get(id)!; + node.depth = depth; + let descendants = 0; + let childrenWidth = 0; + let carried = node.ownWidth * node.ownWidth; + for (const childId of node.childIds) { + measure(childId, depth + 1); + const child = nodes.get(childId)!; + descendants += 1 + child.descendants; + childrenWidth += child.subtreeWidth; + carried += child.baseWidth * child.baseWidth; + } + node.descendants = descendants; + node.baseWidth = Math.min(TREE_LAYOUT.MAX_TRUNK_PX, Math.sqrt(carried)); + node.subtreeWidth = Math.max( + node.baseWidth + TREE_LAYOUT.LIMB_GAP_PX, + node.childIds.length === 0 ? TREE_LAYOUT.LEAF_SLOT_PX : childrenWidth + ); + }; + for (const id of rootIds) measure(id, 0); + + // Widest tree takes the centre; the rest are planted alternately right / left. + rootIds.sort((a, b) => { + const na = nodes.get(a)!; + const nb = nodes.get(b)!; + return ( + nb.descendants - na.descendants || + na.movement.start_year - nb.movement.start_year || + na.movement.name.localeCompare(nb.movement.name) + ); + }); + + const place = (id: number, x: number): void => { + const node = nodes.get(id)!; + node.x = x; + const total = node.childIds.reduce((sum, cid) => sum + nodes.get(cid)!.subtreeWidth, 0); + let cursor = x - total / 2; + for (const childId of node.childIds) { + const child = nodes.get(childId)!; + place(childId, cursor + child.subtreeWidth / 2); + cursor += child.subtreeWidth; + } + }; + + if (rootIds.length > 0) { + const trunk = nodes.get(rootIds[0])!; + place(rootIds[0], 0); + let rightEdge = trunk.subtreeWidth / 2; + let leftEdge = -trunk.subtreeWidth / 2; + rootIds.slice(1).forEach((id, index) => { + const node = nodes.get(id)!; + if (index % 2 === 0) { + place(id, rightEdge + node.subtreeWidth / 2); + rightEdge += node.subtreeWidth; + } else { + place(id, leftEdge - node.subtreeWidth / 2); + leftEdge -= node.subtreeWidth; + } + }); + } + + // Lean: outward from the parent, capped by the slack left inside the slot. + for (const node of nodes.values()) { + const parent = node.parentId != null ? nodes.get(node.parentId) : null; + const slack = Math.max(0, (node.subtreeWidth - node.baseWidth) / 2); + const side = parent ? (Math.sign(node.x - parent.x) as -1 | 0 | 1) : 0; + node.side = side; + node.lean = + side !== 0 + ? side * Math.min(TREE_LAYOUT.MAX_LEAN_PX, slack * TREE_LAYOUT.LEAN_SLACK) + : idDrift(node.movement.id) * Math.min(9, slack * 0.2); + } + + let halfSpan = 1; + for (const node of nodes.values()) { + halfSpan = Math.max( + halfSpan, + Math.abs(node.x) + Math.abs(node.lean) + node.baseWidth / 2 + ); + } + + const drawOrder = [...nodes.keys()].sort((a, b) => { + const na = nodes.get(a)!; + const nb = nodes.get(b)!; + return nb.baseWidth - na.baseWidth || na.depth - nb.depth; + }); + + return { nodes, rootIds, drawOrder, halfSpan }; +} + +/** Fraction of a movement's lifespan elapsed at `year`, clamped to [0, 1]. */ +export function lifeProgress(node: MovementTreeNode, year: number): number { + const { start_year: start, end_year: end } = node.movement; + if (end <= start) return 0; + return Math.min(1, Math.max(0, (year - start) / (end - start))); +} + +/** Tree-space x of a limb's centreline at `year` (accounts for the lean). */ +export function limbXAtYear(node: MovementTreeNode, year: number): number { + return node.x + node.lean * lifeProgress(node, year); +} + +/** Limb thickness at `year`, tapering from base to tip. */ +export function limbWidthAtYear(node: MovementTreeNode, year: number): number { + const t = lifeProgress(node, year); + return node.baseWidth + (node.tipWidth - node.baseWidth) * t; +} + +/** + * Year at which a child limb leaves its parent. Branches split a little before + * the successor movement is dated, which is both how lineage works and what + * keeps the junction from looking like a right angle. + */ +export function branchOriginYear(parent: MovementTreeNode, child: MovementTreeNode): number { + const childStart = child.movement.start_year; + const parentStart = parent.movement.start_year; + const parentEnd = parent.movement.end_year; + const lead = Math.min(60, Math.max(6, (childStart - parentStart) * 0.22)); + return Math.min(parentEnd, Math.max(parentStart, childStart - lead)); +}