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:
1 parent
33b8ae5a5f
commit
44092d102b
10 files changed
+1313
-23
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user