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:
@@ -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`),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user