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 Khodjaef
2026-08-13 12:44:08 +03:00
co-authored by Cursor
parent 33b8ae5a5f
commit 44092d102b
10 changed files with 1313 additions and 23 deletions
+7 -3
View File
@@ -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)
+98
View File
@@ -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.
+1
View File
@@ -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 |
+125
View File
@@ -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;
}
+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>
);
}
+1
View File
@@ -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.",
+1
View File
@@ -21,6 +21,7 @@
"audit": "Активность",
"layoutHorizontal": "Классическая шкала →",
"layoutVertical": "↑ Вертикальная шкала",
"layoutTree": "🌳 Древо искусства",
"openingTourGallery": "Открытие экскурсии…",
"tourEmpty": "В этой экскурсии пока нет картин.",
"tourLoadFailed": "Не удалось загрузить экскурсию.",
+14
View File
@@ -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;
+94 -20
View File
@@ -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<View>({ type: 'timeline' });
const [view, setView] = useState<View>(readInitialTimelineView);
const [gallerySession, setGallerySession] = useState<GallerySession | null>(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() {
<div className="home-page">
<header className="site-header">
<div className="site-layout-switch">
{isVerticalTimeline ? (
{!isTreeTimeline && (
<button
type="button"
className="site-layout-link"
onClick={openHorizontalTimeline}
title="Switch to classic left-to-right timeline"
className="site-layout-link site-layout-link-feature"
onClick={openTreeTimeline}
title="Open the alternative start page: bottom-up timeline with movements drawn as a growing tree"
>
{t('layoutHorizontal')}
{t('layoutTree')}
</button>
) : (
)}
{!isVerticalTimeline && (
<button
type="button"
className="site-layout-link"
@@ -1241,6 +1293,16 @@ export default function HomePage() {
{t('layoutVertical')}
</button>
)}
{!(view.type === 'timeline') && (
<button
type="button"
className="site-layout-link"
onClick={openHorizontalTimeline}
title="Switch to classic left-to-right timeline"
>
{t('layoutHorizontal')}
</button>
)}
</div>
<div className="site-dev-tools">
{isCurator ? (
@@ -1369,7 +1431,7 @@ export default function HomePage() {
/>
</header>
<div className={`home-timeline-stack${isVerticalTimeline ? ' home-timeline-stack-vertical' : ''}`}>
<div className={`home-timeline-stack${isVerticalLayout ? ' home-timeline-stack-vertical' : ''}`}>
{loading && (
<GalleryLoadingMarker overlay message="Loading art history…" />
)}
@@ -1380,7 +1442,7 @@ export default function HomePage() {
<GalleryLoadingMarker banner message="Loading portraits…" />
)}
{isVerticalTimeline ? (
{isVerticalLayout ? (
<>
<VerticalTimeline
eras={timelineData.eras}
@@ -1394,15 +1456,27 @@ export default function HomePage() {
{error && <div className="error-banner">{error}</div>}
{!loading && (
<div className="home-movements-section-vertical">
<VerticalMovementBands
movements={timelineData.movements}
viewStart={viewStart}
viewEnd={viewEnd}
absoluteMin={bounds.min}
absoluteMax={bounds.max}
onViewChange={handleViewChange}
onMovementClick={handleMovementClick}
/>
{isTreeTimeline ? (
<MovementTree
movements={timelineData.movements}
viewStart={viewStart}
viewEnd={viewEnd}
absoluteMin={bounds.min}
absoluteMax={bounds.max}
onViewChange={handleViewChange}
onMovementClick={handleMovementClick}
/>
) : (
<VerticalMovementBands
movements={timelineData.movements}
viewStart={viewStart}
viewEnd={viewEnd}
absoluteMin={bounds.min}
absoluteMax={bounds.max}
onViewChange={handleViewChange}
onMovementClick={handleMovementClick}
/>
)}
</div>
)}
</>
+328
View File
@@ -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<number, MovementTreeNode>;
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<number, number[]> {
const nameToId = new Map(movements.map((m) => [m.name, m.id]));
const parents = new Map<number, number[]>();
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<number, number[]>
): Map<number, { parentId: number | null; grafts: number[] }> {
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<number, { parentId: number | null; grafts: number[] }>();
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<number, MovementTreeNode>();
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));
}