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
+16
-8
@@ -6,8 +6,8 @@ Interactive virtual museum spanning art history: zoomable timeline with event gu
|
||||
|
||||
The app is organised as a **drill-down hierarchy**:
|
||||
|
||||
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries.
|
||||
2. **Movement flow** — art movements as curved SVG streams on the same year axis; documented predecessor→successor branches; portrait thumbnails placed along each stream.
|
||||
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries; classic left→right or vertical bottom→up layout (header link).
|
||||
2. **Movement flow** — art movements as SVG streams on the same year axis; documented predecessor→successor branches (classic); portrait thumbnails along each stream.
|
||||
3. **3D gallery** — one personal hall per artist, a **movement gallery** (click a movement name → artist filter → hall), or a **guided tour** hall (timeline → **Tours**): period-themed or tour wings of up to ~55 works, U-shaped hang (left → end wall → right).
|
||||
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **curator notes** and **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography.
|
||||
5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**).
|
||||
@@ -45,8 +45,11 @@ Gallery/
|
||||
│ │ ├── components/ArtistBio.tsx # Biography + portrait debug panel
|
||||
│ │ ├── components/DebugSearchResultsModal.tsx # “More” search picker (20 results)
|
||||
│ │ ├── components/Timeline.tsx # Era bar, year ticks, event markers
|
||||
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow
|
||||
│ │ ├── components/Timeline.tsx # Classic horizontal era bar
|
||||
│ │ ├── components/VerticalTimeline.tsx # Bottom-up vertical era rail
|
||||
│ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches)
|
||||
│ │ ├── components/VerticalMovementBands.tsx # Bottom-up movement streams
|
||||
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow
|
||||
│ │ ├── components/CatalogSearchBar.tsx # Timeline header catalog search
|
||||
│ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail
|
||||
│ │ ├── pages/CheckupPage.tsx # Image audit table
|
||||
@@ -170,14 +173,19 @@ Implementation: `goToTimelineHome()` in `HomePage.tsx` — do not use the browse
|
||||
|
||||
## Timeline and movement flow
|
||||
|
||||
The home page shows two linked views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`):
|
||||
The home page shows two linked views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`). A header link switches between layouts:
|
||||
|
||||
| Layout | Era rail | Movement flow | Time direction |
|
||||
|--------|----------|---------------|----------------|
|
||||
| **Classic** (default) | `Timeline.tsx` (top bar) | `MovementBands.tsx` | Left → right |
|
||||
| **Vertical** | `VerticalTimeline.tsx` (left rail) | `VerticalMovementBands.tsx` (streams + lineage branches; no portraits) | Bottom → top |
|
||||
|
||||
| View | Component | Purpose |
|
||||
|------|-----------|---------|
|
||||
| Era bar | `Timeline.tsx` | Historical eras, major event markers, click-to-zoom |
|
||||
| Movement flow | `MovementBands.tsx` | Curved streams per movement, lineage branches, artist portraits |
|
||||
| Era bar / rail | `Timeline.tsx` / `VerticalTimeline.tsx` | Historical eras, major event markers, click-to-zoom |
|
||||
| Movement flow | `MovementBands.tsx` / `VerticalMovementBands.tsx` | Streams per movement, artist portraits (classic also draws lineage branches) |
|
||||
|
||||
Both views share zoom/pan behaviour via `client/src/utils/timelineView.ts` (`zoomTimelineView`, `panTimelineView`, `chooseTimelineTickInterval`, `createViewChangeScheduler`). The home page uses a **fixed viewport** (`100vh`): timeline + movement flow sit in a shared `home-timeline-stack` so event guide lines can extend from the era bar down through the movement canvas. The movement flow compresses vertically so all movements in the visible year range fit without page scrolling.
|
||||
Both layouts share zoom/pan behaviour via `client/src/utils/timelineView.ts` (`zoomTimelineView`, `panTimelineView`, `chooseTimelineTickInterval`, `createViewChangeScheduler`). The home page uses a **fixed viewport** (`100vh`). Classic stacks timeline above movements; vertical places the year rail beside the flow (`home-timeline-stack-vertical`).
|
||||
|
||||
### Catalog search (timeline header)
|
||||
|
||||
@@ -347,7 +355,7 @@ Enter from the home page by clicking a **movement name** on the movement flow. F
|
||||
| Period details | `MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** (never freestanding mid-hall or proud corner shafts that cover frames); `byzantine` adds engaged porphyry colonnettes with basket capitals, a marble revetment dado, and hanging brass polycandela; `gothic` adds bay-spaced compound piers with vault springers, transverse ribs arching across the nave, and a moulded string course — all flush to the side walls |
|
||||
| Back wall | Single wing: solid display wall. Multi-wing: **Exit double doors** → **Wing navigator** or **Exit to Timeline** |
|
||||
| Front / entrance wall | Single wing: **Exit double doors** (leave the way you entered). Multi-wing: **“Next wing →”** archway when a later wing exists |
|
||||
| Influence lamps | Same golden upside-down emissive fixtures as artist halls when `has_influence_links` is true |
|
||||
| Influence lamps | Same golden upside-down emissive fixtures as artist halls when `has_influence_links` is true (shared rail: 40 cm above the tallest frame in the wing) |
|
||||
| Missing images | Draped canvas cover in frame |
|
||||
| Detail return | **Back to Gallery** from painting detail returns to the same wing with camera preserved; **Back to Timeline** exits the hall entirely |
|
||||
|
||||
|
||||
@@ -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