Author SHA1 Message Date
Danila KhodjaefandClaude Opus 5 f2a256132c Give every movement its own period interior.
Movement halls routed 24 of the 26 movements through 8 generic detail kinds,
four of which (modern, industrial, atelier, museum) rendered nothing at all,
and `salon` was a single ceiling ring shared by eight movements.

`details` is now a 26-value MovementDetailKind, one per movement, each built
from the architecture of the same period as the movement itself. Gothic and
Byzantine are unchanged and stay in MovementHallDetails.tsx as the reference
implementations; the rest live under components/hall-details/, split by era,
over shared wall-flush primitives.

hall-details/geometry.ts records the budget the Gothic and Byzantine
components established, since frames hang 0.23 m proud with a 0.7 m clear
margin at each wall end: relief limits above and below the frame line, corner
pockets for freestanding masses, doorway and title-band clearance, and a cap
of two added lights per hall. Halls now pass their computed windows down so
upper-wall panels, arches and roundels step around the openings.

Also holds the pale interiors back from blowing out: gallery wall tints are
authored around 0.50-0.56 luminance instead of near-white, and the 19 halls
that read too bright take lightScale 0.30, matching the basilica.

Verified by rendering all 26 interiors offline at two hall sizes and
measuring the scene graph: nothing through walls, floor or ceiling, maximum
0.17 m intrusion into the hang zone, at most two added lights per hall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 18:44:05 +03:00
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
Danila KhodjaefandClaude Opus 5 1cb9eef935 Rework Byzantine hall as a late-antique basilica interior.
Replace the flat gold-mosaic chapel with marble revetment walls, a
Cosmatesque stone floor, and a coffered timber ceiling, matching
Santa Sabina / San Vitale reference interiors.

New procedural surfaces in galleryProceduralTextures.ts:
- marble-revetment: book-matched veined panels in moulded travertine
  surrounds (each panel mirrored about its centre)
- marble-opus-sectile: cut-stone bays with diagonal diamond inlays and
  corner triangles in porphyry, verd-antique, giallo, and grey
- coffered-wood: recessed walnut coffers with lit beam chamfers

Add a byzantine detail set to MovementHallDetails.tsx: engaged porphyry
colonnettes with white-marble basket capitals and impost blocks carrying
the ceiling, a marble revetment dado, and hanging brass polycandela.
Colonnettes stay flush in the corner pockets so they do not clip the
~0.7m frame hang margin.

Basilicas are lit by shafts from high windows rather than evenly
flooded, but the shared hall lights carry a brightness floor that
prevented dimming. Add an opt-in lightScale to the interior style that
scales the scene, hall, track, and HDR environment lights. It defaults
to 1, so all other halls are unchanged; only Byzantine opts down. Window
fill lights are left unscaled so the daylight shafts still read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:59:56 +03:00
Danila KhodjaefandCursor 1853222e01 Fix movement-hall black walls and column clipping.
Cap shared hall lights (no per-painting spots) so MeshStandard walls stay within WebGL limits, tuck classical/neoclassical details into engaged corner pilasters, and document the light budget.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 00:26:14 +03:00
Danila KhodjaefandCursor 710d262516 Speed up large movement gallery entry (Byzantine)
Hall frames use thumbs only (never multi-MB originals). Preload no longer blocks open; texture downloads are queued; boot overlay no longer waits on HDR Environment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 20:40:06 +03:00
Danila KhodjaefandCursor 0a5918c4dd Add movement artist filter modal and fix large-hall texture blanks
Clicking a movement opens ArtistFilterModal to choose artists before the gallery. Large halls no longer permanently blank frames when texture loads exceed the overlay deadline; fall back thumb to full to on-demand API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 19:05:54 +03:00
Danila KhodjaefandCursor ad1572b3aa Improve movement label placement and lane corridor clearing
Label priority: center, right-end inside, overflow right/left (prefer side with fewer transition-line overlaps), adjacent gap, hover-only. Exclude own portraits from collision. Min stream height 25px. Corridor clearing: 20 passes, prefer above, re-attract after move.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 12:13:55 +03:00
Danila KhodjaefandCursor 8111cd0163 Improve movement flow layout/animation and fix Byzantine hall doors, styles, and preload.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 20:36:55 +03:00
Danila Khodjaef 23e1210e86 Release: weekly deploy 2026-07-27 20:14:51 +03:00
Danila Khodjaef 485c7d3e6f Release: weekly deploy 2026-07-27 19:29:19 +03:00
Danila KhodjaefandCursor 08ff4651e2 Enlarge movement-flow artist portraits to 150% size.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:07:58 +03:00
Danila KhodjaefandCursor 0466b77328 Add curator roles/permissions with Users admin, and fix lineage branch joins.
Staff accounts use admin/curator roles and fine-grained flags; transitions connect source-to-target with color gradients and stream cutout masks so overlaps stay seamless.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:04:17 +03:00
Danila KhodjaefandCursor bfa21989c9 Require painting thumb regen on every picture change and document the invariant.
Fix disk sync, ensure, and preload paths that previously reused the full image as a thumb.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 14:45:46 +03:00
Danila KhodjaefandCursor 67594ea4d3 Sync checkup flags with image harmonize; tighten gallery collision and exit-to-timeline.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 14:33:36 +03:00
Danila Khodjaef 41d2d5d844 Release: weekly deploy 2026-07-27 13:25:43 +03:00
Danila KhodjaefandCursor 8d3ebcce60 Improve movement flow layout: smaller hover portraits, on-band labels, and clear lineage corridors.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 13:16:20 +03:00
Danila KhodjaefandCursor 89e39d408a Fix harmonize:images thumb rebuild and allow oversized museum scans.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 12:23:25 +03:00
Danila KhodjaefandCursor 6656f91f25 Document Gariff influence workbook and pack timeline lanes by time span.
Add Inputs/gariff_influential_painters_influences.xlsx for curator import, document Inputs/Output conventions, and let non-overlapping movements share horizontal lanes on the timeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:32:59 +03:00
Danila Khodjaef 8005252881 Release: weekly deploy 2026-07-26 12:14:41 +03:00
Danila KhodjaefandCursor d709e98640 Merge remote main: upside-down influence lamps with local hall fixes.
Keep remote lamp orientation, drop castShadow (shadows stay disabled), and retain curator-note plates plus large-hall loading docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 11:40:55 +03:00
Danila KhodjaefandCursor 8e444fa461 Fix harmonize prod DB targeting and large-hall loading hangs.
Prod env files now win over root .env so harmonize:db can open gallery_prod after gallery_dev; tour_stops gains updated_at; halls no longer block the overlay on every painting texture. Docs cover the fixes and one-artist image pull from prod; Duccio painting assets synced from TrueNAS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 01:26:51 +03:00
Danila KhodjaefandCursor f6c73c1792 Smooth 3D hall boot: disable shadows and warm door shaders under the loading overlay.
Keep Loading gallery until Environment and gl.compileAsync finish (with timeouts), GPU-upload painting textures before dismiss, and drop Canvas shadow maps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 15:32:10 +03:00
Danila KhodjaefandCursor bc8369e373 Add public curator notes and U-shaped hall wall hang.
Paintings get editable curator notes with brass plates in the 3D hall, and visit order now uses the far/end wall between left and right.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 14:21:22 +03:00
Danila Khodjaef 77befd94b2 Merge branch 'main' of https://gitea.mysuperlab.netcraze.pro/Danilka/Art-gallery 2026-07-22 23:14:09 +03:00
Danila KhodjaefandCursor cc39c2b138 Flip influence-link picture lamps upside down in the 3D hall.
Document the inverted InfluencePictureLamp mount so link indicators match the intended fixture orientation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 23:12:54 +03:00
215 changed files with 15355 additions and 1274 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.
+2 -1
View File
@@ -16,8 +16,9 @@ TRUST_PROXY=true
IMAGE_DIR=./data/images
# Curator auth (run npm run dev:migrate after setting CURATOR_PASSWORD)
# Reset password anytime: npm run dev:reset-curator
SESSION_SECRET=change-me-to-a-long-random-string
SESSION_COOKIE_SECURE=false
# Omit SESSION_COOKIE_SECURE for auto (HTTPS via proxy → Secure cookie). Set true/false to force.
CURATOR_USERNAME=curator
CURATOR_PASSWORD=
+9
View File
@@ -13,9 +13,18 @@ infra/deploy/last-docker-release.json
# DB backups (may contain data)
db/DataBackup/
# Generated exports / scratch (CSV dumps, PDF text extracts)
Output/
__pycache__/
# Large local book PDFs (keep workbooks under Inputs/*.xlsx)
Inputs/*.pdf
# Recovery / temp files from local restore
_extracted/
_parse_*.js
tmp-*.js
tmp-*.py
# Logs
*.log
+138 -27
View File
@@ -30,7 +30,7 @@ curl.exe -sk https://devgallery.mysuperlab.netcraze.pro/api/bounds
## Authentication
Anonymous visitors have implicit role **`user`** (browse only). **Curator** accounts unlock debug mode, the Checkup page, and all mutating audit routes.
Anonymous visitors have implicit role **`user`** (browse only). Staff accounts in `users` are **`admin`** or **`curator`** with fine-grained **permissions**. Admins have all tools; curators only the flags assigned to them. Mutations are logged in `curator_audit_log` with `user_id`.
Sessions use an HTTP-only cookie (`gallery.sid`). The client sends `credentials: 'include'` on API requests.
@@ -42,19 +42,25 @@ Sessions use an HTTP-only cookie (`gallery.sid`). The client sends `credentials:
{ "role": "user" }
```
**Response (curator session)**
**Response (staff session)**
```json
{ "role": "curator", "username": "curator" }
{
"role": "admin",
"username": "curator",
"permissions": ["images", "checkup", "curator_notes", "translations", "influences", "tours", "users"]
}
```
`permissions` is the effective set (admins always receive the full list).
### `POST /api/auth/login`
**Body:** `{ "username": "curator", "password": "…" }`
**Response:** `{ "role": "curator", "username": "curator" }`
**Response:** same shape as `/me` for staff (`role`, `username`, `permissions`).
**Errors:** `401` invalid credentials, `400` missing fields.
**Errors:** `401` invalid credentials or disabled account, `400` missing fields.
### `POST /api/auth/logout`
@@ -62,29 +68,70 @@ Destroys the session cookie.
**Response:** `{ "ok": true }`
### Curator-only routes
### Permission flags
These return **`401`** with `{ "error": "Curator login required" }` without a valid curator session:
| Permission | Gates |
|------------|--------|
| `images` | Debug image/portrait fix/clear/upload/delete, debug search/proxy |
| `checkup` | Checkup page + checkup flag patches |
| `curator_notes` | `PATCH …/curator-notes` |
| `translations` | `/api/translations/*` |
| `influences` | `/api/influences/*` |
| `tours` | Tour admin CRUD |
| `users` | `/api/users/*` (Users page) |
| Route | Audit action (mutations only) |
|-------|-------------------------------|
| `GET /api/paintings/checkup` | — (read) |
| `GET /api/paintings/:id/debug-image-search` (+ `/more`) | — |
| `GET /api/artists/:id/debug-portrait-search` (+ `/more`) | — |
| `GET /api/debug/image-proxy` | — |
| `PATCH /api/paintings/:id/checkup-flags` | `painting.checkup_flags` |
| `PATCH /api/artists/:id/checkup-flags` | `artist.checkup_flags` |
| `POST /api/paintings/:id/fix-image` | `painting.fix_image` |
| `POST /api/paintings/:id/clear-image` | `painting.clear_image` |
| `POST /api/paintings/:id/upload-image` | `painting.upload_image` |
| `DELETE /api/paintings/:id` | `painting.delete` |
| `POST /api/artists/:id/fix-portrait` | `artist.fix_portrait` |
| `POST /api/artists/:id/clear-portrait` | `artist.clear_portrait` |
| `POST /api/artists/:id/upload-portrait` | `artist.upload_portrait` |
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" }`.
**Public** (no login): all catalog `GET` routes, `POST /api/artists/:id/preload-images` (local file linking for 3D halls), `/images`, SPA static.
### Users (admin / `users` permission)
Curator mutations are recorded in `curator_audit_log` (see [DB_structure.md](DB_structure.md)).
| Method | Path | Notes |
|--------|------|-------|
| `GET` | `/api/users` | List users + known permission keys |
| `POST` | `/api/users` | Create `{ username, password, role, permissions }` |
| `PATCH` | `/api/users/:id` | Update `role`, `permissions`, `is_active` |
| `POST` | `/api/users/:id/password` | Set new password; clears that users sessions |
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
| Route | Permission | Audit action (mutations only) |
|-------|------------|-------------------------------|
| `GET /api/paintings/checkup` | `checkup` | — (read) |
| `GET /api/paintings/:id/debug-image-search` (+ `/more`) | `images` | — |
| `GET /api/artists/:id/debug-portrait-search` (+ `/more`) | `images` | — |
| `GET /api/debug/image-proxy` | `images` | — |
| `PATCH /api/paintings/:id/checkup-flags` | `checkup` | `painting.checkup_flags` |
| `PATCH /api/paintings/:id/curator-notes` | `curator_notes` | `painting.update_curator_notes` |
| `PATCH /api/artists/:id/checkup-flags` | `checkup` | `artist.checkup_flags` |
| `POST /api/paintings/:id/fix-image` | `images` | `painting.fix_image` |
| `POST /api/paintings/:id/clear-image` | `images` | `painting.clear_image` |
| `POST /api/paintings/:id/upload-image` | `images` | `painting.upload_image` |
| `DELETE /api/paintings/:id` | `images` | `painting.delete` |
| `POST /api/artists/:id/fix-portrait` | `images` | `artist.fix_portrait` |
| `POST /api/artists/:id/clear-portrait` | `images` | `artist.clear_portrait` |
| `POST /api/artists/:id/upload-portrait` | `images` | `artist.upload_portrait` |
| `/api/translations/*` | `translations` | `translation.*` |
| `/api/influences/*` | `influences` | `influence.*` |
| Tour admin (`/api/tours/admin`, POST/PATCH/DELETE, stops) | `tours` | `tour.*` |
| `/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.
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`.
---
@@ -339,9 +386,38 @@ Artists belonging to a single movement.
---
## `GET /api/movements/:id/artists-summary`
Lightweight artist list for the **movement gallery entry filter** modal (portraits + painting counts).
**Response** — array of:
```json
{
"id": 6,
"name": "Andrei Rublev",
"birth_year": 1360,
"death_year": 1430,
"portrait_path": "portraits/...",
"portrait_thumb_path": "portraits/thumbs/...",
"portrait_cache_key": 1710000000000,
"portrait_thumb_cache_key": 1710000000000,
"painting_count": 12
}
```
| Field | Meaning |
|-------|---------|
| `painting_count` | Number of paintings by this artist (any movement filter is by `artists.movement_id`) |
| `portrait_*_cache_key` | File mtimes for cache-busting (from `enrichArtistRow`) |
Ordered by `birth_year`, then name. Localized when `?lang=` / locale headers request a non-default locale.
---
## `GET /api/movements/:id/gallery`
Full **movement gallery** payload for the 3D movement wings view (opened by clicking a movement name on the home page).
Full **movement gallery** payload for the 3D movement wings view (opened by clicking a movement name on the home page, after the artist-filter modal).
**Response**
@@ -418,7 +494,7 @@ Each painting includes:
| 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_fixed` | Image replaced via **Fix it** |
@@ -563,7 +639,7 @@ Both lists are grouped by art movement and exclude the current artist. Each arti
**Public** — no curator login required.
Fast local scan: links paintings to files already on disk. Does **not** download from the internet. The 3D client calls this automatically when entering an **artist** hall.
Fast local scan: links paintings to files already on disk and regenerates missing thumbs. Does **not** download from the internet. The 3D client calls this automatically when entering an **artist** hall.
**Response**
@@ -575,6 +651,20 @@ Fast local scan: links paintings to files already on disk. Does **not** download
---
## `POST /api/movements/:id/preload-images`
**Public** — no curator login required.
Same local-only scan as the artist preload, for every painting by artists in the movement. The 3D client calls this automatically when entering a **movement** hall.
**Response**
```json
{ "fetched": 40, "total": 45 }
```
---
## `GET /api/paintings/:id`
Painting detail with influence graph neighbours.
@@ -593,6 +683,7 @@ Painting detail with influence graph neighbours.
"thumbnail_cache_key": 1739123456790,
"checkup_checked": false,
"checkup_fixed": false,
"curator_notes": "",
"has_influence_links": true
},
"influencedBy": [
@@ -698,6 +789,22 @@ Full-catalog audit table for the Checkup UI.
---
### `PATCH /api/paintings/:id/curator-notes`
Update public curator notes for a painting.
**Body**
```json
{ "curatorNotes": "Optional editorial text…" }
```
`curatorNotes` must be a string (trim applied; empty string clears the notes).
**Response:** `{ "curatorNotes": "…" }`
**Audit:** `painting.update_curator_notes`
### `PATCH /api/paintings/:id/checkup-flags`
Update review flags. Body: `{ "checked"?: boolean, "fixed"?: boolean }` — at least one field required.
@@ -733,6 +840,8 @@ Google-family image search for debug / checkup (Custom Search → Google Arts &
Download a remote URL and replace the paintings local full image + thumbnail. Sets `checkup_fixed = true` and `checkup_checked = true`.
**Invariant:** every painting picture write (fix, upload, fetch, disk sync) must regenerate a dedicated `paintings/thumbs/*_thumb.jpg` from the full file — never reuse a remote thumb URL or point `thumbnail_path` at the full image. See [data-and-images.md — Painting thumbnail invariant](data-and-images.md#painting-thumbnail-invariant).
Uses `downloadImageForFix` in `scripts/image-fetcher.js` (browser User-Agent, referer fallbacks, Wikimedia URL upgrades) for reliable downloads from Google Arts, Commons, etc.
**Body**
@@ -868,6 +977,7 @@ The React client wraps these endpoints in `client/src/api/client.ts`. All reques
| `api.getArtistNavigation(id)` | `GET /api/artists/:id/navigation` |
| `api.getPainting(id)` | `GET /api/paintings/:id` |
| `preloadArtistImages(id)` | `POST /api/artists/:id/preload-images` |
| `preloadMovementImages(id)` | `POST /api/movements/:id/preload-images` |
| `imageUrl(path, revision?)` | `/images/<path>` or placeholder; optional `?v=` cache buster |
| `paintingImageRevision(painting, sessionRevision?)` | Prefer API `image_cache_key` / `thumbnail_cache_key`, else in-session counter |
| `galleryImageUrl(painting, sessionRevision?)` | Local thumb/full only (3D); auto `?v=` from cache keys |
@@ -877,6 +987,7 @@ The React client wraps these endpoints in `client/src/api/client.ts`. All reques
| `validateDebugUploadFile(file)` | Client-side size/type check before base64 upload |
| `api.getPaintingCheckup()` | `GET /api/paintings/checkup` |
| `api.updatePaintingCheckupFlags(id, flags)` | `PATCH /api/paintings/:id/checkup-flags` |
| `api.updatePaintingCuratorNotes(id, curatorNotes)` | `PATCH /api/paintings/:id/curator-notes` |
| `api.getPaintingDebugImageSearch(id)` | `GET /api/paintings/:id/debug-image-search` |
| `api.getPaintingDebugImageSearchMore(id, limit?)` | `GET /api/paintings/:id/debug-image-search/more` |
| `api.fixPaintingImage(id, imageUrl, context?)` | `POST /api/paintings/:id/fix-image` |
+20 -9
View File
@@ -104,7 +104,8 @@ Phases within an artists career (e.g. “Blue Period”, “Roman Period”).
| `period_id` | FK → `artist_periods` | Optional grouping |
| `title` | VARCHAR(300) | |
| `year`, `year_end` | INTEGER | Creation date(s) |
| `description` | TEXT | |
| `description` | TEXT | Wikipedia-style catalog text |
| `curator_notes` | TEXT NOT NULL DEFAULT '' | Public curator editorial notes (inline edit on detail; brass plate in 3D hall when non-empty) |
| `image_path` | VARCHAR(500) | Full-size local file; nullable after debug **Clear** |
| `thumbnail_path` | VARCHAR(500) | Smaller variant for lists / 3D; nullable after **Clear** |
| `wikipedia_title` | VARCHAR(300) | Used by image fetcher |
@@ -114,7 +115,7 @@ Phases within an artists career (e.g. “Blue Period”, “Roman Period”).
When `checkup_fixed` is true, `checkup_checked` is set automatically and cannot be cleared until **Fixed** is off. A cleared painting (`image_path` and `thumbnail_path` both null, `checkup_fixed` true) is shown as an empty frame in detail view and is not refetched on demand.
Applied by `npm run dev:migrate:checkup-flags` (`db/migrate-checkup-flags.sql`).
Applied by `npm run dev:migrate:checkup-flags` (`db/migrate-checkup-flags.sql`). `curator_notes` is applied by `db/migrate-curator-notes.sql` via `npm run dev:migrate`.
### `painting_annotations`
@@ -160,8 +161,9 @@ Curated guided tours. See [tours.md](tours.md).
| `painting_id` | FK → `paintings` | ON DELETE CASCADE; UNIQUE with `tour_id` |
| `sort_order` | INTEGER | Visitor / editor order |
| `body` | TEXT | English tour notes for the stop (v1) |
| `updated_at` | TIMESTAMPTZ | Trigger on UPDATE; required for `npm run harmonize:db` |
Applied by `npm run dev:migrate` (`db/migrate-tours.sql`).
Applied by `npm run dev:migrate` (`db/migrate-tours.sql`; `updated_at` also covered by `migrate-sync-timestamps.sql` when the table already exists).
### `painting_influences`
@@ -213,34 +215,43 @@ Unique index on `(painting_id, source_type, source_painting_id, source_artist_id
### `users`
Curator accounts (named logins). Anonymous site visitors do not have rows here.
Staff accounts (named logins). Anonymous site visitors do not have rows here. Migration: `db/migrate-auth.sql` + `db/migrate-user-roles.sql`.
| Column | Type | Notes |
|--------|------|-------|
| `id` | SERIAL PK | |
| `username` | VARCHAR(64) UNIQUE | Login name |
| `password_hash` | VARCHAR(255) | bcrypt hash |
| `role` | VARCHAR(32) | `admin` or `curator` (`users_role_check`) |
| `permissions` | TEXT[] | Fine-grained flags for `curator` accounts; admins are treated as having all |
| `is_active` | BOOLEAN | Soft-disable; inactive users cannot log in |
| `created_at` | TIMESTAMPTZ | |
| `last_login_at` | TIMESTAMPTZ | Updated on successful login |
First curator is bootstrapped on `npm run dev:migrate` when `users` is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set in env.
**Permission keys:** `images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`.
- **`admin`** — all curator tools + **Users** management (role bypasses permission checks).
- **`curator`** — only assigned permission flags.
- First account is bootstrapped as **admin** on `npm run dev:migrate` when `users` is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set. Manage additional accounts via the in-app **Users** page or `/api/users`.
### `curator_audit_log`
Append-only log of curator debug mutations (fix/clear/upload/delete, checkup flag changes).
Append-only log of staff mutations (fix/clear/upload/delete, checkup flags, translations, influences, tours, user management).
| Column | Type | Notes |
|--------|------|-------|
| `id` | BIGSERIAL PK | |
| `user_id` | FK → `users` | Who performed the action |
| `action` | VARCHAR(64) | e.g. `painting.fix_image`, `artist.upload_portrait` |
| `resource_type` | VARCHAR(32) | `painting` or `artist` |
| `action` | VARCHAR(64) | e.g. `painting.fix_image`, `user.create` |
| `resource_type` | VARCHAR(32) | `painting`, `artist`, `tour`, `user`, etc. |
| `resource_id` | INTEGER | Target row id |
| `details` | JSONB | Optional metadata (URL, mime type, flag values) |
| `ip_address` | VARCHAR(45) | Client IP (respects `TRUST_PROXY`) |
| `created_at` | TIMESTAMPTZ | |
**Logged `action` values:** `painting.fix_image`, `painting.clear_image`, `painting.upload_image`, `painting.delete`, `painting.checkup_flags`, `artist.fix_portrait`, `artist.clear_portrait`, `artist.upload_portrait`, `artist.checkup_flags`, `translation.upsert`, `translation.publish`, `influence.create`, `influence.update`, `influence.delete`, `influence.import`.
**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:
+29 -10
View File
@@ -23,7 +23,7 @@ Details: [environments.md](environments.md) · Deploy: [../infra/docker/DEPLOY-t
### Start — public dev (Keenetic URL)
```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
cd T:\Repo\Gallery
npm run dev:web
```
@@ -77,12 +77,13 @@ Stop-Process -Id <PID> -Force
## First-time install
```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
cd T:\Repo\Gallery
copy .env.example .env # edit DB credentials, PUBLIC_URL
npm install
cd client; npm install; cd ..
npm run dev:migrate # schema + incremental SQL (+ auth tables, bootstrap curator)
npm run dev:reset-curator # upsert curator password from .env CURATOR_* (clears sessions)
npm run dev:setup # migrate + seed (fresh empty DB only)
```
@@ -94,16 +95,23 @@ CURATOR_USERNAME=curator
CURATOR_PASSWORD=your-secure-password
```
Then open the gallery → **Curator login** (top-right) → use debug mode / Checkup / Translations / **Influences**. Mutations are logged in `curator_audit_log` (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**).
**Roles:**
| Role | Access |
|------|--------|
| Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios |
| Curator | Above + debug mode, Checkup, Translations, Influences (import/CRUD/graph), image fix/upload/delete APIs |
| Curator | Public browse + assigned permission flags (`images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`) |
| Admin | All curator tools + **Users** + **Activity** audit reports |
**Audit log (pgAdmin on `gallery_dev` or `gallery_prod`):**
**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.
**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
SELECT l.created_at, u.username, l.action, l.resource_type, l.resource_id
@@ -137,10 +145,10 @@ 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 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 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:schema` | Apply dev migrations to prod schema only (dev → prod) |
| `npm run harmonize:db` / `harmonize:images` | DB or image merge only |
| `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 dev:backfill-updated-at` | Backfill catalog `updated_at` from image mtimes (dev) |
**One-time split (recommended):** pgAdmin on dev PC → open [`db/split-dev-prod-pgadmin.sql`](../db/split-dev-prod-pgadmin.sql) → run each STEP on database `postgres`, then verify on `gallery_dev`.
@@ -152,7 +160,7 @@ DB_NAME=gallery_dev
PORT=3451
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
SESSION_SECRET=your-long-random-secret
SESSION_COOKIE_SECURE=false
# Omit SESSION_COOKIE_SECURE for auto (HTTPS via Keenetic → Secure cookie)
CURATOR_USERNAME=curator
CURATOR_PASSWORD=your-secure-password
```
@@ -210,7 +218,9 @@ npm run dev:discover-influences # discovery only, no curated insert
| `npm run dev:find-duplicates` | Duplicate / near-duplicate painting rows |
| `npm run dev:audit-influence-duplicates` | Duplicate influence edges |
| `npm run dev:analyze-painter-palette` | CSV ↔ artist name match report |
| `npm run dev:export-paintings` | Write `Output/paintings.csv` |
| `npm run dev:export-paintings` | Write `Output/paintings.csv` (gitignored folder) |
**Influence workbooks** (curator import): see [influence-import.md](influence-import.md) — e.g. `Inputs/gariff_influential_painters_influences.xlsx`, `Inputs/story_of_art_influences.xlsx`.
---
@@ -240,11 +250,20 @@ net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER
npm run devtoprod:release # full promote from infra/deploy/devtoprod.config.json
npm run devtoprod:thumbnails # rebuild thumb files + DB paths on dev (before backup)
npm run devtoprod:images # dev repo → TrueNAS (promote / first deploy)
npm run prodto:dev:images # TrueNAS → dev repo
npm run prodto:dev:images # TrueNAS → dev repo (all images)
npm run harmonize # bidirectional merge (newer wins) — see harmonize-dev-prod.md
npm run harmonize:dry-run # preview DB + image changes only
```
**One artist only (prod → dev):** after `net use`, robocopy the artist file prefix (example: Duccio):
```powershell
$src = "\\192.168.10.122\Gallery\data\images\paintings"
$dst = "T:\Repo\Gallery\data\images\paintings"
robocopy $src $dst "Duccio*" /XO /R:2 /W:3
robocopy "$src\thumbs" "$dst\thumbs" "Duccio*" /XO /R:2 /W:3
```
Type `yes` when prompted (or set `autoConfirm: true` in release config). Robocopy exit codes **07** = success. Deploy scripts print a final **`===== SUCCESS =====`** or **`===== FAILED =====`** banner.
---
+10 -2
View File
@@ -1,10 +1,18 @@
this file contains draft for future releases and features
## Standing requirements (do not regress)
- **Painting thumbnails:** any create/replace/clear of a paintings full picture must regenerate or remove its dedicated `paintings/thumbs/` file — never use the full image or a remote thumb URL as `thumbnail_path`. See [data-and-images.md — Painting thumbnail invariant](data-and-images.md#painting-thumbnail-invariant).
- **Staff auth:** mutating curator tools must check session + permission flags (`admin` bypasses flags); actions must log to `curator_audit_log` with `user_id`. See [basics.md — User roles](basics.md#user-roles-and-access) and [API.md — Authentication](API.md#authentication).
## Feature backlog
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)
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)
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`
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)
+106 -58
View File
@@ -6,10 +6,10 @@ Interactive virtual museum spanning art history: zoomable timeline with event gu
The app is organised as a **drill-down hierarchy**:
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries.
2. **Movement flow** — art movements as curved SVG streams on the same year axis; documented predecessor→successor branches; portrait thumbnails placed along each stream.
3. **3D gallery** — one personal hall per artist, a **movement gallery** (click a movement name), or a **guided tour** hall (timeline → **Tours**): period-themed or tour wings of up to ~55 works, side-wall hang, visit order left→right.
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography.
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries; classic left→right, vertical bottom→up, or **tree** bottom→up layout (header links).
2. **Movement flow** — art movements as SVG streams on the same year axis; documented predecessor→successor branches (classic); portrait thumbnails along each stream. The tree layout redraws the same lineage as a growing tree — see [movement-tree.md](movement-tree.md).
3. **3D gallery** — one personal hall per artist, a **movement gallery** (click a movement name → artist filter → hall), or a **guided tour** hall (timeline → **Tours**): period-themed or tour wings of up to ~55 works, U-shaped hang (left → end wall → right).
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **curator notes** and **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography.
5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**).
**Catalog search** — on the timeline home page, the header search bar (`CatalogSearchBar.tsx`) finds artists, paintings, and movements by name and metadata (year, movement, Wikipedia title). Type at least **2 characters** (300 ms debounce); results group into **Artists**, **Movements**, and **Paintings** with thumbnails. Keyboard: `↑`/`↓` to move, `Enter` to open, `Escape` to close. Choosing a result opens the artist gallery, movement gallery, or painting detail. Paintings opened from search show **← Back to Timeline** and return to the home timeline (full year range), not the previous view.
@@ -37,30 +37,38 @@ Gallery/
│ │ ├── components/GalleryLoadingMarker.tsx # Loading spinner overlay/banner (catalog, portraits, halls)
│ │ ├── components/GalleryWindows.tsx # Side-wall daylight windows (movement)
│ │ ├── components/HallPassage.tsx # Open archway between movement wings
│ │ ├── components/MovementHallDetails.tsx # Period architectural details
│ │ ├── components/MovementHallDetails.tsx # Period architecture dispatcher (Gothic + Byzantine reference)
│ │ ├── components/hall-details/ # One period interior per movement + shared primitives
│ │ ├── data/movement-interior-styles.ts # Per-movement interior themes
│ │ ├── utils/movementHallLayout.ts # Wing split + window gap placement
│ │ ├── utils/galleryProceduralTextures.ts # Hi-res wall/floor textures
│ │ ├── components/PaintingDetail.tsx # Detail view + debug panel
│ │ ├── components/ArtistBio.tsx # Biography + portrait debug panel
│ │ ├── components/DebugSearchResultsModal.tsx # “More” search picker (20 results)
│ │ ├── components/Timeline.tsx # Era bar, year ticks, event markers
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow
│ │ ├── components/Timeline.tsx # Classic horizontal era bar
│ │ ├── components/VerticalTimeline.tsx # Bottom-up vertical era rail
│ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches)
│ │ ├── components/VerticalMovementBands.tsx # Bottom-up movement streams
│ │ ├── components/MovementTree.tsx # Bottom-up movement tree (alternative start page)
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow
│ │ ├── components/CatalogSearchBar.tsx # Timeline header catalog search
│ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail
│ │ ├── pages/CheckupPage.tsx # Image audit table
│ │ ├── pages/TranslationsPage.tsx # Russian translation review
│ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard
│ │ ├── 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
│ │ ├── i18n/ # react-i18next bootstrap
│ │ ── locales/{en,ru}/ # UI chrome strings
│ │ ── locales/{en,ru}/ # UI chrome strings (incl. timeline layout + captions)
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor
│ │ ├── utils/debugMode.ts # Debug mode + “Show more” localStorage prefs
│ │ ── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
│ │ ── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
│ │ ├── utils/movementColor.ts # Shared vivid/shade hex helpers for all movement charts
│ │ └── utils/movementTree.ts # View-independent Tree of Art layout engine
│ └── dist/ # Production build (served by API when present)
├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools
│ ├── seed-wikipedia.js
@@ -76,8 +84,8 @@ Gallery/
│ ├── painter-palette-lib.js
│ ├── import-painter-palette.js
│ └── image-fetcher.js
├── Inputs/ # External datasets (e.g. PainterPalette.csv)
├── Output/ # Generated exports (e.g. paintings.csv)
├── Inputs/ # External datasets & influence workbooks (PainterPalette, book extracts, …)
├── Output/ # Generated exports (CSV dumps, scratch extracts) — gitignored
├── data/images/ # Local portraits and paintings (+ thumbs/)
├── db/ # schema.sql, setup-admin.sql, migrate-*.sql
├── server/migrate.js # npm run dev:migrate — schema + incremental migrations
@@ -168,15 +176,25 @@ Implementation: `goToTimelineHome()` in `HomePage.tsx` — do not use the browse
## Timeline and movement flow
The home page shows two linked views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`):
The home page shows linked era + movement views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`). Header links switch among three layouts; the active layout is also deep-linked:
| Layout | Era rail | Movement flow | Time direction | URL |
|--------|----------|---------------|----------------|-----|
| **Classic** (default) | `Timeline.tsx` (top bar) | `MovementBands.tsx` | Left → right | omit or `?layout=classic` |
| **Vertical** | `VerticalTimeline.tsx` (left rail) | `VerticalMovementBands.tsx` (streams + lineage; no portraits) | Bottom → top | `?layout=vertical` |
| **Tree of art** | `VerticalTimeline.tsx` (left rail) | `MovementTree.tsx` (lineage as a growing tree; no portraits) | Bottom → top | `?layout=tree` |
`HomePage` reads `?layout=` once on load and calls `history.replaceState` when the user switches. Classic clears the param so the default URL stays clean. Alias `horizontal` maps to classic.
| View | Component | Purpose |
|------|-----------|---------|
| Era bar | `Timeline.tsx` | Historical eras, major event markers, click-to-zoom |
| Movement flow | `MovementBands.tsx` | Curved streams per movement, lineage branches, artist portraits |
| Era bar / rail | `Timeline.tsx` / `VerticalTimeline.tsx` | Historical eras, major event markers, click-to-zoom |
| Movement flow | `MovementBands.tsx` / `VerticalMovementBands.tsx` / `MovementTree.tsx` | Streams or tree limbs per movement; classic also places artist portraits |
| Stream colours | `utils/movementColor.ts` | Shared `vividMovementColor` / `shadeMovementColor` for all three charts |
Both views share zoom/pan behaviour via `client/src/utils/timelineView.ts` (`zoomTimelineView`, `panTimelineView`, `chooseTimelineTickInterval`, `createViewChangeScheduler`). The home page uses a **fixed viewport** (`100vh`): timeline + movement flow sit in a shared `home-timeline-stack` so event guide lines can extend from the era bar down through the movement canvas. The movement flow compresses vertically so all movements in the visible year range fit without page scrolling.
Hint captions under each rail/chart (`captionClassicTimeline`, `captionClassicFlow`, `captionVerticalTimeline`, `captionVerticalFlow`, `captionTreeFlow`) live in `locales/{en,ru}/home.json` and follow the EN|RU toggle.
All three layouts share zoom/pan behaviour via `client/src/utils/timelineView.ts` (`zoomTimelineView`, `panTimelineView`, `chooseTimelineTickInterval`, `createViewChangeScheduler`). The home page uses a **fixed viewport** (`100vh`). Classic stacks timeline above movements; vertical and tree place the year rail beside the flow (`home-timeline-stack-vertical`). The tree layout keeps its horizontal geometry fixed across zoom — rules in [movement-tree.md](movement-tree.md).
### Catalog search (timeline header)
`CatalogSearchBar.tsx` calls `GET /api/search?q=…` (public, no login). The dropdown is stacked above the timeline (`z-index` on `.site-header`) so results are not hidden by movement bands.
@@ -209,9 +227,9 @@ Pan, zoom, and era/event click-to-zoom only update **local** `viewStart` / `view
|--------|------|
| Overlay **“Loading art history…”** | Until the first catalog fetch (`bounds` + `timeline` + `artists`) completes |
| Bottom banner **“Loading portraits…”** | While artist portrait thumbnails are still downloading on the movement flow (timeline stays interactive) |
| Overlay **“Opening artist/movement gallery…”** | Between clicking a portrait/movement and the 3D hall data being ready |
| Overlay **“Loading gallery…”** | While the 3D canvas initializes after the hall opens (center area; header and controls stay visible) |
| Overlay **“Loading paintings…”** | While wall textures are still downloading in the 3D hall |
| Overlay **“Opening artist/movement gallery…”** / **“Loading artists…”** | Between clicking a portrait/movement and the 3D hall (or artist-filter modal) data being ready |
| Overlay **“Loading gallery…”** | While the 3D canvas initializes and door/hall shaders warm up after the hall opens (HDR Environment and painting textures continue in the background) |
| Overlay **“Loading paintings…”** | Brief counter while wall painting textures start downloading; large halls (e.g. Byzantine) keep loading after the overlay dismisses — a slow image no longer permanently blanks the frame |
| Overlay **“Restoring gallery…”** | Briefly after WebGL context loss while the canvas remounts |
View updates are **batched to one commit per animation frame** via `createViewChangeScheduler()` in `timelineView.ts` (`HomePage.tsx``handleViewChange`), so rapid scroll-wheel events do not flood React with separate renders.
@@ -241,16 +259,17 @@ Major world events appear on the era bar as pin markers (single years) or shaded
### Movement flow
Each visible movement is drawn as a **portrait-width curved stream** (~54 px stroke, compressed when many rows are visible) from its start year to its end year.
Each visible movement is drawn as a **portrait-width curved stream** (~54 px stroke, compressed when many rows are visible) in a **solid vivid colour** from its start year to its end year.
| Feature | Implementation |
|---------|----------------|
| Lineage layout | `client/src/data/movement-lineage.ts` — curated predecessor→successor pairs (Met / ArtStory / museum essays); multiple parents allowed |
| Vertical depth | Successor movements sit on rows below their deepest parent; sibling movements at the same depth are spread into lanes to limit overlap |
| Branch connectors | Smooth curves from the **centre** of a parent stream to the **centre** of each child stream (siblings fan out along the parents length) |
| Visual blending | Path-aligned SVG gradients with transparent fades at stream ends and branch junctions; streams draw on top of branches so overlap brightness stays uniform |
| Vertical lanes | Movements share a horizontal lane only when one ends at least **10 years** before the next starts (in the current zoom); closer or overlapping spans stack into extra rows (`assignTemporalLanes`). Lane choice **minimizes vertical branch length**: children prefer parent lanes (same row when years allow), corridor clearing (up to 20 passes) evicts unrelated streams preferring **rows above** the parent/child strip, then re-attracts the child toward the parent |
| Branch connectors | Smooth curves from fan-out points along a parent stream to the **left edge (start)** of each child stream; origin X is always strictly left of the target (time-forward only — never right→left); stroke thickness matches the **target** (child) band height; color gradients from parent → child at constant opacity; a stream-shaped mask hides branch ink under movements so translucent overlaps do not brighten the bands; pan/zoom layout changes **animate** (streams + branches chase new geometry) so shifts stay trackable |
| Band thickness | Stream height is **proportional to `influence_link_count`** (edges on paintings by artists in that movement): thicker bands for denser influence graphs; lane rows share vertical space weighted by the thickest band in each lane |
| Filtering | A movement is drawn when its **span overlaps** the visible year range **and** it has at least one catalogued artist — artists whose lifespan falls outside the window still keep their movement visible (their portraits simply do not render). Filtered client-side after initial load |
| Viewport layout | Row height and stream width scale from measured canvas size so every visible movement row fits in the remaining screen space |
| Name labels | On-band **movement name** only. Label placement priority: **(1)** center of movement if label fits and no portrait collision; **(2)** right end inside band borders; **(3)** overflow right or left (up to 100% outside the band border), choosing the side with less transition-line overlap; **(4)** adjacent free gap before/after the band; **(5)** hidden until hover. Collision checks exclude the movement's own portraits (labels may overlap their own band's artists). Movements with overflow labels are packed onto **exclusive horizontal lanes** before vertical stacking so they never share a row with other movements. Minimum movement height = 125% of label height |
### Artists on movement streams
@@ -271,9 +290,9 @@ Each artist appears as a **portrait circle** on their movements stream row:
| Scroll wheel anywhere on flow canvas | Zoom (same year range as timeline; works over portraits and labels too) |
| Drag on flow canvas | Pan |
| Click portrait | Open artist biography |
| Click **movement name** (label on stream) | Open **movement gallery** for that movement |
| Click **movement name** (label on stream) | Open the **artist filter** modal for that movement, then the movement gallery |
**Zoom/pan performance:** The movement canvas uses a **capture-phase** wheel listener so scroll zoom works even when the cursor is over a portrait. While scrolling or dragging, a short **interaction mode** (~120 ms after the last input) draws simplified solid SVG strokes and temporarily hides portrait/label DOM so zoom stays responsive; full gradients and portraits return when you stop.
**Zoom/pan performance:** The movement canvas uses a **capture-phase** wheel listener so scroll zoom works even when the cursor is over a portrait. While scrolling or dragging, a short **interaction mode** (~120 ms after the last input) draws simplified solid SVG strokes and temporarily hides portrait/label DOM so zoom stays responsive; full stream styling and portraits return when you stop.
Only **mousedown** on portraits and movement labels stops propagation (so drag-to-pan does not start when clicking them). Hovering a portrait highlights the artists lifespan on the era bar and brightens their segment on the movement stream.
@@ -283,7 +302,7 @@ Only **mousedown** on portraits and movement labels stops propagation (so drag-t
The 3D scene supports three modes in `VirtualGallery.tsx`: **artist halls** (personal catalog), **movement galleries** (full movement collection, chronological), and **guided tours** (curator-ordered stops — [tours.md](tours.md)).
**Shared wall hang (all modes):** visit order fills the **left wall first**, then the **right**. The **first** work hangs near the entrance on the left (immediately left of the opening view); the **last** hangs near the entrance on the right. Artist halls use chronological order; movement wings use chronological order within each wing; tours use stop order.
**Shared wall hang (all modes):** visit order is a **U-shape****left wall**, then the **far/end wall** ahead when entering, then the **right wall**. The **first** work hangs near the entrance on the left (immediately left of the opening view); the **last** hangs near the entrance on the right. With fewer than three works, only left/right are used. Artist halls use chronological order; movement wings use chronological order within each wing; tours use stop order.
### Artist halls
@@ -293,15 +312,17 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi
|------|----------------|
| One hall per artist | `VirtualGallery.tsx` builds a single room from that artists paintings |
| Catalog depth | Most artists target **≥ 6** notable works via `npm run dev:expand-catalog` and `famous-paintings-data.js`; some masters have larger museum dumps |
| Paintings on walls | Works hang on the **left and right** walls in **one row per wall**; room **depth grows** when the catalog is large (back wall is for the exit only) |
| Wall order | Chronological visit order: first half on the **left** (entrance → back), second half on the **right** (back → entrance); first work left of the opening view, last on the right |
| Paintings on walls | Works hang on **left**, **far/end**, and **right** walls in **one row per wall**; room **depth** and **width** grow with the catalog |
| Wall order | Chronological U-path: first third **left** (entrance → end), middle third **end wall**, last third **right** (end → entrance) |
| Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) |
| 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** |
| Influence lamps | A golden picture light appears **above frames** whose work has any influence-graph edge (`has_influence_links` from the API) |
| 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` |
| 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 columns, pedestals, or other centre objects |
| Open centre | Floor and ceiling only — no freestanding columns or pedestals in the walkway |
| Museum exit | Front-wall **double doors** with transom, brass hardware, sconces, marble threshold, and warm vestibule glow |
| Shadows | **Disabled** — no Canvas shadow maps / `castShadow` (performance; flat lighting only) |
| Hall-to-hall travel | Exit opens a panel: **Predecessors** (left) and **Successors** (right), each grouped by art movement |
| Missing images | Works without a local file show a **draped canvas cover** in the frame (not a blank white rectangle) |
| Detail view return | Opening a painting close-up **keeps the 3D hall mounted** in the background so position and view direction are preserved when you go back |
@@ -323,23 +344,27 @@ Predecessors and successors come from **`painting_influence_sources`** (painting
### Movement galleries
Enter from the home page by clicking a **movement name** on the movement flow (`MovementBands.tsx``GET /api/movements/:id/gallery`).
Enter from the home page by clicking a **movement name** on the movement flow. First a centered **artist filter** modal (`ArtistFilterModal.tsx`) loads `GET /api/movements/:id/artists-summary` (portrait, lifespan, painting count). All artists are selected by default; deselect any to exclude their works, then **Enter gallery**. The client loads `GET /api/movements/:id/gallery` and filters paintings to the selected artist IDs before opening `VirtualGallery`.
| Rule | Implementation |
|------|----------------|
| One gallery per movement | All paintings by artists in that movement, sorted chronologically |
| One gallery per movement | Paintings by **selected** artists in that movement, sorted chronologically |
| Wings | Catalog split into wings of up to **55 works** (`movementHallLayout.ts`); large movements (e.g. Baroque) use multiple wings |
| Paintings on walls | **Left and right walls only** — back wall reserved for exit, front for passage to the next wing |
| Wall order | Same shared hang as artist halls: first half left (entrance → back), second half right (back → entrance) |
| Paintings on walls | **Left, end, and right** — single-wing halls keep the far wall solid (full span); multi-wing halls hang end-wall works on panels beside the back exit |
| Wall order | Same U-shaped hang as artist halls (left → end → right) |
| 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, NYC loft, white cube, etc.) |
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco — 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 |
| 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 architecture | Each style names a `details` kind (26 of them) that selects one component under `components/hall-details/` — the hall is built from the architecture of the **same period as the movement** (Roman entablature, Brunelleschi arcade, Flemish beam ceiling, Horta whiplash brackets, Rodchenko lattice, 1960s troffer grid). Gothic and Byzantine remain the reference implementations in `MovementHallDetails.tsx`; the geometry budget every module works to is documented in `hall-details/geometry.ts` |
| 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 |
| Windows | **Side walls only** — placed in gaps between frames (high on the wall, no overlap with paintings); style matches the movement era |
| Lighting | Daylight from windows + ceiling track lights + ambient/sun fill |
| Back wall | **Exit double doors****Wing navigator** (jump to any wing) or **Exit to Timeline** |
| Front wall | Open **“Next wing →”** archway when a later wing exists; walk through or press `E` when near |
| Influence lamps | Same golden lamps as artist halls when `has_influence_links` is true |
| 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 |
| Period details | `MovementHallDetails.tsx` dispatches to one module per movement. `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. Every other movement follows the same rules in `components/hall-details/`: orders and piers stay **flush to the side walls** (≤ 0.17 m proud), freestanding masses only in the corner pockets, and no more than two added lights per hall — see `hall-details/geometry.ts` |
| Back wall | Single wing: solid display wall. Multi-wing: **Exit double doors****Wing navigator** or **Exit to Timeline** |
| Front / entrance wall | Single wing: **Exit double doors** (leave the way you entered). Multi-wing: **“Next wing →”** archway when a later wing exists |
| Influence lamps | Same golden upside-down emissive fixtures as artist halls when `has_influence_links` is true (shared rail: 40 cm above the tallest frame in the wing) |
| Missing images | Draped canvas cover in frame |
| Detail return | **Back to Gallery** from painting detail returns to the same wing with camera preserved; **Back to Timeline** exits the hall entirely |
@@ -349,8 +374,8 @@ Enter from the home page by clicking a **movement name** on the movement flow (`
|-------|--------|
| Walk / turn / drag | Same as artist hall |
| Click painting | Open detail view (returns to the same wing) |
| Back wall / `E` / **Wings / Exit →** | Open wing navigator |
| Front archway / `E` (when near) | Advance to the **next chronological wing** |
| Entrance / back door / `E` / **Wings / Exit →** | Open wing navigator (or exit on single-wing halls) |
| Front archway / `E` (when near, multi-wing) | Advance to the **next chronological wing** |
Movement galleries do **not** use the predecessor/successor influence picker — that remains artist-hall only.
@@ -362,7 +387,7 @@ Enter from the home page **Tours** popup (`GET /api/tours/:id`). Layout reuses t
|------|----------------|
| Visit order | Curator `sort_order` on `tour_stops` (not chronological) |
| Wings | Same ~55-per-wing split as movements; order preserved across wings |
| Wall hang | Same left-then-right rule as other halls |
| Wall hang | Same U-shaped hang as other halls (left → end → right) |
| Detail | Tour stop text panel; walks tour stops |
| Exit | Wing navigator / **Exit to Timeline** (no influence picker) |
@@ -370,7 +395,21 @@ Full guide: [tours.md](tours.md).
### Shared 3D behaviour
**3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; the client calls `POST /api/artists/:id/preload-images` automatically when entering an **artist** hall (public route — links disk files only). Movement galleries load painting lists from the API without a separate preload step. While a texture is loading, the frame shows the canvas cover instead of a white placeholder. The center of the hall shows **“Loading gallery…”** until the WebGL canvas is ready, then **“Loading paintings…”** until every wall texture has resolved (tracked through `GalleryTextureLoadContext`). The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again.
**Light budget (critical):** Three.js forward shading cannot compile `MeshStandardMaterial` with dozens of dynamic lights. Large wings used to add **one `spotLight` per painting** (plus influence lamps), which pushed the scene to ~100200 lights — walls, floor, and ceiling then rendered as a black void while painting canvases (`MeshBasicMaterial`) still showed. Halls therefore use **shared lighting only**: ambient / hemisphere / directional fill, a small set of ceiling track spots (`GalleryTrackLights`, capped), and **one** window spot each. Painting canvases stay unlit (`MeshBasicMaterial`); frames and influence fixtures use **emissive** accents instead of per-frame lights. Intensities are scaled for three.js physical lights (post-r155).
Shared lights carry a **brightness floor** so dark period styles never render as a black void. That floor is high enough to blow out a pale wall, so **`lightScale`** in `movement-interior-styles.ts` is the knob that holds a hall back — it multiplies the scene ambient/hemisphere/directional/fill lights, the hall point lights, the ceiling track spots, and the HDR `environmentIntensity`. Window fill lights are deliberately **not** scaled, so daylight shafts still read against the darker room.
It defaults to **1**, but most halls now set it: Gothic uses **0.26**, Byzantine **0.30**, and the 19 formerly pale interiors (Roman through Pop Art) also use **0.30**. Only the five halls that are already dark by albedo — Symbolist, Expressionist, Surrealist, Baroque, Romantic — stay at 1, since scaling their lights would crush walls that already sit at 0.150.27 luminance.
**Wall brightness is albedo × `lightScale`.** Gallery walls are authored well below white for that reason: the reworked interiors sit around **0.500.56** wall luminance (0.66 for the two white-cube halls), landing at an effective 0.140.20 — the range Gothic and Byzantine established. Reach for `lightScale` before darkening a tint further; the two multiply, and dimming the hall dims the paintings with it.
**Period architecture:** Freestanding columns must not sit in the walkway or in the corner pocket in front of frames (hang margin is only ~0.7 m from each wall end). Classical / neoclassical details use **engaged corner pilasters** flush to the walls (`MovementHallDetails.tsx`).
**Surface textures:** Procedural colour maps use sRGB; **normal maps use `NoColorSpace`** (`galleryProceduralTextures.ts`). Wrong colour space on normals breaks wall shading.
**3D images** prefer local **thumbnail** files (`galleryImageUrlCandidates` in `client/src/api/client.ts`: thumb → `GET /api/paintings/:id/image?size=thumb`; full originals are **not** used for hall frames — they can be tens of MB and made large halls take ~1 minute). Remote Wikipedia fetches are too slow for realtime WebGL textures. Entering an **artist** or **movement** hall fires `POST /api/artists/:id/preload-images` or `POST /api/movements/:id/preload-images` in the **background** (does not block hall open). Texture downloads are **queued** (max 8 parallel). While a texture is loading, the frame shows the canvas cover instead of a white placeholder. A per-image deadline only releases the boot overlay counter — it does **not** permanently blank the frame if the download finishes later.
**Boot overlay** (`VirtualGallery.tsx`): the center shows **“Loading gallery…”** until the WebGL canvas is ready and a short `gl.compileAsync` warm-up finishes (so entrance doors / passages do not hitch on the first turn). HDR `Environment` loads in a Suspense boundary **without** blocking the overlay. Painting textures keep loading in the background. The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again.
**WebGL context-loss recovery:** on some GPUs/drivers (notably certain Chrome setups) the browser can drop the WebGL context right after entering a hall, which would otherwise leave a permanent dark window. `VirtualGallery.tsx` listens for `webglcontextlost` / `webglcontextrestored`, calls `preventDefault()` so the browser can restore the context, and remounts the `<Canvas>` with a fresh context (a **“Restoring gallery…”** overlay shows briefly). The network-loaded HDR `Environment` map is wrapped in an error boundary so, if it fails to load, the hall still renders without reflections instead of unmounting the whole scene.
@@ -382,6 +421,7 @@ Opened from the 3D hall (artist, movement, or tour wing — click a frame) or fr
|-------|----------------|
| **Detail** | Centre image, *Influenced By* (left) and *Influenced* (right) — painting thumbnails (full work visible, letterboxed), artist portraits, or movement swatches — position in catalog (e.g. `3 of 12`) |
| **Tour notes** | When opened from a guided tour: stop text panel under the image (English body from `tour_stops`) |
| **Curator notes** | Public editorial text on the painting (`paintings.curator_notes`); visitors see it when non-empty; curators edit inline on the detail page |
| **Art history notes** | Numbered markers on the image (when positioned) plus a note list below — short citations from Gombrich, museum catalogs, Wikipedia, etc. (`painting_annotations` table) |
| **Fullscreen** | Click the centre image; `Escape` or click anywhere to return to detail only |
@@ -425,10 +465,10 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens
- **Guided tours** add curator-ordered winged halls with stop text on painting detail — [tours.md](tours.md).
- **Wall hang** is shared: first work on the left at the entrance, last on the right.
- **Influence-based hall links** connect artists through documented painting relationships, grouped by movement at the exit.
- **3D gallery images** use locally cached files only; slow remote fetches would break realtime rendering. The client calls `POST /api/artists/:id/preload-images` automatically when entering an **artist** hall (public route — links disk files only).
- **3D gallery images** use local **thumbnails** for hall frames (not multi-MB originals). `POST /api/artists/:id/preload-images` / `POST /api/movements/:id/preload-images` run in the background when entering a hall (public routes — link disk files and regenerate missing thumbs).
- **3D gallery session** stays mounted while painting detail or bio overlays are open; returning to the hall remounts the WebGL canvas when it becomes active again.
- **3D gallery resilience:** a lost WebGL context is recovered by remounting the canvas with a fresh context (rather than showing a dark window), and the HDR environment map is isolated behind an error boundary so its failure never blanks the scene.
- **Loading feedback:** `GalleryLoadingMarker` surfaces catalog load, portrait download, gallery entry, painting-texture load, and context-restore states so the user always knows work is still in progress.
- **Loading feedback:** `GalleryLoadingMarker` surfaces catalog load, portrait download, gallery entry, painting-texture / GPU warm-up, Environment settle, and context-restore states so the user always knows work is still in progress.
- **Influence data** is stored in **`painting_influence_sources`** (directed links from paintings to source paintings, artists, or movements), with optional period fields and citation metadata. Sources include curated scholarship (`art-influences-data.js`) and **PainterPalette** (`discovered_via = painter-palette`). Legacy `painting_influences` mirrors painting-to-painting edges for scripts only.
## User roles and access
@@ -436,28 +476,35 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens
| Role | Who | Can do |
|------|-----|--------|
| **`user`** | Anonymous visitor (default) | Browse timeline, movement flow, 3D artist/movement halls, painting detail, artist bios, images |
| **`curator`** | Named account (`users` table) | Everything above + **Debug mode**, **Checkup**, **Translations**, **Influences**, **Tour editor**, debug API mutations |
| **`curator`** | Named staff account | Public browse + tools allowed by their **permission flags** |
| **`admin`** | Named staff account | All curator tools + **Users** management + **Activity** audit reports |
Curators sign in via **Curator login** in the site header. Sessions use an HTTP-only cookie (`gallery.sid`). The UI hides debug controls from guests; the server enforces the same rules on debug/checkup API routes (`401` without a valid session).
**Permission flags:** `images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`. Admins always have every flag.
Mutating debug actions (fix/clear/upload/delete, checkup flag changes) are appended to **`curator_audit_log`** with username, action, target id, optional JSON details, and client IP. Query in pgAdmin — see [DB_structure.md](DB_structure.md#curator_audit_log).
Staff sign in via **Curator login** in the site header (individual username/password). Sessions use an HTTP-only cookie (`gallery.sid`). The UI shows only tools the account may use; the server enforces the same rules (`401` without a session, `403` without permission).
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. 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)
Curator-only workflow for reviewing and fixing local image files — not part of the public visitor experience.
Staff workflow for reviewing and fixing local image files (requires **`images`** permission) — not part of the public visitor experience.
| Feature | Where | Purpose |
|---------|--------|---------|
| **Catalog search** | Timeline header (all visitors) | Find artists, paintings, movements; `GET /api/search`; navigate to gallery or detail |
| **Curator login** | Home header (guests) | Username + password modal; unlocks debug tools |
| **Debug mode** | Home header toggle (curators only) | Persists in `localStorage`; enables debug panel on painting detail and artist bio |
| **Show more** | Home header checkbox (curators, when debug on) | Auto-opens the **More** modal on each painting / bio page load |
| **Checkup page** | Home header → **Checkup** (curators only) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags |
| **Translations** | Home header → **Translations** (curators only) | Review/publish Russian `entity_translations` |
| **Influences** | Home header → **Influences** (curators only) | List/CRUD influence edges, CSV/JSON/XLSX import wizard, neighborhood graph — [influence-import.md](influence-import.md) |
| **Tour editor** | Home header → **Tour editor** (curators only) | Create/publish guided tours and stop text — [tours.md](tours.md) |
| **Curator login** | Home header (guests) | Username + password modal; unlocks permitted tools |
| **Debug mode** | Home header toggle (`images`) | Persists in `localStorage`; enables debug panel on painting detail and artist bio |
| **Show more** | Home header checkbox (`images`, when debug on) | Auto-opens the **More** modal on each painting / bio page load |
| **Checkup page** | Home header → **Checkup** (`checkup`) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags |
| **Translations** | Home header → **Translations** (`translations`) | Review/publish Russian `entity_translations` |
| **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) |
| **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) |
| **Logout** | Home header (curators) | Ends session; hides debug 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 and artist bio)
@@ -506,3 +553,4 @@ See [API.md](API.md#authentication) and [data-and-images.md](data-and-images.md#
| [tours.md](tours.md) | Guided tours — editor, public popup, 3D tour hall |
| [i18n-russian.md](i18n-russian.md) | Russian UI + entity_translations |
| [data-and-images.md](data-and-images.md) | Image pipeline and seeding |
| [ui-interaction-and-component-standards.md](ui-interaction-and-component-standards.md) | UI interaction, navigation, and component standards |
+73 -12
View File
@@ -7,6 +7,21 @@ How catalog content, biographies, and artwork files enter the system.
1. **No runtime hot-linking** — the UI reads from `/images/…` (local disk). External URLs are used only during ingest.
2. **No AI-generated art or text** — biographies and descriptions come from Wikipedia; influence notes from curated art-history sources.
3. **Local copies** — every displayed image should exist under `data/images/` after seeding or fetch.
4. **Painting thumbnails must stay in sync with the full image** — see [Painting thumbnail invariant](#painting-thumbnail-invariant) below. Treat this as a hard requirement for any new feature or script that writes or replaces painting picture files.
## Painting thumbnail invariant
**Requirement (do not regress):** any action that **creates, replaces, or clears** a paintings full picture must also **regenerate or remove** that paintings dedicated thumbnail under `paintings/thumbs/`. Never point `thumbnail_path` at the full-size file as a stand-in, and never reuse a search-result or Commons thumb URL.
| Change type | Expected thumb behavior | Primary code |
|-------------|-------------------------|--------------|
| Fix / upload / buffer or URL replace | Write full file, then `writePaintingThumb` / `generateThumbnailFromFull` | `server/image-service.js` |
| Fetch / seed / expand / influence ingest | Same: thumb from saved full via `savePaintingImages` | `scripts/image-fetcher.js` |
| Disk sync / preload / ensure when full exists but thumb missing | Generate real `*_thumb.jpg` and update `thumbnail_path` | `ensurePaintingThumbFromFull`, `syncPaintingFromDisk`, `ensurePaintingImages`, `preloadArtistImagesLocal`, `preloadMovementImagesLocal` |
| Clear image / delete painting | Delete thumb file(s) and null paths (or drop row) | `clearPaintingImage`, `deletePainting` |
| Bulk rebuild after sync/harmonize | `regenerate-thumbnails.js` / `harmonize:images` | scripts + npm scripts |
Path-only maintenance (`sync-image-paths.js`) may link existing thumb files without rewriting pixels; if full images changed without a matching thumb step, run `npm run dev:regenerate-thumbnails` (or `harmonize:images`).
## Directory layout
@@ -31,7 +46,7 @@ File names are sanitised `{Artist}_{Title}.{ext}`. The image service can redisco
| **Development** | `./data/images/` in repo | Working copy on dev PC |
| **Production** | `/mnt/BasePool/Applications/Gallery/data/images` on TrueNAS | SMB `\\192.168.10.122\Gallery\data\images` |
**One-direction promote:** `npm run devtoprod:images` (dev → prod, skip older). **Refresh dev from prod:** `npm run prodto:dev:images`. **Bidirectional merge** (newer file wins): `npm run harmonize:images` or full `npm run harmonize` — see [harmonize-dev-prod.md](harmonize-dev-prod.md). General sync reference: [environments.md](environments.md).
**One-direction promote:** `npm run devtoprod:images` (dev → prod, skip older). **Refresh all images on dev from prod:** `npm run prodto:dev:images`. **One artist only:** map the SMB share, then `robocopy` that artists `ArtistName*` files under `paintings/` and `paintings/thumbs/` (see [FAC.md](FAC.md#dev--prod-image-sync-smb)). **Bidirectional merge** (newer file wins, then merge artists/paintings checkup flags + image paths, then rebuild painting + portrait thumbs on both sides): `npm run harmonize:images` or full `npm run harmonize` — see [harmonize-dev-prod.md](harmonize-dev-prod.md). General sync reference: [environments.md](environments.md).
## Scripts overview
@@ -49,7 +64,7 @@ File names are sanitised `{Artist}_{Title}.{ext}`. The image service can redisco
| `fetch-missing-images.js` | `npm run dev:fetch-images` | Downloads files for paintings missing on disk |
| `image-fetcher.js` | *(library)* | Wikimedia / museum resolution used by fetch scripts and API |
| `sync-images-to-prod.ps1` / `sync-images-from-prod.ps1` | `npm run devtoprod:images` / `npm run prodto:dev:images` | Robocopy via SMB `\\192.168.10.122\Gallery` |
| `harmonize-db.js` / `harmonize-images.js` | `npm run harmonize:db` / `harmonize:images` | Bidirectional merge by `updated_at` / file mtime |
| `harmonize-db.js` / `harmonize-images.js` | `npm run harmonize:db` / `harmonize:images` | Bidirectional merge by `updated_at` / file mtime; images step also merges artists/paintings checkup flags + paths, then rebuilds thumbs on both sides |
| `harmonize.ps1` | `npm run harmonize` | Orchestrator: backups, optional schema, DB + image merge |
| `regenerate-thumbnails.js` | `npm run dev:regenerate-thumbnails` | Rebuild painting thumbs from full images via `sharp` |
| `regenerate-portrait-thumbs.js` | `npm run dev:regenerate-portrait-thumbs` | Rebuild timeline portrait thumbs (~256px) and set `portrait_thumb_path` |
@@ -183,7 +198,7 @@ Influence data drives:
- **Painting detail** — *Influenced By* (left) and *Influenced* (right) panels: painting thumbnails, artist portraits, or movement colour swatches, plus notes, aspects, period labels, and citations
- **3D hall exit** — predecessor and successor artists grouped by movement (from painting and artist sources)
- **3D gallery lamps** — golden picture light above frames with any influence edge (`has_influence_links` on painting API responses)
- **3D gallery lamps** — golden picture light above frames with any influence edge (`has_influence_links` on painting API responses); rendered upside down in `InfluencePictureLamp`
### Audit duplicate influence links
@@ -263,9 +278,40 @@ To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the cli
## 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 (columns, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts`.
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 the `details` kind that selects its architecture. Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts` (colour maps in sRGB, normal maps in linear/`NoColorSpace`).
Wing layout (up to 55 works per wing, side-wall-only hang, window gap placement) lives in `client/src/utils/movementHallLayout.ts`. Visit order fills the **left wall** (first work at the entrance), then the **right** (last work at the entrance). The same hang applies to artist halls and guided tours. To change a movements look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
### Period architecture per movement
Every movement is built from the architecture of **its own period** — no two movements share an interior. `client/src/components/MovementHallDetails.tsx` dispatches on `style.details` (`MovementDetailKind`, 26 values) to one component:
| Module | Movements |
|--------|-----------|
| `MovementHallDetails.tsx` (reference implementations) | `gothic` — bay-spaced compound piers, vault springers, transverse ribs · `byzantine` — porphyry colonnettes, basket capitals, revetment dado, hanging polycandela |
| `hall-details/classical.tsx` | `roman` (Tuscan half-columns, dentil entablature, Pompeian socle) · `quattrocento` (pietra serena pilasters, blind arcade, tondi) · `cinquecento` (paired fluted Corinthian pilasters, Pantheon coffers) |
| `hall-details/courtly.tsx` | `flemish` (linenfold wainscot, beam-and-corbel ceiling) · `mannerist` (herm pilasters, banded rustication, broken cornice) · `baroque` (solomonic corner columns, modillions, velvet field panels) · `rococo` (boiserie, rocaille cartouches, painted cove) · `neoclassical` (fluted Ionic order, meander frieze) |
| `hall-details/nineteenth.tsx` | `gothic-revival` · `bourgeois` · `north-light` · `paris-atelier` · `symbolist` · `art-nouveau` |
| `hall-details/modernism.tsx` | `fauvist` · `expressionist` · `cubist-atelier` · `futurist` · `suprematist` · `constructivist` · `dada` · `surrealist` · `loft` · `white-cube` |
`hall-details/primitives.tsx` holds the wall-flush building blocks (`WallBand`, `CorniceRing`, `FlutedShaft`, capitals, `CofferedCeiling`, `HangingFixture`, `WallSconce`); `hall-details/geometry.ts` holds the shared hooks and the **geometry budget** every module has to obey, because frames hang 0.23 m proud of the wall face with a 0.7 m clear margin at each wall end:
- relief below the frame line stays within **0.11 m** proud; friezes and cornices above 2.6 m within **0.17 m**
- freestanding masses only inside the **0.34 m corner pocket**; nothing under 2.6 m within 1.45 m of a wall centre (doorways)
- the end wall carries the hall title between **3.4 m and 3.9 m** — keep it clear
- side-wall windows are placed at runtime between 2.4 m and 3.9 m, so panels, arches and roundels up there are filtered through **`useWallClearance`** (the hall passes its computed windows into `MovementHallDetails`)
- at most **two added lights per hall**, so hall materials stay inside the WebGL light limits the shared track lighting already budgets for
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.
### Wall brightness
Halls are lit by a shared rig with a high brightness floor, so a pale wall tint clips to flat white and loses its texture. Two settings control this together, and they **multiply**:
- **tints** — gallery walls are authored around **0.500.56** relative luminance (0.66 for the Suprematist and Pop white cubes, 0.150.27 for the deliberately dark halls). Nothing sits near white.
- **`lightScale`** — Gothic **0.26**, Byzantine **0.30**, and every formerly pale interior **0.30**; the already-dark halls keep **1**. Window fills are not scaled, so daylight shafts stay bright.
When a hall reads too bright, prefer `lightScale`: it keeps the material colour believable (Byzantine's warm stone is a genuine `#a89880`, simply lit dimly) and avoids greying tints toward neutral. Remember that dimming a hall dims its paintings too — the lighting is shared, with no per-painting spots.
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.
## Historical event markers (frontend timeline)
@@ -282,9 +328,22 @@ Wing layout (up to 55 works per wing, side-wall-only hang, window gap placement)
Edit `HISTORICAL_EVENTS` and rebuild the client to extend the set.
## Curator notes (painting editorial text)
Public notes authored by curators on a painting — separate from Wikipedia `description`, tour stop text, and art-history annotations.
| Aspect | Detail |
|--------|--------|
| Storage | `paintings.curator_notes` (`TEXT NOT NULL DEFAULT ''`) |
| Migration | `db/migrate-curator-notes.sql` via `npm run dev:migrate` |
| UI | Panel on painting detail (after tour notes, before description); curators edit inline when signed in |
| Hall marker | Small brass plate beneath the frame in the 3D hall when notes are non-empty |
| API | Included on `GET /api/paintings/:id` (`p.*`); update via `PATCH /api/paintings/:id/curator-notes` (curator) |
| i18n | Canonical English only for now (not in the translations pipeline) |
## Painting annotations (art-history notes)
Short curator-style notes on the painting detail page — separate from the influence graph.
Short art-history citations on the painting detail page — separate from the influence graph and from `curator_notes`.
| Aspect | Detail |
|--------|--------|
@@ -351,17 +410,17 @@ When a painting has no local file, `GET /api/paintings/:id/image` triggers `ensu
- **Wikipedia search** when catalog labels fail
- Wikidata → Wikimedia Commons → Wikipedia page image
- Fallbacks: Met Museum, Art Institute of Chicago, Cleveland Museum, Rijksmuseum, Smithsonian*, Harvard*
4. Save full image, **generate thumbnail by resizing the full file** (not a separate Commons thumb URL), update DB, serve file.
4. Save full image, **generate thumbnail by resizing the full file** (not a separate Commons thumb URL), update DB, serve file. If a full file already exists but a dedicated thumb under `paintings/thumbs/` is missing, regenerate the thumb (`ensurePaintingThumbFromFull`) rather than serving the full image as a thumb.
Separate Wikipedia/Commons thumbnail URLs often resolve to the **wrong work** (e.g. a different painting with a similar title). Thumbnails are always derived locally from the downloaded full image via `sharp` in `scripts/image-fetcher.js`.
Separate Wikipedia/Commons thumbnail URLs often resolve to the **wrong work** (e.g. a different painting with a similar title). Thumbnails are always derived locally from the downloaded full image via `sharp` in `scripts/image-fetcher.js`. See [Painting thumbnail invariant](#painting-thumbnail-invariant).
Requests are deduplicated (`inflight` map) and timeout after 15 seconds. On-demand resolution uses a ~2.5 s delay between external requests to reduce rate-limit risk; batch `fetch-images` runs skip that delay while the per-painting deadline is active.
## Preload before 3D gallery
`POST /api/artists/:id/preload-images` is a **public** route (no curator login). It runs **local-only** linking — no network. The React client calls it automatically when entering an **artists** 3D hall so textures use files already on disk.
`POST /api/artists/:id/preload-images` and `POST /api/movements/:id/preload-images` are **public** routes (no curator login). They run **local-only** linking — no network — and regenerate missing `*_thumb.jpg` files from full images on disk. The React client calls them automatically when entering an artist or movement 3D hall so textures use thumbs already on disk.
**Movement galleries** (`GET /api/movements/:id/gallery`) do not use preload — they load the full painting list from the API and resolve local paths the same way as artist halls. Works without files still show the canvas cover in the frame.
Works without files still show the canvas cover in the frame.
The 3D scene uses `galleryImageUrl()`, which never hits the on-demand API (remote latency breaks WebGL texture loading).
@@ -421,13 +480,15 @@ Re-run `import-painter-palette` after adding gallery artists or updating the CSV
## Catalog export
Export the full painting catalog as CSV:
Export helpers write under **`Output/`** (gitignored — regenerate as needed):
```bash
npm run dev:export-paintings
```
Writes **`Output/paintings.csv`** with columns `artist`, `painting`, `year` (sorted by artist, year, title). The `Output/` folder is git-ignored by convention; regenerate after catalog changes.
Writes **`Output/paintings.csv`** with columns `artist`, `painting`, `year` (sorted by artist, year, title).
Ad-hoc **prod** dumps (movements / artists / paintings) can also be written to `Output/*.csv` for offline review. Influence workbooks ready for the curator import wizard belong under **`Inputs/`** (e.g. Gariff, Story of Art) — see [influence-import.md](influence-import.md).
## Image fetcher overrides
+5 -4
View File
@@ -16,7 +16,7 @@ Step-by-step guide for promoting the **development** version of Gallery to **pro
| URL | https://devgallery.mysuperlab.netcraze.pro | https://gallery.mysuperlab.netcraze.pro |
| Env file | root [`.env`](../.env) (`gallery_dev`) | [`infra/docker/.env.prod`](../infra/docker/.env.prod) (`gallery_prod`) |
All commands run on the **dev PC** from the repo root (`C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery`) unless a step says **TrueNAS**.
All commands run on the **dev PC** from the repo root (`T:\Repo\Gallery`) unless a step says **TrueNAS**.
---
@@ -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.
**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.
- The `session` table is truncated, so any active prod curator sessions are logged out.
- **`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`.
- 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`.
> **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;`
+8 -10
View File
@@ -90,14 +90,14 @@ If STEP 2 fails with “database already exists”, STEP 0 likely already shows
**Alternative (dev PC with psql installed):**
```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
cd T:\Repo\Gallery
psql -h 192.168.10.122 -U postgres -d postgres -f db/split-dev-prod.sql
```
**Alternative (Node, postgres password in env):**
```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
cd T:\Repo\Gallery
$env:PGHOST="192.168.10.122"; $env:PGUSER="postgres"; $env:PGPASSWORD="YOUR_POSTGRES_PASSWORD"
npm run infra:db:split-dev-prod
```
@@ -113,17 +113,16 @@ npm run infra:db:split-dev-prod
PORT=3451
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
SESSION_SECRET=your-long-random-secret
SESSION_COOKIE_SECURE=false
CURATOR_USERNAME=curator
CURATOR_PASSWORD=your-secure-password
```
`npm run dev:migrate` creates auth tables and bootstraps the first curator when `users` is empty.
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:
```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
cd T:\Repo\Gallery
npm run dev:migrate
npm run dev:web
```
@@ -156,7 +155,7 @@ Requires SMB share **`Gallery`** → `/mnt/BasePool/Applications/Gallery` on Tru
# Map share (use your TrueNAS SMB user/password)
net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
cd T:\Repo\Gallery
npm run devtoprod:images
```
@@ -173,7 +172,7 @@ If `net use` fails, open `\\192.168.10.122\Gallery` in File Explorer and sign in
Prerequisites: **Docker Desktop running**, logged in to Gitea.
```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
cd T:\Repo\Gallery
docker login gitea.mysuperlab.netcraze.pro
npm run prod:docker:publish
```
@@ -254,10 +253,9 @@ Expect **HTTP 200** (not 502).
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
TRUST_PROXY=true
SESSION_SECRET=your-long-random-secret
SESSION_COOKIE_SECURE=false
```
Prod (`infra/docker/.env.prod`): set `SESSION_COOKIE_SECURE=true` and the same `SESSION_SECRET` / `CURATOR_*` vars on the TrueNAS app environment.
Omit `SESSION_COOKIE_SECURE` for auto Secure cookies behind Keenetic HTTPS. Prod (`infra/docker/.env.prod`): set `SESSION_COOKIE_SECURE=true` and the same `SESSION_SECRET` / `CURATOR_*` vars on the TrueNAS app environment.
Restart `npm run dev:web` after changing `PUBLIC_URL`.
@@ -343,7 +341,7 @@ For **incremental** dev ↔ prod merge (both sides edited), use [harmonize-dev-p
| `npm run devtoprod:db:restore` | Dev PC PowerShell | Restore into prod |
| `npm run harmonize` | Dev PC PowerShell | Bidirectional catalog + image merge — [harmonize-dev-prod.md](harmonize-dev-prod.md) |
| `npm run harmonize:db` | Dev PC PowerShell | DB merge only |
| `npm run harmonize:images` | Dev PC PowerShell | Image merge only |
| `npm run harmonize:images` | Dev PC PowerShell | Image merge + artists/paintings checkup/path sync + regenerate thumbs on both sides |
## Image sync
+22 -4
View File
@@ -45,7 +45,7 @@ For refreshing dev from prod entirely, use `npm run prodto:dev:db` (destructive
net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER
```
5. Copy harmonize config:
5. Copy **dedicated** harmonize config (do not rely on `devtoprod.config.json` alone — that file is a release profile and its step flags are ignored for harmonize):
```powershell
Copy-Item infra/deploy/harmonize.config.example.json infra/deploy/harmonize.config.json
@@ -53,6 +53,8 @@ For refreshing dev from prod entirely, use `npm run prodto:dev:db` (destructive
Edit `harmonize.config.json` (gitignored) — optional `smb.user` / `smb.password`, `prefer` for tie-breaks (`dev` | `prod`), `schemaChanged: true` when new migrations shipped.
6. Prod DB targeting uses [`infra/docker/.env.prod`](../infra/docker/.env.prod). `scripts/db-env.js` loads that file with **file values winning** over the root `.env`, so `harmonize:db` can open `gallery_dev` and `gallery_prod` in the same process without the safety check rejecting `gallery_dev` as a fake prod target.
---
## One-command harmonize
@@ -81,7 +83,7 @@ npm run harmonize:dry-run
| `backupProd` | `prod:db:backup` | Safety snapshot |
| `schema` | `harmonize:schema` | Apply dev migrations to prod (when `schemaChanged: true`) |
| `db` | `harmonize:db` | Row-level catalog merge |
| `images` | `harmonize:images` | Bidirectional file merge |
| `images` | `harmonize:images` | Bidirectional file merge, then merge **artists/paintings** (checkup flags + image paths), then regenerate thumbs on **dev and prod** |
| `verify` | curl `/api/bounds` | Optional smoke check |
Reports are written to `db/SyncReports/harmonize_db_*.json` and `harmonize_images_*.json` (gitignored).
@@ -96,7 +98,10 @@ Reports are written to `db/SyncReports/harmonize_db_*.json` and `harmonize_image
| `npm run harmonize:db` | Merge catalog rows by `updated_at` |
| `npm run harmonize:db -- --dry-run` | Preview DB changes |
| `npm run harmonize:db -- --prefer=dev` | On equal `updated_at`, dev wins |
| `npm run harmonize:images` | Merge image files by mtime |
| `npm run harmonize:images` | Merge image files by mtime, merge artists/paintings checkup flags + image paths, then regenerate thumbs on both sides |
| `npm run harmonize:images -- --skip-thumbnails` | File + checkup/path DB merge only (no thumb rebuild) |
| `npm run harmonize:images -- --skip-db` | File merge (+ thumbs) without artists/paintings checkup sync |
| `npm run harmonize:images -- --dry-run` | Preview file copies and catalog merge (skips thumbnails) |
| `npm run dev:migrate:sync-timestamps` | Apply `updated_at` migration on dev only |
| `npm run dev:backfill-updated-at` | Backfill dev `updated_at` from image mtimes |
| `npm run harmonize:backfill-updated-at` | Same backfill on prod |
@@ -107,7 +112,9 @@ Reports are written to `db/SyncReports/harmonize_db_*.json` and `harmonize_image
Processed in FK order:
`historical_eras` → `art_movements` → `artists` → `artist_periods` → `paintings` → `painting_influences` → `painting_influence_sources` → `painting_annotations` → **`entity_translations`**
`historical_eras` → `art_movements` → `artists` → `artist_periods` → `paintings` → `painting_influences` → `painting_influence_sources` → `painting_annotations` → **`entity_translations`** → **`tours`** → **`tour_stops`**
Every synced table needs an `updated_at` column (including `tour_stops` — added via `migrate-tours.sql` / `migrate-sync-timestamps.sql`).
---
@@ -134,6 +141,7 @@ Harmonize reports conflicts in the JSON report and skips those rows:
| New migration in repo | `harmonize:schema` or deploy step 4 |
| Only images changed on one side | `npm run harmonize:images` |
| Only DB metadata changed | `npm run harmonize:db` |
| Pull **one artists** images from prod → dev | Map SMB, then robocopy `Duccio*` (or the artist prefix) under `paintings/` and `paintings/thumbs/` — see [FAC.md — Dev ↔ prod image sync](FAC.md#dev--prod-image-sync-smb) |
---
@@ -163,6 +171,16 @@ npm run harmonize
---
## Troubleshooting
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| `Refusing prod operation on database "gallery_dev"` | Old `db-env` left `DB_NAME` from root `.env` when loading prod | Update to current `scripts/db-env.js` (`.env.prod` wins); confirm `infra/docker/.env.prod` has `DB_NAME=gallery_prod` |
| `Table tour_stops missing updated_at` | Prod/dev schema behind | `npm run dev:migrate` and migrate prod (`$env:DB_NAME="gallery_prod"; npm run dev:migrate; Remove-Item Env:\DB_NAME`) |
| Orchestrator lists release steps (`restoreProd`, …) | Missing `harmonize.config.json`; fallback to release config | Copy `harmonize.config.example.json` → `harmonize.config.json` (current `harmonize.ps1` ignores release-only step keys) |
---
## Safety
- Pre-flight backups of dev and prod DB (configurable; on by default)
+11 -1
View File
@@ -25,6 +25,16 @@ Timeline header: **EN | RU** toggle (`LocaleSwitcher`).
- Passes `?locale=ru` on catalog API requests
- Refetches bootstrap catalog when locale changes
Timeline **layout** chrome and chart hints are also localised in `home.json`:
| Key | Where |
|-----|--------|
| `layoutHorizontal` / `layoutVertical` / `layoutTree` | Header layout switch |
| `captionClassicTimeline` / `captionClassicFlow` | Classic era bar + movement streams |
| `captionVerticalTimeline` / `captionVerticalFlow` | Vertical rail + streams |
| `captionTreeFlow` | Tree of Art chart |
Shareable layout URLs (`?layout=tree` etc.) are language-independent; captions follow the active locale.
---
## Setup (dev)
@@ -69,7 +79,7 @@ API (curator-only): see [API.md](API.md#translations-curator).
| `era`, `movement` | `name`, `description` |
| `artist` | `name`, `bio_short`, `bio_full` |
| `artist_period` | `name`, `description` |
| `painting` | `title`, `description` |
| `painting` | `title`, `description` (not `curator_notes` — English-only for now) |
| `annotation` | `label`, `body` |
| `influence_source` | `notes`, `aspects`, `quote`, `period_note` |
+19 -6
View File
@@ -30,17 +30,30 @@ Unresolved names (artists / movements / paintings not in the DB) are **skipped**
### Presets
| Preset | Typical headers |
|--------|-----------------|
| Web sources | `Artist`, `Painting`, `Influenced by`, `Influenced`, `Reference (source + link)` |
| Story of Art | Same Title Case (+ chapter reference column) |
| Art influences | `artist`, `painting`, `influenced_by`, `influenced`, `reference` |
| Custom | Map any columns manually |
| Preset | Typical headers | Example file |
|--------|-----------------|--------------|
| Web sources | `Artist`, `Painting`, `Influenced by`, `Influenced`, `Reference (source + link)` | [`Inputs/artist_influences_web_sources.xlsx`](../Inputs/artist_influences_web_sources.xlsx) |
| Story of Art | Same Title Case (+ chapter reference column) | [`Inputs/story_of_art_influences.xlsx`](../Inputs/story_of_art_influences.xlsx) |
| Gariff influential painters | `artist`, `painting`, `influenced by`, `influenced`, `reference` | [`Inputs/gariff_influential_painters_influences.xlsx`](../Inputs/gariff_influential_painters_influences.xlsx) |
| Art influences | `artist`, `painting`, `influenced_by`, `influenced`, `reference` | [`Inputs/art_influences.xlsx`](../Inputs/art_influences.xlsx) |
| Custom | Map any columns manually | — |
Token classification order: **artist → movement → painting title** (under subject artist, then global).
Committed edges use `confidence=curated`, `discovered_via=import-wizard`.
### Book extract: Gariff (2008)
[`Inputs/gariff_influential_painters_influences.xlsx`](../Inputs/gariff_influential_painters_influences.xlsx) is distilled from David Gariff et al., *The World's Most Influential Painters and the Artists They Inspired* (Herbert Press / Quarto, 2008; local PDF `Inputs/1.pdf`, not committed).
- Columns match the import wizard (`artist` / `painting` / `influenced by` / `influenced` / `reference`).
- **`artist` and `painting`** are limited to catalog slices in `Inputs/artistslistdbdata-*.csv` and `Inputs/paintinglistdbdata-*.csv` (regenerate those from prod/dev as needed).
- **`influenced by` / `influenced`** may name artists, movements, or works from the book (including entities outside those CSVs).
- Multiple rows per subject when the book states several influences.
- Import via curator **Influences → Import** (Art influences / custom mapping) or keep as a curated source workbook.
See also [`Inputs/story_of_art_influences.xlsx`](../Inputs/story_of_art_influences.xlsx) (Gombrich) and optional local `Inputs/janson_short_history_influences.xlsx` (same column shape).
### Duplicate file / data guard
Each successful commit stores SHA-256 fingerprints in `curator_audit_log` (`influence.import` details):
+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.
+7 -6
View File
@@ -29,9 +29,9 @@ cp .env.example .env
| `TRUST_PROXY` | Set to `true` when behind nginx/reverse proxy (honours `X-Forwarded-*`) |
| `IMAGE_DIR` | Root for cached images (default `./data/images`) |
| `SESSION_SECRET` | Random string for signed session cookies (required for curator login) |
| `SESSION_COOKIE_SECURE` | `false` for local HTTP dev; `true` in prod behind HTTPS |
| `CURATOR_USERNAME` | Bootstrap only — first curator account name (default `curator`) |
| `CURATOR_PASSWORD` | Bootstrap only — password for first curator when `users` table is empty |
| `SESSION_COOKIE_SECURE` | Optional — omit for auto (HTTPS via proxy → Secure); set `true`/`false` to force |
| `CURATOR_USERNAME` | Bootstrap / reset — curator account name (default `curator`) |
| `CURATOR_PASSWORD` | Bootstrap when `users` is empty; also used by `npm run dev:reset-curator` |
`.env` is git-ignored; never commit passwords.
@@ -70,7 +70,7 @@ If migration fails with permission errors, grant schema rights to the app user f
### Curator accounts (auth migration)
`npm run dev:migrate` applies `db/migrate-auth.sql` (`users`, `curator_audit_log`, `session` tables). When the `users` table is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set in `.env`, the first curator account is created automatically.
`npm run dev:migrate` applies `db/migrate-auth.sql` (`users`, `curator_audit_log`, `session` tables). When the `users` table is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set in `.env`, the first curator account is created automatically. To sync the password from `.env` later, run `npm run dev:reset-curator`.
After migrate, sign in from the site header (**Curator login**). Debug mode, Checkup, Translations, Influences, Tour editor, and all mutating debug APIs require an active curator session. Anonymous visitors browse the timeline, published Tours, and 3D halls without logging in.
@@ -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 prodto:dev:images` | Copy prod images → dev repo |
| `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 prod:build` | Build production SPA into `client/dist` |
| `npm run dev:start` | API + static SPA on `HOST`:`PORT` (uses root `.env`) |
@@ -283,9 +283,10 @@ After clone: copy `.env.example` → `.env`, install dependencies, run [one-time
| Movement gallery shows generic cream walls | Stale client build | `cd client && npm run build`; hard-refresh browser |
| Windows overlap paintings in movement wing | Stale client | Rebuild client — windows are placed only on side walls in gaps between frames |
| Influence thumbnails cropped on painting detail | Stale client build | `npm run prod:build` — panels use `object-fit: contain` for full image |
| **Curator login** fails / always guest | Auth tables missing or wrong password | Set `SESSION_SECRET` + `CURATOR_PASSWORD` in `.env`, run `npm run dev:migrate`, restart server |
| **Curator login** fails / always guest | Auth tables missing or wrong password | Set `SESSION_SECRET` + `CURATOR_PASSWORD` in `.env`, run `npm run dev:migrate` (or `npm run dev:reset-curator`), restart server |
| Debug / Checkup returns **401** | Not signed in as curator | **Curator login** (top-right); session cookie `gallery.sid` must be sent (`credentials: include`) |
| Debug works in UI but API rejects | Stale server without auth middleware | Restart `npm run dev:web` or `npm run dev:server` after pulling auth changes |
| **Empty screen** entering 3D hall (header missing) | Stale client before gallery-session fix | Hard-refresh; pull latest client — hall renders from `view` state, not only `gallerySession` |
| **Dark center** entering 3D hall (header visible, no spinner) | Stale client before gallery loading overlay fix | Hard-refresh; latest client shows **Loading gallery…** / **Loading paintings…** in the canvas area until ready |
| 3D hall loading overlay never clears | Stuck texture counter or hung HDR | Hard-refresh; current client times out Environment / shader warm-up so the overlay cannot stay forever |
| 3D hall black after returning from painting detail | WebGL context lost while hall was hidden | Hard-refresh; latest client remounts canvas when hall becomes active again |
@@ -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.
Binary file not shown.
Binary file not shown.
+3 -1
View File
@@ -1,12 +1,14 @@
# Art Gallery
Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, **curator-gated** debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**; painting detail also **Remove entry**), optional **Show more** auto-opens the search picker, Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. Anonymous visitors browse freely; curators sign in via **Curator login** in the header.
Interactive virtual art gallery: zoomable historical timeline (classic left→right, vertical, or **Tree of Art** lineage chart — shareable via `?layout=`) with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, **curator-gated** debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**; painting detail also **Remove entry**), optional **Show more** auto-opens the search picker, Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. Anonymous visitors browse freely; curators sign in via **Curator login** in the header.
## Documentation
| Document | Purpose |
|----------|---------|
| [Documentation/basics.md](Documentation/basics.md) | Architecture, layout, user flow |
| [Documentation/ui-interaction-and-component-standards.md](Documentation/ui-interaction-and-component-standards.md) | UI interaction, navigation, and component standards |
| [Documentation/movement-tree.md](Documentation/movement-tree.md) | **Tree of Art** start page — tree layout rules |
| [Documentation/FAC.md](Documentation/FAC.md) | **Command cheat sheet** — start/stop, import, deploy |
| [Documentation/environments.md](Documentation/environments.md) | Dev/prod URLs, DB split, deploy, sync |
| [Documentation/setup.md](Documentation/setup.md) | Install, env, npm scripts |
+220 -1
View File
@@ -5,6 +5,7 @@ import type {
Artist,
ArtistDetail,
MovementGalleryDetail,
ArtistSummary,
Painting,
PaintingDetail,
ArtistNavigation,
@@ -39,11 +40,112 @@ function localizedPath(path: string, params?: URLSearchParams): string {
const fetchCredentials: RequestInit = { credentials: 'include' };
export type AuthRole = 'user' | 'curator';
export type AuthRole = 'user' | 'admin' | 'curator';
export type StaffPermission =
| 'images'
| 'checkup'
| 'curator_notes'
| 'translations'
| 'influences'
| 'tours'
| 'users';
export const ALL_STAFF_PERMISSIONS: StaffPermission[] = [
'images',
'checkup',
'curator_notes',
'translations',
'influences',
'tours',
'users',
];
export interface AuthState {
role: AuthRole;
username?: string;
permissions?: StaffPermission[];
}
export interface StaffUser {
id: number;
username: string;
role: 'admin' | 'curator';
permissions: StaffPermission[];
is_active: boolean;
created_at: string;
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> {
@@ -64,6 +166,14 @@ export async function loginCurator(username: string, password: string): Promise<
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
throw new Error(
res.status === 401
? 'Login blocked by the reverse proxy (not the gallery). Use http://localhost:5173 or fix Keenetic access.'
: `Login failed: HTTP ${res.status}`
);
}
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Login failed: ${res.status}`);
}
@@ -138,6 +248,34 @@ export function galleryImageUrl(
return null;
}
/** Ordered hall texture URLs — thumbs only (never full originals; those can be 10MB+ each). */
export function galleryImageUrlCandidates(
painting: {
id?: number;
thumbnail_path?: string | null;
image_path?: string | null;
image_cache_key?: number | null;
thumbnail_cache_key?: number | null;
},
sessionRevision?: number | null
): string[] {
const revision = paintingImageRevision(painting, sessionRevision);
const urls: string[] = [];
const push = (u: string | null | undefined) => {
if (u && !urls.includes(u)) urls.push(u);
};
if (painting.thumbnail_path) push(imageUrl(painting.thumbnail_path, revision));
// If DB has no thumb path but has a full file, still prefer the on-demand thumb API
// over streaming the multi-megabyte original into WebGL.
if (painting.id != null) {
push(`/api/paintings/${painting.id}/image?size=thumb`);
}
if (!painting.thumbnail_path && painting.image_path) {
push(imageUrl(painting.image_path, revision));
}
return urls;
}
export function galleryImageUrlWithRevision(
painting: {
id?: number;
@@ -264,6 +402,15 @@ export async function preloadArtistImages(artistId: number): Promise<{ fetched:
return res.json();
}
export async function preloadMovementImages(movementId: number): Promise<{ fetched: number; total: number }> {
const res = await fetch(`${API}/movements/${movementId}/preload-images`, {
...fetchCredentials,
method: 'POST',
});
if (!res.ok) throw new Error('Preload failed');
return res.json();
}
export interface FixPaintingImageResult {
imagePath: string | null;
thumbnailPath: string | null;
@@ -329,6 +476,60 @@ export interface PaintingCheckupData {
}
export const api = {
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: {
username: string;
password: string;
role: 'admin' | 'curator';
permissions: StaffPermission[];
}) =>
fetch(`${API}/users`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(async (res) => {
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Create failed: ${res.status}`);
return data as { user: StaffUser };
}),
updateUser: (
id: number,
body: Partial<{ role: 'admin' | 'curator'; permissions: StaffPermission[]; is_active: boolean }>
) =>
fetch(`${API}/users/${id}`, {
...fetchCredentials,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(async (res) => {
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Update failed: ${res.status}`);
return data as { user: StaffUser };
}),
resetUserPassword: (id: number, password: string) =>
fetch(`${API}/users/${id}/password`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
}).then(async (res) => {
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Password reset failed: ${res.status}`);
return data as { ok: boolean };
}),
getBounds: () => fetchJson<YearBounds>(`${API}/bounds`),
getCatalogBootstrap: (start?: number, end?: number) => {
@@ -365,6 +566,9 @@ export const api = {
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(localizedPath(`${API}/movements/${id}/gallery`)),
getMovementArtistsSummary: (id: number) =>
fetchJson<ArtistSummary[]>(localizedPath(`${API}/movements/${id}/artists-summary`)),
getArtistNavigation: (id: number) =>
fetchJson<ArtistNavigation>(localizedPath(`${API}/artists/${id}/navigation`)),
@@ -436,6 +640,20 @@ export const api = {
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
}),
updatePaintingCuratorNotes: (id: number, curatorNotes: string) =>
fetch(`${API}/paintings/${id}/curator-notes`, {
...fetchCredentials,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ curatorNotes }),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Update failed: ${res.status}`);
}
return res.json() as Promise<{ curatorNotes: string }>;
}),
getArtistDebugPortraitSearch: (id: number) =>
fetchJson<DebugImageSearchResult>(`${API}/artists/${id}/debug-portrait-search`),
@@ -766,6 +984,7 @@ export const api = {
}),
preloadArtistImages,
preloadMovementImages,
};
export interface TranslationWorklistItem {
+4
View File
@@ -19,6 +19,7 @@ interface Props {
artist: Artist & { movement_name?: string };
debugMode?: boolean;
debugShowMore?: boolean;
canCheckup?: boolean;
portraitRevision?: number;
onBack: () => void;
onEnterGallery: () => void;
@@ -36,6 +37,7 @@ export default function ArtistBio({
artist,
debugMode = false,
debugShowMore = false,
canCheckup = true,
portraitRevision = 0,
onBack,
onEnterGallery,
@@ -322,6 +324,7 @@ export default function ArtistBio({
<p className="debug-image-status">No portrait image result found.</p>
)}
<div className="debug-action-buttons">
{canCheckup && (
<button
type="button"
className="debug-checked-btn"
@@ -330,6 +333,7 @@ export default function ArtistBio({
>
{markingChecked ? '…' : 'Checked'}
</button>
)}
<button
type="button"
className="debug-fix-btn"
+229
View File
@@ -0,0 +1,229 @@
.artist-filter-backdrop {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.65);
padding: 16px;
}
.artist-filter-modal {
width: min(100%, 720px);
max-height: min(90vh, 640px);
display: flex;
flex-direction: column;
border-radius: 10px;
border: 1px solid rgba(201, 169, 110, 0.35);
background: linear-gradient(180deg, #1a1a2e 0%, #12121f 100%);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
}
.artist-filter-header {
padding: 20px 24px 12px;
flex-shrink: 0;
}
.artist-filter-header h2 {
margin: 0 0 6px;
font-family: 'Georgia', serif;
font-size: 22px;
color: #e8d5b5;
}
.artist-filter-subtitle {
margin: 0;
font-size: 13px;
color: rgba(201, 169, 110, 0.7);
}
.artist-filter-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 24px 12px;
flex-shrink: 0;
}
.artist-filter-toggle-all {
border: none;
background: transparent;
color: rgba(255, 220, 160, 0.9);
font-size: 13px;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.artist-filter-toggle-all:hover {
color: #ffe8c0;
}
.artist-filter-count {
font-size: 12px;
color: rgba(201, 169, 110, 0.65);
}
.artist-filter-grid {
flex: 1;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
padding: 4px 24px 16px;
}
.artist-filter-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 8px;
border-radius: 8px;
border: 1px solid rgba(201, 169, 110, 0.2);
background: rgba(0, 0, 0, 0.25);
cursor: pointer;
text-align: center;
transition: border-color 0.15s, background 0.15s, opacity 0.15s;
}
.artist-filter-card:hover {
border-color: rgba(255, 220, 160, 0.45);
background: rgba(0, 0, 0, 0.35);
}
.artist-filter-card-selected {
border-color: rgba(255, 220, 160, 0.55);
background: rgba(255, 220, 160, 0.08);
}
.artist-filter-card:not(.artist-filter-card-selected) {
opacity: 0.55;
}
.artist-filter-portrait-wrap {
position: relative;
width: 56px;
height: 56px;
margin-bottom: 4px;
}
.artist-filter-portrait {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(201, 169, 110, 0.4);
}
.artist-filter-portrait-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: rgba(201, 169, 110, 0.15);
color: rgba(232, 213, 181, 0.6);
font-size: 20px;
font-family: 'Georgia', serif;
}
.artist-filter-check {
position: absolute;
right: -2px;
bottom: -2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(255, 220, 160, 0.95);
color: #1a1a2e;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.artist-filter-card:not(.artist-filter-card-selected) .artist-filter-check {
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(201, 169, 110, 0.35);
color: transparent;
}
.artist-filter-name {
font-family: 'Georgia', serif;
font-size: 13px;
font-weight: 600;
color: #e8d5b5;
line-height: 1.25;
}
.artist-filter-years {
font-size: 11px;
color: rgba(201, 169, 110, 0.75);
}
.artist-filter-paintings {
font-size: 11px;
color: rgba(201, 169, 110, 0.55);
}
.artist-filter-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 24px 20px;
border-top: 1px solid rgba(201, 169, 110, 0.15);
flex-shrink: 0;
}
.artist-filter-cancel,
.artist-filter-proceed {
padding: 10px 18px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
.artist-filter-cancel {
border: 1px solid rgba(201, 169, 110, 0.35);
background: transparent;
color: rgba(232, 213, 181, 0.85);
}
.artist-filter-cancel:hover {
background: rgba(201, 169, 110, 0.1);
}
.artist-filter-proceed {
border: none;
background: linear-gradient(180deg, #c9a96e 0%, #a88b4a 100%);
color: #1a1a2e;
font-weight: 600;
}
.artist-filter-proceed:hover:not(:disabled) {
filter: brightness(1.08);
}
.artist-filter-proceed:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@media (max-width: 480px) {
.artist-filter-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
padding-left: 16px;
padding-right: 16px;
}
.artist-filter-header,
.artist-filter-toolbar,
.artist-filter-footer {
padding-left: 16px;
padding-right: 16px;
}
}
+171
View File
@@ -0,0 +1,171 @@
import { useEffect, useMemo, useState } from 'react';
import type { ArtistSummary } from '../types';
import { portraitThumbUrl, portraitUrl } from '../api/client';
import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc';
import './ArtistFilterModal.css';
interface Props {
open: boolean;
movementName: string;
artists: ArtistSummary[];
portraitRevisions?: Record<number, number>;
onProceed: (selectedIds: Set<number>) => void;
onClose: () => void;
}
function formatLifespan(birth: number | null, death: number | null): string {
const b = birth != null ? String(birth) : '?';
const d = death != null ? String(death) : '?';
return `${b}${d}`;
}
function paintingCountLabel(count: number): string {
return count === 1 ? '1 painting' : `${count} paintings`;
}
function ArtistFilterPortrait({
artist,
revision,
}: {
artist: ArtistSummary;
revision?: number;
}) {
const thumbSrc =
artist.portrait_thumb_path || artist.portrait_path
? portraitThumbUrl(artist, revision)
: null;
const fullSrc = artist.portrait_path
? portraitUrl(artist.portrait_path, revision ?? artist.portrait_cache_key)
: null;
const [srcIndex, setSrcIndex] = useState(0);
const candidates = [thumbSrc, fullSrc].filter((s, i, arr): s is string => Boolean(s) && arr.indexOf(s) === i);
const requested = candidates[srcIndex] ?? null;
const queuedSrc = useQueuedImageSrc(requested);
useEffect(() => {
setSrcIndex(0);
}, [thumbSrc, fullSrc]);
if (!queuedSrc) {
return (
<span className="artist-filter-portrait artist-filter-portrait-placeholder" aria-hidden>
?
</span>
);
}
return (
<img
src={queuedSrc}
alt=""
className="artist-filter-portrait"
onError={() => {
setSrcIndex((i) => (i + 1 < candidates.length ? i + 1 : candidates.length));
}}
/>
);
}
export default function ArtistFilterModal({
open,
movementName,
artists,
portraitRevisions,
onProceed,
onClose,
}: Props) {
const allIds = useMemo(() => new Set(artists.map((a) => a.id)), [artists]);
const [selected, setSelected] = useState<Set<number>>(() => new Set(allIds));
useEffect(() => {
if (open) {
setSelected(new Set(artists.map((a) => a.id)));
}
}, [open, artists]);
if (!open) return null;
const allSelected = selected.size === artists.length && artists.length > 0;
const noneSelected = selected.size === 0;
const toggleArtist = (id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleAll = () => {
setSelected(allSelected ? new Set() : new Set(allIds));
};
return (
<div className="artist-filter-backdrop" onMouseDown={onClose}>
<div
className="artist-filter-modal"
role="dialog"
aria-labelledby="artist-filter-title"
aria-modal="true"
onMouseDown={(e) => e.stopPropagation()}
>
<header className="artist-filter-header">
<h2 id="artist-filter-title">{movementName}</h2>
<p className="artist-filter-subtitle">Choose artists to include in the gallery hall</p>
</header>
<div className="artist-filter-toolbar">
<button type="button" className="artist-filter-toggle-all" onClick={toggleAll}>
{allSelected ? 'Deselect all' : 'Select all'}
</button>
<span className="artist-filter-count">
{selected.size} of {artists.length} selected
</span>
</div>
<div className="artist-filter-grid">
{artists.map((artist) => {
const isSelected = selected.has(artist.id);
return (
<button
key={artist.id}
type="button"
className={`artist-filter-card${isSelected ? ' artist-filter-card-selected' : ''}`}
onClick={() => toggleArtist(artist.id)}
aria-pressed={isSelected}
>
<span className="artist-filter-portrait-wrap">
<ArtistFilterPortrait
artist={artist}
revision={portraitRevisions?.[artist.id]}
/>
<span className="artist-filter-check" aria-hidden>
{isSelected ? '✓' : ''}
</span>
</span>
<span className="artist-filter-name">{artist.name}</span>
<span className="artist-filter-years">{formatLifespan(artist.birth_year, artist.death_year)}</span>
<span className="artist-filter-paintings">{paintingCountLabel(artist.painting_count)}</span>
</button>
);
})}
</div>
<footer className="artist-filter-footer">
<button type="button" className="artist-filter-cancel" onClick={onClose}>
Cancel
</button>
<button
type="button"
className="artist-filter-proceed"
disabled={noneSelected}
onClick={() => onProceed(new Set(selected))}
>
Enter gallery
</button>
</footer>
</div>
</div>
);
}
+59 -22
View File
@@ -1,5 +1,4 @@
import { useMemo } from 'react';
import * as THREE from 'three';
import type { GalleryWindowSpec, GalleryWindowStyle } from '../data/movement-interior-styles';
const WALL_HEIGHT = 4.2;
@@ -69,10 +68,56 @@ function WindowFrame({
</mesh>
{arch && style === 'gothic-lancet' && (
<mesh position={[0, height / 2 + 0.15, -depth / 2 + 0.02]}>
<coneGeometry args={[width / 2 + frameW, 0.5, 4]} />
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.35} />
<>
{/* Two-centred arch head built from short chords */}
{Array.from({ length: 14 }, (_, i) => {
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' && (
@@ -146,8 +191,6 @@ function SingleWindow({
spec: GalleryWindowSpec;
trimColor: string;
}) {
const glassColor = useMemo(() => new THREE.Color(spec.lightColor), [spec.lightColor]);
return (
<group>
<WindowFrame style={spec.style} width={spec.width} height={spec.height} trimColor={trimColor} />
@@ -173,22 +216,15 @@ function SingleWindow({
<meshBasicMaterial color="#ffffff" transparent opacity={0.25} toneMapped={false} />
</mesh>
{/* Daylight into room */}
{/* One fill light per window — keep total scene lights well under WebGL limits. */}
<spotLight
position={[0, 0, 0.15]}
angle={Math.min(1.2, (spec.width / Math.max(spec.height, 0.5)) * 0.55)}
penumbra={0.95}
intensity={spec.lightIntensity}
intensity={spec.lightIntensity * Math.PI * 0.85}
distance={14}
decay={0}
color={spec.lightColor}
castShadow={false}
/>
<pointLight
position={[0, 0, 0.25]}
intensity={spec.lightIntensity * 0.45}
distance={10}
color={glassColor}
decay={2}
/>
</group>
);
@@ -223,8 +259,9 @@ export function GalleryTrackLights({
}) {
const positions = useMemo(() => {
const pts: [number, number, number][] = [];
const cols = Math.max(2, Math.min(5, Math.floor(width / 4)));
const rows = Math.max(1, Math.min(3, Math.floor(depth / 6)));
// Cap fixtures so MeshStandard hall materials stay within WebGL light limits.
const cols = Math.max(2, Math.min(3, Math.floor(width / 5)));
const rows = Math.max(1, Math.min(2, Math.floor(depth / 8)));
for (let c = 0; c < cols; c++) {
for (let r = 0; r < rows; r++) {
const x = -width / 2 + (width / (cols + 1)) * (c + 1);
@@ -245,12 +282,12 @@ export function GalleryTrackLights({
</mesh>
<spotLight
position={[0, -0.04, 0]}
angle={0.55}
penumbra={0.85}
angle={0.7}
penumbra={0.9}
intensity={intensity}
distance={8}
distance={12}
decay={0}
color={color}
castShadow={false}
/>
</group>
))}
+2 -2
View File
@@ -46,14 +46,14 @@ export default function HallPassage({
{/* Side jambs */}
{([-1, 1] as const).map((sign) => (
<mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), openingH / 2, faceZ]} castShadow>
<mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), openingH / 2, faceZ]}>
<boxGeometry args={[jamb, openingH + 0.1, 0.1]} />
<meshStandardMaterial color={trimColor} roughness={0.45} metalness={0.25} />
</mesh>
))}
{/* Arch header */}
<mesh position={[0, openingH + 0.06, faceZ]} castShadow>
<mesh position={[0, openingH + 0.06, faceZ]}>
<boxGeometry args={[openingW + jamb * 2, 0.14, 0.1]} />
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.3} />
</mesh>
+49 -26
View File
@@ -28,7 +28,7 @@
.movements-flow-canvas {
--stream-stroke: 54px;
--portrait-size: 52px;
--portrait-size: 39px;
position: relative;
flex: 1;
min-height: 0;
@@ -96,12 +96,13 @@
}
.movement-branch-fast {
stroke-opacity: 0.32;
stroke-opacity: 0.55;
stroke-width: calc(var(--stream-stroke) * 0.45);
stroke-linecap: round;
}
.movement-stream-fast {
stroke-opacity: 0.42;
stroke-opacity: 0.62;
stroke-width: var(--stream-stroke);
stroke-linecap: round;
vector-effect: non-scaling-stroke;
@@ -119,13 +120,14 @@
stroke-width: var(--stream-stroke);
stroke-linecap: round;
vector-effect: non-scaling-stroke;
filter: saturate(1.25) brightness(1.12) contrast(1.08);
transition: filter 0.2s ease, opacity 0.2s ease;
}
.movement-stream-core {
stroke-width: 2px;
stroke-linecap: round;
opacity: 0.22;
opacity: 0.38;
stroke-dasharray: 8 6;
animation: stream-shimmer 16s linear infinite;
vector-effect: non-scaling-stroke;
@@ -133,29 +135,30 @@
}
.movements-flow-canvas.movements-flow-movement-hover .movement-stream-fill:not(.movement-stream-highlighted) {
opacity: 0.55;
opacity: 0.5;
filter: saturate(1.05) brightness(0.95) contrast(1.02);
}
.movements-flow-canvas.movements-flow-movement-hover .movement-stream-core:not(.movement-stream-highlighted) {
opacity: 0.1;
opacity: 0.14;
}
.movements-flow-canvas.movements-flow-movement-hover .movement-branch:not(.movement-branch-highlighted) {
opacity: 0.45;
opacity: 0.4;
}
.movement-stream-fill.movement-stream-highlighted {
filter: brightness(1.65) saturate(1.3);
filter: brightness(1.75) saturate(1.45) contrast(1.12);
}
.movement-stream-core.movement-stream-highlighted {
opacity: 0.72;
filter: brightness(1.5);
opacity: 0.82;
filter: brightness(1.55) saturate(1.2);
stroke-width: 2.5px;
}
.movement-branch.movement-branch-highlighted {
filter: brightness(1.55) saturate(1.2);
filter: brightness(1.65) saturate(1.35) contrast(1.1);
}
@keyframes stream-shimmer {
@@ -173,7 +176,29 @@
inset: 0;
}
.movements-flow-hits {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
}
.movement-stream-hit {
position: absolute;
height: var(--stream-stroke);
transform: translateY(-50%);
pointer-events: auto;
}
.movements-flow-artists {
opacity: 0;
pointer-events: none;
transition: opacity 0.18s ease;
z-index: 5;
}
.movements-flow-canvas.movements-flow-band-hover .movements-flow-artists {
opacity: 1;
pointer-events: auto;
}
@@ -239,23 +264,16 @@
padding: 2px 6px;
pointer-events: none;
z-index: 6;
}
.movement-flow-label-above {
transform: translate(-4px, -100%);
}
.movement-flow-label-below {
transform: translate(-4px, 0);
transform: translate(-50%, -50%);
}
.movement-flow-label-btn {
pointer-events: auto;
border: none;
background: rgba(12, 10, 18, 0.55);
background: rgba(12, 10, 18, 0.72);
border-radius: 4px;
cursor: pointer;
text-align: left;
text-align: center;
transition: background 0.2s, box-shadow 0.2s;
}
@@ -269,6 +287,11 @@
outline-offset: 2px;
}
.movement-flow-label-hover-reveal {
z-index: 8;
box-shadow: 0 0 0 1px rgba(255, 220, 160, 0.4), 0 4px 18px rgba(0, 0, 0, 0.45);
}
.movement-name {
display: block;
font-family: 'Georgia', serif;
@@ -290,8 +313,8 @@
background: none;
border: 2px solid rgba(232, 213, 181, 0.75);
border-radius: 50%;
width: var(--portrait-size, 52px);
height: var(--portrait-size, 52px);
width: var(--portrait-size, 39px);
height: var(--portrait-size, 39px);
padding: 0;
cursor: pointer;
overflow: visible;
@@ -335,7 +358,7 @@
@media (max-width: 768px) {
.movements-flow-canvas {
--stream-stroke: 44px;
--portrait-size: 44px;
--portrait-size: 33px;
}
.movements-flow-caption {
@@ -343,8 +366,8 @@
}
.artist-portrait {
width: 44px;
height: 44px;
width: var(--portrait-size, 33px);
height: var(--portrait-size, 33px);
}
.movement-flow-label {
File diff suppressed because it is too large Load Diff
+269 -181
View File
@@ -1,6 +1,38 @@
import { useMemo } from 'react';
import * as THREE from 'three';
import type { MovementInteriorStyle } from '../data/movement-interior-styles';
import type { MovementInteriorStyle, GalleryWindowSpec } from '../data/movement-interior-styles';
import type { RoomDims } from './hall-details/geometry';
import {
CinquecentoDetails,
QuattrocentoDetails,
RomanAtriumDetails,
} from './hall-details/classical';
import {
BaroqueStateDetails,
FlemishDetails,
ManneristDetails,
NeoclassicalDetails,
RococoDetails,
} from './hall-details/courtly';
import {
ArtNouveauDetails,
BourgeoisSalonDetails,
GothicRevivalDetails,
NorthLightSalonDetails,
ParisAtelierDetails,
SymbolistDetails,
} from './hall-details/nineteenth';
import {
ConstructivistDetails,
CubistAtelierDetails,
DadaDetails,
ExpressionistDetails,
FauvistDetails,
FuturistDetails,
LoftDetails,
SuprematistDetails,
SurrealistDetails,
WhiteCubeDetails,
} from './hall-details/modernism';
interface Props {
style: MovementInteriorStyle;
@@ -8,215 +40,271 @@ interface Props {
depth: number;
halfW: number;
halfD: number;
/** Runtime window placement, so upper-wall relief can step around openings. */
windows?: GalleryWindowSpec[];
}
function PalazzoDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
const pilasterPositions = useMemo(
function GothicDetails({ width, depth, halfW, halfD, trim }: Omit<Props, 'style' | 'windows'> & { trim: string }) {
// 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.
const bays = useMemo(() => {
const spacing = 4.2;
const count = Math.max(2, Math.min(9, Math.floor(depth / spacing)));
const step = depth / (count + 1);
return Array.from({ length: count }, (_, i) => -halfD + step * (i + 1));
}, [depth, halfD]);
const shaftHeight = 3.5;
return (
<group>
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (halfW - 0.09), 0, z]}>
{/* Compound pier: a heavier centre shaft flanked by colonnettes */}
<mesh position={[0, shaftHeight / 2, 0]}>
<cylinderGeometry args={[0.1, 0.115, shaftHeight, 12]} />
<meshStandardMaterial color="#a89e88" roughness={0.78} metalness={0.04} />
</mesh>
{[-0.17, 0.17].map((dz) => (
<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>
))}
{/* Moulded string course running the length of both side walls */}
{[-1, 1].map((side) => (
<mesh key={side} position={[side * (halfW - 0.05), 2.62, 0]}>
<boxGeometry args={[0.12, 0.1, depth - 0.2]} />
<meshStandardMaterial color="#b3a892" roughness={0.7} metalness={0.05} />
</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>
);
}
function ByzantineDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
// Engaged porphyry colonnettes with Byzantine basket/impost capitals at the
// corners (flush — never proud of the ~0.7m frame hang margin), a marble
// revetment dado at spring-line height, and hanging brass polycandela in
// place of wall torches (a Byzantine basilica hangs its light, not sconces it).
const colonnettes = useMemo(
() =>
[
[-halfW + 0.35, -halfD + 0.35],
[halfW - 0.35, -halfD + 0.35],
[-halfW + 0.35, halfD - 0.35],
[halfW - 0.35, halfD - 0.35],
[-halfW + 0.08, -halfD + 0.08],
[halfW - 0.08, -halfD + 0.08],
[-halfW + 0.08, halfD - 0.08],
[halfW - 0.08, halfD - 0.08],
] as [number, number][],
[halfW, halfD]
);
return (
<group>
{pilasterPositions.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 1.8, 0]} castShadow>
<boxGeometry args={[0.22, 3.6, 0.22]} />
<meshStandardMaterial color="#e8dcc8" roughness={0.85} metalness={0.05} />
</mesh>
<mesh position={[0, 3.65, 0]}>
<boxGeometry args={[0.28, 0.14, 0.28]} />
<meshStandardMaterial color={trim} roughness={0.35} metalness={0.55} />
</mesh>
</group>
))}
{/* Wainscoting rail */}
{[
[0, -halfD + 0.09, width, 0.12] as const,
[0, halfD - 0.09, width, 0.12] as const,
[-halfW + 0.09, 0, 0.12, depth] as const,
[halfW - 0.09, 0, 0.12, depth] as const,
].map(([x, z, w, d], i) => (
<mesh key={i} position={[x, 1.05, z]}>
<boxGeometry args={[w, 0.08, d]} />
<meshStandardMaterial color="#ddd0b8" roughness={0.78} metalness={0.08} />
</mesh>
))}
{/* Coffered ceiling */}
{Array.from({ length: Math.min(6, Math.floor(width / 2.5)) }, (_, col) =>
Array.from({ length: Math.min(5, Math.floor(depth / 2.8)) }, (_, row) => {
const cx = -halfW + 1.4 + col * 2.4;
const cz = -halfD + 1.5 + row * 2.6;
return (
<mesh key={`${col}-${row}`} position={[cx, 4.12, cz]} rotation={[Math.PI / 2, 0, 0]}>
<boxGeometry args={[2.0, 2.2, 0.06]} />
<meshStandardMaterial color="#f5efe4" roughness={0.88} />
</mesh>
);
})
)}
</group>
);
}
function BaroqueDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
return (
<group>
{/* Gilded cornice ring */}
{[
[0, -halfD + 0.06, width, 0.1] as const,
[0, halfD - 0.06, width, 0.1] as const,
[-halfW + 0.06, 0, 0.1, depth] as const,
[halfW - 0.06, 0, 0.1, depth] as const,
].map(([x, z, w, d], i) => (
<mesh key={i} position={[x, 3.95, z]}>
<boxGeometry args={[w, 0.18, d]} />
<meshStandardMaterial color={trim} roughness={0.25} metalness={0.75} emissive="#3a2808" emissiveIntensity={0.08} />
</mesh>
))}
{/* Wall panels */}
{[-halfW + 0.12, halfW - 0.12].map((x, i) => (
<mesh key={i} position={[x, 2.1, 0]} rotation={[0, Math.PI / 2, 0]}>
<boxGeometry args={[depth * 0.85, 2.8, 0.04]} />
<meshStandardMaterial color="#3a1820" roughness={0.88} metalness={0.06} />
</mesh>
))}
<pointLight position={[0, 3.6, 0]} intensity={0.9} distance={Math.max(width, depth)} color="#ffd080" />
<mesh position={[0, 3.5, 0]}>
<torusGeometry args={[0.55, 0.04, 8, 24]} />
<meshStandardMaterial color={trim} roughness={0.2} metalness={0.85} emissive="#5a4010" emissiveIntensity={0.15} />
</mesh>
</group>
);
}
function MedievalDetails({ halfW, halfD }: Pick<Props, 'halfW' | 'halfD'>) {
const torchPositions = useMemo(
const lampPositions = useMemo(
() =>
[
[-halfW + 0.2, -halfD * 0.5],
[halfW - 0.2, -halfD * 0.5],
[-halfW + 0.2, halfD * 0.3],
[halfW - 0.2, halfD * 0.3],
[0, -halfD * 0.32],
[0, halfD * 0.18],
] as [number, number][],
[halfW, halfD]
[halfD]
);
const dadoW = Math.max(0, halfW * 2 - 0.3);
const dadoD = Math.max(0, halfD * 2 - 0.3);
return (
<group>
{torchPositions.map(([x, z], i) => (
<group key={i} position={[x, 2.2, z]}>
<mesh>
<boxGeometry args={[0.08, 0.35, 0.12]} />
<meshStandardMaterial color="#3a3028" roughness={0.9} />
</mesh>
<pointLight position={[0, 0.15, 0.08]} intensity={0.65} distance={5} color="#ff9830" />
<mesh position={[0, 0.2, 0.06]}>
<sphereGeometry args={[0.06, 8, 8]} />
<meshStandardMaterial color="#ffb040" emissive="#ff8010" emissiveIntensity={0.8} toneMapped={false} />
</mesh>
</group>
))}
{/* Rough stone courses */}
{[-halfD + 0.08, halfD - 0.08].map((z, i) => (
<mesh key={i} position={[0, 1.5, z]}>
<boxGeometry args={[0.04, 3, 0.04]} />
<meshStandardMaterial color="#6a6458" roughness={0.98} />
</mesh>
))}
</group>
);
}
function NeoclassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
const columns = [
[-halfW + 0.45, -halfD + 0.6],
[halfW - 0.45, -halfD + 0.6],
[-halfW + 0.45, halfD - 0.6],
[halfW - 0.45, halfD - 0.6],
] as [number, number][];
return (
<group>
{columns.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 1.85, 0]}>
<cylinderGeometry args={[0.18, 0.2, 3.7, 12]} />
<meshStandardMaterial color="#f0ece8" roughness={0.72} metalness={0.12} />
</mesh>
<mesh position={[0, 3.85, 0]}>
<boxGeometry args={[0.42, 0.12, 0.42]} />
<meshStandardMaterial color={trim} roughness={0.4} metalness={0.35} />
</mesh>
</group>
))}
<mesh position={[0, 3.88, -halfD + 0.12]}>
<boxGeometry args={[2.8, 0.14, 0.2]} />
<meshStandardMaterial color={trim} roughness={0.45} metalness={0.3} />
</mesh>
</group>
);
}
function ClassicalDetails({ halfW, trim }: Pick<Props, 'halfW'> & { trim: string }) {
return (
<group>
{/* Marble revetment dado — imperial banding course at chair-rail height */}
{[
[-halfW + 0.5, 0],
[halfW - 0.5, 0],
].map(([x, z], i) => (
[0, -halfD + 0.07, dadoW, 0.1] as const,
[0, halfD - 0.07, dadoW, 0.1] as const,
[-halfW + 0.07, 0, 0.1, dadoD] as const,
[halfW - 0.07, 0, 0.1, dadoD] as const,
].map(([x, z, w, d], i) => (
<group key={i}>
<mesh position={[x, 0.62, z]}>
<boxGeometry args={[w, 1.24, d]} />
<meshStandardMaterial color="#6d5644" roughness={0.34} metalness={0.12} />
</mesh>
<mesh position={[x, 1.28, z]}>
<boxGeometry args={[w * 1.004, 0.09, d * 1.004]} />
<meshStandardMaterial color="#d8c8a8" roughness={0.42} metalness={0.1} />
</mesh>
</group>
))}
{/* Engaged colonnettes, basket capital, and impost block */}
{colonnettes.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 2, 0]}>
<cylinderGeometry args={[0.16, 0.18, 4, 10]} />
<meshStandardMaterial color="#e0d8c8" roughness={0.88} />
{/* Polished porphyry shaft — the imperial stone of the reference basilicas */}
<mesh position={[0, 1.95, 0]}>
<cylinderGeometry args={[0.14, 0.16, 3.4, 14]} />
<meshStandardMaterial color="#43222a" roughness={0.32} metalness={0.16} />
</mesh>
<mesh position={[0, 0.12, 0]}>
<cylinderGeometry args={[0.17, 0.19, 0.24, 14]} />
<meshStandardMaterial color="#d8cdb4" roughness={0.5} metalness={0.08} />
</mesh>
{/* Basket capital in white marble, then an impost block carrying the ceiling */}
<mesh position={[0, 3.8, 0]}>
<cylinderGeometry args={[0.22, 0.11, 0.3, 8]} />
<meshStandardMaterial color="#e8e0cc" roughness={0.44} metalness={0.06} />
</mesh>
<mesh position={[0, 4.05, 0]}>
<boxGeometry args={[0.36, 0.1, 0.36]} />
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.25} />
<boxGeometry args={[0.28, 0.22, 0.28]} />
<meshStandardMaterial color="#ded4bc" roughness={0.5} metalness={0.05} />
</mesh>
</group>
))}
{/* Hanging polycandela — brass ring lamps suspended from the vault */}
{lampPositions.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 4.1, 0]}>
<cylinderGeometry args={[0.012, 0.012, 0.5, 6]} />
<meshStandardMaterial color="#5a4a20" roughness={0.4} metalness={0.6} />
</mesh>
<mesh position={[0, 3.84, 0]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.32, 0.025, 8, 20]} />
<meshStandardMaterial color={trim} roughness={0.25} metalness={0.8} emissive="#3a2808" emissiveIntensity={0.1} />
</mesh>
{Array.from({ length: 6 }, (_, j) => {
const a = (j / 6) * Math.PI * 2;
return (
<mesh key={j} position={[Math.cos(a) * 0.32, 3.76, Math.sin(a) * 0.32]}>
<sphereGeometry args={[0.045, 8, 8]} />
<meshStandardMaterial color="#ffcf80" emissive="#ff9830" emissiveIntensity={0.9} toneMapped={false} />
</mesh>
);
})}
<pointLight position={[0, 3.76, 0]} intensity={0.85} distance={6} color="#ffb060" />
</group>
))}
</group>
);
}
function SalonDetails({ trim }: { trim: string }) {
return (
<mesh position={[0, 4.05, 0]} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.3, 1.2, 32]} />
<meshStandardMaterial color={trim} roughness={0.35} metalness={0.5} side={THREE.DoubleSide} />
</mesh>
);
}
export default function MovementHallDetails({ style, width, depth, halfW, halfD }: Props) {
/**
* Period architecture for a movement hall.
*
* Every movement has its own interior, built from the architecture of the same
* period as the movement itself. The Gothic nave and Byzantine basilica below
* are the reference implementations; everything else lives in
* `hall-details/`, sharing the geometry budget documented in
* `hall-details/primitives.tsx`.
*/
export default function MovementHallDetails({ style, width, depth, halfW, halfD, windows }: Props) {
const trim = style.tints.trim;
const room: RoomDims = useMemo(
() => ({ width, depth, halfW, halfD }),
[width, depth, halfW, halfD]
);
const period = { room, trim, windows };
switch (style.details) {
case 'palazzo':
return <PalazzoDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
case 'gothic':
return <GothicDetails width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
case 'byzantine':
return <ByzantineDetails halfW={halfW} halfD={halfD} trim={trim} />;
case 'roman':
return <RomanAtriumDetails {...period} />;
case 'quattrocento':
return <QuattrocentoDetails {...period} />;
case 'cinquecento':
return <CinquecentoDetails {...period} />;
case 'flemish':
return <FlemishDetails {...period} />;
case 'mannerist':
return <ManneristDetails {...period} />;
case 'baroque':
return <BaroqueDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
case 'medieval':
return <MedievalDetails halfW={halfW} halfD={halfD} />;
return <BaroqueStateDetails {...period} />;
case 'rococo':
return <RococoDetails {...period} />;
case 'neoclassical':
return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
case 'classical':
return <ClassicalDetails halfW={halfW} trim={trim} />;
case 'salon':
return <SalonDetails trim={trim} />;
default:
return null;
return <NeoclassicalDetails {...period} />;
case 'gothic-revival':
return <GothicRevivalDetails {...period} />;
case 'bourgeois':
return <BourgeoisSalonDetails {...period} />;
case 'north-light':
return <NorthLightSalonDetails {...period} />;
case 'paris-atelier':
return <ParisAtelierDetails {...period} />;
case 'symbolist':
return <SymbolistDetails {...period} />;
case 'art-nouveau':
return <ArtNouveauDetails {...period} />;
case 'fauvist':
return <FauvistDetails {...period} />;
case 'expressionist':
return <ExpressionistDetails {...period} />;
case 'cubist-atelier':
return <CubistAtelierDetails {...period} />;
case 'futurist':
return <FuturistDetails {...period} />;
case 'suprematist':
return <SuprematistDetails {...period} />;
case 'constructivist':
return <ConstructivistDetails {...period} />;
case 'dada':
return <DadaDetails {...period} />;
case 'surrealist':
return <SurrealistDetails {...period} />;
case 'loft':
return <LoftDetails {...period} />;
case 'white-cube':
return <WhiteCubeDetails {...period} />;
}
}
+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>
);
}
+109
View File
@@ -441,6 +441,115 @@
font-style: italic;
}
.curator-notes-panel {
max-width: 700px;
width: 100%;
margin-top: 20px;
padding: 16px 18px;
background: rgba(201, 169, 110, 0.1);
border-radius: 6px;
border: 1px solid rgba(201, 169, 110, 0.28);
}
.curator-notes-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.curator-notes-panel h3 {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: 15px;
font-weight: 600;
color: #c9a96e;
}
.curator-notes-body {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: 14px;
line-height: 1.7;
color: rgba(232, 213, 181, 0.92);
white-space: pre-wrap;
}
.curator-notes-empty {
margin: 0;
font-size: 13px;
color: rgba(201, 169, 110, 0.55);
font-style: italic;
}
.curator-notes-edit-btn,
.curator-notes-save-btn,
.curator-notes-cancel-btn {
font-family: Georgia, 'Times New Roman', serif;
font-size: 13px;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
border: 1px solid rgba(201, 169, 110, 0.45);
background: transparent;
color: #c9a96e;
}
.curator-notes-edit-btn:hover,
.curator-notes-cancel-btn:hover {
background: rgba(201, 169, 110, 0.12);
}
.curator-notes-save-btn {
background: rgba(201, 169, 110, 0.2);
color: #e8d5b5;
}
.curator-notes-save-btn:hover:not(:disabled) {
background: rgba(201, 169, 110, 0.32);
}
.curator-notes-edit-btn:disabled,
.curator-notes-save-btn:disabled,
.curator-notes-cancel-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.curator-notes-textarea {
display: block;
width: 100%;
box-sizing: border-box;
margin: 0;
padding: 10px 12px;
border-radius: 4px;
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(15, 15, 26, 0.55);
color: rgba(232, 213, 181, 0.95);
font-family: Georgia, 'Times New Roman', serif;
font-size: 14px;
line-height: 1.6;
resize: vertical;
}
.curator-notes-textarea:focus {
outline: none;
border-color: rgba(201, 169, 110, 0.7);
}
.curator-notes-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.curator-notes-error {
margin: 8px 0 0;
font-size: 13px;
color: #e08a6a;
}
.painting-description {
max-width: 700px;
margin-top: 20px;
+100 -1
View File
@@ -21,6 +21,9 @@ interface Props {
onCatalogNavigate: (paintingId: number) => void;
onArtistBio: () => void;
onInfluenceArtistClick?: (artistId: number) => void;
isCurator?: boolean;
/** When false, hide the Checked action (needs checkup permission). Defaults to true when debugMode is on. */
canCheckup?: boolean;
debugMode?: boolean;
debugShowMore?: boolean;
onPaintingImageFixed?: (
@@ -32,6 +35,7 @@ interface Props {
flags: { checked: boolean; fixed: boolean }
) => void | Promise<void>;
onPaintingRemoved?: (paintingId: number, artistId: number) => void | Promise<void>;
onCuratorNotesUpdated?: (paintingId: number, curatorNotes: string) => void;
}
function influenceKey(inf: InfluenceLink, index: number): string {
@@ -203,15 +207,23 @@ export default function PaintingDetailView({
onCatalogNavigate,
onArtistBio,
onInfluenceArtistClick,
isCurator = false,
canCheckup = true,
debugMode = false,
debugShowMore = false,
onPaintingImageFixed,
onPaintingCheckupFlagsUpdated,
onPaintingRemoved,
onCuratorNotesUpdated,
}: Props) {
const { t } = useTranslation('painting');
const { painting, influencedBy, influenced, annotations = [] } = data;
const inTour = tourText != null;
const [curatorNotes, setCuratorNotes] = useState(painting.curator_notes ?? '');
const [editingNotes, setEditingNotes] = useState(false);
const [notesDraft, setNotesDraft] = useState(painting.curator_notes ?? '');
const [savingNotes, setSavingNotes] = useState(false);
const [notesError, setNotesError] = useState<string | null>(null);
const [fullscreen, setFullscreen] = useState(false);
const [imageVersion, setImageVersion] = useState(0);
const [debugSearch, setDebugSearch] = useState<DebugImageSearchResult | null>(null);
@@ -257,7 +269,13 @@ export default function PaintingDetailView({
setMarkingChecked(false);
setApplyingUrl(null);
setMoreLoading(false);
}, [painting.id]);
const notes = painting.curator_notes ?? '';
setCuratorNotes(notes);
setNotesDraft(notes);
setEditingNotes(false);
setSavingNotes(false);
setNotesError(null);
}, [painting.id, painting.curator_notes]);
useEffect(() => {
if (!debugMode) {
@@ -291,6 +309,34 @@ export default function PaintingDetailView({
};
}, [debugMode, uploading, painting.id, painting.title, painting.artist_name]);
const startEditingNotes = () => {
setNotesDraft(curatorNotes);
setNotesError(null);
setEditingNotes(true);
};
const cancelEditingNotes = () => {
setNotesDraft(curatorNotes);
setNotesError(null);
setEditingNotes(false);
};
const saveCuratorNotes = async () => {
setSavingNotes(true);
setNotesError(null);
try {
const result = await api.updatePaintingCuratorNotes(painting.id, notesDraft);
setCuratorNotes(result.curatorNotes);
setNotesDraft(result.curatorNotes);
setEditingNotes(false);
onCuratorNotesUpdated?.(painting.id, result.curatorNotes);
} catch {
setNotesError(t('curatorNotesSaveFailed'));
} finally {
setSavingNotes(false);
}
};
const applyImageUpdate = async (fixResult: FixPaintingImageResult) => {
setImageVersion((v) => v + 1);
if (onPaintingImageFixed) {
@@ -586,6 +632,57 @@ export default function PaintingDetailView({
)}
</aside>
)}
{(isCurator || curatorNotes.trim()) && (
<aside className="curator-notes-panel" aria-label={t('curatorNotes')}>
<div className="curator-notes-header">
<h3>{t('curatorNotes')}</h3>
{isCurator && !editingNotes && (
<button
type="button"
className="curator-notes-edit-btn"
onClick={startEditingNotes}
>
{t('curatorNotesEdit')}
</button>
)}
</div>
{editingNotes ? (
<div className="curator-notes-editor">
<textarea
className="curator-notes-textarea"
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
rows={6}
disabled={savingNotes}
aria-label={t('curatorNotes')}
/>
{notesError && <p className="curator-notes-error">{notesError}</p>}
<div className="curator-notes-actions">
<button
type="button"
className="curator-notes-save-btn"
onClick={saveCuratorNotes}
disabled={savingNotes}
>
{savingNotes ? t('curatorNotesSaving') : t('curatorNotesSave')}
</button>
<button
type="button"
className="curator-notes-cancel-btn"
onClick={cancelEditingNotes}
disabled={savingNotes}
>
{t('curatorNotesCancel')}
</button>
</div>
</div>
) : curatorNotes.trim() ? (
<p className="curator-notes-body">{curatorNotes}</p>
) : (
<p className="curator-notes-empty">{t('curatorNotesEmpty')}</p>
)}
</aside>
)}
{painting.description && (
<div className="painting-description">
<p>{painting.description}</p>
@@ -640,6 +737,7 @@ export default function PaintingDetailView({
<p className="debug-image-status">No Google image result found.</p>
)}
<div className="debug-action-buttons">
{canCheckup && (
<button
type="button"
className="debug-checked-btn"
@@ -648,6 +746,7 @@ export default function PaintingDetailView({
>
{markingChecked ? '…' : 'Checked'}
</button>
)}
<button
type="button"
className="debug-fix-btn"
+3 -1
View File
@@ -1,4 +1,5 @@
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { HistoricalEra } from '../types';
import {
HISTORICAL_EVENTS,
@@ -38,6 +39,7 @@ function formatYear(year: number): string {
}
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight }: Props) {
const { t } = useTranslation('home');
const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<'left' | 'right' | 'pan' | null>(null);
const [containerWidth, setContainerWidth] = useState(800);
@@ -365,7 +367,7 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
</div>
<p className="timeline-hint">
Click an era or event to zoom · Scroll to zoom · Drag to pan
{t('captionClassicTimeline')}
</p>
</div>
);
@@ -0,0 +1,104 @@
.vflow {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 8px 12px 12px;
}
.vflow-caption {
margin: 0 0 8px;
text-align: center;
font-family: 'Georgia', serif;
font-size: 13px;
font-style: italic;
color: rgba(201, 169, 110, 0.65);
flex-shrink: 0;
}
.vflow-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
text-align: center;
color: rgba(201, 169, 110, 0.6);
font-family: 'Georgia', serif;
}
.vflow-canvas {
position: relative;
flex: 1;
min-height: 0;
width: 100%;
border-radius: 8px;
background:
radial-gradient(ellipse 70% 80% at 50% 0%, rgba(201, 169, 110, 0.07), transparent 70%),
rgba(0, 0, 0, 0.25);
border: 1px solid rgba(201, 169, 110, 0.12);
overflow: hidden;
cursor: grab;
touch-action: none;
}
.vflow-canvas.vflow-panning {
cursor: grabbing;
}
.vflow-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.vflow-stream {
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.72;
cursor: pointer;
transition: opacity 0.15s ease, filter 0.15s ease;
vector-effect: non-scaling-stroke;
}
.vflow-stream-highlighted {
opacity: 1;
filter: brightness(1.15);
}
.vflow-branch {
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.95;
pointer-events: none;
vector-effect: non-scaling-stroke;
}
.vflow-branch-highlighted {
opacity: 1;
filter: brightness(1.25) drop-shadow(0 0 4px rgba(255, 230, 180, 0.45));
}
.vflow-label {
position: absolute;
z-index: 4;
transform: translate(-50%, 50%);
margin: 0;
padding: 2px 8px;
border: none;
border-radius: 4px;
background: rgba(12, 12, 22, 0.72);
color: rgba(245, 230, 200, 0.95);
font-family: 'Georgia', serif;
font-size: 12px;
white-space: nowrap;
cursor: pointer;
pointer-events: auto;
}
.vflow-label:hover {
background: rgba(30, 28, 40, 0.9);
color: #fff6e0;
}
@@ -0,0 +1,734 @@
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { ArtMovement } from '../types';
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
import { vividMovementColor } from '../utils/movementColor';
import './VerticalMovementBands.css';
interface Props {
movements: ArtMovement[];
viewStart: number;
viewEnd: number;
absoluteMin: number;
absoluteMax: number;
onViewChange: (start: number, end: number) => void;
onMovementClick?: (movementId: number) => void;
}
interface VLayout {
movement: ArtMovement;
/** Year span as % from bottom (earlier = lower). */
yStart: number;
yEnd: number;
/** Lane center as % from left. */
x: number;
strokePx: number;
displayColor: string;
parentIds: number[];
}
interface VBranch {
key: string;
d: string;
colorFrom: string;
colorTo: string;
strokePx: number;
fromId: number;
toId: number;
x1: number;
y1: number;
x2: number;
y2: number;
}
const MAX_STROKE = 108; // ~300% of prior max (36)
const MIN_STROKE = 54; // ~300% of prior min (18)
const SIDE_PAD = 24;
/** Preferred centre-to-centre spacing: stroke + small gap (keeps columns tight). */
const PREFERRED_LANE_PITCH = MAX_STROKE + 16;
const LANE_MIN_GAP_YEARS = 2;
function yearToBottomPercent(year: number, start: number, end: number): number {
return ((year - start) / (end - start)) * 100;
}
function buildLineageParentMap(
visible: ArtMovement[],
nameToId: Map<string, number>
): Map<number, number[]> {
const parents = new Map<number, number[]>();
const visibleIds = new Set(visible.map((m) => m.id));
for (const [parentName, childName] of MOVEMENT_LINEAGE) {
const parentId = nameToId.get(parentName);
const childId = nameToId.get(childName);
if (parentId == null || childId == null) continue;
if (!visibleIds.has(parentId) || !visibleIds.has(childId)) continue;
const list = parents.get(childId) || [];
if (!list.includes(parentId)) list.push(parentId);
parents.set(childId, list);
}
return parents;
}
function assignDepths(
group: ArtMovement[],
lineageParents: Map<number, number[]>
): Map<number, number> {
const depths = new Map<number, number>();
const visiting = new Set<number>();
const visit = (id: number): number => {
if (depths.has(id)) return depths.get(id)!;
if (visiting.has(id)) return 0;
visiting.add(id);
const parents = lineageParents.get(id) || [];
const d = parents.length ? 1 + Math.max(...parents.map(visit)) : 0;
visiting.delete(id);
depths.set(id, d);
return d;
};
for (const m of group) visit(m.id);
return depths;
}
/** Prefer center, then alternate right / left: 0, +1, -1, +2, -2, … */
function centerOutOffsets(max = 64): number[] {
const out = [0];
for (let d = 1; d <= max; d++) out.push(d, -d);
return out;
}
function pickNearestFreeLane(preferred: number, isFree: (lane: number) => boolean): number {
for (const delta of centerOutOffsets()) {
const lane = preferred + delta;
if (isFree(lane)) return lane;
}
return preferred;
}
/** Collapse signed lane indices to contiguous 0..n-1 (left → right). */
function compactSignedLanes(laneById: Map<number, number>): void {
const usedSorted = [...new Set(laneById.values())].sort((a, b) => a - b);
const remap = new Map(usedSorted.map((lane, index) => [lane, index]));
for (const [id, lane] of laneById) {
laneById.set(id, remap.get(lane) ?? 0);
}
}
function assignTemporalLanes(
group: ArtMovement[],
viewStart: number,
viewEnd: number,
lineageParents: Map<number, number[]>
): Map<number, number> {
const depths = assignDepths(group, lineageParents);
const spans = group
.map((m) => ({
id: m.id,
start: Math.max(m.start_year, viewStart),
end: Math.min(m.end_year, viewEnd),
depth: depths.get(m.id) ?? 0,
}))
.filter((s) => s.end > s.start)
.sort((a, b) => a.depth - b.depth || a.start - b.start || a.end - b.end);
/** Signed lane → year when that lane frees up. */
const laneEnds = new Map<number, number>();
const laneById = new Map<number, number>();
for (const span of spans) {
const parentLanes = (lineageParents.get(span.id) || [])
.map((pid) => laneById.get(pid))
.filter((lane): lane is number => lane != null);
const preferred =
parentLanes.length > 0
? Math.round(parentLanes.reduce((s, l) => s + l, 0) / parentLanes.length)
: 0;
const lane = pickNearestFreeLane(preferred, (candidate) => {
const end = laneEnds.get(candidate);
return end == null || end + LANE_MIN_GAP_YEARS <= span.start;
});
laneEnds.set(lane, span.end);
laneById.set(span.id, lane);
}
compactSignedLanes(laneById);
return laneById;
}
/**
* Prefer one column per movement when the canvas is wide enough, so streams
* do not stack in the same vertical lane. Fall back to temporal packing only
* when there is not enough horizontal room.
*
* Lanes grow from the center outward (0, +1, 1, …) so the layout reads as a tree.
*/
function assignVerticalLanes(
group: ArtMovement[],
viewStart: number,
viewEnd: number,
lineageParents: Map<number, number[]>,
canvasWidth: number
): Map<number, number> {
const usable = Math.max(200, canvasWidth - SIDE_PAD * 2);
const minLanePx = PREFERRED_LANE_PITCH;
const maxExclusive = Math.max(1, Math.floor(usable / minLanePx));
if (group.length <= maxExclusive) {
const depths = assignDepths(group, lineageParents);
// Roots first so the trunk claims center; children then fan around parents.
const sorted = [...group].sort(
(a, b) =>
(depths.get(a.id) ?? 0) - (depths.get(b.id) ?? 0) ||
a.start_year - b.start_year ||
a.name.localeCompare(b.name)
);
const laneById = new Map<number, number>();
const used = new Set<number>();
for (const m of sorted) {
const parentLanes = (lineageParents.get(m.id) || [])
.map((pid) => laneById.get(pid))
.filter((lane): lane is number => lane != null);
const preferred =
parentLanes.length > 0
? Math.round(parentLanes.reduce((s, l) => s + l, 0) / parentLanes.length)
: 0;
const lane = pickNearestFreeLane(preferred, (candidate) => !used.has(candidate));
used.add(lane);
laneById.set(m.id, lane);
}
compactSignedLanes(laneById);
return laneById;
}
return assignTemporalLanes(group, viewStart, viewEnd, lineageParents);
}
function influenceCount(m: ArtMovement): number {
const n = m.influence_link_count;
return typeof n === 'number' && Number.isFinite(n) ? Math.max(0, n) : 0;
}
function pctToSvg(xPct: number, yBottomPct: number, widthPx: number, heightPx: number) {
return {
x: (xPct / 100) * widthPx,
y: ((100 - yBottomPct) / 100) * heightPx,
};
}
/** Vertical stream path: time along Y (SVG y grows down → invert bottom%). */
function verticalStreamPath(
xPct: number,
yStartPct: number,
yEndPct: number,
heightPx: number,
widthPx: number
): string {
const start = pctToSvg(xPct, yEndPct, widthPx, heightPx);
const end = pctToSvg(xPct, yStartPct, widthPx, heightPx);
const midY = (start.y + end.y) / 2;
const bulge = Math.min(18, Math.abs(end.y - start.y) * 0.06);
return `M ${start.x} ${start.y} C ${start.x + bulge} ${midY}, ${end.x - bulge} ${midY}, ${end.x} ${end.y}`;
}
/** Absolute-year anchors so pan/scroll keeps a constant connection angle. */
function branchAnchorYears(
parent: ArtMovement,
child: ArtMovement,
childIndex: number,
childCount: number
): { originYear: number; targetYear: number } | null {
const parentSpan = parent.end_year - parent.start_year;
if (parentSpan <= 0) return null;
const tBase = childCount === 1 ? 0.38 : 0.28 + (childIndex / Math.max(1, childCount - 1)) * 0.22;
let originYear = parent.start_year + parentSpan * tBase;
const targetYear = child.start_year;
if (originYear >= targetYear) {
originYear = Math.min(parent.start_year + parentSpan * 0.2, targetYear - 1);
}
originYear = Math.max(parent.start_year, Math.min(parent.end_year, originYear));
if (originYear >= targetYear) return null;
return { originYear, targetYear };
}
function branchPath(x1: number, y1: number, x2: number, y2: number): string {
const dx = x2 - x1;
const dy = y2 - y1;
// Pull control points along the diagonal so the curve reads as a waterfall, not an L-stair.
const c1x = x1 + dx * 0.35;
const c1y = y1 + dy * 0.55;
const c2x = x2 - dx * 0.25;
const c2y = y2 - dy * 0.2;
return `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`;
}
/** Exponential chase rate (1/s) so lane / view shifts read as motion, not snaps. */
const LAYOUT_ANIM_RATE = 14;
const LAYOUT_ANIM_EPS = 0.06;
interface AnimatedVFlow {
layouts: VLayout[];
branches: VBranch[];
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
function geomSettled(a: number, b: number, eps = LAYOUT_ANIM_EPS): boolean {
return Math.abs(a - b) <= eps;
}
function lerpVLayout(from: VLayout, to: VLayout, t: number): VLayout {
return {
...to,
yStart: lerp(from.yStart, to.yStart, t),
yEnd: lerp(from.yEnd, to.yEnd, t),
x: lerp(from.x, to.x, t),
strokePx: lerp(from.strokePx, to.strokePx, t),
};
}
function vLayoutSettled(a: VLayout, b: VLayout): boolean {
return (
geomSettled(a.yStart, b.yStart) &&
geomSettled(a.yEnd, b.yEnd) &&
geomSettled(a.x, b.x) &&
geomSettled(a.strokePx, b.strokePx, 0.35)
);
}
function lerpVBranch(from: VBranch, to: VBranch, t: number): VBranch {
const x1 = lerp(from.x1, to.x1, t);
const y1 = lerp(from.y1, to.y1, t);
const x2 = lerp(from.x2, to.x2, t);
const y2 = lerp(from.y2, to.y2, t);
const strokePx = lerp(from.strokePx, to.strokePx, t);
return {
...to,
x1,
y1,
x2,
y2,
strokePx,
d: branchPath(x1, y1, x2, y2),
};
}
function vBranchSettled(a: VBranch, b: VBranch): boolean {
return (
geomSettled(a.x1, b.x1) &&
geomSettled(a.y1, b.y1) &&
geomSettled(a.x2, b.x2) &&
geomSettled(a.y2, b.y2) &&
geomSettled(a.strokePx, b.strokePx, 0.35)
);
}
function blendVFlow(
from: AnimatedVFlow,
to: AnimatedVFlow,
t: number
): { next: AnimatedVFlow; settled: boolean } {
const fromLayouts = new Map(from.layouts.map((l) => [l.movement.id, l]));
const fromBranches = new Map(from.branches.map((b) => [b.key, b]));
let settled = true;
const layouts = to.layouts.map((target) => {
const prev = fromLayouts.get(target.movement.id);
if (!prev) return target;
if (vLayoutSettled(prev, target)) return target;
settled = false;
return lerpVLayout(prev, target, t);
});
const branches = to.branches.map((target) => {
const prev = fromBranches.get(target.key);
if (!prev) return target;
if (vBranchSettled(prev, target)) return target;
settled = false;
return lerpVBranch(prev, target, t);
});
return { next: { layouts, branches }, settled };
}
export default function VerticalMovementBands({
movements,
viewStart,
viewEnd,
absoluteMin,
absoluteMax,
onViewChange,
onMovementClick,
}: Props) {
const { t } = useTranslation('home');
const canvasRef = useRef<HTMLDivElement>(null);
const [canvasSize, setCanvasSize] = useState({ w: 800, h: 600 });
const [panning, setPanning] = useState(false);
const [hoveredMovementId, setHoveredMovementId] = useState<number | null>(null);
const [flowVisual, setFlowVisual] = useState<AnimatedVFlow | null>(null);
const panStart = useRef({ y: 0, viewStart: 0, viewEnd: 0 });
const viewRef = useRef({ viewStart, viewEnd });
const onViewChangeRef = useRef(onViewChange);
const flowVisualRef = useRef<AnimatedVFlow | null>(null);
const flowTargetRef = useRef<AnimatedVFlow | null>(null);
const flowRafRef = useRef<number | null>(null);
const flowLastTsRef = useRef(0);
viewRef.current = { viewStart, viewEnd };
onViewChangeRef.current = onViewChange;
useLayoutEffect(() => {
const el = canvasRef.current;
if (!el) return;
const measure = () => {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
setCanvasSize({ w: Math.round(rect.width), h: Math.round(rect.height) });
}
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
const visibleMovements = useMemo(
() => movements.filter((m) => m.end_year > viewStart && m.start_year < viewEnd),
[movements, viewStart, viewEnd]
);
const { layouts, branches } = useMemo(() => {
if (visibleMovements.length === 0) {
return { layouts: [] as VLayout[], branches: [] as VBranch[] };
}
const nameToId = new Map(movements.map((m) => [m.name, m.id]));
const lineageParents = buildLineageParentMap(visibleMovements, nameToId);
const laneIndex = assignVerticalLanes(
visibleMovements,
viewStart,
viewEnd,
lineageParents,
canvasSize.w
);
let maxLanes = 0;
for (const m of visibleMovements) {
const lane = laneIndex.get(m.id) ?? 0;
maxLanes = Math.max(maxLanes, lane + 1);
}
maxLanes = Math.max(1, maxLanes);
const usable = Math.max(200, canvasSize.w - SIDE_PAD * 2);
// Pack columns tightly; only stretch if the canvas is narrower than the preferred cluster.
const lanePitch = Math.min(usable / maxLanes, PREFERRED_LANE_PITCH);
const clusterWidth = lanePitch * maxLanes;
const startX = SIDE_PAD + Math.max(0, (usable - clusterWidth) / 2);
const laneCentersPct: number[] = [];
for (let lane = 0; lane < maxLanes; lane++) {
const centerPx = startX + lanePitch * lane + lanePitch / 2;
laneCentersPct[lane] = (centerPx / Math.max(1, canvasSize.w)) * 100;
}
const maxInf = Math.max(1, ...visibleMovements.map(influenceCount));
const layoutById = new Map<number, VLayout>();
for (const m of visibleMovements) {
const yStart = yearToBottomPercent(Math.max(m.start_year, viewStart), viewStart, viewEnd);
const yEnd = yearToBottomPercent(Math.min(m.end_year, viewEnd), viewStart, viewEnd);
if (yEnd <= yStart) continue;
const lane = laneIndex.get(m.id) ?? 0;
const baseStroke =
MIN_STROKE + (influenceCount(m) / maxInf) * (MAX_STROKE - MIN_STROKE);
// Allow nearly full preferred stroke; only shrink if the pitch is forced smaller.
const strokePx = Math.min(MAX_STROKE, Math.max(MIN_STROKE, baseStroke), lanePitch * 0.88);
layoutById.set(m.id, {
movement: m,
yStart,
yEnd,
x: laneCentersPct[lane] ?? 50,
strokePx,
displayColor: vividMovementColor(m.color),
parentIds: lineageParents.get(m.id) || [],
});
}
const childIdsByParent = new Map<number, number[]>();
for (const layout of layoutById.values()) {
for (const parentId of layout.parentIds) {
if (!layoutById.has(parentId)) continue;
const children = childIdsByParent.get(parentId) || [];
children.push(layout.movement.id);
childIdsByParent.set(parentId, children);
}
}
for (const children of childIdsByParent.values()) {
children.sort((a, b) => {
const la = layoutById.get(a)!;
const lb = layoutById.get(b)!;
return la.x - lb.x || la.movement.start_year - lb.movement.start_year;
});
}
const branchList: VBranch[] = [];
for (const layout of layoutById.values()) {
for (const parentId of layout.parentIds) {
const parent = layoutById.get(parentId);
if (!parent) continue;
const children = childIdsByParent.get(parentId) || [layout.movement.id];
const childIndex = children.indexOf(layout.movement.id);
const anchors = branchAnchorYears(
parent.movement,
layout.movement,
childIndex,
children.length
);
if (!anchors) continue;
// Map fixed calendar years → current view % so pan keeps dx/dy (and angle) stable.
const originY = yearToBottomPercent(anchors.originYear, viewStart, viewEnd);
const targetY = yearToBottomPercent(anchors.targetYear, viewStart, viewEnd);
const from = pctToSvg(parent.x, originY, canvasSize.w, canvasSize.h);
const to = pctToSvg(layout.x, targetY, canvasSize.w, canvasSize.h);
branchList.push({
key: `${parentId}-${layout.movement.id}`,
d: branchPath(from.x, from.y, to.x, to.y),
colorFrom: parent.displayColor,
colorTo: layout.displayColor,
strokePx: Math.max(14, Math.min(parent.strokePx, layout.strokePx) * 0.55),
fromId: parentId,
toId: layout.movement.id,
x1: from.x,
y1: from.y,
x2: to.x,
y2: to.y,
});
}
}
return {
layouts: [...layoutById.values()].sort((a, b) => a.movement.start_year - b.movement.start_year),
branches: branchList,
};
}, [visibleMovements, movements, viewStart, viewEnd, canvasSize.w, canvasSize.h]);
useLayoutEffect(() => {
const target: AnimatedVFlow = { layouts, branches };
flowTargetRef.current = target;
if (!flowVisualRef.current) {
flowVisualRef.current = target;
setFlowVisual(target);
return;
}
if (flowRafRef.current != null) return;
flowLastTsRef.current = performance.now();
const step = (now: number) => {
const prev = flowVisualRef.current;
const goal = flowTargetRef.current;
if (!prev || !goal) {
flowRafRef.current = null;
return;
}
const dt = Math.min(0.048, Math.max(0, (now - flowLastTsRef.current) / 1000));
flowLastTsRef.current = now;
const t = 1 - Math.exp(-LAYOUT_ANIM_RATE * dt);
const { next, settled } = blendVFlow(prev, goal, t);
flowVisualRef.current = settled ? goal : next;
setFlowVisual(flowVisualRef.current);
if (settled) {
flowRafRef.current = null;
return;
}
flowRafRef.current = requestAnimationFrame(step);
};
flowRafRef.current = requestAnimationFrame(step);
}, [layouts, branches]);
useEffect(() => {
return () => {
if (flowRafRef.current != null) {
cancelAnimationFrame(flowRafRef.current);
flowRafRef.current = null;
}
};
}, []);
useEffect(() => {
const el = canvasRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = el.getBoundingClientRect();
const { viewStart: vs, viewEnd: ve } = viewRef.current;
const invertedClientY = rect.bottom - (e.clientY - rect.top);
const next = zoomTimelineView(
invertedClientY,
0,
rect.height,
e.deltaY,
vs,
ve,
absoluteMin,
absoluteMax
);
onViewChangeRef.current(next.start, next.end);
};
el.addEventListener('wheel', onWheel, { passive: false, capture: true });
return () => el.removeEventListener('wheel', onWheel, { capture: true });
}, [absoluteMin, absoluteMax]);
const handlePanStart = useCallback(
(e: React.MouseEvent) => {
if (e.button !== 0) return;
e.preventDefault();
setPanning(true);
panStart.current = { y: e.clientY, viewStart, viewEnd };
},
[viewStart, viewEnd]
);
useEffect(() => {
if (!panning) return;
const onMove = (e: MouseEvent) => {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const dy = e.clientY - panStart.current.y;
const next = panTimelineView(
-dy,
rect.height,
panStart.current.viewStart,
panStart.current.viewEnd,
absoluteMin,
absoluteMax
);
onViewChangeRef.current(next.start, next.end);
};
const onUp = () => setPanning(false);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, [panning, absoluteMin, absoluteMax]);
if (visibleMovements.length === 0) {
return (
<div className="vflow-empty">
<p>No art movements in this time range. Zoom out to explore more periods.</p>
</div>
);
}
const displayLayouts = flowVisual?.layouts ?? layouts;
const displayBranches = flowVisual?.branches ?? branches;
return (
<div className="vflow">
<p className="vflow-caption">
{t('captionVerticalFlow')}
</p>
<div
ref={canvasRef}
className={`vflow-canvas${panning ? ' vflow-panning' : ''}`}
onMouseDown={handlePanStart}
>
<svg
className="vflow-svg"
viewBox={`0 0 ${canvasSize.w} ${canvasSize.h}`}
preserveAspectRatio="none"
>
<defs>
{displayBranches.map((branch) => (
<linearGradient
key={`grad-${branch.key}`}
id={`vflow-branch-grad-${branch.key}`}
gradientUnits="userSpaceOnUse"
x1={branch.x1}
y1={branch.y1}
x2={branch.x2}
y2={branch.y2}
>
<stop offset="0%" stopColor={branch.colorFrom} stopOpacity={0.9} />
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0.9} />
</linearGradient>
))}
</defs>
{displayLayouts.map((layout) => {
const d = verticalStreamPath(
layout.x,
layout.yStart,
layout.yEnd,
canvasSize.h,
canvasSize.w
);
const highlighted = hoveredMovementId === layout.movement.id;
return (
<path
key={layout.movement.id}
d={d}
className={`vflow-stream${highlighted ? ' vflow-stream-highlighted' : ''}`}
stroke={layout.displayColor}
fill="none"
style={{ strokeWidth: layout.strokePx }}
onMouseEnter={() => setHoveredMovementId(layout.movement.id)}
onMouseLeave={() => setHoveredMovementId(null)}
onClick={(e) => {
e.stopPropagation();
onMovementClick?.(layout.movement.id);
}}
/>
);
})}
{displayBranches.map((branch) => {
const highlighted =
hoveredMovementId != null &&
(branch.fromId === hoveredMovementId || branch.toId === hoveredMovementId);
return (
<path
key={branch.key}
d={branch.d}
className={`vflow-branch${highlighted ? ' vflow-branch-highlighted' : ''}`}
stroke={`url(#vflow-branch-grad-${branch.key})`}
fill="none"
style={{ strokeWidth: branch.strokePx }}
/>
);
})}
</svg>
{displayLayouts.map((layout) => {
const midY = (layout.yStart + layout.yEnd) / 2;
return (
<button
key={`label-${layout.movement.id}`}
type="button"
className="vflow-label"
style={{
left: `${layout.x}%`,
bottom: `${midY}%`,
}}
onClick={(e) => {
e.stopPropagation();
onMovementClick?.(layout.movement.id);
}}
onMouseEnter={() => setHoveredMovementId(layout.movement.id)}
onMouseLeave={() => setHoveredMovementId(null)}
>
{layout.movement.name}
</button>
);
})}
</div>
</div>
);
}
+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>
);
}
+6
View File
@@ -419,3 +419,9 @@
width: 100%;
margin-top: 8px;
}
.exit-nav-footer {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid rgba(201, 169, 110, 0.25);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,271 @@
import {
CofferedCeiling,
CorniceRing,
FlutedShaft,
HangingFixture,
RoundArch,
ShaftBase,
TuscanCapital,
WallBand,
} from './primitives';
import {
shade,
useBayZ,
useCorners,
useRepeat,
useWallClearance,
type PeriodProps,
} from './geometry';
/**
* Roman atrium Ancient / Classical.
*
* Engaged Tuscan half-columns on a bay rhythm carry a compressed but complete
* entablature (architrave, plain frieze, dentil course, projecting cornice).
* Below the frame line the walls carry the Pompeian red socle of a Fourth Style
* room; a bronze polycandelon hangs over the centre in place of ceiling lights.
*/
export function RomanAtriumDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 4.0, 9);
const corners = useCorners(room.halfW, room.halfD, 0.1);
const stone = '#e6dcc6';
const shadowStone = shade(stone, -0.14);
const socle = '#7c3227';
const dentils = useRepeat(room.depth, 0.46, 30);
return (
<group>
{/* Pompeian socle with a black skirting course */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.62} height={1.16} proud={0.07} color={socle} roughness={0.8} metalness={0.04} />
<WallBand wall={wall} room={room} y={0.09} height={0.18} proud={0.09} color="#241a14" roughness={0.7} metalness={0.06} />
<WallBand wall={wall} room={room} y={1.24} height={0.08} proud={0.1} color={stone} roughness={0.66} metalness={0.06} />
</group>
))}
{/* Engaged half-columns on the bay rhythm */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.115), 0, z]}>
<ShaftBase size={0.15} color={shadowStone} />
<group position={[0, 0.26, 0]}>
<FlutedShaft height={3.34} radius={0.135} color={stone} flutes={10} facing="x" />
</group>
<TuscanCapital y={3.72} size={0.15} color={stone} />
</group>
))}
</group>
))}
{/* Entablature: architrave, frieze and dentils along the colonnaded walls */}
{(['left', 'right'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={3.9} height={0.1} proud={0.16} color={shadowStone} roughness={0.7} />
<WallBand wall={wall} room={room} y={3.99} height={0.11} proud={0.13} color={stone} roughness={0.74} />
{dentils.map((z, i) => (
<mesh
key={i}
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.16), 4.07, z]}
>
<boxGeometry args={[0.16, 0.07, 0.16]} />
<meshStandardMaterial color={shade(stone, 0.06)} roughness={0.7} metalness={0.05} />
</mesh>
))}
</group>
))}
{/* Projecting cornice returning around the whole room */}
<CorniceRing room={room} y={4.13} height={0.09} proud={0.19} color={trim} roughness={0.62} metalness={0.12} />
{/* Corner antae — square pilasters closing the order at each corner */}
{corners.map(([x, z], i) => (
<mesh key={i} position={[x, 1.95, z]}>
<boxGeometry args={[0.2, 3.9, 0.2]} />
<meshStandardMaterial color={shade(stone, 0.04)} roughness={0.76} metalness={0.04} />
</mesh>
))}
<HangingFixture
position={[0, 4.05, 0]}
dropLength={0.72}
radius={0.46}
arms={8}
metalColor={trim}
light
lightColor="#ffb862"
lightIntensity={0.9}
lightDistance={9}
/>
</group>
);
}
/**
* Florentine palazzo Early Renaissance.
*
* Brunelleschi's grammar: flat pietra serena pilasters set against lime plaster,
* a blind arcade of semicircular arches springing between them, glazed roundels
* in the spandrels, and a painted-and-beamed quattrocento ceiling.
*/
export function QuattrocentoDetails({ room, trim, windows }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 3.8, 9);
const clearLeft = useWallClearance(windows, 'left');
const clearRight = useWallClearance(windows, 'right');
const serena = '#8b8f8a';
const serenaLight = shade(serena, 0.16);
const step = bays.length > 1 ? bays[1] - bays[0] : room.depth / 2;
return (
<group>
{/* Pietra serena dado with a moulded cap */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.6} height={1.2} proud={0.05} color={shade(serena, 0.24)} roughness={0.72} metalness={0.05} />
<WallBand wall={wall} room={room} y={1.24} height={0.09} proud={0.08} color={serena} roughness={0.62} metalness={0.07} />
</group>
))}
{/* Flat pilasters carrying the arcade */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.05), 0, z]}>
<mesh position={[0, 1.78, 0]}>
<boxGeometry args={[0.08, 3.56, 0.34]} />
<meshStandardMaterial color={serena} roughness={0.66} metalness={0.07} />
</mesh>
<mesh position={[0, 0.11, 0]}>
<boxGeometry args={[0.11, 0.22, 0.44]} />
<meshStandardMaterial color={shade(serena, -0.08)} roughness={0.7} metalness={0.06} />
</mesh>
{/* Simplified Corinthian capital */}
<mesh position={[0, 3.68, 0]}>
<boxGeometry args={[0.1, 0.2, 0.44]} />
<meshStandardMaterial color={serenaLight} roughness={0.6} metalness={0.08} />
</mesh>
<mesh position={[0, 3.82, 0]}>
<boxGeometry args={[0.12, 0.09, 0.5]} />
<meshStandardMaterial color={serenaLight} roughness={0.58} metalness={0.08} />
</mesh>
</group>
))}
</group>
))}
{/* Blind arcade between the pilasters, plus a roundel in each spandrel */}
{bays.slice(0, -1).map((z, i) => {
const mid = z + step / 2;
return (
<group key={i}>
{([-1, 1] as const).map((side) => (
<group key={side} position={[side * (room.halfW - 0.05), 3.88, mid]} rotation={[0, Math.PI / 2, 0]}>
<RoundArch
span={step - 0.34}
rise={0.24}
thickness={0.12}
depth={0.09}
color={serena}
segments={9}
roughness={0.64}
metalness={0.07}
/>
{/* Glazed tondo, only where no window claims the bay */}
{(side < 0 ? clearLeft : clearRight)(mid, 0.22) && (
<mesh position={[0, -0.5, 0]}>
<torusGeometry args={[0.17, 0.035, 8, 20]} />
<meshStandardMaterial color={trim} roughness={0.42} metalness={0.35} />
</mesh>
)}
</group>
))}
</group>
);
})}
{/* Painted beam ceiling over the arcade */}
<CorniceRing room={room} y={4.11} height={0.1} proud={0.14} color={serena} roughness={0.64} metalness={0.07} />
<CofferedCeiling
room={room}
y={4.09}
cell={2.0}
panelColor="#e2d3ba"
ribColor="#8a5a30"
ribWidth={0.14}
ribDrop={0.14}
/>
</group>
);
}
/**
* Roman palazzo High Renaissance.
*
* Bramante's cinquecento order: paired fluted Corinthian pilasters on a wide
* bay, a full marble entablature, panelled dado, and a rosetted coffer field
* modelled on the Pantheon soffit.
*/
export function CinquecentoDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 5.2, 6);
const marble = '#efe9dd';
const grey = shade(marble, -0.16);
return (
<group>
{/* Panelled marble dado */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.58} height={1.16} proud={0.06} color={grey} roughness={0.44} metalness={0.1} />
<WallBand wall={wall} room={room} y={1.2} height={0.1} proud={0.09} color={marble} roughness={0.38} metalness={0.12} />
<WallBand wall={wall} room={room} y={0.07} height={0.14} proud={0.1} color={shade(grey, -0.18)} roughness={0.4} metalness={0.12} />
</group>
))}
{/* Paired fluted pilasters */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) =>
[-0.19, 0.19].map((dz) => (
<group key={`${side}-${dz}`} position={[side * (room.halfW - 0.13), 0, z + dz]}>
<ShaftBase size={0.12} color={grey} />
<group position={[0, 0.24, 0]}>
<FlutedShaft height={3.32} radius={0.11} color={marble} flutes={8} facing="x" />
</group>
<mesh position={[0, 3.66, 0]}>
<cylinderGeometry args={[0.15, 0.1, 0.24, 12]} />
<meshStandardMaterial color={marble} roughness={0.42} metalness={0.1} />
</mesh>
<mesh position={[0, 3.82, 0]}>
<boxGeometry args={[0.12, 0.1, 0.34]} />
<meshStandardMaterial color={shade(marble, 0.06)} roughness={0.4} metalness={0.11} />
</mesh>
</group>
))
)}
</group>
))}
{/* Full entablature on the pilastered walls, cornice all round */}
{(['left', 'right'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={3.92} height={0.1} proud={0.15} color={marble} roughness={0.42} metalness={0.1} />
<WallBand wall={wall} room={room} y={4.02} height={0.1} proud={0.12} color={grey} roughness={0.46} metalness={0.1} />
</group>
))}
<CorniceRing room={room} y={4.12} height={0.09} proud={0.18} color={trim} roughness={0.4} metalness={0.28} />
<CofferedCeiling
room={room}
y={4.08}
cell={2.6}
panelColor="#f2ede3"
ribColor="#ded5c4"
ribWidth={0.2}
ribDrop={0.18}
rosette={trim}
/>
</group>
);
}
@@ -0,0 +1,415 @@
import {
CeilingBeams,
CeilingRose,
CofferedCeiling,
CorniceRing,
FlutedShaft,
HangingFixture,
IonicCapital,
ShaftBase,
WallBand,
} from './primitives';
import {
shade,
useBayZ,
useCorners,
useRepeat,
useWallClearance,
type PeriodProps,
} from './geometry';
/**
* Flemish panel hall Northern Renaissance.
*
* Oak linenfold wainscot under a moulded cap rail, carved corbels carrying an
* exposed beam-and-joist ceiling, and the brass ring chandelier that hangs in
* the background of half the period's interiors.
*/
export function FlemishDetails({ room, trim }: PeriodProps) {
const beams = useBayZ(room.depth, room.halfD, 2.3, 10);
const oak = '#6f4a2c';
const oakLight = shade(oak, 0.2);
const folds = useRepeat(room.depth, 0.55, 34);
const backFolds = useRepeat(room.width, 0.55, 34);
return (
<group>
{/* Wainscot ground, cap rail and skirting */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.66} height={1.24} proud={0.06} color={oak} roughness={0.66} metalness={0.08} />
<WallBand wall={wall} room={room} y={1.32} height={0.12} proud={0.1} color={oakLight} roughness={0.54} metalness={0.1} />
<WallBand wall={wall} room={room} y={0.08} height={0.16} proud={0.09} color={shade(oak, -0.3)} roughness={0.6} metalness={0.09} />
</group>
))}
{/* Linenfold ribs across the wainscot */}
{(['left', 'right'] as const).map((wall) =>
folds.map((z, i) => (
<mesh
key={`${wall}-${i}`}
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.07), 0.68, z]}
>
<cylinderGeometry args={[0.035, 0.035, 1.18, 6]} />
<meshStandardMaterial color={oakLight} roughness={0.6} metalness={0.09} />
</mesh>
))
)}
{backFolds.map((x, i) => (
<mesh key={`b-${i}`} position={[x, 0.68, -room.halfD + 0.07]}>
<cylinderGeometry args={[0.035, 0.035, 1.18, 6]} />
<meshStandardMaterial color={oakLight} roughness={0.6} metalness={0.09} />
</mesh>
))}
{/* Carved corbels carrying the beams */}
{beams.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * (room.halfW - 0.11), 3.84, z]}>
<boxGeometry args={[0.2, 0.24, 0.2]} />
<meshStandardMaterial color={shade(oak, -0.12)} roughness={0.66} metalness={0.08} />
</mesh>
))}
</group>
))}
<CeilingBeams room={room} positions={beams} axis="x" width={0.18} height={0.26} y={3.98} color={shade(oak, -0.2)} roughness={0.72} metalness={0.06} />
<CorniceRing room={room} y={4.14} height={0.08} proud={0.12} color={oakLight} roughness={0.56} metalness={0.1} />
<HangingFixture
position={[0, 3.94, 0]}
dropLength={0.5}
radius={0.4}
arms={8}
metalColor={trim}
flameColor="#ffe0a0"
light
lightColor="#ffca80"
lightIntensity={0.85}
lightDistance={8}
/>
</group>
);
}
/**
* Mannerist gallery Mannerism.
*
* The order used against itself: herm pilasters that taper the wrong way,
* banded rustication blocking the shafts, oversized scroll consoles, and a
* cornice deliberately broken over each bay.
*/
export function ManneristDetails({ room, trim, windows }: PeriodProps) {
const clearLeft = useWallClearance(windows, 'left');
const clearRight = useWallClearance(windows, 'right');
const bays = useBayZ(room.depth, room.halfD, 4.4, 7);
const stone = '#ddc9ae';
const dark = shade(stone, -0.34);
const bands = [0.55, 1.55, 2.35, 3.05];
return (
<group>
{/* Heavy blocked socle */}
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.5} height={1.0} proud={0.08} color={dark} roughness={0.82} metalness={0.05} />
))}
{/* Herm pilasters — wider at the shoulder than at the foot */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.05), 0, z]}>
<mesh position={[0, 1.9, 0]}>
<boxGeometry args={[0.09, 3.8, 0.3]} />
<meshStandardMaterial color={stone} roughness={0.78} metalness={0.05} />
</mesh>
{/* Rustication blocks interrupting the shaft */}
{bands.map((y) => (
<mesh key={y} position={[side * 0.03, y, 0]}>
<boxGeometry args={[0.14, 0.3, 0.44]} />
<meshStandardMaterial color={shade(stone, -0.16)} roughness={0.86} metalness={0.04} />
</mesh>
))}
{/* Scroll console instead of a capital */}
<mesh position={[0, 3.86, 0]} rotation={[0, Math.PI / 2, 0]}>
<torusGeometry args={[0.14, 0.055, 8, 16, Math.PI * 1.5]} />
<meshStandardMaterial color={shade(stone, 0.12)} roughness={0.66} metalness={0.08} />
</mesh>
<mesh position={[0, 4.03, 0]}>
<boxGeometry args={[0.16, 0.12, 0.46]} />
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.24} />
</mesh>
</group>
))}
</group>
))}
{/* Broken cornice — segments that stop short of each bay */}
{(['left', 'right'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={3.96} height={0.12} proud={0.14} span={0.62} color={stone} roughness={0.72} metalness={0.06} />
<WallBand wall={wall} room={room} y={4.1} height={0.08} proud={0.17} span={0.86} color={trim} roughness={0.52} metalness={0.26} />
</group>
))}
<CorniceRing room={room} y={4.1} height={0.08} proud={0.15} walls={['back', 'front']} color={trim} roughness={0.52} metalness={0.26} />
{/* Cartouches slung between the bays, clear of the window openings */}
{bays.slice(0, -1).map((z, i) => {
const mid = (z + bays[i + 1]) / 2;
return (
<group key={i}>
{([-1, 1] as const)
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, 0.24))
.map((side) => (
<group key={side} position={[side * (room.halfW - 0.07), 3.6, mid]} rotation={[0, Math.PI / 2, 0]}>
<mesh>
<sphereGeometry args={[0.17, 12, 8]} />
<meshStandardMaterial color={shade(stone, 0.16)} roughness={0.66} metalness={0.08} />
</mesh>
<mesh position={[0, 0, 0.04]}>
<torusGeometry args={[0.19, 0.03, 6, 18]} />
<meshStandardMaterial color={trim} roughness={0.44} metalness={0.4} />
</mesh>
</group>
))}
</group>
);
})}
</group>
);
}
/**
* Baroque state gallery Baroque.
*
* Twisted solomonic half-columns at the corners, gilt-framed velvet field
* panels between the bays, a modillion cornice, and a crystal-and-gilt
* chandelier over the centre of the room.
*/
export function BaroqueStateDetails({ room, trim, windows }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 4.2, 8);
const corners = useCorners(room.halfW, room.halfD, 0.16);
const clearLeft = useWallClearance(windows, 'left');
const clearRight = useWallClearance(windows, 'right');
const gilt = trim;
const modillions = useRepeat(room.depth, 0.86, 26);
return (
<group>
{/* Gilt-edged velvet field panels between the bays, clear of the windows */}
{bays.slice(0, -1).map((z, i) => {
const mid = (z + bays[i + 1]) / 2;
const w = Math.min(1.5, Math.max(0.7, (bays[i + 1] - z) * 0.4));
return (
<group key={i}>
{([-1, 1] as const)
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, w / 2 + 0.1))
.map((side) => (
<group key={side} position={[side * (room.halfW - 0.055), 3.2, mid]}>
<mesh>
<boxGeometry args={[0.03, 1.0, w]} />
<meshStandardMaterial color="#4a1220" roughness={0.9} metalness={0.04} />
</mesh>
<mesh position={[-side * 0.015, 0, 0]}>
<boxGeometry args={[0.04, 1.12, w + 0.12]} />
<meshStandardMaterial color={gilt} roughness={0.3} metalness={0.7} emissive="#3a2808" emissiveIntensity={0.06} />
</mesh>
</group>
))}
</group>
);
})}
{/* Solomonic corner columns — twist read as stacked, rotated drums */}
{corners.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<ShaftBase size={0.13} color="#2a1a12" />
{Array.from({ length: 14 }, (_, d) => (
<mesh key={d} position={[0, 0.34 + d * 0.24, 0]} rotation={[0, d * 0.42, 0]}>
<boxGeometry args={[0.2, 0.24, 0.13]} />
<meshStandardMaterial color={gilt} roughness={0.32} metalness={0.68} emissive="#2e2006" emissiveIntensity={0.05} />
</mesh>
))}
<mesh position={[0, 3.86, 0]}>
<cylinderGeometry args={[0.2, 0.13, 0.28, 10]} />
<meshStandardMaterial color={gilt} roughness={0.28} metalness={0.75} />
</mesh>
</group>
))}
{/* Modillion brackets under a heavy gilt cornice */}
{(['left', 'right'] as const).map((wall) =>
modillions.map((z, i) => (
<mesh key={`${wall}-${i}`} position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.17), 3.92, z]}>
<boxGeometry args={[0.18, 0.16, 0.12]} />
<meshStandardMaterial color={shade(gilt, 0.1)} roughness={0.34} metalness={0.62} />
</mesh>
))
)}
<CorniceRing room={room} y={4.06} height={0.18} proud={0.19} color={gilt} roughness={0.24} metalness={0.78} emissive="#3a2808" emissiveIntensity={0.08} />
<CeilingRose y={4.1} radius={1.15} color={gilt} rings={4} />
<HangingFixture
position={[0, 4.05, 0]}
dropLength={0.5}
radius={0.6}
arms={10}
metalColor={gilt}
flameColor="#fff0c0"
glowColor="#ffbe58"
light
lightColor="#ffd08a"
lightIntensity={1.0}
lightDistance={11}
/>
</group>
);
}
/**
* Rococo salon Rococo.
*
* Boiserie panelling with rounded corner arcs, asymmetric rocaille cartouches,
* a deep painted cove instead of a cornice, and a light gilt lustre. Everything
* is kept thin and pale: the period's relief is shallow by design.
*/
export function RococoDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 3.4, 9);
const gilt = trim;
const boiserie = '#efe6ee';
return (
<group>
{/* Boiserie dado with a moulded chair rail */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.55} height={1.04} proud={0.045} color={boiserie} roughness={0.6} metalness={0.08} />
<WallBand wall={wall} room={room} y={1.12} height={0.07} proud={0.08} color={gilt} roughness={0.34} metalness={0.52} />
<WallBand wall={wall} room={room} y={0.07} height={0.14} proud={0.08} color={boiserie} roughness={0.62} metalness={0.07} />
</group>
))}
{/* Panel styles with quarter-round corners, and a rocaille cartouche above */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.06), 0, z]}>
<mesh position={[0, 2.05, 0]}>
<boxGeometry args={[0.04, 4.0, 0.07]} />
<meshStandardMaterial color={gilt} roughness={0.36} metalness={0.5} />
</mesh>
<group position={[0, 3.5, 0]} rotation={[0, Math.PI / 2, 0]}>
<mesh>
<torusGeometry args={[0.2, 0.028, 6, 18, Math.PI * 1.35]} />
<meshStandardMaterial color={gilt} roughness={0.3} metalness={0.6} />
</mesh>
<mesh position={[0.1, -0.14, 0]} rotation={[0, 0, 0.7]}>
<torusGeometry args={[0.12, 0.022, 6, 14, Math.PI * 1.1]} />
<meshStandardMaterial color={gilt} roughness={0.3} metalness={0.6} />
</mesh>
</group>
</group>
))}
</group>
))}
{/* Painted cove: two stacked, slightly stepped bands reading as a curve */}
<CorniceRing room={room} y={3.86} height={0.16} proud={0.08} color={shade(boiserie, 0.04)} roughness={0.72} metalness={0.05} />
<CorniceRing room={room} y={4.0} height={0.16} proud={0.14} color={boiserie} roughness={0.7} metalness={0.05} />
<CorniceRing room={room} y={4.13} height={0.07} proud={0.17} color={gilt} roughness={0.3} metalness={0.6} />
<CeilingRose y={4.09} radius={0.9} color={gilt} rings={3} />
<HangingFixture
position={[0, 4.02, 0]}
dropLength={0.42}
radius={0.42}
arms={8}
metalColor={gilt}
flameColor="#fff4dc"
glowColor="#ffd58c"
light
lightColor="#ffe6c0"
lightIntensity={0.8}
lightDistance={10}
/>
</group>
);
}
/**
* Neoclassical museum Neoclassicism.
*
* Schinkel's museum grammar: fluted Ionic pilasters on a wide bay, a Greek-key
* meander frieze, a dentilled cornice, and a plain coffered soffit around the
* lantern. Deliberately cool and flat next to the Baroque hall.
*/
export function NeoclassicalDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 4.8, 6);
const stone = '#f1eee8';
const shadowStone = shade(stone, -0.14);
const meander = useRepeat(room.depth, 0.72, 24);
return (
<group>
{/* Plinth course under the hang */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.36} height={0.72} proud={0.05} color={shadowStone} roughness={0.72} metalness={0.08} />
<WallBand wall={wall} room={room} y={0.75} height={0.08} proud={0.08} color={stone} roughness={0.62} metalness={0.1} />
</group>
))}
{/* Fluted Ionic pilasters */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.13), 0, z]}>
<ShaftBase size={0.13} color={shadowStone} />
<group position={[0, 0.26, 0]}>
<FlutedShaft height={3.3} radius={0.12} color={stone} flutes={10} facing="x" />
</group>
<IonicCapital y={3.68} size={0.15} color={stone} axis="z" />
</group>
))}
</group>
))}
{/* Meander frieze — alternating key blocks read as a running fret */}
{(['left', 'right'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={3.9} height={0.24} proud={0.1} color={shade(stone, 0.03)} roughness={0.66} metalness={0.09} />
{meander.map((z, i) => (
<group key={i} position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.14), 3.9, z]}>
<mesh position={[0, i % 2 === 0 ? 0.06 : -0.06, 0]}>
<boxGeometry args={[0.06, 0.05, 0.34]} />
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.28} />
</mesh>
<mesh position={[0, 0, 0.15]}>
<boxGeometry args={[0.06, 0.16, 0.05]} />
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.28} />
</mesh>
</group>
))}
</group>
))}
<CorniceRing room={room} y={4.07} height={0.1} proud={0.16} color={stone} roughness={0.6} metalness={0.1} />
<CorniceRing room={room} y={4.15} height={0.06} proud={0.19} color={trim} roughness={0.48} metalness={0.24} />
<CofferedCeiling
room={room}
y={4.06}
cell={2.9}
panelColor="#f7f6f2"
ribColor="#e2ded4"
ribWidth={0.18}
ribDrop={0.13}
/>
</group>
);
}
@@ -0,0 +1,109 @@
import { useMemo } from 'react';
import type { GalleryWindowSpec } from '../../data/movement-interior-styles';
/**
* Shared geometry budget for every period interior.
*
* Movement halls hang frames 0.23 m proud of the inner wall face
* (`MOUNT_OFFSET` + `WALL_STANDOFF`), centred at eye height with tops around
* 2.4 m, and each wall keeps a 0.7 m clear margin at both ends
* (`WALL_PADDING` in `movementHallLayout.ts`). Period architecture therefore
* has to stay inside these limits, exactly as the Gothic nave and Byzantine
* basilica do:
*
* - relief below the frame line: at most 0.11 m proud of the wall face
* - friezes and cornices: 2.6 m and above, at most 0.17 m proud
* - freestanding masses: only inside the 0.34 m corner pocket at a corner
* - doorways: nothing below 2.6 m within 1.45 m of a wall centre
* - the end wall carries the hall title from 3.4 m to 3.9 m keep it clear
* - side-wall windows are placed at runtime between 2.4 m and 3.9 m, so
* anything wider than a mullion up there goes through `useWallClearance`
* - added lights: at most two per hall, so hall materials stay inside the
* WebGL light limits the shared track lighting already budgets for
*/
/** Half of WALL_THICKNESS — distance from a wall's centre plane to its inner face. */
export const WALL_INNER = 0.09;
/** Highest a detail can sit before it breaks the ceiling plane at 4.2 m. */
export const CEILING_Y = 4.13;
/** Sink bands this far into the wall so they never z-fight with the surface. */
export const EMBED = 0.02;
export type WallId = 'back' | 'front' | 'left' | 'right';
export interface RoomDims {
width: number;
depth: number;
halfW: number;
halfD: number;
}
/** Every period module takes the same three inputs. */
export interface PeriodProps {
room: RoomDims;
/** The movement's trim colour, already blended with its accent. */
trim: string;
/** Windows the hall placed at runtime, so upper-wall relief can avoid them. */
windows?: GalleryWindowSpec[];
}
/** Lighten (amount > 0) or darken (amount < 0) a hex colour. */
export function shade(hex: string, amount: number): string {
const n = parseInt(hex.replace('#', ''), 16);
const ch = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((c) =>
Math.max(0, Math.min(255, Math.round(amount >= 0 ? c + (255 - c) * amount : c * (1 + amount))))
);
return `#${((ch[0] << 16) | (ch[1] << 8) | ch[2]).toString(16).padStart(6, '0')}`;
}
/** Bay rhythm along the depth axis — the spacing that drives piers and beams. */
export function useBayZ(depth: number, halfD: number, spacing = 4.2, max = 9): number[] {
return useMemo(() => {
const count = Math.max(2, Math.min(max, Math.floor(depth / spacing)));
const step = depth / (count + 1);
return Array.from({ length: count }, (_, i) => -halfD + step * (i + 1));
}, [depth, halfD, spacing, max]);
}
/**
* Evenly spaced positions centred on a wall, for the repeating small members
* dentils, linenfold ribs, modillions, stencil motifs, quatrefoils.
*/
export function useRepeat(span: number, step: number, max: number): number[] {
return useMemo(() => {
const count = Math.min(max, Math.max(3, Math.floor((span - 0.6) / step)));
const run = (count - 1) * step;
return Array.from({ length: count }, (_, i) => -run / 2 + i * step);
}, [span, step, max]);
}
/**
* Side-wall windows are placed at runtime in whatever gaps the hang leaves, so
* any panel, arch or roundel a period module paints across the upper wall has
* to step around them. Returns a predicate: true when the span is clear.
*/
export function useWallClearance(
windows: GalleryWindowSpec[] | undefined,
wall: 'left' | 'right' | 'back'
) {
return useMemo(() => {
const spans = (windows ?? [])
.filter((w) => w.wall === wall)
.map((w) => [w.x - w.width / 2 - 0.18, w.x + w.width / 2 + 0.18] as [number, number]);
return (pos: number, halfSpan = 0.2) =>
!spans.some(([a, b]) => pos + halfSpan > a && pos - halfSpan < b);
}, [windows, wall]);
}
/** The four corner columns of the room, each inside its hang-margin pocket. */
export function useCorners(halfW: number, halfD: number, inset = 0.08): [number, number][] {
return useMemo(
() =>
[
[-halfW + inset, -halfD + inset],
[halfW - inset, -halfD + inset],
[-halfW + inset, halfD - inset],
[halfW - inset, halfD - inset],
] as [number, number][],
[halfW, halfD, inset]
);
}
@@ -0,0 +1,717 @@
import { useMemo } from 'react';
import {
CeilingBeams,
CorniceRing,
HangingFixture,
SideRails,
WallBand,
} from './primitives';
import {
shade,
useBayZ,
useCorners,
useRepeat,
type PeriodProps,
} from './geometry';
/**
* Fauvist studio Fauvism.
*
* The Collioure summer room: close-set whitewashed rafters, plank shutters
* folded back against the wall beside every window, and a plain painted rail.
* The architecture stays vernacular so the wall colour does the work.
*/
export function FauvistDetails({ room, trim, windows }: PeriodProps) {
const rafters = useBayZ(room.depth, room.halfD, 1.5, 14);
const timber = '#d8c8a8';
const shutters = useMemo(
() =>
(windows ?? [])
.filter((w) => w.wall === 'left' || w.wall === 'right')
.flatMap((w) =>
[-1, 1].map((dir) => ({
wall: w.wall as 'left' | 'right',
z: w.x + dir * (w.width / 2 + w.width * 0.28),
y: w.y,
h: w.height,
w: w.width * 0.5,
}))
),
[windows]
);
return (
<group>
<CeilingBeams
room={room}
positions={rafters}
axis="x"
width={0.1}
height={0.16}
y={4.02}
color={timber}
roughness={0.88}
metalness={0.02}
/>
<CeilingBeams
room={room}
positions={[0]}
axis="z"
width={0.2}
height={0.24}
y={3.86}
color={shade(timber, -0.24)}
roughness={0.88}
metalness={0.02}
/>
{/* Folded-back plank shutters flanking the windows */}
{shutters.map((s, i) => (
<group
key={i}
position={[(s.wall === 'left' ? -1 : 1) * (room.halfW - 0.13), s.y, s.z]}
>
<mesh>
<boxGeometry args={[0.06, s.h, s.w]} />
<meshStandardMaterial color={trim} roughness={0.8} metalness={0.04} />
</mesh>
{[-0.3, 0, 0.3].map((t) => (
<mesh key={t} position={[0.04, s.h * t, 0]}>
<boxGeometry args={[0.02, s.h * 0.22, s.w * 0.86]} />
<meshStandardMaterial color={shade(trim, -0.22)} roughness={0.84} metalness={0.03} />
</mesh>
))}
</group>
))}
<SideRails room={room} y={2.6} height={0.08} proud={0.11} color={timber} roughness={0.82} metalness={0.03} />
<WallBand wall="back" room={room} y={2.6} height={0.08} proud={0.11} color={timber} roughness={0.82} metalness={0.03} />
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.12} height={0.24} proud={0.07} color={trim} roughness={0.8} metalness={0.04} />
))}
</group>
);
}
/**
* Expressionist room Expressionism.
*
* Die Brücke's carved-plank interior pushed towards the raked sets of the
* period's cinema: battens canted at alternating angles above the hang, a
* zig-zag frieze, and ceiling members that refuse to run square.
*/
export function ExpressionistDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 2.2, 11);
const plank = '#5a3f27';
const light = shade(plank, 0.3);
return (
<group>
{/* Canted battens, alternating lean */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<mesh
key={side}
position={[side * (room.halfW - 0.07), 3.28, z]}
rotation={[i % 2 === 0 ? 0.3 : -0.3, 0, 0]}
>
<boxGeometry args={[0.08, 1.5, 0.17]} />
<meshStandardMaterial color={i % 2 === 0 ? plank : light} roughness={0.84} metalness={0.04} />
</mesh>
))}
</group>
))}
{/* Zig-zag frieze under the ceiling */}
{(['left', 'right'] as const).map((wall) =>
bays.map((z, i) => (
<mesh
key={`${wall}-${i}`}
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.08), 4.06, z]}
rotation={[i % 2 === 0 ? 0.62 : -0.62, 0, 0]}
>
<boxGeometry args={[0.1, 0.42, 0.1]} />
<meshStandardMaterial color={trim} roughness={0.7} metalness={0.1} />
</mesh>
))
)}
{/* Ceiling members deliberately off-square */}
{bays
.filter((_, i) => i % 3 === 0)
.map((z, i) => (
<mesh key={i} position={[0, 3.98, z]} rotation={[0, i % 2 === 0 ? 0.14 : -0.14, 0]}>
<boxGeometry args={[room.width * 0.97, 0.2, 0.15]} />
<meshStandardMaterial color={shade(plank, -0.24)} roughness={0.88} metalness={0.03} />
</mesh>
))}
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.13} height={0.26} proud={0.09} color={shade(plank, -0.3)} roughness={0.86} metalness={0.03} />
))}
</group>
);
}
/**
* Cubist studio Cubism.
*
* The Bateau-Lavoir atelier: slim cast-iron columns in the corners, riveted tie
* rods across the room, and a cornice broken into faceted plaster planes the
* building itself analysed into flat facets.
*/
export function CubistAtelierDetails({ room, trim }: PeriodProps) {
const corners = useCorners(room.halfW, room.halfD, 0.24);
const ties = useBayZ(room.depth, room.halfD, 3.6, 8);
const iron = '#4d4a45';
const facet = '#dedad2';
const facets = useRepeat(room.depth, 1.15, 22);
return (
<group>
{/* Cast-iron columns in the corner pockets */}
{corners.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 0.1, 0]}>
<boxGeometry args={[0.3, 0.2, 0.3]} />
<meshStandardMaterial color={shade(iron, -0.2)} roughness={0.6} metalness={0.45} />
</mesh>
<mesh position={[0, 2.1, 0]}>
<cylinderGeometry args={[0.09, 0.11, 3.8, 10]} />
<meshStandardMaterial color={iron} roughness={0.52} metalness={0.55} />
</mesh>
<mesh position={[0, 4.02, 0]}>
<boxGeometry args={[0.34, 0.18, 0.34]} />
<meshStandardMaterial color={iron} roughness={0.5} metalness={0.58} />
</mesh>
</group>
))}
{/* Riveted tie rods spanning the room, with a turnbuckle at mid-span */}
{ties.map((z, i) => (
<group key={i} position={[0, 3.8, z]}>
<mesh rotation={[0, 0, Math.PI / 2]}>
<cylinderGeometry args={[0.035, 0.035, room.width - 0.2, 8]} />
<meshStandardMaterial color={iron} roughness={0.46} metalness={0.62} />
</mesh>
<mesh rotation={[0, 0, Math.PI / 2]}>
<cylinderGeometry args={[0.06, 0.06, 0.3, 8]} />
<meshStandardMaterial color={shade(iron, -0.2)} roughness={0.4} metalness={0.7} />
</mesh>
</group>
))}
{/* Faceted cornice: flat planes tilted alternately */}
{(['left', 'right'] as const).map((wall) =>
facets.map((z, i) => (
<mesh
key={`${wall}-${i}`}
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.1), 4.02, z]}
rotation={[0, 0, i % 2 === 0 ? 0.34 : -0.34]}
>
<boxGeometry args={[0.2, 0.22, 0.86]} />
<meshStandardMaterial
color={i % 3 === 0 ? shade(facet, -0.14) : facet}
roughness={0.82}
metalness={0.05}
/>
</mesh>
))
)}
<CorniceRing room={room} y={4.15} height={0.08} proud={0.13} color={trim} roughness={0.6} metalness={0.2} />
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.1} height={0.2} proud={0.07} color={shade(iron, 0.24)} roughness={0.7} metalness={0.16} />
))}
</group>
);
}
/**
* Futurist loft Futurism.
*
* Sant'Elia's Città Nuova reduced to one room: riveted steel stanchions,
* diagonal wind bracing across the upper wall, and a gantry rail running the
* length of the hall. Structure celebrated, nothing concealed.
*/
export function FuturistDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 4.0, 8);
const steel = '#8f9298';
const dark = shade(steel, -0.42);
return (
<group>
{/* Riveted stanchions */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.06), 0, z]}>
<mesh position={[0, 2.05, 0]}>
<boxGeometry args={[0.09, 4.1, 0.36]} />
<meshStandardMaterial color={steel} roughness={0.42} metalness={0.72} />
</mesh>
{[0.6, 1.7, 2.8, 3.8].map((y) => (
<mesh key={y} position={[side * -0.03, y, 0]}>
<boxGeometry args={[0.06, 0.1, 0.44]} />
<meshStandardMaterial color={dark} roughness={0.38} metalness={0.8} />
</mesh>
))}
</group>
))}
</group>
))}
{/* Diagonal wind bracing between the stanchions, above the hang */}
{bays.slice(0, -1).map((z, i) => {
const mid = (z + bays[i + 1]) / 2;
const run = bays[i + 1] - z;
return (
<group key={i}>
{[-1, 1].map((side) =>
[-1, 1].map((dir) => (
<mesh
key={`${side}-${dir}`}
position={[side * (room.halfW - 0.1), 3.4, mid]}
rotation={[dir * Math.atan2(run - 0.4, 0.9), 0, 0]}
>
<boxGeometry args={[0.06, Math.hypot(run - 0.4, 0.9), 0.06]} />
<meshStandardMaterial color={steel} roughness={0.4} metalness={0.75} />
</mesh>
))
)}
</group>
);
})}
{/* Gantry rail and its brackets */}
<SideRails room={room} y={2.72} height={0.12} proud={0.15} color={dark} roughness={0.36} metalness={0.82} />
<CorniceRing room={room} y={4.13} height={0.1} proud={0.16} color={trim} roughness={0.34} metalness={0.7} />
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.11} height={0.22} proud={0.09} color={dark} roughness={0.4} metalness={0.7} />
))}
</group>
);
}
/**
* Suprematist white cube Suprematism.
*
* The 0,10 room: a bare white shell, a shelf slung across the upper corner
* where Malevich hung the Black Square in the icon position, and thin black and
* red geometric bands drifting off the axis of the walls.
*/
export function SuprematistDetails({ room, trim }: PeriodProps) {
const bars = useMemo(() => {
const seeds = [-0.62, -0.24, 0.18, 0.55];
return seeds.map((t, i) => ({
z: t,
y: 3.0 + (i % 3) * 0.28,
len: 0.9 + (i % 2) * 0.7,
tilt: i % 2 === 0 ? 0.22 : -0.34,
color: i % 3 === 0 ? '#c8302a' : '#141414',
}));
}, []);
return (
<group>
{/* Icon-corner shelf, carried on two plain brackets */}
<group position={[room.halfW - 0.46, 3.3, -room.halfD + 0.46]} rotation={[0, Math.PI / 4, 0]}>
<mesh>
<boxGeometry args={[1.16, 0.05, 0.34]} />
<meshStandardMaterial color="#f2f2f2" roughness={0.72} metalness={0.04} />
</mesh>
{[-0.4, 0.4].map((t) => (
<mesh key={t} position={[t, -0.16, 0.02]} rotation={[0, 0, 0]}>
<boxGeometry args={[0.05, 0.28, 0.24]} />
<meshStandardMaterial color="#e2e2e2" roughness={0.74} metalness={0.04} />
</mesh>
))}
</group>
{/* Drifting bands above the hang */}
{(['left', 'right'] as const).map((wall) =>
bars.map((b, i) => (
<mesh
key={`${wall}-${i}`}
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.07), b.y, b.z * room.halfD]}
rotation={[b.tilt, 0, 0]}
>
<boxGeometry args={[0.04, 0.14, b.len]} />
<meshStandardMaterial color={b.color} roughness={0.66} metalness={0.06} />
</mesh>
))
)}
{/* Shadow-gap reveal instead of a cornice */}
<CorniceRing room={room} y={4.04} height={0.05} proud={0.03} color="#d2d2d2" roughness={0.8} metalness={0.04} />
<CorniceRing room={room} y={4.16} height={0.06} proud={0.1} color={trim} roughness={0.7} metalness={0.08} />
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.07} height={0.14} proud={0.06} color="#dcdcdc" roughness={0.76} metalness={0.05} />
))}
</group>
);
}
/**
* Constructivist space Constructivism.
*
* Rodchenko's lattice made architecture: red-painted diagonal trusses under the
* ceiling, angled Proun panels folded into the corners, and exposed steel ties.
* Every member is doing structural work and says so.
*/
export function ConstructivistDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 2.8, 10);
const corners = useCorners(room.halfW, room.halfD, 0.3);
const red = '#b8342a';
const steel = '#9a9a96';
return (
<group>
{/* Lattice truss under the ceiling — top chord, bottom chord, diagonals */}
<CeilingBeams room={room} positions={[-0.9, 0.9]} axis="z" width={0.1} height={0.12} y={4.06} color={steel} roughness={0.44} metalness={0.6} />
<CeilingBeams room={room} positions={[-0.9, 0.9]} axis="z" width={0.09} height={0.1} y={3.66} color={steel} roughness={0.44} metalness={0.6} />
{bays.map((z, i) =>
[-0.9, 0.9].map((x) => (
<mesh key={`${i}-${x}`} position={[x, 3.86, z]} rotation={[i % 2 === 0 ? 0.62 : -0.62, 0, 0]}>
<boxGeometry args={[0.07, 0.62, 0.07]} />
<meshStandardMaterial color={red} roughness={0.56} metalness={0.3} />
</mesh>
))
)}
{/* Proun panels folded into the corners */}
{corners.map(([x, z], i) => (
<group key={i} position={[x, 2.9, z]} rotation={[0, Math.PI / 4 + (i % 2) * 0.2, 0]}>
<mesh rotation={[0, 0, 0.18]}>
<boxGeometry args={[0.66, 1.5, 0.05]} />
<meshStandardMaterial color={i % 2 === 0 ? red : '#141414'} roughness={0.66} metalness={0.12} />
</mesh>
<mesh position={[0.1, -0.3, 0.05]} rotation={[0, 0, -0.5]}>
<boxGeometry args={[0.5, 0.14, 0.04]} />
<meshStandardMaterial color={steel} roughness={0.42} metalness={0.6} />
</mesh>
</group>
))}
{/* Steel tie rails at the hang line and the skirting */}
<SideRails room={room} y={2.64} height={0.07} proud={0.12} color={steel} roughness={0.4} metalness={0.68} />
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.1} height={0.2} proud={0.08} color={red} roughness={0.62} metalness={0.2} />
))}
<CorniceRing room={room} y={4.16} height={0.06} proud={0.11} color={trim} roughness={0.5} metalness={0.4} />
</group>
);
}
/**
* Dada salon Dada.
*
* A bourgeois room being dismantled: cornice fragments that stop and restart at
* different heights, a picture rail hung visibly out of level, and a service
* pipe left running across the wall where the moulding should be.
*/
export function DadaDetails({ room, trim }: PeriodProps) {
const fragments = useMemo(
() =>
[
{ wall: 'left' as const, y: 4.04, span: 0.3, proud: 0.14 },
{ wall: 'left' as const, y: 3.78, span: 0.18, proud: 0.1 },
{ wall: 'right' as const, y: 4.12, span: 0.44, proud: 0.16 },
{ wall: 'right' as const, y: 3.9, span: 0.22, proud: 0.09 },
{ wall: 'back' as const, y: 4.06, span: 0.35, proud: 0.13 },
],
[]
);
const plaster = '#e6dac4';
const pipe = '#6a6259';
return (
<group>
{/* Cornice, in pieces */}
{fragments.map((f, i) => (
<WallBand
key={i}
wall={f.wall}
room={room}
y={f.y}
height={0.13}
proud={f.proud}
span={f.span}
color={i % 2 === 0 ? plaster : shade(plaster, -0.18)}
roughness={0.76}
metalness={0.05}
/>
))}
{/* Picture rail, visibly out of level */}
{[-1, 1].map((side) => (
<mesh key={side} position={[side * (room.halfW - 0.07), 2.62, 0]} rotation={[side * 0.035, 0, 0]}>
<boxGeometry args={[0.1, 0.08, room.depth * 0.86]} />
<meshStandardMaterial color={trim} roughness={0.6} metalness={0.18} />
</mesh>
))}
{/* Service pipe run, with a clumsy elbow into the ceiling */}
<mesh position={[-room.halfW + 0.16, 3.34, 0]} rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[0.06, 0.06, room.depth * 0.8, 10]} />
<meshStandardMaterial color={pipe} roughness={0.64} metalness={0.4} />
</mesh>
<mesh position={[-room.halfW + 0.16, 3.7, room.depth * 0.4]}>
<cylinderGeometry args={[0.06, 0.06, 0.75, 10]} />
<meshStandardMaterial color={pipe} roughness={0.64} metalness={0.4} />
</mesh>
{/* Skirting that changes its mind halfway along */}
<WallBand wall="left" room={room} y={0.11} height={0.22} proud={0.08} span={0.55} color={plaster} roughness={0.78} metalness={0.04} />
<WallBand wall="right" room={room} y={0.15} height={0.3} proud={0.1} span={0.8} color={shade(plaster, -0.26)} roughness={0.78} metalness={0.04} />
<WallBand wall="back" room={room} y={0.11} height={0.22} proud={0.08} color={plaster} roughness={0.78} metalness={0.04} />
<HangingFixture
position={[room.halfW * 0.32, 3.9, room.halfD * 0.2]}
dropLength={0.7}
radius={0.2}
arms={3}
metalColor={trim}
flameColor="#fff0cc"
glowColor="#ffb45a"
light
lightColor="#ffd0a0"
lightIntensity={0.7}
lightDistance={8}
/>
</group>
);
}
/**
* Surrealist interior Surrealism.
*
* The 1938 Exposition Internationale: sacks slung under the ceiling, drapery
* pulled across the cornice, and a door standing in a corner where no door can
* lead anywhere. The room is furnished as an image, not as a gallery.
*/
export function SurrealistDetails({ room, trim }: PeriodProps) {
const sacks = useBayZ(room.depth, room.halfD, 2.6, 9);
return (
<group>
{/* Sacks hanging under the ceiling */}
{sacks.map((z, i) =>
[-1, 0, 1].map((c) => (
<mesh
key={`${i}-${c}`}
position={[c * room.width * 0.26, 3.86 - ((i + c) % 3) * 0.09, z]}
rotation={[0, ((i + c) % 4) * 0.4, 0.1 * ((i % 2) * 2 - 1)]}
>
<sphereGeometry args={[0.34, 10, 8]} />
<meshStandardMaterial color={i % 2 === 0 ? '#3a3128' : '#2c261f'} roughness={0.96} metalness={0.02} />
</mesh>
))
)}
{/* Drapery gathered along the top of the side walls */}
{(['left', 'right'] as const).map((wall) =>
sacks.map((z, i) => (
<mesh
key={`${wall}-${i}`}
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.13), 3.7, z]}
rotation={[0, 0, 0]}
>
<cylinderGeometry args={[0.13, 0.09, 0.86, 8]} />
<meshStandardMaterial color="#38304a" roughness={0.92} metalness={0.03} />
</mesh>
))
)}
{/* A door that leads nowhere, standing in the corner pocket */}
<group position={[-room.halfW + 0.34, 0, -room.halfD + 0.34]} rotation={[0, Math.PI / 4, 0]}>
<mesh position={[0, 1.05, 0]}>
<boxGeometry args={[0.72, 2.1, 0.07]} />
<meshStandardMaterial color="#4a3a2a" roughness={0.78} metalness={0.06} />
</mesh>
<mesh position={[0, 1.12, 0]}>
<boxGeometry args={[0.84, 2.24, 0.05]} />
<meshStandardMaterial color={shade('#4a3a2a', -0.3)} roughness={0.8} metalness={0.05} />
</mesh>
<mesh position={[0.26, 1.02, 0.06]}>
<sphereGeometry args={[0.05, 10, 10]} />
<meshStandardMaterial color={trim} roughness={0.34} metalness={0.66} />
</mesh>
</group>
<CorniceRing room={room} y={4.14} height={0.09} proud={0.12} color={trim} roughness={0.62} metalness={0.2} />
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.11} height={0.22} proud={0.08} color="#2e2822" roughness={0.82} metalness={0.05} />
))}
{/* A brazier glow in the opposite corner */}
<group position={[room.halfW - 0.44, 0, room.halfD - 0.44]}>
<mesh position={[0, 0.34, 0]}>
<cylinderGeometry args={[0.22, 0.15, 0.28, 10]} />
<meshStandardMaterial color="#2a2622" roughness={0.7} metalness={0.35} />
</mesh>
<mesh position={[0, 0.5, 0]}>
<sphereGeometry args={[0.16, 10, 8]} />
<meshStandardMaterial color="#ff9a40" emissive="#ff6a12" emissiveIntensity={1.1} toneMapped={false} />
</mesh>
<pointLight position={[0, 0.7, 0]} intensity={0.8} distance={7} color="#ff8a3c" />
</group>
</group>
);
}
/**
* NYC loft Abstract Expressionism.
*
* A Tenth Street cast-iron building: fluted iron columns on their corbelled
* caps, a pressed-tin ceiling field, and the sprinkler main running the length
* of the room. Industrial fabric left exactly as the painters found it.
*/
export function LoftDetails({ room, trim }: PeriodProps) {
const corners = useCorners(room.halfW, room.halfD, 0.26);
const joists = useBayZ(room.depth, room.halfD, 1.7, 13);
const iron = '#5c5852';
return (
<group>
{/* Cast-iron columns with corbelled caps */}
{corners.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 0.12, 0]}>
<cylinderGeometry args={[0.2, 0.24, 0.24, 12]} />
<meshStandardMaterial color={shade(iron, -0.2)} roughness={0.62} metalness={0.42} />
</mesh>
<mesh position={[0, 2.05, 0]}>
<cylinderGeometry args={[0.11, 0.13, 3.6, 12]} />
<meshStandardMaterial color={iron} roughness={0.56} metalness={0.48} />
</mesh>
<mesh position={[0, 3.92, 0]}>
<cylinderGeometry args={[0.22, 0.13, 0.2, 12]} />
<meshStandardMaterial color={iron} roughness={0.54} metalness={0.5} />
</mesh>
<mesh position={[0, 4.06, 0]}>
<boxGeometry args={[0.4, 0.14, 0.4]} />
<meshStandardMaterial color={shade(iron, 0.1)} roughness={0.54} metalness={0.5} />
</mesh>
</group>
))}
{/* Pressed-tin ceiling field on close-set joists */}
<CeilingBeams
room={room}
positions={joists}
axis="x"
width={0.08}
height={0.09}
y={4.05}
color="#b4b0a6"
roughness={0.48}
metalness={0.42}
/>
<CeilingBeams
room={room}
positions={[-room.width * 0.24, 0, room.width * 0.24]}
axis="z"
width={0.14}
height={0.16}
y={3.96}
color={iron}
roughness={0.6}
metalness={0.4}
/>
{/* Sprinkler main with drops */}
<mesh position={[0, 3.78, 0]} rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[0.055, 0.055, room.depth * 0.92, 10]} />
<meshStandardMaterial color="#7a3f2c" roughness={0.66} metalness={0.4} />
</mesh>
{joists
.filter((_, i) => i % 4 === 0)
.map((z, i) => (
<mesh key={i} position={[0, 3.68, z]}>
<cylinderGeometry args={[0.02, 0.025, 0.16, 6]} />
<meshStandardMaterial color="#8a4a34" roughness={0.6} metalness={0.45} />
</mesh>
))}
{/* Steel skirting angle */}
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.09} height={0.18} proud={0.08} color={trim} roughness={0.56} metalness={0.44} />
))}
</group>
);
}
/**
* Pop Art gallery Pop Art.
*
* The 1960s dealer's room: a flat white shell, recessed fluorescent troffers in
* a ceiling grid, an aluminium reveal at the floor, and a shadow gap where a
* cornice used to be. The architecture is engineered to disappear.
*/
export function WhiteCubeDetails({ room, trim }: PeriodProps) {
const troffers = useMemo(() => {
const cols = Math.max(2, Math.min(3, Math.round(room.width / 4.5)));
const rows = Math.max(2, Math.min(6, Math.round(room.depth / 3.4)));
const out: [number, number][] = [];
for (let c = 0; c < cols; c++) {
for (let r = 0; r < rows; r++) {
out.push([
-room.halfW + (room.width / (cols + 1)) * (c + 1),
-room.halfD + (room.depth / (rows + 1)) * (r + 1),
]);
}
}
return out;
}, [room.width, room.depth, room.halfW, room.halfD]);
return (
<group>
{/* Recessed fluorescent troffers */}
{troffers.map(([x, z], i) => (
<group key={i} position={[x, 4.08, z]}>
<mesh>
<boxGeometry args={[1.24, 0.06, 0.62]} />
<meshStandardMaterial color="#c8c8c8" roughness={0.4} metalness={0.5} />
</mesh>
<mesh position={[0, -0.04, 0]}>
<boxGeometry args={[1.1, 0.03, 0.5]} />
<meshStandardMaterial
color="#ffffff"
emissive="#f4f8ff"
emissiveIntensity={0.85}
toneMapped={false}
/>
</mesh>
</group>
))}
{/* Ceiling grid tees between the fittings */}
<CeilingBeams
room={room}
positions={troffers.map(([, z]) => z).filter((z, i, a) => a.indexOf(z) === i)}
axis="x"
width={0.05}
height={0.04}
y={4.1}
color="#d6d6d6"
roughness={0.5}
metalness={0.36}
/>
{/* Shadow gap and aluminium skirting reveal */}
<CorniceRing room={room} y={4.02} height={0.06} proud={0.02} color="#c0c0c0" roughness={0.66} metalness={0.16} />
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.06} height={0.12} proud={0.05} color={trim} roughness={0.42} metalness={0.5} />
<WallBand wall={wall} room={room} y={0.14} height={0.03} proud={0.02} color="#9a9a9a" roughness={0.6} metalness={0.3} />
</group>
))}
</group>
);
}
@@ -0,0 +1,507 @@
import {
CeilingBeams,
CeilingRose,
CorniceRing,
HangingFixture,
SideRails,
WallBand,
WallSconce,
} from './primitives';
import {
shade,
useBayZ,
useRepeat,
useWallClearance,
type PeriodProps,
} from './geometry';
/**
* Romantic gallery Romanticism (Gothic Revival).
*
* Pugin's revival vocabulary rather than a real nave: dark walnut linenfold
* panelling, a cusped blind arcade of pointed arches, a quatrefoil frieze, and
* gasoliers. The pointed arch here is applied ornament, which is exactly what
* separates the revival from the Gothic hall it quotes.
*/
export function GothicRevivalDetails({ room, trim, windows }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 3.8, 8);
const clearLeft = useWallClearance(windows, 'left');
const clearRight = useWallClearance(windows, 'right');
const walnut = '#4c3323';
const walnutLight = shade(walnut, 0.22);
const quatrefoils = useRepeat(room.depth, 0.95, 24);
return (
<group>
{/* Panelled dado with a heavy moulded rail */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.62} height={1.2} proud={0.06} color={walnut} roughness={0.68} metalness={0.08} />
<WallBand wall={wall} room={room} y={1.28} height={0.13} proud={0.11} color={walnutLight} roughness={0.52} metalness={0.12} />
<WallBand wall={wall} room={room} y={0.09} height={0.18} proud={0.1} color={shade(walnut, -0.35)} roughness={0.62} metalness={0.08} />
</group>
))}
{/* Colonnettes between the bays, carrying an applied pointed arcade */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.06), 0, z]}>
<mesh position={[0, 2.0, 0]}>
<cylinderGeometry args={[0.055, 0.065, 3.9, 8]} />
<meshStandardMaterial color={walnutLight} roughness={0.6} metalness={0.1} />
</mesh>
<mesh position={[0, 3.98, 0]}>
<boxGeometry args={[0.13, 0.16, 0.22]} />
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.2} />
</mesh>
</group>
))}
</group>
))}
{/* Cusped pointed arches over each bay, stepping around the windows */}
{bays.slice(0, -1).map((z, i) => {
const mid = (z + bays[i + 1]) / 2;
// Capped so the raking limbs stay under the ceiling in deep halls.
const half = Math.min(1.1, (bays[i + 1] - z) / 2 - 0.1);
return (
<group key={i}>
{([-1, 1] as const)
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, half))
.map((side) => (
<group key={side} position={[side * (room.halfW - 0.06), 0, mid]}>
{[-1, 1].map((dir) => (
<mesh
key={dir}
position={[0, 3.62, (dir * half) / 2]}
rotation={[dir * 0.42, 0, 0]}
>
<boxGeometry args={[0.09, half * 1.12, 0.09]} />
<meshStandardMaterial color={walnutLight} roughness={0.58} metalness={0.1} />
</mesh>
))}
<mesh position={[0, 3.9, 0]}>
<sphereGeometry args={[0.07, 8, 8]} />
<meshStandardMaterial color={trim} roughness={0.44} metalness={0.28} />
</mesh>
</group>
))}
</group>
);
})}
{/* Quatrefoil frieze under the cornice */}
{(['left', 'right'] as const).map((wall) =>
quatrefoils.map((z, i) => (
<group
key={`${wall}-${i}`}
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.1), 4.02, z]}
rotation={[0, Math.PI / 2, 0]}
>
{[0, 1, 2, 3].map((q) => {
const a = (q / 4) * Math.PI * 2 + Math.PI / 4;
return (
<mesh key={q} position={[Math.cos(a) * 0.07, Math.sin(a) * 0.07, 0]}>
<sphereGeometry args={[0.055, 8, 6]} />
<meshStandardMaterial color={shade(walnut, 0.34)} roughness={0.6} metalness={0.1} />
</mesh>
);
})}
</group>
))
)}
<CorniceRing room={room} y={4.14} height={0.1} proud={0.15} color={walnutLight} roughness={0.5} metalness={0.14} />
{[-1, 1].map((side) => (
<WallSconce
key={side}
wall={side < 0 ? 'left' : 'right'}
room={room}
along={side * room.halfD * 0.3}
y={2.9}
armColor={trim}
glowColor="#ff9c46"
shadeColor="#ffd8a0"
/>
))}
<HangingFixture
position={[0, 4.0, 0]}
dropLength={0.55}
radius={0.36}
arms={6}
metalColor={trim}
flameColor="#ffdca0"
glowColor="#ff9a3c"
light
lightColor="#ffb268"
lightIntensity={0.8}
lightDistance={9}
/>
</group>
);
}
/**
* Realist picture gallery Realism.
*
* The mid-century bourgeois interior the Realists exhibited against: a plaster
* dado and picture rail, run cornice with a moulded frieze, a ceiling rose, and
* gas brackets. Restrained, machine-made mouldings no carving anywhere.
*/
export function BourgeoisSalonDetails({ room, trim }: PeriodProps) {
const plaster = '#efe8dc';
const shadow = shade(plaster, -0.12);
return (
<group>
{/* Dado, chair rail, skirting */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.5} height={0.96} proud={0.04} color={shadow} roughness={0.78} metalness={0.05} />
<WallBand wall={wall} room={room} y={1.03} height={0.09} proud={0.09} color={plaster} roughness={0.7} metalness={0.06} />
<WallBand wall={wall} room={room} y={0.09} height={0.18} proud={0.1} color={plaster} roughness={0.74} metalness={0.05} />
</group>
))}
{/* Picture rail just above the hang, as the period actually hung its walls */}
<SideRails room={room} y={2.52} height={0.07} proud={0.09} color={trim} roughness={0.55} metalness={0.2} />
{/* Run cornice: cavetto, bead and a moulded frieze */}
<CorniceRing room={room} y={3.9} height={0.1} proud={0.07} color={shadow} roughness={0.76} metalness={0.05} />
<CorniceRing room={room} y={4.0} height={0.12} proud={0.13} color={plaster} roughness={0.72} metalness={0.05} />
<CorniceRing room={room} y={4.13} height={0.07} proud={0.17} color={shade(plaster, 0.06)} roughness={0.7} metalness={0.06} />
<CeilingRose y={4.09} radius={0.85} color={shade(plaster, 0.04)} rings={3} />
{[-1, 1].map((side) => (
<WallSconce
key={side}
wall={side < 0 ? 'left' : 'right'}
room={room}
along={side * room.halfD * 0.34}
y={2.78}
armColor={trim}
/>
))}
<HangingFixture
position={[0, 4.04, 0]}
dropLength={0.62}
radius={0.3}
arms={4}
metalColor={trim}
flameColor="#fff0cc"
glowColor="#ffbe6a"
light
lightColor="#ffd8a8"
lightIntensity={0.75}
lightDistance={9}
/>
</group>
);
}
/**
* Impressionist salon Impressionism.
*
* The 1874 Boulevard des Capucines idea of a hung room: an iron-and-glass
* lantern overhead, a coved plaster cornice, and a velvet-faced picture rail
* with visible hanging rods above the frames. Nothing carved, everything pale.
*/
export function NorthLightSalonDetails({ room, trim }: PeriodProps) {
const iron = '#6d6a63';
const plaster = '#f4f1ea';
const trusses = useBayZ(room.depth, room.halfD, 2.4, 11);
return (
<group>
{/* Skylight lantern: glazing bars across a shallow raised centre */}
<mesh position={[0, 4.11, 0]}>
<boxGeometry args={[Math.min(room.width - 2.2, 6.4), 0.06, Math.min(room.depth - 2.2, 14)]} />
<meshStandardMaterial
color="#ffffff"
emissive="#eaf2ff"
emissiveIntensity={0.55}
roughness={0.4}
metalness={0.05}
/>
</mesh>
<CeilingBeams
room={room}
positions={trusses}
axis="x"
width={0.07}
height={0.1}
y={4.05}
color={iron}
roughness={0.5}
metalness={0.55}
/>
{[-1, 1].map((side) => (
<mesh key={side} position={[(side * Math.min(room.width - 2.2, 6.4)) / 2, 4.03, 0]}>
<boxGeometry args={[0.1, 0.18, Math.min(room.depth - 2.2, 14)]} />
<meshStandardMaterial color={iron} roughness={0.48} metalness={0.6} />
</mesh>
))}
{/* Picture rail with hanging rods dropping to the frame line */}
<SideRails room={room} y={2.6} height={0.09} proud={0.1} color={trim} roughness={0.52} metalness={0.24} />
<WallBand wall="back" room={room} y={2.6} height={0.09} proud={0.1} color={trim} roughness={0.52} metalness={0.24} />
{/* Coved cornice */}
<CorniceRing room={room} y={3.94} height={0.14} proud={0.06} color={shade(plaster, -0.06)} roughness={0.8} metalness={0.04} />
<CorniceRing room={room} y={4.07} height={0.1} proud={0.12} color={plaster} roughness={0.78} metalness={0.04} />
{/* Dark skirting so the pale walls sit on something */}
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.1} height={0.2} proud={0.09} color="#b6a88f" roughness={0.7} metalness={0.06} />
))}
</group>
);
}
/**
* Montmartre atelier Post-Impressionism.
*
* A working painter's room rather than a gallery: a king-post roof truss under
* the slope, a cast-iron stove flue climbing the end wall, plain board skirting
* and a rough shelf rail carrying the studio's clutter line.
*/
export function ParisAtelierDetails({ room, trim }: PeriodProps) {
const trusses = useBayZ(room.depth, room.halfD, 3.1, 8);
const timber = '#8a6a48';
const iron = '#3a3632';
return (
<group>
{/* Roof trusses: tie beam, king post and raking struts */}
{trusses.map((z, i) => {
// Struts run from the head of the king post down to the tie beam, and
// have to stay inside the 0.3 m the ceiling leaves above the beam.
const run = room.halfW - 0.6;
const rise = 0.16;
const strut = Math.hypot(run, rise);
return (
<group key={i} position={[0, 0, z]}>
<mesh position={[0, 3.9, 0]}>
<boxGeometry args={[room.width - 0.1, 0.16, 0.16]} />
<meshStandardMaterial color={timber} roughness={0.86} metalness={0.03} />
</mesh>
<mesh position={[0, 4.02, 0]}>
<boxGeometry args={[0.13, 0.24, 0.13]} />
<meshStandardMaterial color={timber} roughness={0.86} metalness={0.03} />
</mesh>
{[-1, 1].map((side) => (
<mesh
key={side}
position={[(side * run) / 2, 4.06, 0]}
rotation={[0, 0, side * Math.atan2(-rise, run)]}
>
<boxGeometry args={[strut, 0.09, 0.11]} />
<meshStandardMaterial color={shade(timber, -0.16)} roughness={0.88} metalness={0.03} />
</mesh>
))}
</group>
);
})}
{/* Stove flue running up the end wall and out along the ceiling */}
<mesh position={[room.halfW - 0.28, 2.1, -room.halfD + 0.28]}>
<cylinderGeometry args={[0.09, 0.09, 4.0, 10]} />
<meshStandardMaterial color={iron} roughness={0.62} metalness={0.42} />
</mesh>
<mesh position={[room.halfW - 0.28, 4.02, -room.halfD + 0.28]}>
<cylinderGeometry args={[0.12, 0.12, 0.14, 10]} />
<meshStandardMaterial color={shade(iron, 0.14)} roughness={0.58} metalness={0.5} />
</mesh>
{/* Board skirting and a rough shelf rail above the hang */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.12} height={0.24} proud={0.06} color={shade(timber, -0.2)} roughness={0.88} metalness={0.03} />
<WallBand wall={wall} room={room} y={2.66} height={0.06} proud={0.13} color={timber} roughness={0.84} metalness={0.04} />
</group>
))}
{/* A single bare bulb on a flex, the way the ateliers were actually lit */}
<mesh position={[0, 3.7, 0]}>
<cylinderGeometry args={[0.008, 0.008, 0.5, 6]} />
<meshStandardMaterial color={iron} roughness={0.6} metalness={0.3} />
</mesh>
<mesh position={[0, 3.42, 0]}>
<sphereGeometry args={[0.09, 12, 12]} />
<meshStandardMaterial color="#fff2d0" emissive="#ffc061" emissiveIntensity={0.95} toneMapped={false} />
</mesh>
<pointLight position={[0, 3.4, 0]} intensity={0.9} distance={9} color={trim} />
</group>
);
}
/**
* Symbolist chamber Symbolism.
*
* The aesthetic interior: a stencilled frieze of repeating motifs, a heavy
* portière rail, dark lacquered dado, and low amber sconces. Light is kept
* pooled and low, which is the whole point of the period's rooms.
*/
export function SymbolistDetails({ room, trim }: PeriodProps) {
const lacquer = '#12281f';
const stencil = shade(trim, 0.1);
const motifs = useRepeat(room.depth, 0.8, 26);
return (
<group>
{/* Lacquered dado under a gilt bead */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.54} height={1.04} proud={0.05} color={lacquer} roughness={0.42} metalness={0.16} />
<WallBand wall={wall} room={room} y={1.1} height={0.06} proud={0.09} color={trim} roughness={0.38} metalness={0.44} />
</group>
))}
{/* Stencilled frieze — a repeating lotus/flame motif under the cornice */}
{(['left', 'right'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={3.86} height={0.34} proud={0.04} color={shade(lacquer, 0.1)} roughness={0.6} metalness={0.12} />
{motifs.map((z, i) => (
<group key={i} position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.11), 3.86, z]} rotation={[0, Math.PI / 2, 0]}>
<mesh>
<coneGeometry args={[0.09, 0.24, 5]} />
<meshStandardMaterial color={stencil} roughness={0.44} metalness={0.36} />
</mesh>
<mesh position={[0, -0.16, 0]}>
<sphereGeometry args={[0.045, 8, 6]} />
<meshStandardMaterial color={stencil} roughness={0.44} metalness={0.36} />
</mesh>
</group>
))}
</group>
))}
<CorniceRing room={room} y={4.08} height={0.12} proud={0.13} color={shade(lacquer, 0.16)} roughness={0.5} metalness={0.18} />
<CorniceRing room={room} y={4.16} height={0.05} proud={0.16} color={trim} roughness={0.36} metalness={0.5} />
{/* Portière rail across the end wall */}
<WallBand wall="back" room={room} y={2.72} height={0.08} proud={0.12} color={trim} roughness={0.4} metalness={0.44} />
{[-0.36, 0.36].map((t) => (
<WallSconce
key={t}
wall={t < 0 ? 'left' : 'right'}
room={room}
along={t * room.halfD}
y={2.94}
armColor={trim}
glowColor="#ff8a2e"
shadeColor="#ffcf94"
/>
))}
<HangingFixture
position={[0, 4.04, 0]}
dropLength={0.66}
radius={0.26}
arms={5}
metalColor={trim}
flameColor="#ffd9a0"
glowColor="#ff8f2c"
light
lightColor="#ffa050"
lightIntensity={0.7}
lightDistance={8}
/>
</group>
);
}
/**
* Art Nouveau salon Art Nouveau.
*
* Horta's structural line: whiplash brackets springing from the wall into the
* ceiling, tendrils running the cornice, and organic sconces. The curve is
* carried by the structure itself rather than applied as ornament.
*/
export function ArtNouveauDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 3.0, 10);
const iron = shade(trim, -0.24);
return (
<group>
{/* Whiplash brackets: a stem up the wall curling into the ceiling */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.07), 0, z]}>
<mesh position={[0, 2.05, 0]}>
<cylinderGeometry args={[0.045, 0.06, 4.0, 8]} />
<meshStandardMaterial color={iron} roughness={0.44} metalness={0.42} />
</mesh>
{[0, 1, 2, 3].map((s) => {
const t = (s + 1) / 4;
return (
<mesh
key={s}
position={[-side * (0.1 + t * 0.32), 4.02 - t * t * 0.22, 0]}
rotation={[0, 0, side * (0.35 + t * 0.7)]}
>
<cylinderGeometry args={[0.035, 0.045, 0.34, 6]} />
<meshStandardMaterial color={iron} roughness={0.44} metalness={0.42} />
</mesh>
);
})}
{/* Bud terminal */}
<mesh position={[-side * 0.52, 3.78, 0]}>
<sphereGeometry args={[0.07, 10, 8]} />
<meshStandardMaterial color={trim} roughness={0.36} metalness={0.5} />
</mesh>
</group>
))}
</group>
))}
{/* Tendril cornice — a wave riding the top of every wall */}
<CorniceRing room={room} y={4.05} height={0.11} proud={0.11} color={shade(trim, 0.18)} roughness={0.5} metalness={0.3} />
<CorniceRing room={room} y={4.15} height={0.06} proud={0.15} color={trim} roughness={0.42} metalness={0.42} />
{/* Sinuous dado line */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.11} height={0.22} proud={0.08} color={shade(trim, -0.3)} roughness={0.52} metalness={0.28} />
<WallBand wall={wall} room={room} y={0.86} height={0.05} proud={0.07} color={trim} roughness={0.44} metalness={0.4} />
</group>
))}
{[-0.3, 0.3].map((t) => (
<WallSconce
key={t}
wall={t < 0 ? 'left' : 'right'}
room={room}
along={t * room.halfD}
y={2.88}
armColor={trim}
glowColor="#8fd66a"
shadeColor="#e8ffd8"
/>
))}
<HangingFixture
position={[0, 4.0, 0]}
dropLength={0.5}
radius={0.44}
arms={7}
metalColor={trim}
flameColor="#f2ffe0"
glowColor="#a8d86a"
light
lightColor="#dff2c0"
lightIntensity={0.75}
lightDistance={9}
/>
</group>
);
}
@@ -0,0 +1,513 @@
import { useMemo } from 'react';
import { CEILING_Y, EMBED, WALL_INNER, shade, type RoomDims, type WallId } from './geometry';
/** Wall-flush building blocks shared by the period interiors in this folder. */
interface SurfaceProps {
color: string;
roughness?: number;
metalness?: number;
emissive?: string;
emissiveIntensity?: number;
}
/** A moulding running the length of one wall, flush against its inner face. */
export function WallBand({
wall,
room,
y,
height,
proud = 0.1,
span = 1,
color,
roughness = 0.7,
metalness = 0.06,
emissive,
emissiveIntensity,
}: SurfaceProps & {
wall: WallId;
room: RoomDims;
y: number;
height: number;
proud?: number;
span?: number;
}) {
const alongZ = wall === 'left' || wall === 'right';
const len = (alongZ ? room.depth : room.width) * span;
const thick = proud + EMBED * 2;
const off = WALL_INNER + proud / 2 - EMBED;
const args: [number, number, number] = alongZ ? [thick, height, len] : [len, height, thick];
const pos: [number, number, number] =
wall === 'back'
? [0, y, -room.halfD + off]
: wall === 'front'
? [0, y, room.halfD - off]
: wall === 'left'
? [-room.halfW + off, y, 0]
: [room.halfW - off, y, 0];
return (
<mesh position={pos}>
<boxGeometry args={args} />
<meshStandardMaterial
color={color}
roughness={roughness}
metalness={metalness}
emissive={emissive}
emissiveIntensity={emissiveIntensity}
/>
</mesh>
);
}
/** A moulding ring running right around the room at one height. */
export function CorniceRing({
room,
y,
height,
proud = 0.12,
walls = ['back', 'front', 'left', 'right'],
...surface
}: SurfaceProps & {
room: RoomDims;
y: number;
height: number;
proud?: number;
walls?: WallId[];
}) {
return (
<group>
{walls.map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={y} height={height} proud={proud} {...surface} />
))}
</group>
);
}
/** String course / picture rail on the two side walls only. */
export function SideRails({
room,
y,
height,
proud = 0.1,
...surface
}: SurfaceProps & { room: RoomDims; y: number; height: number; proud?: number }) {
return (
<group>
{(['left', 'right'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={y} height={height} proud={proud} {...surface} />
))}
</group>
);
}
/**
* Vertical shaft with carved flutes, read as alternating light stone and
* shadowed grooves. Sits flush: the caller places it against a wall face.
*/
export function FlutedShaft({
height,
radius,
color,
flutes = 12,
taper = 0.9,
facing = 'x',
}: {
height: number;
radius: number;
color: string;
flutes?: number;
taper?: number;
/** Wall normal axis — grooves are only cut on the exposed half. */
facing?: 'x' | 'z';
}) {
const groove = shade(color, -0.28);
const arcs = useMemo(
() => Array.from({ length: flutes }, (_, i) => (i / flutes) * Math.PI * 2),
[flutes]
);
return (
<group>
<mesh position={[0, height / 2, 0]}>
<cylinderGeometry args={[radius * taper, radius, height, 16]} />
<meshStandardMaterial color={color} roughness={0.74} metalness={0.05} />
</mesh>
{arcs.map((a, i) => {
const dx = Math.cos(a) * radius * 0.94;
const dz = Math.sin(a) * radius * 0.94;
// Skip the grooves buried in the wall behind the shaft.
if (facing === 'x' ? dx > 0 : dz > 0) return null;
return (
<mesh key={i} position={[dx, height / 2, dz]}>
<cylinderGeometry args={[radius * 0.16, radius * 0.16, height * 0.98, 6]} />
<meshStandardMaterial color={groove} roughness={0.88} metalness={0.03} />
</mesh>
);
})}
</group>
);
}
export function TuscanCapital({ y, size, color }: { y: number; size: number; color: string }) {
return (
<group position={[0, y, 0]}>
<mesh>
<cylinderGeometry args={[size * 1.15, size * 0.85, size * 0.42, 14]} />
<meshStandardMaterial color={color} roughness={0.7} metalness={0.05} />
</mesh>
<mesh position={[0, size * 0.36, 0]}>
<boxGeometry args={[size * 2.5, size * 0.28, size * 2.5]} />
<meshStandardMaterial color={shade(color, 0.08)} roughness={0.68} metalness={0.05} />
</mesh>
</group>
);
}
/** Ionic capital — abacus over a pair of spiral volutes. */
export function IonicCapital({
y,
size,
color,
axis = 'z',
}: {
y: number;
size: number;
color: string;
/** Axis the volute pair spreads along. */
axis?: 'x' | 'z';
}) {
return (
<group position={[0, y, 0]}>
<mesh position={[0, size * 0.1, 0]}>
<boxGeometry args={[size * 2.4, size * 0.34, size * 1.5]} />
<meshStandardMaterial color={color} roughness={0.66} metalness={0.06} />
</mesh>
{[-1, 1].map((s) => (
<mesh
key={s}
position={axis === 'z' ? [0, -size * 0.12, s * size * 0.85] : [s * size * 0.85, -size * 0.12, 0]}
rotation={axis === 'z' ? [0, Math.PI / 2, 0] : [0, 0, 0]}
>
<torusGeometry args={[size * 0.38, size * 0.14, 8, 16]} />
<meshStandardMaterial color={shade(color, 0.06)} roughness={0.62} metalness={0.07} />
</mesh>
))}
</group>
);
}
/** Moulded plinth under a shaft. */
export function ShaftBase({ size, color }: { size: number; color: string }) {
return (
<group>
<mesh position={[0, 0.08, 0]}>
<boxGeometry args={[size * 2.4, 0.16, size * 2.4]} />
<meshStandardMaterial color={color} roughness={0.78} metalness={0.04} />
</mesh>
<mesh position={[0, 0.21, 0]}>
<cylinderGeometry args={[size * 1.0, size * 1.15, 0.1, 14]} />
<meshStandardMaterial color={shade(color, 0.06)} roughness={0.72} metalness={0.05} />
</mesh>
</group>
);
}
/** Semicircular arch drawn as a chord of straight voussoir blocks. */
export function RoundArch({
span,
rise,
thickness = 0.14,
depth = 0.16,
color,
segments = 11,
roughness = 0.72,
metalness = 0.05,
}: {
span: number;
rise: number;
thickness?: number;
depth?: number;
color: string;
segments?: number;
roughness?: number;
metalness?: number;
}) {
const blocks = useMemo(() => {
const out: { x: number; y: number; len: number; rot: number }[] = [];
for (let s = 0; s < segments; s++) {
const t0 = s / segments;
const t1 = (s + 1) / segments;
const at = (t: number) => {
const a = Math.PI * (1 - t);
return { x: Math.cos(a) * (span / 2), y: Math.sin(a) * rise };
};
const p0 = at(t0);
const p1 = at(t1);
out.push({
x: (p0.x + p1.x) / 2,
y: (p0.y + p1.y) / 2,
len: Math.hypot(p1.x - p0.x, p1.y - p0.y) * 1.08,
rot: Math.atan2(p1.y - p0.y, p1.x - p0.x),
});
}
return out;
}, [span, rise, segments]);
return (
<group>
{blocks.map((b, i) => (
<mesh key={i} position={[b.x, b.y, 0]} rotation={[0, 0, b.rot]}>
<boxGeometry args={[b.len, thickness, depth]} />
<meshStandardMaterial color={color} roughness={roughness} metalness={metalness} />
</mesh>
))}
</group>
);
}
/** Recessed coffer grid under the ceiling plane. */
export function CofferedCeiling({
room,
y = CEILING_Y,
cell = 2.3,
panelColor,
ribColor,
ribWidth = 0.16,
ribDrop = 0.12,
rosette,
}: {
room: RoomDims;
y?: number;
cell?: number;
panelColor: string;
ribColor: string;
ribWidth?: number;
ribDrop?: number;
rosette?: string;
}) {
const grid = useMemo(() => {
const cols = Math.max(2, Math.min(6, Math.round(room.width / cell)));
const rows = Math.max(2, Math.min(8, Math.round(room.depth / cell)));
const cw = room.width / cols;
const cd = room.depth / rows;
const cells: { x: number; z: number }[] = [];
for (let c = 0; c < cols; c++) {
for (let r = 0; r < rows; r++) {
cells.push({ x: -room.halfW + cw * (c + 0.5), z: -room.halfD + cd * (r + 0.5) });
}
}
return { cells, cw, cd, cols, rows };
}, [room.width, room.depth, room.halfW, room.halfD, cell]);
return (
<group>
{grid.cells.map((c, i) => (
<group key={i} position={[c.x, y, c.z]}>
<mesh position={[0, 0.01, 0]}>
<boxGeometry args={[grid.cw - ribWidth, 0.03, grid.cd - ribWidth]} />
<meshStandardMaterial color={panelColor} roughness={0.86} metalness={0.04} />
</mesh>
{rosette && (
<mesh position={[0, -ribDrop * 0.35, 0]} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[grid.cw * 0.06, grid.cw * 0.15, 12]} />
<meshStandardMaterial color={rosette} roughness={0.4} metalness={0.5} side={2} />
</mesh>
)}
</group>
))}
{/* Ribs between the coffers */}
{Array.from({ length: grid.cols + 1 }, (_, c) => (
<mesh key={`c${c}`} position={[-room.halfW + grid.cw * c, y - ribDrop / 2, 0]}>
<boxGeometry args={[ribWidth, ribDrop, room.depth]} />
<meshStandardMaterial color={ribColor} roughness={0.7} metalness={0.08} />
</mesh>
))}
{Array.from({ length: grid.rows + 1 }, (_, r) => (
<mesh key={`r${r}`} position={[0, y - ribDrop / 2, -room.halfD + grid.cd * r]}>
<boxGeometry args={[room.width, ribDrop, ribWidth]} />
<meshStandardMaterial color={ribColor} roughness={0.7} metalness={0.08} />
</mesh>
))}
</group>
);
}
/** Exposed structural beams spanning the room. */
export function CeilingBeams({
room,
positions,
axis = 'x',
width = 0.22,
height = 0.3,
y = CEILING_Y - 0.16,
color,
roughness = 0.82,
metalness = 0.05,
}: SurfaceProps & {
room: RoomDims;
positions: number[];
/** Axis the beams run along. */
axis?: 'x' | 'z';
width?: number;
height?: number;
y?: number;
}) {
return (
<group>
{positions.map((p, i) => (
<mesh key={i} position={axis === 'x' ? [0, y, p] : [p, y, 0]}>
<boxGeometry
args={axis === 'x' ? [room.width, height, width] : [width, height, room.depth]}
/>
<meshStandardMaterial color={color} roughness={roughness} metalness={metalness} />
</mesh>
))}
</group>
);
}
/** Plaster ceiling rose, the anchor a salon chandelier hangs from. */
export function CeilingRose({
y = CEILING_Y,
radius = 1.0,
color,
rings = 3,
}: {
y?: number;
radius?: number;
color: string;
rings?: number;
}) {
return (
<group position={[0, y, 0]} rotation={[Math.PI / 2, 0, 0]}>
{Array.from({ length: rings }, (_, i) => {
const t = (i + 1) / rings;
return (
<mesh key={i} position={[0, 0, i * 0.012]}>
<ringGeometry args={[radius * t * 0.55, radius * t, 32]} />
<meshStandardMaterial color={shade(color, i * 0.06)} roughness={0.7} metalness={0.12} side={2} />
</mesh>
);
})}
</group>
);
}
/**
* Hanging light fixture. `light` adds a real point light keep at most two per
* hall so the shared track lighting stays inside the WebGL light budget.
*/
export function HangingFixture({
position,
dropLength = 0.6,
radius = 0.42,
arms = 6,
metalColor,
flameColor = '#ffcf80',
glowColor = '#ff9830',
light = false,
lightColor = '#ffb060',
lightIntensity = 0.8,
lightDistance = 7,
}: {
position: [number, number, number];
dropLength?: number;
radius?: number;
arms?: number;
metalColor: string;
flameColor?: string;
glowColor?: string;
light?: boolean;
lightColor?: string;
lightIntensity?: number;
lightDistance?: number;
}) {
const ringY = position[1] - dropLength;
return (
<group position={[position[0], 0, position[2]]}>
<mesh position={[0, position[1] - dropLength / 2, 0]}>
<cylinderGeometry args={[0.012, 0.012, dropLength, 6]} />
<meshStandardMaterial color={shade(metalColor, -0.4)} roughness={0.42} metalness={0.7} />
</mesh>
<mesh position={[0, ringY, 0]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[radius, 0.028, 8, 22]} />
<meshStandardMaterial color={metalColor} roughness={0.26} metalness={0.82} />
</mesh>
{Array.from({ length: arms }, (_, i) => {
const a = (i / arms) * Math.PI * 2;
return (
<mesh key={i} position={[Math.cos(a) * radius, ringY - 0.09, Math.sin(a) * radius]}>
<sphereGeometry args={[0.05, 8, 8]} />
<meshStandardMaterial
color={flameColor}
emissive={glowColor}
emissiveIntensity={0.9}
toneMapped={false}
/>
</mesh>
);
})}
{light && (
<pointLight
position={[0, ringY - 0.09, 0]}
intensity={lightIntensity}
distance={lightDistance}
color={lightColor}
/>
)}
</group>
);
}
/**
* Wall light mounted above the frame line. Emissive only sconces are for
* period character, not for lighting the hall.
*/
export function WallSconce({
wall,
room,
along,
y = 2.86,
armColor,
glowColor = '#ffb45a',
shadeColor = '#ffdca8',
}: {
wall: 'left' | 'right' | 'back';
room: RoomDims;
/** Offset along the wall from its centre. */
along: number;
y?: number;
armColor: string;
glowColor?: string;
shadeColor?: string;
}) {
const pos: [number, number, number] =
wall === 'back'
? [along, y, -room.halfD + WALL_INNER + 0.02]
: wall === 'left'
? [-room.halfW + WALL_INNER + 0.02, y, along]
: [room.halfW - WALL_INNER - 0.02, y, along];
const rot: [number, number, number] =
wall === 'back' ? [0, 0, 0] : wall === 'left' ? [0, Math.PI / 2, 0] : [0, -Math.PI / 2, 0];
return (
<group position={pos} rotation={rot}>
<mesh position={[0, 0, 0.03]}>
<boxGeometry args={[0.16, 0.22, 0.05]} />
<meshStandardMaterial color={armColor} roughness={0.34} metalness={0.68} />
</mesh>
<mesh position={[0, 0.06, 0.09]} rotation={[Math.PI / 2.6, 0, 0]}>
<cylinderGeometry args={[0.012, 0.012, 0.16, 6]} />
<meshStandardMaterial color={armColor} roughness={0.34} metalness={0.68} />
</mesh>
<mesh position={[0, 0.15, 0.13]}>
<sphereGeometry args={[0.075, 10, 10]} />
<meshStandardMaterial
color={shadeColor}
emissive={glowColor}
emissiveIntensity={0.75}
toneMapped={false}
/>
</mesh>
</group>
);
}
+40 -8
View File
@@ -1,11 +1,20 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { getAuthMe, loginCurator, logoutCurator, type AuthRole } from '../api/client';
import { useCallback, useContext, useEffect, useMemo, useState, type ReactNode, createContext } from 'react';
import {
getAuthMe,
loginCurator,
logoutCurator,
type AuthRole,
type StaffPermission,
} from '../api/client';
interface AuthContextValue {
role: AuthRole;
username?: string;
permissions: StaffPermission[];
isCurator: boolean;
isAdmin: boolean;
loading: boolean;
can: (permission: StaffPermission) => boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
@@ -13,15 +22,26 @@ interface AuthContextValue {
const AuthContext = createContext<AuthContextValue | null>(null);
function applyAuthState(
me: { role: AuthRole; username?: string; permissions?: StaffPermission[] },
setRole: (r: AuthRole) => void,
setUsername: (u: string | undefined) => void,
setPermissions: (p: StaffPermission[]) => void
) {
setRole(me.role);
setUsername(me.username);
setPermissions(me.role === 'user' ? [] : me.permissions ?? []);
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [role, setRole] = useState<AuthRole>('user');
const [username, setUsername] = useState<string | undefined>();
const [permissions, setPermissions] = useState<StaffPermission[]>([]);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
const me = await getAuthMe();
setRole(me.role);
setUsername(me.username);
applyAuthState(me, setRole, setUsername, setPermissions);
}, []);
useEffect(() => {
@@ -30,27 +50,39 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const login = useCallback(async (user: string, password: string) => {
const me = await loginCurator(user, password);
setRole(me.role);
setUsername(me.username);
applyAuthState(me, setRole, setUsername, setPermissions);
}, []);
const logout = useCallback(async () => {
await logoutCurator();
setRole('user');
setUsername(undefined);
setPermissions([]);
}, []);
const can = useCallback(
(permission: StaffPermission) => {
if (role === 'admin') return true;
if (role !== 'curator') return false;
return permissions.includes(permission);
},
[role, permissions]
);
const value = useMemo(
() => ({
role,
username,
isCurator: role === 'curator',
permissions,
isCurator: role === 'admin' || role === 'curator',
isAdmin: role === 'admin',
loading,
can,
login,
logout,
refresh,
}),
[role, username, loading, login, logout, refresh]
[role, username, permissions, loading, can, login, logout, refresh]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+214 -75
View File
@@ -52,9 +52,44 @@ export interface MovementInteriorStyle {
doorWood: [string, string, string];
windows: GalleryWindowSpec[];
trackLights: number;
details: 'palazzo' | 'baroque' | 'medieval' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier';
/** Scales the shared hall/scene lights. <1 for deliberately dim period interiors. */
lightScale: number;
/**
* Which period architecture the hall is built from. Every movement has its
* own the interiors are not shared between movements and each kind maps
* to one component under `components/hall-details/`.
*/
details: MovementDetailKind;
}
export type MovementDetailKind =
| 'roman'
| 'byzantine'
| 'gothic'
| 'quattrocento'
| 'cinquecento'
| 'flemish'
| 'mannerist'
| 'baroque'
| 'rococo'
| 'neoclassical'
| 'gothic-revival'
| 'bourgeois'
| 'north-light'
| 'paris-atelier'
| 'symbolist'
| 'art-nouveau'
| 'fauvist'
| 'expressionist'
| 'cubist-atelier'
| 'futurist'
| 'suprematist'
| 'constructivist'
| 'dada'
| 'surrealist'
| 'loft'
| 'white-cube';
function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] {
return specs;
}
@@ -85,6 +120,7 @@ function mk(
doorWood: opts.doorWood ?? ['#3d2818', '#4e3624', '#261a10'],
windows: opts.windows,
trackLights: opts.trackLights ?? 0.8,
lightScale: opts.lightScale ?? 1,
details: opts.details,
};
}
@@ -96,9 +132,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Roman atrium',
'Marble-clad villa · mosaic floor · clerestory daylight',
{ wall: 'limestone', ceiling: 'plaster-warm', floor: 'mosaic-roman' },
{ wall: '#e8e0d0', ceiling: '#f5f0e8', floor: '#c8b898', trim: '#a89878' },
{ wall: '#8e8980', ceiling: '#9c9994', floor: '#c8b898', trim: '#a89878' },
{
details: 'classical',
details: 'roman',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#5a4830',
ambient: 0.62,
warmLight: '#fff8e8',
@@ -114,47 +153,57 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
),
28: mk(
'byzantine-sanctuary',
'Byzantine chapel',
'Gold mosaic walls · amber light through arched windows',
{ wall: 'mosaic-byzantine', ceiling: 'gilded-stucco', floor: 'marble-checker' },
{ wall: '#d4af37', ceiling: '#c8a840', floor: '#ddd8cc', trim: '#b8860b' },
'Byzantine basilica',
'Marble revetment · coffered timber ceiling · Cosmatesque stone floor',
{ wall: 'marble-revetment', ceiling: 'coffered-wood', floor: 'marble-opus-sectile' },
{ wall: '#a89880', ceiling: '#6a4526', floor: '#a09079', trim: '#8a6440' },
{
details: 'medieval',
titleColor: '#f0e8c0',
ambient: 0.48,
warmLight: '#ffd898',
sunLight: '#ffe8b0',
fog: '#0a0806',
background: '#060504',
details: 'byzantine',
titleColor: '#e8d8b0',
ambient: 0.44,
warmLight: '#ffdca8',
sunLight: '#ffdca0',
fog: '#100b07',
background: '#0a0705',
windows: windows(
{ wall: 'back', x: 0, y: 2.8, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 2.4 },
{ wall: 'left', x: -1, y: 2.6, width: 0.7, height: 1.8, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 1.8 },
{ wall: 'right', x: 1, y: 2.6, width: 0.7, height: 1.8, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 1.8 }
{ wall: 'back', x: 0, y: 3.1, width: 0.9, height: 1.9, style: 'roman-arch', lightColor: '#ffd28a', lightIntensity: 3.4 },
{ wall: 'left', x: -3.5, y: 3.2, width: 0.7, height: 1.6, style: 'roman-arch', lightColor: '#ffd28a', lightIntensity: 3.0 },
{ wall: 'left', x: 3.5, y: 3.2, width: 0.7, height: 1.6, style: 'roman-arch', lightColor: '#ffd28a', lightIntensity: 3.0 },
{ wall: 'right', x: -3.5, y: 3.2, width: 0.7, height: 1.6, style: 'roman-arch', lightColor: '#ffd28a', lightIntensity: 3.0 },
{ wall: 'right', x: 3.5, y: 3.2, width: 0.7, height: 1.6, style: 'roman-arch', lightColor: '#ffd28a', lightIntensity: 3.0 }
),
trackLights: 0.5,
doorWood: ['#3a3020', '#4a3830', '#2a2018'],
trackLights: 0.35,
// Basilica interiors are lit by shafts from high windows, not evenly flooded.
lightScale: 0.3,
doorWood: ['#3a2416', '#4c3220', '#241608'],
}
),
29: mk(
'gothic-cathedral',
'Gothic hall',
'Stone vault · tall lancet windows · flagstone floor',
{ wall: 'rough-stone', ceiling: 'basalt', floor: 'flagstone' },
{ wall: '#8a8478', ceiling: '#3a3630', floor: '#6a6458', trim: '#5c5648' },
'Gothic nave',
'Blind-arcaded ashlar · ribbed stone vault · worn flagstones',
{ wall: 'gothic-ashlar', ceiling: 'gothic-vault', floor: 'gothic-stone-floor' },
{ wall: '#8d8471', ceiling: '#6d6555', floor: '#6f6759', trim: '#7d7159' },
{
details: 'medieval',
titleColor: '#e8dcc8',
ambient: 0.52,
warmLight: '#e8d8c0',
sunLight: '#d0e8ff',
fog: '#0c0c10',
details: 'gothic',
titleColor: '#efe4cc',
ambient: 0.6,
warmLight: '#ffe6bc',
sunLight: '#dbe8ff',
// Cool shadowed stone, but never a pure void — walls must stay readable.
fog: '#1b1a18',
background: '#131211',
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.5 },
{ wall: 'left', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 },
{ wall: 'right', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 }
{ 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.8 },
{ wall: 'left', x: -4.5, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.2 },
{ 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.3,
trackLights: 0.5,
// Naves are lit by window shafts against shadowed stone, not flooded.
lightScale: 0.26,
}
),
30: mk(
@@ -162,9 +211,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Florentine palazzo',
'Lime-washed walls · terracotta accents · arched windows',
{ wall: 'painted-lime', wallSide: 'stucco-cream', ceiling: 'fresco-worn', floor: 'terracotta-tiles' },
{ wall: '#f4ebe0', ceiling: '#faf6ee', floor: '#c89070', trim: '#c9a227' },
{ wall: '#89847e', ceiling: '#96948f', floor: '#c89070', trim: '#c9a227' },
{
details: 'palazzo',
details: 'quattrocento',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#6b4423',
windows: windows(
{ wall: 'back', x: -2.2, y: 2.6, width: 1.3, height: 1.7, style: 'roman-arch', lightColor: '#fff4e0', lightIntensity: 3.0 },
@@ -178,9 +230,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Roman palazzo',
'Marble and stucco · coffered ceiling · checker floor',
{ wall: 'marble-veined-carrara', wallSide: 'stucco-cream', ceiling: 'plaster-warm', floor: 'marble-checker' },
{ wall: '#f0ece4', wallSide: '#efe4d4', ceiling: '#faf6ee', floor: '#ddd8cc', trim: '#c9a227' },
{ wall: '#918f8a', wallSide: '#8f8980', ceiling: '#9c9994', floor: '#ddd8cc', trim: '#c9a227' },
{
details: 'palazzo',
details: 'cinquecento',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#6b4423',
windows: windows(
{ wall: 'back', x: 0, y: 2.8, width: 2.8, height: 1.8, style: 'baroque-pair', lightColor: '#fff8f0', lightIntensity: 3.8 },
@@ -195,9 +250,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Flemish panel hall',
'Oak wainscoting · leaded glass · herringbone floor',
{ wall: 'oak-panel', wallSide: 'plaster-warm', ceiling: 'dark-wood-panel', floor: 'parquet-herringbone' },
{ wall: '#8a6848', wallSide: '#f0ebe3', ceiling: '#3a2818', floor: '#8a6848', trim: '#5c4030' },
{ wall: '#8a6848', wallSide: '#8c8985', ceiling: '#3a2818', floor: '#8a6848', trim: '#5c4030' },
{
details: 'salon',
details: 'flemish',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#f0e8d8',
warmLight: '#ffe8c8',
windows: windows(
@@ -213,9 +271,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Mannerist gallery',
'Dramatic stucco · elongated windows · veined marble',
{ wall: 'stucco-terracotta', ceiling: 'gilded-stucco', floor: 'marble-veined-emerald' },
{ wall: '#d8b898', ceiling: '#d8c070', floor: '#dce8e0', trim: '#b8860b' },
{ wall: '#98816b', ceiling: '#a79456', floor: '#dce8e0', trim: '#b8860b' },
{
details: 'baroque',
details: 'mannerist',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#4a2818',
windows: windows(
{ wall: 'back', x: -1.8, y: 2.7, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#fff0d8', lightIntensity: 2.8 },
@@ -251,9 +312,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Rococo salon',
'Pastel painted walls · gilt trim · chevron parquet',
{ wall: 'painted-oil-satin', ceiling: 'silk-pale', floor: 'parquet-chevrons' },
{ wall: '#e8dce8', ceiling: '#faf6f0', floor: '#c8a882', trim: '#d4af37' },
{ wall: '#918a91', ceiling: '#9b9995', floor: '#c8a882', trim: '#d4af37' },
{
details: 'salon',
details: 'rococo',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#6a4858',
warmLight: '#fff0f0',
windows: windows(
@@ -268,9 +332,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Neoclassical museum',
'White painted walls · coffered ceiling · skylit salon',
{ wall: 'painted-flat', ceiling: 'plaster-white', floor: 'marble-veined-carrara' },
{ wall: '#f2f0ec', ceiling: '#fafafa', floor: '#eceae6', trim: '#b8b0a4' },
{ wall: '#908f8d', ceiling: '#9e9e9e', floor: '#eceae6', trim: '#b8b0a4' },
{
details: 'neoclassical',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#4a4844',
ambient: 0.65,
windows: windows(
@@ -288,7 +355,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
{ wall: 'walnut-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
{ wall: '#5a4030', ceiling: '#2a2420', floor: '#6a5040', trim: '#8a7050' },
{
details: 'salon',
details: 'gothic-revival',
titleColor: '#e8dcc8',
ambient: 0.5,
warmLight: '#ffe0c0',
@@ -304,9 +371,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Realist picture gallery',
'Warm painted walls · bourgeois salon · oak parquet',
{ wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
{ wall: '#e8e2d8', ceiling: '#f5f0e8', floor: '#a08060', trim: '#8a7050' },
{ wall: '#88847e', ceiling: '#97948f', floor: '#a08060', trim: '#8a7050' },
{
details: 'salon',
details: 'bourgeois',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
windows: windows(
{ wall: 'back', x: 0, y: 2.5, width: 2.4, height: 1.6, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 3.2 },
{ wall: 'left', x: 0, y: 2.7, width: 1.6, height: 1.3, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.4 }
@@ -318,9 +388,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Impressionist salon',
'North-light skylight · pale painted walls · herringbone floor',
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
{ wall: '#f0ebe3', ceiling: '#fafafa', floor: '#c8a882', trim: '#c9a96e' },
{ wall: '#8f8c87', ceiling: '#9e9e9e', floor: '#c8a882', trim: '#c9a96e' },
{
details: 'salon',
details: 'north-light',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
ambient: 0.68,
windows: windows(
{ wall: 'ceiling', x: 0, y: 0, width: 5.0, height: 3.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 6.0 },
@@ -335,9 +408,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Montmartre atelier',
'Painted studio walls · large north window · worn floorboards',
{ wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
{ wall: '#e8e0d0', ceiling: '#f0ebe0', floor: '#9a7858', trim: '#8a6848' },
{ wall: '#847f76', ceiling: '#918f88', floor: '#9a7858', trim: '#8a6848' },
{
details: 'atelier',
details: 'paris-atelier',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
windows: windows(
{ wall: 'left', x: 0, y: 2.4, width: 2.8, height: 2.0, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 4.5 },
{ wall: 'back', x: 0, y: 2.8, width: 1.0, height: 1.2, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.0 }
@@ -351,7 +427,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
{ wall: '#1a4030', ceiling: '#1a1814', floor: '#4a3828', trim: '#8a7050' },
{
details: 'salon',
details: 'symbolist',
titleColor: '#d8e8c8',
ambient: 0.55,
warmLight: '#ffd898',
@@ -367,9 +443,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Art Nouveau salon',
'Painted plaster · stained glass · terrazzo floor',
{ wall: 'painted-oil-satin', ceiling: 'silk-gold', floor: 'terrazzo' },
{ wall: '#f0ece4', ceiling: '#d8c890', floor: '#d8d0c4', trim: '#6a9868' },
{ wall: '#8f8c87', ceiling: '#a0946b', floor: '#d8d0c4', trim: '#6a9868' },
{
details: 'salon',
details: 'art-nouveau',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#3a5840',
windows: windows(
{ wall: 'back', x: 0, y: 2.5, width: 2.2, height: 2.0, style: 'art-nouveau', lightColor: '#e8ffe8', lightIntensity: 3.2 },
@@ -383,9 +462,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Fauvist studio',
'Bold painted walls · flooded with color and light',
{ wall: 'painted-oil-matte', wallSide: 'painted-oil-matte', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
{ wall: '#e89060', wallSide: '#e8c860', ceiling: '#f8f0e0', floor: '#a07048', trim: '#c04020' },
{ wall: '#e89060', wallSide: '#e8c860', ceiling: '#9e998e', floor: '#a07048', trim: '#c04020' },
{
details: 'atelier',
details: 'fauvist',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#402010',
ambient: 0.7,
windows: windows(
@@ -401,7 +483,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
{ wall: 'dark-wood-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
{ wall: '#3a2818', ceiling: '#2a2018', floor: '#5a4030', trim: '#8a6040' },
{
details: 'atelier',
details: 'expressionist',
titleColor: '#f0d8b0',
ambient: 0.52,
windows: windows(
@@ -415,9 +497,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Cubist studio',
'Paris atelier · factory windows · painted plaster',
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
{ wall: '#e8e4dc', ceiling: '#f5f5f5', floor: '#9a8870', trim: '#888888' },
{ wall: '#8c8a85', ceiling: '#999999', floor: '#9a8870', trim: '#888888' },
{
details: 'atelier',
details: 'cubist-atelier',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
ambient: 0.65,
windows: windows(
{ wall: 'left', x: 0, y: 2.5, width: 3.5, height: 2.4, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 5.5 },
@@ -430,9 +515,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Futurist loft',
'Steel and glass · industrial concrete · sweeping daylight',
{ wall: 'concrete-raw', ceiling: 'concrete-raw', floor: 'industrial-floor' },
{ wall: '#a8a8a8', ceiling: '#989898', floor: '#888880', trim: '#606060' },
{ wall: '#858585', ceiling: '#7a7a7a', floor: '#888880', trim: '#606060' },
{
details: 'industrial',
details: 'futurist',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#303030',
ambient: 0.62,
fog: '#181818',
@@ -449,9 +537,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Suprematist white cube',
'Matte white paint · geometric light · polished floor',
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#e0e0e0', trim: '#cccccc' },
{ wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#e0e0e0', trim: '#cccccc' },
{
details: 'modern',
details: 'suprematist',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#222222',
ambient: 0.72,
fog: '#1a1a1a',
@@ -469,9 +560,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Constructivist space',
'Glass block · concrete · angular daylight',
{ wall: 'concrete-block', ceiling: 'concrete-raw', floor: 'concrete-polished' },
{ wall: '#b0b0b0', ceiling: '#a0a0a0', floor: '#c0c0c0', trim: '#808080' },
{ wall: '#858585', ceiling: '#7a7a7a', floor: '#c0c0c0', trim: '#808080' },
{
details: 'industrial',
details: 'constructivist',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#303030',
ambient: 0.65,
windows: windows(
@@ -487,9 +581,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Dada salon',
'Eclectic bourgeois room · painted walls and exposed brick',
{ wall: 'painted-emulsion', wallSide: 'brick', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
{ wall: '#e8dcc8', wallSide: '#8a5040', ceiling: '#f0ebe0', floor: '#8a6848', trim: '#6a4830' },
{ wall: '#8b8478', wallSide: '#8a5040', ceiling: '#97948d', floor: '#8a6848', trim: '#6a4830' },
{
details: 'salon',
details: 'dada',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
windows: windows(
{ wall: 'back', x: -1.5, y: 2.5, width: 1.0, height: 1.4, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.5 },
{ wall: 'back', x: 1.5, y: 2.6, width: 0.8, height: 1.8, style: 'gothic-lancet', lightColor: '#ffe8c0', lightIntensity: 2.0 },
@@ -505,7 +602,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
{ wall: '#1a2848', ceiling: '#2a2428', floor: '#5a4838', trim: '#8a7050' },
{
details: 'salon',
details: 'surrealist',
titleColor: '#e8dcc8',
ambient: 0.58,
warmLight: '#ffd898',
@@ -522,9 +619,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'NYC loft',
'Raw brick and painted plaster · north-light factory windows',
{ wall: 'brick', wallSide: 'painted-flat', ceiling: 'concrete-raw', floor: 'industrial-floor' },
{ wall: '#8a5040', wallSide: '#f5f5f5', ceiling: '#989898', floor: '#888880', trim: '#606060' },
{ wall: '#8a5040', wallSide: '#858585', ceiling: '#989898', floor: '#888880', trim: '#606060' },
{
details: 'industrial',
details: 'loft',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#303030',
ambient: 0.65,
windows: windows(
@@ -538,9 +638,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Pop Art gallery',
'White painted walls · fluorescent daylight · polished concrete',
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#d8d8d8', trim: '#cccccc' },
{ wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#d8d8d8', trim: '#cccccc' },
{
details: 'modern',
details: 'white-cube',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#222222',
ambient: 0.75,
warmLight: '#ffffff',
@@ -578,13 +681,48 @@ function blendAccent(style: MovementInteriorStyle, accentHex?: string): Movement
};
}
/**
* Styles were authored with legacy ids 2752; gallery_dev uses 126.
* Northern (5) / High Renaissance (6) are swapped vs the legacy sequence.
*/
const DB_ID_TO_STYLE_KEY: Record<number, number> = {
1: 27,
2: 28,
3: 29,
4: 30,
5: 32,
6: 31,
7: 33,
8: 34,
9: 35,
10: 36,
11: 37,
12: 38,
13: 39,
14: 40,
15: 41,
16: 42,
17: 43,
18: 44,
19: 45,
20: 46,
21: 47,
22: 48,
23: 49,
24: 50,
25: 51,
26: 52,
};
/** Fallback name-based resolver for movements not in the catalog. */
function fallbackByName(movement: ArtMovement & { era_name?: string }): MovementInteriorStyle {
const n = movement.name.toLowerCase();
const era = (movement.era_name ?? '').toLowerCase();
if (n.includes('byzantine')) return BY_MOVEMENT_ID[28];
if (n.includes('gothic')) return BY_MOVEMENT_ID[29];
if (n.includes('renaissance')) return BY_MOVEMENT_ID[31];
if (n.includes('baroque') || n.includes('rococo')) return BY_MOVEMENT_ID[34];
if (era.includes('medieval') || n.includes('gothic')) return BY_MOVEMENT_ID[29];
if (era.includes('medieval')) return BY_MOVEMENT_ID[29];
if (n.includes('impression')) return BY_MOVEMENT_ID[39];
if (era.includes('modern') || era.includes('contemporary')) return BY_MOVEMENT_ID[52];
return BY_MOVEMENT_ID[36];
@@ -593,7 +731,8 @@ function fallbackByName(movement: ArtMovement & { era_name?: string }): Movement
export function resolveMovementInteriorStyle(
movement: ArtMovement & { era_name?: string }
): MovementInteriorStyle {
const base = BY_MOVEMENT_ID[movement.id] ?? fallbackByName(movement);
const styleKey = DB_ID_TO_STYLE_KEY[movement.id] ?? movement.id;
const base = BY_MOVEMENT_ID[styleKey] ?? fallbackByName(movement);
return blendAccent(base, movement.color);
}
+2 -1
View File
@@ -22,7 +22,8 @@ export function useTexturedMaterial(
color: tint,
roughness: surf.roughness,
metalness: surf.metalness,
envMapIntensity: kind.startsWith('painted-') ? 0.45 : 0.65,
// Keep walls readable without HDR Environment IBL (CDN may be slow/offline).
envMapIntensity: kind.startsWith('painted-') ? 0.2 : 0.3,
});
}, [kind, tint, spanW, spanH]);
+9 -1
View File
@@ -13,6 +13,8 @@ import enDebug from '../locales/en/debug.json';
import enTranslations from '../locales/en/translations.json';
import enInfluences from '../locales/en/influences.json';
import enTours from '../locales/en/tours.json';
import enUsers from '../locales/en/users.json';
import enAudit from '../locales/en/audit.json';
import ruCommon from '../locales/ru/common.json';
import ruHome from '../locales/ru/home.json';
@@ -25,6 +27,8 @@ import ruDebug from '../locales/ru/debug.json';
import ruTranslations from '../locales/ru/translations.json';
import ruInfluences from '../locales/ru/influences.json';
import ruTours from '../locales/ru/tours.json';
import ruUsers from '../locales/ru/users.json';
import ruAudit from '../locales/ru/audit.json';
const initialLocale = readStoredLocale();
writeStoredLocale(initialLocale);
@@ -33,7 +37,7 @@ void i18n.use(initReactI18next).init({
lng: initialLocale,
fallbackLng: 'en',
supportedLngs: ['en', 'ru'],
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours'],
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours', 'users', 'audit'],
defaultNS: 'common',
resources: {
en: {
@@ -48,6 +52,8 @@ void i18n.use(initReactI18next).init({
translations: enTranslations,
influences: enInfluences,
tours: enTours,
users: enUsers,
audit: enAudit,
},
ru: {
common: ruCommon,
@@ -61,6 +67,8 @@ void i18n.use(initReactI18next).init({
translations: ruTranslations,
influences: ruInfluences,
tours: ruTours,
users: ruUsers,
audit: ruAudit,
},
},
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"
}
+10
View File
@@ -17,6 +17,16 @@
"influences": "Influences",
"tours": "Tours",
"toursEditor": "Tour editor",
"users": "Users",
"audit": "Activity",
"layoutHorizontal": "Classic timeline →",
"layoutVertical": "↑ Vertical timeline",
"layoutTree": "🌳 Tree of art",
"captionClassicTimeline": "Click an era or event to zoom · Scroll to zoom · Drag to pan",
"captionClassicFlow": "Scroll to zoom · drag to pan · each movement stream is a solid colour band through history",
"captionVerticalTimeline": "Bottom → top · Scroll to zoom · Drag to pan",
"captionVerticalFlow": "Bottom → top through history · scroll to zoom · drag to pan · click a stream",
"captionTreeFlow": "Roots at the bottom, living movements at the crown · scroll to zoom · drag to pan · click a branch to enter its gallery",
"openingTourGallery": "Opening guided tour…",
"tourEmpty": "This tour has no paintings yet.",
"tourLoadFailed": "Failed to load the tour.",
+8 -1
View File
@@ -11,5 +11,12 @@
"tourNotes": "Tour notes",
"tourNotesFor": "Tour notes · {{title}}",
"tourNotesEmpty": "No notes for this stop.",
"tourStopPosition": "Stop {{current}} of {{total}}"
"tourStopPosition": "Stop {{current}} of {{total}}",
"curatorNotes": "Curator notes",
"curatorNotesEmpty": "No curator notes yet.",
"curatorNotesEdit": "Edit",
"curatorNotesSave": "Save",
"curatorNotesSaving": "Saving…",
"curatorNotesCancel": "Cancel",
"curatorNotesSaveFailed": "Could not save curator notes."
}
+37
View File
@@ -0,0 +1,37 @@
{
"title": "Users",
"back": "← Back",
"loadFailed": "Failed to load users",
"createTitle": "Create user",
"username": "Username",
"password": "Password",
"role": "Role",
"roleAdmin": "Admin",
"roleCurator": "Curator",
"permissions": "Permissions",
"active": "Active",
"inactive": "Disabled",
"create": "Create",
"creating": "Creating…",
"save": "Save",
"saving": "Saving…",
"resetPassword": "Reset password",
"newPassword": "New password",
"lastLogin": "Last login",
"never": "Never",
"selectUser": "Select a user to edit",
"loading": "Loading…",
"perm_images": "Images (fix/upload/debug)",
"perm_checkup": "Checkup",
"perm_curator_notes": "Curator notes",
"perm_translations": "Translations",
"perm_influences": "Influences",
"perm_tours": "Tours",
"perm_users": "Users",
"adminAllPerms": "Admins have all permissions automatically.",
"deactivate": "Disable account",
"activate": "Enable account",
"created": "User created",
"saved": "Saved",
"passwordReset": "Password updated"
}
+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": "Далее"
}
+10
View File
@@ -17,6 +17,16 @@
"influences": "Влияния",
"tours": "Экскурсии",
"toursEditor": "Редактор экскурсий",
"users": "Пользователи",
"audit": "Активность",
"layoutHorizontal": "Классическая шкала →",
"layoutVertical": "↑ Вертикальная шкала",
"layoutTree": "🌳 Древо искусства",
"captionClassicTimeline": "Щёлкните эпоху или событие для приближения · Колесо — масштаб · Перетащите для панорамы",
"captionClassicFlow": "Колесо — масштаб · перетащите для панорамы · каждое направление — цветная полоса сквозь историю",
"captionVerticalTimeline": "Снизу вверх · Колесо — масштаб · Перетащите для панорамы",
"captionVerticalFlow": "Снизу вверх по истории · колесо — масштаб · перетащите для панорамы · щёлкните поток",
"captionTreeFlow": "Корни внизу, живые направления в кроне · колесо — масштаб · перетащите для панорамы · щёлкните ветвь, чтобы войти в зал",
"openingTourGallery": "Открытие экскурсии…",
"tourEmpty": "В этой экскурсии пока нет картин.",
"tourLoadFailed": "Не удалось загрузить экскурсию.",
+8 -1
View File
@@ -11,5 +11,12 @@
"tourNotes": "Текст экскурсии",
"tourNotesFor": "Экскурсия · {{title}}",
"tourNotesEmpty": "Для этой остановки нет текста.",
"tourStopPosition": "Остановка {{current}} из {{total}}"
"tourStopPosition": "Остановка {{current}} из {{total}}",
"curatorNotes": "Заметки куратора",
"curatorNotesEmpty": "Заметок куратора пока нет.",
"curatorNotesEdit": "Изменить",
"curatorNotesSave": "Сохранить",
"curatorNotesSaving": "Сохранение…",
"curatorNotesCancel": "Отмена",
"curatorNotesSaveFailed": "Не удалось сохранить заметки куратора."
}
+37
View File
@@ -0,0 +1,37 @@
{
"title": "Пользователи",
"back": "← Назад",
"loadFailed": "Не удалось загрузить пользователей",
"createTitle": "Создать пользователя",
"username": "Имя пользователя",
"password": "Пароль",
"role": "Роль",
"roleAdmin": "Администратор",
"roleCurator": "Куратор",
"permissions": "Права",
"active": "Активен",
"inactive": "Отключён",
"create": "Создать",
"creating": "Создание…",
"save": "Сохранить",
"saving": "Сохранение…",
"resetPassword": "Сбросить пароль",
"newPassword": "Новый пароль",
"lastLogin": "Последний вход",
"never": "Никогда",
"selectUser": "Выберите пользователя для редактирования",
"loading": "Загрузка…",
"perm_images": "Изображения (правка/загрузка)",
"perm_checkup": "Проверка",
"perm_curator_notes": "Заметки куратора",
"perm_translations": "Переводы",
"perm_influences": "Влияния",
"perm_tours": "Экскурсии",
"perm_users": "Пользователи",
"adminAllPerms": "У администраторов все права автоматически.",
"deactivate": "Отключить учётную запись",
"activate": "Включить учётную запись",
"created": "Пользователь создан",
"saved": "Сохранено",
"passwordReset": "Пароль обновлён"
}
+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;
}
.home-timeline-stack-vertical {
flex-direction: row;
align-items: stretch;
}
.home-movements-section-vertical {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
z-index: 1;
}
.site-layout-switch {
position: absolute;
top: 16px;
left: 16px;
z-index: 120;
display: flex;
gap: 8px;
align-items: center;
}
.site-layout-link {
padding: 6px 12px;
border-radius: 6px;
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(15, 15, 26, 0.85);
color: rgba(201, 169, 110, 0.9);
font-size: 12px;
font-family: 'Georgia', serif;
cursor: pointer;
text-decoration: none;
}
.site-layout-link:hover {
background: rgba(201, 169, 110, 0.18);
color: #f5e6c8;
}
/* Link to the alternative tree start page — the one worth noticing. */
.site-layout-link-feature {
border-color: rgba(201, 169, 110, 0.7);
background: rgba(201, 169, 110, 0.16);
color: #f5e6c8;
}
.site-layout-link-feature:hover {
background: rgba(201, 169, 110, 0.3);
}
.gallery-session-suspended {
position: fixed;
inset: 0;
+379 -24
View File
@@ -3,6 +3,9 @@ import { useTranslation } from 'react-i18next';
import Timeline from '../components/Timeline';
import TimelineEventGuides from '../components/TimelineEventGuides';
import MovementBands from '../components/MovementBands';
import VerticalTimeline from '../components/VerticalTimeline';
import VerticalMovementBands from '../components/VerticalMovementBands';
import MovementTree from '../components/MovementTree';
import VirtualGallery from '../components/VirtualGallery';
import PaintingDetailView from '../components/PaintingDetail';
import ArtistBio from '../components/ArtistBio';
@@ -10,18 +13,24 @@ import CheckupPage from '../pages/CheckupPage';
import TranslationsPage from '../pages/TranslationsPage';
import InfluencesPage from '../pages/InfluencesPage';
import ToursPage from '../pages/ToursPage';
import UsersPage from '../pages/UsersPage';
import AuditPage from '../pages/AuditPage';
import CuratorLoginModal from '../components/CuratorLoginModal';
import ArtistFilterModal from '../components/ArtistFilterModal';
import ToursPopup from '../components/ToursPopup';
import CatalogSearchBar from '../components/CatalogSearchBar';
import LocaleSwitcher from '../components/LocaleSwitcher';
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
import '../components/CatalogSearchBar.css';
import '../components/CuratorLoginModal.css';
import '../components/ArtistFilterModal.css';
import '../components/ToursPopup.css';
import '../components/LocaleSwitcher.css';
import '../pages/TranslationsPage.css';
import '../pages/InfluencesPage.css';
import '../pages/ToursPage.css';
import '../pages/UsersPage.css';
import '../pages/AuditPage.css';
import { useAuth } from '../context/AuthContext';
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
import type {
@@ -32,6 +41,7 @@ import type {
PaintingDetail,
MovementGalleryDetail,
TourGalleryDetail,
ArtistSummary,
} from '../types';
import { createViewChangeScheduler } from '../utils/timelineView';
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
@@ -40,10 +50,14 @@ import './HomePage.css';
type View =
| { type: 'timeline' }
| { type: 'timeline-vertical' }
| { type: 'timeline-tree' }
| { type: 'checkup' }
| { type: 'translations' }
| { type: 'influences' }
| { type: 'tours' }
| { type: 'users' }
| { type: 'audit' }
| { type: 'gallery'; artistId: number; data: ArtistDetail }
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
| { type: 'tour-gallery'; tourId: number; data: TourGalleryDetail }
@@ -55,7 +69,7 @@ type GallerySession =
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail }
| { kind: 'tour'; tourId: number; data: TourGalleryDetail };
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | null;
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | 'users' | 'audit' | null;
function patchPaintingInMovementDetail(
detail: MovementGalleryDetail,
@@ -145,10 +159,48 @@ function catalogNavigateTarget(
return idx < remaining.length ? remaining[idx].id : remaining[remaining.length - 1].id;
}
/** Shareable timeline layout via `?layout=classic|vertical|tree` (classic may omit the param). */
type TimelineLayoutId = 'classic' | 'vertical' | 'tree';
function parseTimelineLayoutParam(raw: string | null): TimelineLayoutId {
if (raw === 'vertical' || raw === 'tree') return raw;
if (raw === 'classic' || raw === 'horizontal') return 'classic';
return 'classic';
}
function timelineViewFromLayout(layout: TimelineLayoutId): View {
if (layout === 'vertical') return { type: 'timeline-vertical' };
if (layout === 'tree') return { type: 'timeline-tree' };
return { type: 'timeline' };
}
function writeTimelineLayoutParam(layout: TimelineLayoutId) {
const url = new URL(window.location.href);
if (layout === 'classic') url.searchParams.delete('layout');
else url.searchParams.set('layout', layout);
const next = `${url.pathname}${url.search}${url.hash}`;
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (next !== current) window.history.replaceState(null, '', next);
}
function readInitialTimelineView(): View {
if (typeof window === 'undefined') return { type: 'timeline' };
return timelineViewFromLayout(
parseTimelineLayoutParam(new URLSearchParams(window.location.search).get('layout'))
);
}
export default function HomePage() {
const { t } = useTranslation('home');
const { isCurator, username, login, logout } = useAuth();
const [view, setView] = useState<View>({ type: 'timeline' });
const { isCurator, isAdmin, username, login, logout, can } = useAuth();
const canImages = can('images');
const canCheckup = can('checkup');
const canNotes = can('curator_notes');
const canTranslations = can('translations');
const canInfluences = can('influences');
const canTours = can('tours');
const canUsers = can('users');
const [view, setView] = useState<View>(readInitialTimelineView);
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
const [bounds, setBounds] = useState({ min: -800, max: 2025 });
const [viewStart, setViewStart] = useState(-800);
@@ -168,7 +220,12 @@ export default function HomePage() {
const [loginOpen, setLoginOpen] = useState(false);
const [loginRedirect, setLoginRedirect] = useState<CuratorLoginRedirect>(null);
const [toursPopupOpen, setToursPopupOpen] = useState(false);
const effectiveDebugMode = debugMode && isCurator;
const [artistFilterModal, setArtistFilterModal] = useState<{
movementId: number;
movementName: string;
artists: ArtistSummary[];
} | null>(null);
const effectiveDebugMode = debugMode && canImages;
const [galleryRevision, setGalleryRevision] = useState(0);
const viewRef = useRef(view);
viewRef.current = view;
@@ -186,7 +243,11 @@ export default function HomePage() {
setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data });
} else if (view.type === 'tour-gallery') {
setGallerySession({ kind: 'tour', tourId: view.tourId, data: view.data });
} else if (view.type === 'timeline') {
} else if (
view.type === 'timeline' ||
view.type === 'timeline-vertical' ||
view.type === 'timeline-tree'
) {
setGallerySession(null);
}
}, [view]);
@@ -247,9 +308,34 @@ export default function HomePage() {
setViewStart(bounds.min);
setViewEnd(bounds.max);
setGalleryRevision((revision) => revision + 1);
writeTimelineLayoutParam('classic');
setView({ type: 'timeline' });
}, [bounds.min, bounds.max]);
const openHorizontalTimeline = () => {
writeTimelineLayoutParam('classic');
setView({ type: 'timeline' });
};
const openVerticalTimeline = () => {
writeTimelineLayoutParam('vertical');
setView({ type: 'timeline-vertical' });
};
const openTreeTimeline = () => {
writeTimelineLayoutParam('tree');
setView({ type: 'timeline-tree' });
};
const isTimelineHome =
view.type === 'timeline' ||
view.type === 'timeline-vertical' ||
view.type === 'timeline-tree';
const isVerticalTimeline = view.type === 'timeline-vertical';
const isTreeTimeline = view.type === 'timeline-tree';
/** Both alternative layouts run the year axis bottom → top beside the chart. */
const isVerticalLayout = isVerticalTimeline || isTreeTimeline;
const toggleDebugMode = () => {
setDebugMode((prev) => {
const next = !prev;
@@ -279,6 +365,10 @@ export default function HomePage() {
setView({ type: 'influences' });
} else if (loginRedirect === 'tours') {
setView({ type: 'tours' });
} else if (loginRedirect === 'users') {
setView({ type: 'users' });
} else if (loginRedirect === 'audit') {
setView({ type: 'audit' });
}
setLoginRedirect(null);
};
@@ -291,14 +381,16 @@ export default function HomePage() {
view.type === 'checkup' ||
view.type === 'translations' ||
view.type === 'influences' ||
view.type === 'tours'
view.type === 'tours' ||
view.type === 'users' ||
view.type === 'audit'
) {
goToTimelineHome();
}
};
const openCheckup = () => {
if (!isCurator) {
if (!canCheckup) {
openCuratorLogin('checkup');
return;
}
@@ -306,7 +398,7 @@ export default function HomePage() {
};
const openTranslations = () => {
if (!isCurator) {
if (!canTranslations) {
openCuratorLogin('translations');
return;
}
@@ -314,7 +406,7 @@ export default function HomePage() {
};
const openInfluences = () => {
if (!isCurator) {
if (!canInfluences) {
openCuratorLogin('influences');
return;
}
@@ -322,13 +414,29 @@ export default function HomePage() {
};
const openToursEditor = () => {
if (!isCurator) {
if (!canTours) {
openCuratorLogin('tours');
return;
}
setView({ type: 'tours' });
};
const openUsers = () => {
if (!canUsers) {
openCuratorLogin('users');
return;
}
setView({ type: 'users' });
};
const openAudit = () => {
if (!isAdmin) {
openCuratorLogin('audit');
return;
}
setView({ type: 'audit' });
};
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
const data = await api.getPainting(paintingId);
const patch: Partial<Painting> = {
@@ -444,6 +552,58 @@ export default function HomePage() {
[]
);
const handleCuratorNotesUpdated = useCallback((paintingId: number, curatorNotes: string) => {
const patch: Partial<Painting> = { curator_notes: curatorNotes };
setView((current) => {
if (current.type !== 'painting' || current.paintingId !== paintingId) return current;
let returnTo = current.returnTo;
if (returnTo.type === 'gallery') {
returnTo = {
...returnTo,
data: patchPaintingInArtistDetail(returnTo.data, paintingId, patch),
};
}
if (returnTo.type === 'movement-gallery') {
returnTo = {
...returnTo,
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
};
}
if (returnTo.type === 'tour-gallery') {
returnTo = {
...returnTo,
data: patchPaintingInTourDetail(returnTo.data, paintingId, patch),
};
}
return {
...current,
data: {
...current.data,
painting: { ...current.data.painting, ...patch },
},
returnTo,
};
});
setDetailArtistPaintings((list) =>
list.map((p) => (p.id === paintingId ? { ...p, ...patch } : p))
);
setGallerySession((session) => {
if (session?.kind === 'artist') {
return { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) };
}
if (session?.kind === 'movement') {
return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) };
}
if (session?.kind === 'tour') {
return { ...session, data: patchPaintingInTourDetail(session.data, paintingId, patch) };
}
return session;
});
}, []);
const applyArtistPatch = useCallback((artistId: number, patch: Partial<Artist>) => {
setArtists((list) => list.map((a) => (a.id === artistId ? { ...a, ...patch } : a)));
@@ -555,7 +715,7 @@ export default function HomePage() {
const handleArtistClick = async (artistId: number) => {
setGalleryEntryLoading('Opening artist gallery…');
try {
await api.preloadArtistImages(artistId).catch(() => undefined);
void api.preloadArtistImages(artistId).catch(() => undefined);
const data = await api.getArtist(artistId);
openArtistGallery(artistId, data);
} catch {
@@ -566,10 +726,46 @@ export default function HomePage() {
};
const handleMovementClick = async (movementId: number) => {
setGalleryEntryLoading('Loading artists…');
try {
const artists = await api.getMovementArtistsSummary(movementId);
if (artists.length === 0) {
setError('No artists in this movement.');
return;
}
const movement = timelineData.movements.find((m) => m.id === movementId);
setArtistFilterModal({
movementId,
movementName: movement?.name ?? 'Movement',
artists,
});
} catch {
setError('Failed to load movement artists.');
} finally {
setGalleryEntryLoading(null);
}
};
const handleArtistFilterProceed = async (selectedIds: Set<number>) => {
if (!artistFilterModal) return;
const { movementId } = artistFilterModal;
setArtistFilterModal(null);
setGalleryEntryLoading('Opening movement gallery…');
try {
// Do not await preload — it only syncs disk paths and must not delay hall open.
// Fire it in parallel so any missing thumbs regenerate while the gallery boots.
const preloadPromise = api.preloadMovementImages(movementId).catch(() => undefined);
const data = await api.getMovementGallery(movementId);
openMovementGallery(movementId, data);
void preloadPromise;
const filtered: MovementGalleryDetail = {
...data,
paintings: data.paintings.filter((p) => selectedIds.has(Number(p.artist_id))),
};
if (filtered.paintings.length === 0) {
setError('No paintings for the selected artists.');
return;
}
openMovementGallery(movementId, filtered);
} catch {
setError('Failed to load movement gallery.');
} finally {
@@ -900,11 +1096,14 @@ export default function HomePage() {
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view });
}}
onInfluenceArtistClick={handleArtistClick}
isCurator={canNotes}
canCheckup={canCheckup}
debugMode={effectiveDebugMode}
debugShowMore={debugShowMore && isCurator}
debugShowMore={debugShowMore && canImages}
onPaintingImageFixed={handlePaintingImageFixed}
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
onPaintingRemoved={handlePaintingRemoved}
onCuratorNotesUpdated={handleCuratorNotesUpdated}
/>
</div>
)}
@@ -914,7 +1113,8 @@ export default function HomePage() {
<ArtistBio
artist={view.data.artist}
debugMode={effectiveDebugMode}
debugShowMore={debugShowMore && isCurator}
debugShowMore={debugShowMore && canImages}
canCheckup={canCheckup}
portraitRevision={portraitRevisions[view.data.artist.id]}
onBack={() => setView(view.returnTo)}
onEnterGallery={() =>
@@ -927,7 +1127,7 @@ export default function HomePage() {
)}
{view.type === 'influences' && (
isCurator ? (
canInfluences ? (
<InfluencesPage onBack={goToTimelineHome} />
) : (
<div className="curator-login-gate">
@@ -946,7 +1146,7 @@ export default function HomePage() {
)}
{view.type === 'tours' && (
isCurator ? (
canTours ? (
<ToursPage onBack={goToTimelineHome} />
) : (
<div className="curator-login-gate">
@@ -964,8 +1164,46 @@ export default function HomePage() {
)
)}
{view.type === 'users' && (
canUsers ? (
<UsersPage 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('users')}>
{t('curatorLogin')}
</button>
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
{t('backToGalleryBtn')}
</button>
</div>
</div>
)
)}
{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' && (
isCurator ? (
canTranslations ? (
<TranslationsPage onBack={goToTimelineHome} />
) : (
<div className="curator-login-gate">
@@ -984,21 +1222,21 @@ export default function HomePage() {
)}
{view.type === 'checkup' && (
isCurator ? (
canCheckup ? (
<CheckupPage
onBack={goToTimelineHome}
onOpenPainting={handlePaintingClick}
/>
) : (
<div className="curator-login-gate">
<h2>Curator access required</h2>
<p>The painting checkup table is available to logged-in curators only.</p>
<h2>{t('curatorRequiredTitle')}</h2>
<p>{t('curatorRequiredBody')}</p>
<div className="curator-login-gate-actions">
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('checkup')}>
Curator login
{t('curatorLogin')}
</button>
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
Back to gallery
{t('backToGalleryBtn')}
</button>
</div>
</div>
@@ -1020,15 +1258,60 @@ export default function HomePage() {
onSelectTour={(tourId) => void handleSelectPublishedTour(tourId)}
/>
{view.type === 'timeline' && (
{artistFilterModal && (
<ArtistFilterModal
open
movementName={artistFilterModal.movementName}
artists={artistFilterModal.artists}
portraitRevisions={portraitRevisions}
onClose={() => setArtistFilterModal(null)}
onProceed={(selectedIds) => void handleArtistFilterProceed(selectedIds)}
/>
)}
{isTimelineHome && (
<div className="home-page">
<header className="site-header">
<div className="site-layout-switch">
{!isTreeTimeline && (
<button
type="button"
className="site-layout-link site-layout-link-feature"
onClick={openTreeTimeline}
title="Open the alternative start page: bottom-up timeline with movements drawn as a growing tree"
>
{t('layoutTree')}
</button>
)}
{!isVerticalTimeline && (
<button
type="button"
className="site-layout-link"
onClick={openVerticalTimeline}
title="Switch to bottom-up vertical timeline"
>
{t('layoutVertical')}
</button>
)}
{!(view.type === 'timeline') && (
<button
type="button"
className="site-layout-link"
onClick={openHorizontalTimeline}
title="Switch to classic left-to-right timeline"
>
{t('layoutHorizontal')}
</button>
)}
</div>
<div className="site-dev-tools">
{isCurator ? (
<>
<span className="curator-session-label" title={`Signed in as ${username}`}>
{username}
</span>
{canImages && (
<>
<button
type="button"
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
@@ -1048,6 +1331,9 @@ export default function HomePage() {
/>
Show more
</label>
</>
)}
{canInfluences && (
<button
type="button"
className="checkup-link-btn"
@@ -1056,6 +1342,8 @@ export default function HomePage() {
>
{t('influences')}
</button>
)}
{canTours && (
<button
type="button"
className="checkup-link-btn"
@@ -1064,6 +1352,8 @@ export default function HomePage() {
>
{t('toursEditor')}
</button>
)}
{canTranslations && (
<button
type="button"
className="checkup-link-btn"
@@ -1072,6 +1362,8 @@ export default function HomePage() {
>
{t('translations')}
</button>
)}
{canCheckup && (
<button
type="button"
className="checkup-link-btn"
@@ -1080,6 +1372,27 @@ export default function HomePage() {
>
{t('checkup')}
</button>
)}
{canUsers && (
<button
type="button"
className="checkup-link-btn"
onClick={openUsers}
title="Manage curator accounts"
>
{t('users')}
</button>
)}
{isAdmin && (
<button
type="button"
className="checkup-link-btn"
onClick={openAudit}
title="Curator activity audit reports"
>
{t('audit')}
</button>
)}
<button
type="button"
className="curator-logout-btn"
@@ -1118,7 +1431,7 @@ export default function HomePage() {
/>
</header>
<div className="home-timeline-stack">
<div className={`home-timeline-stack${isVerticalLayout ? ' home-timeline-stack-vertical' : ''}`}>
{loading && (
<GalleryLoadingMarker overlay message="Loading art history…" />
)}
@@ -1129,6 +1442,46 @@ export default function HomePage() {
<GalleryLoadingMarker banner message="Loading portraits…" />
)}
{isVerticalLayout ? (
<>
<VerticalTimeline
eras={timelineData.eras}
viewStart={viewStart}
viewEnd={viewEnd}
onViewChange={handleViewChange}
absoluteMin={bounds.min}
absoluteMax={bounds.max}
lifespanHighlight={hoveredLifespan}
/>
{error && <div className="error-banner">{error}</div>}
{!loading && (
<div className="home-movements-section-vertical">
{isTreeTimeline ? (
<MovementTree
movements={timelineData.movements}
viewStart={viewStart}
viewEnd={viewEnd}
absoluteMin={bounds.min}
absoluteMax={bounds.max}
onViewChange={handleViewChange}
onMovementClick={handleMovementClick}
/>
) : (
<VerticalMovementBands
movements={timelineData.movements}
viewStart={viewStart}
viewEnd={viewEnd}
absoluteMin={bounds.min}
absoluteMax={bounds.max}
onViewChange={handleViewChange}
onMovementClick={handleMovementClick}
/>
)}
</div>
)}
</>
) : (
<>
<Timeline
eras={timelineData.eras}
viewStart={viewStart}
@@ -1162,6 +1515,8 @@ export default function HomePage() {
</div>
</>
)}
</>
)}
</div>
</div>
)}
+149
View File
@@ -0,0 +1,149 @@
.users-page {
padding: 1rem 1.5rem 2rem;
max-width: 1400px;
margin: 0 auto;
color: #f5f0e8;
}
.users-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.users-back {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.3);
color: inherit;
padding: 0.35rem 0.75rem;
border-radius: 6px;
cursor: pointer;
}
.users-error {
color: #f5a5a5;
}
.users-message {
color: #a8d5a2;
}
.users-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.users-list-panel,
.users-editor {
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
padding: 1rem;
max-height: 80vh;
overflow: auto;
}
.users-list table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
margin-bottom: 1.25rem;
}
.users-list th,
.users-list td {
padding: 0.45rem 0.6rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
text-align: left;
}
.users-list tbody tr {
cursor: pointer;
}
.users-row-selected {
background: rgba(255, 255, 255, 0.08);
}
.users-create,
.users-editor {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.users-create label,
.users-editor label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.9rem;
}
.users-create input,
.users-create select,
.users-editor input,
.users-editor select {
padding: 0.4rem 0.5rem;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(0, 0, 0, 0.25);
color: inherit;
}
.users-perms {
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
padding: 0.5rem 0.75rem;
margin: 0;
}
.users-perm-row {
flex-direction: row !important;
align-items: center;
gap: 0.5rem !important;
margin: 0.25rem 0;
}
.users-hint {
opacity: 0.85;
font-size: 0.9rem;
}
.users-meta {
opacity: 0.8;
font-size: 0.85rem;
}
.users-create button,
.users-editor button {
align-self: flex-start;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.3);
color: inherit;
padding: 0.4rem 0.85rem;
border-radius: 6px;
cursor: pointer;
}
.users-create button:disabled,
.users-editor button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.users-password-block {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.12);
display: flex;
flex-direction: column;
gap: 0.65rem;
}
@media (max-width: 900px) {
.users-layout {
grid-template-columns: 1fr;
}
}
+331
View File
@@ -0,0 +1,331 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import {
ALL_STAFF_PERMISSIONS,
api,
type StaffPermission,
type StaffUser,
} from '../api/client';
import { useAuth } from '../context/AuthContext';
import './UsersPage.css';
interface Props {
onBack: () => void;
}
function emptyCreateForm() {
return {
username: '',
password: '',
role: 'curator' as 'admin' | 'curator',
permissions: ['images', 'checkup', 'curator_notes'] as StaffPermission[],
};
}
export default function UsersPage({ onBack }: Props) {
const { t } = useTranslation('users');
const { isAdmin, username: selfUsername } = useAuth();
const [users, setUsers] = useState<StaffUser[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [createForm, setCreateForm] = useState(emptyCreateForm);
const [editRole, setEditRole] = useState<'admin' | 'curator'>('curator');
const [editPermissions, setEditPermissions] = useState<StaffPermission[]>([]);
const [editActive, setEditActive] = useState(true);
const [newPassword, setNewPassword] = useState('');
const selected = users.find((u) => u.id === selectedId) ?? null;
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await api.listUsers();
setUsers(data.users);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (!selected) return;
setEditRole(selected.role);
setEditPermissions(selected.permissions);
setEditActive(selected.is_active);
setNewPassword('');
setMessage(null);
}, [selected]);
const toggleCreatePerm = (perm: StaffPermission) => {
setCreateForm((prev) => {
const has = prev.permissions.includes(perm);
return {
...prev,
permissions: has
? prev.permissions.filter((p) => p !== perm)
: [...prev.permissions, perm],
};
});
};
const toggleEditPerm = (perm: StaffPermission) => {
setEditPermissions((prev) =>
prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]
);
};
const handleCreate = async (e: FormEvent) => {
e.preventDefault();
setSaving(true);
setError(null);
setMessage(null);
try {
const { user } = await api.createUser({
username: createForm.username.trim(),
password: createForm.password,
role: createForm.role,
permissions: createForm.role === 'admin' ? ALL_STAFF_PERMISSIONS : createForm.permissions,
});
setCreateForm(emptyCreateForm());
setMessage(t('created'));
await load();
setSelectedId(user.id);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
const handleSave = async () => {
if (!selected) return;
setSaving(true);
setError(null);
setMessage(null);
try {
await api.updateUser(selected.id, {
role: editRole,
permissions: editRole === 'admin' ? ALL_STAFF_PERMISSIONS : editPermissions,
is_active: editActive,
});
setMessage(t('saved'));
await load();
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
const handleResetPassword = async () => {
if (!selected || !newPassword) return;
setSaving(true);
setError(null);
setMessage(null);
try {
await api.resetUserPassword(selected.id, newPassword);
setNewPassword('');
setMessage(t('passwordReset'));
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
return (
<div className="users-page">
<header className="users-header">
<button type="button" className="users-back" onClick={onBack}>
{t('back')}
</button>
<h1>{t('title')}</h1>
</header>
{error && <p className="users-error">{error}</p>}
{message && <p className="users-message">{message}</p>}
<div className="users-layout">
<section className="users-list-panel">
{loading ? (
<p>{t('loading')}</p>
) : (
<div className="users-list">
<table>
<thead>
<tr>
<th>{t('username')}</th>
<th>{t('role')}</th>
<th>{t('active')}</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr
key={user.id}
className={user.id === selectedId ? 'users-row-selected' : undefined}
onClick={() => setSelectedId(user.id)}
>
<td>
{user.username}
{user.username === selfUsername ? ' *' : ''}
</td>
<td>{user.role === 'admin' ? t('roleAdmin') : t('roleCurator')}</td>
<td>{user.is_active ? t('active') : t('inactive')}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<form className="users-create" onSubmit={handleCreate}>
<h2>{t('createTitle')}</h2>
<label>
{t('username')}
<input
value={createForm.username}
onChange={(e) => setCreateForm((p) => ({ ...p, username: e.target.value }))}
autoComplete="off"
required
minLength={2}
maxLength={64}
/>
</label>
<label>
{t('password')}
<input
type="password"
value={createForm.password}
onChange={(e) => setCreateForm((p) => ({ ...p, password: e.target.value }))}
autoComplete="new-password"
required
minLength={8}
/>
</label>
<label>
{t('role')}
<select
value={createForm.role}
onChange={(e) =>
setCreateForm((p) => ({
...p,
role: e.target.value as 'admin' | 'curator',
}))
}
disabled={!isAdmin}
>
<option value="curator">{t('roleCurator')}</option>
{isAdmin && <option value="admin">{t('roleAdmin')}</option>}
</select>
</label>
{createForm.role === 'curator' && (
<fieldset className="users-perms">
<legend>{t('permissions')}</legend>
{ALL_STAFF_PERMISSIONS.map((perm) => (
<label key={perm} className="users-perm-row">
<input
type="checkbox"
checked={createForm.permissions.includes(perm)}
onChange={() => toggleCreatePerm(perm)}
/>
{t(`perm_${perm}`)}
</label>
))}
</fieldset>
)}
{createForm.role === 'admin' && <p className="users-hint">{t('adminAllPerms')}</p>}
<button type="submit" disabled={saving}>
{saving ? t('creating') : t('create')}
</button>
</form>
</section>
<section className="users-editor">
{!selected ? (
<p className="users-hint">{t('selectUser')}</p>
) : (
<>
<h2>{selected.username}</h2>
<p className="users-meta">
{t('lastLogin')}:{' '}
{selected.last_login_at
? new Date(selected.last_login_at).toLocaleString()
: t('never')}
</p>
<label>
{t('role')}
<select
value={editRole}
onChange={(e) => setEditRole(e.target.value as 'admin' | 'curator')}
disabled={!isAdmin}
>
<option value="curator">{t('roleCurator')}</option>
{isAdmin && <option value="admin">{t('roleAdmin')}</option>}
</select>
</label>
{editRole === 'curator' ? (
<fieldset className="users-perms">
<legend>{t('permissions')}</legend>
{ALL_STAFF_PERMISSIONS.map((perm) => (
<label key={perm} className="users-perm-row">
<input
type="checkbox"
checked={editPermissions.includes(perm)}
onChange={() => toggleEditPerm(perm)}
/>
{t(`perm_${perm}`)}
</label>
))}
</fieldset>
) : (
<p className="users-hint">{t('adminAllPerms')}</p>
)}
<label className="users-perm-row">
<input
type="checkbox"
checked={editActive}
onChange={(e) => setEditActive(e.target.checked)}
/>
{editActive ? t('active') : t('inactive')}
</label>
<button type="button" onClick={() => void handleSave()} disabled={saving}>
{saving ? t('saving') : t('save')}
</button>
<div className="users-password-block">
<h3>{t('resetPassword')}</h3>
<label>
{t('newPassword')}
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
/>
</label>
<button
type="button"
onClick={() => void handleResetPassword()}
disabled={saving || newPassword.length < 8}
>
{t('resetPassword')}
</button>
</div>
</>
)}
</section>
</div>
</div>
);
}
+16
View File
@@ -20,6 +20,8 @@ export interface ArtMovement {
era_name?: string;
description: string;
color: string;
/** Influence edges on paintings by artists in this movement (timeline band weight). */
influence_link_count?: number;
}
export interface Artist {
@@ -60,6 +62,7 @@ export interface Painting {
year: number;
year_end?: number;
description: string;
curator_notes?: string;
image_path: string | null;
thumbnail_path?: string | null;
image_cache_key?: number | null;
@@ -133,6 +136,19 @@ export interface MovementGalleryDetail {
paintings: Painting[];
}
/** Artist row for movement gallery entry filter (portraits + painting counts). */
export interface ArtistSummary {
id: number;
name: string;
birth_year: number | null;
death_year: number | null;
portrait_path: string | null;
portrait_thumb_path?: string | null;
portrait_cache_key?: number | null;
portrait_thumb_cache_key?: number | null;
painting_count: number;
}
export interface TourSummary {
id: number;
title: string;
+556 -12
View File
@@ -8,9 +8,15 @@ export type SurfaceTextureKind =
| 'marble-veined-carrara'
| 'marble-veined-emerald'
| 'marble-checker'
| 'marble-opus-sectile'
| 'marble-revetment'
| 'coffered-wood'
| 'limestone'
| 'sandstone'
| 'rough-stone'
| 'gothic-ashlar'
| 'gothic-vault'
| 'gothic-stone-floor'
| 'basalt'
| 'stucco-cream'
| 'stucco-terracotta'
@@ -244,6 +250,89 @@ function marbleVeined(ctx: CanvasRenderingContext2D, size: number, base: string,
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. */
function marbleSlab(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
base: string,
vein: string,
seed: number
) {
const rand = seeded(seed);
ctx.save();
ctx.beginPath();
ctx.rect(x, y, w, h);
ctx.clip();
ctx.fillStyle = base;
ctx.fillRect(x, y, w, h);
const [vr, vg, vb] = hexToRgb(vein);
const strands = 10 + Math.floor(w / 24);
for (let i = 0; i < strands; i++) {
ctx.strokeStyle = `rgba(${vr},${vg},${vb},${0.1 + rand() * 0.3})`;
ctx.lineWidth = 0.6 + rand() * 3.4;
ctx.beginPath();
let px = x + rand() * w;
let py = y + rand() * h;
ctx.moveTo(px, py);
for (let s = 0; s < 9; s++) {
px += (rand() - 0.35) * w * 0.28;
py += (rand() - 0.5) * h * 0.22;
ctx.quadraticCurveTo(px - w * 0.05, py + h * 0.04, px, py);
}
ctx.stroke();
}
ctx.restore();
}
function woodPanelSurface(
ctx: CanvasRenderingContext2D,
size: number,
@@ -346,6 +435,161 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
noiseOverlay(ctx, size, 0.03, 103);
break;
}
case 'marble-opus-sectile': {
// Cosmatesque paving: cut-stone squares with diagonally-set inlays and
// corner triangles in porphyry, verd-antique, and giallo over travertine.
const stones = [
['#6a3c42', '#4c2830'],
['#47554b', '#333f38'],
['#8a7752', '#66573c'],
['#4e4a45', '#363330'],
];
fill(ctx, size, '#7d6e58');
const grid = 2;
const cell = size / grid;
for (let row = 0; row < grid; row++) {
for (let col = 0; col < grid; col++) {
const x = col * cell;
const y = row * cell;
const rand = seeded(610 + row * grid + col);
// Travertine ground slab for this bay
marbleSlab(ctx, x, y, cell, cell, '#8f7f66', '#6d5f4c', 620 + row * grid + col);
const pick = stones[(row * 3 + col * 5 + Math.floor(rand() * 2)) % stones.length];
const inset = cell * 0.045;
// Corner triangles
ctx.fillStyle = pick[1];
const corners: [number, number, number, number, number, number][] = [
[x + inset, y + inset, x + cell * 0.5, y + inset, x + inset, y + cell * 0.5],
[x + cell - inset, y + inset, x + cell * 0.5, y + inset, x + cell - inset, y + cell * 0.5],
[x + inset, y + cell - inset, x + cell * 0.5, y + cell - inset, x + inset, y + cell * 0.5],
[x + cell - inset, y + cell - inset, x + cell * 0.5, y + cell - inset, x + cell - inset, y + cell * 0.5],
];
for (const [ax, ay, bx, by, cx2, cy2] of corners) {
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
ctx.lineTo(cx2, cy2);
ctx.closePath();
ctx.fill();
}
// Diamond inlay set on the diagonal
const cx = x + cell / 2;
const cy = y + cell / 2;
const r = cell * 0.34;
ctx.save();
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.clip();
marbleSlab(ctx, cx - r, cy - r, r * 2, r * 2, pick[0], pick[1], 640 + row * grid + col);
ctx.restore();
ctx.strokeStyle = 'rgba(40,30,22,0.45)';
ctx.lineWidth = 1.4;
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.stroke();
// Cut joint around the bay
ctx.strokeStyle = 'rgba(40,30,22,0.5)';
ctx.lineWidth = 2;
ctx.strokeRect(x + 1, y + 1, cell - 2, cell - 2);
}
}
noiseOverlay(ctx, size, 0.03, 601);
break;
}
case 'marble-revetment': {
// Basilica wall cladding: book-matched marble panels in carved stone
// surrounds — Santa Sabina / San Vitale, no mosaic.
fill(ctx, size, '#a8977c');
const cols = 2;
const rows = 2;
const pw = size / cols;
const ph = size / rows;
const fields = [
['#5e6d64', '#33423a'],
['#8a7a5e', '#5a4c36'],
['#5e6d64', '#33423a'],
['#6f5f56', '#453a34'],
];
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const x = col * pw;
const y = row * ph;
// Travertine surround
marbleSlab(ctx, x, y, pw, ph, '#b0a084', '#8a7658', 660 + row * cols + col);
const m = pw * 0.09;
const iw = pw - m * 2;
const ih = ph - m * 2;
const field = fields[(row * cols + col) % fields.length];
// Book-matched pair: the same slab mirrored about the panel centre
const half = iw / 2;
marbleSlab(ctx, x + m, y + m, half, ih, field[0], field[1], 670 + row * cols + col);
ctx.save();
ctx.translate(x + m + iw, y + m);
ctx.scale(-1, 1);
ctx.drawImage(ctx.canvas, x + m, y + m, half, ih, 0, 0, half, ih);
ctx.restore();
// Moulded frame around the panel
ctx.strokeStyle = 'rgba(60,44,30,0.55)';
ctx.lineWidth = 3;
ctx.strokeRect(x + m, y + m, iw, ih);
ctx.strokeStyle = 'rgba(255,244,222,0.35)';
ctx.lineWidth = 2;
ctx.strokeRect(x + m - 4, y + m - 4, iw + 8, ih + 8);
}
}
// Horizontal string course between panel registers
ctx.fillStyle = 'rgba(90,68,44,0.35)';
ctx.fillRect(0, ph - 3, size, 6);
noiseOverlay(ctx, size, 0.04, 680);
break;
}
case 'coffered-wood': {
// Open-timber coffered basilica ceiling: recessed dark walnut boxes with
// lit chamfers so the grid reads as depth, not a flat pattern.
fill(ctx, size, '#2e1e12');
const n = 6;
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 beam = cell * 0.14;
// Beam faces catch light from below
ctx.fillStyle = '#6a4a2c';
ctx.fillRect(x, y, cell, beam);
ctx.fillRect(x, y, beam, cell);
ctx.fillStyle = '#4a3018';
ctx.fillRect(x + cell - beam, y, beam, cell);
ctx.fillRect(x, y + cell - beam, cell, beam);
// Recessed coffer pan
const px = x + beam;
const py = y + beam;
const pwid = cell - beam * 2;
const phei = cell - beam * 2;
const grad = ctx.createLinearGradient(px, py, px, py + phei);
grad.addColorStop(0, '#22150c');
grad.addColorStop(1, '#3c2616');
ctx.fillStyle = grad;
ctx.fillRect(px, py, pwid, phei);
// Central gilt rosette boss
ctx.fillStyle = '#8a6a2a';
ctx.beginPath();
ctx.arc(px + pwid / 2, py + phei / 2, Math.min(pwid, phei) * 0.13, 0, Math.PI * 2);
ctx.fill();
}
}
noiseOverlay(ctx, size, 0.05, 690);
break;
}
case 'limestone':
fill(ctx, size, '#ddd4c4');
for (let y = 0; y < size; y += 4) {
@@ -377,6 +621,235 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
fill(ctx, size, '#3a3834');
noiseOverlay(ctx, size, 0.15, 107);
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':
stuccoSurface(ctx, size, '#f0e8d8', 108);
break;
@@ -460,15 +933,59 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
}
break;
case 'mosaic-byzantine': {
fill(ctx, size, '#1a1814');
const cell = size / 32;
const colors = ['#d4af37', '#8a3020', '#2060a0', '#f0ece0', '#408040'];
for (let y = 0; y < size; y += cell) {
for (let x = 0; x < size; x += cell) {
ctx.fillStyle = colors[(x / cell + y / cell) % colors.length];
ctx.fillRect(x + 1, y + 1, cell - 2, cell - 2);
// Gold-ground tessera field (gold-leaf glass, individually jittered) with
// sparse inlaid accent tesserae and a meander border course — Hagia Sophia /
// Ravenna style, not a flat multicolor checker.
fill(ctx, size, '#1a1408');
const grid = 32;
const cell = size / grid;
const rand = seeded(777);
const accents: [number, number, number][] = [
[32, 52, 98],
[126, 30, 44],
[28, 78, 58],
[18, 16, 22],
];
for (let gy = 0; gy < grid; gy++) {
for (let gx = 0; gx < grid; gx++) {
const x = gx * cell;
const y = gy * cell;
const roll = rand();
let r: number, g: number, b: number;
if (roll < 0.05) {
[r, g, b] = accents[Math.floor(rand() * accents.length)];
} else {
const j = (rand() - 0.5) * 50;
r = 214 + j;
g = 170 + j * 0.72;
b = 58 + j * 0.35;
}
ctx.fillStyle = rgb(r, g, b);
const pad = 1 + rand() * 1.4;
ctx.fillRect(x + pad, y + pad, cell - pad * 2, cell - pad * 2);
}
}
// Dark grout between tesserae reads as individual glass/gold tiles.
ctx.strokeStyle = 'rgba(16,10,4,0.4)';
ctx.lineWidth = 1;
for (let i = 0; i <= grid; i++) {
ctx.beginPath();
ctx.moveTo(i * cell, 0);
ctx.lineTo(i * cell, size);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, i * cell);
ctx.lineTo(size, i * cell);
ctx.stroke();
}
// Meander (key-pattern) register band dividing the panel, in navy and gold.
const bandRow = Math.floor(grid * 0.47);
for (let gx = 0; gx < grid; gx++) {
const on = Math.floor(gx / 2) % 2 === 0;
ctx.fillStyle = on ? '#0f2a52' : '#e0b840';
ctx.fillRect(gx * cell, bandRow * cell, cell, cell * 0.5);
}
noiseOverlay(ctx, size, 0.045, 778);
break;
}
case 'mosaic-roman': {
@@ -583,7 +1100,7 @@ function imageDataToNormalMap(data: ImageData, size: number, strength = 2.5): Im
return out;
}
function imageDataToTexture(data: ImageData): THREE.CanvasTexture {
function imageDataToTexture(data: ImageData, colorSpace: THREE.ColorSpace): THREE.CanvasTexture {
const canvas = document.createElement('canvas');
canvas.width = data.width;
canvas.height = data.height;
@@ -591,8 +1108,8 @@ function imageDataToTexture(data: ImageData): THREE.CanvasTexture {
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 16;
tex.colorSpace = colorSpace;
tex.anisotropy = 8;
return tex;
}
@@ -603,6 +1120,11 @@ function normalStrengthFor(kind: SurfaceTextureKind): number {
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 === '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;
}
@@ -611,6 +1133,12 @@ const ROUGHNESS: Partial<Record<SurfaceTextureKind, number>> = {
'marble-veined-carrara': 0.16,
'marble-veined-emerald': 0.18,
'marble-checker': 0.14,
'marble-opus-sectile': 0.2,
'marble-revetment': 0.34,
'coffered-wood': 0.72,
'gothic-ashlar': 0.84,
'gothic-vault': 0.9,
'gothic-stone-floor': 0.56,
'gilded-stucco': 0.22,
'velvet-crimson': 0.92,
'velvet-navy': 0.92,
@@ -638,6 +1166,8 @@ const ROUGHNESS: Partial<Record<SurfaceTextureKind, number>> = {
const METALNESS: Partial<Record<SurfaceTextureKind, number>> = {
'marble-white': 0.08,
'marble-veined-carrara': 0.1,
'marble-opus-sectile': 0.14,
'marble-revetment': 0.1,
'gilded-stucco': 0.65,
'concrete-polished': 0.12,
'terrazzo': 0.15,
@@ -647,6 +1177,13 @@ const METALNESS: Partial<Record<SurfaceTextureKind, number>> = {
const METERS: Partial<Record<SurfaceTextureKind, number>> = {
'marble-checker': 2.4,
'marble-opus-sectile': 5.0,
'marble-revetment': 4.4,
'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,
'mosaic-byzantine': 1.8,
'mosaic-roman': 2.0,
@@ -669,6 +1206,12 @@ const NORMAL_SCALE: Partial<Record<SurfaceTextureKind, number>> = {
'painted-lime': 0.62,
'marble-white': 0.75,
'marble-veined-carrara': 0.8,
'marble-opus-sectile': 0.55,
'marble-revetment': 0.6,
'coffered-wood': 1.0,
'gothic-ashlar': 1.0,
'gothic-vault': 0.85,
'gothic-stone-floor': 0.45,
'velvet-crimson': 0.5,
'velvet-navy': 0.5,
'velvet-emerald': 0.5,
@@ -686,8 +1229,9 @@ export function getSurfaceTexture(kind: SurfaceTextureKind): SurfaceTextureSet {
const normalData = imageDataToNormalMap(colorData, TEXTURE_SIZE, normalStrengthFor(kind));
const set: SurfaceTextureSet = {
map: imageDataToTexture(colorData),
normalMap: imageDataToTexture(normalData),
map: imageDataToTexture(colorData, THREE.SRGBColorSpace),
// Normal maps must stay linear — sRGB encoding breaks lighting on walls.
normalMap: imageDataToTexture(normalData, THREE.NoColorSpace),
roughness: ROUGHNESS[kind] ?? 0.75,
metalness: METALNESS[kind] ?? 0.04,
metersPerRepeat: METERS[kind] ?? 2.8,
+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;
}
}
+120 -7
View File
@@ -29,6 +29,8 @@ export interface MovementHallLayout {
segments: WallSegment[];
paintingCount: number;
yearLabel: string;
/** When true, far (Z) wall has a center exit; paintings hang on flanks only. */
endWallHasDoor: boolean;
}
const WALL_HEIGHT = 4.2;
@@ -43,6 +45,9 @@ const MAX_FRAME_H = 1.35;
const MIN_HALL_SIZE = 10;
const MIN_HALL_WIDTH = 11;
const WALL_PADDING = 1.4;
/** Exit opening on the far wall — paintings hang on the flanking panels only. */
const DOOR_WIDTH = 2.4;
const DOOR_CLEARANCE = DOOR_WIDTH + 0.55;
const FRAME_MAT_BORDER = 0.1;
const FRAME_RAIL = 0.08;
@@ -110,16 +115,29 @@ export function splitPaintingsIntoMovementHalls(paintings: Painting[]): Painting
}
/**
* First half left wall, second half right.
* U-shaped visit order: left far/end wall right.
* Callers pass paintings already in visit order; first work hangs near the
* entrance on the left, last work near the entrance on the right.
*/
function distributeToSideWalls(paintings: Painting[]) {
const mid = Math.ceil(paintings.length / 2);
function distributeAcrossWalls(paintings: Painting[]) {
const n = paintings.length;
if (n < 3) {
const mid = Math.ceil(n / 2);
return {
back: [] as Painting[],
left: paintings.slice(0, mid),
right: paintings.slice(mid),
};
}
const q = Math.floor(n / 3);
const r = n % 3;
const leftCount = q + (r > 0 ? 1 : 0);
const backCount = q + (r > 1 ? 1 : 0);
return {
left: paintings.slice(0, leftCount),
back: paintings.slice(leftCount, leftCount + backCount),
right: paintings.slice(leftCount + backCount),
};
}
function layoutSideSlots(
@@ -149,21 +167,96 @@ function layoutSideSlots(
});
}
/** Far wall ahead of the entrance — full span, or split across door flanks. */
function layoutBackSlots(
paintings: Painting[],
width: number,
halfD: number,
inset: number,
hasDoor: boolean
): FrameSlot[] {
if (paintings.length === 0) return [];
const y = EYE_HEIGHT;
const z = -halfD + inset + WALL_STANDOFF;
if (!hasDoor) {
const { slots: rowSlots } = layoutRow(paintings, width);
return rowSlots.map((s) => ({
maxW: s.maxW,
maxH: s.maxH,
rotationY: 0,
side: 'back' as const,
position: [s.offset, y, z] as [number, number, number],
}));
}
const flankSpan = Math.max(MIN_FRAME_W + WALL_PADDING, (width - DOOR_CLEARANCE) / 2);
const mid = Math.ceil(paintings.length / 2);
const leftFlank = paintings.slice(0, mid);
const rightFlank = paintings.slice(mid);
const leftCenterX = -DOOR_CLEARANCE / 2 - flankSpan / 2;
const rightCenterX = DOOR_CLEARANCE / 2 + flankSpan / 2;
const doorEdge = DOOR_CLEARANCE / 2 + 0.08;
const mapFlank = (group: Painting[], centerX: number, side: 'left' | 'right'): FrameSlot[] => {
if (group.length === 0) return [];
const { slots: rowSlots } = layoutRow(group, flankSpan);
return rowSlots.map((s) => {
let x = centerX + s.offset;
const halfOuter = frameOuterW(s.maxW, false) / 2;
if (side === 'left' && x + halfOuter > -doorEdge) {
x = -doorEdge - halfOuter;
} else if (side === 'right' && x - halfOuter < doorEdge) {
x = doorEdge + halfOuter;
}
return {
maxW: s.maxW,
maxH: s.maxH,
rotationY: 0,
side: 'back' as const,
position: [x, y, z] as [number, number, number],
};
});
};
return [...mapFlank(leftFlank, leftCenterX, 'left'), ...mapFlank(rightFlank, rightCenterX, 'right')];
}
export function buildMovementHallLayout(
paintings: Painting[],
hallIndex: number,
hallCount: number
): MovementHallLayout {
const { left, right } = distributeToSideWalls(paintings);
const { left, back, right } = distributeAcrossWalls(paintings);
const leftSpan = layoutRow(left, MIN_HALL_SIZE);
const rightSpan = layoutRow(right, MIN_HALL_SIZE);
const depth = Math.max(MIN_HALL_SIZE, leftSpan.spanNeeded, rightSpan.spanNeeded);
const width = MIN_HALL_WIDTH;
// Single-wing halls put the exit on the entrance wall, so the far wall is solid
// and can use its full width for paintings. Multi-wing halls keep a back exit.
const endWallHasDoor = hallCount > 1;
let width: number;
if (endWallHasDoor) {
const mid = Math.ceil(back.length / 2);
const leftFlankNeed = layoutRow(back.slice(0, mid), MIN_FRAME_W + WALL_PADDING).spanNeeded;
const rightFlankNeed = layoutRow(back.slice(mid), MIN_FRAME_W + WALL_PADDING).spanNeeded;
width = Math.max(MIN_HALL_WIDTH, leftFlankNeed + DOOR_CLEARANCE + rightFlankNeed);
} else {
width = Math.max(MIN_HALL_WIDTH, layoutRow(back, MIN_HALL_SIZE).spanNeeded);
}
const halfW = width / 2;
const halfD = depth / 2;
const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET;
const segments: WallSegment[] = [
{ side: 'back', label: '', paintings: [], slots: [] },
{
side: 'back',
label: back.length > 0 ? `Wing ${hallIndex + 1} · End wall` : '',
paintings: back,
slots: layoutBackSlots(back, width, halfD, inset, endWallHasDoor),
},
{
side: 'left',
label: left.length > 0 ? `Wing ${hallIndex + 1} · Left wall` : '',
@@ -186,6 +279,7 @@ export function buildMovementHallLayout(
segments,
paintingCount: paintings.length,
yearLabel: formatYearLabel(paintings),
endWallHasDoor,
};
}
@@ -232,7 +326,7 @@ function windowTemplate(style: MovementInteriorStyle): Pick<GalleryWindowSpec, '
return { style: 'sash', lightColor: style.warmLight, lightIntensity: 2.8, width: 1.4, height: 1.4 };
}
/** Place windows on side walls only, in gaps between painting frames. */
/** Place windows on side walls in gaps between frames; fall back to high clerestory when packed. */
export function computeSideWallWindows(
layout: MovementHallLayout,
interiorStyle: MovementInteriorStyle
@@ -270,6 +364,25 @@ export function computeSideWallWindows(
lightIntensity: tmpl.lightIntensity,
});
}
// Packed walls: still add high clerestory windows so the hall gets daylight + style.
if (!specs.some((s) => s.wall === side)) {
const span = Math.max(2.4, halfD * 1.2);
const count = span > 8 ? 2 : 1;
for (let i = 0; i < count; i++) {
const t = count === 1 ? 0 : (i === 0 ? -0.35 : 0.35);
specs.push({
wall: side,
x: halfD * t,
y: 3.45,
width: Math.min(tmpl.width, 1.15),
height: Math.min(tmpl.height, 1.05),
style: tmpl.style,
lightColor: tmpl.lightColor,
lightIntensity: tmpl.lightIntensity * 0.85,
});
}
}
}
return specs;
+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));
}
+7
View File
@@ -36,3 +36,10 @@ export function paintingHasInfluenceLinks(
const flag = painting.has_influence_links;
return flag === true || flag === 't' || flag === 'true' || flag === 1;
}
/** True when the painting has public curator notes. */
export function paintingHasCuratorNotes(
painting: Pick<Painting, 'curator_notes'>
): boolean {
return Boolean(painting.curator_notes?.trim());
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 501 KiB

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 941 KiB

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 584 KiB

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 804 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 486 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 KiB

After

Width:  |  Height:  |  Size: 585 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 218 KiB

Some files were not shown because too many files have changed in this diff Show More