733 lines
24 KiB
TypeScript
733 lines
24 KiB
TypeScript
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 { vividMovementColor } from '../utils/movementColor';
|
||
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 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>
|
||
);
|
||
}
|