Compare commits
6
Commits
4088d7d57b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e52415ea8 | ||
|
|
3fe88ffcfa | ||
|
|
1c9fa20191 | ||
|
|
44092d102b | ||
|
|
33b8ae5a5f | ||
|
|
971c1e8dd8 |
@@ -0,0 +1,25 @@
|
||||
---
|
||||
description: Gallery UI interaction and component standards for client screens
|
||||
globs: client/src/**/*.{tsx,css}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# UI interaction standards
|
||||
|
||||
Follow [Documentation/ui-interaction-and-component-standards.md](Documentation/ui-interaction-and-component-standards.md) (MUST / SHOULD / MAY). Do not invent a second nav, toast, or CSS framework.
|
||||
|
||||
## MUST
|
||||
|
||||
- Drill-down via `HomePage` `View` state. Nested screens get an explicit Back; do not rely on the browser Back button.
|
||||
- **Back to Timeline** → `goToTimelineHome()` (clears hall, resets year range). **Back to Gallery** → same hall session (camera/wing kept).
|
||||
- Hide actions the user cannot `can()`; do not leave buttons that 403.
|
||||
- Visitor chrome strings in `locales/{en,ru}`. Movement colours via `utils/movementColor.ts`. Timeline zoom/pan via `utils/timelineView.ts`.
|
||||
- Loading: `GalleryLoadingMarker`. Errors/empty states: visible copy, not a blank canvas.
|
||||
- Modals: `role="dialog"`, visible close, Escape. No nested modals. Destructive actions confirm first.
|
||||
- Colocated CSS; museum gold `#c9a96e` / navy `/ Georgia`. No MUI/Ant Design.
|
||||
|
||||
## SHOULD
|
||||
|
||||
- Reuse `CatalogSearchBar`, `CuratorLoginModal`, `ArtistFilterModal`, `PaintingLightbox`, `DebugSearchResultsModal`, `DebugUploadButton`.
|
||||
- Curator filters above the table; expensive work behind an explicit button.
|
||||
- Update the standards doc when changing Back, search, layout query, or loading behaviour.
|
||||
+27
-11
@@ -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, 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. 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).
|
||||
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**).
|
||||
@@ -44,9 +44,12 @@ Gallery/
|
||||
│ │ ├── components/PaintingDetail.tsx # Detail view + debug panel
|
||||
│ │ ├── 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/MovementTree.tsx # Bottom-up movement tree (alternative start page)
|
||||
│ │ ├── 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
|
||||
@@ -57,12 +60,14 @@ Gallery/
|
||||
│ │ ├── pages/AuditPage.tsx # Admin curator activity reports
|
||||
│ │ ├── components/ToursPopup.tsx # Public published-tours modal
|
||||
│ │ ├── 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/movement-lineage.ts # Curated movement predecessor links (UI)
|
||||
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor
|
||||
│ │ ├── 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
|
||||
│ │ ├── 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
|
||||
│ ├── seed-wikipedia.js
|
||||
@@ -170,15 +175,25 @@ 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 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 | URL |
|
||||
|--------|----------|---------------|----------------|-----|
|
||||
| **Classic** (default) | `Timeline.tsx` (top bar) | `MovementBands.tsx` | Left → right | omit or `?layout=classic` |
|
||||
| **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 |
|
||||
|------|-----------|---------|
|
||||
| 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` / `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 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.
|
||||
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)
|
||||
|
||||
`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.
|
||||
@@ -347,7 +362,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 |
|
||||
|
||||
@@ -532,3 +547,4 @@ See [API.md](API.md#authentication) and [data-and-images.md](data-and-images.md#
|
||||
| [tours.md](tours.md) | Guided tours — editor, public popup, 3D tour hall |
|
||||
| [i18n-russian.md](i18n-russian.md) | Russian UI + entity_translations |
|
||||
| [data-and-images.md](data-and-images.md) | Image pipeline and seeding |
|
||||
| [ui-interaction-and-component-standards.md](ui-interaction-and-component-standards.md) | UI interaction, navigation, and component standards |
|
||||
|
||||
@@ -25,6 +25,16 @@ Timeline header: **EN | RU** toggle (`LocaleSwitcher`).
|
||||
- Passes `?locale=ru` on catalog API requests
|
||||
- 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)
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,527 @@
|
||||
# UI Interaction and Component Standards
|
||||
|
||||
**Subject:** Virtual Art Gallery — screen and interaction guidance
|
||||
**Applies to:** Public visitor UI, 3D halls, curator tools
|
||||
**Companion rule:** `.cursor/rules/ui-interaction-standards.mdc`
|
||||
|
||||
---
|
||||
|
||||
## 0. Introduction
|
||||
|
||||
The Gallery is a single React SPA (`HomePage.tsx` view union — no URL router except `?layout=`). Public visitors browse a museum-dark timeline and 3D halls; curators use the same shell for catalog tools (Checkup, Translations, Influences, Tours, Users, Activity). Work lands in the same header, palette, and back-stack, so a new screen that invents its own chrome, confirmations, or loading pattern fragments the experience.
|
||||
|
||||
This document is the shared contract for **how the UI behaves**. Architecture, APIs, and 3D hall construction live in [basics.md](basics.md), [API.md](API.md), and [data-and-images.md](data-and-images.md). Tree geometry lives in [movement-tree.md](movement-tree.md). Locale strings live in [i18n-russian.md](i18n-russian.md).
|
||||
|
||||
### 0.1 Purpose and audience
|
||||
|
||||
- **Purpose:** Define common UI interaction, navigation, and component standards for the Gallery client.
|
||||
- **Primary audience:** Developers (and agents) adding or changing `client/src` UI; anyone writing curator-tool screens.
|
||||
- **Secondary audience:** QA, product, copy/i18n.
|
||||
|
||||
### 0.2 Scope
|
||||
|
||||
- **In scope:**
|
||||
- Navigation and back-stack behaviour
|
||||
- Timeline / search / 3D / detail / curator-tool interaction patterns
|
||||
- Loading, empty, error, and confirmation behaviour
|
||||
- Permission-based UI (`can()` / RBAC)
|
||||
- Shared component and visual-language rules
|
||||
- **Out of scope:**
|
||||
- REST contracts, retries, and image pipeline internals
|
||||
- Three.js hall architecture, textures, and lighting (see [basics.md](basics.md))
|
||||
- Pixel-perfect branding kit (no separate design-system package; follow existing CSS)
|
||||
|
||||
### 0.3 Requirement levels
|
||||
|
||||
- **MUST:** Mandatory for new work and for fixes that touch the same screen.
|
||||
- **SHOULD:** Recommended; deviate only with a short note in the PR or screen section.
|
||||
- **MAY:** Optional pattern when the screen specification calls for it.
|
||||
|
||||
### 0.4 Surface map
|
||||
|
||||
| Surface | Typical components | Visitors | Curators |
|
||||
|---------|--------------------|----------|----------|
|
||||
| Timeline home | `Timeline`, `VerticalTimeline`, `MovementBands`, `VerticalMovementBands`, `MovementTree`, `CatalogSearchBar` | yes | yes |
|
||||
| 3D hall | `VirtualGallery`, wing navigator, exit overlays | yes | yes |
|
||||
| Painting / bio | `PaintingDetail`, `PaintingLightbox`, `ArtistBio`, `PaintingAnnotations` | yes | + debug panel when `can('images')` |
|
||||
| Overlays | `CuratorLoginModal`, `ArtistFilterModal`, `ToursPopup`, `DebugSearchResultsModal` | some | all |
|
||||
| Curator tools | `CheckupPage`, `TranslationsPage`, `InfluencesPage`, `ToursPage`, `UsersPage`, `AuditPage` | no | permission-gated |
|
||||
|
||||
---
|
||||
|
||||
## 1. Design Goals and Principles
|
||||
|
||||
### 1.1 Problem statement
|
||||
|
||||
- Statement: Visitors and curators share one shell. Inconsistent back labels, ad-hoc modals, English-only captions, and one-off loading/error treatment make the museum feel like several apps glued together.
|
||||
- Scope: All `client/src` screens and overlays.
|
||||
- Rationale: The product is a gallery, not an admin console with a visitor skin. Predictable chrome is part of the exhibit.
|
||||
- Verification: Compare a new screen’s header, back control, loading marker, and locale keys against this document.
|
||||
|
||||
### 1.2 Functional design principles
|
||||
|
||||
- Principle 1: One museum, two roles
|
||||
- Statement: Public browse chrome MUST stay museum-dark (navy / gold / Georgia). Curator tools MAY be denser but MUST reuse the same header back pattern, gold accent, and permission hiding — they MUST NOT look like a separate product.
|
||||
- Rationale: Curators enter from the same timeline; a visual cliff breaks trust.
|
||||
- Verification: Side-by-side with timeline header and Checkup / Tours editor.
|
||||
|
||||
- Principle 2: Predictable drill-down and return
|
||||
- Statement: Navigation MUST follow Timeline → (movement picker) → hall → painting/bio, with an explicit in-app Back that restores the intended session — not the browser history stack.
|
||||
- Rationale: `HomePage` owns view state; the browser Back button is not wired.
|
||||
- Verification: Walk the [basics.md navigation flow](basics.md#user-navigation-flow) and the Back table in §2.4.
|
||||
|
||||
- Principle 3: Clarity of actions and feedback
|
||||
- Statement: Every user-initiated load, save, delete, or failed request MUST show a loading, success, or error state the user can see without opening the console.
|
||||
- Rationale: 3D and image work is slow; silent failure looks like a broken hall.
|
||||
- Verification: Trigger catalog load, hall open, form save, and a failed API call.
|
||||
|
||||
- Principle 4: Minimize cognitive load
|
||||
- Statement: Timeline charts MUST keep zoom/pan/click hints visible. Curator tables MUST put filter/search above the grid. Destructive actions MUST be confirmed.
|
||||
- Rationale: Dense history data and catalog tables are easy to mis-click.
|
||||
- Verification: Hint captions present; filters above tables; delete paths show a confirm.
|
||||
|
||||
- Principle 5: Locale and permission are first-class
|
||||
- Statement: New visitor-facing copy MUST go through `react-i18next` (`locales/{en,ru}`). Actions the user cannot perform MUST be hidden, not disabled-without-explanation.
|
||||
- Rationale: EN/RU is a product requirement; exposing forbidden tools invites errors.
|
||||
- Verification: Toggle EN|RU; log in as a curator without the relevant `can()` and confirm the control is absent.
|
||||
|
||||
---
|
||||
|
||||
## 2. Navigation Principles
|
||||
|
||||
### 2.1 Application chrome (no left module rail)
|
||||
|
||||
The Gallery has **no persistent left navigation menu**. Primary wayfinding is the **timeline header** on home, and an explicit **Back** control on every nested view.
|
||||
|
||||
- Statement: The timeline home MUST keep layout switch, catalog search, locale switcher, and role-appropriate tools in the site header.
|
||||
- Scope: `HomePage` when `view` is `timeline` / `timeline-vertical` / `timeline-tree`.
|
||||
- Rationale: Visitors need search and layout without hunting; curators need tools without leaving the museum frame.
|
||||
- Verification: Header remains usable at 100vh; search dropdown stacks above movement bands (`z-index` on `.site-header`).
|
||||
|
||||
- Statement: Layout switch links MUST stay in the top-left (`.site-layout-switch`). The Tree of Art control SHOULD use `.site-layout-link-feature`.
|
||||
- Scope: Timeline home.
|
||||
- Rationale: Three layouts must stay discoverable; Tree is the featured alternative start page.
|
||||
- Verification: Classic / Vertical / Tree links match [basics.md](basics.md#timeline-and-movement-flow).
|
||||
|
||||
- Statement: Nested views (hall, painting, bio, curator pages) MUST NOT reintroduce a second global nav. They MUST show a single primary Back control in the page header.
|
||||
- Scope: All non-home views.
|
||||
- Rationale: Avoid competing menus; the drill-down is the nav.
|
||||
- Verification: No duplicate “home” plus “modules” rails on curator pages.
|
||||
|
||||
### 2.2 Page hierarchy
|
||||
|
||||
- Statement: Screens MUST stay within this hierarchy (max four levels):
|
||||
|
||||
1. Timeline home (classic / vertical / tree)
|
||||
2. Overlay or picker (artist filter, tours popup, login) **or** curator tool page
|
||||
3. 3D hall (artist / movement / tour)
|
||||
4. Painting detail or artist bio (optional lightbox on top of detail)
|
||||
|
||||
- Scope: All visitor and curator flows.
|
||||
- Rationale: Matches the existing drill-down; deeper stacks become unrecoverable without a router.
|
||||
- Verification: New views are added to the `View` union in `HomePage.tsx` with a defined parent and Back handler.
|
||||
|
||||
- Statement: Curator tools MUST open as siblings of the timeline (replace the home canvas), not as a fifth level under a hall.
|
||||
- Scope: Checkup, Translations, Influences, Tours editor, Users, Activity.
|
||||
- Rationale: Tools operate on the catalog, not on a hall session.
|
||||
- Verification: Opening Checkup from a hall is not required; from timeline header, Back returns to timeline home.
|
||||
|
||||
### 2.3 Location awareness (no breadcrumbs)
|
||||
|
||||
- Statement: The app MUST NOT add a breadcrumb trail unless a future router lands. Until then, the Back label MUST name the destination (`← Back to Timeline`, `← Back to Gallery`, `← Back`).
|
||||
- Scope: All nested views.
|
||||
- Rationale: There is no URL path to reflect; a fake breadcrumb would lie.
|
||||
- Verification: Labels match §2.4; they are i18n keys (`backToTimeline`, `backToGallery`, …).
|
||||
|
||||
- Statement: Timeline layout MUST be deep-linkable via `?layout=classic|vertical|tree` (omit param for classic). Other views MUST NOT pretend to be bookmarkable until a router exists.
|
||||
- Scope: Timeline home.
|
||||
- Rationale: Layout is the one shareable start-page choice; halls and tools are session state.
|
||||
- Verification: Load `?layout=tree`, switch layouts, confirm `history.replaceState` updates the query.
|
||||
|
||||
### 2.4 Back navigation
|
||||
|
||||
| Control | MUST return to |
|
||||
|---------|----------------|
|
||||
| **← Back to Timeline** (hall header, movement Exit to Timeline, painting opened from search) | Home timeline via `goToTimelineHome()` — unmount hall, clear session, reset year window to full catalog bounds |
|
||||
| **← Back to Gallery** (painting from a hall) | Same hall session (camera / wing preserved) |
|
||||
| **← Back** (artist bio) | `returnTo` view (usually the hall that opened bio) |
|
||||
| Curator page Back | Timeline home |
|
||||
|
||||
- Statement: Back MUST be an in-app control. The browser Back button MUST NOT be relied on (it is not wired to `View` state).
|
||||
- Scope: All nested views.
|
||||
- Rationale: `setView` is the router.
|
||||
- Verification: From painting detail, in-app Back restores the hall; browser Back does not need to.
|
||||
|
||||
- Statement: `goToTimelineHome()` MUST be the single implementation for “leave everything and show the timeline.” New exits MUST call it rather than duplicating reset logic.
|
||||
- Scope: Halls, search-opened paintings, curator Back.
|
||||
- Rationale: Year-range reset and session clear must stay consistent.
|
||||
- Verification: After Exit to Timeline, `viewStart`/`viewEnd` equal catalog bounds.
|
||||
|
||||
### 2.5 State preservation
|
||||
|
||||
- Statement: Timeline pan/zoom (`viewStart` / `viewEnd`) MUST persist while the user stays on a timeline layout. Switching classic ↔ vertical ↔ tree MUST keep the same year window.
|
||||
- Scope: Timeline home.
|
||||
- Rationale: Layout is a lens, not a new dataset.
|
||||
- Verification: Zoom, switch to Tree, confirm the year rail range is unchanged.
|
||||
|
||||
- Statement: Returning to timeline via `goToTimelineHome()` MUST reset the year window to full catalog bounds.
|
||||
- Scope: Hall / search / curator exits that call `goToTimelineHome()`.
|
||||
- Rationale: Documented in [basics.md](basics.md#back-navigation); visitors expect a fresh overview, not a leftover zoom.
|
||||
- Verification: Zoom in, enter a hall, Back to Timeline → full span.
|
||||
|
||||
- Statement: Hall camera and wing MUST be preserved across **Back to Gallery** from painting detail. They MUST be discarded on **Back to Timeline**.
|
||||
- Scope: Artist, movement, and tour halls.
|
||||
- Rationale: Inspecting a painting is a detour; leaving the museum is not.
|
||||
- Verification: Move in the hall, open a painting, Back to Gallery → same viewpoint.
|
||||
|
||||
- Statement: Catalog search input MAY clear when the dropdown closes. It MUST NOT change timeline zoom by itself.
|
||||
- Scope: `CatalogSearchBar`.
|
||||
- Rationale: Search is a jump, not a filter on the chart.
|
||||
- Verification: Type a query, Escape; year window unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 3. Common UI Interaction Patterns
|
||||
|
||||
### 3.1 Catalog search (visitor)
|
||||
|
||||
- Statement: Timeline search MUST live in the header, require **2** trimmed characters, debounce **300 ms**, and group results into Artists, Movements, Paintings.
|
||||
- Scope: `CatalogSearchBar.tsx`.
|
||||
- Rationale: Documented product behaviour; keeps `/api/search` load reasonable.
|
||||
- Verification: 1 character shows no fetch; 2+ after debounce shows groups.
|
||||
|
||||
- Statement: Keyboard MUST support `↑`/`↓` highlight, `Enter` to open, `Escape` to close.
|
||||
- Scope: Catalog search dropdown.
|
||||
- Rationale: Timeline is pointer-heavy; search should still be keyboardable.
|
||||
- Verification: Keyboard-only open of an artist, movement, and painting.
|
||||
|
||||
- Statement: Opening a painting from search MUST set `returnTo` timeline so Back is **← Back to Timeline**, not Gallery.
|
||||
- Scope: Search → painting detail.
|
||||
- Rationale: There is no hall session.
|
||||
- Verification: Search a title, open, Back → home timeline.
|
||||
|
||||
### 3.2 Timeline charts (zoom, pan, click)
|
||||
|
||||
- Statement: Scroll MUST zoom, drag MUST pan, click on a stream/limb/label MUST open the movement (artist filter → hall). All three layouts MUST use `zoomTimelineView` / `panTimelineView`.
|
||||
- Scope: `MovementBands`, `VerticalMovementBands`, `MovementTree`, era rails.
|
||||
- Rationale: Shared year window; one mental model.
|
||||
- Verification: Same wheel/drag behaviour on classic, vertical, and tree.
|
||||
|
||||
- Statement: Each chart MUST show a localised hint caption (`captionClassicTimeline`, `captionClassicFlow`, `captionVerticalTimeline`, `captionVerticalFlow`, `captionTreeFlow`).
|
||||
- Scope: Timeline home.
|
||||
- Rationale: First-time visitors cannot discover zoom/pan otherwise.
|
||||
- Verification: EN and RU captions change with `LocaleSwitcher`.
|
||||
|
||||
- Statement: Timeline layout shifts (lane packing, tree fit scale) SHOULD animate rather than snap.
|
||||
- Scope: Movement charts.
|
||||
- Rationale: Unexplained jumps look like bugs.
|
||||
- Verification: Zoom/pan does not teleport streams.
|
||||
|
||||
### 3.3 Filters and search (curator tables)
|
||||
|
||||
- Statement: Filter/search controls MUST sit in a toolbar **above** the table, not in a column header hack or a page footer.
|
||||
- Scope: Checkup, Translations, Influences, Tours editor, Users, Activity.
|
||||
- Rationale: Matches Checkup (`checkup-toolbar`) and keeps the grid scannable.
|
||||
- Verification: Filters remain visible while the table scrolls.
|
||||
|
||||
- Statement: Simple text filters MAY apply as the user types. Expensive operations (image search, import, refetch) MUST require an explicit button (e.g. Checkup **Search visible**).
|
||||
- Scope: Curator list screens.
|
||||
- Rationale: Checkup search is rate-limited and slow; typing must not fire it.
|
||||
- Verification: Typing in Checkup filter does not start image search.
|
||||
|
||||
- Statement: When a filter hides rows, the toolbar SHOULD show how many rows are visible (e.g. `N shown`).
|
||||
- Scope: Filtered tables.
|
||||
- Rationale: Empty-looking tables need an explanation.
|
||||
- Verification: Filter to zero rows → empty state plus count.
|
||||
|
||||
### 3.4 Date selection
|
||||
|
||||
- Statement: Year fields in the catalog and timeline MUST use numeric years (negative = BCE). They MUST NOT switch to locale-specific calendar widgets for historical BCE dates.
|
||||
- Scope: Timeline bounds, artist lifespan, painting years, curator year filters.
|
||||
- Rationale: The catalog spans −800 to the present; HTML date inputs cannot represent BCE.
|
||||
- Verification: Ancient era still filters correctly.
|
||||
|
||||
- Statement: If a future screen needs a civil date (e.g. audit log day), it SHOULD use ISO `YYYY-MM-DD` and validate start ≤ end for ranges.
|
||||
- Scope: Activity / audit and any new timestamp filters.
|
||||
- Rationale: Consistent with API timestamps; avoids DD/MM ambiguity.
|
||||
- Verification: Invalid range shows a field-level message.
|
||||
|
||||
### 3.5 Tables and lists (curator)
|
||||
|
||||
- Statement: Structured curator datasets MUST use a labeled HTML table (or existing page table classes), one logical record per row.
|
||||
- Scope: Checkup, Users, Influences worklists, Tours list, Translations worklist, Activity.
|
||||
- Rationale: Comparison and row actions need columns, not cards.
|
||||
- Verification: Column headers present; row click/action affects one record.
|
||||
|
||||
- Statement: Visitor-facing catalog MUST NOT be presented as a spreadsheet. Timeline streams, tree limbs, and 3D hangs are the list metaphor.
|
||||
- Scope: Public home and halls.
|
||||
- Rationale: The product is a gallery, not a DAM table.
|
||||
- Verification: No “all paintings” data grid on the public home.
|
||||
|
||||
- Statement: Tables MAY omit pagination while the dataset is curator-sized and client-filtered. If a list grows past comfortable scrolling, it SHOULD paginate or virtualise rather than rendering thousands of DOM rows.
|
||||
- Scope: Curator tools.
|
||||
- Rationale: Checkup is already filter-then-scroll; unbounded paint is a future foot-gun.
|
||||
- Verification: New tools with large lists have a documented paging or virtualisation plan.
|
||||
|
||||
- Statement: Row actions MUST sit in a dedicated column or overflow control, not as random icons in every cell.
|
||||
- Scope: Interactive curator tables.
|
||||
- Rationale: Scanability.
|
||||
- Verification: Action column or consistent button set per row.
|
||||
|
||||
### 3.6 Multi-row and per-record actions
|
||||
|
||||
- Statement: Bulk actions MUST use a leading checkbox column, Select All for **visible** rows only, and a confirmation that includes the affected count for destructive work.
|
||||
- Scope: Any new bulk-enabled table. (Today: ArtistFilterModal multi-select is a picker, not a bulk delete.)
|
||||
- Rationale: Same as the Logistics template; prevent silent mass edits.
|
||||
- Verification: Select All does not imply “all matching in the database” unless explicitly labelled.
|
||||
|
||||
- Statement: Artist filter before a movement hall MUST be a modal checklist with explicit proceed/cancel, not a bulk-edit of the catalog.
|
||||
- Scope: `ArtistFilterModal`.
|
||||
- Rationale: It only chooses who appears in the hall.
|
||||
- Verification: Cancel leaves the user on the timeline; proceed opens the hall.
|
||||
|
||||
- Statement: Primary row/object action SHOULD be the name/title (open painting, open user, open tour). Secondary actions SHOULD stay in the row’s action controls.
|
||||
- Scope: Curator tables and search results.
|
||||
- Rationale: Matches search-result click-to-open.
|
||||
- Verification: Clicking a Checkup title opens the painting when that handler exists.
|
||||
|
||||
- Statement: Unavailable-by-permission actions MUST be hidden. Unavailable-by-record-state SHOULD be disabled with a `title`/tooltip explaining why.
|
||||
- Scope: All tools.
|
||||
- Rationale: RBAC vs workflow are different signals.
|
||||
- Verification: Non-admin does not see Users; a disabled Fix button states why.
|
||||
|
||||
### 3.7 Forms
|
||||
|
||||
- Statement: Short auth and picker flows MUST use a modal. Multi-section catalog editors (Users create/edit, Tours editor, Influences wizard, Translations worklist) MUST be full-page (or the existing page layout), not nested modals.
|
||||
- Scope: All forms.
|
||||
- Rationale: Halls and timeline need to stay the “place”; heavy edit needs space.
|
||||
- Verification: Login is modal; Users is a page.
|
||||
|
||||
- Statement: Forms SHOULD be a single column. Related fields MAY group under a heading when there are more than five inputs.
|
||||
- Scope: Curator forms.
|
||||
- Rationale: Scanning beats dense multi-column on museum-width pages.
|
||||
- Verification: Users create form remains vertically grouped.
|
||||
|
||||
- Statement: Required fields MUST use the native `required` attribute and/or a visible marker; validation errors MUST appear next to the field or as a form-level error the submit control does not obscure.
|
||||
- Scope: Login, Users, Tours, Influences, Translations.
|
||||
- Rationale: Silent submit-disable is not enough.
|
||||
- Verification: Submit empty login → field or form error, not a blank modal.
|
||||
|
||||
- Statement: After successful save, the system MUST show an inline success message (or equivalent) and keep the user on the tool unless the spec says to return to timeline.
|
||||
- Scope: Curator mutations.
|
||||
- Rationale: Users page already uses `message` / `error` banners.
|
||||
- Verification: Save permissions → success text; failed save → error text.
|
||||
|
||||
- Statement: Unsaved-change guards SHOULD be added when a form is long enough that accidental Back would lose work (Tours editor, Translations). Login and tiny pickers MAY skip this.
|
||||
- Scope: Heavy editors.
|
||||
- Rationale: `window.confirm` on delete already exists; abandon-edit is the remaining hole.
|
||||
- Verification: Dirty Tours editor + Back prompts or discards explicitly.
|
||||
|
||||
### 3.8 Edit interaction pattern selection
|
||||
|
||||
| Pattern | Use when |
|
||||
|---------|----------|
|
||||
| **Modal** | Login, artist filter, tours list popup, debug image picker, lightbox, hall exit/wing overlays |
|
||||
| **Inline** | Checkup flags, debug **Checked** / **Fix it** on painting detail — small, reversible |
|
||||
| **Full page** | Curator tools, painting detail, artist bio, 3D hall |
|
||||
| **Drawer** | MUST NOT be introduced unless a spec adds a shared drawer component |
|
||||
|
||||
- Statement: Nested modals MUST NOT be used (no modal opened from another modal). The debug “More” picker MAY stack on painting detail because detail is a full page, not a modal.
|
||||
- Scope: All overlays.
|
||||
- Rationale: Focus traps and Back labels break.
|
||||
- Verification: Login does not open another dialog.
|
||||
|
||||
### 3.9 Modals and overlays
|
||||
|
||||
- Statement: Modals MUST use `role="dialog"` and `aria-modal="true"`, a visible close/cancel, and **Escape** to dismiss unless a submit is in flight.
|
||||
- Scope: `CuratorLoginModal`, `ArtistFilterModal`, `ToursPopup`, `DebugSearchResultsModal`, `PaintingLightbox`, hall exit overlays.
|
||||
- Rationale: Accessibility and parity with search.
|
||||
- Verification: Esc closes lightbox and debug picker; backdrop click matches existing login behaviour.
|
||||
|
||||
- Statement: Backdrop click MAY close pickers and login. It MUST NOT close a modal that is applying a destructive or long-running action.
|
||||
- Scope: Overlays.
|
||||
- Rationale: Accidental dismiss during Fix/upload is costly.
|
||||
- Verification: Click outside login closes; do not dismiss mid-upload.
|
||||
|
||||
### 3.10 Loading, empty, and error states
|
||||
|
||||
- Statement: Catalog, portrait, and hall loads MUST use `GalleryLoadingMarker` (overlay or banner), not an ad-hoc spinner per screen unless the marker cannot cover the region.
|
||||
- Scope: Home, halls, painting/bio image work.
|
||||
- Rationale: One recognisable “the museum is fetching” treatment.
|
||||
- Verification: First visit shows “Loading art history…”; hall open uses the same marker family.
|
||||
|
||||
- Statement: Loading SHOULD be scoped to the affected region. Full-viewport overlay MUST be used only when the user cannot usefully interact (first catalog load, hall WebGL init).
|
||||
- Scope: All loads.
|
||||
- Rationale: Portrait banner vs full-page overlay already follows this.
|
||||
- Verification: Timeline remains visible while “Loading portraits…” banners.
|
||||
|
||||
- Statement: Empty datasets MUST explain themselves (e.g. vertical flow: no movements in range; search: no matches; Checkup: no rows for filter).
|
||||
- Scope: Charts, search, tables.
|
||||
- Rationale: Blank gold-on-navy reads as a crash.
|
||||
- Verification: Zoom to a year with no movements; search a nonsense string.
|
||||
|
||||
- Statement: Failed loads MUST set a visible error string (`error-banner`, form error, or page error) — never `console.error` alone.
|
||||
- Scope: All data-fetching views.
|
||||
- Rationale: Visitors have no console.
|
||||
- Verification: Stop the API and confirm home shows a load failure message.
|
||||
|
||||
### 3.11 3D hall interaction
|
||||
|
||||
- Statement: Halls MUST keep **← Back to Timeline**, pointer-lock / click-to-move as already implemented, and **E** (or documented key) for exit/wing navigation. New hall UI MUST not steal those keys without updating this section.
|
||||
- Scope: `VirtualGallery`.
|
||||
- Rationale: Muscle memory across artist, movement, and tour halls.
|
||||
- Verification: Same Back label and exit overlay pattern in all three hall kinds.
|
||||
|
||||
- Statement: Clicking a framed painting MUST open painting detail with `returnTo` the current hall. Missing images MUST show the draped-canvas placeholder, not a broken `<img>`.
|
||||
- Scope: Halls.
|
||||
- Rationale: Documented in [basics.md](basics.md).
|
||||
- Verification: Work without a file still shows a frame cover.
|
||||
|
||||
---
|
||||
|
||||
## 4. Behavioral Standards (Screen UX)
|
||||
|
||||
### 4.1 Permission-based rendering (RBAC)
|
||||
|
||||
- Statement: Header tools and debug controls MUST render only when `can('<permission>')` (and login for curator). Anonymous visitors MUST see browse + Tours popup + locale, not Checkup/Users/etc.
|
||||
- Scope: `HomePage` header, painting/bio debug panel.
|
||||
- Rationale: Security UX: hide, don’t tease.
|
||||
- Verification: Logged-out home; curator without `users` cannot open Users.
|
||||
|
||||
- Statement: Route-like views that require a role MUST show the existing “Curator access required” panel with login and back-to-gallery actions — they MUST NOT render an empty privileged page.
|
||||
- Scope: Checkup, Translations, Influences, Tours editor, Users, Activity.
|
||||
- Rationale: Deep view state can still be set; the gate must hold.
|
||||
- Verification: Set view to Users while logged out → curator required copy.
|
||||
|
||||
- Statement: The same permission MUST hide the same action on every surface (header, painting debug, API). Do not leave a visible button that 403s.
|
||||
- Scope: All mutations.
|
||||
- Rationale: Predictable roles.
|
||||
- Verification: `can('images')` off → no debug panel and no Fix buttons.
|
||||
|
||||
### 4.2 Notifications and user feedback
|
||||
|
||||
- Statement: Curator mutations MUST show success or error text in the page’s existing banner/message area. The app has no global toast system; new screens MUST NOT invent a third notification widget without replacing this standard.
|
||||
- Scope: Curator pages.
|
||||
- Rationale: Users/Influences already use inline `message` / `error`.
|
||||
- Verification: One visual treatment per page, consistent placement under the header.
|
||||
|
||||
- Statement: Visitor-facing destructive actions (painting **Remove entry** in debug) MUST confirm and then show failure inline if the API rejects.
|
||||
- Scope: Debug painting tools.
|
||||
- Rationale: Catalog deletes are irreversible.
|
||||
- Verification: Cancel confirm → no delete.
|
||||
|
||||
- Statement: Copy MUST be concise and, for errors, actionable (“Is the server running?”, “Sign in as a curator…”).
|
||||
- Scope: All user-visible strings.
|
||||
- Rationale: Support load.
|
||||
- Verification: Home catalog failure string remains understandable.
|
||||
|
||||
### 4.3 Confirmation and cancellation
|
||||
|
||||
- Statement: Destructive curator actions (delete influence, delete tour, remove painting, deactivate user if offered) MUST confirm before the request. `window.confirm` with an i18n string is the current standard (`confirmDelete`); a shared modal MAY replace it later but MUST stay one pattern.
|
||||
- Scope: Influences, Tours, painting remove, Users.
|
||||
- Rationale: Accidental clicks on dense tables.
|
||||
- Verification: Delete tour → confirm; Cancel → no API call.
|
||||
|
||||
- Statement: Cancel on a confirm MUST leave filters, selection, and unsaved fields unchanged.
|
||||
- Scope: All confirms.
|
||||
- Rationale: Context preservation.
|
||||
- Verification: Filtered Checkup, cancel a destructive action → filter still applied.
|
||||
|
||||
---
|
||||
|
||||
## 5. Component Consistency Rules
|
||||
|
||||
### 5.1 Shared interaction pattern usage
|
||||
|
||||
- Statement: New UI MUST reuse existing components before creating parallels: `GalleryLoadingMarker`, `CatalogSearchBar`, `LocaleSwitcher`, `CuratorLoginModal`, `ArtistFilterModal`, `PaintingLightbox`, `DebugSearchResultsModal`, `DebugUploadButton`.
|
||||
- Scope: `client/src`.
|
||||
- Rationale: Duplicate spinners and dialogs already caused drift.
|
||||
- Verification: PR does not add a second login modal or loading overlay.
|
||||
|
||||
- Statement: Movement colours MUST go through `utils/movementColor.ts` (`vividMovementColor`, `shadeMovementColor`). Hex parsing MUST tolerate `#rgb` / `#rrggbb` and keep the first six digits of longer values.
|
||||
- Scope: Timeline charts and any movement swatch.
|
||||
- Rationale: Classic, vertical, and tree already share this util.
|
||||
- Verification: No local `parseHexColor` copies in components.
|
||||
|
||||
- Statement: Timeline zoom/pan MUST use `utils/timelineView.ts`. Tree horizontal layout MUST use `utils/movementTree.ts` (view-independent structure).
|
||||
- Scope: Timeline surfaces.
|
||||
- Rationale: Layouts share one year window.
|
||||
- Verification: No one-off wheel handlers that bypass the util.
|
||||
|
||||
### 5.2 Component and style selection
|
||||
|
||||
- Statement: There is **no Ant Design / MUI**. UI MUST be React + colocated CSS (`ComponentName.tsx` + `ComponentName.css`). Global museum tokens SHOULD reuse existing values:
|
||||
|
||||
| Token | Typical value | Use |
|
||||
|-------|----------------|-----|
|
||||
| Gold | `#c9a96e` / `rgba(201, 169, 110, …)` | Accents, borders, links |
|
||||
| Ink / navy | `#0f0f1a`, `#1a1a2e`, `#16213e` | Page background |
|
||||
| Type | Georgia, serif | Titles, captions, layout links |
|
||||
| Viewport | `100vh`, `overflow: hidden` on home | No document scroll for the museum shell |
|
||||
|
||||
- Scope: All client UI.
|
||||
- Rationale: The look *is* the design system.
|
||||
- Verification: New CSS does not introduce a bright Bootstrap theme.
|
||||
|
||||
- Statement: Prefer semantic `<button type="button">` for actions and native `<form>` for submits. Do not use `<div onClick>` for primary actions.
|
||||
- Scope: All interactive chrome.
|
||||
- Rationale: Keyboard and a11y.
|
||||
- Verification: Header tools are buttons.
|
||||
|
||||
- Statement: Colocate styles; do not add a CSS-in-JS runtime. Shared layout classes live in `HomePage.css` / page CSS, not inline theme objects.
|
||||
- Scope: Client.
|
||||
- Rationale: Matches the repo.
|
||||
- Verification: New component ships a `.css` file or uses an existing one.
|
||||
|
||||
### 5.3 i18n
|
||||
|
||||
- Statement: New user-visible chrome MUST add keys to `client/src/locales/en/*.json` and `ru/*.json` (namespace that matches the screen: `home`, `common`, `debug`, `users`, …). Hardcoded English is allowed only for curator-only screens that are not yet migrated, and those SHOULD be migrated when the screen is touched.
|
||||
- Scope: All new copy.
|
||||
- Rationale: [i18n-russian.md](i18n-russian.md).
|
||||
- Verification: Locale toggle changes the new string.
|
||||
|
||||
- Statement: Catalog entities (names, titles, bios) MUST use API locale resolution, not a second client-side dictionary.
|
||||
- Scope: Timeline labels, search, halls, detail.
|
||||
- Rationale: `entity_translations` is canonical for RU catalog text.
|
||||
- Verification: RU locale shows published translations.
|
||||
|
||||
### 5.4 Exception process
|
||||
|
||||
- Statement: Deviations (new overlay type, toast system, left nav, router, design library) MUST be documented in this file or in the feature’s `Documentation/*.md` with rationale before they spread to a second screen.
|
||||
- Scope: Client-wide patterns.
|
||||
- Rationale: One exception is an experiment; two without a write-up is fragmentation.
|
||||
- Verification: PR description links the exception note.
|
||||
|
||||
- Statement: Known current exceptions (do not cargo-cult; fix when touching the file):
|
||||
- Browser history is not a router.
|
||||
- Only `?layout=` is deep-linked.
|
||||
- Some curator pages (e.g. Checkup) still have English chrome.
|
||||
- Some modals may still omit Escape (login); new modals MUST include it.
|
||||
- Destructive confirms use `window.confirm` rather than a shared dialog component.
|
||||
|
||||
### 5.5 Versioning of behaviour
|
||||
|
||||
- Statement: Behaviour changes to shared patterns (Back, search debounce, layout query, loading marker) MUST update this document in the same change.
|
||||
- Scope: Shared components and `HomePage` navigation.
|
||||
- Rationale: Agents and humans use this as the spec.
|
||||
- Verification: Doc diff accompanies the code diff.
|
||||
|
||||
---
|
||||
|
||||
## 6. Screen-Level Checklist
|
||||
|
||||
Use this before submitting a new view, overlay, or curator tool. Unchecked items need a note.
|
||||
|
||||
- [ ] Surface type is identified (timeline / hall / detail / overlay / curator page).
|
||||
- [ ] Parent view and Back label/destination are defined (`returnTo` or `goToTimelineHome`).
|
||||
- [ ] Permissions: controls hidden unless `can(…)` / signed in as required.
|
||||
- [ ] Loading uses `GalleryLoadingMarker` or a justified scoped indicator.
|
||||
- [ ] Empty and error states are visible copy, not a blank canvas.
|
||||
- [ ] Destructive actions confirm; cancel leaves state unchanged.
|
||||
- [ ] Visitor chrome strings are in `locales/{en,ru}`; catalog text uses API locale.
|
||||
- [ ] No new CSS framework; gold/navy/Georgia; colocated CSS.
|
||||
- [ ] Search/filter (if any) sits above the list; expensive work is explicit-button.
|
||||
- [ ] Modals: `role="dialog"`, close control, Escape.
|
||||
- [ ] Timeline work uses `timelineView` / `movementColor` / `movementTree` as applicable.
|
||||
- [ ] Hall work preserves camera on Back to Gallery and resets on Back to Timeline.
|
||||
- [ ] This document updated if a shared pattern changed.
|
||||
@@ -1,12 +1,14 @@
|
||||
# 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
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| [Documentation/basics.md](Documentation/basics.md) | Architecture, layout, user flow |
|
||||
| [Documentation/ui-interaction-and-component-standards.md](Documentation/ui-interaction-and-component-standards.md) | UI interaction, navigation, and component standards |
|
||||
| [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/environments.md](Documentation/environments.md) | Dev/prod URLs, DB split, deploy, sync |
|
||||
| [Documentation/setup.md](Documentation/setup.md) | Install, env, npm scripts |
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState, memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ArtMovement, Artist } from '../types';
|
||||
import { portraitThumbUrl } from '../api/client';
|
||||
import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc';
|
||||
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||
import { parseHexColor, rgbToHsl, hslToHex, vividMovementColor } from '../utils/movementColor';
|
||||
import './MovementBands.css';
|
||||
|
||||
interface Props {
|
||||
@@ -244,74 +246,6 @@ function buildArtistPlacements(
|
||||
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 {
|
||||
if (laneCount <= 1) return baseColor;
|
||||
try {
|
||||
@@ -1762,6 +1696,7 @@ export default function MovementBands({
|
||||
onArtistHover,
|
||||
onPortraitsLoadingChange,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('home');
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const pendingPortraitsRef = useRef(0);
|
||||
const [portraitsLoading, setPortraitsLoading] = useState(false);
|
||||
@@ -2260,7 +2195,7 @@ export default function MovementBands({
|
||||
return (
|
||||
<div className="movements-flow">
|
||||
<p className="movements-flow-caption">
|
||||
Scroll to zoom · drag to pan · each movement stream is a solid colour band through history
|
||||
{t('captionClassicFlow')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { HistoricalEra } from '../types';
|
||||
import {
|
||||
HISTORICAL_EVENTS,
|
||||
@@ -38,6 +39,7 @@ function formatYear(year: number): string {
|
||||
}
|
||||
|
||||
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight }: Props) {
|
||||
const { t } = useTranslation('home');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState<'left' | 'right' | 'pan' | null>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(800);
|
||||
@@ -365,7 +367,7 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
|
||||
</div>
|
||||
|
||||
<p className="timeline-hint">
|
||||
Click an era or event to zoom · Scroll to zoom · Drag to pan
|
||||
{t('captionClassicTimeline')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,734 @@
|
||||
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ArtMovement } from '../types';
|
||||
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||
import { vividMovementColor } from '../utils/movementColor';
|
||||
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 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 { t } = useTranslation('home');
|
||||
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">
|
||||
{t('captionVerticalFlow')}
|
||||
</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,417 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 { t } = useTranslation('home');
|
||||
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">{t('captionVerticalTimeline')}</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,14 @@
|
||||
"toursEditor": "Tour editor",
|
||||
"users": "Users",
|
||||
"audit": "Activity",
|
||||
"layoutHorizontal": "Classic 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…",
|
||||
"tourEmpty": "This tour has no paintings yet.",
|
||||
"tourLoadFailed": "Failed to load the tour.",
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
"toursEditor": "Редактор экскурсий",
|
||||
"users": "Пользователи",
|
||||
"audit": "Активность",
|
||||
"layoutHorizontal": "Классическая шкала →",
|
||||
"layoutVertical": "↑ Вертикальная шкала",
|
||||
"layoutTree": "🌳 Древо искусства",
|
||||
"captionClassicTimeline": "Щёлкните эпоху или событие для приближения · Колесо — масштаб · Перетащите для панорамы",
|
||||
"captionClassicFlow": "Колесо — масштаб · перетащите для панорамы · каждое направление — цветная полоса сквозь историю",
|
||||
"captionVerticalTimeline": "Снизу вверх · Колесо — масштаб · Перетащите для панорамы",
|
||||
"captionVerticalFlow": "Снизу вверх по истории · колесо — масштаб · перетащите для панорамы · щёлкните поток",
|
||||
"captionTreeFlow": "Корни внизу, живые направления в кроне · колесо — масштаб · перетащите для панорамы · щёлкните ветвь, чтобы войти в зал",
|
||||
"openingTourGallery": "Открытие экскурсии…",
|
||||
"tourEmpty": "В этой экскурсии пока нет картин.",
|
||||
"tourLoadFailed": "Не удалось загрузить экскурсию.",
|
||||
|
||||
@@ -24,6 +24,59 @@
|
||||
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;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -3,6 +3,9 @@ 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 MovementTree from '../components/MovementTree';
|
||||
import VirtualGallery from '../components/VirtualGallery';
|
||||
import PaintingDetailView from '../components/PaintingDetail';
|
||||
import ArtistBio from '../components/ArtistBio';
|
||||
@@ -47,6 +50,8 @@ import './HomePage.css';
|
||||
|
||||
type View =
|
||||
| { type: 'timeline' }
|
||||
| { type: 'timeline-vertical' }
|
||||
| { type: 'timeline-tree' }
|
||||
| { type: 'checkup' }
|
||||
| { type: 'translations' }
|
||||
| { type: 'influences' }
|
||||
@@ -154,6 +159,37 @@ function catalogNavigateTarget(
|
||||
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() {
|
||||
const { t } = useTranslation('home');
|
||||
const { isCurator, isAdmin, username, login, logout, can } = useAuth();
|
||||
@@ -164,7 +200,7 @@ export default function HomePage() {
|
||||
const canInfluences = can('influences');
|
||||
const canTours = can('tours');
|
||||
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 [bounds, setBounds] = useState({ min: -800, max: 2025 });
|
||||
const [viewStart, setViewStart] = useState(-800);
|
||||
@@ -207,7 +243,11 @@ 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' ||
|
||||
view.type === 'timeline-tree'
|
||||
) {
|
||||
setGallerySession(null);
|
||||
}
|
||||
}, [view]);
|
||||
@@ -268,9 +308,34 @@ export default function HomePage() {
|
||||
setViewStart(bounds.min);
|
||||
setViewEnd(bounds.max);
|
||||
setGalleryRevision((revision) => revision + 1);
|
||||
writeTimelineLayoutParam('classic');
|
||||
setView({ type: 'timeline' });
|
||||
}, [bounds.min, bounds.max]);
|
||||
|
||||
const openHorizontalTimeline = () => {
|
||||
writeTimelineLayoutParam('classic');
|
||||
setView({ type: 'timeline' });
|
||||
};
|
||||
|
||||
const openVerticalTimeline = () => {
|
||||
writeTimelineLayoutParam('vertical');
|
||||
setView({ 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 isTreeTimeline = view.type === 'timeline-tree';
|
||||
/** Both alternative layouts run the year axis bottom → top beside the chart. */
|
||||
const isVerticalLayout = isVerticalTimeline || isTreeTimeline;
|
||||
|
||||
const toggleDebugMode = () => {
|
||||
setDebugMode((prev) => {
|
||||
const next = !prev;
|
||||
@@ -1204,9 +1269,41 @@ export default function HomePage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{view.type === 'timeline' && (
|
||||
{isTimelineHome && (
|
||||
<div className="home-page">
|
||||
<header className="site-header">
|
||||
<div className="site-layout-switch">
|
||||
{!isTreeTimeline && (
|
||||
<button
|
||||
type="button"
|
||||
className="site-layout-link site-layout-link-feature"
|
||||
onClick={openTreeTimeline}
|
||||
title="Open the alternative start page: bottom-up timeline with movements drawn as a growing tree"
|
||||
>
|
||||
{t('layoutTree')}
|
||||
</button>
|
||||
)}
|
||||
{!isVerticalTimeline && (
|
||||
<button
|
||||
type="button"
|
||||
className="site-layout-link"
|
||||
onClick={openVerticalTimeline}
|
||||
title="Switch to bottom-up vertical timeline"
|
||||
>
|
||||
{t('layoutVertical')}
|
||||
</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 className="site-dev-tools">
|
||||
{isCurator ? (
|
||||
<>
|
||||
@@ -1334,7 +1431,7 @@ export default function HomePage() {
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div className="home-timeline-stack">
|
||||
<div className={`home-timeline-stack${isVerticalLayout ? ' home-timeline-stack-vertical' : ''}`}>
|
||||
{loading && (
|
||||
<GalleryLoadingMarker overlay message="Loading art history…" />
|
||||
)}
|
||||
@@ -1345,6 +1442,46 @@ export default function HomePage() {
|
||||
<GalleryLoadingMarker banner message="Loading portraits…" />
|
||||
)}
|
||||
|
||||
{isVerticalLayout ? (
|
||||
<>
|
||||
<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">
|
||||
{isTreeTimeline ? (
|
||||
<MovementTree
|
||||
movements={timelineData.movements}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
absoluteMin={bounds.min}
|
||||
absoluteMax={bounds.max}
|
||||
onViewChange={handleViewChange}
|
||||
onMovementClick={handleMovementClick}
|
||||
/>
|
||||
) : (
|
||||
<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}
|
||||
@@ -1378,6 +1515,8 @@ export default function HomePage() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user