Add vertical bottom-up timeline with center-out streams and smooth layout transitions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-08-11 21:31:30 +03:00
co-authored by Cursor
parent 4088d7d57b
commit 971c1e8dd8
9 changed files with 1695 additions and 41 deletions
@@ -0,0 +1,791 @@
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
import type { ArtMovement } from '../types';
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
import './VerticalMovementBands.css';
interface Props {
movements: ArtMovement[];
viewStart: number;
viewEnd: number;
absoluteMin: number;
absoluteMax: number;
onViewChange: (start: number, end: number) => void;
onMovementClick?: (movementId: number) => void;
}
interface VLayout {
movement: ArtMovement;
/** Year span as % from bottom (earlier = lower). */
yStart: number;
yEnd: number;
/** Lane center as % from left. */
x: number;
strokePx: number;
displayColor: string;
parentIds: number[];
}
interface VBranch {
key: string;
d: string;
colorFrom: string;
colorTo: string;
strokePx: number;
fromId: number;
toId: number;
x1: number;
y1: number;
x2: number;
y2: number;
}
const MAX_STROKE = 108; // ~300% of prior max (36)
const MIN_STROKE = 54; // ~300% of prior min (18)
const SIDE_PAD = 24;
/** Preferred centre-to-centre spacing: stroke + small gap (keeps columns tight). */
const PREFERRED_LANE_PITCH = MAX_STROKE + 16;
const LANE_MIN_GAP_YEARS = 2;
function yearToBottomPercent(year: number, start: number, end: number): number {
return ((year - start) / (end - start)) * 100;
}
function parseHexColor(hex: string): [number, number, number] {
const normalized = hex.replace('#', '');
const value =
normalized.length === 3
? normalized
.split('')
.map((c) => c + c)
.join('')
: normalized;
const n = parseInt(value, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return [0, 0, l];
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h = 0;
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
return [h * 360, s, l];
}
function hslToHex(h: number, s: number, l: number): string {
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r = 0;
let g = 0;
let b = 0;
if (h < 60) [r, g, b] = [c, x, 0];
else if (h < 120) [r, g, b] = [x, c, 0];
else if (h < 180) [r, g, b] = [0, c, x];
else if (h < 240) [r, g, b] = [0, x, c];
else if (h < 300) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];
const toByte = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
}
function vividMovementColor(hex: string): string {
try {
const [r, g, b] = parseHexColor(hex);
const [h, s, l] = rgbToHsl(r, g, b);
const s2 = s < 0.1 ? Math.min(0.55, s + 0.42) : Math.min(1, s * 1.65 + 0.08);
const l2 =
l < 0.22 ? 0.5 : l > 0.78 ? 0.62 : Math.min(0.68, Math.max(0.4, l * 0.75 + 0.28));
return hslToHex(h, s2, l2);
} catch {
return hex;
}
}
function buildLineageParentMap(
visible: ArtMovement[],
nameToId: Map<string, number>
): Map<number, number[]> {
const parents = new Map<number, number[]>();
const visibleIds = new Set(visible.map((m) => m.id));
for (const [parentName, childName] of MOVEMENT_LINEAGE) {
const parentId = nameToId.get(parentName);
const childId = nameToId.get(childName);
if (parentId == null || childId == null) continue;
if (!visibleIds.has(parentId) || !visibleIds.has(childId)) continue;
const list = parents.get(childId) || [];
if (!list.includes(parentId)) list.push(parentId);
parents.set(childId, list);
}
return parents;
}
function assignDepths(
group: ArtMovement[],
lineageParents: Map<number, number[]>
): Map<number, number> {
const depths = new Map<number, number>();
const visiting = new Set<number>();
const visit = (id: number): number => {
if (depths.has(id)) return depths.get(id)!;
if (visiting.has(id)) return 0;
visiting.add(id);
const parents = lineageParents.get(id) || [];
const d = parents.length ? 1 + Math.max(...parents.map(visit)) : 0;
visiting.delete(id);
depths.set(id, d);
return d;
};
for (const m of group) visit(m.id);
return depths;
}
/** Prefer center, then alternate right / left: 0, +1, -1, +2, -2, … */
function centerOutOffsets(max = 64): number[] {
const out = [0];
for (let d = 1; d <= max; d++) out.push(d, -d);
return out;
}
function pickNearestFreeLane(preferred: number, isFree: (lane: number) => boolean): number {
for (const delta of centerOutOffsets()) {
const lane = preferred + delta;
if (isFree(lane)) return lane;
}
return preferred;
}
/** Collapse signed lane indices to contiguous 0..n-1 (left → right). */
function compactSignedLanes(laneById: Map<number, number>): void {
const usedSorted = [...new Set(laneById.values())].sort((a, b) => a - b);
const remap = new Map(usedSorted.map((lane, index) => [lane, index]));
for (const [id, lane] of laneById) {
laneById.set(id, remap.get(lane) ?? 0);
}
}
function assignTemporalLanes(
group: ArtMovement[],
viewStart: number,
viewEnd: number,
lineageParents: Map<number, number[]>
): Map<number, number> {
const depths = assignDepths(group, lineageParents);
const spans = group
.map((m) => ({
id: m.id,
start: Math.max(m.start_year, viewStart),
end: Math.min(m.end_year, viewEnd),
depth: depths.get(m.id) ?? 0,
}))
.filter((s) => s.end > s.start)
.sort((a, b) => a.depth - b.depth || a.start - b.start || a.end - b.end);
/** Signed lane → year when that lane frees up. */
const laneEnds = new Map<number, number>();
const laneById = new Map<number, number>();
for (const span of spans) {
const parentLanes = (lineageParents.get(span.id) || [])
.map((pid) => laneById.get(pid))
.filter((lane): lane is number => lane != null);
const preferred =
parentLanes.length > 0
? Math.round(parentLanes.reduce((s, l) => s + l, 0) / parentLanes.length)
: 0;
const lane = pickNearestFreeLane(preferred, (candidate) => {
const end = laneEnds.get(candidate);
return end == null || end + LANE_MIN_GAP_YEARS <= span.start;
});
laneEnds.set(lane, span.end);
laneById.set(span.id, lane);
}
compactSignedLanes(laneById);
return laneById;
}
/**
* Prefer one column per movement when the canvas is wide enough, so streams
* do not stack in the same vertical lane. Fall back to temporal packing only
* when there is not enough horizontal room.
*
* Lanes grow from the center outward (0, +1, 1, …) so the layout reads as a tree.
*/
function assignVerticalLanes(
group: ArtMovement[],
viewStart: number,
viewEnd: number,
lineageParents: Map<number, number[]>,
canvasWidth: number
): Map<number, number> {
const usable = Math.max(200, canvasWidth - SIDE_PAD * 2);
const minLanePx = PREFERRED_LANE_PITCH;
const maxExclusive = Math.max(1, Math.floor(usable / minLanePx));
if (group.length <= maxExclusive) {
const depths = assignDepths(group, lineageParents);
// Roots first so the trunk claims center; children then fan around parents.
const sorted = [...group].sort(
(a, b) =>
(depths.get(a.id) ?? 0) - (depths.get(b.id) ?? 0) ||
a.start_year - b.start_year ||
a.name.localeCompare(b.name)
);
const laneById = new Map<number, number>();
const used = new Set<number>();
for (const m of sorted) {
const parentLanes = (lineageParents.get(m.id) || [])
.map((pid) => laneById.get(pid))
.filter((lane): lane is number => lane != null);
const preferred =
parentLanes.length > 0
? Math.round(parentLanes.reduce((s, l) => s + l, 0) / parentLanes.length)
: 0;
const lane = pickNearestFreeLane(preferred, (candidate) => !used.has(candidate));
used.add(lane);
laneById.set(m.id, lane);
}
compactSignedLanes(laneById);
return laneById;
}
return assignTemporalLanes(group, viewStart, viewEnd, lineageParents);
}
function influenceCount(m: ArtMovement): number {
const n = m.influence_link_count;
return typeof n === 'number' && Number.isFinite(n) ? Math.max(0, n) : 0;
}
function pctToSvg(xPct: number, yBottomPct: number, widthPx: number, heightPx: number) {
return {
x: (xPct / 100) * widthPx,
y: ((100 - yBottomPct) / 100) * heightPx,
};
}
/** Vertical stream path: time along Y (SVG y grows down → invert bottom%). */
function verticalStreamPath(
xPct: number,
yStartPct: number,
yEndPct: number,
heightPx: number,
widthPx: number
): string {
const start = pctToSvg(xPct, yEndPct, widthPx, heightPx);
const end = pctToSvg(xPct, yStartPct, widthPx, heightPx);
const midY = (start.y + end.y) / 2;
const bulge = Math.min(18, Math.abs(end.y - start.y) * 0.06);
return `M ${start.x} ${start.y} C ${start.x + bulge} ${midY}, ${end.x - bulge} ${midY}, ${end.x} ${end.y}`;
}
/** Absolute-year anchors so pan/scroll keeps a constant connection angle. */
function branchAnchorYears(
parent: ArtMovement,
child: ArtMovement,
childIndex: number,
childCount: number
): { originYear: number; targetYear: number } | null {
const parentSpan = parent.end_year - parent.start_year;
if (parentSpan <= 0) return null;
const tBase = childCount === 1 ? 0.38 : 0.28 + (childIndex / Math.max(1, childCount - 1)) * 0.22;
let originYear = parent.start_year + parentSpan * tBase;
const targetYear = child.start_year;
if (originYear >= targetYear) {
originYear = Math.min(parent.start_year + parentSpan * 0.2, targetYear - 1);
}
originYear = Math.max(parent.start_year, Math.min(parent.end_year, originYear));
if (originYear >= targetYear) return null;
return { originYear, targetYear };
}
function branchPath(x1: number, y1: number, x2: number, y2: number): string {
const dx = x2 - x1;
const dy = y2 - y1;
// Pull control points along the diagonal so the curve reads as a waterfall, not an L-stair.
const c1x = x1 + dx * 0.35;
const c1y = y1 + dy * 0.55;
const c2x = x2 - dx * 0.25;
const c2y = y2 - dy * 0.2;
return `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`;
}
/** Exponential chase rate (1/s) so lane / view shifts read as motion, not snaps. */
const LAYOUT_ANIM_RATE = 14;
const LAYOUT_ANIM_EPS = 0.06;
interface AnimatedVFlow {
layouts: VLayout[];
branches: VBranch[];
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
function geomSettled(a: number, b: number, eps = LAYOUT_ANIM_EPS): boolean {
return Math.abs(a - b) <= eps;
}
function lerpVLayout(from: VLayout, to: VLayout, t: number): VLayout {
return {
...to,
yStart: lerp(from.yStart, to.yStart, t),
yEnd: lerp(from.yEnd, to.yEnd, t),
x: lerp(from.x, to.x, t),
strokePx: lerp(from.strokePx, to.strokePx, t),
};
}
function vLayoutSettled(a: VLayout, b: VLayout): boolean {
return (
geomSettled(a.yStart, b.yStart) &&
geomSettled(a.yEnd, b.yEnd) &&
geomSettled(a.x, b.x) &&
geomSettled(a.strokePx, b.strokePx, 0.35)
);
}
function lerpVBranch(from: VBranch, to: VBranch, t: number): VBranch {
const x1 = lerp(from.x1, to.x1, t);
const y1 = lerp(from.y1, to.y1, t);
const x2 = lerp(from.x2, to.x2, t);
const y2 = lerp(from.y2, to.y2, t);
const strokePx = lerp(from.strokePx, to.strokePx, t);
return {
...to,
x1,
y1,
x2,
y2,
strokePx,
d: branchPath(x1, y1, x2, y2),
};
}
function vBranchSettled(a: VBranch, b: VBranch): boolean {
return (
geomSettled(a.x1, b.x1) &&
geomSettled(a.y1, b.y1) &&
geomSettled(a.x2, b.x2) &&
geomSettled(a.y2, b.y2) &&
geomSettled(a.strokePx, b.strokePx, 0.35)
);
}
function blendVFlow(
from: AnimatedVFlow,
to: AnimatedVFlow,
t: number
): { next: AnimatedVFlow; settled: boolean } {
const fromLayouts = new Map(from.layouts.map((l) => [l.movement.id, l]));
const fromBranches = new Map(from.branches.map((b) => [b.key, b]));
let settled = true;
const layouts = to.layouts.map((target) => {
const prev = fromLayouts.get(target.movement.id);
if (!prev) return target;
if (vLayoutSettled(prev, target)) return target;
settled = false;
return lerpVLayout(prev, target, t);
});
const branches = to.branches.map((target) => {
const prev = fromBranches.get(target.key);
if (!prev) return target;
if (vBranchSettled(prev, target)) return target;
settled = false;
return lerpVBranch(prev, target, t);
});
return { next: { layouts, branches }, settled };
}
export default function VerticalMovementBands({
movements,
viewStart,
viewEnd,
absoluteMin,
absoluteMax,
onViewChange,
onMovementClick,
}: Props) {
const canvasRef = useRef<HTMLDivElement>(null);
const [canvasSize, setCanvasSize] = useState({ w: 800, h: 600 });
const [panning, setPanning] = useState(false);
const [hoveredMovementId, setHoveredMovementId] = useState<number | null>(null);
const [flowVisual, setFlowVisual] = useState<AnimatedVFlow | null>(null);
const panStart = useRef({ y: 0, viewStart: 0, viewEnd: 0 });
const viewRef = useRef({ viewStart, viewEnd });
const onViewChangeRef = useRef(onViewChange);
const flowVisualRef = useRef<AnimatedVFlow | null>(null);
const flowTargetRef = useRef<AnimatedVFlow | null>(null);
const flowRafRef = useRef<number | null>(null);
const flowLastTsRef = 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) {
setCanvasSize({ w: Math.round(rect.width), h: Math.round(rect.height) });
}
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
const visibleMovements = useMemo(
() => movements.filter((m) => m.end_year > viewStart && m.start_year < viewEnd),
[movements, viewStart, viewEnd]
);
const { layouts, branches } = useMemo(() => {
if (visibleMovements.length === 0) {
return { layouts: [] as VLayout[], branches: [] as VBranch[] };
}
const nameToId = new Map(movements.map((m) => [m.name, m.id]));
const lineageParents = buildLineageParentMap(visibleMovements, nameToId);
const laneIndex = assignVerticalLanes(
visibleMovements,
viewStart,
viewEnd,
lineageParents,
canvasSize.w
);
let maxLanes = 0;
for (const m of visibleMovements) {
const lane = laneIndex.get(m.id) ?? 0;
maxLanes = Math.max(maxLanes, lane + 1);
}
maxLanes = Math.max(1, maxLanes);
const usable = Math.max(200, canvasSize.w - SIDE_PAD * 2);
// Pack columns tightly; only stretch if the canvas is narrower than the preferred cluster.
const lanePitch = Math.min(usable / maxLanes, PREFERRED_LANE_PITCH);
const clusterWidth = lanePitch * maxLanes;
const startX = SIDE_PAD + Math.max(0, (usable - clusterWidth) / 2);
const laneCentersPct: number[] = [];
for (let lane = 0; lane < maxLanes; lane++) {
const centerPx = startX + lanePitch * lane + lanePitch / 2;
laneCentersPct[lane] = (centerPx / Math.max(1, canvasSize.w)) * 100;
}
const maxInf = Math.max(1, ...visibleMovements.map(influenceCount));
const layoutById = new Map<number, VLayout>();
for (const m of visibleMovements) {
const yStart = yearToBottomPercent(Math.max(m.start_year, viewStart), viewStart, viewEnd);
const yEnd = yearToBottomPercent(Math.min(m.end_year, viewEnd), viewStart, viewEnd);
if (yEnd <= yStart) continue;
const lane = laneIndex.get(m.id) ?? 0;
const baseStroke =
MIN_STROKE + (influenceCount(m) / maxInf) * (MAX_STROKE - MIN_STROKE);
// Allow nearly full preferred stroke; only shrink if the pitch is forced smaller.
const strokePx = Math.min(MAX_STROKE, Math.max(MIN_STROKE, baseStroke), lanePitch * 0.88);
layoutById.set(m.id, {
movement: m,
yStart,
yEnd,
x: laneCentersPct[lane] ?? 50,
strokePx,
displayColor: vividMovementColor(m.color),
parentIds: lineageParents.get(m.id) || [],
});
}
const childIdsByParent = new Map<number, number[]>();
for (const layout of layoutById.values()) {
for (const parentId of layout.parentIds) {
if (!layoutById.has(parentId)) continue;
const children = childIdsByParent.get(parentId) || [];
children.push(layout.movement.id);
childIdsByParent.set(parentId, children);
}
}
for (const children of childIdsByParent.values()) {
children.sort((a, b) => {
const la = layoutById.get(a)!;
const lb = layoutById.get(b)!;
return la.x - lb.x || la.movement.start_year - lb.movement.start_year;
});
}
const branchList: VBranch[] = [];
for (const layout of layoutById.values()) {
for (const parentId of layout.parentIds) {
const parent = layoutById.get(parentId);
if (!parent) continue;
const children = childIdsByParent.get(parentId) || [layout.movement.id];
const childIndex = children.indexOf(layout.movement.id);
const anchors = branchAnchorYears(
parent.movement,
layout.movement,
childIndex,
children.length
);
if (!anchors) continue;
// Map fixed calendar years → current view % so pan keeps dx/dy (and angle) stable.
const originY = yearToBottomPercent(anchors.originYear, viewStart, viewEnd);
const targetY = yearToBottomPercent(anchors.targetYear, viewStart, viewEnd);
const from = pctToSvg(parent.x, originY, canvasSize.w, canvasSize.h);
const to = pctToSvg(layout.x, targetY, canvasSize.w, canvasSize.h);
branchList.push({
key: `${parentId}-${layout.movement.id}`,
d: branchPath(from.x, from.y, to.x, to.y),
colorFrom: parent.displayColor,
colorTo: layout.displayColor,
strokePx: Math.max(14, Math.min(parent.strokePx, layout.strokePx) * 0.55),
fromId: parentId,
toId: layout.movement.id,
x1: from.x,
y1: from.y,
x2: to.x,
y2: to.y,
});
}
}
return {
layouts: [...layoutById.values()].sort((a, b) => a.movement.start_year - b.movement.start_year),
branches: branchList,
};
}, [visibleMovements, movements, viewStart, viewEnd, canvasSize.w, canvasSize.h]);
useLayoutEffect(() => {
const target: AnimatedVFlow = { layouts, branches };
flowTargetRef.current = target;
if (!flowVisualRef.current) {
flowVisualRef.current = target;
setFlowVisual(target);
return;
}
if (flowRafRef.current != null) return;
flowLastTsRef.current = performance.now();
const step = (now: number) => {
const prev = flowVisualRef.current;
const goal = flowTargetRef.current;
if (!prev || !goal) {
flowRafRef.current = null;
return;
}
const dt = Math.min(0.048, Math.max(0, (now - flowLastTsRef.current) / 1000));
flowLastTsRef.current = now;
const t = 1 - Math.exp(-LAYOUT_ANIM_RATE * dt);
const { next, settled } = blendVFlow(prev, goal, t);
flowVisualRef.current = settled ? goal : next;
setFlowVisual(flowVisualRef.current);
if (settled) {
flowRafRef.current = null;
return;
}
flowRafRef.current = requestAnimationFrame(step);
};
flowRafRef.current = requestAnimationFrame(step);
}, [layouts, branches]);
useEffect(() => {
return () => {
if (flowRafRef.current != null) {
cancelAnimationFrame(flowRafRef.current);
flowRafRef.current = null;
}
};
}, []);
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;
const invertedClientY = rect.bottom - (e.clientY - rect.top);
const next = zoomTimelineView(
invertedClientY,
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 (visibleMovements.length === 0) {
return (
<div className="vflow-empty">
<p>No art movements in this time range. Zoom out to explore more periods.</p>
</div>
);
}
const displayLayouts = flowVisual?.layouts ?? layouts;
const displayBranches = flowVisual?.branches ?? branches;
return (
<div className="vflow">
<p className="vflow-caption">
Bottom top through history · scroll to zoom · drag to pan · click a stream
</p>
<div
ref={canvasRef}
className={`vflow-canvas${panning ? ' vflow-panning' : ''}`}
onMouseDown={handlePanStart}
>
<svg
className="vflow-svg"
viewBox={`0 0 ${canvasSize.w} ${canvasSize.h}`}
preserveAspectRatio="none"
>
<defs>
{displayBranches.map((branch) => (
<linearGradient
key={`grad-${branch.key}`}
id={`vflow-branch-grad-${branch.key}`}
gradientUnits="userSpaceOnUse"
x1={branch.x1}
y1={branch.y1}
x2={branch.x2}
y2={branch.y2}
>
<stop offset="0%" stopColor={branch.colorFrom} stopOpacity={0.9} />
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0.9} />
</linearGradient>
))}
</defs>
{displayLayouts.map((layout) => {
const d = verticalStreamPath(
layout.x,
layout.yStart,
layout.yEnd,
canvasSize.h,
canvasSize.w
);
const highlighted = hoveredMovementId === layout.movement.id;
return (
<path
key={layout.movement.id}
d={d}
className={`vflow-stream${highlighted ? ' vflow-stream-highlighted' : ''}`}
stroke={layout.displayColor}
fill="none"
style={{ strokeWidth: layout.strokePx }}
onMouseEnter={() => setHoveredMovementId(layout.movement.id)}
onMouseLeave={() => setHoveredMovementId(null)}
onClick={(e) => {
e.stopPropagation();
onMovementClick?.(layout.movement.id);
}}
/>
);
})}
{displayBranches.map((branch) => {
const highlighted =
hoveredMovementId != null &&
(branch.fromId === hoveredMovementId || branch.toId === hoveredMovementId);
return (
<path
key={branch.key}
d={branch.d}
className={`vflow-branch${highlighted ? ' vflow-branch-highlighted' : ''}`}
stroke={`url(#vflow-branch-grad-${branch.key})`}
fill="none"
style={{ strokeWidth: branch.strokePx }}
/>
);
})}
</svg>
{displayLayouts.map((layout) => {
const midY = (layout.yStart + layout.yEnd) / 2;
return (
<button
key={`label-${layout.movement.id}`}
type="button"
className="vflow-label"
style={{
left: `${layout.x}%`,
bottom: `${midY}%`,
}}
onClick={(e) => {
e.stopPropagation();
onMovementClick?.(layout.movement.id);
}}
onMouseEnter={() => setHoveredMovementId(layout.movement.id)}
onMouseLeave={() => setHoveredMovementId(null)}
>
{layout.movement.name}
</button>
);
})}
</div>
</div>
);
}