329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
/**
|
|
* 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));
|
|
}
|