Add vertical bottom-up timeline with center-out streams and smooth layout transitions.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
4088d7d57b
commit
971c1e8dd8
@@ -0,0 +1,104 @@
|
||||
.vflow {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 12px 12px;
|
||||
}
|
||||
|
||||
.vflow-caption {
|
||||
margin: 0 0 8px;
|
||||
text-align: center;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
color: rgba(201, 169, 110, 0.65);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vflow-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: rgba(201, 169, 110, 0.6);
|
||||
font-family: 'Georgia', serif;
|
||||
}
|
||||
|
||||
.vflow-canvas {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 80% at 50% 0%, rgba(201, 169, 110, 0.07), transparent 70%),
|
||||
rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(201, 169, 110, 0.12);
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.vflow-canvas.vflow-panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.vflow-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.vflow-stream {
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 0.72;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease, filter 0.15s ease;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.vflow-stream-highlighted {
|
||||
opacity: 1;
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.vflow-branch {
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 0.95;
|
||||
pointer-events: none;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.vflow-branch-highlighted {
|
||||
opacity: 1;
|
||||
filter: brightness(1.25) drop-shadow(0 0 4px rgba(255, 230, 180, 0.45));
|
||||
}
|
||||
|
||||
.vflow-label {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
transform: translate(-50%, 50%);
|
||||
margin: 0;
|
||||
padding: 2px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: rgba(12, 12, 22, 0.72);
|
||||
color: rgba(245, 230, 200, 0.95);
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.vflow-label:hover {
|
||||
background: rgba(30, 28, 40, 0.9);
|
||||
color: #fff6e0;
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
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 './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 parseHexColor(hex: string): [number, number, number] {
|
||||
const normalized = hex.replace('#', '');
|
||||
const value =
|
||||
normalized.length === 3
|
||||
? normalized
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('')
|
||||
: normalized;
|
||||
const n = parseInt(value, 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
if (max === min) return [0, 0, l];
|
||||
const d = max - min;
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
let h = 0;
|
||||
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
else if (max === g) h = ((b - r) / d + 2) / 6;
|
||||
else h = ((r - g) / d + 4) / 6;
|
||||
return [h * 360, s, l];
|
||||
}
|
||||
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
if (h < 60) [r, g, b] = [c, x, 0];
|
||||
else if (h < 120) [r, g, b] = [x, c, 0];
|
||||
else if (h < 180) [r, g, b] = [0, c, x];
|
||||
else if (h < 240) [r, g, b] = [0, x, c];
|
||||
else if (h < 300) [r, g, b] = [x, 0, c];
|
||||
else [r, g, b] = [c, 0, x];
|
||||
const toByte = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
||||
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
|
||||
}
|
||||
|
||||
function vividMovementColor(hex: string): string {
|
||||
try {
|
||||
const [r, g, b] = parseHexColor(hex);
|
||||
const [h, s, l] = rgbToHsl(r, g, b);
|
||||
const s2 = s < 0.1 ? Math.min(0.55, s + 0.42) : Math.min(1, s * 1.65 + 0.08);
|
||||
const l2 =
|
||||
l < 0.22 ? 0.5 : l > 0.78 ? 0.62 : Math.min(0.68, Math.max(0.4, l * 0.75 + 0.28));
|
||||
return hslToHex(h, s2, l2);
|
||||
} catch {
|
||||
return hex;
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
.vtimeline-wrapper {
|
||||
flex-shrink: 0;
|
||||
width: 148px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(90deg, #1a1a2e 0%, #16213e 100%);
|
||||
border-right: 2px solid #c9a96e;
|
||||
padding: 8px 8px 12px;
|
||||
z-index: 100;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.vtimeline-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vtimeline-controls button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid #c9a96e;
|
||||
background: rgba(201, 169, 110, 0.15);
|
||||
color: #e8d5b5;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.vtimeline-controls button:hover {
|
||||
background: rgba(201, 169, 110, 0.35);
|
||||
}
|
||||
|
||||
.vtimeline-range {
|
||||
width: 100%;
|
||||
color: #f5e6c8;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.vtimeline-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 120px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
border: 1px solid rgba(201, 169, 110, 0.3);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vtimeline-container:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.vtimeline-track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.vtimeline-era-block {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 28px;
|
||||
margin: 0;
|
||||
padding: 4px 2px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vtimeline-era-label {
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 245, 220, 0.92);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vtimeline-lifespan-overlays {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vtimeline-lifespan-dim {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.vtimeline-lifespan-highlight {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.05) 0%,
|
||||
rgba(255, 255, 255, 0.28) 50%,
|
||||
rgba(255, 255, 255, 0.05) 100%
|
||||
);
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 240, 200, 0.5);
|
||||
border-top: 2px solid rgba(255, 230, 180, 0.75);
|
||||
border-bottom: 2px solid rgba(255, 230, 180, 0.75);
|
||||
}
|
||||
|
||||
.vtimeline-events {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vtimeline-event-mark,
|
||||
.vtimeline-event-span {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
right: 30px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: rgba(232, 196, 120, 0.55);
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vtimeline-event-mark {
|
||||
height: 3px;
|
||||
transform: translateY(50%);
|
||||
}
|
||||
|
||||
.vtimeline-event-span {
|
||||
min-height: 4px;
|
||||
background: rgba(232, 196, 120, 0.28);
|
||||
border-left: 2px solid rgba(232, 196, 120, 0.7);
|
||||
}
|
||||
|
||||
.vtimeline-event-label {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
margin-left: 4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
writing-mode: horizontal-tb;
|
||||
font-size: 9px;
|
||||
color: rgba(245, 230, 200, 0.85);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vtimeline-ticks {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vtimeline-tick {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: auto;
|
||||
width: 26px;
|
||||
transform: translateY(50%);
|
||||
border-bottom: 1px solid rgba(201, 169, 110, 0.35);
|
||||
text-align: right;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.vtimeline-tick span {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: rgba(232, 213, 181, 0.85);
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
line-height: 1;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
.vtimeline-brush {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 10px;
|
||||
z-index: 8;
|
||||
cursor: ns-resize;
|
||||
background: rgba(201, 169, 110, 0.25);
|
||||
}
|
||||
|
||||
.vtimeline-brush-start {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.vtimeline-brush-end {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.vtimeline-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
color: rgba(201, 169, 110, 0.55);
|
||||
font-family: 'Georgia', serif;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.vtimeline-wrapper {
|
||||
width: 112px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
||||
import type { HistoricalEra } from '../types';
|
||||
import {
|
||||
HISTORICAL_EVENTS,
|
||||
eventEndYear,
|
||||
eventInView,
|
||||
type HistoricalEvent,
|
||||
} from '../data/historical-events';
|
||||
import {
|
||||
buildTimelineTickYears,
|
||||
chooseTimelineTickInterval,
|
||||
} from '../utils/timelineView';
|
||||
import './VerticalTimeline.css';
|
||||
|
||||
interface LifespanHighlight {
|
||||
birthYear: number;
|
||||
deathYear: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
eras: HistoricalEra[];
|
||||
viewStart: number;
|
||||
viewEnd: number;
|
||||
onViewChange: (start: number, end: number) => void;
|
||||
absoluteMin: number;
|
||||
absoluteMax: number;
|
||||
lifespanHighlight?: LifespanHighlight | null;
|
||||
}
|
||||
|
||||
/** Earlier years at the bottom (0%), later at the top (100%). */
|
||||
function yearToBottomPercent(year: number, start: number, end: number): number {
|
||||
return ((year - start) / (end - start)) * 100;
|
||||
}
|
||||
|
||||
function formatYear(year: number): string {
|
||||
if (year < 0) return `${Math.abs(year)} BCE`;
|
||||
return `${year} CE`;
|
||||
}
|
||||
|
||||
function getEraColor(name: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
Ancient: 'rgba(139,115,85,0.7)',
|
||||
Medieval: 'rgba(74,85,104,0.7)',
|
||||
Renaissance: 'rgba(184,134,11,0.7)',
|
||||
Baroque: 'rgba(139,0,0,0.6)',
|
||||
'Neoclassicism & Romanticism': 'rgba(70,130,180,0.6)',
|
||||
Modern: 'rgba(100,100,120,0.6)',
|
||||
Contemporary: 'rgba(60,60,80,0.7)',
|
||||
};
|
||||
return colors[name] || 'rgba(100,100,100,0.5)';
|
||||
}
|
||||
|
||||
export default function VerticalTimeline({
|
||||
eras,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
onViewChange,
|
||||
absoluteMin,
|
||||
absoluteMax,
|
||||
lifespanHighlight,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState<'start' | 'end' | 'pan' | null>(null);
|
||||
const [containerHeight, setContainerHeight] = useState(600);
|
||||
const dragStart = useRef({ y: 0, viewStart: 0, viewEnd: 0 });
|
||||
|
||||
const span = viewEnd - viewStart;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const h = el.getBoundingClientRect().height;
|
||||
if (h > 0) setContainerHeight(Math.round(h));
|
||||
};
|
||||
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, containerHeight, 56),
|
||||
[span, containerHeight]
|
||||
);
|
||||
|
||||
const ticks = useMemo(
|
||||
() => buildTimelineTickYears(viewStart, viewEnd, tickInterval),
|
||||
[viewStart, viewEnd, tickInterval]
|
||||
);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
// Bottom = early: invert Y ratio so scroll-at-bottom zooms around early years.
|
||||
const ratioFromTop = (e.clientY - rect.top) / rect.height;
|
||||
const ratio = 1 - ratioFromTop;
|
||||
const centerYear = viewStart + ratio * span;
|
||||
const factor = e.deltaY > 0 ? 1.15 : 0.85;
|
||||
const newSpan = Math.max(10, Math.min(absoluteMax - absoluteMin, span * factor));
|
||||
let newStart = centerYear - ratio * newSpan;
|
||||
let newEnd = centerYear + (1 - ratio) * newSpan;
|
||||
if (newStart < absoluteMin) {
|
||||
newEnd += absoluteMin - newStart;
|
||||
newStart = absoluteMin;
|
||||
}
|
||||
if (newEnd > absoluteMax) {
|
||||
newStart -= newEnd - absoluteMax;
|
||||
newEnd = absoluteMax;
|
||||
}
|
||||
onViewChange(Math.round(newStart), Math.round(newEnd));
|
||||
},
|
||||
[viewStart, span, absoluteMin, absoluteMax, onViewChange]
|
||||
);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent, mode: 'start' | 'end' | 'pan') => {
|
||||
e.preventDefault();
|
||||
setDragging(mode);
|
||||
dragStart.current = { y: e.clientY, viewStart, viewEnd };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
// Drag up (negative clientY delta) → toward later years at top → increase view.
|
||||
const dy = e.clientY - dragStart.current.y;
|
||||
const yearDelta = -(dy / rect.height) * span;
|
||||
|
||||
if (dragging === 'pan') {
|
||||
let ns = dragStart.current.viewStart - yearDelta;
|
||||
let ne = dragStart.current.viewEnd - yearDelta;
|
||||
if (ns < absoluteMin) {
|
||||
ne += absoluteMin - ns;
|
||||
ns = absoluteMin;
|
||||
}
|
||||
if (ne > absoluteMax) {
|
||||
ns -= ne - absoluteMax;
|
||||
ne = absoluteMax;
|
||||
}
|
||||
onViewChange(Math.round(ns), Math.round(ne));
|
||||
} else if (dragging === 'start') {
|
||||
const ns = Math.min(dragStart.current.viewEnd - 10, dragStart.current.viewStart + yearDelta);
|
||||
onViewChange(Math.round(ns), viewEnd);
|
||||
} else {
|
||||
const ne = Math.max(dragStart.current.viewStart + 10, dragStart.current.viewEnd + yearDelta);
|
||||
onViewChange(viewStart, Math.round(ne));
|
||||
}
|
||||
};
|
||||
const onUp = () => setDragging(null);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [dragging, span, viewStart, viewEnd, absoluteMin, absoluteMax, onViewChange]);
|
||||
|
||||
const zoomIn = () => {
|
||||
const center = (viewStart + viewEnd) / 2;
|
||||
const newSpan = Math.max(10, span * 0.5);
|
||||
onViewChange(Math.round(center - newSpan / 2), Math.round(center + newSpan / 2));
|
||||
};
|
||||
|
||||
const zoomOut = () => {
|
||||
const center = (viewStart + viewEnd) / 2;
|
||||
const newSpan = Math.min(absoluteMax - absoluteMin, span * 2);
|
||||
let ns = center - newSpan / 2;
|
||||
let ne = center + newSpan / 2;
|
||||
if (ns < absoluteMin) {
|
||||
ne += absoluteMin - ns;
|
||||
ns = absoluteMin;
|
||||
}
|
||||
if (ne > absoluteMax) {
|
||||
ns -= ne - absoluteMax;
|
||||
ne = absoluteMax;
|
||||
}
|
||||
onViewChange(Math.round(ns), Math.round(ne));
|
||||
};
|
||||
|
||||
const resetView = () => onViewChange(absoluteMin, absoluteMax);
|
||||
|
||||
const zoomToEra = useCallback(
|
||||
(era: HistoricalEra, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const eraSpan = era.end_year - era.start_year;
|
||||
const padding = Math.max(5, Math.round(eraSpan * 0.03));
|
||||
let start = Math.max(absoluteMin, era.start_year - padding);
|
||||
let end = Math.min(absoluteMax, era.end_year + padding);
|
||||
if (end - start < 10) {
|
||||
const center = (era.start_year + era.end_year) / 2;
|
||||
start = Math.max(absoluteMin, Math.round(center - 5));
|
||||
end = Math.min(absoluteMax, Math.round(center + 5));
|
||||
}
|
||||
onViewChange(Math.round(start), Math.round(end));
|
||||
},
|
||||
[absoluteMin, absoluteMax, onViewChange]
|
||||
);
|
||||
|
||||
const zoomToEvent = useCallback(
|
||||
(event: HistoricalEvent, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const end = eventEndYear(event);
|
||||
const eventSpan = Math.max(end - event.startYear, 1);
|
||||
const padding = Math.max(8, Math.round(eventSpan * 0.2));
|
||||
let start = Math.max(absoluteMin, event.startYear - padding);
|
||||
let endView = Math.min(absoluteMax, end + padding);
|
||||
if (endView - start < 10) {
|
||||
const center = (event.startYear + end) / 2;
|
||||
start = Math.max(absoluteMin, Math.round(center - 5));
|
||||
endView = Math.min(absoluteMax, Math.round(center + 5));
|
||||
}
|
||||
onViewChange(Math.round(start), Math.round(endView));
|
||||
},
|
||||
[absoluteMin, absoluteMax, onViewChange]
|
||||
);
|
||||
|
||||
const visibleEvents = useMemo(() => {
|
||||
const inView = HISTORICAL_EVENTS.filter((event) => eventInView(event, viewStart, viewEnd));
|
||||
const minLabelGapYears = span > 200 ? 40 : span > 80 ? 18 : span > 30 ? 10 : 5;
|
||||
let lastLabelYear = -Infinity;
|
||||
return inView.map((event) => {
|
||||
const end = eventEndYear(event);
|
||||
const labelAnchor = event.endYear ? (event.startYear + end) / 2 : event.startYear;
|
||||
const showLabel = labelAnchor - lastLabelYear >= minLabelGapYears;
|
||||
if (showLabel) lastLabelYear = labelAnchor;
|
||||
return { event, showLabel };
|
||||
});
|
||||
}, [viewStart, viewEnd, span]);
|
||||
|
||||
const lifespanBand = useMemo(() => {
|
||||
if (!lifespanHighlight) return null;
|
||||
const bottom = yearToBottomPercent(
|
||||
Math.max(lifespanHighlight.birthYear, viewStart),
|
||||
viewStart,
|
||||
viewEnd
|
||||
);
|
||||
const top = yearToBottomPercent(
|
||||
Math.min(lifespanHighlight.deathYear, viewEnd),
|
||||
viewStart,
|
||||
viewEnd
|
||||
);
|
||||
const height = top - bottom;
|
||||
if (height <= 0) return null;
|
||||
return { bottom, height, color: lifespanHighlight.color };
|
||||
}, [lifespanHighlight, viewStart, viewEnd]);
|
||||
|
||||
return (
|
||||
<aside className="vtimeline-wrapper">
|
||||
<div className="vtimeline-controls">
|
||||
<button type="button" onClick={zoomIn} title="Zoom in">
|
||||
+
|
||||
</button>
|
||||
<button type="button" onClick={zoomOut} title="Zoom out">
|
||||
−
|
||||
</button>
|
||||
<button type="button" onClick={resetView} title="Reset view">
|
||||
⟲
|
||||
</button>
|
||||
<span className="vtimeline-range">
|
||||
{formatYear(viewStart)}
|
||||
<br />—<br />
|
||||
{formatYear(viewEnd)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`vtimeline-container${lifespanBand ? ' vtimeline-container-lifespan-hover' : ''}`}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={(e) => handleMouseDown(e, 'pan')}
|
||||
>
|
||||
<div className="vtimeline-track">
|
||||
{eras.map((era) => {
|
||||
const bottom = yearToBottomPercent(Math.max(era.start_year, viewStart), viewStart, viewEnd);
|
||||
const top = yearToBottomPercent(Math.min(era.end_year, viewEnd), viewStart, viewEnd);
|
||||
if (top <= 0 || bottom >= 100) return null;
|
||||
const height = Math.min(100, top) - Math.max(0, bottom);
|
||||
return (
|
||||
<button
|
||||
key={era.id}
|
||||
type="button"
|
||||
className="vtimeline-era-block"
|
||||
style={{
|
||||
bottom: `${Math.max(0, bottom)}%`,
|
||||
height: `${height}%`,
|
||||
borderBottom: era.start_definite ? '2px solid rgba(255,255,255,0.6)' : undefined,
|
||||
borderTop: era.end_definite ? '2px solid rgba(255,255,255,0.6)' : undefined,
|
||||
background: `linear-gradient(0deg,
|
||||
${era.start_definite ? 'var(--era-color)' : 'transparent'} 0%,
|
||||
var(--era-color) 15%,
|
||||
var(--era-color) 85%,
|
||||
${era.end_definite ? 'var(--era-color)' : 'transparent'} 100%)`,
|
||||
['--era-color' as string]: getEraColor(era.name),
|
||||
}}
|
||||
title={`${era.name}: ${formatYear(era.start_year)} – ${formatYear(era.end_year)}`}
|
||||
onClick={(e) => zoomToEra(era, e)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="vtimeline-era-label">{era.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{lifespanBand && (
|
||||
<div className="vtimeline-lifespan-overlays" aria-hidden>
|
||||
{lifespanBand.bottom > 0 && (
|
||||
<div className="vtimeline-lifespan-dim" style={{ bottom: 0, height: `${lifespanBand.bottom}%` }} />
|
||||
)}
|
||||
{lifespanBand.bottom + lifespanBand.height < 100 && (
|
||||
<div
|
||||
className="vtimeline-lifespan-dim"
|
||||
style={{
|
||||
bottom: `${lifespanBand.bottom + lifespanBand.height}%`,
|
||||
height: `${100 - lifespanBand.bottom - lifespanBand.height}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="vtimeline-lifespan-highlight"
|
||||
style={{
|
||||
bottom: `${lifespanBand.bottom}%`,
|
||||
height: `${lifespanBand.height}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="vtimeline-events">
|
||||
{visibleEvents.map(({ event, showLabel }) => {
|
||||
const end = eventEndYear(event);
|
||||
const isSpan = event.endYear != null && event.endYear !== event.startYear;
|
||||
if (isSpan) {
|
||||
const bottom = yearToBottomPercent(Math.max(event.startYear, viewStart), viewStart, viewEnd);
|
||||
const top = yearToBottomPercent(Math.min(end, viewEnd), viewStart, viewEnd);
|
||||
if (top <= bottom) return null;
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
className="vtimeline-event-span"
|
||||
style={{ bottom: `${bottom}%`, height: `${top - bottom}%` }}
|
||||
title={event.name}
|
||||
onClick={(e) => zoomToEvent(event, e)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{showLabel && (
|
||||
<span className="vtimeline-event-label">{event.shortLabel ?? event.name}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const bottom = yearToBottomPercent(event.startYear, viewStart, viewEnd);
|
||||
if (bottom < 0 || bottom > 100) return null;
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
className="vtimeline-event-mark"
|
||||
style={{ bottom: `${bottom}%` }}
|
||||
title={event.name}
|
||||
onClick={(e) => zoomToEvent(event, e)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{showLabel && (
|
||||
<span className="vtimeline-event-label">{event.shortLabel ?? event.name}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="vtimeline-ticks">
|
||||
{ticks.map((year) => (
|
||||
<div
|
||||
key={year}
|
||||
className="vtimeline-tick"
|
||||
style={{ bottom: `${yearToBottomPercent(year, viewStart, viewEnd)}%` }}
|
||||
>
|
||||
<span>{formatYear(year)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="vtimeline-brush vtimeline-brush-start"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMouseDown(e, 'start');
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="vtimeline-brush vtimeline-brush-end"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMouseDown(e, 'end');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="vtimeline-hint">Bottom → top · Scroll to zoom · Drag to pan</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,8 @@
|
||||
"toursEditor": "Tour editor",
|
||||
"users": "Users",
|
||||
"audit": "Activity",
|
||||
"layoutHorizontal": "Classic timeline →",
|
||||
"layoutVertical": "↑ Vertical timeline",
|
||||
"openingTourGallery": "Opening guided tour…",
|
||||
"tourEmpty": "This tour has no paintings yet.",
|
||||
"tourLoadFailed": "Failed to load the tour.",
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"toursEditor": "Редактор экскурсий",
|
||||
"users": "Пользователи",
|
||||
"audit": "Активность",
|
||||
"layoutHorizontal": "Классическая шкала →",
|
||||
"layoutVertical": "↑ Вертикальная шкала",
|
||||
"openingTourGallery": "Открытие экскурсии…",
|
||||
"tourEmpty": "В этой экскурсии пока нет картин.",
|
||||
"tourLoadFailed": "Не удалось загрузить экскурсию.",
|
||||
|
||||
@@ -24,6 +24,45 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.home-timeline-stack-vertical {
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.home-movements-section-vertical {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.site-layout-switch {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
.site-layout-link {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
color: rgba(201, 169, 110, 0.9);
|
||||
font-size: 12px;
|
||||
font-family: 'Georgia', serif;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site-layout-link:hover {
|
||||
background: rgba(201, 169, 110, 0.18);
|
||||
color: #f5e6c8;
|
||||
}
|
||||
|
||||
.gallery-session-suspended {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import Timeline from '../components/Timeline';
|
||||
import TimelineEventGuides from '../components/TimelineEventGuides';
|
||||
import MovementBands from '../components/MovementBands';
|
||||
import VerticalTimeline from '../components/VerticalTimeline';
|
||||
import VerticalMovementBands from '../components/VerticalMovementBands';
|
||||
import VirtualGallery from '../components/VirtualGallery';
|
||||
import PaintingDetailView from '../components/PaintingDetail';
|
||||
import ArtistBio from '../components/ArtistBio';
|
||||
@@ -47,6 +49,7 @@ import './HomePage.css';
|
||||
|
||||
type View =
|
||||
| { type: 'timeline' }
|
||||
| { type: 'timeline-vertical' }
|
||||
| { type: 'checkup' }
|
||||
| { type: 'translations' }
|
||||
| { type: 'influences' }
|
||||
@@ -207,7 +210,7 @@ export default function HomePage() {
|
||||
setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data });
|
||||
} else if (view.type === 'tour-gallery') {
|
||||
setGallerySession({ kind: 'tour', tourId: view.tourId, data: view.data });
|
||||
} else if (view.type === 'timeline') {
|
||||
} else if (view.type === 'timeline' || view.type === 'timeline-vertical') {
|
||||
setGallerySession(null);
|
||||
}
|
||||
}, [view]);
|
||||
@@ -271,6 +274,17 @@ export default function HomePage() {
|
||||
setView({ type: 'timeline' });
|
||||
}, [bounds.min, bounds.max]);
|
||||
|
||||
const openHorizontalTimeline = () => {
|
||||
setView({ type: 'timeline' });
|
||||
};
|
||||
|
||||
const openVerticalTimeline = () => {
|
||||
setView({ type: 'timeline-vertical' });
|
||||
};
|
||||
|
||||
const isTimelineHome = view.type === 'timeline' || view.type === 'timeline-vertical';
|
||||
const isVerticalTimeline = view.type === 'timeline-vertical';
|
||||
|
||||
const toggleDebugMode = () => {
|
||||
setDebugMode((prev) => {
|
||||
const next = !prev;
|
||||
@@ -1204,9 +1218,30 @@ export default function HomePage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{view.type === 'timeline' && (
|
||||
{isTimelineHome && (
|
||||
<div className="home-page">
|
||||
<header className="site-header">
|
||||
<div className="site-layout-switch">
|
||||
{isVerticalTimeline ? (
|
||||
<button
|
||||
type="button"
|
||||
className="site-layout-link"
|
||||
onClick={openHorizontalTimeline}
|
||||
title="Switch to classic left-to-right timeline"
|
||||
>
|
||||
{t('layoutHorizontal')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="site-layout-link"
|
||||
onClick={openVerticalTimeline}
|
||||
title="Switch to bottom-up vertical timeline"
|
||||
>
|
||||
{t('layoutVertical')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="site-dev-tools">
|
||||
{isCurator ? (
|
||||
<>
|
||||
@@ -1334,7 +1369,7 @@ export default function HomePage() {
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div className="home-timeline-stack">
|
||||
<div className={`home-timeline-stack${isVerticalTimeline ? ' home-timeline-stack-vertical' : ''}`}>
|
||||
{loading && (
|
||||
<GalleryLoadingMarker overlay message="Loading art history…" />
|
||||
)}
|
||||
@@ -1345,37 +1380,67 @@ export default function HomePage() {
|
||||
<GalleryLoadingMarker banner message="Loading portraits…" />
|
||||
)}
|
||||
|
||||
<Timeline
|
||||
eras={timelineData.eras}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
onViewChange={handleViewChange}
|
||||
absoluteMin={bounds.min}
|
||||
absoluteMax={bounds.max}
|
||||
lifespanHighlight={hoveredLifespan}
|
||||
/>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{!loading && (
|
||||
{isVerticalTimeline ? (
|
||||
<>
|
||||
<TimelineEventGuides viewStart={viewStart} viewEnd={viewEnd} />
|
||||
<div className="home-movements-section">
|
||||
<MovementBands
|
||||
movements={timelineData.movements}
|
||||
artists={artists}
|
||||
portraitRevisions={portraitRevisions}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
absoluteMin={bounds.min}
|
||||
absoluteMax={bounds.max}
|
||||
onViewChange={handleViewChange}
|
||||
onArtistClick={handleArtistClick}
|
||||
onMovementClick={handleMovementClick}
|
||||
onArtistHover={setHoveredLifespan}
|
||||
onPortraitsLoadingChange={setPortraitsLoading}
|
||||
/>
|
||||
</div>
|
||||
<VerticalTimeline
|
||||
eras={timelineData.eras}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
onViewChange={handleViewChange}
|
||||
absoluteMin={bounds.min}
|
||||
absoluteMax={bounds.max}
|
||||
lifespanHighlight={hoveredLifespan}
|
||||
/>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{!loading && (
|
||||
<div className="home-movements-section-vertical">
|
||||
<VerticalMovementBands
|
||||
movements={timelineData.movements}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
absoluteMin={bounds.min}
|
||||
absoluteMax={bounds.max}
|
||||
onViewChange={handleViewChange}
|
||||
onMovementClick={handleMovementClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Timeline
|
||||
eras={timelineData.eras}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
onViewChange={handleViewChange}
|
||||
absoluteMin={bounds.min}
|
||||
absoluteMax={bounds.max}
|
||||
lifespanHighlight={hoveredLifespan}
|
||||
/>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
<TimelineEventGuides viewStart={viewStart} viewEnd={viewEnd} />
|
||||
<div className="home-movements-section">
|
||||
<MovementBands
|
||||
movements={timelineData.movements}
|
||||
artists={artists}
|
||||
portraitRevisions={portraitRevisions}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
absoluteMin={bounds.min}
|
||||
absoluteMax={bounds.max}
|
||||
onViewChange={handleViewChange}
|
||||
onArtistClick={handleArtistClick}
|
||||
onMovementClick={handleMovementClick}
|
||||
onArtistHover={setHoveredLifespan}
|
||||
onPortraitsLoadingChange={setPortraitsLoading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user