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
+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));
}