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
+3
View File
@@ -174,6 +174,9 @@ export const api = {
return fetchJson<Artist[]>(`${API}/artists?${params}`);
},
/** Lightweight artist rows for the timeline (no biography text). */
getTimelineArtists: () => fetchJson<Artist[]>(`${API}/artists?timeline=1`),
getArtist: (id: number) => fetchJson<ArtistDetail>(`${API}/artists/${id}`),
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(`${API}/movements/${id}/gallery`),
+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) => {
+48 -35
View File
@@ -8,6 +8,7 @@ import ArtistBio from '../components/ArtistBio';
import CheckupPage from '../pages/CheckupPage';
import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types';
import { createViewChangeScheduler } from '../utils/timelineView';
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
import { readDebugMode, readDebugShowMore, writeDebugMode, writeDebugShowMore } from '../utils/debugMode';
import './HomePage.css';
@@ -119,18 +120,6 @@ export default function HomePage() {
} | null>(null);
const detailReturnToRef = useRef<View>({ type: 'timeline' });
useEffect(() => {
api.getBounds()
.then((b) => {
const min = b.min_year ?? -800;
const max = b.max_year ?? 2025;
setBounds({ min, max });
setViewStart(min);
setViewEnd(max);
})
.catch(() => {});
}, []);
useEffect(() => {
if (view.type === 'gallery') {
setGallerySession({ kind: 'artist', artistId: view.artistId, data: view.data });
@@ -141,31 +130,55 @@ export default function HomePage() {
}
}, [view]);
const loadTimelineData = useCallback(async (start: number, end: number) => {
try {
setLoading(true);
const [timeline, artistList] = await Promise.all([
api.getTimeline(start, end),
api.getArtists(start, end),
]);
setTimelineData(timeline);
setArtists(artistList);
setError(null);
} catch {
setError('Could not load gallery data. Is the server running?');
} finally {
setLoading(false);
}
// Load full catalog once — pan/zoom filters client-side (MovementBands, Timeline).
useEffect(() => {
let cancelled = false;
(async () => {
try {
setLoading(true);
const b = await api.getBounds();
const min = b.min_year ?? -800;
const max = b.max_year ?? 2025;
if (cancelled) return;
setBounds({ min, max });
setViewStart(min);
setViewEnd(max);
const [timeline, artistList] = await Promise.all([
api.getTimeline(min, max),
api.getTimelineArtists(),
]);
if (cancelled) return;
setTimelineData(timeline);
setArtists(artistList);
setError(null);
} catch {
if (!cancelled) setError('Could not load gallery data. Is the server running?');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
loadTimelineData(viewStart, viewEnd);
}, [viewStart, viewEnd, loadTimelineData]);
const viewChangeScheduler = useRef(
createViewChangeScheduler((start, end) => {
setViewStart(start);
setViewEnd(end);
})
);
const handleViewChange = (start: number, end: number) => {
setViewStart(start);
setViewEnd(end);
};
useEffect(() => () => viewChangeScheduler.current.cancel(), []);
const handleViewChange = useCallback((start: number, end: number) => {
viewChangeScheduler.current.schedule(start, end);
}, []);
const toggleDebugMode = () => {
setDebugMode((prev) => {
@@ -683,7 +696,7 @@ export default function HomePage() {
{error && <div className="error-banner">{error}</div>}
{loading ? (
{loading && timelineData.movements.length === 0 ? (
<div className="loading home-movements-section">Loading art history...</div>
) : (
<>
+76
View File
@@ -1,4 +1,45 @@
/** Shared timeline zoom/pan math for Timeline and MovementBands. */
/** "Nice" year steps for axis labels (ascending). */
const TICK_INTERVALS = [1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000, 5000];
/** Pick a label interval from span + pixel width so dates do not overlap. */
export function chooseTimelineTickInterval(
spanYears: number,
widthPx: number,
minLabelGapPx = 76
): number {
if (spanYears <= 0) return 1;
const width = Math.max(widthPx, 320);
const minYears = (spanYears / width) * minLabelGapPx;
let chosen = TICK_INTERVALS[TICK_INTERVALS.length - 1];
for (const interval of TICK_INTERVALS) {
if (interval >= minYears) {
chosen = interval;
break;
}
}
// Hard cap — never more than ~16 labelled ticks
while (spanYears / chosen > 16) {
const idx = TICK_INTERVALS.indexOf(chosen);
if (idx < 0 || idx >= TICK_INTERVALS.length - 1) break;
chosen = TICK_INTERVALS[idx + 1];
}
return chosen;
}
export function buildTimelineTickYears(viewStart: number, viewEnd: number, interval: number): number[] {
const ticks: number[] = [];
const firstTick = Math.ceil(viewStart / interval) * interval;
for (let y = firstTick; y <= viewEnd; y += interval) {
ticks.push(y);
}
return ticks;
}
export function zoomTimelineView(
clientX: number,
rectLeft: number,
@@ -49,3 +90,38 @@ export function panTimelineView(
}
return { start: Math.round(newStart), end: Math.round(newEnd) };
}
/** Coalesce rapid view updates to one React commit per animation frame. */
export function createViewChangeScheduler(onApply: (start: number, end: number) => void) {
let rafId: number | null = null;
let pending: { start: number; end: number } | null = null;
return {
schedule(start: number, end: number) {
pending = { start, end };
if (rafId != null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
const next = pending;
pending = null;
if (next) onApply(next.start, next.end);
});
},
flush() {
if (rafId != null) {
cancelAnimationFrame(rafId);
rafId = null;
}
if (pending) {
const next = pending;
pending = null;
onApply(next.start, next.end);
}
},
cancel() {
if (rafId != null) cancelAnimationFrame(rafId);
rafId = null;
pending = null;
},
};
}