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:
co-authored by
Cursor
parent
33b8ae5a5f
commit
44092d102b
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user