Author SHA1 Message Date
Danila KhodjaefandCursor 8e52415ea8 Add UI interaction and component standards for Gallery screens.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 11:46:38 +03:00
Danila KhodjaefandCursor 3fe88ffcfa Document Tree of Art layouts, colour helpers, deep-links, and caption i18n.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 12:52:50 +03:00
Danila KhodjaefandCursor 1c9fa20191 Localise timeline and movement chart captions in English and Russian.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 12:45:44 +03:00
Danila KhodjaefandCursor 44092d102b Add Tree of Art start page with fixed lineage geometry and shareable layout links.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 12:44:08 +03:00
Danila KhodjaefandCursor 33b8ae5a5f Extract shared movement colour helpers and harden hex parsing.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 12:43:49 +03:00
Danila KhodjaefandCursor 971c1e8dd8 Add vertical bottom-up timeline with center-out streams and smooth layout transitions.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 21:31:30 +03:00
Danila KhodjaefandCursor 4088d7d57b Document influence lamp shared-rail height in API.md.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 22:42:33 +03:00
Danila KhodjaefandCursor f20f811f21 Align influence lamps 40cm above the tallest hall frame.
Compute the highest allocated frame top per hall and place every influence lamp on that shared rail so markers line up across different canvas sizes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 22:42:23 +03:00
Danila KhodjaefandCursor cfee69c9a6 Keep prod users local and fix post-restore id sequences.
Prod restore skips users/session/audit, syncs serial sequences after load, and user create re-aligns users_id_seq so new accounts are not misreported as duplicates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:26:52 +03:00
Danila KhodjaefandCursor 8a68e98258 Turn influence lamps 180 degrees in gallery halls.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 18:03:07 +03:00
Danila Khodjaef 8c823a6dfe Release: weekly deploy 2026-08-03 22:50:07 +03:00
Danila KhodjaefandCursor dc0ac81081 Add admin Activity page for curator audit reports.
Admins can filter and review curator_audit_log (date/time, actor, action, resource, details) via /api/audit against the environment DB.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 22:20:09 +03:00
Danila KhodjaefandCursor f9b0fb0496 Rework Gothic hall as a stone nave interior.
Add gothic ashlar/vault/floor textures, compound piers with transverse ribs, proper lancet arches, textured door-flanking panels, and lightScale for shaft-lit naves. Docs updated in basics and data-and-images.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 17:16:54 +03:00
51 changed files with 6947 additions and 216 deletions
@@ -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.
+16 -3
View File
@@ -80,7 +80,7 @@ Destroys the session cookie.
| `tours` | Tour admin CRUD | | `tours` | Tour admin CRUD |
| `users` | `/api/users/*` (Users page) | | `users` | `/api/users/*` (Users page) |
Missing session → **`401`** `{ "error": "Curator login required" }`. Missing permission → **`403`** `{ "error": "Permission denied" }`. Missing session → **`401`** `{ "error": "Curator login required" }`. Missing permission → **`403`** `{ "error": "Permission denied" }`. Admin-only routes (no matching permission flag) → **`403`** `{ "error": "Admin access required" }`.
### Users (admin / `users` permission) ### Users (admin / `users` permission)
@@ -93,6 +93,18 @@ Missing session → **`401`** `{ "error": "Curator login required" }`. Missing p
Only **admins** can create or promote **admin** accounts. Cannot deactivate/demote the last active admin. Audit: `user.create`, `user.update`, `user.reset_password`. Only **admins** can create or promote **admin** accounts. Cannot deactivate/demote the last active admin. Audit: `user.create`, `user.update`, `user.reset_password`.
### Audit log (admin)
Admin-only reports over `curator_audit_log`. Responses include `database` (`process.env.DB_NAME`) so the UI shows whether you are reading **dev** or **prod**.
| Method | Path | Notes |
|--------|------|-------|
| `GET` | `/api/audit` | Paginated entries + resource labels. Query: `user_id`, `username`, `action`, `resource_type`, `resource_id`, `from`, `to`, `q`, `limit`, `offset` |
| `GET` | `/api/audit/summary` | Totals (all / 24h / 7d), breakdowns by user / action / resource_type. Same filters as list |
| `GET` | `/api/audit/meta` | Distinct users, actions, resource types for filter dropdowns |
Each list entry includes `created_at`, `username`, `user_role`, `action`, `resource_type`, `resource_id`, `resource_label`, `details`, `ip_address`.
### Staff-gated routes ### Staff-gated routes
| Route | Permission | Audit action (mutations only) | | Route | Permission | Audit action (mutations only) |
@@ -115,10 +127,11 @@ Only **admins** can create or promote **admin** accounts. Cannot deactivate/demo
| `/api/influences/*` | `influences` | `influence.*` | | `/api/influences/*` | `influences` | `influence.*` |
| Tour admin (`/api/tours/admin`, POST/PATCH/DELETE, stops) | `tours` | `tour.*` | | Tour admin (`/api/tours/admin`, POST/PATCH/DELETE, stops) | `tours` | `tour.*` |
| `/api/users/*` | `users` | `user.*` | | `/api/users/*` | `users` | `user.*` |
| `/api/audit/*` | admin role | — (read) |
**Public** (no login): all catalog `GET` routes, `POST /api/artists/:id/preload-images`, `POST /api/movements/:id/preload-images` (local file linking for 3D halls), `/images`, SPA static. **Public** (no login): all catalog `GET` routes, `POST /api/artists/:id/preload-images`, `POST /api/movements/:id/preload-images` (local file linking for 3D halls), `/images`, SPA static.
Staff mutations are recorded in `curator_audit_log` with `user_id` (see [DB_structure.md](DB_structure.md)). Staff mutations are recorded in `curator_audit_log` with `user_id` (see [DB_structure.md](DB_structure.md)). Browse via admin **Activity** UI or `/api/audit`.
--- ---
@@ -481,7 +494,7 @@ Each painting includes:
| Field | Meaning | | Field | Meaning |
|-------|---------| |-------|---------|
| `has_influence_links` | `true` when the work appears in any influence row — 3D gallery shows a golden lamp above the frame | | `has_influence_links` | `true` when the work appears in any influence row — 3D gallery shows a golden lamp on a shared rail (40 cm above the tallest frame in the hall) |
| `checkup_checked` | Reviewed in checkup / debug workflow (gold frame in 3D when true) | | `checkup_checked` | Reviewed in checkup / debug workflow (gold frame in 3D when true) |
| `checkup_fixed` | Image replaced via **Fix it** | | `checkup_fixed` | Image replaced via **Fix it** |
+2
View File
@@ -251,6 +251,8 @@ Append-only log of staff mutations (fix/clear/upload/delete, checkup flags, tran
**Logged `action` values:** `painting.fix_image`, `painting.clear_image`, `painting.upload_image`, `painting.delete`, `painting.checkup_flags`, `painting.update_curator_notes`, `artist.fix_portrait`, `artist.clear_portrait`, `artist.upload_portrait`, `artist.checkup_flags`, `translation.upsert`, `translation.publish`, `influence.create`, `influence.update`, `influence.delete`, `influence.import`, `tour.create`, `tour.update`, `tour.delete`, `tour.stops`, `user.create`, `user.update`, `user.reset_password`. **Logged `action` values:** `painting.fix_image`, `painting.clear_image`, `painting.upload_image`, `painting.delete`, `painting.checkup_flags`, `painting.update_curator_notes`, `artist.fix_portrait`, `artist.clear_portrait`, `artist.upload_portrait`, `artist.checkup_flags`, `translation.upsert`, `translation.publish`, `influence.create`, `influence.update`, `influence.delete`, `influence.import`, `tour.create`, `tour.update`, `tour.delete`, `tour.stops`, `user.create`, `user.update`, `user.reset_password`.
Admins browse this table in the app (**Activity** / `GET /api/audit*`). Each environments API uses its own DB (`gallery_dev` vs `gallery_prod`); **`users`**, **`session`**, and audit history are not synced by harmonize/`devtoprod:db:restore`.
Example query in pgAdmin: Example query in pgAdmin:
```sql ```sql
+7 -5
View File
@@ -95,7 +95,7 @@ CURATOR_USERNAME=curator
CURATOR_PASSWORD=your-secure-password CURATOR_PASSWORD=your-secure-password
``` ```
Then open the gallery → **Curator login** (top-right) → use tools allowed by your role/permissions (debug, Checkup, Translations, Influences, Tour editor, **Users**). Mutations are logged in `curator_audit_log` per user (view in pgAdmin). Then open the gallery → **Curator login** (top-right) → use tools allowed by your role/permissions (debug, Checkup, Translations, Influences, Tour editor, **Users**, **Activity**). Mutations are logged in `curator_audit_log` per user.
If login fails after changing `CURATOR_PASSWORD` in `.env`, run `npm run dev:reset-curator` (bootstrap only runs when `users` is empty; reset upserts the env account as **admin**). If login fails after changing `CURATOR_PASSWORD` in `.env`, run `npm run dev:reset-curator` (bootstrap only runs when `users` is empty; reset upserts the env account as **admin**).
@@ -105,11 +105,13 @@ If login fails after changing `CURATOR_PASSWORD` in `.env`, run `npm run dev:res
|------|--------| |------|--------|
| Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios | | Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios |
| Curator | Public browse + assigned permission flags (`images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`) | | Curator | Public browse + assigned permission flags (`images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`) |
| Admin | All curator tools + **Users** page to create accounts with individual passwords and permissions | | Admin | All curator tools + **Users** + **Activity** audit reports |
**Users page:** after admin login, header → **Users** — create/edit staff, reset passwords, disable accounts. **Users page:** after admin login, header → **Users** — create/edit staff, reset passwords, disable accounts. Prod and dev keep **separate** `users` tables: `devtoprod:db:restore` and harmonize never copy staff accounts. If create fails with a confusing “already exists” after a restore, serial sequences may be lagging — current restore syncs them to `MAX(id)`, and user create re-syncs `users_id_seq` before insert.
**Audit log (pgAdmin on `gallery_dev` or `gallery_prod`):** **Activity page:** after admin login, header → **Activity** — filterable curator action log (date/time, curator, action, resource, details, IP) plus summary charts. Reads the DB for that environment (`gallery_dev` on devgallery / `npm run dev:web`, `gallery_prod` on prod).
**Audit log (SQL / pgAdmin on `gallery_dev` or `gallery_prod`):**
```sql ```sql
SELECT l.created_at, u.username, l.action, l.resource_type, l.resource_id SELECT l.created_at, u.username, l.action, l.resource_type, l.resource_id
@@ -143,7 +145,7 @@ Run `npm run dev:migrate` against prod DB after first deploy with auth vars set
| `npm run dev:db:backup` | Dev data-only backup → `db/DataBackup/*.txt` + `.zip` | | `npm run dev:db:backup` | Dev data-only backup → `db/DataBackup/*.txt` + `.zip` |
| `npm run prod:db:backup` | Prod backup (reads `infra/docker/.env.prod`) | | `npm run prod:db:backup` | Prod backup (reads `infra/docker/.env.prod`) |
| `npm run dev:db:restore -- --file <path>` | Restore backup into **dev** (truncates tables first; prompts `yes`) | | `npm run dev:db:restore -- --file <path>` | Restore backup into **dev** (truncates tables first; prompts `yes`) |
| `npm run devtoprod:db:restore -- --file <path>` | Restore into **prod** (requires confirmation) | | `npm run devtoprod:db:restore -- --file <path>` | Restore catalog into **prod** (skips `users` / `session` / `curator_audit_log`; syncs serial sequences; requires confirmation) |
| `npm run harmonize` | Bidirectional catalog DB + image merge by `updated_at` / file mtime — [harmonize-dev-prod.md](harmonize-dev-prod.md) | | `npm run harmonize` | Bidirectional catalog DB + image merge by `updated_at` / file mtime — [harmonize-dev-prod.md](harmonize-dev-prod.md) |
| `npm run harmonize:schema` | Apply dev migrations to prod schema only (dev → prod) | | `npm run harmonize:schema` | Apply dev migrations to prod schema only (dev → prod) |
| `npm run harmonize:db` / `harmonize:images` | DB or image merge only (`harmonize:images` also merges artists/paintings checkup flags + image paths, then regenerates thumbs on both sides) | | `npm run harmonize:db` / `harmonize:images` | DB or image merge only (`harmonize:images` also merges artists/paintings checkup flags + image paths, then regenerates thumbs on both sides) |
+2 -2
View File
@@ -9,9 +9,9 @@ this file contains draft for future releases and features
1. ~~Multi language support, russian version at least~~ — done: UI i18n (EN/RU) + `entity_translations` DB + curator Translations tool — [i18n-russian.md](i18n-russian.md) 1. ~~Multi language support, russian version at least~~ — done: UI i18n (EN/RU) + `entity_translations` DB + curator Translations tool — [i18n-russian.md](i18n-russian.md)
2. ~~tool to manage links (influence/influenced by ) import csv's ( define format), edit ,add, delete, visualize, map to pictures/ entities~~ — done: curator Influences page (list CRUD + import wizard CSV/JSON/XLSX + neighborhood graph) — [influence-import.md](influence-import.md) 2. ~~tool to manage links (influence/influenced by ) import csv's ( define format), edit ,add, delete, visualize, map to pictures/ entities~~ — done: curator Influences page (list CRUD + import wizard CSV/JSON/XLSX + neighborhood graph) — [influence-import.md](influence-import.md)
3. tool to monitor/manage (plan actions) of curator actions, markers to check painting/text ? 3. ~~tool to monitor/manage (plan actions) of curator actions, markers to check painting/text ?~~ — done for monitoring: admin **Activity** page + `/api/audit` over `curator_audit_log` (filters, summary, per-env DB). Checkup flags remain the painting review markers.
4. ~~tool to sync prod /env resources (both ways), db structure, db data, images, users etc~~ — done for catalog DB + images: `npm run harmonize` (schema dev→prod only; users/audit excluded) — [harmonize-dev-prod.md](harmonize-dev-prod.md) 4. ~~tool to sync prod /env resources (both ways), db structure, db data, images, users etc~~ — done for catalog DB + images: `npm run harmonize` (schema dev→prod only; users/audit excluded) — [harmonize-dev-prod.md](harmonize-dev-prod.md)
5. ~~curator_audit_log should contain log of actions like fixit, checked, upload etc with details for which entity it was made and details what was the action and outcome~~ — done: table + `logCuratorAction` on fix/clear/upload/delete/checkup flags (and translation upsert/publish); see [DB_structure.md](DB_structure.md#curator_audit_log). (UI to browse logs is still item 3.) 5. ~~curator_audit_log should contain log of actions like fixit, checked, upload etc with details for which entity it was made and details what was the action and outcome~~ — done: table + `logCuratorAction` on fix/clear/upload/delete/checkup flags (and translation upsert/publish); browse via admin **Activity** page — [DB_structure.md](DB_structure.md#curator_audit_log) / [API.md](API.md#audit-log-admin).
6. ~~create search by entity (painting, artist, movement)~~ — done: timeline header + `GET /api/search` 6. ~~create search by entity (painting, artist, movement)~~ — done: timeline header + `GET /api/search`
7. ~~create guided tours (with text/extra infor, set of entities)~~ — done: `tours` / `tour_stops`, public Tours popup + 3D tour hall, curator Tour editor — [tours.md](tours.md) 7. ~~create guided tours (with text/extra infor, set of entities)~~ — done: `tours` / `tour_stops`, public Tours popup + 3D tour hall, curator Tour editor — [tours.md](tours.md)
8. ~~curator role + multi-user accounts with permissions~~ — done: `admin`/`curator` roles, permission flags, Users page + `/api/users`, per-user audit — [API.md](API.md#authentication) / [basics.md](basics.md#user-roles-and-access) 8. ~~curator role + multi-user accounts with permissions~~ — done: `admin`/`curator` roles, permission flags, Users page + `/api/users`, per-user audit — [API.md](API.md#authentication) / [basics.md](basics.md#user-roles-and-access)
+38 -17
View File
@@ -6,8 +6,8 @@ Interactive virtual museum spanning art history: zoomable timeline with event gu
The app is organised as a **drill-down hierarchy**: The app is organised as a **drill-down hierarchy**:
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries. 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 curved SVG streams on the same year axis; documented predecessor→successor branches; portrait thumbnails placed along each stream. 2. **Movement flow** — art movements as SVG streams on the same year axis; documented predecessor→successor branches (classic); portrait thumbnails along each stream. The tree layout redraws the same lineage as a growing tree — see [movement-tree.md](movement-tree.md).
3. **3D gallery** — one personal hall per artist, a **movement gallery** (click a movement name → artist filter → hall), or a **guided tour** hall (timeline → **Tours**): period-themed or tour wings of up to ~55 works, U-shaped hang (left → end wall → right). 3. **3D gallery** — one personal hall per artist, a **movement gallery** (click a movement name → artist filter → hall), or a **guided tour** hall (timeline → **Tours**): period-themed or tour wings of up to ~55 works, U-shaped hang (left → end wall → right).
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **curator notes** and **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography. 4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **curator notes** and **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography.
5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**). 5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**).
@@ -44,23 +44,30 @@ Gallery/
│ │ ├── components/PaintingDetail.tsx # Detail view + debug panel │ │ ├── components/PaintingDetail.tsx # Detail view + debug panel
│ │ ├── components/ArtistBio.tsx # Biography + portrait debug panel │ │ ├── components/ArtistBio.tsx # Biography + portrait debug panel
│ │ ├── components/DebugSearchResultsModal.tsx # “More” search picker (20 results) │ │ ├── components/DebugSearchResultsModal.tsx # “More” search picker (20 results)
│ │ ├── components/Timeline.tsx # Era bar, year ticks, event markers │ │ ├── components/Timeline.tsx # Classic horizontal era bar
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow │ │ ├── components/VerticalTimeline.tsx # Bottom-up vertical era rail
│ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches) │ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches)
│ │ ├── components/VerticalMovementBands.tsx # Bottom-up movement streams
│ │ ├── components/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/CatalogSearchBar.tsx # Timeline header catalog search
│ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail │ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail
│ │ ├── pages/CheckupPage.tsx # Image audit table │ │ ├── pages/CheckupPage.tsx # Image audit table
│ │ ├── pages/TranslationsPage.tsx # Russian translation review │ │ ├── pages/TranslationsPage.tsx # Russian translation review
│ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard │ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard
│ │ ├── pages/ToursPage.tsx # Guided tour editor │ │ ├── pages/ToursPage.tsx # Guided tour editor
│ │ ├── pages/UsersPage.tsx # Staff accounts
│ │ ├── pages/AuditPage.tsx # Admin curator activity reports
│ │ ├── components/ToursPopup.tsx # Public published-tours modal │ │ ├── components/ToursPopup.tsx # Public published-tours modal
│ │ ├── i18n/ # react-i18next bootstrap │ │ ├── i18n/ # react-i18next bootstrap
│ │ ── locales/{en,ru}/ # UI chrome strings │ │ ── locales/{en,ru}/ # UI chrome strings (incl. timeline layout + captions)
│ │ ├── data/historical-events.ts # Timeline event markers (UI) │ │ ├── data/historical-events.ts # Timeline event markers (UI)
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI) │ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor │ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor
│ │ ├── utils/debugMode.ts # Debug mode + “Show more” localStorage prefs │ │ ├── utils/debugMode.ts # Debug mode + “Show more” localStorage prefs
│ │ ── utils/timelineView.ts # Shared zoom/pan math for timeline + movements │ │ ── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
│ │ ├── 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) │ └── dist/ # Production build (served by API when present)
├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools ├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools
│ ├── seed-wikipedia.js │ ├── seed-wikipedia.js
@@ -168,15 +175,25 @@ Implementation: `goToTimelineHome()` in `HomePage.tsx` — do not use the browse
## Timeline and movement flow ## Timeline and movement flow
The home page shows two linked views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`): 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 | | View | Component | Purpose |
|------|-----------|---------| |------|-----------|---------|
| Era bar | `Timeline.tsx` | Historical eras, major event markers, click-to-zoom | | Era bar / rail | `Timeline.tsx` / `VerticalTimeline.tsx` | Historical eras, major event markers, click-to-zoom |
| Movement flow | `MovementBands.tsx` | Curved streams per movement, lineage branches, artist portraits | | 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) ### Catalog search (timeline header)
`CatalogSearchBar.tsx` calls `GET /api/search?q=…` (public, no login). The dropdown is stacked above the timeline (`z-index` on `.site-header`) so results are not hidden by movement bands. `CatalogSearchBar.tsx` calls `GET /api/search?q=…` (public, no login). The dropdown is stacked above the timeline (`z-index` on `.site-header`) so results are not hidden by movement bands.
@@ -299,7 +316,7 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi
| Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) | | Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) |
| Wall tint | Gallery walls blend the artists **movement colour** into cream plaster tones | | Wall tint | Gallery walls blend the artists **movement colour** into cream plaster tones |
| Frame finish | **Unchecked** works: black moulding; **Reviewed** (`checkup_checked`): bright gold moulding at **double width** | | Frame finish | **Unchecked** works: black moulding; **Reviewed** (`checkup_checked`): bright gold moulding at **double width** |
| Influence lamps | A golden picture light appears **above frames** whose work has any influence-graph edge (`has_influence_links` from the API); fixture is mounted **upside down** and is **emissive-only** (no per-frame lights — see light budget below) | | Influence lamps | A golden picture light marks works with any influence-graph edge (`has_influence_links` from the API). All lamps in a hall share one rail height: **40 cm above the tallest allocated frame** in that hall (so they line up regardless of canvas size). Fixture is mounted **upside down** and is **emissive-only** (no per-frame lights — see light budget below) |
| Curator-note plates | A small brass plate hangs **beneath frames** that have non-empty `curator_notes` | | Curator-note plates | A small brass plate hangs **beneath frames** that have non-empty `curator_notes` |
| Eye-level viewing | Frame centres sit at **eye height (~1.65 m)**; the camera stays **level with the floor** (no pitch up/down) | | Eye-level viewing | Frame centres sit at **eye height (~1.65 m)**; the camera stays **level with the floor** (no pitch up/down) |
| Open centre | Floor and ceiling only — no freestanding columns or pedestals in the walkway | | Open centre | Floor and ceiling only — no freestanding columns or pedestals in the walkway |
@@ -336,14 +353,16 @@ Enter from the home page by clicking a **movement name** on the movement flow. F
| Wall order | Same U-shaped hang as artist halls (left → end → right) | | Wall order | Same U-shaped hang as artist halls (left → end → right) |
| Frame captions | **Year · artist** label below each frame | | Frame captions | **Year · artist** label below each frame |
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, Byzantine basilica, NYC loft, white cube, etc.). Style keys use a legacy id map (`DB_ID_TO_STYLE_KEY`: gallery ids **126** → authored keys **2752**, with Northern/High Renaissance swapped) so Byzantine gets the basilica, not Gothic stone | | Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, Byzantine basilica, NYC loft, white cube, etc.). Style keys use a legacy id map (`DB_ID_TO_STYLE_KEY`: gallery ids **126** → authored keys **2752**, with Northern/High Renaissance swapped) so Byzantine gets the basilica, not Gothic stone |
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco, **marble revetment** (framed book-matched panels), **Cosmatesque paving** (`marble-opus-sectile`), **coffered timber** ceilings — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes | | Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco, **marble revetment** (framed book-matched panels), **Cosmatesque paving** (`marble-opus-sectile`), **coffered timber** ceilings, **blind-arcaded ashlar** (`gothic-ashlar`) and **ribbed vaulting** (`gothic-vault`) — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes |
| Architecture in texture | Period halls that need arcades, vaults, or panelling draw that **relief into the texture** rather than adding geometry: `gothic-ashlar` paints a full storey (plinth → blind arcade → string course → triforium → cornice) into one tile whose `metersPerRepeat` equals `WALL_HEIGHT`, so it maps **once vertically** onto the wall; `gothic-vault` paints a quadripartite bay with tiercerons and a boss. Light/dark banding plus a high `normalStrengthFor()` makes it read as carved stone, keeping the mesh and light budget flat |
| Door-flanking panels | Wall segments beside exit doors and passages take the hall's own wall texture (`GalleryWall` accepts `kind`/`tint`) instead of rendering as flat single-colour blocks next to textured walls |
| Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue | | Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue |
| Windows | **Side walls only** — placed in gaps between frames when possible; if a wall is packed, high **clerestory** windows are still added so the hall keeps daylight (`computeSideWallWindows`) | | Windows | **Side walls only** — placed in gaps between frames when possible; if a wall is packed, high **clerestory** windows are still added so the hall keeps daylight (`computeSideWallWindows`). Styles are drawn in `GalleryWindows.tsx`; `gothic-lancet` builds a two-centred arch head from chords with a glazed spandrel, mullions, and transoms |
| Lighting | Shared hall lights only (ambient / hemisphere / directional + capped ceiling track spots + one fill per window). See **light budget** below | | Lighting | Shared hall lights only (ambient / hemisphere / directional + capped ceiling track spots + one fill per window). See **light budget** below |
| 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 | | 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** | | 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 | | 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 | | 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 | | Detail return | **Back to Gallery** from painting detail returns to the same wing with camera preserved; **Back to Timeline** exits the hall entirely |
@@ -452,7 +471,7 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens
|------|-----|--------| |------|-----|--------|
| **`user`** | Anonymous visitor (default) | Browse timeline, movement flow, 3D artist/movement halls, painting detail, artist bios, images | | **`user`** | Anonymous visitor (default) | Browse timeline, movement flow, 3D artist/movement halls, painting detail, artist bios, images |
| **`curator`** | Named staff account | Public browse + tools allowed by their **permission flags** | | **`curator`** | Named staff account | Public browse + tools allowed by their **permission flags** |
| **`admin`** | Named staff account | All curator tools + **Users** management | | **`admin`** | Named staff account | All curator tools + **Users** management + **Activity** audit reports |
**Permission flags:** `images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`. Admins always have every flag. **Permission flags:** `images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`. Admins always have every flag.
@@ -460,7 +479,7 @@ Staff sign in via **Curator login** in the site header (individual username/pass
Admins create and manage accounts on the **Users** page (`UsersPage.tsx` / `/api/users`). Bootstrap the first admin with `CURATOR_*` env vars + `npm run dev:migrate` (or `npm run dev:reset-curator`). Admins create and manage accounts on the **Users** page (`UsersPage.tsx` / `/api/users`). Bootstrap the first admin with `CURATOR_*` env vars + `npm run dev:migrate` (or `npm run dev:reset-curator`).
Mutating actions are appended to **`curator_audit_log`** with `user_id`, action, target id, optional JSON details, and client IP. Query in pgAdmin — see [DB_structure.md](DB_structure.md#curator_audit_log). Mutating actions are appended to **`curator_audit_log`** with `user_id`, action, target id, optional JSON details, and client IP. Admins browse reports on the **Activity** page (`AuditPage.tsx` / `/api/audit`); the API reads whatever DB the server is connected to (`DB_NAME`: `gallery_dev` on dev, `gallery_prod` on prod). See [DB_structure.md](DB_structure.md#curator_audit_log).
## Developer tools (image audit) ## Developer tools (image audit)
@@ -477,6 +496,7 @@ Staff workflow for reviewing and fixing local image files (requires **`images`**
| **Influences** | Home header → **Influences** (`influences`) | List/CRUD influence edges, CSV/JSON/XLSX import wizard, neighborhood graph — [influence-import.md](influence-import.md) | | **Influences** | Home header → **Influences** (`influences`) | List/CRUD influence edges, CSV/JSON/XLSX import wizard, neighborhood graph — [influence-import.md](influence-import.md) |
| **Tour editor** | Home header → **Tour editor** (`tours`) | Create/publish guided tours and stop text — [tours.md](tours.md) | | **Tour editor** | Home header → **Tour editor** (`tours`) | Create/publish guided tours and stop text — [tours.md](tours.md) |
| **Users** | Home header → **Users** (`users` / admin) | Create staff accounts, roles, permissions, reset passwords, disable accounts | | **Users** | Home header → **Users** (`users` / admin) | Create staff accounts, roles, permissions, reset passwords, disable accounts |
| **Activity** | Home header → **Activity** (admin only) | Curator audit reports: filters, summary, dated action log from `curator_audit_log` (env DB) |
| **Tours** | Home header → **Tours** (everyone) | Open published tours in a 3D hall — [tours.md](tours.md) | | **Tours** | Home header → **Tours** (everyone) | Open published tours in a 3D hall — [tours.md](tours.md) |
| **Logout** | Home header (staff) | Ends session; hides staff tools | | **Logout** | Home header (staff) | Ends session; hides staff tools |
| **Debug panel** | Painting detail or artist bio (bottom-left, when debug mode on) | Search preview + action buttons (six on painting detail, five on artist bio) | | **Debug panel** | Painting detail or artist bio (bottom-left, when debug mode on) | Search preview + action buttons (six on painting detail, five on artist bio) |
@@ -527,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 | | [tours.md](tours.md) | Guided tours — editor, public popup, 3D tour hall |
| [i18n-russian.md](i18n-russian.md) | Russian UI + entity_translations | | [i18n-russian.md](i18n-russian.md) | Russian UI + entity_translations |
| [data-and-images.md](data-and-images.md) | Image pipeline and seeding | | [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 |
+3 -1
View File
@@ -278,7 +278,9 @@ To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the cli
## Movement gallery interiors (frontend) ## Movement gallery interiors (frontend)
Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (pilasters, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts` (colour maps in sRGB, normal maps in linear/`NoColorSpace`). Period mesh details live in `client/src/components/MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** so shafts never cover frames. Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (pilasters, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts` (colour maps in sRGB, normal maps in linear/`NoColorSpace`). Period mesh details live in `client/src/components/MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** so shafts never cover frames; Byzantine adds porphyry colonnettes / polycandela; Gothic (`details: 'gothic'`) adds bay-spaced compound piers with vault springers and transverse ribs flush to the side walls.
Gothic surfaces include **`gothic-ashlar`** (full-storey blind arcade mapped once vertically via `metersPerRepeat === WALL_HEIGHT`), **`gothic-vault`** (quadripartite rib bay), and **`gothic-stone-floor`**. Door-flanking wall panels use the hall wall texture via `GalleryWall` `kind`/`tint` so they match the textured walls. Deliberately dim naves set **`lightScale`** (Byzantine / Gothic) so shared lights stay soft while window shafts stay bright.
Wing layout (up to 55 works per wing, U-shaped hang, window gap placement with packed-wall **clerestory** fallback) lives in `client/src/utils/movementHallLayout.ts`. Visit order fills the **left wall** (first work at the entrance), then the **far/end wall**, then the **right** (last work at the entrance). The same hang applies to artist halls and guided tours. Hall lighting is **shared** (no per-painting spotlights) so `MeshStandardMaterial` walls stay within WebGL light limits — see [basics.md](basics.md) § Shared 3D behaviour. To change a movements look, edit its entry in `movement-interior-styles.ts` and rebuild the client. Wing layout (up to 55 works per wing, U-shaped hang, window gap placement with packed-wall **clerestory** fallback) lives in `client/src/utils/movementHallLayout.ts`. Visit order fills the **left wall** (first work at the entrance), then the **far/end wall**, then the **right** (last work at the entrance). The same hang applies to artist halls and guided tours. Hall lighting is **shared** (no per-painting spotlights) so `MeshStandardMaterial` walls stay within WebGL light limits — see [basics.md](basics.md) § Shared 3D behaviour. To change a movements look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
+4 -3
View File
@@ -244,10 +244,11 @@ The restore loads rows in two ways automatically:
Multi-line values (e.g. artist bios with embedded newlines) are parsed as whole statements, so long text restores correctly. Multi-line values (e.g. artist bios with embedded newlines) are parsed as whole statements, so long text restores correctly.
**Caveats — prod tables are replaced by dev's contents:** **Caveats — prod catalog tables are replaced by dev's contents:**
- `users` is overwritten. The **dev curator account and password become the prod login**. `curator_audit_log` is **not** synced — prod keeps its existing audit history. - **`users`**, **`session`**, and **`curator_audit_log`** are **not** truncated or loaded from the backup. Prod staff accounts, passwords, active sessions, and audit history stay as they are on `gallery_prod`.
- The `session` table is truncated, so any active prod curator sessions are logged out. - Catalog / content tables (`artists`, `paintings`, tours, translations, etc.) are fully replaced by the dev dump.
- After load, serial sequences are reset to `MAX(id)` so new rows (including staff users) do not collide with restored ids.
- The target is guarded: the restore refuses to run unless the database name ends with `_prod` and only reads `infra/docker/.env.prod`. - The target is guarded: the restore refuses to run unless the database name ends with `_prod` and only reads `infra/docker/.env.prod`.
> **Optional** — to use the faster single-pass load, have the postgres superuser run this once in pgAdmin (role-global, covers dev and prod): `GRANT SET ON PARAMETER session_replication_role TO gallery;` > **Optional** — to use the faster single-pass load, have the postgres superuser run this once in pgAdmin (role-global, covers dev and prod): `GRANT SET ON PARAMETER session_replication_role TO gallery;`
+1 -1
View File
@@ -117,7 +117,7 @@ npm run infra:db:split-dev-prod
CURATOR_PASSWORD=your-secure-password CURATOR_PASSWORD=your-secure-password
``` ```
Omit `SESSION_COOKIE_SECURE` so cookies follow the request scheme (`TRUST_PROXY` + HTTPS → Secure). Set `true`/`false` to force. `npm run dev:migrate` creates auth tables/roles and bootstraps the first **admin** when `users` is empty. Reset that account later with `npm run dev:reset-curator`. Create additional staff via the in-app **Users** page. Omit `SESSION_COOKIE_SECURE` so cookies follow the request scheme (`TRUST_PROXY` + HTTPS → Secure). Set `true`/`false` to force. `npm run dev:migrate` creates auth tables/roles and bootstraps the first **admin** when `users` is empty. Reset that account later with `npm run dev:reset-curator`. Create additional staff via the in-app **Users** page. Admins browse curator actions on **Activity** (`/api/audit`), which always reads the DB named by `DB_NAME` for that environment (`gallery_dev` here; `gallery_prod` on prod). **`users`**, **`session`**, and **`curator_audit_log`** are never copied by harmonize or `devtoprod:db:restore` — each env keeps its own staff accounts and audit history.
2. Run: 2. Run:
+10
View File
@@ -25,6 +25,16 @@ Timeline header: **EN | RU** toggle (`LocaleSwitcher`).
- Passes `?locale=ru` on catalog API requests - Passes `?locale=ru` on catalog API requests
- Refetches bootstrap catalog when locale changes - Refetches bootstrap catalog when locale changes
Timeline **layout** chrome and chart hints are also localised in `home.json`:
| Key | Where |
|-----|--------|
| `layoutHorizontal` / `layoutVertical` / `layoutTree` | Header layout switch |
| `captionClassicTimeline` / `captionClassicFlow` | Classic era bar + movement streams |
| `captionVerticalTimeline` / `captionVerticalFlow` | Vertical rail + streams |
| `captionTreeFlow` | Tree of Art chart |
Shareable layout URLs (`?layout=tree` etc.) are language-independent; captions follow the active locale.
--- ---
## Setup (dev) ## Setup (dev)
+107
View File
@@ -0,0 +1,107 @@
# Tree of Art — alternative start page
An alternative landing layout for the timeline: the year axis runs **bottom → top**
and the art movements are drawn as a **growing tree** instead of parallel streams.
Reached from the **🌳 Tree of art** link at the top-left of every timeline page
(`layoutTree`); the classic and vertical layouts stay untouched and are one click away.
Shareable URL: `?layout=tree` (also `vertical` / omit or `classic` for the other layouts).
| | Classic | Vertical | **Tree** |
|---|---|---|---|
| Component | [`MovementBands`](../client/src/components/MovementBands.tsx) | [`VerticalMovementBands`](../client/src/components/VerticalMovementBands.tsx) | [`MovementTree`](../client/src/components/MovementTree.tsx) |
| Time axis | left → right | bottom → top | bottom → top |
| Layout | temporal lanes | centre-out lanes | spanning tree |
| Recomputed on zoom | yes | yes | **no — structure is fixed** |
| Artist portraits | yes | no | no |
Both bottom-up layouts share the [`VerticalTimeline`](../client/src/components/VerticalTimeline.tsx)
axis, so eras, event marks, zoom and pan behave identically across them.
## Layout rules
The geometry splits in two: [`utils/movementTree.ts`](../client/src/utils/movementTree.ts)
decides the **shape of the tree** (horizontal, view-independent), and `MovementTree`
maps that shape onto the **current year window** (vertical) each frame.
### Structure — `buildMovementTree()`
1. **Time grows upward.** Oldest movements at the bottom, newest at the crown.
Y is a plain `year → pixel` mapping; the layout engine never touches it.
2. **One trunk, at the centre.** `MOVEMENT_LINEAGE` is a DAG, so it is reduced to a
spanning tree: each movement keeps its **most immediate predecessor** (the parent
with the latest start year that still precedes it) as its structural parent.
Ranking by start year first makes cycles impossible by construction.
3. **Extra parents become grafts.** The predecessors that lost step 2 are still drawn —
as thin, low-opacity limbs behind the tree — so `Post-Impressionism → Cubism`
survives even though Cubism hangs structurally off Fauvism.
4. **Children split the parent's slot.** Each node reserves a slot as wide as its whole
subtree (`max(own limb, Σ children)`); children are packed side by side and centred
on the parent. A single-child chain inherits the parent's x exactly — the trunk stays
straight until it forks, forks spread symmetrically, and later generations land
further from the centre.
5. **Leonardo's rule for thickness.** A limb is as thick as the limbs it carries:
`base² = own² + Σ child.base²`. The trunk is the thickest thing on screen and every
branch tapers as it rises and sheds children. A movement's *own* thickness comes from
its `influence_link_count`.
6. **Branches lean outward** across their own lifespan, by at most the slack left inside
their slot — organic, and collision-free by construction.
7. **Unlinked movements are saplings.** A movement with no lineage edge is its own root;
extra roots are planted alternately right and left of the trunk, widest subtree first,
so the main trunk keeps x = 0 (canvas centre). Roots get a small root flare.
Because the structure is built from the **whole catalogue**, zooming never reshuffles the
tree — you keep your bearings, unlike the lane-packed layouts which re-pack on every view
change.
### Rendering — `MovementTree`
8. **Spread follows zoom, not the canvas.** 90 % of the catalogue lives in the last 15 %
of the time axis, so a tree stretched to full width with all of history in view is one
long trunk under a flat bar. The whole-history view draws the tree at
`FULL_VIEW_WIDTH_SHARE` (52 %) of the available width, and each zoom step fans the
crown out (`(totalSpan / visibleSpan) ^ 0.45`, capped at `MAX_FIT_BOOST`). The chart
grows as you walk up it. Whatever is on screen is always clamped to fit the canvas.
9. **Readability floors, never date changes.** A 30-year movement is ~8 px tall with 2 900
years in view. So a limb is drawn at least `MIN_LIMB_RISE_PX` long, a junction climbs at
least `MIN_JUNCTION_RISE_PX` before it spreads sideways, and **a limb is never thicker
than 55 % of its own length**. Positions still come from real years; only the drawn
length and thickness have a floor, and the junction slides down the parent limb (never
off it) to find its rise.
10. **Ribbons, not strokes.** Limbs are filled ribbons sampled along a cubic and offset
along the curve *normal*, so a junction stays solid even when a zoomed-out view
squeezes it almost flat. Shading runs dark → colour → dark across each limb for a
rounded, woody read.
11. **Greedy label declutter.** Every visible movement asks for a name; closest to the
trunk wins, and names that would collide with a placed one — or fall off the canvas —
stay hidden until you zoom in on them.
12. **Hover lights the descent line.** Hovering a movement brightens its whole path back
to the root (grafts included) and dims the rest — the fastest way to read "where did
this come from".
Clicking any limb or label opens the movement's artist picker and then its 3D movement
gallery, exactly as the other two layouts do.
## Colours and captions
- Limb fill/shading uses [`utils/movementColor.ts`](../client/src/utils/movementColor.ts)
(`vividMovementColor`, `shadeMovementColor`) — the same helpers as the classic and
vertical charts. Malformed catalogue hex falls back to the input string; values longer
than six digits keep the first six (`#rrggbbaa``#rrggbb`).
- The chart hint under the canvas is `captionTreeFlow` in
`locales/{en,ru}/home.json` (same pattern as the other layouts).
## Tuning
All constants sit at the top of the two files and are safe to tune:
| Constant | File | Effect |
|---|---|---|
| `LEAF_SLOT_PX`, `LIMB_GAP_PX` | `movementTree.ts` | how far apart branches sit |
| `MIN_LIMB_PX`, `MAX_OWN_LIMB_PX`, `MAX_TRUNK_PX` | `movementTree.ts` | thickness range |
| `LEAN_SLACK`, `MAX_LEAN_PX` | `movementTree.ts` | how much limbs bend outward |
| `FULL_VIEW_WIDTH_SHARE`, `ZOOM_SPREAD_EXPONENT`, `MAX_FIT_BOOST` | `MovementTree.tsx` | crown spread vs. zoom |
| `MIN_LIMB_RISE_PX`, `MIN_JUNCTION_RISE_PX`, `MAX_THICKNESS_OF_LENGTH` | `MovementTree.tsx` | crown legibility at full zoom-out |
New lineage edges only need adding to
[`client/src/data/movement-lineage.ts`](../client/src/data/movement-lineage.ts) — the tree
picks up parents, grafts, thickness and spacing from there automatically.
+1 -1
View File
@@ -112,7 +112,7 @@ Image fetch can take hours if you run it for the entire catalog. The first line
| `npm run devtoprod:images` | Copy `data/images/` → TrueNAS via SMB `Gallery` share | | `npm run devtoprod:images` | Copy `data/images/` → TrueNAS via SMB `Gallery` share |
| `npm run prodto:dev:images` | Copy prod images → dev repo | | `npm run prodto:dev:images` | Copy prod images → dev repo |
| `npm run prodto:dev:db` | Clone `gallery_prod``gallery_dev` | | `npm run prodto:dev:db` | Clone `gallery_prod``gallery_dev` |
| `npm run dev:db:backup` / `devtoprod:db:restore` | Dev backup / promote DB to prod | | `npm run dev:db:backup` / `devtoprod:db:restore` | Dev backup / promote catalog DB to prod (prod restore skips staff/session/audit; syncs sequences) |
| `npm run harmonize` | Bidirectional catalog + image merge (last-write-wins) — [harmonize-dev-prod.md](harmonize-dev-prod.md) | | `npm run harmonize` | Bidirectional catalog + image merge (last-write-wins) — [harmonize-dev-prod.md](harmonize-dev-prod.md) |
| `npm run prod:build` | Build production SPA into `client/dist` | | `npm run prod:build` | Build production SPA into `client/dist` |
| `npm run dev:start` | API + static SPA on `HOST`:`PORT` (uses root `.env`) | | `npm run dev:start` | API + static SPA on `HOST`:`PORT` (uses root `.env`) |
@@ -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 screens 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 rows 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, dont 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 pages 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 features `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.
+3 -1
View File
@@ -1,12 +1,14 @@
# Art Gallery # Art Gallery
Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, **curator-gated** debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**; painting detail also **Remove entry**), optional **Show more** auto-opens the search picker, Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. Anonymous visitors browse freely; curators sign in via **Curator login** in the header. Interactive virtual art gallery: zoomable historical timeline (classic left→right, vertical, or **Tree of Art** lineage chart — shareable via `?layout=`) with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, **curator-gated** debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**; painting detail also **Remove entry**), optional **Show more** auto-opens the search picker, Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. Anonymous visitors browse freely; curators sign in via **Curator login** in the header.
## Documentation ## Documentation
| Document | Purpose | | Document | Purpose |
|----------|---------| |----------|---------|
| [Documentation/basics.md](Documentation/basics.md) | Architecture, layout, user flow | | [Documentation/basics.md](Documentation/basics.md) | Architecture, layout, user flow |
| [Documentation/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/FAC.md](Documentation/FAC.md) | **Command cheat sheet** — start/stop, import, deploy |
| [Documentation/environments.md](Documentation/environments.md) | Dev/prod URLs, DB split, deploy, sync | | [Documentation/environments.md](Documentation/environments.md) | Dev/prod URLs, DB split, deploy, sync |
| [Documentation/setup.md](Documentation/setup.md) | Install, env, npm scripts | | [Documentation/setup.md](Documentation/setup.md) | Install, env, npm scripts |
+79
View File
@@ -77,6 +77,77 @@ export interface StaffUser {
last_login_at: string | null; last_login_at: string | null;
} }
export interface AuditLogEntry {
id: number;
created_at: string;
user_id: number;
username: string;
user_role: 'admin' | 'curator';
action: string;
resource_type: string;
resource_id: number | null;
resource_label: string | null;
details: Record<string, unknown> | null;
ip_address: string | null;
}
export interface AuditLogList {
database: string | null;
total: number;
limit: number;
offset: number;
entries: AuditLogEntry[];
}
export interface AuditSummary {
database: string | null;
total: number;
last_24h: number;
last_7d: number;
oldest: string | null;
newest: string | null;
by_user: Array<{ user_id: number; username: string; role: string; count: number }>;
by_action: Array<{ action: string; count: number }>;
by_resource_type: Array<{ resource_type: string; count: number }>;
}
export interface AuditMeta {
database: string | null;
users: Array<{ id: number; username: string; role: string }>;
actions: string[];
resource_types: string[];
}
export type AuditQuery = {
user_id?: number;
username?: string;
action?: string;
resource_type?: string;
resource_id?: number;
from?: string;
to?: string;
q?: string;
limit?: number;
offset?: number;
};
function auditQueryString(params?: AuditQuery): string {
if (!params) return '';
const qs = new URLSearchParams();
if (params.user_id != null) qs.set('user_id', String(params.user_id));
if (params.username) qs.set('username', params.username);
if (params.action) qs.set('action', params.action);
if (params.resource_type) qs.set('resource_type', params.resource_type);
if (params.resource_id != null) qs.set('resource_id', String(params.resource_id));
if (params.from) qs.set('from', params.from);
if (params.to) qs.set('to', params.to);
if (params.q) qs.set('q', params.q);
if (params.limit != null) qs.set('limit', String(params.limit));
if (params.offset != null) qs.set('offset', String(params.offset));
const s = qs.toString();
return s ? `?${s}` : '';
}
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> { async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, { ...fetchCredentials, ...init }); const res = await fetch(url, { ...fetchCredentials, ...init });
if (!res.ok) throw new Error(`API error: ${res.status}`); if (!res.ok) throw new Error(`API error: ${res.status}`);
@@ -407,6 +478,14 @@ export interface PaintingCheckupData {
export const api = { export const api = {
listUsers: () => fetchJson<{ users: StaffUser[]; permissions: StaffPermission[] }>(`${API}/users`), listUsers: () => fetchJson<{ users: StaffUser[]; permissions: StaffPermission[] }>(`${API}/users`),
listAuditLog: (params?: AuditQuery) =>
fetchJson<AuditLogList>(`${API}/audit${auditQueryString(params)}`),
getAuditSummary: (params?: AuditQuery) =>
fetchJson<AuditSummary>(`${API}/audit/summary${auditQueryString(params)}`),
getAuditMeta: () => fetchJson<AuditMeta>(`${API}/audit/meta`),
createUser: (body: { createUser: (body: {
username: string; username: string;
password: string; password: string;
+50 -7
View File
@@ -1,5 +1,4 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import * as THREE from 'three';
import type { GalleryWindowSpec, GalleryWindowStyle } from '../data/movement-interior-styles'; import type { GalleryWindowSpec, GalleryWindowStyle } from '../data/movement-interior-styles';
const WALL_HEIGHT = 4.2; const WALL_HEIGHT = 4.2;
@@ -69,10 +68,56 @@ function WindowFrame({
</mesh> </mesh>
{arch && style === 'gothic-lancet' && ( {arch && style === 'gothic-lancet' && (
<mesh position={[0, height / 2 + 0.15, -depth / 2 + 0.02]}> <>
<coneGeometry args={[width / 2 + frameW, 0.5, 4]} /> {/* Two-centred arch head built from short chords */}
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.35} /> {Array.from({ length: 14 }, (_, i) => {
</mesh> const segs = 14;
const rise = width * 0.62;
const pt = (t: number): [number, number] => {
const x = (t - 0.5) * (width + frameW * 2);
const y = Math.pow(Math.cos((t - 0.5) * Math.PI), 0.72) * rise;
return [x, y];
};
const [x0, y0] = pt(i / segs);
const [x1, y1] = pt((i + 1) / segs);
const len = Math.hypot(x1 - x0, y1 - y0);
return (
<mesh
key={i}
position={[(x0 + x1) / 2, height / 2 + (y0 + y1) / 2, -depth / 2 + 0.02]}
rotation={[0, 0, Math.atan2(y1 - y0, x1 - x0)]}
>
<boxGeometry args={[len * 1.15, frameW * 1.4, depth]} />
<meshStandardMaterial color={trimColor} roughness={0.55} metalness={0.15} />
</mesh>
);
})}
{/* Glazed spandrel filling the arch head */}
<mesh position={[0, height / 2 + width * 0.16, 0.01]}>
<planeGeometry args={[width * 0.72, width * 0.5]} />
<meshStandardMaterial
color="#9fc4ef"
emissive="#9fc4ef"
emissiveIntensity={0.6}
toneMapped={false}
transparent
opacity={0.8}
/>
</mesh>
{/* Mullions and transoms */}
{[-width / 4, width / 4].map((ox) => (
<mesh key={ox} position={[ox, 0, -depth / 2 + 0.03]}>
<boxGeometry args={[0.045, height, depth]} />
<meshStandardMaterial color={trimColor} roughness={0.55} metalness={0.15} />
</mesh>
))}
{[height / 3, -height / 6].map((oy) => (
<mesh key={oy} position={[0, oy, -depth / 2 + 0.03]}>
<boxGeometry args={[width, 0.035, depth]} />
<meshStandardMaterial color={trimColor} roughness={0.55} metalness={0.15} />
</mesh>
))}
</>
)} )}
{style === 'roman-arch' && ( {style === 'roman-arch' && (
@@ -146,8 +191,6 @@ function SingleWindow({
spec: GalleryWindowSpec; spec: GalleryWindowSpec;
trimColor: string; trimColor: string;
}) { }) {
const glassColor = useMemo(() => new THREE.Color(spec.lightColor), [spec.lightColor]);
return ( return (
<group> <group>
<WindowFrame style={spec.style} width={spec.width} height={spec.height} trimColor={trimColor} /> <WindowFrame style={spec.style} width={spec.width} height={spec.height} trimColor={trimColor} />
+7 -73
View File
@@ -1,9 +1,11 @@
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState, memo } from 'react'; import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState, memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { ArtMovement, Artist } from '../types'; import type { ArtMovement, Artist } from '../types';
import { portraitThumbUrl } from '../api/client'; import { portraitThumbUrl } from '../api/client';
import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc'; import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc';
import { MOVEMENT_LINEAGE } from '../data/movement-lineage'; import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
import { panTimelineView, zoomTimelineView } from '../utils/timelineView'; import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
import { parseHexColor, rgbToHsl, hslToHex, vividMovementColor } from '../utils/movementColor';
import './MovementBands.css'; import './MovementBands.css';
interface Props { interface Props {
@@ -32,6 +34,8 @@ interface MovementLayout {
/** Band thickness in CSS pixels (proportional to influence_link_count). */ /** Band thickness in CSS pixels (proportional to influence_link_count). */
strokePx: number; strokePx: number;
portraitSizePx: number; portraitSizePx: number;
/** Saturated stream colour used for bands, branches, and artist lifespan tints. */
displayColor: string;
/** True when the on-band name label is wider than the visible movement span. */ /** True when the on-band name label is wider than the visible movement span. */
labelFitsInBand: boolean; labelFitsInBand: boolean;
} }
@@ -242,74 +246,6 @@ function buildArtistPlacements(
return placements; return placements;
} }
function parseHexColor(hex: string): [number, number, number] {
const normalized = hex.replace('#', '');
const value =
normalized.length === 3
? normalized
.split('')
.map((c) => c + c)
.join('')
: normalized.padStart(6, '0').slice(0, 6);
return [
parseInt(value.slice(0, 2), 16),
parseInt(value.slice(2, 4), 16),
parseInt(value.slice(4, 6), 16),
];
}
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
if (max === min) return [0, 0, l];
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h = 0;
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
else h = ((rn - gn) / d + 4) / 6;
return [h * 360, s, l];
}
function hslToHex(h: number, s: number, l: number): string {
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r = 0;
let g = 0;
let b = 0;
if (h < 60) [r, g, b] = [c, x, 0];
else if (h < 120) [r, g, b] = [x, c, 0];
else if (h < 180) [r, g, b] = [0, c, x];
else if (h < 240) [r, g, b] = [0, x, c];
else if (h < 300) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];
const toByte = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
}
/** Boost saturation / mid lightness so streams read vividly on the dark flow canvas. */
function vividMovementColor(hex: string): string {
try {
const [r, g, b] = parseHexColor(hex);
const [h, s, l] = rgbToHsl(r, g, b);
const s2 = s < 0.1 ? Math.min(0.55, s + 0.42) : Math.min(1, s * 1.65 + 0.08);
const l2 =
l < 0.22
? 0.5
: l > 0.78
? 0.62
: Math.min(0.68, Math.max(0.4, l * 0.75 + 0.28));
return hslToHex(h, s2, l2);
} catch {
return hex;
}
}
function artistLifespanColor(baseColor: string, laneIndex: number, laneCount: number): string { function artistLifespanColor(baseColor: string, laneIndex: number, laneCount: number): string {
if (laneCount <= 1) return baseColor; if (laneCount <= 1) return baseColor;
try { try {
@@ -1760,6 +1696,7 @@ export default function MovementBands({
onArtistHover, onArtistHover,
onPortraitsLoadingChange, onPortraitsLoadingChange,
}: Props) { }: Props) {
const { t } = useTranslation('home');
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
const pendingPortraitsRef = useRef(0); const pendingPortraitsRef = useRef(0);
const [portraitsLoading, setPortraitsLoading] = useState(false); const [portraitsLoading, setPortraitsLoading] = useState(false);
@@ -1953,14 +1890,13 @@ export default function MovementBands({
}; };
}, [visibleMovements.length]); }, [visibleMovements.length]);
const { layouts, layoutHeight, branches, childIdsByParent, streamStrokePx, portraitSizePx, streamCurveOffset } = const { layouts, layoutHeight, branches, streamStrokePx, portraitSizePx, streamCurveOffset } =
useMemo(() => { useMemo(() => {
if (visibleMovements.length === 0) { if (visibleMovements.length === 0) {
return { return {
layouts: [] as MovementLayout[], layouts: [] as MovementLayout[],
layoutHeight: DEFAULT_CANVAS_HEIGHT, layoutHeight: DEFAULT_CANVAS_HEIGHT,
branches: [] as BranchSegment[], branches: [] as BranchSegment[],
childIdsByParent: new Map<number, number[]>(),
streamStrokePx: MAX_STREAM_STROKE_PX, streamStrokePx: MAX_STREAM_STROKE_PX,
portraitSizePx: 78, portraitSizePx: 78,
streamCurveOffset: 14, streamCurveOffset: 14,
@@ -2148,7 +2084,6 @@ export default function MovementBands({
layouts: [...layoutById.values()].sort((a, b) => a.depth - b.depth), layouts: [...layoutById.values()].sort((a, b) => a.depth - b.depth),
layoutHeight, layoutHeight,
branches: branchList, branches: branchList,
childIdsByParent,
streamStrokePx, streamStrokePx,
portraitSizePx, portraitSizePx,
streamCurveOffset, streamCurveOffset,
@@ -2250,7 +2185,6 @@ export default function MovementBands({
); );
} }
const layoutById = new Map((flowVisual?.layouts ?? layouts).map((l) => [l.movement.id, l]));
const drawLayouts = flowVisual?.layouts ?? layouts; const drawLayouts = flowVisual?.layouts ?? layouts;
const drawBranches = flowVisual?.branches ?? branches; const drawBranches = flowVisual?.branches ?? branches;
const drawCurveOffset = flowVisual?.streamCurveOffset ?? streamCurveOffset; const drawCurveOffset = flowVisual?.streamCurveOffset ?? streamCurveOffset;
@@ -2261,7 +2195,7 @@ export default function MovementBands({
return ( return (
<div className="movements-flow"> <div className="movements-flow">
<p className="movements-flow-caption"> <p className="movements-flow-caption">
Scroll to zoom · drag to pan · each movement stream is a solid colour band through history {t('captionClassicFlow')}
</p> </p>
<div <div
+82 -29
View File
@@ -100,41 +100,94 @@ function BaroqueDetails({ width, depth, halfW, halfD, trim }: Props & { trim: st
); );
} }
function MedievalDetails({ halfW, halfD }: Pick<Props, 'halfW' | 'halfD'>) { function GothicDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
const torchPositions = useMemo( // Compound piers along the side walls, spaced by bay, each rising into the
() => // vault springer. Shafts stay flush against the wall (0.16 m proud) so they
[ // never intrude on the ~0.7 m frame hang margin.
[-halfW + 0.2, -halfD * 0.5], const bays = useMemo(() => {
[halfW - 0.2, -halfD * 0.5], const spacing = 4.2;
[-halfW + 0.2, halfD * 0.3], const count = Math.max(2, Math.min(9, Math.floor(depth / spacing)));
[halfW - 0.2, halfD * 0.3], const step = depth / (count + 1);
] as [number, number][], return Array.from({ length: count }, (_, i) => -halfD + step * (i + 1));
[halfW, halfD] }, [depth, halfD]);
);
const shaftHeight = 3.5;
return ( return (
<group> <group>
{torchPositions.map(([x, z], i) => ( {bays.map((z, i) => (
<group key={i} position={[x, 2.2, z]}> <group key={i}>
<mesh> {[-1, 1].map((side) => (
<boxGeometry args={[0.08, 0.35, 0.12]} /> <group key={side} position={[side * (halfW - 0.09), 0, z]}>
<meshStandardMaterial color="#3a3028" roughness={0.9} /> {/* Compound pier: a heavier centre shaft flanked by colonnettes */}
</mesh> <mesh position={[0, shaftHeight / 2, 0]}>
<pointLight position={[0, 0.15, 0.08]} intensity={0.65} distance={5} color="#ff9830" /> <cylinderGeometry args={[0.1, 0.115, shaftHeight, 12]} />
<mesh position={[0, 0.2, 0.06]}> <meshStandardMaterial color="#a89e88" roughness={0.78} metalness={0.04} />
<sphereGeometry args={[0.06, 8, 8]} /> </mesh>
<meshStandardMaterial color="#ffb040" emissive="#ff8010" emissiveIntensity={0.8} toneMapped={false} /> {[-0.17, 0.17].map((dz) => (
</mesh> <mesh key={dz} position={[0, shaftHeight / 2, dz]}>
<cylinderGeometry args={[0.052, 0.06, shaftHeight, 10]} />
<meshStandardMaterial color="#9e9482" roughness={0.82} metalness={0.03} />
</mesh>
))}
{/* Moulded base and foliate capital */}
<mesh position={[0, 0.1, 0]}>
<boxGeometry args={[0.28, 0.2, 0.52]} />
<meshStandardMaterial color="#978d79" roughness={0.85} />
</mesh>
<mesh position={[0, shaftHeight + 0.12, 0]}>
<boxGeometry args={[0.3, 0.24, 0.56]} />
<meshStandardMaterial color="#b3a892" roughness={0.7} metalness={0.05} />
</mesh>
{/* Vault springer resting on the capital */}
<mesh position={[0, shaftHeight + 0.36, 0]} rotation={[0, 0, Math.PI / 4]}>
<boxGeometry args={[0.17, 0.17, 0.46]} />
<meshStandardMaterial color={trim} roughness={0.72} metalness={0.06} />
</mesh>
</group>
))}
{/* Transverse rib arching across the nave between opposite piers */}
<group position={[0, shaftHeight + 0.42, z]}>
{Array.from({ length: 9 }, (_, s) => {
const segs = 9;
const t0 = s / segs;
const t1 = (s + 1) / segs;
const xAt = (t: number) => -halfW + 0.09 + t * (halfW - 0.09) * 2;
const yAt = (t: number) => Math.sin(Math.PI * t) * 0.34;
const x0 = xAt(t0);
const x1 = xAt(t1);
const y0 = yAt(t0);
const y1 = yAt(t1);
const len = Math.hypot(x1 - x0, y1 - y0);
return (
<mesh
key={s}
position={[(x0 + x1) / 2, (y0 + y1) / 2, 0]}
rotation={[0, 0, Math.atan2(y1 - y0, x1 - x0)]}
>
<boxGeometry args={[len * 1.06, 0.13, 0.16]} />
<meshStandardMaterial color="#b0a58f" roughness={0.74} metalness={0.05} />
</mesh>
);
})}
</group>
</group> </group>
))} ))}
{/* Rough stone courses */} {/* Moulded string course running the length of both side walls */}
{[-halfD + 0.08, halfD - 0.08].map((z, i) => ( {[-1, 1].map((side) => (
<mesh key={i} position={[0, 1.5, z]}> <mesh key={side} position={[side * (halfW - 0.05), 2.62, 0]}>
<boxGeometry args={[0.04, 3, 0.04]} /> <boxGeometry args={[0.12, 0.1, depth - 0.2]} />
<meshStandardMaterial color="#6a6458" roughness={0.98} /> <meshStandardMaterial color="#b3a892" roughness={0.7} metalness={0.05} />
</mesh> </mesh>
))} ))}
{/* Chancel-style cornice on the end wall */}
<mesh position={[0, 3.96, -halfD + 0.1]}>
<boxGeometry args={[Math.max(2.4, width - 0.4), 0.16, 0.18]} />
<meshStandardMaterial color="#b0a58f" roughness={0.7} metalness={0.05} />
</mesh>
</group> </group>
); );
} }
@@ -324,8 +377,8 @@ export default function MovementHallDetails({ style, width, depth, halfW, halfD
return <PalazzoDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />; return <PalazzoDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
case 'baroque': case 'baroque':
return <BaroqueDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />; return <BaroqueDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
case 'medieval': case 'gothic':
return <MedievalDetails halfW={halfW} halfD={halfD} />; return <GothicDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
case 'byzantine': case 'byzantine':
return <ByzantineDetails halfW={halfW} halfD={halfD} trim={trim} />; return <ByzantineDetails halfW={halfW} halfD={halfD} trim={trim} />;
case 'neoclassical': case 'neoclassical':
+125
View File
@@ -0,0 +1,125 @@
.mtree {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 8px 12px 12px;
}
.mtree-caption {
margin: 0 0 8px;
text-align: center;
font-family: 'Georgia', serif;
font-size: 13px;
font-style: italic;
color: rgba(201, 169, 110, 0.65);
flex-shrink: 0;
}
.mtree-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
text-align: center;
color: rgba(201, 169, 110, 0.6);
font-family: 'Georgia', serif;
}
.mtree-canvas {
position: relative;
flex: 1;
min-height: 0;
width: 100%;
border-radius: 8px;
background:
radial-gradient(ellipse 60% 45% at 50% 100%, rgba(201, 169, 110, 0.1), transparent 72%),
radial-gradient(ellipse 80% 60% at 50% 0%, rgba(120, 150, 190, 0.08), transparent 70%),
rgba(0, 0, 0, 0.28);
border: 1px solid rgba(201, 169, 110, 0.12);
overflow: hidden;
cursor: grab;
touch-action: none;
}
.mtree-canvas.mtree-panning {
cursor: grabbing;
}
.mtree-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.mtree-limb {
cursor: pointer;
opacity: 0.9;
transition: opacity 0.18s ease, filter 0.18s ease;
}
.mtree-limb-lit {
opacity: 1;
filter: brightness(1.22) drop-shadow(0 0 6px rgba(255, 226, 170, 0.35));
}
.mtree-limb-dim {
opacity: 0.34;
}
.mtree-root {
opacity: 0.72;
}
.mtree-graft {
opacity: 0.3;
pointer-events: none;
transition: opacity 0.18s ease;
}
.mtree-graft-lit {
opacity: 0.85;
}
.mtree-label {
position: absolute;
z-index: 4;
transform: translate(-50%, -50%);
margin: 0;
padding: 2px 8px;
border: none;
border-radius: 4px;
background: rgba(12, 12, 22, 0.74);
color: rgba(245, 230, 200, 0.95);
font-family: 'Georgia', serif;
font-size: 12px;
white-space: nowrap;
cursor: pointer;
pointer-events: auto;
transition: opacity 0.18s ease;
}
.mtree-label:hover {
background: rgba(30, 28, 40, 0.92);
color: #fff6e0;
}
.mtree-label-dim {
opacity: 0.35;
}
.mtree-out-of-range {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
text-align: center;
color: rgba(201, 169, 110, 0.7);
font-family: 'Georgia', serif;
}
+645
View File
@@ -0,0 +1,645 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { ArtMovement } from '../types';
import {
branchOriginYear,
buildMovementTree,
limbXAtYear,
type MovementTreeNode,
} from '../utils/movementTree';
import { shadeMovementColor, vividMovementColor } from '../utils/movementColor';
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
import './MovementTree.css';
interface Props {
movements: ArtMovement[];
viewStart: number;
viewEnd: number;
absoluteMin: number;
absoluteMax: number;
onViewChange: (start: number, end: number) => void;
onMovementClick?: (movementId: number) => void;
}
interface Pt {
x: number;
y: number;
}
const SIDE_PAD = 56;
/**
* Horizontal spread is tied to the zoom, not to the canvas.
*
* 90 % of the catalogue lives in the last 15 % of the time axis, so a tree
* stretched to full width with all of history in view is one long trunk under a
* flat bar. Instead the whole-history view draws a narrow tree, and every zoom
* step fans the crown out the chart grows as you walk up it.
*/
const FULL_VIEW_WIDTH_SHARE = 0.52;
const ZOOM_SPREAD_EXPONENT = 0.45;
/** How far past "everything fits" the tree may be blown up when zoomed in. */
const MAX_FIT_BOOST = 2.4;
/** Exponential chase rate (1/s) for the horizontal fit, so zoom reads as growth. */
const FIT_ANIM_RATE = 9;
const RIBBON_SAMPLES = 18;
/** Vertical room a label needs. */
const MIN_LABEL_HEIGHT_PX = 20;
/**
* Readability floors. A 30-year movement is 8 px tall when the whole of
* history is on screen; without these the modern crown fuses into one bar.
*/
const MIN_LIMB_RISE_PX = 30;
const MIN_JUNCTION_RISE_PX = 38;
/** A limb is never drawn thicker than this share of its own length. */
const MAX_THICKNESS_OF_LENGTH = 0.55;
function cubicAt(p0: Pt, c1: Pt, c2: Pt, p3: Pt, t: number): Pt {
const u = 1 - t;
const a = u * u * u;
const b = 3 * u * u * t;
const c = 3 * u * t * t;
const d = t * t * t;
return {
x: a * p0.x + b * c1.x + c * c2.x + d * p3.x,
y: a * p0.y + b * c1.y + c * c2.y + d * p3.y,
};
}
function cubicTangent(p0: Pt, c1: Pt, c2: Pt, p3: Pt, t: number): Pt {
const u = 1 - t;
const a = 3 * u * u;
const b = 6 * u * t;
const c = 3 * t * t;
return {
x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p3.x - c2.x),
y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y),
};
}
/**
* Filled ribbon of varying width along a cubic. Offsetting along the curve
* normal (rather than horizontally) keeps branch junctions solid even when a
* zoomed-out view squeezes them almost flat.
*/
function ribbonPath(p0: Pt, c1: Pt, c2: Pt, p3: Pt, w0: number, w1: number): string {
const left: Pt[] = [];
const right: Pt[] = [];
for (let i = 0; i <= RIBBON_SAMPLES; i++) {
const t = i / RIBBON_SAMPLES;
const p = cubicAt(p0, c1, c2, p3, t);
const d = cubicTangent(p0, c1, c2, p3, t);
const len = Math.hypot(d.x, d.y) || 1;
const nx = -d.y / len;
const ny = d.x / len;
const half = (w0 + (w1 - w0) * t) / 2;
left.push({ x: p.x + nx * half, y: p.y + ny * half });
right.push({ x: p.x - nx * half, y: p.y - ny * half });
}
const fmt = (pt: Pt) => `${pt.x.toFixed(2)} ${pt.y.toFixed(2)}`;
const forward = left.map((pt, i) => `${i === 0 ? 'M' : 'L'} ${fmt(pt)}`).join(' ');
const back = right
.slice()
.reverse()
.map((pt) => `L ${fmt(pt)}`)
.join(' ');
return `${forward} ${back} Z`;
}
interface LimbShape {
id: number;
name: string;
color: string;
shade: string;
/** Trunk / limb body. */
d: string;
/** Junction ribbon growing out of the structural parent (may be empty). */
junction: string;
/** Rounded tip cap. */
tip: Pt & { r: number };
/** Root flare under a tree root, drawn only when the base is on screen. */
roots: string[];
labelX: number;
labelY: number;
labelVisible: boolean;
yearRange: string;
depth: number;
inView: boolean;
}
interface GraftShape {
key: string;
d: string;
color: string;
fromId: number;
toId: number;
}
function formatYear(year: number): string {
return year < 0 ? `${Math.abs(year)} BCE` : `${year}`;
}
/**
* Greedy declutter: closer to the trunk wins. Every visible movement asks for a
* name, and the ones that would collide with an already-placed name or fall
* off the canvas stay anonymous until you zoom in on them.
*/
function hideOverlappingLabels(limbs: LimbShape[], width: number, height: number): void {
const placed: { x0: number; y0: number; x1: number; y1: number }[] = [];
const candidates = limbs
.map((limb, index) => ({ limb, index }))
.filter(({ limb }) => limb.labelVisible)
.sort((a, b) => a.limb.depth - b.limb.depth || b.limb.labelY - a.limb.labelY);
for (const { limb } of candidates) {
const halfW = (limb.name.length * 6.6 + 16) / 2;
const halfH = MIN_LABEL_HEIGHT_PX / 2;
const box = {
x0: limb.labelX - halfW,
y0: limb.labelY - halfH,
x1: limb.labelX + halfW,
y1: limb.labelY + halfH,
};
const offCanvas = box.x0 < 2 || box.x1 > width - 2 || box.y0 < 2 || box.y1 > height - 2;
const collides = placed.some(
(p) => box.x0 < p.x1 && box.x1 > p.x0 && box.y0 < p.y1 && box.y1 > p.y0
);
if (offCanvas || collides) {
limb.labelVisible = false;
continue;
}
placed.push(box);
}
}
export default function MovementTree({
movements,
viewStart,
viewEnd,
absoluteMin,
absoluteMax,
onViewChange,
onMovementClick,
}: Props) {
const { t } = useTranslation('home');
const canvasRef = useRef<HTMLDivElement>(null);
const [canvas, setCanvas] = useState({ w: 900, h: 600 });
const [panning, setPanning] = useState(false);
const [hoveredId, setHoveredId] = useState<number | null>(null);
const [fitScale, setFitScale] = useState(1);
const panStart = useRef({ y: 0, viewStart: 0, viewEnd: 0 });
const viewRef = useRef({ viewStart, viewEnd });
const onViewChangeRef = useRef(onViewChange);
const fitRef = useRef(1);
const fitReadyRef = useRef(false);
const fitTargetRef = useRef(1);
const fitRafRef = useRef<number | null>(null);
const fitLastTsRef = useRef(0);
viewRef.current = { viewStart, viewEnd };
onViewChangeRef.current = onViewChange;
useLayoutEffect(() => {
const el = canvasRef.current;
if (!el) return;
const measure = () => {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
setCanvas({ w: Math.round(rect.width), h: Math.round(rect.height) });
}
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
// Structure is catalogue-wide and view-independent: zooming must not reshape
// the tree, only travel along it.
const tree = useMemo(() => buildMovementTree(movements), [movements]);
const visibleIds = useMemo(() => {
const ids = new Set<number>();
for (const node of tree.nodes.values()) {
const { start_year: s, end_year: e } = node.movement;
if (e > viewStart && s < viewEnd) ids.add(node.movement.id);
}
return ids;
}, [tree, viewStart, viewEnd]);
const targetScale = useMemo(() => {
const usable = Math.max(240, canvas.w - SIDE_PAD * 2);
const fitAll = usable / (2 * Math.max(1, tree.halfSpan));
const visibleSpan = Math.max(1, viewEnd - viewStart);
const totalSpan = Math.max(visibleSpan, absoluteMax - absoluteMin);
const zoomSpread = Math.pow(totalSpan / visibleSpan, ZOOM_SPREAD_EXPONENT);
const spread = Math.min(
MAX_FIT_BOOST,
Math.max(FULL_VIEW_WIDTH_SHARE, FULL_VIEW_WIDTH_SHARE * zoomSpread)
);
let visibleHalfSpan = 0;
for (const id of visibleIds) {
const node = tree.nodes.get(id);
if (!node) continue;
visibleHalfSpan = Math.max(
visibleHalfSpan,
Math.abs(node.x) + Math.abs(node.lean) + node.baseWidth / 2
);
}
// Never let what is on screen spill off the canvas.
const overflowCap = visibleHalfSpan > 0 ? usable / (2 * visibleHalfSpan) : Infinity;
return Math.min(fitAll * spread, overflowCap);
}, [canvas.w, tree, visibleIds, viewStart, viewEnd, absoluteMin, absoluteMax]);
useLayoutEffect(() => {
fitTargetRef.current = targetScale;
if (!fitReadyRef.current) {
// First measured layout — adopt it instead of animating in from nothing.
fitReadyRef.current = true;
fitRef.current = targetScale;
setFitScale(targetScale);
return;
}
if (fitRafRef.current != null) return;
fitLastTsRef.current = performance.now();
const step = (now: number) => {
const dt = Math.min(0.05, Math.max(0, (now - fitLastTsRef.current) / 1000));
fitLastTsRef.current = now;
const t = 1 - Math.exp(-FIT_ANIM_RATE * dt);
const next = fitRef.current + (fitTargetRef.current - fitRef.current) * t;
if (Math.abs(fitTargetRef.current - next) < 0.002) {
fitRef.current = fitTargetRef.current;
setFitScale(fitTargetRef.current);
fitRafRef.current = null;
return;
}
fitRef.current = next;
setFitScale(next);
fitRafRef.current = requestAnimationFrame(step);
};
fitRafRef.current = requestAnimationFrame(step);
}, [targetScale]);
useEffect(
() => () => {
if (fitRafRef.current != null) cancelAnimationFrame(fitRafRef.current);
},
[]
);
const { limbs, grafts } = useMemo(() => {
const span = viewEnd - viewStart || 1;
const centerX = canvas.w / 2;
const widthScale = Math.min(1.7, Math.max(0.55, fitScale));
const sx = (treeX: number) => centerX + treeX * fitScale;
const sy = (year: number) => canvas.h - ((year - viewStart) / span) * canvas.h;
const limbList: LimbShape[] = [];
const graftList: GraftShape[] = [];
/**
* Pass 1 screen geometry per movement.
*
* Two readability floors apply here, and only here: the structure and the
* dates stay untouched. A limb is drawn at least `MIN_LIMB_RISE_PX` long,
* and never thicker than it is long, so 2 500 years of trunk and 30 years
* of Fauvism can share one linear axis without the modern crown fusing
* into a solid bar.
*/
const drawn = new Map<
number,
{ base: Pt; c1: Pt; c2: Pt; tip: Pt; wBase: number; wTip: number }
>();
for (const id of tree.drawOrder) {
const node = tree.nodes.get(id)!;
const { start_year: start, end_year: end } = node.movement;
if (end <= start) continue;
const base: Pt = { x: sx(limbXAtYear(node, start)), y: sy(start) };
const trueTipY = sy(end);
const tip: Pt = {
x: sx(limbXAtYear(node, end)),
y: Math.min(trueTipY, base.y - MIN_LIMB_RISE_PX),
};
const lengthPx = Math.hypot(tip.x - base.x, tip.y - base.y);
const cap = Math.max(3, lengthPx * MAX_THICKNESS_OF_LENGTH);
const wBase = Math.min(node.baseWidth * widthScale, cap);
const wTip = Math.min(node.tipWidth * widthScale, cap * 0.82);
const dy = tip.y - base.y;
drawn.set(id, {
base,
c1: { x: base.x, y: base.y + dy * 0.42 },
c2: { x: tip.x, y: tip.y - dy * 0.34 },
tip,
wBase,
wTip,
});
}
/** Point on a drawn limb at the screen height closest to `targetY`. */
const pointOnLimb = (parentId: number, targetY: number) => {
const p = drawn.get(parentId)!;
const total = p.base.y - p.tip.y || 1;
const t = Math.min(1, Math.max(0, (p.base.y - targetY) / total));
return {
pt: cubicAt(p.base, p.c1, p.c2, p.tip, t),
width: p.wBase + (p.wTip - p.wBase) * t,
};
};
// Pass 2 — ribbons.
for (const id of tree.drawOrder) {
const node = tree.nodes.get(id)!;
const shape = drawn.get(id);
if (!shape) continue;
const { base, c1, c2, tip, wBase, wTip } = shape;
const { start_year: start, end_year: end } = node.movement;
const color = vividMovementColor(node.movement.color);
const d = ribbonPath(base, c1, c2, tip, wBase, wTip);
// Junction: the limb grows out of its parent a little before its own
// date, and always climbs far enough to read as a fork.
let junction = '';
const parent = node.parentId != null ? tree.nodes.get(node.parentId) : null;
if (parent && drawn.has(parent.movement.id)) {
const byDate = sy(branchOriginYear(parent, node));
const origin = pointOnLimb(
parent.movement.id,
Math.max(byDate, base.y + MIN_JUNCTION_RISE_PX)
);
const from = origin.pt;
const jdy = base.y - from.y;
const jLength = Math.hypot(base.x - from.x, jdy);
const jCap = Math.max(3, jLength * MAX_THICKNESS_OF_LENGTH);
const wFrom = Math.min(origin.width * 0.92, wBase * 1.25, jCap);
const jc1: Pt = { x: from.x, y: from.y + jdy * 0.45 };
const jc2: Pt = { x: base.x, y: base.y - jdy * 0.45 };
junction = ribbonPath(from, jc1, jc2, base, wFrom, Math.min(wBase, jCap));
}
// Roots: only the bottom of a tree, and only when that bottom is in frame.
const roots: string[] = [];
if (!parent && base.y > -canvas.h && base.y < canvas.h * 2) {
const flare = Math.max(22, wBase * 1.4);
for (const dir of [-1, -0.35, 0.35, 1]) {
const endPt: Pt = { x: base.x + dir * flare, y: base.y + flare * 0.72 };
const rc1: Pt = { x: base.x + dir * flare * 0.2, y: base.y + flare * 0.34 };
const rc2: Pt = { x: base.x + dir * flare * 0.8, y: base.y + flare * 0.5 };
roots.push(ribbonPath(base, rc1, rc2, endPt, wBase * 0.42, 1.5));
}
}
const inView = visibleIds.has(id);
const clampedStart = Math.max(start, viewStart);
const clampedEnd = Math.min(end, viewEnd);
const midYear = (clampedStart + clampedEnd) / 2;
const labelY = Math.min(
Math.max(sy(midYear), tip.y + MIN_LABEL_HEIGHT_PX / 2),
base.y
);
limbList.push({
id,
name: node.movement.name,
color,
shade: shadeMovementColor(color),
d,
junction,
tip: { x: tip.x, y: tip.y, r: Math.max(1.5, wTip / 2) },
roots,
labelX: sx(limbXAtYear(node, midYear)),
labelY,
labelVisible: inView,
yearRange: `${formatYear(start)} ${formatYear(end)}`,
depth: node.depth,
inView,
});
for (const graftId of node.graftParentIds) {
const graftParent = tree.nodes.get(graftId);
if (!graftParent || !drawn.has(graftId)) continue;
const byDate = sy(branchOriginYear(graftParent, node));
const origin = pointOnLimb(graftId, Math.max(byDate, base.y + MIN_JUNCTION_RISE_PX));
const from = origin.pt;
const gdy = base.y - from.y;
const gWidth = Math.max(2.5, Math.min(wBase * 0.3, 9));
graftList.push({
key: `${graftId}-${id}`,
d: ribbonPath(
from,
{ x: from.x, y: from.y + gdy * 0.55 },
{ x: base.x, y: base.y - gdy * 0.3 },
base,
gWidth * 0.7,
gWidth
),
color: vividMovementColor(graftParent.movement.color),
fromId: graftId,
toId: id,
});
}
}
hideOverlappingLabels(limbList, canvas.w, canvas.h);
return { limbs: limbList, grafts: graftList };
}, [tree, viewStart, viewEnd, canvas.w, canvas.h, fitScale, visibleIds]);
/** A hovered movement lights up its whole descent line back to the root. */
const lineageIds = useMemo(() => {
const ids = new Set<number>();
if (hoveredId == null) return ids;
let cursor: number | null = hoveredId;
let guard = 0;
while (cursor != null && guard++ < 64) {
ids.add(cursor);
const node: MovementTreeNode | undefined = tree.nodes.get(cursor);
if (!node) break;
for (const graftId of node.graftParentIds) ids.add(graftId);
cursor = node.parentId;
}
return ids;
}, [hoveredId, tree]);
useEffect(() => {
const el = canvasRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = el.getBoundingClientRect();
const { viewStart: vs, viewEnd: ve } = viewRef.current;
// Bottom = oldest, so invert the pointer offset before reusing the shared
// left-to-right zoom math.
const invertedY = rect.height - (e.clientY - rect.top);
const next = zoomTimelineView(
invertedY,
0,
rect.height,
e.deltaY,
vs,
ve,
absoluteMin,
absoluteMax
);
onViewChangeRef.current(next.start, next.end);
};
el.addEventListener('wheel', onWheel, { passive: false, capture: true });
return () => el.removeEventListener('wheel', onWheel, { capture: true });
}, [absoluteMin, absoluteMax]);
const handlePanStart = useCallback(
(e: React.MouseEvent) => {
if (e.button !== 0) return;
e.preventDefault();
setPanning(true);
panStart.current = { y: e.clientY, viewStart, viewEnd };
},
[viewStart, viewEnd]
);
useEffect(() => {
if (!panning) return;
const onMove = (e: MouseEvent) => {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const dy = e.clientY - panStart.current.y;
const next = panTimelineView(
-dy,
rect.height,
panStart.current.viewStart,
panStart.current.viewEnd,
absoluteMin,
absoluteMax
);
onViewChangeRef.current(next.start, next.end);
};
const onUp = () => setPanning(false);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, [panning, absoluteMin, absoluteMax]);
if (movements.length === 0) {
return (
<div className="mtree-empty">
<p>No art movements to grow a tree from yet.</p>
</div>
);
}
const anyInView = limbs.some((limb) => limb.inView);
return (
<div className="mtree">
<p className="mtree-caption">
{t('captionTreeFlow')}
</p>
<div
ref={canvasRef}
className={`mtree-canvas${panning ? ' mtree-panning' : ''}`}
onMouseDown={handlePanStart}
>
<svg
className="mtree-svg"
viewBox={`0 0 ${canvas.w} ${canvas.h}`}
preserveAspectRatio="none"
>
<defs>
{limbs.map((limb) => (
<linearGradient
key={`grad-${limb.id}`}
id={`mtree-limb-${limb.id}`}
gradientUnits="objectBoundingBox"
x1="0"
y1="0"
x2="1"
y2="0"
>
<stop offset="0%" stopColor={limb.shade} />
<stop offset="45%" stopColor={limb.color} />
<stop offset="100%" stopColor={limb.shade} />
</linearGradient>
))}
</defs>
<g className="mtree-grafts">
{grafts.map((graft) => (
<path
key={graft.key}
d={graft.d}
fill={graft.color}
className={`mtree-graft${
lineageIds.has(graft.toId) && lineageIds.has(graft.fromId)
? ' mtree-graft-lit'
: ''
}`}
/>
))}
</g>
{limbs.map((limb) => {
const lit = lineageIds.has(limb.id);
const dim = hoveredId != null && !lit;
return (
<g
key={limb.id}
className={`mtree-limb${lit ? ' mtree-limb-lit' : ''}${
dim ? ' mtree-limb-dim' : ''
}`}
onMouseEnter={() => setHoveredId(limb.id)}
onMouseLeave={() => setHoveredId(null)}
onClick={(e) => {
e.stopPropagation();
onMovementClick?.(limb.id);
}}
>
<title>{`${limb.name} · ${limb.yearRange}`}</title>
{limb.roots.map((d, i) => (
<path key={`root-${i}`} d={d} fill={limb.shade} className="mtree-root" />
))}
{limb.junction && (
<path d={limb.junction} fill={`url(#mtree-limb-${limb.id})`} />
)}
<path d={limb.d} fill={`url(#mtree-limb-${limb.id})`} />
<circle cx={limb.tip.x} cy={limb.tip.y} r={limb.tip.r} fill={limb.color} />
</g>
);
})}
</svg>
{limbs
.filter((limb) => limb.labelVisible)
.map((limb) => (
<button
key={`label-${limb.id}`}
type="button"
className={`mtree-label${
hoveredId != null && !lineageIds.has(limb.id) ? ' mtree-label-dim' : ''
}`}
style={{ left: `${limb.labelX}px`, top: `${limb.labelY}px` }}
title={`${limb.name} · ${limb.yearRange}`}
onMouseEnter={() => setHoveredId(limb.id)}
onMouseLeave={() => setHoveredId(null)}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onMovementClick?.(limb.id);
}}
>
{limb.name}
</button>
))}
{!anyInView && (
<div className="mtree-out-of-range">
<p>No movements in this time range zoom out to see the whole tree.</p>
</div>
)}
</div>
</div>
);
}
+3 -1
View File
@@ -1,4 +1,5 @@
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react'; import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { HistoricalEra } from '../types'; import type { HistoricalEra } from '../types';
import { import {
HISTORICAL_EVENTS, HISTORICAL_EVENTS,
@@ -38,6 +39,7 @@ function formatYear(year: number): string {
} }
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight }: Props) { export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight }: Props) {
const { t } = useTranslation('home');
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<'left' | 'right' | 'pan' | null>(null); const [dragging, setDragging] = useState<'left' | 'right' | 'pan' | null>(null);
const [containerWidth, setContainerWidth] = useState(800); const [containerWidth, setContainerWidth] = useState(800);
@@ -365,7 +367,7 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
</div> </div>
<p className="timeline-hint"> <p className="timeline-hint">
Click an era or event to zoom · Scroll to zoom · Drag to pan {t('captionClassicTimeline')}
</p> </p>
</div> </div>
); );
@@ -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>
);
}
+228
View File
@@ -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;
}
}
+417
View File
@@ -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>
);
}
+76 -15
View File
@@ -14,7 +14,7 @@ import type {
} from '../types'; } from '../types';
import { galleryImageUrlCandidates, imageUrl, api } from '../api/client'; import { galleryImageUrlCandidates, imageUrl, api } from '../api/client';
import { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils'; import { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures'; import { cloneSurfaceTexture, getSurfaceTexture, type SurfaceTextureKind } from '../utils/galleryProceduralTextures';
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles'; import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
import { useTexturedMaterial } from '../hooks/useTexturedMaterial'; import { useTexturedMaterial } from '../hooks/useTexturedMaterial';
import MovementHallDetails from './MovementHallDetails'; import MovementHallDetails from './MovementHallDetails';
@@ -191,8 +191,8 @@ function galleryWallColors(movementColor?: string) {
}; };
} }
/** Height above frame top edge — keeps fixture out of the viewer's line of sight. */ /** Clearance above the tallest frame in the hall for influence lamps (metres). */
const INFLUENCE_LAMP_ABOVE_FRAME = 0.46; const INFLUENCE_LAMP_ABOVE_HIGHEST_M = 0.4;
type WallSide = 'back' | 'left' | 'right'; type WallSide = 'back' | 'left' | 'right';
@@ -304,6 +304,20 @@ function frameOuterH(canvasH: number, reviewed: boolean): number {
return canvasH + matBorder * 2 + rail; return canvasH + matBorder * 2 + rail;
} }
/** World Y of the top edge of the tallest allocated frame in the hall. */
function highestPaintingTopY(segments: WallSegment[]): number {
let maxTop = EYE_HEIGHT;
for (const seg of segments) {
for (let i = 0; i < seg.paintings.length; i++) {
const slot = seg.slots[i];
if (!slot) continue;
const top = slot.position[1] + frameOuterH(slot.maxH, paintingIsReviewed(seg.paintings[i])) / 2;
if (top > maxTop) maxTop = top;
}
}
return maxTop;
}
function layoutRow(paintings: Painting[], span: number) { function layoutRow(paintings: Painting[], span: number) {
const count = paintings.length; const count = paintings.length;
if (count === 0) return { slots: [], spanNeeded: span }; if (count === 0) return { slots: [], spanNeeded: span };
@@ -803,22 +817,24 @@ function usePaintingTexture(urls: string[] | string | null) {
} }
function InfluencePictureLamp({ function InfluencePictureLamp({
frameHeight, frameWorldY,
matBorder, lampWorldY,
frameDepth, frameDepth,
highlighted, highlighted,
}: { }: {
frameHeight: number; /** Painting group world Y — local lamp offset = lampWorldY frameWorldY. */
matBorder: number; frameWorldY: number;
/** Shared hall rail: 40 cm above the tallest frame. */
lampWorldY: number;
frameDepth: number; frameDepth: number;
highlighted: boolean; highlighted: boolean;
}) { }) {
const mountY = frameHeight / 2 + matBorder + INFLUENCE_LAMP_ABOVE_FRAME; const mountY = lampWorldY - frameWorldY;
const glow = highlighted ? 1.6 : 1.15; const glow = highlighted ? 1.6 : 1.15;
const scale = 1.35; const scale = 1.35;
return ( return (
<group position={[0, mountY, frameDepth * 0.55]} rotation={[Math.PI, 0, 0]} scale={scale}> <group position={[0, mountY, frameDepth * 0.55]} rotation={[Math.PI, Math.PI, 0]} scale={scale}>
<mesh position={[0, 0.04, -0.05]} renderOrder={30}> <mesh position={[0, 0.04, -0.05]} renderOrder={30}>
<boxGeometry args={[0.11, 0.05, 0.05]} /> <boxGeometry args={[0.11, 0.05, 0.05]} />
<meshStandardMaterial color="#2f2f2f" metalness={0.9} roughness={0.2} /> <meshStandardMaterial color="#2f2f2f" metalness={0.9} roughness={0.2} />
@@ -918,6 +934,7 @@ function PaintingFrame({
wallSide, wallSide,
imageRevision, imageRevision,
caption, caption,
influenceLampWorldY,
onClick, onClick,
}: { }: {
painting: Painting; painting: Painting;
@@ -928,6 +945,7 @@ function PaintingFrame({
wallSide: WallSide; wallSide: WallSide;
imageRevision?: number; imageRevision?: number;
caption?: string; caption?: string;
influenceLampWorldY: number;
onClick: () => void; onClick: () => void;
}) { }) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
@@ -1018,8 +1036,8 @@ function PaintingFrame({
{hasInfluenceLinks && ( {hasInfluenceLinks && (
<InfluencePictureLamp <InfluencePictureLamp
frameHeight={height} frameWorldY={position[1]}
matBorder={matBorder} lampWorldY={influenceLampWorldY}
frameDepth={frameDepth} frameDepth={frameDepth}
highlighted={hovered} highlighted={hovered}
/> />
@@ -1059,13 +1077,31 @@ function GalleryWall({
size, size,
rotation = [0, 0, 0], rotation = [0, 0, 0],
color = '#ebe4d8', color = '#ebe4d8',
kind,
tint,
}: { }: {
position: [number, number, number]; position: [number, number, number];
size: [number, number]; size: [number, number];
rotation?: [number, number, number]; rotation?: [number, number, number];
color?: string; color?: string;
/** When set, the panel is textured like the rest of the hall instead of flat colour. */
kind?: SurfaceTextureKind;
tint?: string;
}) { }) {
const [w, h] = size; const [w, h] = size;
// Door-flanking panels would otherwise read as flat single-colour blocks
// beside fully textured walls.
if (kind) {
return (
<TexturedWall
kind={kind}
tint={tint ?? color}
position={position}
size={[w, h, WALL_THICKNESS]}
rotation={rotation}
/>
);
}
return ( return (
<mesh position={position} rotation={rotation} renderOrder={0}> <mesh position={position} rotation={rotation} renderOrder={0}>
<boxGeometry args={[w, h, WALL_THICKNESS]} /> <boxGeometry args={[w, h, WALL_THICKNESS]} />
@@ -1487,6 +1523,10 @@ function ArtistHall({
const hallLightScale = interiorStyle?.lightScale ?? 1; const hallLightScale = interiorStyle?.lightScale ?? 1;
const wallRoughness = interiorStyle ? 0.75 : 0.92; const wallRoughness = interiorStyle ? 0.75 : 0.92;
const wallMetalness = interiorStyle ? 0.08 : 0.06; const wallMetalness = interiorStyle ? 0.08 : 0.06;
const influenceLampWorldY = useMemo(
() => highestPaintingTopY(segments) + INFLUENCE_LAMP_ABOVE_HIGHEST_M,
[segments]
);
const wallMaterial = (color: string) => ( const wallMaterial = (color: string) => (
<meshStandardMaterial color={color} roughness={wallRoughness} metalness={wallMetalness} /> <meshStandardMaterial color={color} roughness={wallRoughness} metalness={wallMetalness} />
@@ -1563,16 +1603,22 @@ function ArtistHall({
position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]} position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]}
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
color={walls.main} color={walls.main}
kind={interiorStyle?.surfaces.wall}
tint={interiorStyle?.tints.wall}
/> />
<GalleryWall <GalleryWall
position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]} position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]}
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
color={walls.main} color={walls.main}
kind={interiorStyle?.surfaces.wall}
tint={interiorStyle?.tints.wall}
/> />
<GalleryWall <GalleryWall
position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, halfD]} position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, halfD]}
size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]} size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]}
color={walls.main} color={walls.main}
kind={interiorStyle?.surfaces.wall}
tint={interiorStyle?.tints.wall}
/> />
<HallPassage <HallPassage
position={[0, 0, halfD - WALL_THICKNESS / 2 - 0.02]} position={[0, 0, halfD - WALL_THICKNESS / 2 - 0.02]}
@@ -1584,12 +1630,20 @@ function ArtistHall({
/> />
</> </>
) : movementMode && endWallHasDoor ? ( ) : movementMode && endWallHasDoor ? (
<GalleryWall position={[0, WALL_HEIGHT / 2, halfD]} size={[width, WALL_HEIGHT]} color={walls.main} /> <GalleryWall position={[0, WALL_HEIGHT / 2, halfD]} size={[width, WALL_HEIGHT]} color={walls.main}
kind={interiorStyle?.surfaces.wall}
tint={interiorStyle?.tints.wall} />
) : ( ) : (
<> <>
<GalleryWall position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} color={walls.main} /> <GalleryWall position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} color={walls.main}
<GalleryWall position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} color={walls.main} /> kind={interiorStyle?.surfaces.wall}
<GalleryWall position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, halfD]} size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]} color={walls.main} /> tint={interiorStyle?.tints.wall} />
<GalleryWall position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} color={walls.main}
kind={interiorStyle?.surfaces.wall}
tint={interiorStyle?.tints.wall} />
<GalleryWall position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, halfD]} size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]} color={walls.main}
kind={interiorStyle?.surfaces.wall}
tint={interiorStyle?.tints.wall} />
<ExitPortal <ExitPortal
position={[0, 0, halfD - WALL_THICKNESS / 2 - 0.02]} position={[0, 0, halfD - WALL_THICKNESS / 2 - 0.02]}
active={nearExit} active={nearExit}
@@ -1608,16 +1662,22 @@ function ArtistHall({
position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, -halfD]} position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, -halfD]}
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
color={walls.main} color={walls.main}
kind={interiorStyle?.surfaces.wall}
tint={interiorStyle?.tints.wall}
/> />
<GalleryWall <GalleryWall
position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, -halfD]} position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, -halfD]}
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
color={walls.main} color={walls.main}
kind={interiorStyle?.surfaces.wall}
tint={interiorStyle?.tints.wall}
/> />
<GalleryWall <GalleryWall
position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, -halfD]} position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, -halfD]}
size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]} size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]}
color={walls.main} color={walls.main}
kind={interiorStyle?.surfaces.wall}
tint={interiorStyle?.tints.wall}
/> />
<group position={[0, 0, -halfD + WALL_THICKNESS / 2 + 0.02]} rotation={[0, Math.PI, 0]}> <group position={[0, 0, -halfD + WALL_THICKNESS / 2 + 0.02]} rotation={[0, Math.PI, 0]}>
<ExitPortal <ExitPortal
@@ -1714,6 +1774,7 @@ function ArtistHall({
wallSide={seg.slots[i].side} wallSide={seg.slots[i].side}
imageRevision={imageRevisions?.[painting.id]} imageRevision={imageRevisions?.[painting.id]}
caption={showCaptions ? paintingWallCaption(painting) : undefined} caption={showCaptions ? paintingWallCaption(painting) : undefined}
influenceLampWorldY={influenceLampWorldY}
onClick={() => onPaintingClick(painting.id)} onClick={() => onPaintingClick(painting.id)}
/> />
))} ))}
+22 -18
View File
@@ -54,7 +54,7 @@ export interface MovementInteriorStyle {
trackLights: number; trackLights: number;
/** Scales the shared hall/scene lights. <1 for deliberately dim period interiors. */ /** Scales the shared hall/scene lights. <1 for deliberately dim period interiors. */
lightScale: number; lightScale: number;
details: 'palazzo' | 'baroque' | 'medieval' | 'byzantine' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier'; details: 'palazzo' | 'baroque' | 'gothic' | 'byzantine' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier';
} }
function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] { function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] {
@@ -144,26 +144,30 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
), ),
29: mk( 29: mk(
'gothic-cathedral', 'gothic-cathedral',
'Gothic hall', 'Gothic nave',
'Stone vault · tall lancet windows · flagstone floor', 'Blind-arcaded ashlar · ribbed stone vault · worn flagstones',
{ wall: 'rough-stone', ceiling: 'basalt', floor: 'flagstone' }, { wall: 'gothic-ashlar', ceiling: 'gothic-vault', floor: 'gothic-stone-floor' },
{ wall: '#8a8478', ceiling: '#3a3630', floor: '#6a6458', trim: '#5c5648' }, { wall: '#8d8471', ceiling: '#6d6555', floor: '#6f6759', trim: '#7d7159' },
{ {
details: 'medieval', details: 'gothic',
titleColor: '#e8dcc8', titleColor: '#efe4cc',
ambient: 0.72, ambient: 0.6,
warmLight: '#e8d8c0', warmLight: '#ffe6bc',
sunLight: '#d0e8ff', sunLight: '#dbe8ff',
// Keep atmosphere dark, but not pure void (walls must stay readable). // Cool shadowed stone, but never a pure void walls must stay readable.
fog: '#1a1816', fog: '#1b1a18',
background: '#141210', background: '#131211',
windows: windows( windows: windows(
{ wall: 'back', x: -2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.5 }, { wall: 'back', x: -2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.8 },
{ wall: 'back', x: 2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.5 }, { wall: 'back', x: 2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.8 },
{ wall: 'left', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 }, { wall: 'left', x: -4.5, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.2 },
{ wall: 'right', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 } { wall: 'left', x: 4.5, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.2 },
{ wall: 'right', x: -4.5, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.2 },
{ wall: 'right', x: 4.5, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.2 }
), ),
trackLights: 0.85, trackLights: 0.5,
// Naves are lit by window shafts against shadowed stone, not flooded.
lightScale: 0.26,
} }
), ),
30: mk( 30: mk(
+5 -1
View File
@@ -14,6 +14,7 @@ import enTranslations from '../locales/en/translations.json';
import enInfluences from '../locales/en/influences.json'; import enInfluences from '../locales/en/influences.json';
import enTours from '../locales/en/tours.json'; import enTours from '../locales/en/tours.json';
import enUsers from '../locales/en/users.json'; import enUsers from '../locales/en/users.json';
import enAudit from '../locales/en/audit.json';
import ruCommon from '../locales/ru/common.json'; import ruCommon from '../locales/ru/common.json';
import ruHome from '../locales/ru/home.json'; import ruHome from '../locales/ru/home.json';
@@ -27,6 +28,7 @@ import ruTranslations from '../locales/ru/translations.json';
import ruInfluences from '../locales/ru/influences.json'; import ruInfluences from '../locales/ru/influences.json';
import ruTours from '../locales/ru/tours.json'; import ruTours from '../locales/ru/tours.json';
import ruUsers from '../locales/ru/users.json'; import ruUsers from '../locales/ru/users.json';
import ruAudit from '../locales/ru/audit.json';
const initialLocale = readStoredLocale(); const initialLocale = readStoredLocale();
writeStoredLocale(initialLocale); writeStoredLocale(initialLocale);
@@ -35,7 +37,7 @@ void i18n.use(initReactI18next).init({
lng: initialLocale, lng: initialLocale,
fallbackLng: 'en', fallbackLng: 'en',
supportedLngs: ['en', 'ru'], supportedLngs: ['en', 'ru'],
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours', 'users'], ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours', 'users', 'audit'],
defaultNS: 'common', defaultNS: 'common',
resources: { resources: {
en: { en: {
@@ -51,6 +53,7 @@ void i18n.use(initReactI18next).init({
influences: enInfluences, influences: enInfluences,
tours: enTours, tours: enTours,
users: enUsers, users: enUsers,
audit: enAudit,
}, },
ru: { ru: {
common: ruCommon, common: ruCommon,
@@ -65,6 +68,7 @@ void i18n.use(initReactI18next).init({
influences: ruInfluences, influences: ruInfluences,
tours: ruTours, tours: ruTours,
users: ruUsers, users: ruUsers,
audit: ruAudit,
}, },
}, },
interpolation: { escapeValue: false }, interpolation: { escapeValue: false },
+40
View File
@@ -0,0 +1,40 @@
{
"title": "Curator activity",
"back": "← Back",
"loadFailed": "Failed to load audit log",
"loading": "Loading…",
"empty": "No curator actions match these filters.",
"databaseUnknown": "Reading the audit log for this environments database.",
"databaseDev": "Dev database: {{name}}",
"databaseProd": "Production database: {{name}}",
"databaseNamed": "Database: {{name}}",
"statTotal": "Total (filtered)",
"stat24h": "Last 24 hours",
"stat7d": "Last 7 days",
"statNewest": "Most recent",
"filterCurator": "Curator",
"filterAction": "Action",
"filterResource": "Resource type",
"filterResourceId": "Resource id",
"filterFrom": "From",
"filterTo": "To",
"filterSearch": "Search",
"filterSearchPlaceholder": "Action, user, IP, details…",
"allCurators": "All curators",
"allActions": "All actions",
"allResources": "All resources",
"apply": "Apply filters",
"clear": "Clear",
"byCurator": "By curator",
"byAction": "By action",
"showing": "Showing {{count}} of {{total}}",
"page": "Page {{page}} / {{pages}}",
"colWhen": "Date & time",
"colCurator": "Curator",
"colAction": "Action",
"colResource": "Resource",
"colDetails": "Details",
"colIp": "IP",
"prev": "Previous",
"next": "Next"
}
+9
View File
@@ -18,6 +18,15 @@
"tours": "Tours", "tours": "Tours",
"toursEditor": "Tour editor", "toursEditor": "Tour editor",
"users": "Users", "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…", "openingTourGallery": "Opening guided tour…",
"tourEmpty": "This tour has no paintings yet.", "tourEmpty": "This tour has no paintings yet.",
"tourLoadFailed": "Failed to load the tour.", "tourLoadFailed": "Failed to load the tour.",
+40
View File
@@ -0,0 +1,40 @@
{
"title": "Действия кураторов",
"back": "← Назад",
"loadFailed": "Не удалось загрузить журнал действий",
"loading": "Загрузка…",
"empty": "Нет действий кураторов по этим фильтрам.",
"databaseUnknown": "Журнал читается из базы данных текущего окружения.",
"databaseDev": "База разработки: {{name}}",
"databaseProd": "Продакшен-база: {{name}}",
"databaseNamed": "База данных: {{name}}",
"statTotal": "Всего (фильтр)",
"stat24h": "За 24 часа",
"stat7d": "За 7 дней",
"statNewest": "Последнее",
"filterCurator": "Куратор",
"filterAction": "Действие",
"filterResource": "Тип ресурса",
"filterResourceId": "ID ресурса",
"filterFrom": "С",
"filterTo": "По",
"filterSearch": "Поиск",
"filterSearchPlaceholder": "Действие, пользователь, IP, детали…",
"allCurators": "Все кураторы",
"allActions": "Все действия",
"allResources": "Все ресурсы",
"apply": "Применить",
"clear": "Сбросить",
"byCurator": "По кураторам",
"byAction": "По действиям",
"showing": "Показано {{count}} из {{total}}",
"page": "Стр. {{page}} / {{pages}}",
"colWhen": "Дата и время",
"colCurator": "Куратор",
"colAction": "Действие",
"colResource": "Ресурс",
"colDetails": "Детали",
"colIp": "IP",
"prev": "Назад",
"next": "Далее"
}
+9
View File
@@ -18,6 +18,15 @@
"tours": "Экскурсии", "tours": "Экскурсии",
"toursEditor": "Редактор экскурсий", "toursEditor": "Редактор экскурсий",
"users": "Пользователи", "users": "Пользователи",
"audit": "Активность",
"layoutHorizontal": "Классическая шкала →",
"layoutVertical": "↑ Вертикальная шкала",
"layoutTree": "🌳 Древо искусства",
"captionClassicTimeline": "Щёлкните эпоху или событие для приближения · Колесо — масштаб · Перетащите для панорамы",
"captionClassicFlow": "Колесо — масштаб · перетащите для панорамы · каждое направление — цветная полоса сквозь историю",
"captionVerticalTimeline": "Снизу вверх · Колесо — масштаб · Перетащите для панорамы",
"captionVerticalFlow": "Снизу вверх по истории · колесо — масштаб · перетащите для панорамы · щёлкните поток",
"captionTreeFlow": "Корни внизу, живые направления в кроне · колесо — масштаб · перетащите для панорамы · щёлкните ветвь, чтобы войти в зал",
"openingTourGallery": "Открытие экскурсии…", "openingTourGallery": "Открытие экскурсии…",
"tourEmpty": "В этой экскурсии пока нет картин.", "tourEmpty": "В этой экскурсии пока нет картин.",
"tourLoadFailed": "Не удалось загрузить экскурсию.", "tourLoadFailed": "Не удалось загрузить экскурсию.",
+310
View File
@@ -0,0 +1,310 @@
.audit-page {
padding: 1rem 1.5rem 2rem;
max-width: 1500px;
margin: 0 auto;
color: #f5f0e8;
}
.audit-header {
display: flex;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.audit-header-text h1 {
margin: 0;
font-size: 1.6rem;
}
.audit-env {
margin: 0.25rem 0 0;
color: rgba(245, 240, 232, 0.72);
font-size: 0.9rem;
}
.audit-back {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.3);
color: inherit;
padding: 0.35rem 0.75rem;
border-radius: 6px;
cursor: pointer;
}
.audit-error {
color: #f5a5a5;
}
.audit-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.audit-stat {
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 8px;
padding: 0.75rem 0.9rem;
background: rgba(255, 255, 255, 0.03);
}
.audit-stat-label {
display: block;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgba(245, 240, 232, 0.65);
margin-bottom: 0.35rem;
}
.audit-stat-value {
font-size: 1.45rem;
font-weight: 600;
}
.audit-stat-value-sm {
font-size: 0.95rem;
font-weight: 500;
}
.audit-filters {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.65rem 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 8px;
padding: 0.9rem;
margin-bottom: 1rem;
background: rgba(255, 255, 255, 0.03);
}
.audit-filters label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.audit-filters input,
.audit-filters select,
.audit-filter-actions button,
.audit-pager button,
.audit-chip {
font: inherit;
color: inherit;
}
.audit-filters input,
.audit-filters select {
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
padding: 0.4rem 0.55rem;
}
.audit-filter-search {
grid-column: span 2;
}
.audit-filter-actions {
display: flex;
align-items: flex-end;
gap: 0.5rem;
}
.audit-filter-actions button,
.audit-pager button {
background: rgba(201, 169, 110, 0.2);
border: 1px solid rgba(201, 169, 110, 0.45);
border-radius: 6px;
padding: 0.45rem 0.85rem;
cursor: pointer;
}
.audit-filter-actions button:disabled,
.audit-pager button:disabled {
opacity: 0.45;
cursor: default;
}
.audit-secondary {
background: transparent !important;
border-color: rgba(255, 255, 255, 0.28) !important;
}
.audit-breakdowns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin-bottom: 1rem;
}
.audit-breakdown {
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 8px;
padding: 0.75rem 0.9rem;
}
.audit-breakdown h2 {
margin: 0 0 0.55rem;
font-size: 0.95rem;
}
.audit-breakdown ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.audit-chip {
display: inline-flex;
align-items: center;
gap: 0.55rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 999px;
padding: 0.28rem 0.65rem;
cursor: pointer;
font-size: 0.82rem;
}
.audit-chip span {
color: rgba(201, 169, 110, 0.95);
}
.audit-table-wrap {
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 8px;
padding: 0.75rem;
overflow: auto;
}
.audit-table-meta {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.55rem;
font-size: 0.85rem;
color: rgba(245, 240, 232, 0.7);
}
.audit-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
.audit-table th,
.audit-table td {
padding: 0.5rem 0.55rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
text-align: left;
vertical-align: top;
}
.audit-table th {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgba(245, 240, 232, 0.65);
}
.audit-table tbody tr {
cursor: pointer;
}
.audit-table tbody tr:hover {
background: rgba(255, 255, 255, 0.04);
}
.audit-row-open {
background: rgba(201, 169, 110, 0.08);
}
.audit-when {
white-space: nowrap;
}
.audit-user {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.audit-role {
font-size: 0.75rem;
color: rgba(245, 240, 232, 0.55);
}
.audit-resource {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.audit-resource-label {
color: rgba(201, 169, 110, 0.95);
font-size: 0.82rem;
}
.audit-details-cell {
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: rgba(245, 240, 232, 0.7);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.78rem;
}
.audit-details-row td {
background: rgba(0, 0, 0, 0.25);
}
.audit-details-row pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.8rem;
color: rgba(245, 240, 232, 0.88);
}
.audit-empty {
color: rgba(245, 240, 232, 0.65);
}
.audit-pager {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.75rem;
}
@media (max-width: 980px) {
.audit-stats,
.audit-filters,
.audit-breakdowns {
grid-template-columns: 1fr 1fr;
}
.audit-filter-search {
grid-column: span 2;
}
}
@media (max-width: 640px) {
.audit-stats,
.audit-filters,
.audit-breakdowns {
grid-template-columns: 1fr;
}
.audit-filter-search {
grid-column: span 1;
}
}
+407
View File
@@ -0,0 +1,407 @@
import { useCallback, useEffect, useMemo, useState, Fragment, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import {
api,
type AuditLogEntry,
type AuditMeta,
type AuditQuery,
type AuditSummary,
} from '../api/client';
import './AuditPage.css';
interface Props {
onBack: () => void;
}
const PAGE_SIZE = 50;
function formatWhen(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function detailsPreview(details: Record<string, unknown> | null): string {
if (!details || Object.keys(details).length === 0) return '—';
try {
const raw = JSON.stringify(details);
return raw.length > 120 ? `${raw.slice(0, 117)}` : raw;
} catch {
return '—';
}
}
function emptyFilters() {
return {
user_id: '' as string,
action: '',
resource_type: '',
resource_id: '',
from: '',
to: '',
q: '',
};
}
export default function AuditPage({ onBack }: Props) {
const { t } = useTranslation('audit');
const [meta, setMeta] = useState<AuditMeta | null>(null);
const [summary, setSummary] = useState<AuditSummary | null>(null);
const [entries, setEntries] = useState<AuditLogEntry[]>([]);
const [total, setTotal] = useState(0);
const [database, setDatabase] = useState<string | null>(null);
const [offset, setOffset] = useState(0);
const [filters, setFilters] = useState(emptyFilters);
const [applied, setApplied] = useState(emptyFilters);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const queryFromFilters = useCallback((f: ReturnType<typeof emptyFilters>, pageOffset: number): AuditQuery => {
const q: AuditQuery = { limit: PAGE_SIZE, offset: pageOffset };
if (f.user_id) q.user_id = Number(f.user_id);
if (f.action) q.action = f.action;
if (f.resource_type) q.resource_type = f.resource_type;
if (f.resource_id.trim()) {
const id = Number(f.resource_id);
if (Number.isFinite(id)) q.resource_id = id;
}
if (f.from) q.from = new Date(f.from).toISOString();
if (f.to) {
// Inclusive end-of-day when only a date is provided
const end = new Date(f.to);
if (/^\d{4}-\d{2}-\d{2}$/.test(f.to)) {
end.setHours(23, 59, 59, 999);
}
q.to = end.toISOString();
}
if (f.q.trim()) q.q = f.q.trim();
return q;
}, []);
const load = useCallback(
async (f: ReturnType<typeof emptyFilters>, pageOffset: number) => {
setLoading(true);
setError(null);
try {
const q = queryFromFilters(f, pageOffset);
const [list, sum] = await Promise.all([api.listAuditLog(q), api.getAuditSummary(q)]);
setEntries(list.entries);
setTotal(list.total);
setDatabase(list.database ?? sum.database);
setSummary(sum);
setOffset(pageOffset);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setLoading(false);
}
},
[queryFromFilters, t]
);
useEffect(() => {
void (async () => {
try {
const m = await api.getAuditMeta();
setMeta(m);
setDatabase(m.database);
} catch {
// Meta is optional for first paint; list will surface auth errors.
}
await load(emptyFilters(), 0);
})();
}, [load]);
const applyFilters = (e?: FormEvent) => {
e?.preventDefault();
setApplied(filters);
setExpandedId(null);
void load(filters, 0);
};
const clearFilters = () => {
const cleared = emptyFilters();
setFilters(cleared);
setApplied(cleared);
setExpandedId(null);
void load(cleared, 0);
};
const page = Math.floor(offset / PAGE_SIZE) + 1;
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
const envLabel = useMemo(() => {
if (!database) return t('databaseUnknown');
if (database.includes('prod')) return t('databaseProd', { name: database });
if (database.includes('dev')) return t('databaseDev', { name: database });
return t('databaseNamed', { name: database });
}, [database, t]);
return (
<div className="audit-page">
<header className="audit-header">
<button type="button" className="audit-back" onClick={onBack}>
{t('back')}
</button>
<div className="audit-header-text">
<h1>{t('title')}</h1>
<p className="audit-env">{envLabel}</p>
</div>
</header>
{error && <p className="audit-error">{error}</p>}
{summary && (
<section className="audit-stats">
<div className="audit-stat">
<span className="audit-stat-label">{t('statTotal')}</span>
<span className="audit-stat-value">{summary.total}</span>
</div>
<div className="audit-stat">
<span className="audit-stat-label">{t('stat24h')}</span>
<span className="audit-stat-value">{summary.last_24h}</span>
</div>
<div className="audit-stat">
<span className="audit-stat-label">{t('stat7d')}</span>
<span className="audit-stat-value">{summary.last_7d}</span>
</div>
<div className="audit-stat">
<span className="audit-stat-label">{t('statNewest')}</span>
<span className="audit-stat-value audit-stat-value-sm">
{summary.newest ? formatWhen(summary.newest) : '—'}
</span>
</div>
</section>
)}
<form className="audit-filters" onSubmit={applyFilters}>
<label>
{t('filterCurator')}
<select
value={filters.user_id}
onChange={(e) => setFilters((p) => ({ ...p, user_id: e.target.value }))}
>
<option value="">{t('allCurators')}</option>
{(meta?.users ?? []).map((u) => (
<option key={u.id} value={u.id}>
{u.username} ({u.role})
</option>
))}
</select>
</label>
<label>
{t('filterAction')}
<select
value={filters.action}
onChange={(e) => setFilters((p) => ({ ...p, action: e.target.value }))}
>
<option value="">{t('allActions')}</option>
{(meta?.actions ?? []).map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</label>
<label>
{t('filterResource')}
<select
value={filters.resource_type}
onChange={(e) => setFilters((p) => ({ ...p, resource_type: e.target.value }))}
>
<option value="">{t('allResources')}</option>
{(meta?.resource_types ?? []).map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
<label>
{t('filterResourceId')}
<input
value={filters.resource_id}
onChange={(e) => setFilters((p) => ({ ...p, resource_id: e.target.value }))}
inputMode="numeric"
placeholder="e.g. 42"
/>
</label>
<label>
{t('filterFrom')}
<input
type="date"
value={filters.from}
onChange={(e) => setFilters((p) => ({ ...p, from: e.target.value }))}
/>
</label>
<label>
{t('filterTo')}
<input
type="date"
value={filters.to}
onChange={(e) => setFilters((p) => ({ ...p, to: e.target.value }))}
/>
</label>
<label className="audit-filter-search">
{t('filterSearch')}
<input
value={filters.q}
onChange={(e) => setFilters((p) => ({ ...p, q: e.target.value }))}
placeholder={t('filterSearchPlaceholder')}
/>
</label>
<div className="audit-filter-actions">
<button type="submit">{t('apply')}</button>
<button type="button" className="audit-secondary" onClick={clearFilters}>
{t('clear')}
</button>
</div>
</form>
{summary && (summary.by_user.length > 0 || summary.by_action.length > 0) && (
<section className="audit-breakdowns">
<div className="audit-breakdown">
<h2>{t('byCurator')}</h2>
<ul>
{summary.by_user.map((row) => (
<li key={row.user_id}>
<button
type="button"
className="audit-chip"
onClick={() => {
const next = { ...applied, user_id: String(row.user_id) };
setFilters(next);
setApplied(next);
void load(next, 0);
}}
>
<strong>{row.username}</strong>
<span>{row.count}</span>
</button>
</li>
))}
</ul>
</div>
<div className="audit-breakdown">
<h2>{t('byAction')}</h2>
<ul>
{summary.by_action.slice(0, 12).map((row) => (
<li key={row.action}>
<button
type="button"
className="audit-chip"
onClick={() => {
const next = { ...applied, action: row.action };
setFilters(next);
setApplied(next);
void load(next, 0);
}}
>
<strong>{row.action}</strong>
<span>{row.count}</span>
</button>
</li>
))}
</ul>
</div>
</section>
)}
<section className="audit-table-wrap">
<div className="audit-table-meta">
<span>{t('showing', { count: entries.length, total })}</span>
<span>
{t('page', { page, pages: pageCount })}
</span>
</div>
{loading ? (
<p>{t('loading')}</p>
) : entries.length === 0 ? (
<p className="audit-empty">{t('empty')}</p>
) : (
<table className="audit-table">
<thead>
<tr>
<th>{t('colWhen')}</th>
<th>{t('colCurator')}</th>
<th>{t('colAction')}</th>
<th>{t('colResource')}</th>
<th>{t('colDetails')}</th>
<th>{t('colIp')}</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => {
const open = expandedId === entry.id;
return (
<Fragment key={entry.id}>
<tr
className={open ? 'audit-row-open' : undefined}
onClick={() => setExpandedId(open ? null : entry.id)}
>
<td className="audit-when">{formatWhen(entry.created_at)}</td>
<td>
<div className="audit-user">
<strong>{entry.username}</strong>
<span className="audit-role">{entry.user_role}</span>
</div>
</td>
<td>
<code>{entry.action}</code>
</td>
<td>
<div className="audit-resource">
<span>
{entry.resource_type}
{entry.resource_id != null ? ` #${entry.resource_id}` : ''}
</span>
{entry.resource_label && (
<span className="audit-resource-label">{entry.resource_label}</span>
)}
</div>
</td>
<td className="audit-details-cell">{detailsPreview(entry.details)}</td>
<td>{entry.ip_address || '—'}</td>
</tr>
{open && (
<tr className="audit-details-row">
<td colSpan={6}>
<pre>{JSON.stringify(entry.details ?? {}, null, 2)}</pre>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
)}
<div className="audit-pager">
<button
type="button"
disabled={loading || offset <= 0}
onClick={() => void load(applied, Math.max(0, offset - PAGE_SIZE))}
>
{t('prev')}
</button>
<button
type="button"
disabled={loading || offset + PAGE_SIZE >= total}
onClick={() => void load(applied, offset + PAGE_SIZE)}
>
{t('next')}
</button>
</div>
</section>
</div>
);
}
+53
View File
@@ -24,6 +24,59 @@
overflow: hidden; 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 { .gallery-session-suspended {
position: fixed; position: fixed;
inset: 0; inset: 0;
+219 -37
View File
@@ -3,6 +3,9 @@ import { useTranslation } from 'react-i18next';
import Timeline from '../components/Timeline'; import Timeline from '../components/Timeline';
import TimelineEventGuides from '../components/TimelineEventGuides'; import TimelineEventGuides from '../components/TimelineEventGuides';
import MovementBands from '../components/MovementBands'; 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 VirtualGallery from '../components/VirtualGallery';
import PaintingDetailView from '../components/PaintingDetail'; import PaintingDetailView from '../components/PaintingDetail';
import ArtistBio from '../components/ArtistBio'; import ArtistBio from '../components/ArtistBio';
@@ -11,6 +14,7 @@ import TranslationsPage from '../pages/TranslationsPage';
import InfluencesPage from '../pages/InfluencesPage'; import InfluencesPage from '../pages/InfluencesPage';
import ToursPage from '../pages/ToursPage'; import ToursPage from '../pages/ToursPage';
import UsersPage from '../pages/UsersPage'; import UsersPage from '../pages/UsersPage';
import AuditPage from '../pages/AuditPage';
import CuratorLoginModal from '../components/CuratorLoginModal'; import CuratorLoginModal from '../components/CuratorLoginModal';
import ArtistFilterModal from '../components/ArtistFilterModal'; import ArtistFilterModal from '../components/ArtistFilterModal';
import ToursPopup from '../components/ToursPopup'; import ToursPopup from '../components/ToursPopup';
@@ -26,6 +30,7 @@ import '../pages/TranslationsPage.css';
import '../pages/InfluencesPage.css'; import '../pages/InfluencesPage.css';
import '../pages/ToursPage.css'; import '../pages/ToursPage.css';
import '../pages/UsersPage.css'; import '../pages/UsersPage.css';
import '../pages/AuditPage.css';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client'; import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
import type { import type {
@@ -45,11 +50,14 @@ import './HomePage.css';
type View = type View =
| { type: 'timeline' } | { type: 'timeline' }
| { type: 'timeline-vertical' }
| { type: 'timeline-tree' }
| { type: 'checkup' } | { type: 'checkup' }
| { type: 'translations' } | { type: 'translations' }
| { type: 'influences' } | { type: 'influences' }
| { type: 'tours' } | { type: 'tours' }
| { type: 'users' } | { type: 'users' }
| { type: 'audit' }
| { type: 'gallery'; artistId: number; data: ArtistDetail } | { type: 'gallery'; artistId: number; data: ArtistDetail }
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail } | { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
| { type: 'tour-gallery'; tourId: number; data: TourGalleryDetail } | { type: 'tour-gallery'; tourId: number; data: TourGalleryDetail }
@@ -61,7 +69,7 @@ type GallerySession =
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail } | { kind: 'movement'; movementId: number; data: MovementGalleryDetail }
| { kind: 'tour'; tourId: number; data: TourGalleryDetail }; | { kind: 'tour'; tourId: number; data: TourGalleryDetail };
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | 'users' | null; type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | 'users' | 'audit' | null;
function patchPaintingInMovementDetail( function patchPaintingInMovementDetail(
detail: MovementGalleryDetail, detail: MovementGalleryDetail,
@@ -151,9 +159,40 @@ function catalogNavigateTarget(
return idx < remaining.length ? remaining[idx].id : remaining[remaining.length - 1].id; return idx < remaining.length ? remaining[idx].id : remaining[remaining.length - 1].id;
} }
/** Shareable timeline layout via `?layout=classic|vertical|tree` (classic may omit the param). */
type TimelineLayoutId = 'classic' | 'vertical' | 'tree';
function parseTimelineLayoutParam(raw: string | null): TimelineLayoutId {
if (raw === 'vertical' || raw === 'tree') return raw;
if (raw === 'classic' || raw === 'horizontal') return 'classic';
return 'classic';
}
function timelineViewFromLayout(layout: TimelineLayoutId): View {
if (layout === 'vertical') return { type: 'timeline-vertical' };
if (layout === 'tree') return { type: 'timeline-tree' };
return { type: 'timeline' };
}
function writeTimelineLayoutParam(layout: TimelineLayoutId) {
const url = new URL(window.location.href);
if (layout === 'classic') url.searchParams.delete('layout');
else url.searchParams.set('layout', layout);
const next = `${url.pathname}${url.search}${url.hash}`;
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (next !== current) window.history.replaceState(null, '', next);
}
function readInitialTimelineView(): View {
if (typeof window === 'undefined') return { type: 'timeline' };
return timelineViewFromLayout(
parseTimelineLayoutParam(new URLSearchParams(window.location.search).get('layout'))
);
}
export default function HomePage() { export default function HomePage() {
const { t } = useTranslation('home'); const { t } = useTranslation('home');
const { isCurator, username, login, logout, can } = useAuth(); const { isCurator, isAdmin, username, login, logout, can } = useAuth();
const canImages = can('images'); const canImages = can('images');
const canCheckup = can('checkup'); const canCheckup = can('checkup');
const canNotes = can('curator_notes'); const canNotes = can('curator_notes');
@@ -161,7 +200,7 @@ export default function HomePage() {
const canInfluences = can('influences'); const canInfluences = can('influences');
const canTours = can('tours'); const canTours = can('tours');
const canUsers = can('users'); const canUsers = can('users');
const [view, setView] = useState<View>({ type: 'timeline' }); const [view, setView] = useState<View>(readInitialTimelineView);
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null); const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
const [bounds, setBounds] = useState({ min: -800, max: 2025 }); const [bounds, setBounds] = useState({ min: -800, max: 2025 });
const [viewStart, setViewStart] = useState(-800); const [viewStart, setViewStart] = useState(-800);
@@ -204,7 +243,11 @@ export default function HomePage() {
setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data }); setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data });
} else if (view.type === 'tour-gallery') { } else if (view.type === 'tour-gallery') {
setGallerySession({ kind: 'tour', tourId: view.tourId, data: view.data }); setGallerySession({ kind: 'tour', tourId: view.tourId, data: view.data });
} else if (view.type === 'timeline') { } else if (
view.type === 'timeline' ||
view.type === 'timeline-vertical' ||
view.type === 'timeline-tree'
) {
setGallerySession(null); setGallerySession(null);
} }
}, [view]); }, [view]);
@@ -265,9 +308,34 @@ export default function HomePage() {
setViewStart(bounds.min); setViewStart(bounds.min);
setViewEnd(bounds.max); setViewEnd(bounds.max);
setGalleryRevision((revision) => revision + 1); setGalleryRevision((revision) => revision + 1);
writeTimelineLayoutParam('classic');
setView({ type: 'timeline' }); setView({ type: 'timeline' });
}, [bounds.min, bounds.max]); }, [bounds.min, bounds.max]);
const openHorizontalTimeline = () => {
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 = () => { const toggleDebugMode = () => {
setDebugMode((prev) => { setDebugMode((prev) => {
const next = !prev; const next = !prev;
@@ -299,6 +367,8 @@ export default function HomePage() {
setView({ type: 'tours' }); setView({ type: 'tours' });
} else if (loginRedirect === 'users') { } else if (loginRedirect === 'users') {
setView({ type: 'users' }); setView({ type: 'users' });
} else if (loginRedirect === 'audit') {
setView({ type: 'audit' });
} }
setLoginRedirect(null); setLoginRedirect(null);
}; };
@@ -312,7 +382,8 @@ export default function HomePage() {
view.type === 'translations' || view.type === 'translations' ||
view.type === 'influences' || view.type === 'influences' ||
view.type === 'tours' || view.type === 'tours' ||
view.type === 'users' view.type === 'users' ||
view.type === 'audit'
) { ) {
goToTimelineHome(); goToTimelineHome();
} }
@@ -358,6 +429,14 @@ export default function HomePage() {
setView({ type: 'users' }); setView({ type: 'users' });
}; };
const openAudit = () => {
if (!isAdmin) {
openCuratorLogin('audit');
return;
}
setView({ type: 'audit' });
};
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => { const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
const data = await api.getPainting(paintingId); const data = await api.getPainting(paintingId);
const patch: Partial<Painting> = { const patch: Partial<Painting> = {
@@ -1104,6 +1183,25 @@ export default function HomePage() {
) )
)} )}
{view.type === 'audit' && (
isAdmin ? (
<AuditPage onBack={goToTimelineHome} />
) : (
<div className="curator-login-gate">
<h2>{t('curatorRequiredTitle')}</h2>
<p>{t('curatorRequiredBody')}</p>
<div className="curator-login-gate-actions">
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('audit')}>
{t('curatorLogin')}
</button>
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
{t('backToGalleryBtn')}
</button>
</div>
</div>
)
)}
{view.type === 'translations' && ( {view.type === 'translations' && (
canTranslations ? ( canTranslations ? (
<TranslationsPage onBack={goToTimelineHome} /> <TranslationsPage onBack={goToTimelineHome} />
@@ -1171,9 +1269,41 @@ export default function HomePage() {
/> />
)} )}
{view.type === 'timeline' && ( {isTimelineHome && (
<div className="home-page"> <div className="home-page">
<header className="site-header"> <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"> <div className="site-dev-tools">
{isCurator ? ( {isCurator ? (
<> <>
@@ -1253,6 +1383,16 @@ export default function HomePage() {
{t('users')} {t('users')}
</button> </button>
)} )}
{isAdmin && (
<button
type="button"
className="checkup-link-btn"
onClick={openAudit}
title="Curator activity audit reports"
>
{t('audit')}
</button>
)}
<button <button
type="button" type="button"
className="curator-logout-btn" className="curator-logout-btn"
@@ -1291,7 +1431,7 @@ export default function HomePage() {
/> />
</header> </header>
<div className="home-timeline-stack"> <div className={`home-timeline-stack${isVerticalLayout ? ' home-timeline-stack-vertical' : ''}`}>
{loading && ( {loading && (
<GalleryLoadingMarker overlay message="Loading art history…" /> <GalleryLoadingMarker overlay message="Loading art history…" />
)} )}
@@ -1302,37 +1442,79 @@ export default function HomePage() {
<GalleryLoadingMarker banner message="Loading portraits…" /> <GalleryLoadingMarker banner message="Loading portraits…" />
)} )}
<Timeline {isVerticalLayout ? (
eras={timelineData.eras}
viewStart={viewStart}
viewEnd={viewEnd}
onViewChange={handleViewChange}
absoluteMin={bounds.min}
absoluteMax={bounds.max}
lifespanHighlight={hoveredLifespan}
/>
{error && <div className="error-banner">{error}</div>}
{!loading && (
<> <>
<TimelineEventGuides viewStart={viewStart} viewEnd={viewEnd} /> <VerticalTimeline
<div className="home-movements-section"> eras={timelineData.eras}
<MovementBands viewStart={viewStart}
movements={timelineData.movements} viewEnd={viewEnd}
artists={artists} onViewChange={handleViewChange}
portraitRevisions={portraitRevisions} absoluteMin={bounds.min}
viewStart={viewStart} absoluteMax={bounds.max}
viewEnd={viewEnd} lifespanHighlight={hoveredLifespan}
absoluteMin={bounds.min} />
absoluteMax={bounds.max} {error && <div className="error-banner">{error}</div>}
onViewChange={handleViewChange} {!loading && (
onArtistClick={handleArtistClick} <div className="home-movements-section-vertical">
onMovementClick={handleMovementClick} {isTreeTimeline ? (
onArtistHover={setHoveredLifespan} <MovementTree
onPortraitsLoadingChange={setPortraitsLoading} movements={timelineData.movements}
/> viewStart={viewStart}
</div> 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}
viewEnd={viewEnd}
onViewChange={handleViewChange}
absoluteMin={bounds.min}
absoluteMax={bounds.max}
lifespanHighlight={hoveredLifespan}
/>
{error && <div className="error-banner">{error}</div>}
{!loading && (
<>
<TimelineEventGuides viewStart={viewStart} viewEnd={viewEnd} />
<div className="home-movements-section">
<MovementBands
movements={timelineData.movements}
artists={artists}
portraitRevisions={portraitRevisions}
viewStart={viewStart}
viewEnd={viewEnd}
absoluteMin={bounds.min}
absoluteMax={bounds.max}
onViewChange={handleViewChange}
onArtistClick={handleArtistClick}
onMovementClick={handleMovementClick}
onArtistHover={setHoveredLifespan}
onPortraitsLoadingChange={setPortraitsLoading}
/>
</div>
</>
)}
</> </>
)} )}
</div> </div>
@@ -14,6 +14,9 @@ export type SurfaceTextureKind =
| 'limestone' | 'limestone'
| 'sandstone' | 'sandstone'
| 'rough-stone' | 'rough-stone'
| 'gothic-ashlar'
| 'gothic-vault'
| 'gothic-stone-floor'
| 'basalt' | 'basalt'
| 'stucco-cream' | 'stucco-cream'
| 'stucco-terracotta' | 'stucco-terracotta'
@@ -247,6 +250,52 @@ function marbleVeined(ctx: CanvasRenderingContext2D, size: number, base: string,
noiseOverlay(ctx, size, 0.035, seed + 1); noiseOverlay(ctx, size, 0.035, seed + 1);
} }
/**
* Two-centred (pointed) arch path from the springing line up to the apex.
* `springY` is the bottom of the curve, `apexY` the crown canvas y grows down.
*/
function pointedArchPath(
ctx: CanvasRenderingContext2D,
cx: number,
halfW: number,
springY: number,
apexY: number
) {
const shoulder = apexY + (springY - apexY) * 0.28;
ctx.moveTo(cx - halfW, springY);
ctx.quadraticCurveTo(cx - halfW, shoulder, cx, apexY);
ctx.quadraticCurveTo(cx + halfW, shoulder, cx + halfW, springY);
}
/** Coursed ashlar masonry — staggered blocks with cut joints and per-block tone. */
function ashlarCourses(
ctx: CanvasRenderingContext2D,
size: number,
base: string,
seed: number,
courses = 12
) {
fill(ctx, size, base);
const [br, bg, bb] = hexToRgb(base);
const ch = size / courses;
const bw = size / 6;
for (let row = 0; row < courses; row++) {
const offset = row % 2 ? bw / 2 : 0;
for (let col = -1; col < 7; col++) {
const rand = seeded(seed + row * 11 + col * 3);
const t = (rand() - 0.5) * 22;
ctx.fillStyle = rgb(br + t, bg + t, bb + t * 0.9);
ctx.fillRect(col * bw + offset, row * ch, bw - 1.5, ch - 1.5);
}
// Recessed bed joint with a lit lower lip
ctx.fillStyle = 'rgba(60,52,40,0.35)';
ctx.fillRect(0, row * ch + ch - 1.5, size, 1.5);
ctx.fillStyle = 'rgba(255,250,235,0.16)';
ctx.fillRect(0, row * ch, size, 1);
}
noiseOverlay(ctx, size, 0.05, seed + 1);
}
/** Veined marble confined to one slab rect — for revetment panels and inlay. */ /** Veined marble confined to one slab rect — for revetment panels and inlay. */
function marbleSlab( function marbleSlab(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
@@ -572,6 +621,235 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
fill(ctx, size, '#3a3834'); fill(ctx, size, '#3a3834');
noiseOverlay(ctx, size, 0.15, 107); noiseOverlay(ctx, size, 0.15, 107);
break; break;
case 'gothic-ashlar': {
// One tile spans a full wall height: plinth, tall blind arcade, string
// course, triforium gallery, cornice — so the wall reads as architecture
// rather than a flat stone panel. Relief comes through the normal map.
ashlarCourses(ctx, size, '#c9bda6', 210, 13);
const stringCourse = (y: number, h: number) => {
ctx.fillStyle = 'rgba(250,244,228,0.5)';
ctx.fillRect(0, y, size, h * 0.45);
ctx.fillStyle = 'rgba(74,64,50,0.45)';
ctx.fillRect(0, y + h * 0.45, size, h * 0.55);
};
// Plinth and cornice
ctx.fillStyle = 'rgba(60,52,40,0.22)';
ctx.fillRect(0, size * 0.9, size, size * 0.1);
stringCourse(size * 0.885, size * 0.022);
stringCourse(size * 0.055, size * 0.028);
stringCourse(size * 0.375, size * 0.024);
// Tall blind arcade — 3 bays across the tile
const bays = 3;
const bayW = size / bays;
for (let i = 0; i < bays; i++) {
const cx = bayW * (i + 0.5);
const halfW = bayW * 0.36;
const springY = size * 0.66;
const apexY = size * 0.43;
// Recessed panel behind the arch
ctx.save();
ctx.beginPath();
pointedArchPath(ctx, cx, halfW, springY, apexY);
ctx.lineTo(cx + halfW, size * 0.885);
ctx.lineTo(cx - halfW, size * 0.885);
ctx.closePath();
ctx.clip();
ctx.fillStyle = 'rgba(52,44,34,0.34)';
ctx.fillRect(cx - halfW, apexY, halfW * 2, size);
const shade = ctx.createLinearGradient(cx - halfW, 0, cx + halfW, 0);
shade.addColorStop(0, 'rgba(20,16,12,0.4)');
shade.addColorStop(0.45, 'rgba(20,16,12,0)');
shade.addColorStop(1, 'rgba(20,16,12,0.28)');
ctx.fillStyle = shade;
ctx.fillRect(cx - halfW, apexY, halfW * 2, size);
ctx.restore();
// Arch moulding: lit outer roll, dark soffit
ctx.beginPath();
pointedArchPath(ctx, cx, halfW + size * 0.016, springY, apexY - size * 0.02);
ctx.strokeStyle = 'rgba(252,246,230,0.62)';
ctx.lineWidth = size * 0.016;
ctx.stroke();
ctx.beginPath();
pointedArchPath(ctx, cx, halfW, springY, apexY);
ctx.strokeStyle = 'rgba(58,48,36,0.5)';
ctx.lineWidth = size * 0.008;
ctx.stroke();
// Colonnette shafts carrying the arch
for (const sx of [cx - halfW, cx + halfW]) {
ctx.fillStyle = 'rgba(250,244,228,0.5)';
ctx.fillRect(sx - size * 0.011, springY, size * 0.014, size * 0.225);
ctx.fillStyle = 'rgba(58,48,36,0.42)';
ctx.fillRect(sx + size * 0.003, springY, size * 0.005, size * 0.225);
// Moulded capital and base
ctx.fillStyle = 'rgba(252,246,230,0.62)';
ctx.fillRect(sx - size * 0.019, springY - size * 0.016, size * 0.038, size * 0.016);
ctx.fillRect(sx - size * 0.017, size * 0.868, size * 0.034, size * 0.017);
}
}
// Triforium gallery — paired small arches per bay
for (let i = 0; i < bays; i++) {
const bayCx = bayW * (i + 0.5);
for (const sub of [-1, 1]) {
const cx = bayCx + sub * bayW * 0.19;
const halfW = bayW * 0.13;
const springY = size * 0.29;
const apexY = size * 0.135;
ctx.save();
ctx.beginPath();
pointedArchPath(ctx, cx, halfW, springY, apexY);
ctx.lineTo(cx + halfW, size * 0.345);
ctx.lineTo(cx - halfW, size * 0.345);
ctx.closePath();
ctx.clip();
ctx.fillStyle = 'rgba(28,24,18,0.55)';
ctx.fillRect(cx - halfW, apexY, halfW * 2, size * 0.3);
ctx.restore();
ctx.beginPath();
pointedArchPath(ctx, cx, halfW + size * 0.009, springY, apexY - size * 0.012);
ctx.strokeStyle = 'rgba(252,246,230,0.58)';
ctx.lineWidth = size * 0.009;
ctx.stroke();
for (const sx of [cx - halfW, cx + halfW]) {
ctx.fillStyle = 'rgba(250,244,228,0.5)';
ctx.fillRect(sx - size * 0.006, springY, size * 0.008, size * 0.055);
}
}
}
noiseOverlay(ctx, size, 0.035, 211);
break;
}
case 'gothic-vault': {
// Quadripartite rib vault with tiercerons, seen from below: webbing
// panels lift toward a gilded central boss.
fill(ctx, size, '#cec4ac');
const c = size / 2;
const glow = ctx.createRadialGradient(c, c, size * 0.05, c, c, size * 0.62);
glow.addColorStop(0, 'rgba(255,250,236,0.55)');
glow.addColorStop(1, 'rgba(38,32,25,0.72)');
ctx.fillStyle = glow;
ctx.fillRect(0, 0, size, size);
for (let y = 0; y < size; y += 4) {
for (let x = 0; x < size; x += 4) {
const n = (fbm(x / 90, y / 90, 221) - 0.5) * 14;
ctx.fillStyle = `rgba(${210 + n | 0},${200 + n | 0},${176 + n | 0},0.35)`;
ctx.fillRect(x, y, 4, 4);
}
}
const rib = (x0: number, y0: number, x1: number, y1: number, w: number) => {
ctx.lineCap = 'round';
ctx.strokeStyle = 'rgba(48,40,30,0.5)';
ctx.lineWidth = w * 1.55;
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
ctx.strokeStyle = 'rgba(236,228,206,0.95)';
ctx.lineWidth = w;
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
ctx.strokeStyle = 'rgba(255,252,242,0.75)';
ctx.lineWidth = w * 0.32;
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
};
const major = size * 0.044;
const minor = size * 0.026;
// Transverse and wall ribs around the bay
rib(0, 0, size, 0, major);
rib(0, size, size, size, major);
rib(0, 0, 0, size, major);
rib(size, 0, size, size, major);
// Diagonal cross ribs
rib(0, 0, size, size, major);
rib(size, 0, 0, size, major);
// Tiercerons springing to the edge midpoints
rib(0, 0, c, 0, minor);
rib(0, 0, 0, c, minor);
rib(size, 0, c, 0, minor);
rib(size, 0, size, c, minor);
rib(0, size, c, size, minor);
rib(0, size, 0, c, minor);
rib(size, size, c, size, minor);
rib(size, size, size, c, minor);
// Ridge ribs
rib(c, 0, c, size, minor);
rib(0, c, size, c, minor);
// Carved boss at the crown
ctx.fillStyle = 'rgba(46,38,28,0.55)';
ctx.beginPath();
ctx.arc(c, c, size * 0.052, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#c9a94e';
ctx.beginPath();
ctx.arc(c, c, size * 0.042, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,246,216,0.75)';
ctx.beginPath();
ctx.arc(c - size * 0.008, c - size * 0.008, size * 0.02, 0, Math.PI * 2);
ctx.fill();
noiseOverlay(ctx, size, 0.03, 222);
break;
}
case 'gothic-stone-floor': {
// Worn limestone flags with dark diamond cabochons at the slab corners.
fill(ctx, size, '#a89c86');
const n = 4;
const cell = size / n;
for (let row = 0; row < n; row++) {
for (let col = 0; col < n; col++) {
const x = col * cell;
const y = row * cell;
const rand = seeded(230 + row * n + col);
const t = (rand() - 0.5) * 26;
ctx.fillStyle = rgb(178 + t, 168 + t, 146 + t);
ctx.fillRect(x + 2, y + 2, cell - 4, cell - 4);
// Footfall polish toward the slab centre
const wear = ctx.createRadialGradient(
x + cell / 2, y + cell / 2, cell * 0.05,
x + cell / 2, y + cell / 2, cell * 0.55
);
wear.addColorStop(0, 'rgba(226,218,198,0.35)');
wear.addColorStop(1, 'rgba(226,218,198,0)');
ctx.fillStyle = wear;
ctx.fillRect(x + 2, y + 2, cell - 4, cell - 4);
ctx.strokeStyle = 'rgba(58,50,40,0.45)';
ctx.lineWidth = 3;
ctx.strokeRect(x + 2, y + 2, cell - 4, cell - 4);
}
}
// Dark cabochons where four slabs meet
for (let row = 1; row < n; row++) {
for (let col = 1; col < n; col++) {
const cx = col * cell;
const cy = row * cell;
const r = cell * 0.085;
ctx.fillStyle = '#3c3a3e';
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
ctx.fill();
}
}
noiseOverlay(ctx, size, 0.05, 231);
break;
}
case 'stucco-cream': case 'stucco-cream':
stuccoSurface(ctx, size, '#f0e8d8', 108); stuccoSurface(ctx, size, '#f0e8d8', 108);
break; break;
@@ -842,6 +1120,11 @@ function normalStrengthFor(kind: SurfaceTextureKind): number {
if (kind.includes('stucco') || kind.includes('plaster')) return 2.6; if (kind.includes('stucco') || kind.includes('plaster')) return 2.6;
if (kind.includes('panel') || kind.includes('oak') || kind.includes('walnut')) return 2.8; if (kind.includes('panel') || kind.includes('oak') || kind.includes('walnut')) return 2.8;
if (kind === 'brick' || kind === 'rough-stone') return 3.2; if (kind === 'brick' || kind === 'rough-stone') return 3.2;
// Arcading and rib mouldings are drawn as light/dark bands — lean on the
// derived normals so they read as carved relief, not painted-on lines.
if (kind === 'gothic-ashlar') return 4.2;
if (kind === 'gothic-vault') return 3.6;
if (kind === 'gothic-stone-floor') return 2.4;
return 2.5; return 2.5;
} }
@@ -853,6 +1136,9 @@ const ROUGHNESS: Partial<Record<SurfaceTextureKind, number>> = {
'marble-opus-sectile': 0.2, 'marble-opus-sectile': 0.2,
'marble-revetment': 0.34, 'marble-revetment': 0.34,
'coffered-wood': 0.72, 'coffered-wood': 0.72,
'gothic-ashlar': 0.84,
'gothic-vault': 0.9,
'gothic-stone-floor': 0.56,
'gilded-stucco': 0.22, 'gilded-stucco': 0.22,
'velvet-crimson': 0.92, 'velvet-crimson': 0.92,
'velvet-navy': 0.92, 'velvet-navy': 0.92,
@@ -894,6 +1180,10 @@ const METERS: Partial<Record<SurfaceTextureKind, number>> = {
'marble-opus-sectile': 5.0, 'marble-opus-sectile': 5.0,
'marble-revetment': 4.4, 'marble-revetment': 4.4,
'coffered-wood': 4.2, 'coffered-wood': 4.2,
// Matches WALL_HEIGHT so one tile = one full storey of arcading.
'gothic-ashlar': 4.2,
'gothic-vault': 5.5,
'gothic-stone-floor': 3.2,
flagstone: 3.0, flagstone: 3.0,
'mosaic-byzantine': 1.8, 'mosaic-byzantine': 1.8,
'mosaic-roman': 2.0, 'mosaic-roman': 2.0,
@@ -919,6 +1209,9 @@ const NORMAL_SCALE: Partial<Record<SurfaceTextureKind, number>> = {
'marble-opus-sectile': 0.55, 'marble-opus-sectile': 0.55,
'marble-revetment': 0.6, 'marble-revetment': 0.6,
'coffered-wood': 1.0, 'coffered-wood': 1.0,
'gothic-ashlar': 1.0,
'gothic-vault': 0.85,
'gothic-stone-floor': 0.45,
'velvet-crimson': 0.5, 'velvet-crimson': 0.5,
'velvet-navy': 0.5, 'velvet-navy': 0.5,
'velvet-emerald': 0.5, 'velvet-emerald': 0.5,
+90
View File
@@ -0,0 +1,90 @@
/** Shared colour helpers for movement streams (timeline flow, vertical flow, tree). */
/** True when `hex` is a usable #rgb / #rrggbb / #rrggbbaa-style value. */
export function isMovementHexColor(hex: string): boolean {
const normalized = hex.replace('#', '').trim();
return /^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6,}$/.test(normalized);
}
/**
* Parse a catalogue colour to RGB.
* 3-digit shorthand expands; 6+ digits keep the first six (so `#rrggbbaa` `#rrggbb`).
* Throws on malformed input so callers can fall back.
*/
export function parseHexColor(hex: string): [number, number, number] {
const normalized = hex.replace('#', '').trim();
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6,}$/.test(normalized)) {
throw new Error(`invalid hex colour: ${hex}`);
}
const value =
normalized.length === 3
? normalized
.split('')
.map((c) => c + c)
.join('')
: normalized.slice(0, 6);
const n = parseInt(value, 16);
if (!Number.isFinite(n)) throw new Error(`invalid hex colour: ${hex}`);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return [0, 0, l];
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h = 0;
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
return [h * 360, s, l];
}
export function hslToHex(h: number, s: number, l: number): string {
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r = 0;
let g = 0;
let b = 0;
if (h < 60) [r, g, b] = [c, x, 0];
else if (h < 120) [r, g, b] = [x, c, 0];
else if (h < 180) [r, g, b] = [0, c, x];
else if (h < 240) [r, g, b] = [0, x, c];
else if (h < 300) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];
const toByte = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
}
/** Lift a muted catalogue colour into a saturated stream colour. */
export function vividMovementColor(hex: string): string {
try {
if (!isMovementHexColor(hex)) return hex;
const [r, g, b] = parseHexColor(hex);
const [h, s, l] = rgbToHsl(r, g, b);
const s2 = s < 0.1 ? Math.min(0.55, s + 0.42) : Math.min(1, s * 1.65 + 0.08);
const l2 =
l < 0.22 ? 0.5 : l > 0.78 ? 0.62 : Math.min(0.68, Math.max(0.4, l * 0.75 + 0.28));
return hslToHex(h, s2, l2);
} catch {
return hex;
}
}
/** Darker variant of a stream colour — used for the shaded side of a tree limb. */
export function shadeMovementColor(hex: string, amount = 0.34): string {
try {
if (!isMovementHexColor(hex)) return hex;
const [r, g, b] = parseHexColor(hex);
const [h, s, l] = rgbToHsl(r, g, b);
return hslToHex(h, Math.min(1, s * 1.05), Math.max(0.08, l * (1 - amount)));
} catch {
return hex;
}
}
+328
View File
@@ -0,0 +1,328 @@
/**
* Tree layout rules for the "Tree of Art" start page.
*
* The classic flow chart packs movements into lanes and lets the lanes drift as
* you pan. A tree needs the opposite: a shape you can recognise again after a
* zoom. So the horizontal geometry here is computed **once from the whole
* catalogue** and never depends on the visible year window only the vertical
* (time) axis reacts to pan/zoom.
*
* Rules
* -----
* 1. **Time grows upward.** The oldest movements sit at the bottom, the newest
* at the top. Y is purely `year → pixel`; this file never computes it.
* 2. **One trunk, at the centre.** `MOVEMENT_LINEAGE` is a DAG, so it is first
* reduced to a spanning tree: each movement keeps its *most immediate
* predecessor* (the parent with the latest start year that still precedes
* it) as its structural parent. Remaining parents survive as **grafts**
* thin secondary limbs the renderer draws behind the tree.
* 3. **Children split the parent's slot.** Every node reserves a horizontal
* slot as wide as its whole subtree (`max(own limb, Σ children)`), and its
* children are packed side by side and centred on the parent. A single-child
* chain therefore inherits the parent's x exactly the trunk stays straight
* until it actually forks, and every fork spreads symmetrically, so later
* generations end up further from the centre.
* 4. **Leonardo's rule for thickness.** A limb is as thick as the limbs it
* carries: `base² = own² + Σ child.base²`. The trunk at the bottom is the
* thickest thing on screen and every branch tapers as it rises and sheds
* children. A movement's *own* thickness comes from its influence-link count.
* 5. **Branches lean outward.** A limb drifts sideways across its own lifespan,
* away from its parent, by at most the slack left inside its slot so limbs
* look grown rather than extruded, and can never collide with a sibling.
* 6. **Unlinked movements are saplings.** A movement with no lineage edge is its
* own root; extra roots are planted alternately right and left of the trunk,
* widest subtree first, so the main trunk keeps x = 0 (canvas centre).
*/
import type { ArtMovement } from '../types';
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
/** All values are tree-space pixels; the renderer scales them to the canvas. */
export const TREE_LAYOUT = {
/** Horizontal room a childless limb claims. */
LEAF_SLOT_PX: 104,
/** Clear space kept around a limb inside its own slot. */
LIMB_GAP_PX: 34,
/** Thinnest a limb may be drawn. */
MIN_LIMB_PX: 13,
/** Thickest a limb can get from its own influence count alone. */
MAX_OWN_LIMB_PX: 36,
/** Ceiling for the accumulated (Leonardo) thickness of the trunk. */
MAX_TRUNK_PX: 96,
/** How much of the slack inside a slot a limb may lean into. */
LEAN_SLACK: 0.55,
MAX_LEAN_PX: 28,
/** A limb ends its life this much thinner than it started it. */
TIP_TAPER: 0.66,
} as const;
export interface MovementTreeNode {
movement: ArtMovement;
/** Structural parent in the spanning tree (`null` for roots). */
parentId: number | null;
/** Documented predecessors that lost to the structural parent. */
graftParentIds: number[];
childIds: number[];
depth: number;
descendants: number;
/** Thickness the movement earns on its own (influence links). */
ownWidth: number;
/** Thickness where the limb leaves its parent — carries every descendant. */
baseWidth: number;
/** Thickness where the limb ends. */
tipWidth: number;
/** Horizontal slot reserved for this node and everything under it. */
subtreeWidth: number;
/** Tree-space x of the limb base. The main trunk sits at 0. */
x: number;
/** Lateral drift from base to tip, px (signed). */
lean: number;
side: -1 | 0 | 1;
}
export interface MovementTree {
nodes: Map<number, MovementTreeNode>;
rootIds: number[];
/** Ids ordered thickest-first, so thin branches paint over the trunk. */
drawOrder: number[];
/** Half the horizontal extent actually occupied, px (>= 1). */
halfSpan: number;
}
/** Stable per-id value in [-1, 1] — organic drift without randomness. */
function idDrift(id: number): number {
const n = Math.sin(id * 12.9898) * 43758.5453;
return (n - Math.floor(n)) * 2 - 1;
}
function influenceCount(m: ArtMovement): number {
const n = m.influence_link_count;
return typeof n === 'number' && Number.isFinite(n) ? Math.max(0, n) : 0;
}
/** child id → documented parent ids, restricted to movements in the catalogue. */
function buildParentMap(movements: ArtMovement[]): Map<number, number[]> {
const nameToId = new Map(movements.map((m) => [m.name, m.id]));
const parents = new Map<number, number[]>();
for (const [parentName, childName] of MOVEMENT_LINEAGE) {
const parentId = nameToId.get(parentName);
const childId = nameToId.get(childName);
if (parentId == null || childId == null || parentId === childId) continue;
const list = parents.get(childId) ?? [];
if (!list.includes(parentId)) list.push(parentId);
parents.set(childId, list);
}
return parents;
}
/**
* Reduce the lineage DAG to a spanning tree. Ranking every movement by start
* year first means a parent is always strictly earlier in the ranking than its
* child, so the result cannot contain a cycle.
*/
function chooseStructuralParents(
movements: ArtMovement[],
parentMap: Map<number, number[]>
): Map<number, { parentId: number | null; grafts: number[] }> {
const ranked = [...movements].sort(
(a, b) => a.start_year - b.start_year || a.id - b.id
);
const rank = new Map(ranked.map((m, index) => [m.id, index]));
const chosen = new Map<number, { parentId: number | null; grafts: number[] }>();
for (const m of movements) {
const candidates = (parentMap.get(m.id) ?? []).filter(
(pid) => (rank.get(pid) ?? Infinity) < (rank.get(m.id) ?? -Infinity)
);
if (candidates.length === 0) {
chosen.set(m.id, { parentId: null, grafts: [] });
continue;
}
// Most immediate predecessor carries the branch; older ones become grafts.
const sorted = [...candidates].sort(
(a, b) => (rank.get(b) ?? 0) - (rank.get(a) ?? 0)
);
chosen.set(m.id, { parentId: sorted[0], grafts: sorted.slice(1) });
}
return chosen;
}
export function buildMovementTree(movements: ArtMovement[]): MovementTree {
const nodes = new Map<number, MovementTreeNode>();
if (movements.length === 0) {
return { nodes, rootIds: [], drawOrder: [], halfSpan: 1 };
}
const parentMap = buildParentMap(movements);
const structure = chooseStructuralParents(movements, parentMap);
const maxInfluence = Math.max(0, ...movements.map(influenceCount));
for (const movement of movements) {
const { parentId, grafts } = structure.get(movement.id) ?? {
parentId: null,
grafts: [],
};
const ownWidth =
maxInfluence > 0
? TREE_LAYOUT.MIN_LIMB_PX +
(influenceCount(movement) / maxInfluence) *
(TREE_LAYOUT.MAX_OWN_LIMB_PX - TREE_LAYOUT.MIN_LIMB_PX)
: (TREE_LAYOUT.MIN_LIMB_PX + TREE_LAYOUT.MAX_OWN_LIMB_PX) / 2;
nodes.set(movement.id, {
movement,
parentId,
graftParentIds: grafts,
childIds: [],
depth: 0,
descendants: 0,
ownWidth,
baseWidth: ownWidth,
tipWidth: Math.max(TREE_LAYOUT.MIN_LIMB_PX * 0.6, ownWidth * TREE_LAYOUT.TIP_TAPER),
subtreeWidth: TREE_LAYOUT.LEAF_SLOT_PX,
x: 0,
lean: 0,
side: 0,
});
}
const rootIds: number[] = [];
for (const node of nodes.values()) {
if (node.parentId != null && nodes.has(node.parentId)) {
nodes.get(node.parentId)!.childIds.push(node.movement.id);
} else {
node.parentId = null;
rootIds.push(node.movement.id);
}
}
for (const node of nodes.values()) {
node.childIds.sort((a, b) => {
const ma = nodes.get(a)!.movement;
const mb = nodes.get(b)!.movement;
return ma.start_year - mb.start_year || ma.name.localeCompare(mb.name);
});
node.graftParentIds = node.graftParentIds.filter((id) => nodes.has(id));
}
// Post-order: depth, descendant count, Leonardo thickness, slot width.
const measure = (id: number, depth: number): void => {
const node = nodes.get(id)!;
node.depth = depth;
let descendants = 0;
let childrenWidth = 0;
let carried = node.ownWidth * node.ownWidth;
for (const childId of node.childIds) {
measure(childId, depth + 1);
const child = nodes.get(childId)!;
descendants += 1 + child.descendants;
childrenWidth += child.subtreeWidth;
carried += child.baseWidth * child.baseWidth;
}
node.descendants = descendants;
node.baseWidth = Math.min(TREE_LAYOUT.MAX_TRUNK_PX, Math.sqrt(carried));
node.subtreeWidth = Math.max(
node.baseWidth + TREE_LAYOUT.LIMB_GAP_PX,
node.childIds.length === 0 ? TREE_LAYOUT.LEAF_SLOT_PX : childrenWidth
);
};
for (const id of rootIds) measure(id, 0);
// Widest tree takes the centre; the rest are planted alternately right / left.
rootIds.sort((a, b) => {
const na = nodes.get(a)!;
const nb = nodes.get(b)!;
return (
nb.descendants - na.descendants ||
na.movement.start_year - nb.movement.start_year ||
na.movement.name.localeCompare(nb.movement.name)
);
});
const place = (id: number, x: number): void => {
const node = nodes.get(id)!;
node.x = x;
const total = node.childIds.reduce((sum, cid) => sum + nodes.get(cid)!.subtreeWidth, 0);
let cursor = x - total / 2;
for (const childId of node.childIds) {
const child = nodes.get(childId)!;
place(childId, cursor + child.subtreeWidth / 2);
cursor += child.subtreeWidth;
}
};
if (rootIds.length > 0) {
const trunk = nodes.get(rootIds[0])!;
place(rootIds[0], 0);
let rightEdge = trunk.subtreeWidth / 2;
let leftEdge = -trunk.subtreeWidth / 2;
rootIds.slice(1).forEach((id, index) => {
const node = nodes.get(id)!;
if (index % 2 === 0) {
place(id, rightEdge + node.subtreeWidth / 2);
rightEdge += node.subtreeWidth;
} else {
place(id, leftEdge - node.subtreeWidth / 2);
leftEdge -= node.subtreeWidth;
}
});
}
// Lean: outward from the parent, capped by the slack left inside the slot.
for (const node of nodes.values()) {
const parent = node.parentId != null ? nodes.get(node.parentId) : null;
const slack = Math.max(0, (node.subtreeWidth - node.baseWidth) / 2);
const side = parent ? (Math.sign(node.x - parent.x) as -1 | 0 | 1) : 0;
node.side = side;
node.lean =
side !== 0
? side * Math.min(TREE_LAYOUT.MAX_LEAN_PX, slack * TREE_LAYOUT.LEAN_SLACK)
: idDrift(node.movement.id) * Math.min(9, slack * 0.2);
}
let halfSpan = 1;
for (const node of nodes.values()) {
halfSpan = Math.max(
halfSpan,
Math.abs(node.x) + Math.abs(node.lean) + node.baseWidth / 2
);
}
const drawOrder = [...nodes.keys()].sort((a, b) => {
const na = nodes.get(a)!;
const nb = nodes.get(b)!;
return nb.baseWidth - na.baseWidth || na.depth - nb.depth;
});
return { nodes, rootIds, drawOrder, halfSpan };
}
/** Fraction of a movement's lifespan elapsed at `year`, clamped to [0, 1]. */
export function lifeProgress(node: MovementTreeNode, year: number): number {
const { start_year: start, end_year: end } = node.movement;
if (end <= start) return 0;
return Math.min(1, Math.max(0, (year - start) / (end - start)));
}
/** Tree-space x of a limb's centreline at `year` (accounts for the lean). */
export function limbXAtYear(node: MovementTreeNode, year: number): number {
return node.x + node.lean * lifeProgress(node, year);
}
/** Limb thickness at `year`, tapering from base to tip. */
export function limbWidthAtYear(node: MovementTreeNode, year: number): number {
const t = lifeProgress(node, year);
return node.baseWidth + (node.tipWidth - node.baseWidth) * t;
}
/**
* Year at which a child limb leaves its parent. Branches split a little before
* the successor movement is dated, which is both how lineage works and what
* keeps the junction from looking like a right angle.
*/
export function branchOriginYear(parent: MovementTreeNode, child: MovementTreeNode): number {
const childStart = child.movement.start_year;
const parentStart = parent.movement.start_year;
const parentEnd = parent.movement.end_year;
const lead = Math.min(60, Math.max(6, (childStart - parentStart) * 0.22));
return Math.min(parentEnd, Math.max(parentStart, childStart - lead));
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+1456
View File
File diff suppressed because it is too large Load Diff
+47 -1
View File
@@ -19,7 +19,47 @@ const { printCliResult } = require('./lib/cli-result');
const { Client } = pg; const { Client } = pg;
// Prod restore keeps these tables untouched (no TRUNCATE, no INSERT from dev backup). // Prod restore keeps these tables untouched (no TRUNCATE, no INSERT from dev backup).
const PROD_RESTORE_SKIP_TABLES = new Set(['curator_audit_log']); // Staff accounts, sessions, and audit history stay env-local — never overwrite from gallery_dev.
const PROD_RESTORE_SKIP_TABLES = new Set(['users', 'session', 'curator_audit_log']);
function quoteIdent(name) {
return `"${String(name).replace(/"/g, '""')}"`;
}
/** Align serial/identity sequences with MAX(column) after explicit-id INSERTs. */
async function syncSerialSequences(client) {
const { rows } = await client.query(
`SELECT
c.relname AS table_name,
a.attname AS column_name,
pg_get_serial_sequence(format('%I.%I', n.nspname, c.relname), a.attname) AS seq_name
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
JOIN pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum
WHERE c.relkind = 'r'
AND n.nspname = 'public'
AND pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval(%'`
);
let synced = 0;
for (const row of rows) {
if (!row.seq_name) continue;
await client.query(
`SELECT setval(
$1::regclass,
GREATEST(
1,
COALESCE((SELECT MAX(${quoteIdent(row.column_name)}) FROM ${quoteIdent(row.table_name)}), 1)
),
true
)`,
[row.seq_name]
);
synced += 1;
}
return synced;
}
function getInsertTableName(statement) { function getInsertTableName(statement) {
const match = statement.match(/^INSERT INTO "([^"]+)"/i) const match = statement.match(/^INSERT INTO "([^"]+)"/i)
@@ -239,6 +279,12 @@ async function main() {
} }
} }
// Backups insert explicit primary keys; without this, serial nextval() can
// collide with existing ids (e.g. creating a user fails as "already exists").
console.log('Syncing serial sequences to MAX(id)...');
const synced = await syncSerialSequences(client);
console.log(` synced ${synced} sequence(s)`);
await client.end(); await client.end();
console.log(`Restore complete: ${restored} statements into "${dbName}".`); console.log(`Restore complete: ${restored} statements into "${dbName}".`);
if (skippedInserts > 0) { if (skippedInserts > 0) {
+2
View File
@@ -12,6 +12,7 @@ const { requirePermission } = require('./middleware/auth');
const { logCuratorAction } = require('./audit-log'); const { logCuratorAction } = require('./audit-log');
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
const usersRoutes = require('./routes/users'); const usersRoutes = require('./routes/users');
const auditRoutes = require('./routes/audit');
const { ensurePaintingImages, preloadArtistImagesLocal, preloadMovementImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, enrichPaintingRow, enrichArtistRow, IMAGE_DIR } = require('./image-service'); const { ensurePaintingImages, preloadArtistImagesLocal, preloadMovementImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, enrichPaintingRow, enrichArtistRow, IMAGE_DIR } = require('./image-service');
const { getVersionInfo } = require('./version-info'); const { getVersionInfo } = require('./version-info');
const { searchCatalog } = require('./search-service'); const { searchCatalog } = require('./search-service');
@@ -46,6 +47,7 @@ app.use(express.json({ limit: '20mb' }));
app.use(createSessionMiddleware()); app.use(createSessionMiddleware());
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/users', usersRoutes); app.use('/api/users', usersRoutes);
app.use('/api/audit', auditRoutes);
app.use('/api/translations', translationRoutes); app.use('/api/translations', translationRoutes);
app.use('/api/influences', influenceRoutes); app.use('/api/influences', influenceRoutes);
app.use('/api/tours', tourRoutes); app.use('/api/tours', tourRoutes);
+28
View File
@@ -79,6 +79,33 @@ function requirePermission(permission) {
}; };
} }
/** Active staff with role admin only. */
async function requireAdmin(req, res, next) {
const userId = req.session?.userId;
if (!userId) {
return res.status(401).json({ error: 'Curator login required' });
}
try {
const user = await loadStaffUser(userId);
if (!user || !user.is_active) {
req.session.destroy(() => {});
return res.status(401).json({ error: 'Curator login required' });
}
attachStaff(req, user);
if (user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
} catch (err) {
console.error('Auth middleware error:', err.message);
res.status(500).json({ error: 'Authentication failed' });
}
}
function staffAuthPayload(user) { function staffAuthPayload(user) {
return { return {
role: user.role, role: user.role,
@@ -90,6 +117,7 @@ function staffAuthPayload(user) {
module.exports = { module.exports = {
requireCurator, requireCurator,
requirePermission, requirePermission,
requireAdmin,
loadStaffUser, loadStaffUser,
staffAuthPayload, staffAuthPayload,
}; };
+266
View File
@@ -0,0 +1,266 @@
const express = require('express');
const pool = require('../db');
const { requireAdmin } = require('../middleware/auth');
const router = express.Router();
router.use(requireAdmin);
const MAX_LIMIT = 200;
const DEFAULT_LIMIT = 50;
function parseOptionalInt(raw) {
if (raw == null || raw === '') return null;
const n = parseInt(String(raw), 10);
return Number.isFinite(n) ? n : null;
}
function parseOptionalDate(raw) {
if (raw == null || raw === '') return null;
const d = new Date(String(raw));
return Number.isNaN(d.getTime()) ? null : d;
}
function buildFilters(query) {
const clauses = [];
const params = [];
const userId = parseOptionalInt(query.user_id);
if (userId != null) {
params.push(userId);
clauses.push(`l.user_id = $${params.length}`);
}
const username =
typeof query.username === 'string' && query.username.trim()
? query.username.trim()
: null;
if (username) {
params.push(username);
clauses.push(`u.username = $${params.length}`);
}
const action =
typeof query.action === 'string' && query.action.trim() ? query.action.trim() : null;
if (action) {
params.push(action);
clauses.push(`l.action = $${params.length}`);
}
const resourceType =
typeof query.resource_type === 'string' && query.resource_type.trim()
? query.resource_type.trim()
: null;
if (resourceType) {
params.push(resourceType);
clauses.push(`l.resource_type = $${params.length}`);
}
const resourceId = parseOptionalInt(query.resource_id);
if (resourceId != null) {
params.push(resourceId);
clauses.push(`l.resource_id = $${params.length}`);
}
const from = parseOptionalDate(query.from);
if (from) {
params.push(from.toISOString());
clauses.push(`l.created_at >= $${params.length}::timestamptz`);
}
const to = parseOptionalDate(query.to);
if (to) {
params.push(to.toISOString());
clauses.push(`l.created_at <= $${params.length}::timestamptz`);
}
const q = typeof query.q === 'string' && query.q.trim() ? query.q.trim() : null;
if (q) {
params.push(`%${q}%`);
const idx = params.length;
clauses.push(`(
l.action ILIKE $${idx}
OR l.resource_type ILIKE $${idx}
OR u.username ILIKE $${idx}
OR COALESCE(l.details::text, '') ILIKE $${idx}
OR COALESCE(l.ip_address, '') ILIKE $${idx}
)`);
}
return {
where: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '',
params,
};
}
const RESOURCE_LABEL_SQL = `
CASE l.resource_type
WHEN 'painting' THEN (SELECT title FROM paintings WHERE id = l.resource_id)
WHEN 'artist' THEN (SELECT name FROM artists WHERE id = l.resource_id)
WHEN 'user' THEN (SELECT username FROM users WHERE id = l.resource_id)
WHEN 'tour' THEN (SELECT title FROM tours WHERE id = l.resource_id)
WHEN 'movement' THEN (SELECT name FROM art_movements WHERE id = l.resource_id)
ELSE NULL
END
`;
router.get('/', async (req, res) => {
try {
const { where, params } = buildFilters(req.query);
let limit = parseOptionalInt(req.query.limit) ?? DEFAULT_LIMIT;
let offset = parseOptionalInt(req.query.offset) ?? 0;
limit = Math.min(MAX_LIMIT, Math.max(1, limit));
offset = Math.max(0, offset);
const countResult = await pool.query(
`SELECT COUNT(*)::int AS total
FROM curator_audit_log l
JOIN users u ON u.id = l.user_id
${where}`,
params
);
const listParams = [...params, limit, offset];
const limitIdx = params.length + 1;
const offsetIdx = params.length + 2;
const { rows } = await pool.query(
`SELECT
l.id,
l.created_at,
l.user_id,
u.username,
u.role AS user_role,
l.action,
l.resource_type,
l.resource_id,
l.details,
l.ip_address,
(${RESOURCE_LABEL_SQL}) AS resource_label
FROM curator_audit_log l
JOIN users u ON u.id = l.user_id
${where}
ORDER BY l.created_at DESC, l.id DESC
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
listParams
);
res.json({
database: process.env.DB_NAME || null,
total: countResult.rows[0].total,
limit,
offset,
entries: rows.map((row) => ({
id: row.id,
created_at: row.created_at,
user_id: row.user_id,
username: row.username,
user_role: row.user_role,
action: row.action,
resource_type: row.resource_type,
resource_id: row.resource_id,
resource_label: row.resource_label,
details: row.details,
ip_address: row.ip_address,
})),
});
} catch (err) {
console.error('Audit list error:', err.message);
res.status(500).json({ error: 'Failed to load audit log' });
}
});
router.get('/summary', async (req, res) => {
try {
const { where, params } = buildFilters(req.query);
const [byUser, byAction, byResource, totals] = await Promise.all([
pool.query(
`SELECT u.id AS user_id, u.username, u.role, COUNT(*)::int AS count
FROM curator_audit_log l
JOIN users u ON u.id = l.user_id
${where}
GROUP BY u.id, u.username, u.role
ORDER BY count DESC, u.username ASC`,
params
),
pool.query(
`SELECT l.action, COUNT(*)::int AS count
FROM curator_audit_log l
JOIN users u ON u.id = l.user_id
${where}
GROUP BY l.action
ORDER BY count DESC, l.action ASC`,
params
),
pool.query(
`SELECT l.resource_type, COUNT(*)::int AS count
FROM curator_audit_log l
JOIN users u ON u.id = l.user_id
${where}
GROUP BY l.resource_type
ORDER BY count DESC, l.resource_type ASC`,
params
),
pool.query(
`SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE l.created_at >= NOW() - INTERVAL '24 hours')::int AS last_24h,
COUNT(*) FILTER (WHERE l.created_at >= NOW() - INTERVAL '7 days')::int AS last_7d,
MIN(l.created_at) AS oldest,
MAX(l.created_at) AS newest
FROM curator_audit_log l
JOIN users u ON u.id = l.user_id
${where}`,
params
),
]);
const t = totals.rows[0] || {};
res.json({
database: process.env.DB_NAME || null,
total: t.total || 0,
last_24h: t.last_24h || 0,
last_7d: t.last_7d || 0,
oldest: t.oldest || null,
newest: t.newest || null,
by_user: byUser.rows,
by_action: byAction.rows,
by_resource_type: byResource.rows,
});
} catch (err) {
console.error('Audit summary error:', err.message);
res.status(500).json({ error: 'Failed to load audit summary' });
}
});
router.get('/meta', async (_req, res) => {
try {
const [users, actions, resourceTypes] = await Promise.all([
pool.query(
`SELECT DISTINCT u.id, u.username, u.role
FROM curator_audit_log l
JOIN users u ON u.id = l.user_id
ORDER BY u.username ASC`
),
pool.query(
`SELECT DISTINCT action FROM curator_audit_log ORDER BY action ASC`
),
pool.query(
`SELECT DISTINCT resource_type FROM curator_audit_log ORDER BY resource_type ASC`
),
]);
res.json({
database: process.env.DB_NAME || null,
users: users.rows,
actions: actions.rows.map((r) => r.action),
resource_types: resourceTypes.rows.map((r) => r.resource_type),
});
} catch (err) {
console.error('Audit meta error:', err.message);
res.status(500).json({ error: 'Failed to load audit filters' });
}
});
module.exports = router;
+27
View File
@@ -38,6 +38,17 @@ async function clearUserSessions(userId) {
await pool.query(`DELETE FROM session WHERE (sess->>'userId')::int = $1`, [userId]); await pool.query(`DELETE FROM session WHERE (sess->>'userId')::int = $1`, [userId]);
} }
/** Keep users_id_seq ahead of existing rows (restore inserts explicit ids). */
async function syncUsersIdSequence() {
await pool.query(
`SELECT setval(
pg_get_serial_sequence('users', 'id'),
GREATEST(1, COALESCE((SELECT MAX(id) FROM users), 1)),
true
)`
);
}
router.use(requirePermission('users')); router.use(requirePermission('users'));
router.get('/', async (_req, res) => { router.get('/', async (_req, res) => {
@@ -76,6 +87,16 @@ router.post('/', async (req, res) => {
const passwordHash = await bcrypt.hash(password, 10); const passwordHash = await bcrypt.hash(password, 10);
const storedPermissions = role === 'admin' ? ALL_PERMISSIONS : permissions; const storedPermissions = role === 'admin' ? ALL_PERMISSIONS : permissions;
const { rows: taken } = await pool.query(
`SELECT username FROM users WHERE LOWER(username) = LOWER($1) LIMIT 1`,
[username]
);
if (taken[0]) {
return res.status(409).json({ error: 'Username already exists' });
}
await syncUsersIdSequence();
const { rows } = await pool.query( const { rows } = await pool.query(
`INSERT INTO users (username, password_hash, role, permissions, is_active) `INSERT INTO users (username, password_hash, role, permissions, is_active)
VALUES ($1, $2, $3, $4::text[], true) VALUES ($1, $2, $3, $4::text[], true)
@@ -95,6 +116,12 @@ router.post('/', async (req, res) => {
res.status(201).json({ user: mapUser(rows[0]) }); res.status(201).json({ user: mapUser(rows[0]) });
} catch (err) { } catch (err) {
if (err.code === '23505') { if (err.code === '23505') {
if (err.constraint === 'users_pkey') {
console.error('Users create PK conflict (sequence lag):', err.detail || err.message);
return res.status(409).json({
error: 'Could not allocate user id — retry create (sequence was out of sync)',
});
}
return res.status(409).json({ error: 'Username already exists' }); return res.status(409).json({ error: 'Username already exists' });
} }
console.error('Users create error:', err.message); console.error('Users create error:', err.message);