4 Commits
16 changed files with 1460 additions and 166 deletions
+19 -13
View File
@@ -6,8 +6,8 @@ Interactive virtual museum spanning art history: zoomable timeline with event gu
The app is organised as a **drill-down hierarchy**: The app is organised as a **drill-down hierarchy**:
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries; classic left→right or vertical bottom→up layout (header link). 1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries; classic left→right, vertical bottom→up, or **tree** bottom→up layout (header links).
2. **Movement flow** — art movements as SVG streams on the same year axis; documented predecessor→successor branches (classic); portrait thumbnails along each stream. 2. **Movement flow** — art movements as SVG streams on the same year axis; documented predecessor→successor branches (classic); portrait thumbnails along each stream. The tree layout redraws the same lineage as a growing tree — see [movement-tree.md](movement-tree.md).
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). 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. 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**). 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**).
@@ -44,11 +44,11 @@ Gallery/
│ │ ├── components/PaintingDetail.tsx # Detail view + debug panel │ │ ├── components/PaintingDetail.tsx # Detail view + debug panel
│ │ ├── components/ArtistBio.tsx # Biography + portrait debug panel │ │ ├── components/ArtistBio.tsx # Biography + portrait debug panel
│ │ ├── components/DebugSearchResultsModal.tsx # “More” search picker (20 results) │ │ ├── components/DebugSearchResultsModal.tsx # “More” search picker (20 results)
│ │ ├── components/Timeline.tsx # Era bar, year ticks, event markers
│ │ ├── components/Timeline.tsx # Classic horizontal era bar │ │ ├── components/Timeline.tsx # Classic horizontal era bar
│ │ ├── components/VerticalTimeline.tsx # Bottom-up vertical era rail │ │ ├── components/VerticalTimeline.tsx # Bottom-up vertical era rail
│ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches) │ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches)
│ │ ├── components/VerticalMovementBands.tsx # Bottom-up movement streams │ │ ├── components/VerticalMovementBands.tsx # Bottom-up movement streams
│ │ ├── components/MovementTree.tsx # Bottom-up movement tree (alternative start page)
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow │ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow
│ │ ├── components/CatalogSearchBar.tsx # Timeline header catalog search │ │ ├── components/CatalogSearchBar.tsx # Timeline header catalog search
│ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail │ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail
@@ -60,13 +60,14 @@ Gallery/
│ │ ├── pages/AuditPage.tsx # Admin curator activity reports │ │ ├── pages/AuditPage.tsx # Admin curator activity reports
│ │ ├── components/ToursPopup.tsx # Public published-tours modal │ │ ├── components/ToursPopup.tsx # Public published-tours modal
│ │ ├── i18n/ # react-i18next bootstrap │ │ ├── i18n/ # react-i18next bootstrap
│ │ ── locales/{en,ru}/ # UI chrome strings │ │ ── locales/{en,ru}/ # UI chrome strings (incl. timeline layout + captions)
│ │ ├── data/historical-events.ts # Timeline event markers (UI) │ │ ├── data/historical-events.ts # Timeline event markers (UI)
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI) │ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor │ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor
│ │ ├── utils/debugMode.ts # Debug mode + “Show more” localStorage prefs │ │ ├── utils/debugMode.ts # Debug mode + “Show more” localStorage prefs
│ │ ── utils/timelineView.ts # Shared zoom/pan math for timeline + movements │ │ ── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
└── dist/ # Production build (served by API when present) │ ├── utils/movementColor.ts # Shared vivid/shade hex helpers for all movement charts
│ │ └── utils/movementTree.ts # View-independent Tree of Art layout engine│ └── dist/ # Production build (served by API when present)
├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools ├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools
│ ├── seed-wikipedia.js │ ├── seed-wikipedia.js
│ ├── seed-catalog-data.js │ ├── seed-catalog-data.js
@@ -173,20 +174,25 @@ Implementation: `goToTimelineHome()` in `HomePage.tsx` — do not use the browse
## Timeline and movement flow ## Timeline and movement flow
The home page shows two linked views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`). A header link switches between layouts: The home page shows linked era + movement views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`). Header links switch among three layouts; the active layout is also deep-linked:
| Layout | Era rail | Movement flow | Time direction | | Layout | Era rail | Movement flow | Time direction | URL |
|--------|----------|---------------|----------------| |--------|----------|---------------|----------------|-----|
| **Classic** (default) | `Timeline.tsx` (top bar) | `MovementBands.tsx` | Left → right | | **Classic** (default) | `Timeline.tsx` (top bar) | `MovementBands.tsx` | Left → right | omit or `?layout=classic` |
| **Vertical** | `VerticalTimeline.tsx` (left rail) | `VerticalMovementBands.tsx` (streams + lineage branches; no portraits) | Bottom → top | | **Vertical** | `VerticalTimeline.tsx` (left rail) | `VerticalMovementBands.tsx` (streams + lineage; no portraits) | Bottom → top | `?layout=vertical` |
| **Tree of art** | `VerticalTimeline.tsx` (left rail) | `MovementTree.tsx` (lineage as a growing tree; no portraits) | Bottom → top | `?layout=tree` |
`HomePage` reads `?layout=` once on load and calls `history.replaceState` when the user switches. Classic clears the param so the default URL stays clean. Alias `horizontal` maps to classic.
| View | Component | Purpose | | View | Component | Purpose |
|------|-----------|---------| |------|-----------|---------|
| Era bar / rail | `Timeline.tsx` / `VerticalTimeline.tsx` | Historical eras, major event markers, click-to-zoom | | 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) | | Movement flow | `MovementBands.tsx` / `VerticalMovementBands.tsx` / `MovementTree.tsx` | Streams or tree limbs per movement; classic also places artist portraits |
| Stream colours | `utils/movementColor.ts` | Shared `vividMovementColor` / `shadeMovementColor` for all three charts |
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`). Hint captions under each rail/chart (`captionClassicTimeline`, `captionClassicFlow`, `captionVerticalTimeline`, `captionVerticalFlow`, `captionTreeFlow`) live in `locales/{en,ru}/home.json` and follow the EN|RU toggle.
All three 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 and tree place the year rail beside the flow (`home-timeline-stack-vertical`). The tree layout keeps its horizontal geometry fixed across zoom — rules in [movement-tree.md](movement-tree.md).
### Catalog search (timeline header) ### Catalog search (timeline header)
`CatalogSearchBar.tsx` calls `GET /api/search?q=…` (public, no login). The dropdown is stacked above the timeline (`z-index` on `.site-header`) so results are not hidden by movement bands. `CatalogSearchBar.tsx` calls `GET /api/search?q=…` (public, no login). The dropdown is stacked above the timeline (`z-index` on `.site-header`) so results are not hidden by movement bands.
+10
View File
@@ -25,6 +25,16 @@ Timeline header: **EN | RU** toggle (`LocaleSwitcher`).
- Passes `?locale=ru` on catalog API requests - Passes `?locale=ru` on catalog API requests
- Refetches bootstrap catalog when locale changes - Refetches bootstrap catalog when locale changes
Timeline **layout** chrome and chart hints are also localised in `home.json`:
| Key | Where |
|-----|--------|
| `layoutHorizontal` / `layoutVertical` / `layoutTree` | Header layout switch |
| `captionClassicTimeline` / `captionClassicFlow` | Classic era bar + movement streams |
| `captionVerticalTimeline` / `captionVerticalFlow` | Vertical rail + streams |
| `captionTreeFlow` | Tree of Art chart |
Shareable layout URLs (`?layout=tree` etc.) are language-independent; captions follow the active locale.
--- ---
## Setup (dev) ## Setup (dev)
+107
View File
@@ -0,0 +1,107 @@
# Tree of Art — alternative start page
An alternative landing layout for the timeline: the year axis runs **bottom → top**
and the art movements are drawn as a **growing tree** instead of parallel streams.
Reached from the **🌳 Tree of art** link at the top-left of every timeline page
(`layoutTree`); the classic and vertical layouts stay untouched and are one click away.
Shareable URL: `?layout=tree` (also `vertical` / omit or `classic` for the other layouts).
| | Classic | Vertical | **Tree** |
|---|---|---|---|
| Component | [`MovementBands`](../client/src/components/MovementBands.tsx) | [`VerticalMovementBands`](../client/src/components/VerticalMovementBands.tsx) | [`MovementTree`](../client/src/components/MovementTree.tsx) |
| Time axis | left → right | bottom → top | bottom → top |
| Layout | temporal lanes | centre-out lanes | spanning tree |
| Recomputed on zoom | yes | yes | **no — structure is fixed** |
| Artist portraits | yes | no | no |
Both bottom-up layouts share the [`VerticalTimeline`](../client/src/components/VerticalTimeline.tsx)
axis, so eras, event marks, zoom and pan behave identically across them.
## Layout rules
The geometry splits in two: [`utils/movementTree.ts`](../client/src/utils/movementTree.ts)
decides the **shape of the tree** (horizontal, view-independent), and `MovementTree`
maps that shape onto the **current year window** (vertical) each frame.
### Structure — `buildMovementTree()`
1. **Time grows upward.** Oldest movements at the bottom, newest at the crown.
Y is a plain `year → pixel` mapping; the layout engine never touches it.
2. **One trunk, at the centre.** `MOVEMENT_LINEAGE` is a DAG, so it is reduced to a
spanning tree: each movement keeps its **most immediate predecessor** (the parent
with the latest start year that still precedes it) as its structural parent.
Ranking by start year first makes cycles impossible by construction.
3. **Extra parents become grafts.** The predecessors that lost step 2 are still drawn —
as thin, low-opacity limbs behind the tree — so `Post-Impressionism → Cubism`
survives even though Cubism hangs structurally off Fauvism.
4. **Children split the parent's slot.** Each node reserves a slot as wide as its whole
subtree (`max(own limb, Σ children)`); children are packed side by side and centred
on the parent. A single-child chain inherits the parent's x exactly — the trunk stays
straight until it forks, forks spread symmetrically, and later generations land
further from the centre.
5. **Leonardo's rule for thickness.** A limb is as thick as the limbs it carries:
`base² = own² + Σ child.base²`. The trunk is the thickest thing on screen and every
branch tapers as it rises and sheds children. A movement's *own* thickness comes from
its `influence_link_count`.
6. **Branches lean outward** across their own lifespan, by at most the slack left inside
their slot — organic, and collision-free by construction.
7. **Unlinked movements are saplings.** A movement with no lineage edge is its own root;
extra roots are planted alternately right and left of the trunk, widest subtree first,
so the main trunk keeps x = 0 (canvas centre). Roots get a small root flare.
Because the structure is built from the **whole catalogue**, zooming never reshuffles the
tree — you keep your bearings, unlike the lane-packed layouts which re-pack on every view
change.
### Rendering — `MovementTree`
8. **Spread follows zoom, not the canvas.** 90 % of the catalogue lives in the last 15 %
of the time axis, so a tree stretched to full width with all of history in view is one
long trunk under a flat bar. The whole-history view draws the tree at
`FULL_VIEW_WIDTH_SHARE` (52 %) of the available width, and each zoom step fans the
crown out (`(totalSpan / visibleSpan) ^ 0.45`, capped at `MAX_FIT_BOOST`). The chart
grows as you walk up it. Whatever is on screen is always clamped to fit the canvas.
9. **Readability floors, never date changes.** A 30-year movement is ~8 px tall with 2 900
years in view. So a limb is drawn at least `MIN_LIMB_RISE_PX` long, a junction climbs at
least `MIN_JUNCTION_RISE_PX` before it spreads sideways, and **a limb is never thicker
than 55 % of its own length**. Positions still come from real years; only the drawn
length and thickness have a floor, and the junction slides down the parent limb (never
off it) to find its rise.
10. **Ribbons, not strokes.** Limbs are filled ribbons sampled along a cubic and offset
along the curve *normal*, so a junction stays solid even when a zoomed-out view
squeezes it almost flat. Shading runs dark → colour → dark across each limb for a
rounded, woody read.
11. **Greedy label declutter.** Every visible movement asks for a name; closest to the
trunk wins, and names that would collide with a placed one — or fall off the canvas —
stay hidden until you zoom in on them.
12. **Hover lights the descent line.** Hovering a movement brightens its whole path back
to the root (grafts included) and dims the rest — the fastest way to read "where did
this come from".
Clicking any limb or label opens the movement's artist picker and then its 3D movement
gallery, exactly as the other two layouts do.
## Colours and captions
- Limb fill/shading uses [`utils/movementColor.ts`](../client/src/utils/movementColor.ts)
(`vividMovementColor`, `shadeMovementColor`) — the same helpers as the classic and
vertical charts. Malformed catalogue hex falls back to the input string; values longer
than six digits keep the first six (`#rrggbbaa``#rrggbb`).
- The chart hint under the canvas is `captionTreeFlow` in
`locales/{en,ru}/home.json` (same pattern as the other layouts).
## Tuning
All constants sit at the top of the two files and are safe to tune:
| Constant | File | Effect |
|---|---|---|
| `LEAF_SLOT_PX`, `LIMB_GAP_PX` | `movementTree.ts` | how far apart branches sit |
| `MIN_LIMB_PX`, `MAX_OWN_LIMB_PX`, `MAX_TRUNK_PX` | `movementTree.ts` | thickness range |
| `LEAN_SLACK`, `MAX_LEAN_PX` | `movementTree.ts` | how much limbs bend outward |
| `FULL_VIEW_WIDTH_SHARE`, `ZOOM_SPREAD_EXPONENT`, `MAX_FIT_BOOST` | `MovementTree.tsx` | crown spread vs. zoom |
| `MIN_LIMB_RISE_PX`, `MIN_JUNCTION_RISE_PX`, `MAX_THICKNESS_OF_LENGTH` | `MovementTree.tsx` | crown legibility at full zoom-out |
New lineage edges only need adding to
[`client/src/data/movement-lineage.ts`](../client/src/data/movement-lineage.ts) — the tree
picks up parents, grafts, thickness and spacing from there automatically.
+2 -1
View File
@@ -1,12 +1,13 @@
# Art Gallery # Art Gallery
Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, **curator-gated** debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**; painting detail also **Remove entry**), optional **Show more** auto-opens the search picker, Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. Anonymous visitors browse freely; curators sign in via **Curator login** in the header. Interactive virtual art gallery: zoomable historical timeline (classic left→right, vertical, or **Tree of Art** lineage chart — shareable via `?layout=`) with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, **curator-gated** debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**; painting detail also **Remove entry**), optional **Show more** auto-opens the search picker, Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. Anonymous visitors browse freely; curators sign in via **Curator login** in the header.
## Documentation ## Documentation
| Document | Purpose | | Document | Purpose |
|----------|---------| |----------|---------|
| [Documentation/basics.md](Documentation/basics.md) | Architecture, layout, user flow | | [Documentation/basics.md](Documentation/basics.md) | Architecture, layout, user flow |
| [Documentation/movement-tree.md](Documentation/movement-tree.md) | **Tree of Art** start page — tree layout rules |
| [Documentation/FAC.md](Documentation/FAC.md) | **Command cheat sheet** — start/stop, import, deploy | | [Documentation/FAC.md](Documentation/FAC.md) | **Command cheat sheet** — start/stop, import, deploy |
| [Documentation/environments.md](Documentation/environments.md) | Dev/prod URLs, DB split, deploy, sync | | [Documentation/environments.md](Documentation/environments.md) | Dev/prod URLs, DB split, deploy, sync |
| [Documentation/setup.md](Documentation/setup.md) | Install, env, npm scripts | | [Documentation/setup.md](Documentation/setup.md) | Install, env, npm scripts |
+4 -69
View File
@@ -1,9 +1,11 @@
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState, memo } from 'react'; import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState, memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { ArtMovement, Artist } from '../types'; import type { ArtMovement, Artist } from '../types';
import { portraitThumbUrl } from '../api/client'; import { portraitThumbUrl } from '../api/client';
import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc'; import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc';
import { MOVEMENT_LINEAGE } from '../data/movement-lineage'; import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
import { panTimelineView, zoomTimelineView } from '../utils/timelineView'; import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
import { parseHexColor, rgbToHsl, hslToHex, vividMovementColor } from '../utils/movementColor';
import './MovementBands.css'; import './MovementBands.css';
interface Props { interface Props {
@@ -244,74 +246,6 @@ function buildArtistPlacements(
return placements; return placements;
} }
function parseHexColor(hex: string): [number, number, number] {
const normalized = hex.replace('#', '');
const value =
normalized.length === 3
? normalized
.split('')
.map((c) => c + c)
.join('')
: normalized.padStart(6, '0').slice(0, 6);
return [
parseInt(value.slice(0, 2), 16),
parseInt(value.slice(2, 4), 16),
parseInt(value.slice(4, 6), 16),
];
}
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
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 === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
else h = ((rn - gn) / 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)}`;
}
/** Boost saturation / mid lightness so streams read vividly on the dark flow canvas. */
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 artistLifespanColor(baseColor: string, laneIndex: number, laneCount: number): string { function artistLifespanColor(baseColor: string, laneIndex: number, laneCount: number): string {
if (laneCount <= 1) return baseColor; if (laneCount <= 1) return baseColor;
try { try {
@@ -1762,6 +1696,7 @@ export default function MovementBands({
onArtistHover, onArtistHover,
onPortraitsLoadingChange, onPortraitsLoadingChange,
}: Props) { }: Props) {
const { t } = useTranslation('home');
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
const pendingPortraitsRef = useRef(0); const pendingPortraitsRef = useRef(0);
const [portraitsLoading, setPortraitsLoading] = useState(false); const [portraitsLoading, setPortraitsLoading] = useState(false);
@@ -2260,7 +2195,7 @@ export default function MovementBands({
return ( return (
<div className="movements-flow"> <div className="movements-flow">
<p className="movements-flow-caption"> <p className="movements-flow-caption">
Scroll to zoom · drag to pan · each movement stream is a solid colour band through history {t('captionClassicFlow')}
</p> </p>
<div <div
+125
View File
@@ -0,0 +1,125 @@
.mtree {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 8px 12px 12px;
}
.mtree-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;
}
.mtree-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;
}
.mtree-canvas {
position: relative;
flex: 1;
min-height: 0;
width: 100%;
border-radius: 8px;
background:
radial-gradient(ellipse 60% 45% at 50% 100%, rgba(201, 169, 110, 0.1), transparent 72%),
radial-gradient(ellipse 80% 60% at 50% 0%, rgba(120, 150, 190, 0.08), transparent 70%),
rgba(0, 0, 0, 0.28);
border: 1px solid rgba(201, 169, 110, 0.12);
overflow: hidden;
cursor: grab;
touch-action: none;
}
.mtree-canvas.mtree-panning {
cursor: grabbing;
}
.mtree-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.mtree-limb {
cursor: pointer;
opacity: 0.9;
transition: opacity 0.18s ease, filter 0.18s ease;
}
.mtree-limb-lit {
opacity: 1;
filter: brightness(1.22) drop-shadow(0 0 6px rgba(255, 226, 170, 0.35));
}
.mtree-limb-dim {
opacity: 0.34;
}
.mtree-root {
opacity: 0.72;
}
.mtree-graft {
opacity: 0.3;
pointer-events: none;
transition: opacity 0.18s ease;
}
.mtree-graft-lit {
opacity: 0.85;
}
.mtree-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.74);
color: rgba(245, 230, 200, 0.95);
font-family: 'Georgia', serif;
font-size: 12px;
white-space: nowrap;
cursor: pointer;
pointer-events: auto;
transition: opacity 0.18s ease;
}
.mtree-label:hover {
background: rgba(30, 28, 40, 0.92);
color: #fff6e0;
}
.mtree-label-dim {
opacity: 0.35;
}
.mtree-out-of-range {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
text-align: center;
color: rgba(201, 169, 110, 0.7);
font-family: 'Georgia', serif;
}
+645
View File
@@ -0,0 +1,645 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { ArtMovement } from '../types';
import {
branchOriginYear,
buildMovementTree,
limbXAtYear,
type MovementTreeNode,
} from '../utils/movementTree';
import { shadeMovementColor, vividMovementColor } from '../utils/movementColor';
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
import './MovementTree.css';
interface Props {
movements: ArtMovement[];
viewStart: number;
viewEnd: number;
absoluteMin: number;
absoluteMax: number;
onViewChange: (start: number, end: number) => void;
onMovementClick?: (movementId: number) => void;
}
interface Pt {
x: number;
y: number;
}
const SIDE_PAD = 56;
/**
* Horizontal spread is tied to the zoom, not to the canvas.
*
* 90 % of the catalogue lives in the last 15 % of the time axis, so a tree
* stretched to full width with all of history in view is one long trunk under a
* flat bar. Instead the whole-history view draws a narrow tree, and every zoom
* step fans the crown out the chart grows as you walk up it.
*/
const FULL_VIEW_WIDTH_SHARE = 0.52;
const ZOOM_SPREAD_EXPONENT = 0.45;
/** How far past "everything fits" the tree may be blown up when zoomed in. */
const MAX_FIT_BOOST = 2.4;
/** Exponential chase rate (1/s) for the horizontal fit, so zoom reads as growth. */
const FIT_ANIM_RATE = 9;
const RIBBON_SAMPLES = 18;
/** Vertical room a label needs. */
const MIN_LABEL_HEIGHT_PX = 20;
/**
* Readability floors. A 30-year movement is 8 px tall when the whole of
* history is on screen; without these the modern crown fuses into one bar.
*/
const MIN_LIMB_RISE_PX = 30;
const MIN_JUNCTION_RISE_PX = 38;
/** A limb is never drawn thicker than this share of its own length. */
const MAX_THICKNESS_OF_LENGTH = 0.55;
function cubicAt(p0: Pt, c1: Pt, c2: Pt, p3: Pt, t: number): Pt {
const u = 1 - t;
const a = u * u * u;
const b = 3 * u * u * t;
const c = 3 * u * t * t;
const d = t * t * t;
return {
x: a * p0.x + b * c1.x + c * c2.x + d * p3.x,
y: a * p0.y + b * c1.y + c * c2.y + d * p3.y,
};
}
function cubicTangent(p0: Pt, c1: Pt, c2: Pt, p3: Pt, t: number): Pt {
const u = 1 - t;
const a = 3 * u * u;
const b = 6 * u * t;
const c = 3 * t * t;
return {
x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p3.x - c2.x),
y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y),
};
}
/**
* Filled ribbon of varying width along a cubic. Offsetting along the curve
* normal (rather than horizontally) keeps branch junctions solid even when a
* zoomed-out view squeezes them almost flat.
*/
function ribbonPath(p0: Pt, c1: Pt, c2: Pt, p3: Pt, w0: number, w1: number): string {
const left: Pt[] = [];
const right: Pt[] = [];
for (let i = 0; i <= RIBBON_SAMPLES; i++) {
const t = i / RIBBON_SAMPLES;
const p = cubicAt(p0, c1, c2, p3, t);
const d = cubicTangent(p0, c1, c2, p3, t);
const len = Math.hypot(d.x, d.y) || 1;
const nx = -d.y / len;
const ny = d.x / len;
const half = (w0 + (w1 - w0) * t) / 2;
left.push({ x: p.x + nx * half, y: p.y + ny * half });
right.push({ x: p.x - nx * half, y: p.y - ny * half });
}
const fmt = (pt: Pt) => `${pt.x.toFixed(2)} ${pt.y.toFixed(2)}`;
const forward = left.map((pt, i) => `${i === 0 ? 'M' : 'L'} ${fmt(pt)}`).join(' ');
const back = right
.slice()
.reverse()
.map((pt) => `L ${fmt(pt)}`)
.join(' ');
return `${forward} ${back} Z`;
}
interface LimbShape {
id: number;
name: string;
color: string;
shade: string;
/** Trunk / limb body. */
d: string;
/** Junction ribbon growing out of the structural parent (may be empty). */
junction: string;
/** Rounded tip cap. */
tip: Pt & { r: number };
/** Root flare under a tree root, drawn only when the base is on screen. */
roots: string[];
labelX: number;
labelY: number;
labelVisible: boolean;
yearRange: string;
depth: number;
inView: boolean;
}
interface GraftShape {
key: string;
d: string;
color: string;
fromId: number;
toId: number;
}
function formatYear(year: number): string {
return year < 0 ? `${Math.abs(year)} BCE` : `${year}`;
}
/**
* Greedy declutter: closer to the trunk wins. Every visible movement asks for a
* name, and the ones that would collide with an already-placed name or fall
* off the canvas stay anonymous until you zoom in on them.
*/
function hideOverlappingLabels(limbs: LimbShape[], width: number, height: number): void {
const placed: { x0: number; y0: number; x1: number; y1: number }[] = [];
const candidates = limbs
.map((limb, index) => ({ limb, index }))
.filter(({ limb }) => limb.labelVisible)
.sort((a, b) => a.limb.depth - b.limb.depth || b.limb.labelY - a.limb.labelY);
for (const { limb } of candidates) {
const halfW = (limb.name.length * 6.6 + 16) / 2;
const halfH = MIN_LABEL_HEIGHT_PX / 2;
const box = {
x0: limb.labelX - halfW,
y0: limb.labelY - halfH,
x1: limb.labelX + halfW,
y1: limb.labelY + halfH,
};
const offCanvas = box.x0 < 2 || box.x1 > width - 2 || box.y0 < 2 || box.y1 > height - 2;
const collides = placed.some(
(p) => box.x0 < p.x1 && box.x1 > p.x0 && box.y0 < p.y1 && box.y1 > p.y0
);
if (offCanvas || collides) {
limb.labelVisible = false;
continue;
}
placed.push(box);
}
}
export default function MovementTree({
movements,
viewStart,
viewEnd,
absoluteMin,
absoluteMax,
onViewChange,
onMovementClick,
}: Props) {
const { t } = useTranslation('home');
const canvasRef = useRef<HTMLDivElement>(null);
const [canvas, setCanvas] = useState({ w: 900, h: 600 });
const [panning, setPanning] = useState(false);
const [hoveredId, setHoveredId] = useState<number | null>(null);
const [fitScale, setFitScale] = useState(1);
const panStart = useRef({ y: 0, viewStart: 0, viewEnd: 0 });
const viewRef = useRef({ viewStart, viewEnd });
const onViewChangeRef = useRef(onViewChange);
const fitRef = useRef(1);
const fitReadyRef = useRef(false);
const fitTargetRef = useRef(1);
const fitRafRef = useRef<number | null>(null);
const fitLastTsRef = 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) {
setCanvas({ w: Math.round(rect.width), h: Math.round(rect.height) });
}
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
// Structure is catalogue-wide and view-independent: zooming must not reshape
// the tree, only travel along it.
const tree = useMemo(() => buildMovementTree(movements), [movements]);
const visibleIds = useMemo(() => {
const ids = new Set<number>();
for (const node of tree.nodes.values()) {
const { start_year: s, end_year: e } = node.movement;
if (e > viewStart && s < viewEnd) ids.add(node.movement.id);
}
return ids;
}, [tree, viewStart, viewEnd]);
const targetScale = useMemo(() => {
const usable = Math.max(240, canvas.w - SIDE_PAD * 2);
const fitAll = usable / (2 * Math.max(1, tree.halfSpan));
const visibleSpan = Math.max(1, viewEnd - viewStart);
const totalSpan = Math.max(visibleSpan, absoluteMax - absoluteMin);
const zoomSpread = Math.pow(totalSpan / visibleSpan, ZOOM_SPREAD_EXPONENT);
const spread = Math.min(
MAX_FIT_BOOST,
Math.max(FULL_VIEW_WIDTH_SHARE, FULL_VIEW_WIDTH_SHARE * zoomSpread)
);
let visibleHalfSpan = 0;
for (const id of visibleIds) {
const node = tree.nodes.get(id);
if (!node) continue;
visibleHalfSpan = Math.max(
visibleHalfSpan,
Math.abs(node.x) + Math.abs(node.lean) + node.baseWidth / 2
);
}
// Never let what is on screen spill off the canvas.
const overflowCap = visibleHalfSpan > 0 ? usable / (2 * visibleHalfSpan) : Infinity;
return Math.min(fitAll * spread, overflowCap);
}, [canvas.w, tree, visibleIds, viewStart, viewEnd, absoluteMin, absoluteMax]);
useLayoutEffect(() => {
fitTargetRef.current = targetScale;
if (!fitReadyRef.current) {
// First measured layout — adopt it instead of animating in from nothing.
fitReadyRef.current = true;
fitRef.current = targetScale;
setFitScale(targetScale);
return;
}
if (fitRafRef.current != null) return;
fitLastTsRef.current = performance.now();
const step = (now: number) => {
const dt = Math.min(0.05, Math.max(0, (now - fitLastTsRef.current) / 1000));
fitLastTsRef.current = now;
const t = 1 - Math.exp(-FIT_ANIM_RATE * dt);
const next = fitRef.current + (fitTargetRef.current - fitRef.current) * t;
if (Math.abs(fitTargetRef.current - next) < 0.002) {
fitRef.current = fitTargetRef.current;
setFitScale(fitTargetRef.current);
fitRafRef.current = null;
return;
}
fitRef.current = next;
setFitScale(next);
fitRafRef.current = requestAnimationFrame(step);
};
fitRafRef.current = requestAnimationFrame(step);
}, [targetScale]);
useEffect(
() => () => {
if (fitRafRef.current != null) cancelAnimationFrame(fitRafRef.current);
},
[]
);
const { limbs, grafts } = useMemo(() => {
const span = viewEnd - viewStart || 1;
const centerX = canvas.w / 2;
const widthScale = Math.min(1.7, Math.max(0.55, fitScale));
const sx = (treeX: number) => centerX + treeX * fitScale;
const sy = (year: number) => canvas.h - ((year - viewStart) / span) * canvas.h;
const limbList: LimbShape[] = [];
const graftList: GraftShape[] = [];
/**
* Pass 1 screen geometry per movement.
*
* Two readability floors apply here, and only here: the structure and the
* dates stay untouched. A limb is drawn at least `MIN_LIMB_RISE_PX` long,
* and never thicker than it is long, so 2 500 years of trunk and 30 years
* of Fauvism can share one linear axis without the modern crown fusing
* into a solid bar.
*/
const drawn = new Map<
number,
{ base: Pt; c1: Pt; c2: Pt; tip: Pt; wBase: number; wTip: number }
>();
for (const id of tree.drawOrder) {
const node = tree.nodes.get(id)!;
const { start_year: start, end_year: end } = node.movement;
if (end <= start) continue;
const base: Pt = { x: sx(limbXAtYear(node, start)), y: sy(start) };
const trueTipY = sy(end);
const tip: Pt = {
x: sx(limbXAtYear(node, end)),
y: Math.min(trueTipY, base.y - MIN_LIMB_RISE_PX),
};
const lengthPx = Math.hypot(tip.x - base.x, tip.y - base.y);
const cap = Math.max(3, lengthPx * MAX_THICKNESS_OF_LENGTH);
const wBase = Math.min(node.baseWidth * widthScale, cap);
const wTip = Math.min(node.tipWidth * widthScale, cap * 0.82);
const dy = tip.y - base.y;
drawn.set(id, {
base,
c1: { x: base.x, y: base.y + dy * 0.42 },
c2: { x: tip.x, y: tip.y - dy * 0.34 },
tip,
wBase,
wTip,
});
}
/** Point on a drawn limb at the screen height closest to `targetY`. */
const pointOnLimb = (parentId: number, targetY: number) => {
const p = drawn.get(parentId)!;
const total = p.base.y - p.tip.y || 1;
const t = Math.min(1, Math.max(0, (p.base.y - targetY) / total));
return {
pt: cubicAt(p.base, p.c1, p.c2, p.tip, t),
width: p.wBase + (p.wTip - p.wBase) * t,
};
};
// Pass 2 — ribbons.
for (const id of tree.drawOrder) {
const node = tree.nodes.get(id)!;
const shape = drawn.get(id);
if (!shape) continue;
const { base, c1, c2, tip, wBase, wTip } = shape;
const { start_year: start, end_year: end } = node.movement;
const color = vividMovementColor(node.movement.color);
const d = ribbonPath(base, c1, c2, tip, wBase, wTip);
// Junction: the limb grows out of its parent a little before its own
// date, and always climbs far enough to read as a fork.
let junction = '';
const parent = node.parentId != null ? tree.nodes.get(node.parentId) : null;
if (parent && drawn.has(parent.movement.id)) {
const byDate = sy(branchOriginYear(parent, node));
const origin = pointOnLimb(
parent.movement.id,
Math.max(byDate, base.y + MIN_JUNCTION_RISE_PX)
);
const from = origin.pt;
const jdy = base.y - from.y;
const jLength = Math.hypot(base.x - from.x, jdy);
const jCap = Math.max(3, jLength * MAX_THICKNESS_OF_LENGTH);
const wFrom = Math.min(origin.width * 0.92, wBase * 1.25, jCap);
const jc1: Pt = { x: from.x, y: from.y + jdy * 0.45 };
const jc2: Pt = { x: base.x, y: base.y - jdy * 0.45 };
junction = ribbonPath(from, jc1, jc2, base, wFrom, Math.min(wBase, jCap));
}
// Roots: only the bottom of a tree, and only when that bottom is in frame.
const roots: string[] = [];
if (!parent && base.y > -canvas.h && base.y < canvas.h * 2) {
const flare = Math.max(22, wBase * 1.4);
for (const dir of [-1, -0.35, 0.35, 1]) {
const endPt: Pt = { x: base.x + dir * flare, y: base.y + flare * 0.72 };
const rc1: Pt = { x: base.x + dir * flare * 0.2, y: base.y + flare * 0.34 };
const rc2: Pt = { x: base.x + dir * flare * 0.8, y: base.y + flare * 0.5 };
roots.push(ribbonPath(base, rc1, rc2, endPt, wBase * 0.42, 1.5));
}
}
const inView = visibleIds.has(id);
const clampedStart = Math.max(start, viewStart);
const clampedEnd = Math.min(end, viewEnd);
const midYear = (clampedStart + clampedEnd) / 2;
const labelY = Math.min(
Math.max(sy(midYear), tip.y + MIN_LABEL_HEIGHT_PX / 2),
base.y
);
limbList.push({
id,
name: node.movement.name,
color,
shade: shadeMovementColor(color),
d,
junction,
tip: { x: tip.x, y: tip.y, r: Math.max(1.5, wTip / 2) },
roots,
labelX: sx(limbXAtYear(node, midYear)),
labelY,
labelVisible: inView,
yearRange: `${formatYear(start)} ${formatYear(end)}`,
depth: node.depth,
inView,
});
for (const graftId of node.graftParentIds) {
const graftParent = tree.nodes.get(graftId);
if (!graftParent || !drawn.has(graftId)) continue;
const byDate = sy(branchOriginYear(graftParent, node));
const origin = pointOnLimb(graftId, Math.max(byDate, base.y + MIN_JUNCTION_RISE_PX));
const from = origin.pt;
const gdy = base.y - from.y;
const gWidth = Math.max(2.5, Math.min(wBase * 0.3, 9));
graftList.push({
key: `${graftId}-${id}`,
d: ribbonPath(
from,
{ x: from.x, y: from.y + gdy * 0.55 },
{ x: base.x, y: base.y - gdy * 0.3 },
base,
gWidth * 0.7,
gWidth
),
color: vividMovementColor(graftParent.movement.color),
fromId: graftId,
toId: id,
});
}
}
hideOverlappingLabels(limbList, canvas.w, canvas.h);
return { limbs: limbList, grafts: graftList };
}, [tree, viewStart, viewEnd, canvas.w, canvas.h, fitScale, visibleIds]);
/** A hovered movement lights up its whole descent line back to the root. */
const lineageIds = useMemo(() => {
const ids = new Set<number>();
if (hoveredId == null) return ids;
let cursor: number | null = hoveredId;
let guard = 0;
while (cursor != null && guard++ < 64) {
ids.add(cursor);
const node: MovementTreeNode | undefined = tree.nodes.get(cursor);
if (!node) break;
for (const graftId of node.graftParentIds) ids.add(graftId);
cursor = node.parentId;
}
return ids;
}, [hoveredId, tree]);
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;
// Bottom = oldest, so invert the pointer offset before reusing the shared
// left-to-right zoom math.
const invertedY = rect.height - (e.clientY - rect.top);
const next = zoomTimelineView(
invertedY,
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 (movements.length === 0) {
return (
<div className="mtree-empty">
<p>No art movements to grow a tree from yet.</p>
</div>
);
}
const anyInView = limbs.some((limb) => limb.inView);
return (
<div className="mtree">
<p className="mtree-caption">
{t('captionTreeFlow')}
</p>
<div
ref={canvasRef}
className={`mtree-canvas${panning ? ' mtree-panning' : ''}`}
onMouseDown={handlePanStart}
>
<svg
className="mtree-svg"
viewBox={`0 0 ${canvas.w} ${canvas.h}`}
preserveAspectRatio="none"
>
<defs>
{limbs.map((limb) => (
<linearGradient
key={`grad-${limb.id}`}
id={`mtree-limb-${limb.id}`}
gradientUnits="objectBoundingBox"
x1="0"
y1="0"
x2="1"
y2="0"
>
<stop offset="0%" stopColor={limb.shade} />
<stop offset="45%" stopColor={limb.color} />
<stop offset="100%" stopColor={limb.shade} />
</linearGradient>
))}
</defs>
<g className="mtree-grafts">
{grafts.map((graft) => (
<path
key={graft.key}
d={graft.d}
fill={graft.color}
className={`mtree-graft${
lineageIds.has(graft.toId) && lineageIds.has(graft.fromId)
? ' mtree-graft-lit'
: ''
}`}
/>
))}
</g>
{limbs.map((limb) => {
const lit = lineageIds.has(limb.id);
const dim = hoveredId != null && !lit;
return (
<g
key={limb.id}
className={`mtree-limb${lit ? ' mtree-limb-lit' : ''}${
dim ? ' mtree-limb-dim' : ''
}`}
onMouseEnter={() => setHoveredId(limb.id)}
onMouseLeave={() => setHoveredId(null)}
onClick={(e) => {
e.stopPropagation();
onMovementClick?.(limb.id);
}}
>
<title>{`${limb.name} · ${limb.yearRange}`}</title>
{limb.roots.map((d, i) => (
<path key={`root-${i}`} d={d} fill={limb.shade} className="mtree-root" />
))}
{limb.junction && (
<path d={limb.junction} fill={`url(#mtree-limb-${limb.id})`} />
)}
<path d={limb.d} fill={`url(#mtree-limb-${limb.id})`} />
<circle cx={limb.tip.x} cy={limb.tip.y} r={limb.tip.r} fill={limb.color} />
</g>
);
})}
</svg>
{limbs
.filter((limb) => limb.labelVisible)
.map((limb) => (
<button
key={`label-${limb.id}`}
type="button"
className={`mtree-label${
hoveredId != null && !lineageIds.has(limb.id) ? ' mtree-label-dim' : ''
}`}
style={{ left: `${limb.labelX}px`, top: `${limb.labelY}px` }}
title={`${limb.name} · ${limb.yearRange}`}
onMouseEnter={() => setHoveredId(limb.id)}
onMouseLeave={() => setHoveredId(null)}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onMovementClick?.(limb.id);
}}
>
{limb.name}
</button>
))}
{!anyInView && (
<div className="mtree-out-of-range">
<p>No movements in this time range zoom out to see the whole tree.</p>
</div>
)}
</div>
</div>
);
}
+3 -1
View File
@@ -1,4 +1,5 @@
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react'; import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { HistoricalEra } from '../types'; import type { HistoricalEra } from '../types';
import { import {
HISTORICAL_EVENTS, HISTORICAL_EVENTS,
@@ -38,6 +39,7 @@ function formatYear(year: number): string {
} }
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight }: Props) { export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight }: Props) {
const { t } = useTranslation('home');
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<'left' | 'right' | 'pan' | null>(null); const [dragging, setDragging] = useState<'left' | 'right' | 'pan' | null>(null);
const [containerWidth, setContainerWidth] = useState(800); const [containerWidth, setContainerWidth] = useState(800);
@@ -365,7 +367,7 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
</div> </div>
<p className="timeline-hint"> <p className="timeline-hint">
Click an era or event to zoom · Scroll to zoom · Drag to pan {t('captionClassicTimeline')}
</p> </p>
</div> </div>
); );
@@ -1,7 +1,9 @@
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react'; import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { ArtMovement } from '../types'; import type { ArtMovement } from '../types';
import { MOVEMENT_LINEAGE } from '../data/movement-lineage'; import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
import { panTimelineView, zoomTimelineView } from '../utils/timelineView'; import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
import { vividMovementColor } from '../utils/movementColor';
import './VerticalMovementBands.css'; import './VerticalMovementBands.css';
interface Props { interface Props {
@@ -51,66 +53,6 @@ function yearToBottomPercent(year: number, start: number, end: number): number {
return ((year - start) / (end - start)) * 100; 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( function buildLineageParentMap(
visible: ArtMovement[], visible: ArtMovement[],
nameToId: Map<string, number> nameToId: Map<string, number>
@@ -423,6 +365,7 @@ export default function VerticalMovementBands({
onViewChange, onViewChange,
onMovementClick, onMovementClick,
}: Props) { }: Props) {
const { t } = useTranslation('home');
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
const [canvasSize, setCanvasSize] = useState({ w: 800, h: 600 }); const [canvasSize, setCanvasSize] = useState({ w: 800, h: 600 });
const [panning, setPanning] = useState(false); const [panning, setPanning] = useState(false);
@@ -690,7 +633,7 @@ export default function VerticalMovementBands({
return ( return (
<div className="vflow"> <div className="vflow">
<p className="vflow-caption"> <p className="vflow-caption">
Bottom top through history · scroll to zoom · drag to pan · click a stream {t('captionVerticalFlow')}
</p> </p>
<div <div
ref={canvasRef} ref={canvasRef}
+3 -1
View File
@@ -1,4 +1,5 @@
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react'; import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { HistoricalEra } from '../types'; import type { HistoricalEra } from '../types';
import { import {
HISTORICAL_EVENTS, HISTORICAL_EVENTS,
@@ -60,6 +61,7 @@ export default function VerticalTimeline({
absoluteMax, absoluteMax,
lifespanHighlight, lifespanHighlight,
}: Props) { }: Props) {
const { t } = useTranslation('home');
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<'start' | 'end' | 'pan' | null>(null); const [dragging, setDragging] = useState<'start' | 'end' | 'pan' | null>(null);
const [containerHeight, setContainerHeight] = useState(600); const [containerHeight, setContainerHeight] = useState(600);
@@ -409,7 +411,7 @@ export default function VerticalTimeline({
/> />
</div> </div>
<p className="vtimeline-hint">Bottom top · Scroll to zoom · Drag to pan</p> <p className="vtimeline-hint">{t('captionVerticalTimeline')}</p>
</aside> </aside>
); );
} }
+6
View File
@@ -21,6 +21,12 @@
"audit": "Activity", "audit": "Activity",
"layoutHorizontal": "Classic timeline →", "layoutHorizontal": "Classic timeline →",
"layoutVertical": "↑ Vertical timeline", "layoutVertical": "↑ Vertical timeline",
"layoutTree": "🌳 Tree of art",
"captionClassicTimeline": "Click an era or event to zoom · Scroll to zoom · Drag to pan",
"captionClassicFlow": "Scroll to zoom · drag to pan · each movement stream is a solid colour band through history",
"captionVerticalTimeline": "Bottom → top · Scroll to zoom · Drag to pan",
"captionVerticalFlow": "Bottom → top through history · scroll to zoom · drag to pan · click a stream",
"captionTreeFlow": "Roots at the bottom, living movements at the crown · scroll to zoom · drag to pan · click a branch to enter its gallery",
"openingTourGallery": "Opening guided tour…", "openingTourGallery": "Opening guided tour…",
"tourEmpty": "This tour has no paintings yet.", "tourEmpty": "This tour has no paintings yet.",
"tourLoadFailed": "Failed to load the tour.", "tourLoadFailed": "Failed to load the tour.",
+6
View File
@@ -21,6 +21,12 @@
"audit": "Активность", "audit": "Активность",
"layoutHorizontal": "Классическая шкала →", "layoutHorizontal": "Классическая шкала →",
"layoutVertical": "↑ Вертикальная шкала", "layoutVertical": "↑ Вертикальная шкала",
"layoutTree": "🌳 Древо искусства",
"captionClassicTimeline": "Щёлкните эпоху или событие для приближения · Колесо — масштаб · Перетащите для панорамы",
"captionClassicFlow": "Колесо — масштаб · перетащите для панорамы · каждое направление — цветная полоса сквозь историю",
"captionVerticalTimeline": "Снизу вверх · Колесо — масштаб · Перетащите для панорамы",
"captionVerticalFlow": "Снизу вверх по истории · колесо — масштаб · перетащите для панорамы · щёлкните поток",
"captionTreeFlow": "Корни внизу, живые направления в кроне · колесо — масштаб · перетащите для панорамы · щёлкните ветвь, чтобы войти в зал",
"openingTourGallery": "Открытие экскурсии…", "openingTourGallery": "Открытие экскурсии…",
"tourEmpty": "В этой экскурсии пока нет картин.", "tourEmpty": "В этой экскурсии пока нет картин.",
"tourLoadFailed": "Не удалось загрузить экскурсию.", "tourLoadFailed": "Не удалось загрузить экскурсию.",
+14
View File
@@ -44,6 +44,9 @@
top: 16px; top: 16px;
left: 16px; left: 16px;
z-index: 120; z-index: 120;
display: flex;
gap: 8px;
align-items: center;
} }
.site-layout-link { .site-layout-link {
@@ -63,6 +66,17 @@
color: #f5e6c8; color: #f5e6c8;
} }
/* Link to the alternative tree start page — the one worth noticing. */
.site-layout-link-feature {
border-color: rgba(201, 169, 110, 0.7);
background: rgba(201, 169, 110, 0.16);
color: #f5e6c8;
}
.site-layout-link-feature:hover {
background: rgba(201, 169, 110, 0.3);
}
.gallery-session-suspended { .gallery-session-suspended {
position: fixed; position: fixed;
inset: 0; inset: 0;
+94 -20
View File
@@ -5,6 +5,7 @@ import TimelineEventGuides from '../components/TimelineEventGuides';
import MovementBands from '../components/MovementBands'; import MovementBands from '../components/MovementBands';
import VerticalTimeline from '../components/VerticalTimeline'; import VerticalTimeline from '../components/VerticalTimeline';
import VerticalMovementBands from '../components/VerticalMovementBands'; import VerticalMovementBands from '../components/VerticalMovementBands';
import MovementTree from '../components/MovementTree';
import VirtualGallery from '../components/VirtualGallery'; import VirtualGallery from '../components/VirtualGallery';
import PaintingDetailView from '../components/PaintingDetail'; import PaintingDetailView from '../components/PaintingDetail';
import ArtistBio from '../components/ArtistBio'; import ArtistBio from '../components/ArtistBio';
@@ -50,6 +51,7 @@ import './HomePage.css';
type View = type View =
| { type: 'timeline' } | { type: 'timeline' }
| { type: 'timeline-vertical' } | { type: 'timeline-vertical' }
| { type: 'timeline-tree' }
| { type: 'checkup' } | { type: 'checkup' }
| { type: 'translations' } | { type: 'translations' }
| { type: 'influences' } | { type: 'influences' }
@@ -157,6 +159,37 @@ function catalogNavigateTarget(
return idx < remaining.length ? remaining[idx].id : remaining[remaining.length - 1].id; return idx < remaining.length ? remaining[idx].id : remaining[remaining.length - 1].id;
} }
/** Shareable timeline layout via `?layout=classic|vertical|tree` (classic may omit the param). */
type TimelineLayoutId = 'classic' | 'vertical' | 'tree';
function parseTimelineLayoutParam(raw: string | null): TimelineLayoutId {
if (raw === 'vertical' || raw === 'tree') return raw;
if (raw === 'classic' || raw === 'horizontal') return 'classic';
return 'classic';
}
function timelineViewFromLayout(layout: TimelineLayoutId): View {
if (layout === 'vertical') return { type: 'timeline-vertical' };
if (layout === 'tree') return { type: 'timeline-tree' };
return { type: 'timeline' };
}
function writeTimelineLayoutParam(layout: TimelineLayoutId) {
const url = new URL(window.location.href);
if (layout === 'classic') url.searchParams.delete('layout');
else url.searchParams.set('layout', layout);
const next = `${url.pathname}${url.search}${url.hash}`;
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (next !== current) window.history.replaceState(null, '', next);
}
function readInitialTimelineView(): View {
if (typeof window === 'undefined') return { type: 'timeline' };
return timelineViewFromLayout(
parseTimelineLayoutParam(new URLSearchParams(window.location.search).get('layout'))
);
}
export default function HomePage() { export default function HomePage() {
const { t } = useTranslation('home'); const { t } = useTranslation('home');
const { isCurator, isAdmin, username, login, logout, can } = useAuth(); const { isCurator, isAdmin, username, login, logout, can } = useAuth();
@@ -167,7 +200,7 @@ export default function HomePage() {
const canInfluences = can('influences'); const canInfluences = can('influences');
const canTours = can('tours'); const canTours = can('tours');
const canUsers = can('users'); const canUsers = can('users');
const [view, setView] = useState<View>({ type: 'timeline' }); const [view, setView] = useState<View>(readInitialTimelineView);
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null); const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
const [bounds, setBounds] = useState({ min: -800, max: 2025 }); const [bounds, setBounds] = useState({ min: -800, max: 2025 });
const [viewStart, setViewStart] = useState(-800); const [viewStart, setViewStart] = useState(-800);
@@ -210,7 +243,11 @@ export default function HomePage() {
setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data }); setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data });
} else if (view.type === 'tour-gallery') { } else if (view.type === 'tour-gallery') {
setGallerySession({ kind: 'tour', tourId: view.tourId, data: view.data }); setGallerySession({ kind: 'tour', tourId: view.tourId, data: view.data });
} else if (view.type === 'timeline' || view.type === 'timeline-vertical') { } else if (
view.type === 'timeline' ||
view.type === 'timeline-vertical' ||
view.type === 'timeline-tree'
) {
setGallerySession(null); setGallerySession(null);
} }
}, [view]); }, [view]);
@@ -271,19 +308,33 @@ export default function HomePage() {
setViewStart(bounds.min); setViewStart(bounds.min);
setViewEnd(bounds.max); setViewEnd(bounds.max);
setGalleryRevision((revision) => revision + 1); setGalleryRevision((revision) => revision + 1);
writeTimelineLayoutParam('classic');
setView({ type: 'timeline' }); setView({ type: 'timeline' });
}, [bounds.min, bounds.max]); }, [bounds.min, bounds.max]);
const openHorizontalTimeline = () => { const openHorizontalTimeline = () => {
writeTimelineLayoutParam('classic');
setView({ type: 'timeline' }); setView({ type: 'timeline' });
}; };
const openVerticalTimeline = () => { const openVerticalTimeline = () => {
writeTimelineLayoutParam('vertical');
setView({ type: 'timeline-vertical' }); setView({ type: 'timeline-vertical' });
}; };
const isTimelineHome = view.type === 'timeline' || view.type === 'timeline-vertical'; const openTreeTimeline = () => {
writeTimelineLayoutParam('tree');
setView({ type: 'timeline-tree' });
};
const isTimelineHome =
view.type === 'timeline' ||
view.type === 'timeline-vertical' ||
view.type === 'timeline-tree';
const isVerticalTimeline = view.type === 'timeline-vertical'; const isVerticalTimeline = view.type === 'timeline-vertical';
const isTreeTimeline = view.type === 'timeline-tree';
/** Both alternative layouts run the year axis bottom → top beside the chart. */
const isVerticalLayout = isVerticalTimeline || isTreeTimeline;
const toggleDebugMode = () => { const toggleDebugMode = () => {
setDebugMode((prev) => { setDebugMode((prev) => {
@@ -1222,16 +1273,17 @@ export default function HomePage() {
<div className="home-page"> <div className="home-page">
<header className="site-header"> <header className="site-header">
<div className="site-layout-switch"> <div className="site-layout-switch">
{isVerticalTimeline ? ( {!isTreeTimeline && (
<button <button
type="button" type="button"
className="site-layout-link" className="site-layout-link site-layout-link-feature"
onClick={openHorizontalTimeline} onClick={openTreeTimeline}
title="Switch to classic left-to-right timeline" title="Open the alternative start page: bottom-up timeline with movements drawn as a growing tree"
> >
{t('layoutHorizontal')} {t('layoutTree')}
</button> </button>
) : ( )}
{!isVerticalTimeline && (
<button <button
type="button" type="button"
className="site-layout-link" className="site-layout-link"
@@ -1241,6 +1293,16 @@ export default function HomePage() {
{t('layoutVertical')} {t('layoutVertical')}
</button> </button>
)} )}
{!(view.type === 'timeline') && (
<button
type="button"
className="site-layout-link"
onClick={openHorizontalTimeline}
title="Switch to classic left-to-right timeline"
>
{t('layoutHorizontal')}
</button>
)}
</div> </div>
<div className="site-dev-tools"> <div className="site-dev-tools">
{isCurator ? ( {isCurator ? (
@@ -1369,7 +1431,7 @@ export default function HomePage() {
/> />
</header> </header>
<div className={`home-timeline-stack${isVerticalTimeline ? ' home-timeline-stack-vertical' : ''}`}> <div className={`home-timeline-stack${isVerticalLayout ? ' home-timeline-stack-vertical' : ''}`}>
{loading && ( {loading && (
<GalleryLoadingMarker overlay message="Loading art history…" /> <GalleryLoadingMarker overlay message="Loading art history…" />
)} )}
@@ -1380,7 +1442,7 @@ export default function HomePage() {
<GalleryLoadingMarker banner message="Loading portraits…" /> <GalleryLoadingMarker banner message="Loading portraits…" />
)} )}
{isVerticalTimeline ? ( {isVerticalLayout ? (
<> <>
<VerticalTimeline <VerticalTimeline
eras={timelineData.eras} eras={timelineData.eras}
@@ -1394,15 +1456,27 @@ export default function HomePage() {
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
{!loading && ( {!loading && (
<div className="home-movements-section-vertical"> <div className="home-movements-section-vertical">
<VerticalMovementBands {isTreeTimeline ? (
movements={timelineData.movements} <MovementTree
viewStart={viewStart} movements={timelineData.movements}
viewEnd={viewEnd} viewStart={viewStart}
absoluteMin={bounds.min} viewEnd={viewEnd}
absoluteMax={bounds.max} absoluteMin={bounds.min}
onViewChange={handleViewChange} absoluteMax={bounds.max}
onMovementClick={handleMovementClick} onViewChange={handleViewChange}
/> onMovementClick={handleMovementClick}
/>
) : (
<VerticalMovementBands
movements={timelineData.movements}
viewStart={viewStart}
viewEnd={viewEnd}
absoluteMin={bounds.min}
absoluteMax={bounds.max}
onViewChange={handleViewChange}
onMovementClick={handleMovementClick}
/>
)}
</div> </div>
)} )}
</> </>
+90
View File
@@ -0,0 +1,90 @@
/** Shared colour helpers for movement streams (timeline flow, vertical flow, tree). */
/** True when `hex` is a usable #rgb / #rrggbb / #rrggbbaa-style value. */
export function isMovementHexColor(hex: string): boolean {
const normalized = hex.replace('#', '').trim();
return /^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6,}$/.test(normalized);
}
/**
* Parse a catalogue colour to RGB.
* 3-digit shorthand expands; 6+ digits keep the first six (so `#rrggbbaa` `#rrggbb`).
* Throws on malformed input so callers can fall back.
*/
export function parseHexColor(hex: string): [number, number, number] {
const normalized = hex.replace('#', '').trim();
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6,}$/.test(normalized)) {
throw new Error(`invalid hex colour: ${hex}`);
}
const value =
normalized.length === 3
? normalized
.split('')
.map((c) => c + c)
.join('')
: normalized.slice(0, 6);
const n = parseInt(value, 16);
if (!Number.isFinite(n)) throw new Error(`invalid hex colour: ${hex}`);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export 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];
}
export 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)}`;
}
/** Lift a muted catalogue colour into a saturated stream colour. */
export function vividMovementColor(hex: string): string {
try {
if (!isMovementHexColor(hex)) return hex;
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;
}
}
/** Darker variant of a stream colour — used for the shaded side of a tree limb. */
export function shadeMovementColor(hex: string, amount = 0.34): string {
try {
if (!isMovementHexColor(hex)) return hex;
const [r, g, b] = parseHexColor(hex);
const [h, s, l] = rgbToHsl(r, g, b);
return hslToHex(h, Math.min(1, s * 1.05), Math.max(0.08, l * (1 - amount)));
} catch {
return hex;
}
}
+328
View File
@@ -0,0 +1,328 @@
/**
* Tree layout rules for the "Tree of Art" start page.
*
* The classic flow chart packs movements into lanes and lets the lanes drift as
* you pan. A tree needs the opposite: a shape you can recognise again after a
* zoom. So the horizontal geometry here is computed **once from the whole
* catalogue** and never depends on the visible year window only the vertical
* (time) axis reacts to pan/zoom.
*
* Rules
* -----
* 1. **Time grows upward.** The oldest movements sit at the bottom, the newest
* at the top. Y is purely `year → pixel`; this file never computes it.
* 2. **One trunk, at the centre.** `MOVEMENT_LINEAGE` is a DAG, so it is first
* reduced to a spanning tree: each movement keeps its *most immediate
* predecessor* (the parent with the latest start year that still precedes
* it) as its structural parent. Remaining parents survive as **grafts**
* thin secondary limbs the renderer draws behind the tree.
* 3. **Children split the parent's slot.** Every node reserves a horizontal
* slot as wide as its whole subtree (`max(own limb, Σ children)`), and its
* children are packed side by side and centred on the parent. A single-child
* chain therefore inherits the parent's x exactly the trunk stays straight
* until it actually forks, and every fork spreads symmetrically, so later
* generations end up further from the centre.
* 4. **Leonardo's rule for thickness.** A limb is as thick as the limbs it
* carries: `base² = own² + Σ child.base²`. The trunk at the bottom is the
* thickest thing on screen and every branch tapers as it rises and sheds
* children. A movement's *own* thickness comes from its influence-link count.
* 5. **Branches lean outward.** A limb drifts sideways across its own lifespan,
* away from its parent, by at most the slack left inside its slot so limbs
* look grown rather than extruded, and can never collide with a sibling.
* 6. **Unlinked movements are saplings.** A movement with no lineage edge is its
* own root; extra roots are planted alternately right and left of the trunk,
* widest subtree first, so the main trunk keeps x = 0 (canvas centre).
*/
import type { ArtMovement } from '../types';
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
/** All values are tree-space pixels; the renderer scales them to the canvas. */
export const TREE_LAYOUT = {
/** Horizontal room a childless limb claims. */
LEAF_SLOT_PX: 104,
/** Clear space kept around a limb inside its own slot. */
LIMB_GAP_PX: 34,
/** Thinnest a limb may be drawn. */
MIN_LIMB_PX: 13,
/** Thickest a limb can get from its own influence count alone. */
MAX_OWN_LIMB_PX: 36,
/** Ceiling for the accumulated (Leonardo) thickness of the trunk. */
MAX_TRUNK_PX: 96,
/** How much of the slack inside a slot a limb may lean into. */
LEAN_SLACK: 0.55,
MAX_LEAN_PX: 28,
/** A limb ends its life this much thinner than it started it. */
TIP_TAPER: 0.66,
} as const;
export interface MovementTreeNode {
movement: ArtMovement;
/** Structural parent in the spanning tree (`null` for roots). */
parentId: number | null;
/** Documented predecessors that lost to the structural parent. */
graftParentIds: number[];
childIds: number[];
depth: number;
descendants: number;
/** Thickness the movement earns on its own (influence links). */
ownWidth: number;
/** Thickness where the limb leaves its parent — carries every descendant. */
baseWidth: number;
/** Thickness where the limb ends. */
tipWidth: number;
/** Horizontal slot reserved for this node and everything under it. */
subtreeWidth: number;
/** Tree-space x of the limb base. The main trunk sits at 0. */
x: number;
/** Lateral drift from base to tip, px (signed). */
lean: number;
side: -1 | 0 | 1;
}
export interface MovementTree {
nodes: Map<number, MovementTreeNode>;
rootIds: number[];
/** Ids ordered thickest-first, so thin branches paint over the trunk. */
drawOrder: number[];
/** Half the horizontal extent actually occupied, px (>= 1). */
halfSpan: number;
}
/** Stable per-id value in [-1, 1] — organic drift without randomness. */
function idDrift(id: number): number {
const n = Math.sin(id * 12.9898) * 43758.5453;
return (n - Math.floor(n)) * 2 - 1;
}
function influenceCount(m: ArtMovement): number {
const n = m.influence_link_count;
return typeof n === 'number' && Number.isFinite(n) ? Math.max(0, n) : 0;
}
/** child id → documented parent ids, restricted to movements in the catalogue. */
function buildParentMap(movements: ArtMovement[]): Map<number, number[]> {
const nameToId = new Map(movements.map((m) => [m.name, m.id]));
const parents = new Map<number, number[]>();
for (const [parentName, childName] of MOVEMENT_LINEAGE) {
const parentId = nameToId.get(parentName);
const childId = nameToId.get(childName);
if (parentId == null || childId == null || parentId === childId) continue;
const list = parents.get(childId) ?? [];
if (!list.includes(parentId)) list.push(parentId);
parents.set(childId, list);
}
return parents;
}
/**
* Reduce the lineage DAG to a spanning tree. Ranking every movement by start
* year first means a parent is always strictly earlier in the ranking than its
* child, so the result cannot contain a cycle.
*/
function chooseStructuralParents(
movements: ArtMovement[],
parentMap: Map<number, number[]>
): Map<number, { parentId: number | null; grafts: number[] }> {
const ranked = [...movements].sort(
(a, b) => a.start_year - b.start_year || a.id - b.id
);
const rank = new Map(ranked.map((m, index) => [m.id, index]));
const chosen = new Map<number, { parentId: number | null; grafts: number[] }>();
for (const m of movements) {
const candidates = (parentMap.get(m.id) ?? []).filter(
(pid) => (rank.get(pid) ?? Infinity) < (rank.get(m.id) ?? -Infinity)
);
if (candidates.length === 0) {
chosen.set(m.id, { parentId: null, grafts: [] });
continue;
}
// Most immediate predecessor carries the branch; older ones become grafts.
const sorted = [...candidates].sort(
(a, b) => (rank.get(b) ?? 0) - (rank.get(a) ?? 0)
);
chosen.set(m.id, { parentId: sorted[0], grafts: sorted.slice(1) });
}
return chosen;
}
export function buildMovementTree(movements: ArtMovement[]): MovementTree {
const nodes = new Map<number, MovementTreeNode>();
if (movements.length === 0) {
return { nodes, rootIds: [], drawOrder: [], halfSpan: 1 };
}
const parentMap = buildParentMap(movements);
const structure = chooseStructuralParents(movements, parentMap);
const maxInfluence = Math.max(0, ...movements.map(influenceCount));
for (const movement of movements) {
const { parentId, grafts } = structure.get(movement.id) ?? {
parentId: null,
grafts: [],
};
const ownWidth =
maxInfluence > 0
? TREE_LAYOUT.MIN_LIMB_PX +
(influenceCount(movement) / maxInfluence) *
(TREE_LAYOUT.MAX_OWN_LIMB_PX - TREE_LAYOUT.MIN_LIMB_PX)
: (TREE_LAYOUT.MIN_LIMB_PX + TREE_LAYOUT.MAX_OWN_LIMB_PX) / 2;
nodes.set(movement.id, {
movement,
parentId,
graftParentIds: grafts,
childIds: [],
depth: 0,
descendants: 0,
ownWidth,
baseWidth: ownWidth,
tipWidth: Math.max(TREE_LAYOUT.MIN_LIMB_PX * 0.6, ownWidth * TREE_LAYOUT.TIP_TAPER),
subtreeWidth: TREE_LAYOUT.LEAF_SLOT_PX,
x: 0,
lean: 0,
side: 0,
});
}
const rootIds: number[] = [];
for (const node of nodes.values()) {
if (node.parentId != null && nodes.has(node.parentId)) {
nodes.get(node.parentId)!.childIds.push(node.movement.id);
} else {
node.parentId = null;
rootIds.push(node.movement.id);
}
}
for (const node of nodes.values()) {
node.childIds.sort((a, b) => {
const ma = nodes.get(a)!.movement;
const mb = nodes.get(b)!.movement;
return ma.start_year - mb.start_year || ma.name.localeCompare(mb.name);
});
node.graftParentIds = node.graftParentIds.filter((id) => nodes.has(id));
}
// Post-order: depth, descendant count, Leonardo thickness, slot width.
const measure = (id: number, depth: number): void => {
const node = nodes.get(id)!;
node.depth = depth;
let descendants = 0;
let childrenWidth = 0;
let carried = node.ownWidth * node.ownWidth;
for (const childId of node.childIds) {
measure(childId, depth + 1);
const child = nodes.get(childId)!;
descendants += 1 + child.descendants;
childrenWidth += child.subtreeWidth;
carried += child.baseWidth * child.baseWidth;
}
node.descendants = descendants;
node.baseWidth = Math.min(TREE_LAYOUT.MAX_TRUNK_PX, Math.sqrt(carried));
node.subtreeWidth = Math.max(
node.baseWidth + TREE_LAYOUT.LIMB_GAP_PX,
node.childIds.length === 0 ? TREE_LAYOUT.LEAF_SLOT_PX : childrenWidth
);
};
for (const id of rootIds) measure(id, 0);
// Widest tree takes the centre; the rest are planted alternately right / left.
rootIds.sort((a, b) => {
const na = nodes.get(a)!;
const nb = nodes.get(b)!;
return (
nb.descendants - na.descendants ||
na.movement.start_year - nb.movement.start_year ||
na.movement.name.localeCompare(nb.movement.name)
);
});
const place = (id: number, x: number): void => {
const node = nodes.get(id)!;
node.x = x;
const total = node.childIds.reduce((sum, cid) => sum + nodes.get(cid)!.subtreeWidth, 0);
let cursor = x - total / 2;
for (const childId of node.childIds) {
const child = nodes.get(childId)!;
place(childId, cursor + child.subtreeWidth / 2);
cursor += child.subtreeWidth;
}
};
if (rootIds.length > 0) {
const trunk = nodes.get(rootIds[0])!;
place(rootIds[0], 0);
let rightEdge = trunk.subtreeWidth / 2;
let leftEdge = -trunk.subtreeWidth / 2;
rootIds.slice(1).forEach((id, index) => {
const node = nodes.get(id)!;
if (index % 2 === 0) {
place(id, rightEdge + node.subtreeWidth / 2);
rightEdge += node.subtreeWidth;
} else {
place(id, leftEdge - node.subtreeWidth / 2);
leftEdge -= node.subtreeWidth;
}
});
}
// Lean: outward from the parent, capped by the slack left inside the slot.
for (const node of nodes.values()) {
const parent = node.parentId != null ? nodes.get(node.parentId) : null;
const slack = Math.max(0, (node.subtreeWidth - node.baseWidth) / 2);
const side = parent ? (Math.sign(node.x - parent.x) as -1 | 0 | 1) : 0;
node.side = side;
node.lean =
side !== 0
? side * Math.min(TREE_LAYOUT.MAX_LEAN_PX, slack * TREE_LAYOUT.LEAN_SLACK)
: idDrift(node.movement.id) * Math.min(9, slack * 0.2);
}
let halfSpan = 1;
for (const node of nodes.values()) {
halfSpan = Math.max(
halfSpan,
Math.abs(node.x) + Math.abs(node.lean) + node.baseWidth / 2
);
}
const drawOrder = [...nodes.keys()].sort((a, b) => {
const na = nodes.get(a)!;
const nb = nodes.get(b)!;
return nb.baseWidth - na.baseWidth || na.depth - nb.depth;
});
return { nodes, rootIds, drawOrder, halfSpan };
}
/** Fraction of a movement's lifespan elapsed at `year`, clamped to [0, 1]. */
export function lifeProgress(node: MovementTreeNode, year: number): number {
const { start_year: start, end_year: end } = node.movement;
if (end <= start) return 0;
return Math.min(1, Math.max(0, (year - start) / (end - start)));
}
/** Tree-space x of a limb's centreline at `year` (accounts for the lean). */
export function limbXAtYear(node: MovementTreeNode, year: number): number {
return node.x + node.lean * lifeProgress(node, year);
}
/** Limb thickness at `year`, tapering from base to tip. */
export function limbWidthAtYear(node: MovementTreeNode, year: number): number {
const t = lifeProgress(node, year);
return node.baseWidth + (node.tipWidth - node.baseWidth) * t;
}
/**
* Year at which a child limb leaves its parent. Branches split a little before
* the successor movement is dated, which is both how lineage works and what
* keeps the junction from looking like a right angle.
*/
export function branchOriginYear(parent: MovementTreeNode, child: MovementTreeNode): number {
const childStart = child.movement.start_year;
const parentStart = parent.movement.start_year;
const parentEnd = parent.movement.end_year;
const lead = Math.min(60, Math.max(6, (childStart - parentStart) * 0.22));
return Math.min(parentEnd, Math.max(parentStart, childStart - lead));
}