Files
Art-gallery/client/src/components/VerticalMovementBands.tsx
T

733 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}