Improve timeline UX and add catalog restore scripts for paintings and portraits.

Load the home-page catalog once with a lightweight artists API, batch pan/zoom updates per frame, use dynamic year labels, and speed up movement-flow zoom. Add sync-image-paths and fetch-artist-images plus docs for the post-seed pipeline.
This commit is contained in:
Danila Khodjaef
2026-07-03 18:33:16 +03:00
parent 7f26696328
commit 02d238b043
26 changed files with 865 additions and 195 deletions
+20
View File
@@ -87,6 +87,26 @@
z-index: 1;
}
.movements-flow-canvas.movements-flow-interacting {
cursor: grab;
}
.movements-flow-canvas.movements-flow-interacting.movements-flow-panning {
cursor: grabbing;
}
.movement-branch-fast {
stroke-opacity: 0.32;
stroke-width: calc(var(--stream-stroke) * 0.45);
}
.movement-stream-fast {
stroke-opacity: 0.42;
stroke-width: var(--stream-stroke);
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.movement-branch {
stroke-width: var(--stream-stroke);
stroke-linecap: round;
+194 -125
View File
@@ -379,45 +379,74 @@ export default function MovementBands({
}: Props) {
const canvasRef = useRef<HTMLDivElement>(null);
const [panning, setPanning] = useState(false);
const [interacting, setInteracting] = useState(false);
const [canvasHeight, setCanvasHeight] = useState(DEFAULT_CANVAS_HEIGHT);
const [canvasWidth, setCanvasWidth] = useState(800);
const [hoveredArtistKey, setHoveredArtistKey] = useState<string | null>(null);
const panStart = useRef({ x: 0, viewStart: 0, viewEnd: 0 });
const interactionTimer = useRef<number | null>(null);
const viewRef = useRef({ viewStart, viewEnd });
const onViewChangeRef = useRef(onViewChange);
viewRef.current = { viewStart, viewEnd };
onViewChangeRef.current = onViewChange;
const handleWheel = useCallback(
(e: React.WheelEvent) => {
const markInteracting = useCallback(() => {
setInteracting(true);
if (interactionTimer.current != null) {
window.clearTimeout(interactionTimer.current);
}
interactionTimer.current = window.setTimeout(() => {
interactionTimer.current = null;
setInteracting(false);
}, 120);
}, []);
useEffect(() => () => {
if (interactionTimer.current != null) window.clearTimeout(interactionTimer.current);
}, []);
useEffect(() => {
const el = canvasRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
markInteracting();
const rect = el.getBoundingClientRect();
const { viewStart: vs, viewEnd: ve } = viewRef.current;
const next = zoomTimelineView(
e.clientX,
rect.left,
rect.width,
e.deltaY,
viewStart,
viewEnd,
vs,
ve,
absoluteMin,
absoluteMax
);
onViewChange(next.start, next.end);
},
[viewStart, viewEnd, absoluteMin, absoluteMax, onViewChange]
);
onViewChangeRef.current(next.start, next.end);
};
el.addEventListener('wheel', onWheel, { passive: false, capture: true });
return () => el.removeEventListener('wheel', onWheel, { capture: true });
}, [absoluteMin, absoluteMax, markInteracting]);
const handlePanStart = useCallback(
(e: React.MouseEvent) => {
if (e.button !== 0) return;
e.preventDefault();
setPanning(true);
markInteracting();
panStart.current = { x: e.clientX, viewStart, viewEnd };
},
[viewStart, viewEnd]
[viewStart, viewEnd, markInteracting]
);
useEffect(() => {
if (!panning) return;
const onMove = (e: MouseEvent) => {
markInteracting();
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const dx = e.clientX - panStart.current.x;
@@ -429,7 +458,7 @@ export default function MovementBands({
absoluteMin,
absoluteMax
);
onViewChange(next.start, next.end);
onViewChangeRef.current(next.start, next.end);
};
const onUp = () => setPanning(false);
@@ -439,7 +468,7 @@ export default function MovementBands({
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, [panning, absoluteMin, absoluteMax, onViewChange]);
}, [panning, absoluteMin, absoluteMax, markInteracting]);
const artistsByMovement = useMemo(() => {
const map = new Map<number, Artist[]>();
@@ -616,18 +645,26 @@ export default function MovementBands({
};
}, [visibleMovements, movements, viewStart, viewEnd, canvasHeight]);
const artistPlacements = useMemo(
() =>
buildArtistPlacements(
layouts,
artistsByMovement,
viewStart,
viewEnd,
portraitSizePx,
canvasWidth
),
[layouts, artistsByMovement, viewStart, viewEnd, portraitSizePx, canvasWidth]
);
const artistPlacements = useMemo(() => {
if (interacting || panning) return [];
return buildArtistPlacements(
layouts,
artistsByMovement,
viewStart,
viewEnd,
portraitSizePx,
canvasWidth
);
}, [
layouts,
artistsByMovement,
viewStart,
viewEnd,
portraitSizePx,
canvasWidth,
interacting,
panning,
]);
const hoveredPlacement = useMemo(() => {
if (!hoveredArtistKey) return null;
@@ -647,6 +684,7 @@ export default function MovementBands({
}
const layoutById = new Map(layouts.map((l) => [l.movement.id, l]));
const fastGraphics = interacting || panning;
return (
<div className="movements-flow">
@@ -656,12 +694,11 @@ export default function MovementBands({
<div
ref={canvasRef}
className={`movements-flow-canvas${panning ? ' movements-flow-panning' : ''}`}
className={`movements-flow-canvas${panning ? ' movements-flow-panning' : ''}${fastGraphics ? ' movements-flow-interacting' : ''}`}
style={{
['--stream-stroke' as string]: `${streamStrokePx}px`,
['--portrait-size' as string]: `${portraitSizePx}px`,
}}
onWheel={handleWheel}
onMouseDown={handlePanStart}
>
<svg
@@ -670,111 +707,141 @@ export default function MovementBands({
preserveAspectRatio="none"
aria-hidden
>
<defs>
{branches.map((branch) => (
<linearGradient
key={`branch-grad-${branch.key}`}
id={`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} />
<stop offset="18%" stopColor={branch.colorFrom} stopOpacity={0.34} />
<stop offset="50%" stopColor={branch.colorTo} stopOpacity={0.34} />
<stop offset="82%" stopColor={branch.colorTo} stopOpacity={0.34} />
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0} />
</linearGradient>
))}
{layouts.map((layout) => {
const primaryParent =
layout.parentIds.length > 0 ? layoutById.get(layout.parentIds[0]) : null;
const hasChildren = (childIdsByParent.get(layout.movement.id)?.length ?? 0) > 0;
const parentColor = primaryParent?.movement.color ?? layout.movement.color;
return (
{!fastGraphics && (
<defs>
{branches.map((branch) => (
<linearGradient
key={`grad-${layout.movement.id}`}
id={`stream-grad-${layout.movement.id}`}
key={`branch-grad-${branch.key}`}
id={`branch-grad-${branch.key}`}
gradientUnits="userSpaceOnUse"
x1={layout.xStart}
y1={layout.y}
x2={layout.xEnd}
y2={layout.yEnd}
x1={branch.x1}
y1={branch.y1}
x2={branch.x2}
y2={branch.y2}
>
{primaryParent ? (
<>
<stop offset="0%" stopColor={parentColor} stopOpacity={0.28} />
<stop offset="16%" stopColor={layout.movement.color} stopOpacity={0.34} />
<stop offset="32%" stopColor={layout.movement.color} stopOpacity={0.38} />
</>
) : (
<stop
offset="0%"
stopColor={layout.movement.color}
stopOpacity={layout.movement.start_definite ? 0.28 : 0.08}
/>
)}
<stop offset="50%" stopColor={layout.movement.color} stopOpacity={0.38} />
{hasChildren ? (
<>
<stop offset="68%" stopColor={layout.movement.color} stopOpacity={0.38} />
<stop offset="84%" stopColor={layout.movement.color} stopOpacity={0.22} />
<stop offset="100%" stopColor={layout.movement.color} stopOpacity={0} />
</>
) : (
<stop
offset="100%"
stopColor={layout.movement.color}
stopOpacity={layout.movement.end_definite ? 0.28 : 0.08}
/>
)}
<stop offset="0%" stopColor={branch.colorFrom} stopOpacity={0} />
<stop offset="18%" stopColor={branch.colorFrom} stopOpacity={0.34} />
<stop offset="50%" stopColor={branch.colorTo} stopOpacity={0.34} />
<stop offset="82%" stopColor={branch.colorTo} stopOpacity={0.34} />
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0} />
</linearGradient>
);
})}
</defs>
))}
{branches.map((branch) => (
<path
key={branch.key}
d={branch.d}
className="movement-branch"
stroke={`url(#branch-grad-${branch.key})`}
fill="none"
/>
))}
{layouts.map((layout) => {
const primaryParent =
layout.parentIds.length > 0 ? layoutById.get(layout.parentIds[0]) : null;
const hasChildren = (childIdsByParent.get(layout.movement.id)?.length ?? 0) > 0;
const parentColor = primaryParent?.movement.color ?? layout.movement.color;
{layouts.map((layout) => {
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd);
return (
<path
key={`stream-${layout.movement.id}`}
d={d}
className="movement-stream-fill"
stroke={`url(#stream-grad-${layout.movement.id})`}
fill="none"
/>
);
})}
return (
<linearGradient
key={`grad-${layout.movement.id}`}
id={`stream-grad-${layout.movement.id}`}
gradientUnits="userSpaceOnUse"
x1={layout.xStart}
y1={layout.y}
x2={layout.xEnd}
y2={layout.yEnd}
>
{primaryParent ? (
<>
<stop offset="0%" stopColor={parentColor} stopOpacity={0.28} />
<stop offset="16%" stopColor={layout.movement.color} stopOpacity={0.34} />
<stop offset="32%" stopColor={layout.movement.color} stopOpacity={0.38} />
</>
) : (
<stop
offset="0%"
stopColor={layout.movement.color}
stopOpacity={layout.movement.start_definite ? 0.28 : 0.08}
/>
)}
<stop offset="50%" stopColor={layout.movement.color} stopOpacity={0.38} />
{hasChildren ? (
<>
<stop offset="68%" stopColor={layout.movement.color} stopOpacity={0.38} />
<stop offset="84%" stopColor={layout.movement.color} stopOpacity={0.22} />
<stop offset="100%" stopColor={layout.movement.color} stopOpacity={0} />
</>
) : (
<stop
offset="100%"
stopColor={layout.movement.color}
stopOpacity={layout.movement.end_definite ? 0.28 : 0.08}
/>
)}
</linearGradient>
);
})}
</defs>
)}
{layouts.map((layout) => {
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd);
return (
<path
key={`core-${layout.movement.id}`}
d={d}
className="movement-stream-core"
stroke={layout.movement.color}
fill="none"
/>
);
})}
{fastGraphics ? (
<>
{branches.map((branch) => (
<path
key={branch.key}
d={branch.d}
className="movement-branch movement-branch-fast"
stroke={branch.colorTo}
fill="none"
/>
))}
{layouts.map((layout) => {
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd);
return (
<path
key={`stream-${layout.movement.id}`}
d={d}
className="movement-stream-core movement-stream-fast"
stroke={layout.movement.color}
fill="none"
/>
);
})}
</>
) : (
<>
{branches.map((branch) => (
<path
key={branch.key}
d={branch.d}
className="movement-branch"
stroke={`url(#branch-grad-${branch.key})`}
fill="none"
/>
))}
{layouts.map((layout) => {
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd);
return (
<path
key={`stream-${layout.movement.id}`}
d={d}
className="movement-stream-fill"
stroke={`url(#stream-grad-${layout.movement.id})`}
fill="none"
/>
);
})}
{layouts.map((layout) => {
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd);
return (
<path
key={`core-${layout.movement.id}`}
d={d}
className="movement-stream-core"
stroke={layout.movement.color}
fill="none"
/>
);
})}
</>
)}
</svg>
{hoveredPlacement && (
{hoveredPlacement && !fastGraphics && (
<div className="movement-lifespan-overlays" aria-hidden>
{hoveredPlacement.lineLeft > 0 && (
<div
@@ -802,6 +869,8 @@ export default function MovementBands({
</div>
)}
{!fastGraphics && (
<>
<div className="movements-flow-labels">
{layouts.map((layout) => (
<button
@@ -815,7 +884,6 @@ export default function MovementBands({
title={`Open ${layout.movement.name} gallery hall`}
onClick={() => onMovementClick?.(layout.movement.id)}
onMouseDown={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
>
<span className="movement-name">{layout.movement.name}</span>
{layout.movement.era_name && (
@@ -872,7 +940,6 @@ export default function MovementBands({
onArtistHover?.(null);
}}
onMouseDown={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
title={`${artist.name} (${birthLabel}${deathLabel}) · ${layout.movement.name}`}
>
<img
@@ -888,6 +955,8 @@ export default function MovementBands({
);
})}
</div>
</>
)}
</div>
</div>
);
+35 -5
View File
@@ -1,4 +1,4 @@
import { useRef, useState, useCallback, useEffect, useMemo } from 'react';
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
import type { HistoricalEra } from '../types';
import {
HISTORICAL_EVENTS,
@@ -6,6 +6,10 @@ import {
eventInView,
type HistoricalEvent,
} from '../data/historical-events';
import {
buildTimelineTickYears,
chooseTimelineTickInterval,
} from '../utils/timelineView';
import './Timeline.css';
interface LifespanHighlight {
@@ -36,13 +40,39 @@ function formatYear(year: number): string {
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<'left' | 'right' | 'pan' | null>(null);
const [containerWidth, setContainerWidth] = useState(800);
const dragStart = useRef({ x: 0, viewStart: 0, viewEnd: 0 });
const span = viewEnd - viewStart;
const tickInterval = span > 500 ? 100 : span > 200 ? 50 : span > 50 ? 25 : span > 10 ? 5 : 1;
const ticks: number[] = [];
const firstTick = Math.ceil(viewStart / tickInterval) * tickInterval;
for (let y = firstTick; y <= viewEnd; y += tickInterval) ticks.push(y);
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
const measure = () => {
const w = el.getBoundingClientRect().width;
if (w > 0) setContainerWidth(Math.round(w));
};
measure();
const ro = new ResizeObserver(() => measure());
ro.observe(el);
window.addEventListener('resize', measure);
return () => {
ro.disconnect();
window.removeEventListener('resize', measure);
};
}, []);
const tickInterval = useMemo(
() => chooseTimelineTickInterval(span, containerWidth),
[span, containerWidth]
);
const ticks = useMemo(
() => buildTimelineTickYears(viewStart, viewEnd, tickInterval),
[viewStart, viewEnd, tickInterval]
);
const handleWheel = useCallback(
(e: React.WheelEvent) => {