/** * 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)); }