646 lines
22 KiB
TypeScript
646 lines
22 KiB
TypeScript
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
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 { t } = useTranslation('home');
|
||
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">
|
||
{t('captionTreeFlow')}
|
||
</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>
|
||
);
|
||
}
|