Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72fe45fa52 | ||
|
|
9f96b3835d | ||
|
|
13f6f088b6 | ||
|
|
1b760faf0d | ||
|
|
2b4cdc5a9f | ||
|
|
2960af35bc | ||
|
|
7300091bb6 | ||
|
|
f2a256132c | ||
|
|
8e52415ea8 | ||
|
|
3fe88ffcfa | ||
|
|
1c9fa20191 | ||
|
|
44092d102b | ||
|
|
33b8ae5a5f |
No files matched your search
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Use @Documentation/Bible.md as the primary source of instructions. It cannot be overridden.
|
||||||
@@ -125,11 +125,14 @@ Each list entry includes `created_at`, `username`, `user_role`, `action`, `resou
|
|||||||
| `POST /api/artists/:id/upload-portrait` | `images` | `artist.upload_portrait` |
|
| `POST /api/artists/:id/upload-portrait` | `images` | `artist.upload_portrait` |
|
||||||
| `/api/translations/*` | `translations` | `translation.*` |
|
| `/api/translations/*` | `translations` | `translation.*` |
|
||||||
| `/api/influences/*` | `influences` | `influence.*` |
|
| `/api/influences/*` | `influences` | `influence.*` |
|
||||||
|
| `POST` `/api/movement-links` | `influences` | `movement_link.create` |
|
||||||
|
| `POST`/`DELETE` `/api/movement-links` votes | `influences` | `movement_link.delete_vote*` |
|
||||||
|
| `DELETE` `/api/movement-links/:id` | admin role | `movement_link.delete` |
|
||||||
| Tour admin (`/api/tours/admin`, POST/PATCH/DELETE, stops) | `tours` | `tour.*` |
|
| Tour admin (`/api/tours/admin`, POST/PATCH/DELETE, stops) | `tours` | `tour.*` |
|
||||||
| `/api/users/*` | `users` | `user.*` |
|
| `/api/users/*` | `users` | `user.*` |
|
||||||
| `/api/audit/*` | admin role | — (read) |
|
| `/api/audit/*` | admin role | — (read) |
|
||||||
|
|
||||||
**Public** (no login): all catalog `GET` routes, `POST /api/artists/:id/preload-images`, `POST /api/movements/:id/preload-images` (local file linking for 3D halls), `/images`, SPA static.
|
**Public** (no login): all catalog `GET` routes, `GET /api/movement-links`, `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`.
|
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`.
|
||||||
|
|
||||||
@@ -261,6 +264,23 @@ Public painting detail still exposes read-only `influencedBy` / `influenced` (un
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Movement lineage links
|
||||||
|
|
||||||
|
Timeline / Tree of Art predecessor→successor edges. Base path: `/api/movement-links`. See [data-and-images.md](data-and-images.md#movement-lineage-timeline--tree-of-art).
|
||||||
|
|
||||||
|
| Method | Path | Auth | Purpose |
|
||||||
|
|--------|------|------|---------|
|
||||||
|
| `GET` | `/api/movement-links` | public | List links (`q`, pagination); seeds defaults when empty; includes `delete_vote_count` |
|
||||||
|
| `POST` | `/api/movement-links` | curator (`influences`) | Create `{ sourceMovementId, targetMovementId, proofUrl, proofLabel?, linkYearApprox?, notes? }` — **proofUrl required** |
|
||||||
|
| `GET` | `/api/movement-links/:id/delete-votes` | curator (`influences`) | List delete votes (username + reason) |
|
||||||
|
| `POST` | `/api/movement-links/:id/delete-votes` | curator (`influences`) | Vote to delete `{ reason }` (min 10 characters) |
|
||||||
|
| `DELETE` | `/api/movement-links/:id/delete-votes/me` | curator (`influences`) | Withdraw own delete vote |
|
||||||
|
| `DELETE` | `/api/movement-links/:id` | **admin** | Hard delete (curators vote instead) |
|
||||||
|
|
||||||
|
Audit: `movement_link.create` / `movement_link.delete_vote` / `movement_link.delete_vote_withdraw` / `movement_link.delete`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Tours
|
## Tours
|
||||||
|
|
||||||
Base path: `/api/tours`. Full guide: [tours.md](tours.md).
|
Base path: `/api/tours`. Full guide: [tours.md](tours.md).
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
This is Bible for agents. Obey what is written here.
|
||||||
|
there are two parts - Old testament You can never override what is written in it.
|
||||||
|
New testament - you can override rules from it only when user dekliberatly asks to do it. it can be done only once at a time, never in any auto mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Old testament**
|
||||||
|
|
||||||
|
"No", "I do not know", "I have no information" etc are valid answers.
|
||||||
|
|
||||||
|
Try to be more professional, if asked to discuss search for the arguments both pro and contra thesis. User encouragement is not required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**New testament**
|
||||||
|
|
||||||
|
Never halucinate, make up facts, refrences, links etc untill user asks you directly.
|
||||||
|
|
||||||
|
When coding and ask to commit, update documentation first.
|
||||||
|
|
||||||
|
Gite has a bug, always repeat pull, push or other commands twice if failed first time.
|
||||||
|
|
||||||
|
When coding write tests, **before ** writing code.
|
||||||
|
|
||||||
|
If possible, check context window usage, warn user if it is above 70%.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
@@ -33,6 +33,8 @@ erDiagram
|
|||||||
paintings ||--o{ painting_influence_sources : influenced_by
|
paintings ||--o{ painting_influence_sources : influenced_by
|
||||||
artists ||--o{ painting_influence_sources : artist_source
|
artists ||--o{ painting_influence_sources : artist_source
|
||||||
art_movements ||--o{ painting_influence_sources : movement_source
|
art_movements ||--o{ painting_influence_sources : movement_source
|
||||||
|
art_movements ||--o{ movement_lineage_links : source
|
||||||
|
art_movements ||--o{ movement_lineage_links : target
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tables
|
## Tables
|
||||||
@@ -213,6 +215,36 @@ Unique index on `(painting_id, source_type, source_painting_id, source_artist_id
|
|||||||
|
|
||||||
**Canonical influence store.** Used by all API influence queries: painting detail (`influencedBy`, `influenced`), `has_influence_links`, and artist hall navigation (predecessors / successors). Legacy `painting_influences` rows are backfilled here on migration; new curated painting edges are written to both tables by `update-influences`.
|
**Canonical influence store.** Used by all API influence queries: painting detail (`influencedBy`, `influenced`), `has_influence_links`, and artist hall navigation (predecessors / successors). Legacy `painting_influences` rows are backfilled here on migration; new curated painting edges are written to both tables by `update-influences`.
|
||||||
|
|
||||||
|
### `movement_lineage_links`
|
||||||
|
|
||||||
|
Art-movement predecessor → successor edges for the timeline flow and Tree of Art. Migration: `db/migrate-movement-links.sql`.
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | SERIAL PK | |
|
||||||
|
| `source_movement_id` | FK → `art_movements` | Predecessor |
|
||||||
|
| `target_movement_id` | FK → `art_movements` | Successor (must differ from source) |
|
||||||
|
| `link_year_approx` | INTEGER | Approximate transition year |
|
||||||
|
| `proof_url` | TEXT NOT NULL | Required literature / museum / web citation URL |
|
||||||
|
| `proof_label` | TEXT | Short display label for the proof |
|
||||||
|
| `notes` | TEXT | Optional curator note |
|
||||||
|
| `created_by` | FK → `users` | Curator who added the row |
|
||||||
|
| `created_at` / `updated_at` | TIMESTAMPTZ | |
|
||||||
|
|
||||||
|
Unique on `(source_movement_id, target_movement_id)`.
|
||||||
|
|
||||||
|
### `movement_lineage_delete_votes`
|
||||||
|
|
||||||
|
Curator votes requesting deletion of a lineage link. Only **admins** may hard-delete the link. Migration: `db/migrate-movement-link-delete-votes.sql`.
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | SERIAL PK | |
|
||||||
|
| `link_id` | FK → `movement_lineage_links` | Cascade on link delete |
|
||||||
|
| `user_id` | FK → `users` | One vote per user per link |
|
||||||
|
| `reason` | TEXT NOT NULL | Min 10 characters (trimmed) |
|
||||||
|
| `created_at` | TIMESTAMPTZ | |
|
||||||
|
|
||||||
### `users`
|
### `users`
|
||||||
|
|
||||||
Staff accounts (named logins). Anonymous site visitors do not have rows here. Migration: `db/migrate-auth.sql` + `db/migrate-user-roles.sql`.
|
Staff accounts (named logins). Anonymous site visitors do not have rows here. Migration: `db/migrate-auth.sql` + `db/migrate-user-roles.sql`.
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ CURATOR_USERNAME=curator
|
|||||||
CURATOR_PASSWORD=your-secure-password
|
CURATOR_PASSWORD=your-secure-password
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open the gallery → **Curator login** (top-right) → use tools allowed by your role/permissions (debug, Checkup, Translations, Influences, Tour editor, **Users**, **Activity**). Mutations are logged in `curator_audit_log` per user.
|
Then open the gallery → **Curator login** (top-right) → use tools allowed by your role/permissions (debug, Checkup, Translations, Influences, **Movement links** top-left, Tour editor, **Users**, **Activity**). Mutations are logged in `curator_audit_log` per user.
|
||||||
|
|
||||||
If login fails after changing `CURATOR_PASSWORD` in `.env`, run `npm run dev:reset-curator` (bootstrap only runs when `users` is empty; reset upserts the env account as **admin**).
|
If login fails after changing `CURATOR_PASSWORD` in `.env`, run `npm run dev:reset-curator` (bootstrap only runs when `users` is empty; reset upserts the env account as **admin**).
|
||||||
|
|
||||||
@@ -103,9 +103,9 @@ If login fails after changing `CURATOR_PASSWORD` in `.env`, run `npm run dev:res
|
|||||||
|
|
||||||
| Role | Access |
|
| Role | Access |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios |
|
| Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios; can open **Movement links** login gate |
|
||||||
| Curator | Public browse + assigned permission flags (`images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`) |
|
| Curator | Public browse + assigned permission flags (`images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`). With `influences`: Influences + Movement links create/vote-to-delete |
|
||||||
| Admin | All curator tools + **Users** + **Activity** audit reports |
|
| Admin | All curator tools + **Users** + **Activity** + hard-delete movement links |
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
@@ -286,6 +286,28 @@ docker login gitea.mysuperlab.netcraze.pro
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test # whole suite
|
||||||
|
node --test server/__tests__/floor-brightness.test.js # one file
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs `node --test server/__tests__/**/*.test.js` — **15 suites, 41 tests, ~1 s**. No test framework is installed: this is Node's built-in runner, and `node --test` expands the `**` pattern itself, so the command behaves the same from PowerShell, cmd and bash.
|
||||||
|
|
||||||
|
The suite needs **no database and no running server** — the tests read the client's style and geometry modules from disk and call the server's validators directly.
|
||||||
|
|
||||||
|
| Area | Test files |
|
||||||
|
|---|---|
|
||||||
|
| Hang geometry and clearance | `painting-column-clearance`, `doorway-clearance` |
|
||||||
|
| Period interiors | `abex-loft-interior`, `baroque-panels`, `dada-interior`, `high-renaissance-interior`, `mannerist-columns`, `neoclassical-interior`, `surrealist-interior` |
|
||||||
|
| Brightness and windows | `floor-brightness`, `no-factory-windows` |
|
||||||
|
| Movement links and lineage | `movement-lineage-service` |
|
||||||
|
|
||||||
|
**Write the test before the code it covers** — see [Bible.md](Bible.md). The interior rules this suite enforces are described in [data-and-images.md](data-and-images.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Build frontend
|
## Build frontend
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -373,6 +395,9 @@ Remote: https://gitea.mysuperlab.netcraze.pro/Danilka/Art-gallery
|
|||||||
|
|
||||||
| Document | Contents |
|
| Document | Contents |
|
||||||
|----------|----------|
|
|----------|----------|
|
||||||
|
| [basics.md](basics.md) | Architecture, navigation, user flow |
|
||||||
|
| [movement-tree.md](movement-tree.md) | Tree of Art layout rules |
|
||||||
|
| [ui-interaction-and-component-standards.md](ui-interaction-and-component-standards.md) | UI interaction and component standards |
|
||||||
| [deploy-dev-to-prod.md](deploy-dev-to-prod.md) | Step-by-step release runbook (code, DB, data, images) |
|
| [deploy-dev-to-prod.md](deploy-dev-to-prod.md) | Step-by-step release runbook (code, DB, data, images) |
|
||||||
| [environments.md](environments.md) | Full dev/prod walkthrough |
|
| [environments.md](environments.md) | Full dev/prod walkthrough |
|
||||||
| [setup.md](setup.md) | Install, env vars, troubleshooting |
|
| [setup.md](setup.md) | Install, env vars, troubleshooting |
|
||||||
|
|||||||
@@ -15,4 +15,9 @@ this file contains draft for future releases and features
|
|||||||
6. ~~create search by entity (painting, artist, movement)~~ — done: timeline header + `GET /api/search`
|
6. ~~create search by entity (painting, artist, movement)~~ — done: timeline header + `GET /api/search`
|
||||||
7. ~~create guided tours (with text/extra infor, set of entities)~~ — done: `tours` / `tour_stops`, public Tours popup + 3D tour hall, curator Tour editor — [tours.md](tours.md)
|
7. ~~create guided tours (with text/extra infor, set of entities)~~ — done: `tours` / `tour_stops`, public Tours popup + 3D tour hall, curator Tour editor — [tours.md](tours.md)
|
||||||
8. ~~curator role + multi-user accounts with permissions~~ — done: `admin`/`curator` roles, permission flags, Users page + `/api/users`, per-user audit — [API.md](API.md#authentication) / [basics.md](basics.md#user-roles-and-access)
|
8. ~~curator role + multi-user accounts with permissions~~ — done: `admin`/`curator` roles, permission flags, Users page + `/api/users`, per-user audit — [API.md](API.md#authentication) / [basics.md](basics.md#user-roles-and-access)
|
||||||
|
9. ~~vertical bottom-up timeline (alternative start page)~~ — done: `VerticalTimeline` + `VerticalMovementBands`, header layout switch — [basics.md](basics.md#timeline-and-movement-flow)
|
||||||
|
10. ~~Tree of Art timeline (lineage as a growing tree)~~ — done: `MovementTree` + `movementTree.ts`, shareable `?layout=tree` — [movement-tree.md](movement-tree.md)
|
||||||
|
11. ~~UI interaction and component standards~~ — done: shared doc + Cursor rule for client screens — [ui-interaction-and-component-standards.md](ui-interaction-and-component-standards.md)
|
||||||
|
12. ~~Movement lineage curator config (links + proof URLs + delete votes)~~ — done: `movement_lineage_links` / `movement_lineage_delete_votes`, `MovementLinksPage`, admin-only hard delete — [data-and-images.md](data-and-images.md#movement-lineage-timeline--tree-of-art) / [API.md](API.md#movement-lineage-links)
|
||||||
|
13. ~~Movement hall interior polish (doors, columns, windows, floors)~~ — done: doorway `WallBand` clearance; painting↔bay-column keep-outs; no factory windows; floor luminance cap; period-module fixes (Dada/loft/Mannerism/Baroque/High Renaissance/…) — [data-and-images.md](data-and-images.md#movement-gallery-interiors-frontend) / [basics.md](basics.md#movement-galleries)
|
||||||
|
|
||||||
@@ -6,8 +6,8 @@ Interactive virtual museum spanning art history: zoomable timeline with event gu
|
|||||||
|
|
||||||
The app is organised as a **drill-down hierarchy**:
|
The app is organised as a **drill-down hierarchy**:
|
||||||
|
|
||||||
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries; classic left→right or vertical bottom→up layout (header link).
|
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries; classic left→right, vertical bottom→up, or **tree** bottom→up layout (header links).
|
||||||
2. **Movement flow** — art movements as SVG streams on the same year axis; documented predecessor→successor branches (classic); portrait thumbnails along each stream.
|
2. **Movement flow** — art movements as SVG streams on the same year axis; documented predecessor→successor branches (classic); portrait thumbnails along each stream. The tree layout redraws the same lineage as a growing tree — see [movement-tree.md](movement-tree.md).
|
||||||
3. **3D gallery** — one personal hall per artist, a **movement gallery** (click a movement name → artist filter → hall), or a **guided tour** hall (timeline → **Tours**): period-themed or tour wings of up to ~55 works, U-shaped hang (left → end wall → right).
|
3. **3D gallery** — one personal hall per artist, a **movement gallery** (click a movement name → artist filter → hall), or a **guided tour** hall (timeline → **Tours**): period-themed or tour wings of up to ~55 works, U-shaped hang (left → end wall → right).
|
||||||
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **curator notes** and **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography.
|
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **curator notes** and **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography.
|
||||||
5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**).
|
5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**).
|
||||||
@@ -37,35 +37,40 @@ Gallery/
|
|||||||
│ │ ├── components/GalleryLoadingMarker.tsx # Loading spinner overlay/banner (catalog, portraits, halls)
|
│ │ ├── components/GalleryLoadingMarker.tsx # Loading spinner overlay/banner (catalog, portraits, halls)
|
||||||
│ │ ├── components/GalleryWindows.tsx # Side-wall daylight windows (movement)
|
│ │ ├── components/GalleryWindows.tsx # Side-wall daylight windows (movement)
|
||||||
│ │ ├── components/HallPassage.tsx # Open archway between movement wings
|
│ │ ├── 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
|
│ │ ├── data/movement-interior-styles.ts # Per-movement interior themes
|
||||||
│ │ ├── utils/movementHallLayout.ts # Wing split + window gap placement
|
│ │ ├── utils/movementHallLayout.ts # Wing split + door/column-aware hang + window gaps
|
||||||
|
│ │ ├── utils/sideColumnKeepOuts.ts # Bay column Z keep-outs for side-wall hang
|
||||||
│ │ ├── utils/galleryProceduralTextures.ts # Hi-res wall/floor textures
|
│ │ ├── utils/galleryProceduralTextures.ts # Hi-res wall/floor textures
|
||||||
│ │ ├── components/PaintingDetail.tsx # Detail view + debug panel
|
│ │ ├── components/PaintingDetail.tsx # Detail view + debug panel
|
||||||
│ │ ├── components/ArtistBio.tsx # Biography + portrait debug panel
|
│ │ ├── components/ArtistBio.tsx # Biography + portrait debug panel
|
||||||
│ │ ├── components/DebugSearchResultsModal.tsx # “More” search picker (20 results)
|
│ │ ├── components/DebugSearchResultsModal.tsx # “More” search picker (20 results)
|
||||||
│ │ ├── components/Timeline.tsx # Era bar, year ticks, event markers
|
|
||||||
│ │ ├── components/Timeline.tsx # Classic horizontal era bar
|
│ │ ├── components/Timeline.tsx # Classic horizontal era bar
|
||||||
│ │ ├── components/VerticalTimeline.tsx # Bottom-up vertical era rail
|
│ │ ├── components/VerticalTimeline.tsx # Bottom-up vertical era rail
|
||||||
│ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches)
|
│ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches)
|
||||||
│ │ ├── components/VerticalMovementBands.tsx # Bottom-up movement streams
|
│ │ ├── components/VerticalMovementBands.tsx # Bottom-up movement streams
|
||||||
|
│ │ ├── components/MovementTree.tsx # Bottom-up movement tree (alternative start page)
|
||||||
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow
|
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow
|
||||||
│ │ ├── components/CatalogSearchBar.tsx # Timeline header catalog search
|
│ │ ├── components/CatalogSearchBar.tsx # Timeline header catalog search
|
||||||
│ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail
|
│ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail
|
||||||
│ │ ├── pages/CheckupPage.tsx # Image audit table
|
│ │ ├── pages/CheckupPage.tsx # Image audit table
|
||||||
│ │ ├── pages/TranslationsPage.tsx # Russian translation review
|
│ │ ├── pages/TranslationsPage.tsx # Russian translation review
|
||||||
│ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard
|
│ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard
|
||||||
|
│ │ ├── pages/MovementLinksPage.tsx # Movement lineage CRUD + delete votes
|
||||||
│ │ ├── pages/ToursPage.tsx # Guided tour editor
|
│ │ ├── pages/ToursPage.tsx # Guided tour editor
|
||||||
│ │ ├── pages/UsersPage.tsx # Staff accounts
|
│ │ ├── pages/UsersPage.tsx # Staff accounts
|
||||||
│ │ ├── pages/AuditPage.tsx # Admin curator activity reports
|
│ │ ├── pages/AuditPage.tsx # Admin curator activity reports
|
||||||
│ │ ├── components/ToursPopup.tsx # Public published-tours modal
|
│ │ ├── components/ToursPopup.tsx # Public published-tours modal
|
||||||
│ │ ├── i18n/ # react-i18next bootstrap
|
│ │ ├── i18n/ # react-i18next bootstrap
|
||||||
│ │ └── locales/{en,ru}/ # UI chrome strings
|
│ │ ├── locales/{en,ru}/ # UI chrome strings (incl. timeline layout + captions)
|
||||||
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
|
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
|
||||||
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
|
│ │ ├── data/movement-lineage.ts # Fallback movement predecessor links (UI)
|
||||||
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor
|
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor
|
||||||
│ │ ├── utils/debugMode.ts # Debug mode + “Show more” localStorage prefs
|
│ │ ├── utils/debugMode.ts # Debug mode + “Show more” localStorage prefs
|
||||||
│ │ └── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
|
│ │ ├── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
|
||||||
|
│ │ ├── utils/movementColor.ts # Shared vivid/shade hex helpers for all movement charts
|
||||||
|
│ │ └── utils/movementTree.ts # View-independent Tree of Art layout engine
|
||||||
│ └── dist/ # Production build (served by API when present)
|
│ └── dist/ # Production build (served by API when present)
|
||||||
├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools
|
├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools
|
||||||
│ ├── seed-wikipedia.js
|
│ ├── seed-wikipedia.js
|
||||||
@@ -173,20 +178,25 @@ Implementation: `goToTimelineHome()` in `HomePage.tsx` — do not use the browse
|
|||||||
|
|
||||||
## Timeline and movement flow
|
## Timeline and movement flow
|
||||||
|
|
||||||
The home page shows two linked views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`). A header link switches between layouts:
|
The home page shows linked era + movement views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`). Header links switch among three layouts; the active layout is also deep-linked:
|
||||||
|
|
||||||
| Layout | Era rail | Movement flow | Time direction |
|
| Layout | Era rail | Movement flow | Time direction | URL |
|
||||||
|--------|----------|---------------|----------------|
|
|--------|----------|---------------|----------------|-----|
|
||||||
| **Classic** (default) | `Timeline.tsx` (top bar) | `MovementBands.tsx` | Left → right |
|
| **Classic** (default) | `Timeline.tsx` (top bar) | `MovementBands.tsx` | Left → right | omit or `?layout=classic` |
|
||||||
| **Vertical** | `VerticalTimeline.tsx` (left rail) | `VerticalMovementBands.tsx` (streams + lineage branches; no portraits) | Bottom → top |
|
| **Vertical** | `VerticalTimeline.tsx` (left rail) | `VerticalMovementBands.tsx` (streams + lineage; no portraits) | Bottom → top | `?layout=vertical` |
|
||||||
|
| **Tree of art** | `VerticalTimeline.tsx` (left rail) | `MovementTree.tsx` (lineage as a growing tree; no portraits) | Bottom → top | `?layout=tree` |
|
||||||
|
|
||||||
|
`HomePage` reads `?layout=` once on load and calls `history.replaceState` when the user switches. Classic clears the param so the default URL stays clean. Alias `horizontal` maps to classic.
|
||||||
|
|
||||||
| View | Component | Purpose |
|
| View | Component | Purpose |
|
||||||
|------|-----------|---------|
|
|------|-----------|---------|
|
||||||
| Era bar / rail | `Timeline.tsx` / `VerticalTimeline.tsx` | Historical eras, major event markers, click-to-zoom |
|
| Era bar / rail | `Timeline.tsx` / `VerticalTimeline.tsx` | Historical eras, major event markers, click-to-zoom |
|
||||||
| Movement flow | `MovementBands.tsx` / `VerticalMovementBands.tsx` | Streams per movement, artist portraits (classic also draws lineage branches) |
|
| Movement flow | `MovementBands.tsx` / `VerticalMovementBands.tsx` / `MovementTree.tsx` | Streams or tree limbs per movement; classic also places artist portraits |
|
||||||
|
| Stream colours | `utils/movementColor.ts` | Shared `vividMovementColor` / `shadeMovementColor` for all three charts |
|
||||||
|
|
||||||
Both layouts share zoom/pan behaviour via `client/src/utils/timelineView.ts` (`zoomTimelineView`, `panTimelineView`, `chooseTimelineTickInterval`, `createViewChangeScheduler`). The home page uses a **fixed viewport** (`100vh`). Classic stacks timeline above movements; vertical places the year rail beside the flow (`home-timeline-stack-vertical`).
|
Hint captions under each rail/chart (`captionClassicTimeline`, `captionClassicFlow`, `captionVerticalTimeline`, `captionVerticalFlow`, `captionTreeFlow`) live in `locales/{en,ru}/home.json` and follow the EN|RU toggle.
|
||||||
|
|
||||||
|
All three layouts share zoom/pan behaviour via `client/src/utils/timelineView.ts` (`zoomTimelineView`, `panTimelineView`, `chooseTimelineTickInterval`, `createViewChangeScheduler`). The home page uses a **fixed viewport** (`100vh`). Classic stacks timeline above movements; vertical and tree place the year rail beside the flow (`home-timeline-stack-vertical`). The tree layout keeps its horizontal geometry fixed across zoom — rules in [movement-tree.md](movement-tree.md).
|
||||||
### Catalog search (timeline header)
|
### Catalog search (timeline header)
|
||||||
|
|
||||||
`CatalogSearchBar.tsx` calls `GET /api/search?q=…` (public, no login). The dropdown is stacked above the timeline (`z-index` on `.site-header`) so results are not hidden by movement bands.
|
`CatalogSearchBar.tsx` calls `GET /api/search?q=…` (public, no login). The dropdown is stacked above the timeline (`z-index` on `.site-header`) so results are not hidden by movement bands.
|
||||||
@@ -255,7 +265,7 @@ Each visible movement is drawn as a **portrait-width curved stream** (~54 px str
|
|||||||
|
|
||||||
| Feature | Implementation |
|
| Feature | Implementation |
|
||||||
|---------|----------------|
|
|---------|----------------|
|
||||||
| Lineage layout | `client/src/data/movement-lineage.ts` — curated predecessor→successor pairs (Met / ArtStory / museum essays); multiple parents allowed |
|
| Lineage layout | `movement_lineage_links` (DB) via `GET /api/movement-links`; client fallback `movement-lineage.ts`; **Movement links** page (top-left header control; create/vote needs `influences`; hard delete is **admin-only**); multiple parents allowed |
|
||||||
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
@@ -288,7 +298,7 @@ Each artist appears as a **portrait circle** on their movement’s stream row:
|
|||||||
|
|
||||||
Only **mousedown** on portraits and movement labels stops propagation (so drag-to-pan does not start when clicking them). Hovering a portrait highlights the artist’s lifespan on the era bar and brightens their segment on the movement stream.
|
Only **mousedown** on portraits and movement labels stops propagation (so drag-to-pan does not start when clicking them). Hovering a portrait highlights the artist’s lifespan on the era bar and brightens their segment on the movement stream.
|
||||||
|
|
||||||
**Note:** Movement lineage is **frontend curation** for layout and labels — it is not stored in PostgreSQL. Painting influence links live in **`painting_influence_sources`** (paintings, artists, or movements as sources). The API reads that table for detail panels, hall navigation, and `has_influence_links`. The legacy **`painting_influences`** table is still written in parallel when curators add painting-to-painting edges but is not queried for display.
|
**Note:** Movement lineage lives in **`movement_lineage_links`** (source/target movements, approx year, required `proof_url`). Timeline / Tree of Art load it from `GET /api/movement-links` (static fallback in `movement-lineage.ts` if the API is empty). Curators with `influences` can add links and **vote to delete** (with reason); only **admins** hard-delete. Votes are stored in `movement_lineage_delete_votes`. Painting influence links live in **`painting_influence_sources`** (paintings, artists, or movements as sources). The API reads that table for detail panels, hall navigation, and `has_influence_links`. The legacy **`painting_influences`** table is still written in parallel when curators add painting-to-painting edges but is not queried for display.
|
||||||
|
|
||||||
## Virtual gallery (3D halls)
|
## Virtual gallery (3D halls)
|
||||||
|
|
||||||
@@ -344,15 +354,18 @@ Enter from the home page by clicking a **movement name** on the movement flow. F
|
|||||||
| Wings | Catalog split into wings of up to **55 works** (`movementHallLayout.ts`); large movements (e.g. Baroque) use multiple wings |
|
| Wings | Catalog split into wings of up to **55 works** (`movementHallLayout.ts`); large movements (e.g. Baroque) use multiple wings |
|
||||||
| 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 |
|
| 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) |
|
| Wall order | Same U-shaped hang as artist halls (left → end → right) |
|
||||||
|
| Column clearance | Side-wall frames **skip bay column/pilaster keep-outs** (`sideColumnKeepOuts.ts`); hall depth grows if needed — columns stay fixed. See [data-and-images.md](data-and-images.md#hang-layout-wings-doors-columns) |
|
||||||
|
| Door mouldings | Low end-wall `WallBand`s split around the exit (`DOOR_CLEAR_HALF` / `DOOR_CLEAR_Y` in `hall-details/geometry.ts`) so rails do not cross the door |
|
||||||
| Frame captions | **Year · artist** label below each frame |
|
| Frame captions | **Year · artist** label below each frame |
|
||||||
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, Byzantine basilica, NYC loft, white cube, etc.). Style keys use a legacy id map (`DB_ID_TO_STYLE_KEY`: gallery ids **1–26** → authored keys **27–52**, with Northern/High Renaissance swapped) so Byzantine gets the basilica, not Gothic stone |
|
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, Byzantine basilica, NYC loft, white cube, etc.). Style keys use a legacy id map (`DB_ID_TO_STYLE_KEY`: gallery ids **1–26** → authored keys **27–52**, 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`. No `factory` (barred) windows — use `picture` / `ribbon` / etc. |
|
||||||
| 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 |
|
| 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 |
|
| 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 |
|
| Door-flanking panels | Wall segments beside exit doors and passages take the hall's own wall texture (`GalleryWall` accepts `kind`/`tint`) instead of rendering as flat single-colour blocks next to textured walls |
|
||||||
| Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue |
|
| Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue |
|
||||||
| Windows | **Side walls only** — placed in gaps between frames when possible; if a wall is packed, high **clerestory** windows are still added so the hall keeps daylight (`computeSideWallWindows`). Styles are drawn in `GalleryWindows.tsx`; `gothic-lancet` builds a two-centred arch head from chords with a glazed spandrel, mullions, and transoms |
|
| Windows | **Side walls only** — placed in gaps between frames when possible; if a wall is packed, high **clerestory** windows are still added so the hall keeps daylight (`computeSideWallWindows`). Styles are drawn in `GalleryWindows.tsx`; `gothic-lancet` builds a two-centred arch head from chords with a glazed spandrel, mullions, and transoms |
|
||||||
| Lighting | Shared hall lights only (ambient / hemisphere / directional + capped ceiling track spots + one fill per window). See **light budget** below |
|
| Lighting | Shared hall lights only (ambient / hemisphere / directional + capped ceiling track spots + one fill per window). See **light budget** below |
|
||||||
| Period details | `MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** (never freestanding mid-hall or proud corner shafts that cover frames); `byzantine` adds engaged porphyry colonnettes with basket capitals, a marble revetment dado, and hanging brass polycandela; `gothic` adds bay-spaced compound piers with vault springers, transverse ribs arching across the nave, and a moulded string course — all flush to the side walls |
|
| 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** |
|
| Back wall | Single wing: solid display wall. Multi-wing: **Exit double doors** → **Wing navigator** or **Exit to Timeline** |
|
||||||
| Front / entrance wall | Single wing: **Exit double doors** (leave the way you entered). Multi-wing: **“Next wing →”** archway when a later wing exists |
|
| Front / entrance wall | Single wing: **Exit double doors** (leave the way you entered). Multi-wing: **“Next wing →”** archway when a later wing exists |
|
||||||
| Influence lamps | Same golden upside-down emissive fixtures as artist halls when `has_influence_links` is true (shared rail: 40 cm above the tallest frame in the wing) |
|
| 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) |
|
||||||
@@ -388,9 +401,13 @@ Full guide: [tours.md](tours.md).
|
|||||||
|
|
||||||
**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 ~100–200 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).
|
**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 ~100–200 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. A style that is *meant* to be dim (a basilica lit by window shafts, not flooded) sets **`lightScale`** in `movement-interior-styles.ts` — it multiplies the scene ambient/hemisphere/directional/fill lights, the hall point lights, the ceiling track spots, and the HDR `environmentIntensity`. It defaults to **1**, so only styles that opt in are affected. Window fill lights are deliberately **not** scaled, so the daylight shafts still read against the darker room.
|
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.
|
||||||
|
|
||||||
**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`).
|
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.15–0.27 luminance.
|
||||||
|
|
||||||
|
**Wall brightness is albedo × `lightScale`.** Gallery walls are authored well below white for that reason: the reworked interiors sit around **0.50–0.56** wall luminance (0.66 for the two white-cube halls), landing at an effective 0.14–0.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). Engaged / bay columns stay on the wall face; the hang **clears** their Z keep-outs rather than moving the shafts (`sideColumnKeepOuts.ts` + `movementHallLayout.ts`). Low mouldings clear exit doors via `WallBand` splits (`DOOR_CLEAR_HALF`).
|
||||||
|
|
||||||
**Surface textures:** Procedural colour maps use sRGB; **normal maps use `NoColorSpace`** (`galleryProceduralTextures.ts`). Wrong colour space on normals breaks wall shading.
|
**Surface textures:** Procedural colour maps use sRGB; **normal maps use `NoColorSpace`** (`galleryProceduralTextures.ts`). Wrong colour space on normals breaks wall shading.
|
||||||
|
|
||||||
@@ -446,7 +463,7 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens
|
|||||||
- **Timeline catalog** loads once from the API; pan/zoom is client-side only, with per-frame batching via `createViewChangeScheduler()`.
|
- **Timeline catalog** loads once from the API; pan/zoom is client-side only, with per-frame batching via `createViewChangeScheduler()`.
|
||||||
- **Movement flow interaction** uses simplified SVG and hides portrait DOM during active scroll/drag so zoom stays responsive over dense portrait fields.
|
- **Movement flow interaction** uses simplified SVG and hides portrait DOM during active scroll/drag so zoom stays responsive over dense portrait fields.
|
||||||
- **Movement filtering** on zoom shows movements whose span overlaps the visible year range and that have at least one catalogued artist; a movement (e.g. Byzantine or Gothic viewed at 311–1231 CE) still appears even when all its artists lived outside the current window.
|
- **Movement filtering** on zoom shows movements whose span overlaps the visible year range and that have at least one catalogued artist; a movement (e.g. Byzantine or Gothic viewed at 311–1231 CE) still appears even when all its artists lived outside the current window.
|
||||||
- **Movement lineage** (`movement-lineage.ts`) documents art-historical predecessor→successor links for the flow diagram; extend that file to add or correct branches.
|
- **Movement lineage** — top-left **Movement links** opens `MovementLinksPage` (login if needed). Create requires `influences` + proof URL; curators vote to delete with a reason; admins review votes and delete. Data: `movement_lineage_links` / `movement_lineage_delete_votes`; timeline uses `GET /api/movement-links` with fallback `movement-lineage.ts`. After code changes that affect the SPA on port `3451`, run `npm run build --prefix client` (that port serves `client/dist`).
|
||||||
- **One hall per artist** keeps navigation predictable: enter from the timeline or bio, leave via the single exit or back button.
|
- **One hall per artist** keeps navigation predictable: enter from the timeline or bio, leave via the single exit or back button.
|
||||||
- **Movement galleries** complement artist halls: full movement corpus in period-themed wings, entered from the flow diagram.
|
- **Movement galleries** complement artist halls: full movement corpus in period-themed wings, entered from the flow diagram.
|
||||||
- **Guided tours** add curator-ordered winged halls with stop text on painting detail — [tours.md](tours.md).
|
- **Guided tours** add curator-ordered winged halls with stop text on painting detail — [tours.md](tours.md).
|
||||||
@@ -540,3 +557,4 @@ See [API.md](API.md#authentication) and [data-and-images.md](data-and-images.md#
|
|||||||
| [tours.md](tours.md) | Guided tours — editor, public popup, 3D tour hall |
|
| [tours.md](tours.md) | Guided tours — editor, public popup, 3D tour hall |
|
||||||
| [i18n-russian.md](i18n-russian.md) | Russian UI + entity_translations |
|
| [i18n-russian.md](i18n-russian.md) | Russian UI + entity_translations |
|
||||||
| [data-and-images.md](data-and-images.md) | Image pipeline and seeding |
|
| [data-and-images.md](data-and-images.md) | Image pipeline and seeding |
|
||||||
|
| [ui-interaction-and-component-standards.md](ui-interaction-and-component-standards.md) | UI interaction, navigation, and component standards |
|
||||||
@@ -263,26 +263,72 @@ Discovered rows are stored with `confidence: discovered` and `discovered_via` (e
|
|||||||
|
|
||||||
Extend `art-influences-data.js` for high-quality curated chains; use discovery to suggest additional artist and movement links for manual review.
|
Extend `art-influences-data.js` for high-quality curated chains; use discovery to suggest additional artist and movement links for manual review.
|
||||||
|
|
||||||
## Movement lineage (frontend flow diagram)
|
## Movement lineage (timeline / Tree of Art)
|
||||||
|
|
||||||
Separate from the painting influence graph, `client/src/data/movement-lineage.ts` lists **art-movement** predecessor→successor pairs used only by `MovementBands.tsx` to position streams and draw branch connectors (e.g. Impressionism → Post-Impressionism → Fauvism / Cubism / Expressionism).
|
Separate from the painting influence graph, **`movement_lineage_links`** stores art-movement predecessor→successor pairs used by `MovementBands`, `VerticalMovementBands`, and `MovementTree` (e.g. Impressionism → Post-Impressionism → Fauvism / Cubism / Expressionism).
|
||||||
|
|
||||||
| Aspect | Detail |
|
| Aspect | Detail |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| Storage | TypeScript module in the client — **not** a database table |
|
| Storage | PostgreSQL `movement_lineage_links` (`db/migrate-movement-links.sql`) |
|
||||||
| Format | `[parentMovementName, childMovementName]` tuples; names must match `art_movements.name` from the seed |
|
| Required proof | `proof_url` must be a valid `http(s)` URL (museum essay, literature, web reference) |
|
||||||
| Multiple parents | Allowed (e.g. Post-Impressionism feeding several modern paths) |
|
| Optional | `link_year_approx`, `proof_label`, `notes` |
|
||||||
| Sources | Curator notes in the file reference Met essays, TheArtStory, and similar |
|
| Format | FK pairs to `art_movements`; multiple parents allowed |
|
||||||
|
| API | Public `GET /api/movement-links`; curator `POST` create + delete votes; **admin-only** hard `DELETE` |
|
||||||
|
| UI | Top-left **Movement links** (timeline header); also linked from Influences. Curators create + vote-to-delete (reason); admins review votes and hard-delete |
|
||||||
|
| Fallback | `client/src/data/movement-lineage.ts` if the API returns no rows |
|
||||||
|
| Defaults | Seeded on migrate / first GET from Met / TheArtStory / Christie's essays |
|
||||||
|
| Delete votes | `movement_lineage_delete_votes` (`db/migrate-movement-link-delete-votes.sql`); one vote per curator per link |
|
||||||
|
|
||||||
To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the client. No migration or API change is required.
|
To add a branch, use **Movement links** (proof URL required). To remove one, curators vote with a reason; an admin deletes after review. No client rebuild is required for data changes; rebuild `client/dist` only when shipping SPA changes to the API-static port (`3451` / prod).
|
||||||
|
|
||||||
## Movement gallery interiors (frontend)
|
## Movement gallery interiors (frontend)
|
||||||
|
|
||||||
Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (pilasters, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts` (colour maps in sRGB, normal maps in linear/`NoColorSpace`). Period mesh details live in `client/src/components/MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** so shafts never cover frames; Byzantine adds porphyry colonnettes / polycandela; Gothic (`details: 'gothic'`) adds bay-spaced compound piers with vault springers and transverse ribs flush to the side walls.
|
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`).
|
||||||
|
|
||||||
Gothic surfaces include **`gothic-ashlar`** (full-storey blind arcade mapped once vertically via `metersPerRepeat === WALL_HEIGHT`), **`gothic-vault`** (quadripartite rib bay), and **`gothic-stone-floor`**. Door-flanking wall panels use the hall wall texture via `GalleryWall` `kind`/`tint` so they match the textured walls. Deliberately dim naves set **`lightScale`** (Byzantine / Gothic) so shared lights stay soft while window shafts stay bright.
|
### Period architecture per movement
|
||||||
|
|
||||||
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 movement’s look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
|
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, plain coffered soffit — no gold rosettes; veined Carrara floor) |
|
||||||
|
| `hall-details/courtly.tsx` | `flemish` (linenfold wainscot, beam-and-corbel ceiling; ribs skip the exit door) · `mannerist` (flush tapering herms, shallow rustication, broken cornice) · `baroque` (solomonic corner columns; gilt moulding rails around velvet panels, not solid slabs) · `rococo` (boiserie, rocaille cartouches, painted cove) · `neoclassical` (fluted Ionic order, meander frieze; cooler/darker floor tint) |
|
||||||
|
| `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` (cream walls, collage panels, open `picture` windows — no factory bars) · `surrealist` (no freestanding “door to nowhere”) · `loft` (timber posts/joists, `picture` windows — no cast-iron columns or sprinkler pipe) · `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 (`bayPositions` / `useBayZ`, `useWallClearance`, …) 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 **`DOOR_CLEAR_HALF` (1.45 m)** of a wall centre (doorways). Low `WallBand`s on back/front walls **auto-split** around that clear zone (`DOOR_CLEAR_Y` ≈ 2.55 m)
|
||||||
|
- 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
|
||||||
|
|
||||||
|
**Window styles:** `GalleryWindows.tsx` includes open `picture` lights and thin-steel `ribbon` bands. Movement styles must **not** use `factory` (barred) windows — regression: `server/__tests__/no-factory-windows.test.js`.
|
||||||
|
|
||||||
|
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 / floor 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 wall brightness together, and they **multiply**:
|
||||||
|
|
||||||
|
- **tints** — gallery walls are authored around **0.50–0.56** relative luminance (0.66 for the Suprematist and Pop white cubes, 0.15–0.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.
|
||||||
|
|
||||||
|
**Floor tints** are capped at relative luminance **≤ 0.58** so checker/marble floors do not blow out under the same rig (`server/__tests__/floor-brightness.test.js`).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Hang layout (wings, doors, columns)
|
||||||
|
|
||||||
|
Wing layout lives in `client/src/utils/movementHallLayout.ts` (up to 55 works per wing, U-shaped hang, window gap placement with packed-wall **clerestory** fallback). Visit order fills the **left wall** (first work at the entrance), then the **far/end wall**, then the **right** (last work at the entrance). Multi-wing halls keep a centre exit on the far wall and hang only on the door flanks.
|
||||||
|
|
||||||
|
**Paintings must not sit in front of mid-wall bay columns/pilasters.** Column Z centres use the same `bayPositions` formula as the period modules. Keep-out half-widths per `details` kind live in `client/src/utils/sideColumnKeepOuts.ts` (`SIDE_WALL_COLUMN_RHYTHMS`: gothic, roman, quattrocento, cinquecento, mannerist, neoclassical, gothic-revival, art-nouveau). Side-wall hang packs into the gaps between those keep-outs and **grows hall depth** when needed; columns are never moved. Windows also treat column keep-outs as occupied so they do not land on shafts. Regression: `server/__tests__/painting-column-clearance.test.js`.
|
||||||
|
|
||||||
|
Run `npm test` after touching any interior module, hall layout or tint: the rules above are covered by 15 suites under `server/__tests__/` that parse these source files directly — see [FAC.md](FAC.md) § Tests.
|
||||||
|
|
||||||
|
The same U-hang applies to artist halls and guided tours (artist halls have no period bay columns). 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 movement’s look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
|
||||||
|
|
||||||
## Historical event markers (frontend timeline)
|
## Historical event markers (frontend timeline)
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ Timeline header: **EN | RU** toggle (`LocaleSwitcher`).
|
|||||||
- Passes `?locale=ru` on catalog API requests
|
- Passes `?locale=ru` on catalog API requests
|
||||||
- Refetches bootstrap catalog when locale changes
|
- Refetches bootstrap catalog when locale changes
|
||||||
|
|
||||||
|
Timeline **layout** chrome and chart hints are also localised in `home.json`:
|
||||||
|
|
||||||
|
| Key | Where |
|
||||||
|
|-----|--------|
|
||||||
|
| `layoutHorizontal` / `layoutVertical` / `layoutTree` | Header layout switch |
|
||||||
|
| `captionClassicTimeline` / `captionClassicFlow` | Classic era bar + movement streams |
|
||||||
|
| `captionVerticalTimeline` / `captionVerticalFlow` | Vertical rail + streams |
|
||||||
|
| `captionTreeFlow` | Tree of Art chart |
|
||||||
|
|
||||||
|
Shareable layout URLs (`?layout=tree` etc.) are language-independent; captions follow the active locale.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Setup (dev)
|
## Setup (dev)
|
||||||
|
|||||||
@@ -82,6 +82,23 @@ Audit: `influence.create` / `update` / `delete` / `import` in `curator_audit_log
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Movement lineage links (related curator tool)
|
||||||
|
|
||||||
|
Top-left timeline control **Movement links** (also from Influences → **Movement links →**). Create/vote need `influences`; hard delete is **admin-only**. Tables `movement_lineage_links` + `movement_lineage_delete_votes`.
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| `GET` | `/api/movement-links` | Public list (timeline / tree); includes `delete_vote_count` |
|
||||||
|
| `POST` | `/api/movement-links` | Create (`sourceMovementId`, `targetMovementId`, `proofUrl` required) |
|
||||||
|
| `POST` | `/api/movement-links/:id/delete-votes` | Curator vote to delete (`reason` required, min 10 chars) |
|
||||||
|
| `GET` | `/api/movement-links/:id/delete-votes` | List votes (username + reason) for admin review |
|
||||||
|
| `DELETE` | `/api/movement-links/:id/delete-votes/me` | Withdraw own vote |
|
||||||
|
| `DELETE` | `/api/movement-links/:id` | **Admin only** — hard delete |
|
||||||
|
|
||||||
|
See [data-and-images.md](data-and-images.md#movement-lineage-timeline--tree-of-art).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## CLI still available
|
## CLI still available
|
||||||
|
|
||||||
- `npm run dev:update-influences` — curated [`scripts/art-influences-data.js`](../scripts/art-influences-data.js)
|
- `npm run dev:update-influences` — curated [`scripts/art-influences-data.js`](../scripts/art-influences-data.js)
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# 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: top-left **Movement links** (required proof URL) →
|
||||||
|
`movement_lineage_links` / `GET /api/movement-links`. Curators vote to delete;
|
||||||
|
admins hard-delete. Static fallback remains in
|
||||||
|
[`client/src/data/movement-lineage.ts`](../client/src/data/movement-lineage.ts).
|
||||||
|
The tree picks up parents, grafts, thickness and spacing from the active lineage automatically.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Node.js** 20+
|
- **Node.js** 20+ (verified on 24.19; `npm test` relies on `node --test` expanding `**` itself, which older majors may not do)
|
||||||
- **PostgreSQL** reachable from the app host
|
- **PostgreSQL** reachable from the app host
|
||||||
- Network access to Wikipedia / Wikimedia (for biographies, seeding, and on-demand image fetch)
|
- Network access to Wikipedia / Wikimedia (for biographies, seeding, and on-demand image fetch)
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ If migration fails with permission errors, grant schema rights to the app user f
|
|||||||
|
|
||||||
`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`.
|
`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.
|
After migrate, sign in from the site header (**Curator login**). Debug mode, Checkup, Translations, Influences, **Movement links** (top-left), Tour editor, and all mutating debug APIs require an active curator session (Movement links opens a login gate for guests). Anonymous visitors browse the timeline, published Tours, and 3D halls without logging in. `dev:migrate` also creates `movement_lineage_links` / `movement_lineage_delete_votes` and seeds default lineage when the table is empty.
|
||||||
|
|
||||||
See [API.md — Authentication](API.md#authentication) and [basics.md — Developer tools](basics.md#developer-tools-image-audit).
|
See [API.md — Authentication](API.md#authentication) and [basics.md — Developer tools](basics.md#developer-tools-image-audit).
|
||||||
|
|
||||||
@@ -107,6 +107,7 @@ Image fetch can take hours if you run it for the entire catalog. The first line
|
|||||||
|
|
||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
|
| `npm test` | **Test suite** — 15 suites / 41 tests on Node’s built-in runner; no database or server needed |
|
||||||
| `npm run dev:web` | **Public dev stack** — Vite `:5173`, API `:3451` (Keenetic → devgallery…) |
|
| `npm run dev:web` | **Public dev stack** — Vite `:5173`, API `:3451` (Keenetic → devgallery…) |
|
||||||
| `npm run prod:docker:publish` | Build + push prod image to Gitea |
|
| `npm run prod:docker:publish` | Build + push prod image to Gitea |
|
||||||
| `npm run devtoprod:images` | Copy `data/images/` → TrueNAS via SMB `Gallery` share |
|
| `npm run devtoprod:images` | Copy `data/images/` → TrueNAS via SMB `Gallery` share |
|
||||||
@@ -120,7 +121,7 @@ Image fetch can take hours if you run it for the entire catalog. The first line
|
|||||||
| `npm run dev:server` | API with nodemon reload (local HMR workflow) |
|
| `npm run dev:server` | API with nodemon reload (local HMR workflow) |
|
||||||
| `npm run dev:client` | Vite dev server on :5173 |
|
| `npm run dev:client` | Vite dev server on :5173 |
|
||||||
|
|
||||||
See [environments.md](environments.md) for dev/prod URLs, database split, Docker deploy, and sync commands. For Russian UI + catalog text, see [i18n-russian.md](i18n-russian.md). Influence link import/CRUD: [influence-import.md](influence-import.md). Quick reference: [FAC.md](FAC.md).
|
See [environments.md](environments.md) for dev/prod URLs, database split, Docker deploy, and sync commands. For Russian UI + catalog text, see [i18n-russian.md](i18n-russian.md). Timeline layouts and Tree of Art: [basics.md](basics.md#timeline-and-movement-flow), [movement-tree.md](movement-tree.md). UI behaviour standards: [ui-interaction-and-component-standards.md](ui-interaction-and-component-standards.md). Influence link import/CRUD: [influence-import.md](influence-import.md). Quick reference: [FAC.md](FAC.md).
|
||||||
|
|
||||||
**Production frontend:** build the client, then start the server:
|
**Production frontend:** build the client, then start the server:
|
||||||
|
|
||||||
@@ -239,6 +240,8 @@ After clone: copy `.env.example` → `.env`, install dependencies, run [one-time
|
|||||||
| Empty timeline | DB not seeded | `npm run dev:seed` |
|
| Empty timeline | DB not seeded | `npm run dev:seed` |
|
||||||
| 502 / 504 on public URL | Keenetic rule wrong (IP, port, or `https` to device) | Dev → `192.168.10.70:5173`; prod → `192.168.10.122:5173`; protocol **`http`** — see [environments.md](environments.md) |
|
| 502 / 504 on public URL | Keenetic rule wrong (IP, port, or `https` to device) | Dev → `192.168.10.70:5173`; prod → `192.168.10.122:5173`; protocol **`http`** — see [environments.md](environments.md) |
|
||||||
| 503 on public URL | Dev servers not running | `npm run dev:web` (or `dev:server` + `dev:client` for local HMR) |
|
| 503 on public URL | Dev servers not running | `npm run dev:web` (or `dev:server` + `dev:client` for local HMR) |
|
||||||
|
| `/api` proxy errors on localhost while Vite works | Shell `PORT=5173` makes API bind the same port as Vite | Set `PORT=3451` in `.env` or `DEV_API_PORT=3451`; restart `npm run dev:web` |
|
||||||
|
| Timeline opens classic after bookmark | Wrong or missing `?layout=` | Use `?layout=tree` or `?layout=vertical`; classic omits the param or uses `?layout=classic` — see [basics.md](basics.md#timeline-and-movement-flow) |
|
||||||
| **`manifest unknown`** on TrueNAS deploy | Image not in Gitea | `npm run prod:docker:publish` on dev PC first |
|
| **`manifest unknown`** on TrueNAS deploy | Image not in Gitea | `npm run prod:docker:publish` on dev PC first |
|
||||||
| 500 on all `/api/*` | Wrong `.env` or Postgres down | Check connection, logs |
|
| 500 on all `/api/*` | Wrong `.env` or Postgres down | Check connection, logs |
|
||||||
| “Biographical information not yet available” | Bios not fetched | `npm run dev:fetch-artist-bios` |
|
| “Biographical information not yet available” | Bios not fetched | `npm run dev:fetch-artist-bios` |
|
||||||
|
|||||||
@@ -0,0 +1,527 @@
|
|||||||
|
# UI Interaction and Component Standards
|
||||||
|
|
||||||
|
**Subject:** Virtual Art Gallery — screen and interaction guidance
|
||||||
|
**Applies to:** Public visitor UI, 3D halls, curator tools
|
||||||
|
**Companion rule:** `.cursor/rules/ui-interaction-standards.mdc`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Introduction
|
||||||
|
|
||||||
|
The Gallery is a single React SPA (`HomePage.tsx` view union — no URL router except `?layout=`). Public visitors browse a museum-dark timeline and 3D halls; curators use the same shell for catalog tools (Checkup, Translations, Influences, Tours, Users, Activity). Work lands in the same header, palette, and back-stack, so a new screen that invents its own chrome, confirmations, or loading pattern fragments the experience.
|
||||||
|
|
||||||
|
This document is the shared contract for **how the UI behaves**. Architecture, APIs, and 3D hall construction live in [basics.md](basics.md), [API.md](API.md), and [data-and-images.md](data-and-images.md). Tree geometry lives in [movement-tree.md](movement-tree.md). Locale strings live in [i18n-russian.md](i18n-russian.md).
|
||||||
|
|
||||||
|
### 0.1 Purpose and audience
|
||||||
|
|
||||||
|
- **Purpose:** Define common UI interaction, navigation, and component standards for the Gallery client.
|
||||||
|
- **Primary audience:** Developers (and agents) adding or changing `client/src` UI; anyone writing curator-tool screens.
|
||||||
|
- **Secondary audience:** QA, product, copy/i18n.
|
||||||
|
|
||||||
|
### 0.2 Scope
|
||||||
|
|
||||||
|
- **In scope:**
|
||||||
|
- Navigation and back-stack behaviour
|
||||||
|
- Timeline / search / 3D / detail / curator-tool interaction patterns
|
||||||
|
- Loading, empty, error, and confirmation behaviour
|
||||||
|
- Permission-based UI (`can()` / RBAC)
|
||||||
|
- Shared component and visual-language rules
|
||||||
|
- **Out of scope:**
|
||||||
|
- REST contracts, retries, and image pipeline internals
|
||||||
|
- Three.js hall architecture, textures, and lighting (see [basics.md](basics.md))
|
||||||
|
- Pixel-perfect branding kit (no separate design-system package; follow existing CSS)
|
||||||
|
|
||||||
|
### 0.3 Requirement levels
|
||||||
|
|
||||||
|
- **MUST:** Mandatory for new work and for fixes that touch the same screen.
|
||||||
|
- **SHOULD:** Recommended; deviate only with a short note in the PR or screen section.
|
||||||
|
- **MAY:** Optional pattern when the screen specification calls for it.
|
||||||
|
|
||||||
|
### 0.4 Surface map
|
||||||
|
|
||||||
|
| Surface | Typical components | Visitors | Curators |
|
||||||
|
|---------|--------------------|----------|----------|
|
||||||
|
| Timeline home | `Timeline`, `VerticalTimeline`, `MovementBands`, `VerticalMovementBands`, `MovementTree`, `CatalogSearchBar` | yes | yes |
|
||||||
|
| 3D hall | `VirtualGallery`, wing navigator, exit overlays | yes | yes |
|
||||||
|
| Painting / bio | `PaintingDetail`, `PaintingLightbox`, `ArtistBio`, `PaintingAnnotations` | yes | + debug panel when `can('images')` |
|
||||||
|
| Overlays | `CuratorLoginModal`, `ArtistFilterModal`, `ToursPopup`, `DebugSearchResultsModal` | some | all |
|
||||||
|
| Curator tools | `CheckupPage`, `TranslationsPage`, `InfluencesPage`, `ToursPage`, `UsersPage`, `AuditPage` | no | permission-gated |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Design Goals and Principles
|
||||||
|
|
||||||
|
### 1.1 Problem statement
|
||||||
|
|
||||||
|
- Statement: Visitors and curators share one shell. Inconsistent back labels, ad-hoc modals, English-only captions, and one-off loading/error treatment make the museum feel like several apps glued together.
|
||||||
|
- Scope: All `client/src` screens and overlays.
|
||||||
|
- Rationale: The product is a gallery, not an admin console with a visitor skin. Predictable chrome is part of the exhibit.
|
||||||
|
- Verification: Compare a new screen’s header, back control, loading marker, and locale keys against this document.
|
||||||
|
|
||||||
|
### 1.2 Functional design principles
|
||||||
|
|
||||||
|
- Principle 1: One museum, two roles
|
||||||
|
- Statement: Public browse chrome MUST stay museum-dark (navy / gold / Georgia). Curator tools MAY be denser but MUST reuse the same header back pattern, gold accent, and permission hiding — they MUST NOT look like a separate product.
|
||||||
|
- Rationale: Curators enter from the same timeline; a visual cliff breaks trust.
|
||||||
|
- Verification: Side-by-side with timeline header and Checkup / Tours editor.
|
||||||
|
|
||||||
|
- Principle 2: Predictable drill-down and return
|
||||||
|
- Statement: Navigation MUST follow Timeline → (movement picker) → hall → painting/bio, with an explicit in-app Back that restores the intended session — not the browser history stack.
|
||||||
|
- Rationale: `HomePage` owns view state; the browser Back button is not wired.
|
||||||
|
- Verification: Walk the [basics.md navigation flow](basics.md#user-navigation-flow) and the Back table in §2.4.
|
||||||
|
|
||||||
|
- Principle 3: Clarity of actions and feedback
|
||||||
|
- Statement: Every user-initiated load, save, delete, or failed request MUST show a loading, success, or error state the user can see without opening the console.
|
||||||
|
- Rationale: 3D and image work is slow; silent failure looks like a broken hall.
|
||||||
|
- Verification: Trigger catalog load, hall open, form save, and a failed API call.
|
||||||
|
|
||||||
|
- Principle 4: Minimize cognitive load
|
||||||
|
- Statement: Timeline charts MUST keep zoom/pan/click hints visible. Curator tables MUST put filter/search above the grid. Destructive actions MUST be confirmed.
|
||||||
|
- Rationale: Dense history data and catalog tables are easy to mis-click.
|
||||||
|
- Verification: Hint captions present; filters above tables; delete paths show a confirm.
|
||||||
|
|
||||||
|
- Principle 5: Locale and permission are first-class
|
||||||
|
- Statement: New visitor-facing copy MUST go through `react-i18next` (`locales/{en,ru}`). Actions the user cannot perform MUST be hidden, not disabled-without-explanation.
|
||||||
|
- Rationale: EN/RU is a product requirement; exposing forbidden tools invites errors.
|
||||||
|
- Verification: Toggle EN|RU; log in as a curator without the relevant `can()` and confirm the control is absent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Navigation Principles
|
||||||
|
|
||||||
|
### 2.1 Application chrome (no left module rail)
|
||||||
|
|
||||||
|
The Gallery has **no persistent left navigation menu**. Primary wayfinding is the **timeline header** on home, and an explicit **Back** control on every nested view.
|
||||||
|
|
||||||
|
- Statement: The timeline home MUST keep layout switch, catalog search, locale switcher, and role-appropriate tools in the site header.
|
||||||
|
- Scope: `HomePage` when `view` is `timeline` / `timeline-vertical` / `timeline-tree`.
|
||||||
|
- Rationale: Visitors need search and layout without hunting; curators need tools without leaving the museum frame.
|
||||||
|
- Verification: Header remains usable at 100vh; search dropdown stacks above movement bands (`z-index` on `.site-header`).
|
||||||
|
|
||||||
|
- Statement: Layout switch links MUST stay in the top-left (`.site-layout-switch`). The Tree of Art control SHOULD use `.site-layout-link-feature`.
|
||||||
|
- Scope: Timeline home.
|
||||||
|
- Rationale: Three layouts must stay discoverable; Tree is the featured alternative start page.
|
||||||
|
- Verification: Classic / Vertical / Tree links match [basics.md](basics.md#timeline-and-movement-flow).
|
||||||
|
|
||||||
|
- Statement: Nested views (hall, painting, bio, curator pages) MUST NOT reintroduce a second global nav. They MUST show a single primary Back control in the page header.
|
||||||
|
- Scope: All non-home views.
|
||||||
|
- Rationale: Avoid competing menus; the drill-down is the nav.
|
||||||
|
- Verification: No duplicate “home” plus “modules” rails on curator pages.
|
||||||
|
|
||||||
|
### 2.2 Page hierarchy
|
||||||
|
|
||||||
|
- Statement: Screens MUST stay within this hierarchy (max four levels):
|
||||||
|
|
||||||
|
1. Timeline home (classic / vertical / tree)
|
||||||
|
2. Overlay or picker (artist filter, tours popup, login) **or** curator tool page
|
||||||
|
3. 3D hall (artist / movement / tour)
|
||||||
|
4. Painting detail or artist bio (optional lightbox on top of detail)
|
||||||
|
|
||||||
|
- Scope: All visitor and curator flows.
|
||||||
|
- Rationale: Matches the existing drill-down; deeper stacks become unrecoverable without a router.
|
||||||
|
- Verification: New views are added to the `View` union in `HomePage.tsx` with a defined parent and Back handler.
|
||||||
|
|
||||||
|
- Statement: Curator tools MUST open as siblings of the timeline (replace the home canvas), not as a fifth level under a hall.
|
||||||
|
- Scope: Checkup, Translations, Influences, Tours editor, Users, Activity.
|
||||||
|
- Rationale: Tools operate on the catalog, not on a hall session.
|
||||||
|
- Verification: Opening Checkup from a hall is not required; from timeline header, Back returns to timeline home.
|
||||||
|
|
||||||
|
### 2.3 Location awareness (no breadcrumbs)
|
||||||
|
|
||||||
|
- Statement: The app MUST NOT add a breadcrumb trail unless a future router lands. Until then, the Back label MUST name the destination (`← Back to Timeline`, `← Back to Gallery`, `← Back`).
|
||||||
|
- Scope: All nested views.
|
||||||
|
- Rationale: There is no URL path to reflect; a fake breadcrumb would lie.
|
||||||
|
- Verification: Labels match §2.4; they are i18n keys (`backToTimeline`, `backToGallery`, …).
|
||||||
|
|
||||||
|
- Statement: Timeline layout MUST be deep-linkable via `?layout=classic|vertical|tree` (omit param for classic). Other views MUST NOT pretend to be bookmarkable until a router exists.
|
||||||
|
- Scope: Timeline home.
|
||||||
|
- Rationale: Layout is the one shareable start-page choice; halls and tools are session state.
|
||||||
|
- Verification: Load `?layout=tree`, switch layouts, confirm `history.replaceState` updates the query.
|
||||||
|
|
||||||
|
### 2.4 Back navigation
|
||||||
|
|
||||||
|
| Control | MUST return to |
|
||||||
|
|---------|----------------|
|
||||||
|
| **← Back to Timeline** (hall header, movement Exit to Timeline, painting opened from search) | Home timeline via `goToTimelineHome()` — unmount hall, clear session, reset year window to full catalog bounds |
|
||||||
|
| **← Back to Gallery** (painting from a hall) | Same hall session (camera / wing preserved) |
|
||||||
|
| **← Back** (artist bio) | `returnTo` view (usually the hall that opened bio) |
|
||||||
|
| Curator page Back | Timeline home |
|
||||||
|
|
||||||
|
- Statement: Back MUST be an in-app control. The browser Back button MUST NOT be relied on (it is not wired to `View` state).
|
||||||
|
- Scope: All nested views.
|
||||||
|
- Rationale: `setView` is the router.
|
||||||
|
- Verification: From painting detail, in-app Back restores the hall; browser Back does not need to.
|
||||||
|
|
||||||
|
- Statement: `goToTimelineHome()` MUST be the single implementation for “leave everything and show the timeline.” New exits MUST call it rather than duplicating reset logic.
|
||||||
|
- Scope: Halls, search-opened paintings, curator Back.
|
||||||
|
- Rationale: Year-range reset and session clear must stay consistent.
|
||||||
|
- Verification: After Exit to Timeline, `viewStart`/`viewEnd` equal catalog bounds.
|
||||||
|
|
||||||
|
### 2.5 State preservation
|
||||||
|
|
||||||
|
- Statement: Timeline pan/zoom (`viewStart` / `viewEnd`) MUST persist while the user stays on a timeline layout. Switching classic ↔ vertical ↔ tree MUST keep the same year window.
|
||||||
|
- Scope: Timeline home.
|
||||||
|
- Rationale: Layout is a lens, not a new dataset.
|
||||||
|
- Verification: Zoom, switch to Tree, confirm the year rail range is unchanged.
|
||||||
|
|
||||||
|
- Statement: Returning to timeline via `goToTimelineHome()` MUST reset the year window to full catalog bounds.
|
||||||
|
- Scope: Hall / search / curator exits that call `goToTimelineHome()`.
|
||||||
|
- Rationale: Documented in [basics.md](basics.md#back-navigation); visitors expect a fresh overview, not a leftover zoom.
|
||||||
|
- Verification: Zoom in, enter a hall, Back to Timeline → full span.
|
||||||
|
|
||||||
|
- Statement: Hall camera and wing MUST be preserved across **Back to Gallery** from painting detail. They MUST be discarded on **Back to Timeline**.
|
||||||
|
- Scope: Artist, movement, and tour halls.
|
||||||
|
- Rationale: Inspecting a painting is a detour; leaving the museum is not.
|
||||||
|
- Verification: Move in the hall, open a painting, Back to Gallery → same viewpoint.
|
||||||
|
|
||||||
|
- Statement: Catalog search input MAY clear when the dropdown closes. It MUST NOT change timeline zoom by itself.
|
||||||
|
- Scope: `CatalogSearchBar`.
|
||||||
|
- Rationale: Search is a jump, not a filter on the chart.
|
||||||
|
- Verification: Type a query, Escape; year window unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Common UI Interaction Patterns
|
||||||
|
|
||||||
|
### 3.1 Catalog search (visitor)
|
||||||
|
|
||||||
|
- Statement: Timeline search MUST live in the header, require **2** trimmed characters, debounce **300 ms**, and group results into Artists, Movements, Paintings.
|
||||||
|
- Scope: `CatalogSearchBar.tsx`.
|
||||||
|
- Rationale: Documented product behaviour; keeps `/api/search` load reasonable.
|
||||||
|
- Verification: 1 character shows no fetch; 2+ after debounce shows groups.
|
||||||
|
|
||||||
|
- Statement: Keyboard MUST support `↑`/`↓` highlight, `Enter` to open, `Escape` to close.
|
||||||
|
- Scope: Catalog search dropdown.
|
||||||
|
- Rationale: Timeline is pointer-heavy; search should still be keyboardable.
|
||||||
|
- Verification: Keyboard-only open of an artist, movement, and painting.
|
||||||
|
|
||||||
|
- Statement: Opening a painting from search MUST set `returnTo` timeline so Back is **← Back to Timeline**, not Gallery.
|
||||||
|
- Scope: Search → painting detail.
|
||||||
|
- Rationale: There is no hall session.
|
||||||
|
- Verification: Search a title, open, Back → home timeline.
|
||||||
|
|
||||||
|
### 3.2 Timeline charts (zoom, pan, click)
|
||||||
|
|
||||||
|
- Statement: Scroll MUST zoom, drag MUST pan, click on a stream/limb/label MUST open the movement (artist filter → hall). All three layouts MUST use `zoomTimelineView` / `panTimelineView`.
|
||||||
|
- Scope: `MovementBands`, `VerticalMovementBands`, `MovementTree`, era rails.
|
||||||
|
- Rationale: Shared year window; one mental model.
|
||||||
|
- Verification: Same wheel/drag behaviour on classic, vertical, and tree.
|
||||||
|
|
||||||
|
- Statement: Each chart MUST show a localised hint caption (`captionClassicTimeline`, `captionClassicFlow`, `captionVerticalTimeline`, `captionVerticalFlow`, `captionTreeFlow`).
|
||||||
|
- Scope: Timeline home.
|
||||||
|
- Rationale: First-time visitors cannot discover zoom/pan otherwise.
|
||||||
|
- Verification: EN and RU captions change with `LocaleSwitcher`.
|
||||||
|
|
||||||
|
- Statement: Timeline layout shifts (lane packing, tree fit scale) SHOULD animate rather than snap.
|
||||||
|
- Scope: Movement charts.
|
||||||
|
- Rationale: Unexplained jumps look like bugs.
|
||||||
|
- Verification: Zoom/pan does not teleport streams.
|
||||||
|
|
||||||
|
### 3.3 Filters and search (curator tables)
|
||||||
|
|
||||||
|
- Statement: Filter/search controls MUST sit in a toolbar **above** the table, not in a column header hack or a page footer.
|
||||||
|
- Scope: Checkup, Translations, Influences, Tours editor, Users, Activity.
|
||||||
|
- Rationale: Matches Checkup (`checkup-toolbar`) and keeps the grid scannable.
|
||||||
|
- Verification: Filters remain visible while the table scrolls.
|
||||||
|
|
||||||
|
- Statement: Simple text filters MAY apply as the user types. Expensive operations (image search, import, refetch) MUST require an explicit button (e.g. Checkup **Search visible**).
|
||||||
|
- Scope: Curator list screens.
|
||||||
|
- Rationale: Checkup search is rate-limited and slow; typing must not fire it.
|
||||||
|
- Verification: Typing in Checkup filter does not start image search.
|
||||||
|
|
||||||
|
- Statement: When a filter hides rows, the toolbar SHOULD show how many rows are visible (e.g. `N shown`).
|
||||||
|
- Scope: Filtered tables.
|
||||||
|
- Rationale: Empty-looking tables need an explanation.
|
||||||
|
- Verification: Filter to zero rows → empty state plus count.
|
||||||
|
|
||||||
|
### 3.4 Date selection
|
||||||
|
|
||||||
|
- Statement: Year fields in the catalog and timeline MUST use numeric years (negative = BCE). They MUST NOT switch to locale-specific calendar widgets for historical BCE dates.
|
||||||
|
- Scope: Timeline bounds, artist lifespan, painting years, curator year filters.
|
||||||
|
- Rationale: The catalog spans −800 to the present; HTML date inputs cannot represent BCE.
|
||||||
|
- Verification: Ancient era still filters correctly.
|
||||||
|
|
||||||
|
- Statement: If a future screen needs a civil date (e.g. audit log day), it SHOULD use ISO `YYYY-MM-DD` and validate start ≤ end for ranges.
|
||||||
|
- Scope: Activity / audit and any new timestamp filters.
|
||||||
|
- Rationale: Consistent with API timestamps; avoids DD/MM ambiguity.
|
||||||
|
- Verification: Invalid range shows a field-level message.
|
||||||
|
|
||||||
|
### 3.5 Tables and lists (curator)
|
||||||
|
|
||||||
|
- Statement: Structured curator datasets MUST use a labeled HTML table (or existing page table classes), one logical record per row.
|
||||||
|
- Scope: Checkup, Users, Influences worklists, Tours list, Translations worklist, Activity.
|
||||||
|
- Rationale: Comparison and row actions need columns, not cards.
|
||||||
|
- Verification: Column headers present; row click/action affects one record.
|
||||||
|
|
||||||
|
- Statement: Visitor-facing catalog MUST NOT be presented as a spreadsheet. Timeline streams, tree limbs, and 3D hangs are the list metaphor.
|
||||||
|
- Scope: Public home and halls.
|
||||||
|
- Rationale: The product is a gallery, not a DAM table.
|
||||||
|
- Verification: No “all paintings” data grid on the public home.
|
||||||
|
|
||||||
|
- Statement: Tables MAY omit pagination while the dataset is curator-sized and client-filtered. If a list grows past comfortable scrolling, it SHOULD paginate or virtualise rather than rendering thousands of DOM rows.
|
||||||
|
- Scope: Curator tools.
|
||||||
|
- Rationale: Checkup is already filter-then-scroll; unbounded paint is a future foot-gun.
|
||||||
|
- Verification: New tools with large lists have a documented paging or virtualisation plan.
|
||||||
|
|
||||||
|
- Statement: Row actions MUST sit in a dedicated column or overflow control, not as random icons in every cell.
|
||||||
|
- Scope: Interactive curator tables.
|
||||||
|
- Rationale: Scanability.
|
||||||
|
- Verification: Action column or consistent button set per row.
|
||||||
|
|
||||||
|
### 3.6 Multi-row and per-record actions
|
||||||
|
|
||||||
|
- Statement: Bulk actions MUST use a leading checkbox column, Select All for **visible** rows only, and a confirmation that includes the affected count for destructive work.
|
||||||
|
- Scope: Any new bulk-enabled table. (Today: ArtistFilterModal multi-select is a picker, not a bulk delete.)
|
||||||
|
- Rationale: Same as the Logistics template; prevent silent mass edits.
|
||||||
|
- Verification: Select All does not imply “all matching in the database” unless explicitly labelled.
|
||||||
|
|
||||||
|
- Statement: Artist filter before a movement hall MUST be a modal checklist with explicit proceed/cancel, not a bulk-edit of the catalog.
|
||||||
|
- Scope: `ArtistFilterModal`.
|
||||||
|
- Rationale: It only chooses who appears in the hall.
|
||||||
|
- Verification: Cancel leaves the user on the timeline; proceed opens the hall.
|
||||||
|
|
||||||
|
- Statement: Primary row/object action SHOULD be the name/title (open painting, open user, open tour). Secondary actions SHOULD stay in the row’s action controls.
|
||||||
|
- Scope: Curator tables and search results.
|
||||||
|
- Rationale: Matches search-result click-to-open.
|
||||||
|
- Verification: Clicking a Checkup title opens the painting when that handler exists.
|
||||||
|
|
||||||
|
- Statement: Unavailable-by-permission actions MUST be hidden. Unavailable-by-record-state SHOULD be disabled with a `title`/tooltip explaining why.
|
||||||
|
- Scope: All tools.
|
||||||
|
- Rationale: RBAC vs workflow are different signals.
|
||||||
|
- Verification: Non-admin does not see Users; a disabled Fix button states why.
|
||||||
|
|
||||||
|
### 3.7 Forms
|
||||||
|
|
||||||
|
- Statement: Short auth and picker flows MUST use a modal. Multi-section catalog editors (Users create/edit, Tours editor, Influences wizard, Translations worklist) MUST be full-page (or the existing page layout), not nested modals.
|
||||||
|
- Scope: All forms.
|
||||||
|
- Rationale: Halls and timeline need to stay the “place”; heavy edit needs space.
|
||||||
|
- Verification: Login is modal; Users is a page.
|
||||||
|
|
||||||
|
- Statement: Forms SHOULD be a single column. Related fields MAY group under a heading when there are more than five inputs.
|
||||||
|
- Scope: Curator forms.
|
||||||
|
- Rationale: Scanning beats dense multi-column on museum-width pages.
|
||||||
|
- Verification: Users create form remains vertically grouped.
|
||||||
|
|
||||||
|
- Statement: Required fields MUST use the native `required` attribute and/or a visible marker; validation errors MUST appear next to the field or as a form-level error the submit control does not obscure.
|
||||||
|
- Scope: Login, Users, Tours, Influences, Translations.
|
||||||
|
- Rationale: Silent submit-disable is not enough.
|
||||||
|
- Verification: Submit empty login → field or form error, not a blank modal.
|
||||||
|
|
||||||
|
- Statement: After successful save, the system MUST show an inline success message (or equivalent) and keep the user on the tool unless the spec says to return to timeline.
|
||||||
|
- Scope: Curator mutations.
|
||||||
|
- Rationale: Users page already uses `message` / `error` banners.
|
||||||
|
- Verification: Save permissions → success text; failed save → error text.
|
||||||
|
|
||||||
|
- Statement: Unsaved-change guards SHOULD be added when a form is long enough that accidental Back would lose work (Tours editor, Translations). Login and tiny pickers MAY skip this.
|
||||||
|
- Scope: Heavy editors.
|
||||||
|
- Rationale: `window.confirm` on delete already exists; abandon-edit is the remaining hole.
|
||||||
|
- Verification: Dirty Tours editor + Back prompts or discards explicitly.
|
||||||
|
|
||||||
|
### 3.8 Edit interaction pattern selection
|
||||||
|
|
||||||
|
| Pattern | Use when |
|
||||||
|
|---------|----------|
|
||||||
|
| **Modal** | Login, artist filter, tours list popup, debug image picker, lightbox, hall exit/wing overlays |
|
||||||
|
| **Inline** | Checkup flags, debug **Checked** / **Fix it** on painting detail — small, reversible |
|
||||||
|
| **Full page** | Curator tools, painting detail, artist bio, 3D hall |
|
||||||
|
| **Drawer** | MUST NOT be introduced unless a spec adds a shared drawer component |
|
||||||
|
|
||||||
|
- Statement: Nested modals MUST NOT be used (no modal opened from another modal). The debug “More” picker MAY stack on painting detail because detail is a full page, not a modal.
|
||||||
|
- Scope: All overlays.
|
||||||
|
- Rationale: Focus traps and Back labels break.
|
||||||
|
- Verification: Login does not open another dialog.
|
||||||
|
|
||||||
|
### 3.9 Modals and overlays
|
||||||
|
|
||||||
|
- Statement: Modals MUST use `role="dialog"` and `aria-modal="true"`, a visible close/cancel, and **Escape** to dismiss unless a submit is in flight.
|
||||||
|
- Scope: `CuratorLoginModal`, `ArtistFilterModal`, `ToursPopup`, `DebugSearchResultsModal`, `PaintingLightbox`, hall exit overlays.
|
||||||
|
- Rationale: Accessibility and parity with search.
|
||||||
|
- Verification: Esc closes lightbox and debug picker; backdrop click matches existing login behaviour.
|
||||||
|
|
||||||
|
- Statement: Backdrop click MAY close pickers and login. It MUST NOT close a modal that is applying a destructive or long-running action.
|
||||||
|
- Scope: Overlays.
|
||||||
|
- Rationale: Accidental dismiss during Fix/upload is costly.
|
||||||
|
- Verification: Click outside login closes; do not dismiss mid-upload.
|
||||||
|
|
||||||
|
### 3.10 Loading, empty, and error states
|
||||||
|
|
||||||
|
- Statement: Catalog, portrait, and hall loads MUST use `GalleryLoadingMarker` (overlay or banner), not an ad-hoc spinner per screen unless the marker cannot cover the region.
|
||||||
|
- Scope: Home, halls, painting/bio image work.
|
||||||
|
- Rationale: One recognisable “the museum is fetching” treatment.
|
||||||
|
- Verification: First visit shows “Loading art history…”; hall open uses the same marker family.
|
||||||
|
|
||||||
|
- Statement: Loading SHOULD be scoped to the affected region. Full-viewport overlay MUST be used only when the user cannot usefully interact (first catalog load, hall WebGL init).
|
||||||
|
- Scope: All loads.
|
||||||
|
- Rationale: Portrait banner vs full-page overlay already follows this.
|
||||||
|
- Verification: Timeline remains visible while “Loading portraits…” banners.
|
||||||
|
|
||||||
|
- Statement: Empty datasets MUST explain themselves (e.g. vertical flow: no movements in range; search: no matches; Checkup: no rows for filter).
|
||||||
|
- Scope: Charts, search, tables.
|
||||||
|
- Rationale: Blank gold-on-navy reads as a crash.
|
||||||
|
- Verification: Zoom to a year with no movements; search a nonsense string.
|
||||||
|
|
||||||
|
- Statement: Failed loads MUST set a visible error string (`error-banner`, form error, or page error) — never `console.error` alone.
|
||||||
|
- Scope: All data-fetching views.
|
||||||
|
- Rationale: Visitors have no console.
|
||||||
|
- Verification: Stop the API and confirm home shows a load failure message.
|
||||||
|
|
||||||
|
### 3.11 3D hall interaction
|
||||||
|
|
||||||
|
- Statement: Halls MUST keep **← Back to Timeline**, pointer-lock / click-to-move as already implemented, and **E** (or documented key) for exit/wing navigation. New hall UI MUST not steal those keys without updating this section.
|
||||||
|
- Scope: `VirtualGallery`.
|
||||||
|
- Rationale: Muscle memory across artist, movement, and tour halls.
|
||||||
|
- Verification: Same Back label and exit overlay pattern in all three hall kinds.
|
||||||
|
|
||||||
|
- Statement: Clicking a framed painting MUST open painting detail with `returnTo` the current hall. Missing images MUST show the draped-canvas placeholder, not a broken `<img>`.
|
||||||
|
- Scope: Halls.
|
||||||
|
- Rationale: Documented in [basics.md](basics.md).
|
||||||
|
- Verification: Work without a file still shows a frame cover.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Behavioral Standards (Screen UX)
|
||||||
|
|
||||||
|
### 4.1 Permission-based rendering (RBAC)
|
||||||
|
|
||||||
|
- Statement: Header tools and debug controls MUST render only when `can('<permission>')` (and login for curator). Anonymous visitors MUST see browse + Tours popup + locale, not Checkup/Users/etc.
|
||||||
|
- Scope: `HomePage` header, painting/bio debug panel.
|
||||||
|
- Rationale: Security UX: hide, don’t tease.
|
||||||
|
- Verification: Logged-out home; curator without `users` cannot open Users.
|
||||||
|
|
||||||
|
- Statement: Route-like views that require a role MUST show the existing “Curator access required” panel with login and back-to-gallery actions — they MUST NOT render an empty privileged page.
|
||||||
|
- Scope: Checkup, Translations, Influences, Tours editor, Users, Activity.
|
||||||
|
- Rationale: Deep view state can still be set; the gate must hold.
|
||||||
|
- Verification: Set view to Users while logged out → curator required copy.
|
||||||
|
|
||||||
|
- Statement: The same permission MUST hide the same action on every surface (header, painting debug, API). Do not leave a visible button that 403s.
|
||||||
|
- Scope: All mutations.
|
||||||
|
- Rationale: Predictable roles.
|
||||||
|
- Verification: `can('images')` off → no debug panel and no Fix buttons.
|
||||||
|
|
||||||
|
### 4.2 Notifications and user feedback
|
||||||
|
|
||||||
|
- Statement: Curator mutations MUST show success or error text in the page’s existing banner/message area. The app has no global toast system; new screens MUST NOT invent a third notification widget without replacing this standard.
|
||||||
|
- Scope: Curator pages.
|
||||||
|
- Rationale: Users/Influences already use inline `message` / `error`.
|
||||||
|
- Verification: One visual treatment per page, consistent placement under the header.
|
||||||
|
|
||||||
|
- Statement: Visitor-facing destructive actions (painting **Remove entry** in debug) MUST confirm and then show failure inline if the API rejects.
|
||||||
|
- Scope: Debug painting tools.
|
||||||
|
- Rationale: Catalog deletes are irreversible.
|
||||||
|
- Verification: Cancel confirm → no delete.
|
||||||
|
|
||||||
|
- Statement: Copy MUST be concise and, for errors, actionable (“Is the server running?”, “Sign in as a curator…”).
|
||||||
|
- Scope: All user-visible strings.
|
||||||
|
- Rationale: Support load.
|
||||||
|
- Verification: Home catalog failure string remains understandable.
|
||||||
|
|
||||||
|
### 4.3 Confirmation and cancellation
|
||||||
|
|
||||||
|
- Statement: Destructive curator actions (delete influence, delete tour, remove painting, deactivate user if offered) MUST confirm before the request. `window.confirm` with an i18n string is the current standard (`confirmDelete`); a shared modal MAY replace it later but MUST stay one pattern.
|
||||||
|
- Scope: Influences, Tours, painting remove, Users.
|
||||||
|
- Rationale: Accidental clicks on dense tables.
|
||||||
|
- Verification: Delete tour → confirm; Cancel → no API call.
|
||||||
|
|
||||||
|
- Statement: Cancel on a confirm MUST leave filters, selection, and unsaved fields unchanged.
|
||||||
|
- Scope: All confirms.
|
||||||
|
- Rationale: Context preservation.
|
||||||
|
- Verification: Filtered Checkup, cancel a destructive action → filter still applied.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Component Consistency Rules
|
||||||
|
|
||||||
|
### 5.1 Shared interaction pattern usage
|
||||||
|
|
||||||
|
- Statement: New UI MUST reuse existing components before creating parallels: `GalleryLoadingMarker`, `CatalogSearchBar`, `LocaleSwitcher`, `CuratorLoginModal`, `ArtistFilterModal`, `PaintingLightbox`, `DebugSearchResultsModal`, `DebugUploadButton`.
|
||||||
|
- Scope: `client/src`.
|
||||||
|
- Rationale: Duplicate spinners and dialogs already caused drift.
|
||||||
|
- Verification: PR does not add a second login modal or loading overlay.
|
||||||
|
|
||||||
|
- Statement: Movement colours MUST go through `utils/movementColor.ts` (`vividMovementColor`, `shadeMovementColor`). Hex parsing MUST tolerate `#rgb` / `#rrggbb` and keep the first six digits of longer values.
|
||||||
|
- Scope: Timeline charts and any movement swatch.
|
||||||
|
- Rationale: Classic, vertical, and tree already share this util.
|
||||||
|
- Verification: No local `parseHexColor` copies in components.
|
||||||
|
|
||||||
|
- Statement: Timeline zoom/pan MUST use `utils/timelineView.ts`. Tree horizontal layout MUST use `utils/movementTree.ts` (view-independent structure).
|
||||||
|
- Scope: Timeline surfaces.
|
||||||
|
- Rationale: Layouts share one year window.
|
||||||
|
- Verification: No one-off wheel handlers that bypass the util.
|
||||||
|
|
||||||
|
### 5.2 Component and style selection
|
||||||
|
|
||||||
|
- Statement: There is **no Ant Design / MUI**. UI MUST be React + colocated CSS (`ComponentName.tsx` + `ComponentName.css`). Global museum tokens SHOULD reuse existing values:
|
||||||
|
|
||||||
|
| Token | Typical value | Use |
|
||||||
|
|-------|----------------|-----|
|
||||||
|
| Gold | `#c9a96e` / `rgba(201, 169, 110, …)` | Accents, borders, links |
|
||||||
|
| Ink / navy | `#0f0f1a`, `#1a1a2e`, `#16213e` | Page background |
|
||||||
|
| Type | Georgia, serif | Titles, captions, layout links |
|
||||||
|
| Viewport | `100vh`, `overflow: hidden` on home | No document scroll for the museum shell |
|
||||||
|
|
||||||
|
- Scope: All client UI.
|
||||||
|
- Rationale: The look *is* the design system.
|
||||||
|
- Verification: New CSS does not introduce a bright Bootstrap theme.
|
||||||
|
|
||||||
|
- Statement: Prefer semantic `<button type="button">` for actions and native `<form>` for submits. Do not use `<div onClick>` for primary actions.
|
||||||
|
- Scope: All interactive chrome.
|
||||||
|
- Rationale: Keyboard and a11y.
|
||||||
|
- Verification: Header tools are buttons.
|
||||||
|
|
||||||
|
- Statement: Colocate styles; do not add a CSS-in-JS runtime. Shared layout classes live in `HomePage.css` / page CSS, not inline theme objects.
|
||||||
|
- Scope: Client.
|
||||||
|
- Rationale: Matches the repo.
|
||||||
|
- Verification: New component ships a `.css` file or uses an existing one.
|
||||||
|
|
||||||
|
### 5.3 i18n
|
||||||
|
|
||||||
|
- Statement: New user-visible chrome MUST add keys to `client/src/locales/en/*.json` and `ru/*.json` (namespace that matches the screen: `home`, `common`, `debug`, `users`, …). Hardcoded English is allowed only for curator-only screens that are not yet migrated, and those SHOULD be migrated when the screen is touched.
|
||||||
|
- Scope: All new copy.
|
||||||
|
- Rationale: [i18n-russian.md](i18n-russian.md).
|
||||||
|
- Verification: Locale toggle changes the new string.
|
||||||
|
|
||||||
|
- Statement: Catalog entities (names, titles, bios) MUST use API locale resolution, not a second client-side dictionary.
|
||||||
|
- Scope: Timeline labels, search, halls, detail.
|
||||||
|
- Rationale: `entity_translations` is canonical for RU catalog text.
|
||||||
|
- Verification: RU locale shows published translations.
|
||||||
|
|
||||||
|
### 5.4 Exception process
|
||||||
|
|
||||||
|
- Statement: Deviations (new overlay type, toast system, left nav, router, design library) MUST be documented in this file or in the feature’s `Documentation/*.md` with rationale before they spread to a second screen.
|
||||||
|
- Scope: Client-wide patterns.
|
||||||
|
- Rationale: One exception is an experiment; two without a write-up is fragmentation.
|
||||||
|
- Verification: PR description links the exception note.
|
||||||
|
|
||||||
|
- Statement: Known current exceptions (do not cargo-cult; fix when touching the file):
|
||||||
|
- Browser history is not a router.
|
||||||
|
- Only `?layout=` is deep-linked.
|
||||||
|
- Some curator pages (e.g. Checkup) still have English chrome.
|
||||||
|
- Some modals may still omit Escape (login); new modals MUST include it.
|
||||||
|
- Destructive confirms use `window.confirm` rather than a shared dialog component.
|
||||||
|
|
||||||
|
### 5.5 Versioning of behaviour
|
||||||
|
|
||||||
|
- Statement: Behaviour changes to shared patterns (Back, search debounce, layout query, loading marker) MUST update this document in the same change.
|
||||||
|
- Scope: Shared components and `HomePage` navigation.
|
||||||
|
- Rationale: Agents and humans use this as the spec.
|
||||||
|
- Verification: Doc diff accompanies the code diff.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Screen-Level Checklist
|
||||||
|
|
||||||
|
Use this before submitting a new view, overlay, or curator tool. Unchecked items need a note.
|
||||||
|
|
||||||
|
- [ ] Surface type is identified (timeline / hall / detail / overlay / curator page).
|
||||||
|
- [ ] Parent view and Back label/destination are defined (`returnTo` or `goToTimelineHome`).
|
||||||
|
- [ ] Permissions: controls hidden unless `can(…)` / signed in as required.
|
||||||
|
- [ ] Loading uses `GalleryLoadingMarker` or a justified scoped indicator.
|
||||||
|
- [ ] Empty and error states are visible copy, not a blank canvas.
|
||||||
|
- [ ] Destructive actions confirm; cancel leaves state unchanged.
|
||||||
|
- [ ] Visitor chrome strings are in `locales/{en,ru}`; catalog text uses API locale.
|
||||||
|
- [ ] No new CSS framework; gold/navy/Georgia; colocated CSS.
|
||||||
|
- [ ] Search/filter (if any) sits above the list; expensive work is explicit-button.
|
||||||
|
- [ ] Modals: `role="dialog"`, close control, Escape.
|
||||||
|
- [ ] Timeline work uses `timelineView` / `movementColor` / `movementTree` as applicable.
|
||||||
|
- [ ] Hall work preserves camera on Back to Gallery and resets on Back to Timeline.
|
||||||
|
- [ ] This document updated if a shared pattern changed.
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
# Art Gallery
|
# Art Gallery
|
||||||
|
|
||||||
Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, **curator-gated** debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**; painting detail also **Remove entry**), optional **Show more** auto-opens the search picker, Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. Anonymous visitors browse freely; curators sign in via **Curator login** in the header.
|
Interactive virtual art gallery: zoomable historical timeline (classic left→right, vertical, or **Tree of Art** lineage chart — shareable via `?layout=`) with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, **curator-gated** debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**; painting detail also **Remove entry**), optional **Show more** auto-opens the search picker, Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. Anonymous visitors browse freely; curators sign in via **Curator login** in the header.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
| Document | Purpose |
|
| Document | Purpose |
|
||||||
|----------|---------|
|
|----------|---------|
|
||||||
|
| [Documentation/Bible.md](Documentation/Bible.md) | Agent instructions (primary source for AI assistants) |
|
||||||
| [Documentation/basics.md](Documentation/basics.md) | Architecture, layout, user flow |
|
| [Documentation/basics.md](Documentation/basics.md) | Architecture, layout, user flow |
|
||||||
|
| [Documentation/ui-interaction-and-component-standards.md](Documentation/ui-interaction-and-component-standards.md) | UI interaction, navigation, and component standards |
|
||||||
|
| [Documentation/movement-tree.md](Documentation/movement-tree.md) | **Tree of Art** start page — tree layout rules |
|
||||||
| [Documentation/FAC.md](Documentation/FAC.md) | **Command cheat sheet** — start/stop, import, deploy |
|
| [Documentation/FAC.md](Documentation/FAC.md) | **Command cheat sheet** — start/stop, import, deploy |
|
||||||
| [Documentation/environments.md](Documentation/environments.md) | Dev/prod URLs, DB split, deploy, sync |
|
| [Documentation/environments.md](Documentation/environments.md) | Dev/prod URLs, DB split, deploy, sync |
|
||||||
| [Documentation/setup.md](Documentation/setup.md) | Install, env, npm scripts |
|
| [Documentation/setup.md](Documentation/setup.md) | Install, env, npm scripts |
|
||||||
@@ -83,6 +86,14 @@ Operator guide: [Documentation/environments.md](Documentation/environments.md).
|
|||||||
npm run dev:web # Vite :5173, API :3451
|
npm run dev:web # Vite :5173, API :3451
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Tests** (Node’s built-in runner — no database or server needed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # 15 suites, 41 tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Per [Documentation/Bible.md](Documentation/Bible.md), write the test before the code it covers.
|
||||||
|
|
||||||
**Local HMR:**
|
**Local HMR:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -773,6 +773,81 @@ export const api = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
listMovementLinks: (params: { q?: string; limit?: number; offset?: number } = {}) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.q) qs.set('q', params.q);
|
||||||
|
if (params.limit) qs.set('limit', String(params.limit));
|
||||||
|
if (params.offset) qs.set('offset', String(params.offset));
|
||||||
|
const q = qs.toString();
|
||||||
|
return fetchJson<{ items: MovementLineageLinkItem[]; total: number }>(
|
||||||
|
`${API}/movement-links${q ? `?${q}` : ''}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
createMovementLink: (payload: {
|
||||||
|
sourceMovementId: number;
|
||||||
|
targetMovementId: number;
|
||||||
|
proofUrl: string;
|
||||||
|
proofLabel?: string;
|
||||||
|
linkYearApprox?: number | null;
|
||||||
|
notes?: string;
|
||||||
|
}) =>
|
||||||
|
fetch(`${API}/movement-links`, {
|
||||||
|
...fetchCredentials,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}).then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Create failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<{ item: MovementLineageLinkItem }>;
|
||||||
|
}),
|
||||||
|
|
||||||
|
listMovementLinkDeleteVotes: (id: number) =>
|
||||||
|
fetchJson<{ linkId: number; votes: MovementLineageDeleteVote[] }>(
|
||||||
|
`${API}/movement-links/${id}/delete-votes`,
|
||||||
|
),
|
||||||
|
|
||||||
|
voteDeleteMovementLink: (id: number, reason: string) =>
|
||||||
|
fetch(`${API}/movement-links/${id}/delete-votes`, {
|
||||||
|
...fetchCredentials,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ reason }),
|
||||||
|
}).then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Vote failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<{ vote: MovementLineageDeleteVote }>;
|
||||||
|
}),
|
||||||
|
|
||||||
|
withdrawDeleteMovementLinkVote: (id: number) =>
|
||||||
|
fetch(`${API}/movement-links/${id}/delete-votes/me`, {
|
||||||
|
...fetchCredentials,
|
||||||
|
method: 'DELETE',
|
||||||
|
}).then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Withdraw failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<{ ok: boolean; id: number; linkId: number }>;
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteMovementLink: (id: number) =>
|
||||||
|
fetch(`${API}/movement-links/${id}`, {
|
||||||
|
...fetchCredentials,
|
||||||
|
method: 'DELETE',
|
||||||
|
}).then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Delete failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<{ ok: boolean; id: number }>;
|
||||||
|
}),
|
||||||
|
|
||||||
getInfluenceGraph: (params: { artistId?: number; paintingId?: number }) => {
|
getInfluenceGraph: (params: { artistId?: number; paintingId?: number }) => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params.artistId) qs.set('artistId', String(params.artistId));
|
if (params.artistId) qs.set('artistId', String(params.artistId));
|
||||||
@@ -1032,6 +1107,33 @@ export interface InfluenceEdgeItem {
|
|||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MovementLineageLinkItem {
|
||||||
|
id: number;
|
||||||
|
source_movement_id: number;
|
||||||
|
target_movement_id: number;
|
||||||
|
link_year_approx: number | null;
|
||||||
|
proof_url: string;
|
||||||
|
proof_label: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
source_name: string;
|
||||||
|
source_start_year: number;
|
||||||
|
target_name: string;
|
||||||
|
target_start_year: number;
|
||||||
|
delete_vote_count?: number;
|
||||||
|
viewer_has_delete_vote?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MovementLineageDeleteVote {
|
||||||
|
id: number;
|
||||||
|
link_id: number;
|
||||||
|
user_id: number;
|
||||||
|
reason: string;
|
||||||
|
created_at: string;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface InfluenceGraph {
|
export interface InfluenceGraph {
|
||||||
focus: { artistId: number; paintingId: number | null; label: string };
|
focus: { artistId: number; paintingId: number | null; label: string };
|
||||||
nodes: Array<{ id: string; type: string; label: string; focus?: boolean; artistId?: number; paintingId?: number; movementId?: number }>;
|
nodes: Array<{ id: string; type: string; label: string; focus?: boolean; artistId?: number; paintingId?: number; movementId?: number }>;
|
||||||
|
|||||||
@@ -61,11 +61,13 @@ function WindowFrame({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{/* Outer frame */}
|
{/* Outer frame — picture/ribbon draw their own thin reveals */}
|
||||||
|
{style !== 'picture' && style !== 'ribbon' && (
|
||||||
<mesh position={[0, 0, -depth / 2]}>
|
<mesh position={[0, 0, -depth / 2]}>
|
||||||
<boxGeometry args={[width + frameW * 2, height + frameW * 2, depth]} />
|
<boxGeometry args={[width + frameW * 2, height + frameW * 2, depth]} />
|
||||||
<meshStandardMaterial color={trimColor} roughness={0.35} metalness={0.45} />
|
<meshStandardMaterial color={trimColor} roughness={0.35} metalness={0.45} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
)}
|
||||||
|
|
||||||
{arch && style === 'gothic-lancet' && (
|
{arch && style === 'gothic-lancet' && (
|
||||||
<>
|
<>
|
||||||
@@ -180,6 +182,33 @@ function WindowFrame({
|
|||||||
<meshStandardMaterial color={trimColor} roughness={0.3} metalness={0.55} />
|
<meshStandardMaterial color={trimColor} roughness={0.3} metalness={0.55} />
|
||||||
</mesh>
|
</mesh>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Open picture window: thin dark reveal only — no mullion / iron-bar grid. */}
|
||||||
|
{style === 'picture' && (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0, -depth / 2 + 0.015]}>
|
||||||
|
<boxGeometry args={[width + frameW * 1.2, height + frameW * 1.2, depth * 0.7]} />
|
||||||
|
<meshStandardMaterial color="#1a1a1a" roughness={0.42} metalness={0.55} />
|
||||||
|
</mesh>
|
||||||
|
{/* Single offset L-accent (collage frame), not a barred grid */}
|
||||||
|
<mesh position={[-width * 0.28, height * 0.22, -depth / 2 + 0.04]}>
|
||||||
|
<boxGeometry args={[frameW * 0.7, height * 0.42, depth * 0.5]} />
|
||||||
|
<meshStandardMaterial color="#c8302a" roughness={0.55} metalness={0.15} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[-width * 0.08, height * 0.38, -depth / 2 + 0.04]}>
|
||||||
|
<boxGeometry args={[width * 0.36, frameW * 0.7, depth * 0.5]} />
|
||||||
|
<meshStandardMaterial color="#c8302a" roughness={0.55} metalness={0.15} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Open ribbon window: slim steel surround only — no bars. */}
|
||||||
|
{style === 'ribbon' && (
|
||||||
|
<mesh position={[0, 0, -depth / 2 + 0.015]}>
|
||||||
|
<boxGeometry args={[width + frameW * 1.1, height + frameW * 1.1, depth * 0.65]} />
|
||||||
|
<meshStandardMaterial color="#9aa0a8" roughness={0.35} metalness={0.72} />
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState, memo } from 'react';
|
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState, memo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { ArtMovement, Artist } from '../types';
|
import type { ArtMovement, Artist } from '../types';
|
||||||
import { portraitThumbUrl } from '../api/client';
|
import { portraitThumbUrl } from '../api/client';
|
||||||
import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc';
|
import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc';
|
||||||
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
import { getMovementLineage } from '../data/movement-lineage';
|
||||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||||
|
import { parseHexColor, rgbToHsl, hslToHex, vividMovementColor } from '../utils/movementColor';
|
||||||
import './MovementBands.css';
|
import './MovementBands.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -244,74 +246,6 @@ function buildArtistPlacements(
|
|||||||
return placements;
|
return placements;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseHexColor(hex: string): [number, number, number] {
|
|
||||||
const normalized = hex.replace('#', '');
|
|
||||||
const value =
|
|
||||||
normalized.length === 3
|
|
||||||
? normalized
|
|
||||||
.split('')
|
|
||||||
.map((c) => c + c)
|
|
||||||
.join('')
|
|
||||||
: normalized.padStart(6, '0').slice(0, 6);
|
|
||||||
return [
|
|
||||||
parseInt(value.slice(0, 2), 16),
|
|
||||||
parseInt(value.slice(2, 4), 16),
|
|
||||||
parseInt(value.slice(4, 6), 16),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
|
||||||
const rn = r / 255;
|
|
||||||
const gn = g / 255;
|
|
||||||
const bn = b / 255;
|
|
||||||
const max = Math.max(rn, gn, bn);
|
|
||||||
const min = Math.min(rn, gn, bn);
|
|
||||||
const l = (max + min) / 2;
|
|
||||||
if (max === min) return [0, 0, l];
|
|
||||||
const d = max - min;
|
|
||||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
||||||
let h = 0;
|
|
||||||
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
|
|
||||||
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
|
|
||||||
else h = ((rn - gn) / d + 4) / 6;
|
|
||||||
return [h * 360, s, l];
|
|
||||||
}
|
|
||||||
|
|
||||||
function hslToHex(h: number, s: number, l: number): string {
|
|
||||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
||||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
||||||
const m = l - c / 2;
|
|
||||||
let r = 0;
|
|
||||||
let g = 0;
|
|
||||||
let b = 0;
|
|
||||||
if (h < 60) [r, g, b] = [c, x, 0];
|
|
||||||
else if (h < 120) [r, g, b] = [x, c, 0];
|
|
||||||
else if (h < 180) [r, g, b] = [0, c, x];
|
|
||||||
else if (h < 240) [r, g, b] = [0, x, c];
|
|
||||||
else if (h < 300) [r, g, b] = [x, 0, c];
|
|
||||||
else [r, g, b] = [c, 0, x];
|
|
||||||
const toByte = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
|
||||||
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Boost saturation / mid lightness so streams read vividly on the dark flow canvas. */
|
|
||||||
function vividMovementColor(hex: string): string {
|
|
||||||
try {
|
|
||||||
const [r, g, b] = parseHexColor(hex);
|
|
||||||
const [h, s, l] = rgbToHsl(r, g, b);
|
|
||||||
const s2 = s < 0.1 ? Math.min(0.55, s + 0.42) : Math.min(1, s * 1.65 + 0.08);
|
|
||||||
const l2 =
|
|
||||||
l < 0.22
|
|
||||||
? 0.5
|
|
||||||
: l > 0.78
|
|
||||||
? 0.62
|
|
||||||
: Math.min(0.68, Math.max(0.4, l * 0.75 + 0.28));
|
|
||||||
return hslToHex(h, s2, l2);
|
|
||||||
} catch {
|
|
||||||
return hex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function artistLifespanColor(baseColor: string, laneIndex: number, laneCount: number): string {
|
function artistLifespanColor(baseColor: string, laneIndex: number, laneCount: number): string {
|
||||||
if (laneCount <= 1) return baseColor;
|
if (laneCount <= 1) return baseColor;
|
||||||
try {
|
try {
|
||||||
@@ -335,7 +269,7 @@ function buildLineageParentMap(
|
|||||||
const visibleIds = new Set(visible.map((m) => m.id));
|
const visibleIds = new Set(visible.map((m) => m.id));
|
||||||
const childToParents = new Map<number, number[]>();
|
const childToParents = new Map<number, number[]>();
|
||||||
|
|
||||||
for (const [parentName, childName] of MOVEMENT_LINEAGE) {
|
for (const [parentName, childName] of getMovementLineage()) {
|
||||||
const parentId = nameToId.get(parentName);
|
const parentId = nameToId.get(parentName);
|
||||||
const childId = nameToId.get(childName);
|
const childId = nameToId.get(childName);
|
||||||
if (!parentId || !childId) continue;
|
if (!parentId || !childId) continue;
|
||||||
@@ -1762,6 +1696,7 @@ export default function MovementBands({
|
|||||||
onArtistHover,
|
onArtistHover,
|
||||||
onPortraitsLoadingChange,
|
onPortraitsLoadingChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation('home');
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
const pendingPortraitsRef = useRef(0);
|
const pendingPortraitsRef = useRef(0);
|
||||||
const [portraitsLoading, setPortraitsLoading] = useState(false);
|
const [portraitsLoading, setPortraitsLoading] = useState(false);
|
||||||
@@ -2260,7 +2195,7 @@ export default function MovementBands({
|
|||||||
return (
|
return (
|
||||||
<div className="movements-flow">
|
<div className="movements-flow">
|
||||||
<p className="movements-flow-caption">
|
<p className="movements-flow-caption">
|
||||||
Scroll to zoom · drag to pan · each movement stream is a solid colour band through history
|
{t('captionClassicFlow')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,6 +1,39 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import * as THREE from 'three';
|
import type { MovementInteriorStyle, GalleryWindowSpec } from '../data/movement-interior-styles';
|
||||||
import type { MovementInteriorStyle } from '../data/movement-interior-styles';
|
import type { RoomDims } from './hall-details/geometry';
|
||||||
|
import { bayPositions } 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 {
|
interface Props {
|
||||||
style: MovementInteriorStyle;
|
style: MovementInteriorStyle;
|
||||||
@@ -8,108 +41,15 @@ interface Props {
|
|||||||
depth: number;
|
depth: number;
|
||||||
halfW: number;
|
halfW: number;
|
||||||
halfD: 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 }) {
|
function GothicDetails({ width, depth, halfW, halfD, trim }: Omit<Props, 'style' | 'windows'> & { trim: string }) {
|
||||||
const pilasterPositions = 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],
|
|
||||||
] 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]}>
|
|
||||||
<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 GothicDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
|
|
||||||
// Compound piers along the side walls, spaced by bay, each rising into the
|
// 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
|
// vault springer. Shafts stay flush against the wall (0.16 m proud) so they
|
||||||
// never intrude on the ~0.7 m frame hang margin.
|
// never intrude on the ~0.7 m frame hang margin.
|
||||||
const bays = useMemo(() => {
|
const bays = useMemo(() => bayPositions(depth, halfD, 4.2, 9), [depth, halfD]);
|
||||||
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;
|
const shaftHeight = 3.5;
|
||||||
|
|
||||||
@@ -292,102 +232,75 @@ function ByzantineDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NeoclassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
|
/**
|
||||||
// Shallow engaged corner pilasters only. Freestanding columns (~0.5m into the
|
* Period architecture for a movement hall.
|
||||||
// room) overlap the outermost frames — hang margin is only ~0.7m from each end.
|
*
|
||||||
const pilasters = useMemo(
|
* 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
|
||||||
[-halfW + 0.06, -halfD + 0.06],
|
* `hall-details/`, sharing the geometry budget documented in
|
||||||
[halfW - 0.06, -halfD + 0.06],
|
* `hall-details/primitives.tsx`.
|
||||||
[-halfW + 0.06, halfD - 0.06],
|
*/
|
||||||
[halfW - 0.06, halfD - 0.06],
|
export default function MovementHallDetails({ style, width, depth, halfW, halfD, windows }: Props) {
|
||||||
] as [number, number][],
|
|
||||||
[halfW, halfD]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
{pilasters.map(([x, z], i) => (
|
|
||||||
<group key={i} position={[x, 0, z]}>
|
|
||||||
<mesh position={[0, 1.85, 0]}>
|
|
||||||
<boxGeometry args={[0.12, 3.7, 0.12]} />
|
|
||||||
<meshStandardMaterial color="#f0ece8" roughness={0.72} metalness={0.12} />
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0, 3.78, 0]}>
|
|
||||||
<boxGeometry args={[0.18, 0.1, 0.18]} />
|
|
||||||
<meshStandardMaterial color={trim} roughness={0.4} metalness={0.35} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
))}
|
|
||||||
{/* Cornice above wall title (title sits ~y 3.75) */}
|
|
||||||
<mesh position={[0, 4.08, -halfD + 0.1]}>
|
|
||||||
<boxGeometry args={[Math.max(2.4, halfW * 2 - 0.5), 0.1, 0.16]} />
|
|
||||||
<meshStandardMaterial color={trim} roughness={0.45} metalness={0.3} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ClassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
|
|
||||||
// Engaged corner shafts only — anything proud of the corner pocket covers frames.
|
|
||||||
const columns = useMemo(
|
|
||||||
() =>
|
|
||||||
[
|
|
||||||
[-halfW + 0.06, -halfD + 0.06],
|
|
||||||
[halfW - 0.06, -halfD + 0.06],
|
|
||||||
[-halfW + 0.06, halfD - 0.06],
|
|
||||||
[halfW - 0.06, halfD - 0.06],
|
|
||||||
] as [number, number][],
|
|
||||||
[halfW, halfD]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
{columns.map(([x, z], i) => (
|
|
||||||
<group key={i} position={[x, 0, z]}>
|
|
||||||
<mesh position={[0, 2, 0]}>
|
|
||||||
<cylinderGeometry args={[0.08, 0.09, 4, 10]} />
|
|
||||||
<meshStandardMaterial color="#e0d8c8" roughness={0.88} />
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0, 4.05, 0]}>
|
|
||||||
<boxGeometry args={[0.18, 0.1, 0.18]} />
|
|
||||||
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.25} />
|
|
||||||
</mesh>
|
|
||||||
</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) {
|
|
||||||
const trim = style.tints.trim;
|
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) {
|
switch (style.details) {
|
||||||
case 'palazzo':
|
|
||||||
return <PalazzoDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
|
||||||
case 'baroque':
|
|
||||||
return <BaroqueDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
|
||||||
case 'gothic':
|
case 'gothic':
|
||||||
return <GothicDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
return <GothicDetails width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
||||||
case 'byzantine':
|
case 'byzantine':
|
||||||
return <ByzantineDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
return <ByzantineDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
||||||
|
case '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 <BaroqueStateDetails {...period} />;
|
||||||
|
case 'rococo':
|
||||||
|
return <RococoDetails {...period} />;
|
||||||
case 'neoclassical':
|
case 'neoclassical':
|
||||||
return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
return <NeoclassicalDetails {...period} />;
|
||||||
case 'classical':
|
case 'gothic-revival':
|
||||||
return <ClassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
return <GothicRevivalDetails {...period} />;
|
||||||
case 'salon':
|
case 'bourgeois':
|
||||||
return <SalonDetails trim={trim} />;
|
return <BourgeoisSalonDetails {...period} />;
|
||||||
default:
|
case 'north-light':
|
||||||
return null;
|
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} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
.mtree {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 8px 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-caption {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: italic;
|
||||||
|
color: rgba(201, 169, 110, 0.65);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(201, 169, 110, 0.6);
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-canvas {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 60% 45% at 50% 100%, rgba(201, 169, 110, 0.1), transparent 72%),
|
||||||
|
radial-gradient(ellipse 80% 60% at 50% 0%, rgba(120, 150, 190, 0.08), transparent 70%),
|
||||||
|
rgba(0, 0, 0, 0.28);
|
||||||
|
border: 1px solid rgba(201, 169, 110, 0.12);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-canvas.mtree-panning {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-svg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-limb {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.9;
|
||||||
|
transition: opacity 0.18s ease, filter 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-limb-lit {
|
||||||
|
opacity: 1;
|
||||||
|
filter: brightness(1.22) drop-shadow(0 0 6px rgba(255, 226, 170, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-limb-dim {
|
||||||
|
opacity: 0.34;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-root {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-graft {
|
||||||
|
opacity: 0.3;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-graft-lit {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-label {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 4;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
margin: 0;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(12, 12, 22, 0.74);
|
||||||
|
color: rgba(245, 230, 200, 0.95);
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: opacity 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-label:hover {
|
||||||
|
background: rgba(30, 28, 40, 0.92);
|
||||||
|
color: #fff6e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-label-dim {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-out-of-range {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(201, 169, 110, 0.7);
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { ArtMovement } from '../types';
|
||||||
|
import {
|
||||||
|
branchOriginYear,
|
||||||
|
buildMovementTree,
|
||||||
|
limbXAtYear,
|
||||||
|
type MovementTreeNode,
|
||||||
|
} from '../utils/movementTree';
|
||||||
|
import { shadeMovementColor, vividMovementColor } from '../utils/movementColor';
|
||||||
|
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||||
|
import './MovementTree.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
movements: ArtMovement[];
|
||||||
|
viewStart: number;
|
||||||
|
viewEnd: number;
|
||||||
|
absoluteMin: number;
|
||||||
|
absoluteMax: number;
|
||||||
|
onViewChange: (start: number, end: number) => void;
|
||||||
|
onMovementClick?: (movementId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pt {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIDE_PAD = 56;
|
||||||
|
/**
|
||||||
|
* Horizontal spread is tied to the zoom, not to the canvas.
|
||||||
|
*
|
||||||
|
* 90 % of the catalogue lives in the last 15 % of the time axis, so a tree
|
||||||
|
* stretched to full width with all of history in view is one long trunk under a
|
||||||
|
* flat bar. Instead the whole-history view draws a narrow tree, and every zoom
|
||||||
|
* step fans the crown out — the chart grows as you walk up it.
|
||||||
|
*/
|
||||||
|
const FULL_VIEW_WIDTH_SHARE = 0.52;
|
||||||
|
const ZOOM_SPREAD_EXPONENT = 0.45;
|
||||||
|
/** How far past "everything fits" the tree may be blown up when zoomed in. */
|
||||||
|
const MAX_FIT_BOOST = 2.4;
|
||||||
|
/** Exponential chase rate (1/s) for the horizontal fit, so zoom reads as growth. */
|
||||||
|
const FIT_ANIM_RATE = 9;
|
||||||
|
const RIBBON_SAMPLES = 18;
|
||||||
|
/** Vertical room a label needs. */
|
||||||
|
const MIN_LABEL_HEIGHT_PX = 20;
|
||||||
|
/**
|
||||||
|
* Readability floors. A 30-year movement is 8 px tall when the whole of
|
||||||
|
* history is on screen; without these the modern crown fuses into one bar.
|
||||||
|
*/
|
||||||
|
const MIN_LIMB_RISE_PX = 30;
|
||||||
|
const MIN_JUNCTION_RISE_PX = 38;
|
||||||
|
/** A limb is never drawn thicker than this share of its own length. */
|
||||||
|
const MAX_THICKNESS_OF_LENGTH = 0.55;
|
||||||
|
|
||||||
|
function cubicAt(p0: Pt, c1: Pt, c2: Pt, p3: Pt, t: number): Pt {
|
||||||
|
const u = 1 - t;
|
||||||
|
const a = u * u * u;
|
||||||
|
const b = 3 * u * u * t;
|
||||||
|
const c = 3 * u * t * t;
|
||||||
|
const d = t * t * t;
|
||||||
|
return {
|
||||||
|
x: a * p0.x + b * c1.x + c * c2.x + d * p3.x,
|
||||||
|
y: a * p0.y + b * c1.y + c * c2.y + d * p3.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cubicTangent(p0: Pt, c1: Pt, c2: Pt, p3: Pt, t: number): Pt {
|
||||||
|
const u = 1 - t;
|
||||||
|
const a = 3 * u * u;
|
||||||
|
const b = 6 * u * t;
|
||||||
|
const c = 3 * t * t;
|
||||||
|
return {
|
||||||
|
x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p3.x - c2.x),
|
||||||
|
y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filled ribbon of varying width along a cubic. Offsetting along the curve
|
||||||
|
* normal (rather than horizontally) keeps branch junctions solid even when a
|
||||||
|
* zoomed-out view squeezes them almost flat.
|
||||||
|
*/
|
||||||
|
function ribbonPath(p0: Pt, c1: Pt, c2: Pt, p3: Pt, w0: number, w1: number): string {
|
||||||
|
const left: Pt[] = [];
|
||||||
|
const right: Pt[] = [];
|
||||||
|
for (let i = 0; i <= RIBBON_SAMPLES; i++) {
|
||||||
|
const t = i / RIBBON_SAMPLES;
|
||||||
|
const p = cubicAt(p0, c1, c2, p3, t);
|
||||||
|
const d = cubicTangent(p0, c1, c2, p3, t);
|
||||||
|
const len = Math.hypot(d.x, d.y) || 1;
|
||||||
|
const nx = -d.y / len;
|
||||||
|
const ny = d.x / len;
|
||||||
|
const half = (w0 + (w1 - w0) * t) / 2;
|
||||||
|
left.push({ x: p.x + nx * half, y: p.y + ny * half });
|
||||||
|
right.push({ x: p.x - nx * half, y: p.y - ny * half });
|
||||||
|
}
|
||||||
|
const fmt = (pt: Pt) => `${pt.x.toFixed(2)} ${pt.y.toFixed(2)}`;
|
||||||
|
const forward = left.map((pt, i) => `${i === 0 ? 'M' : 'L'} ${fmt(pt)}`).join(' ');
|
||||||
|
const back = right
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((pt) => `L ${fmt(pt)}`)
|
||||||
|
.join(' ');
|
||||||
|
return `${forward} ${back} Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LimbShape {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
shade: string;
|
||||||
|
/** Trunk / limb body. */
|
||||||
|
d: string;
|
||||||
|
/** Junction ribbon growing out of the structural parent (may be empty). */
|
||||||
|
junction: string;
|
||||||
|
/** Rounded tip cap. */
|
||||||
|
tip: Pt & { r: number };
|
||||||
|
/** Root flare under a tree root, drawn only when the base is on screen. */
|
||||||
|
roots: string[];
|
||||||
|
labelX: number;
|
||||||
|
labelY: number;
|
||||||
|
labelVisible: boolean;
|
||||||
|
yearRange: string;
|
||||||
|
depth: number;
|
||||||
|
inView: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraftShape {
|
||||||
|
key: string;
|
||||||
|
d: string;
|
||||||
|
color: string;
|
||||||
|
fromId: number;
|
||||||
|
toId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatYear(year: number): string {
|
||||||
|
return year < 0 ? `${Math.abs(year)} BCE` : `${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Greedy declutter: closer to the trunk wins. Every visible movement asks for a
|
||||||
|
* name, and the ones that would collide with an already-placed name — or fall
|
||||||
|
* off the canvas — stay anonymous until you zoom in on them.
|
||||||
|
*/
|
||||||
|
function hideOverlappingLabels(limbs: LimbShape[], width: number, height: number): void {
|
||||||
|
const placed: { x0: number; y0: number; x1: number; y1: number }[] = [];
|
||||||
|
const candidates = limbs
|
||||||
|
.map((limb, index) => ({ limb, index }))
|
||||||
|
.filter(({ limb }) => limb.labelVisible)
|
||||||
|
.sort((a, b) => a.limb.depth - b.limb.depth || b.limb.labelY - a.limb.labelY);
|
||||||
|
|
||||||
|
for (const { limb } of candidates) {
|
||||||
|
const halfW = (limb.name.length * 6.6 + 16) / 2;
|
||||||
|
const halfH = MIN_LABEL_HEIGHT_PX / 2;
|
||||||
|
const box = {
|
||||||
|
x0: limb.labelX - halfW,
|
||||||
|
y0: limb.labelY - halfH,
|
||||||
|
x1: limb.labelX + halfW,
|
||||||
|
y1: limb.labelY + halfH,
|
||||||
|
};
|
||||||
|
const offCanvas = box.x0 < 2 || box.x1 > width - 2 || box.y0 < 2 || box.y1 > height - 2;
|
||||||
|
const collides = placed.some(
|
||||||
|
(p) => box.x0 < p.x1 && box.x1 > p.x0 && box.y0 < p.y1 && box.y1 > p.y0
|
||||||
|
);
|
||||||
|
if (offCanvas || collides) {
|
||||||
|
limb.labelVisible = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
placed.push(box);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MovementTree({
|
||||||
|
movements,
|
||||||
|
viewStart,
|
||||||
|
viewEnd,
|
||||||
|
absoluteMin,
|
||||||
|
absoluteMax,
|
||||||
|
onViewChange,
|
||||||
|
onMovementClick,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation('home');
|
||||||
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [canvas, setCanvas] = useState({ w: 900, h: 600 });
|
||||||
|
const [panning, setPanning] = useState(false);
|
||||||
|
const [hoveredId, setHoveredId] = useState<number | null>(null);
|
||||||
|
const [fitScale, setFitScale] = useState(1);
|
||||||
|
const panStart = useRef({ y: 0, viewStart: 0, viewEnd: 0 });
|
||||||
|
const viewRef = useRef({ viewStart, viewEnd });
|
||||||
|
const onViewChangeRef = useRef(onViewChange);
|
||||||
|
const fitRef = useRef(1);
|
||||||
|
const fitReadyRef = useRef(false);
|
||||||
|
const fitTargetRef = useRef(1);
|
||||||
|
const fitRafRef = useRef<number | null>(null);
|
||||||
|
const fitLastTsRef = useRef(0);
|
||||||
|
viewRef.current = { viewStart, viewEnd };
|
||||||
|
onViewChangeRef.current = onViewChange;
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = canvasRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const measure = () => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (rect.width > 0 && rect.height > 0) {
|
||||||
|
setCanvas({ w: Math.round(rect.width), h: Math.round(rect.height) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Structure is catalogue-wide and view-independent: zooming must not reshape
|
||||||
|
// the tree, only travel along it.
|
||||||
|
const tree = useMemo(() => buildMovementTree(movements), [movements]);
|
||||||
|
|
||||||
|
const visibleIds = useMemo(() => {
|
||||||
|
const ids = new Set<number>();
|
||||||
|
for (const node of tree.nodes.values()) {
|
||||||
|
const { start_year: s, end_year: e } = node.movement;
|
||||||
|
if (e > viewStart && s < viewEnd) ids.add(node.movement.id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}, [tree, viewStart, viewEnd]);
|
||||||
|
|
||||||
|
const targetScale = useMemo(() => {
|
||||||
|
const usable = Math.max(240, canvas.w - SIDE_PAD * 2);
|
||||||
|
const fitAll = usable / (2 * Math.max(1, tree.halfSpan));
|
||||||
|
const visibleSpan = Math.max(1, viewEnd - viewStart);
|
||||||
|
const totalSpan = Math.max(visibleSpan, absoluteMax - absoluteMin);
|
||||||
|
const zoomSpread = Math.pow(totalSpan / visibleSpan, ZOOM_SPREAD_EXPONENT);
|
||||||
|
const spread = Math.min(
|
||||||
|
MAX_FIT_BOOST,
|
||||||
|
Math.max(FULL_VIEW_WIDTH_SHARE, FULL_VIEW_WIDTH_SHARE * zoomSpread)
|
||||||
|
);
|
||||||
|
|
||||||
|
let visibleHalfSpan = 0;
|
||||||
|
for (const id of visibleIds) {
|
||||||
|
const node = tree.nodes.get(id);
|
||||||
|
if (!node) continue;
|
||||||
|
visibleHalfSpan = Math.max(
|
||||||
|
visibleHalfSpan,
|
||||||
|
Math.abs(node.x) + Math.abs(node.lean) + node.baseWidth / 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Never let what is on screen spill off the canvas.
|
||||||
|
const overflowCap = visibleHalfSpan > 0 ? usable / (2 * visibleHalfSpan) : Infinity;
|
||||||
|
return Math.min(fitAll * spread, overflowCap);
|
||||||
|
}, [canvas.w, tree, visibleIds, viewStart, viewEnd, absoluteMin, absoluteMax]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
fitTargetRef.current = targetScale;
|
||||||
|
if (!fitReadyRef.current) {
|
||||||
|
// First measured layout — adopt it instead of animating in from nothing.
|
||||||
|
fitReadyRef.current = true;
|
||||||
|
fitRef.current = targetScale;
|
||||||
|
setFitScale(targetScale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fitRafRef.current != null) return;
|
||||||
|
|
||||||
|
fitLastTsRef.current = performance.now();
|
||||||
|
const step = (now: number) => {
|
||||||
|
const dt = Math.min(0.05, Math.max(0, (now - fitLastTsRef.current) / 1000));
|
||||||
|
fitLastTsRef.current = now;
|
||||||
|
const t = 1 - Math.exp(-FIT_ANIM_RATE * dt);
|
||||||
|
const next = fitRef.current + (fitTargetRef.current - fitRef.current) * t;
|
||||||
|
if (Math.abs(fitTargetRef.current - next) < 0.002) {
|
||||||
|
fitRef.current = fitTargetRef.current;
|
||||||
|
setFitScale(fitTargetRef.current);
|
||||||
|
fitRafRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fitRef.current = next;
|
||||||
|
setFitScale(next);
|
||||||
|
fitRafRef.current = requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
fitRafRef.current = requestAnimationFrame(step);
|
||||||
|
}, [targetScale]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (fitRafRef.current != null) cancelAnimationFrame(fitRafRef.current);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { limbs, grafts } = useMemo(() => {
|
||||||
|
const span = viewEnd - viewStart || 1;
|
||||||
|
const centerX = canvas.w / 2;
|
||||||
|
const widthScale = Math.min(1.7, Math.max(0.55, fitScale));
|
||||||
|
const sx = (treeX: number) => centerX + treeX * fitScale;
|
||||||
|
const sy = (year: number) => canvas.h - ((year - viewStart) / span) * canvas.h;
|
||||||
|
|
||||||
|
const limbList: LimbShape[] = [];
|
||||||
|
const graftList: GraftShape[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pass 1 — screen geometry per movement.
|
||||||
|
*
|
||||||
|
* Two readability floors apply here, and only here: the structure and the
|
||||||
|
* dates stay untouched. A limb is drawn at least `MIN_LIMB_RISE_PX` long,
|
||||||
|
* and never thicker than it is long, so 2 500 years of trunk and 30 years
|
||||||
|
* of Fauvism can share one linear axis without the modern crown fusing
|
||||||
|
* into a solid bar.
|
||||||
|
*/
|
||||||
|
const drawn = new Map<
|
||||||
|
number,
|
||||||
|
{ base: Pt; c1: Pt; c2: Pt; tip: Pt; wBase: number; wTip: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const id of tree.drawOrder) {
|
||||||
|
const node = tree.nodes.get(id)!;
|
||||||
|
const { start_year: start, end_year: end } = node.movement;
|
||||||
|
if (end <= start) continue;
|
||||||
|
|
||||||
|
const base: Pt = { x: sx(limbXAtYear(node, start)), y: sy(start) };
|
||||||
|
const trueTipY = sy(end);
|
||||||
|
const tip: Pt = {
|
||||||
|
x: sx(limbXAtYear(node, end)),
|
||||||
|
y: Math.min(trueTipY, base.y - MIN_LIMB_RISE_PX),
|
||||||
|
};
|
||||||
|
const lengthPx = Math.hypot(tip.x - base.x, tip.y - base.y);
|
||||||
|
const cap = Math.max(3, lengthPx * MAX_THICKNESS_OF_LENGTH);
|
||||||
|
const wBase = Math.min(node.baseWidth * widthScale, cap);
|
||||||
|
const wTip = Math.min(node.tipWidth * widthScale, cap * 0.82);
|
||||||
|
const dy = tip.y - base.y;
|
||||||
|
drawn.set(id, {
|
||||||
|
base,
|
||||||
|
c1: { x: base.x, y: base.y + dy * 0.42 },
|
||||||
|
c2: { x: tip.x, y: tip.y - dy * 0.34 },
|
||||||
|
tip,
|
||||||
|
wBase,
|
||||||
|
wTip,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point on a drawn limb at the screen height closest to `targetY`. */
|
||||||
|
const pointOnLimb = (parentId: number, targetY: number) => {
|
||||||
|
const p = drawn.get(parentId)!;
|
||||||
|
const total = p.base.y - p.tip.y || 1;
|
||||||
|
const t = Math.min(1, Math.max(0, (p.base.y - targetY) / total));
|
||||||
|
return {
|
||||||
|
pt: cubicAt(p.base, p.c1, p.c2, p.tip, t),
|
||||||
|
width: p.wBase + (p.wTip - p.wBase) * t,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pass 2 — ribbons.
|
||||||
|
for (const id of tree.drawOrder) {
|
||||||
|
const node = tree.nodes.get(id)!;
|
||||||
|
const shape = drawn.get(id);
|
||||||
|
if (!shape) continue;
|
||||||
|
const { base, c1, c2, tip, wBase, wTip } = shape;
|
||||||
|
const { start_year: start, end_year: end } = node.movement;
|
||||||
|
|
||||||
|
const color = vividMovementColor(node.movement.color);
|
||||||
|
const d = ribbonPath(base, c1, c2, tip, wBase, wTip);
|
||||||
|
|
||||||
|
// Junction: the limb grows out of its parent a little before its own
|
||||||
|
// date, and always climbs far enough to read as a fork.
|
||||||
|
let junction = '';
|
||||||
|
const parent = node.parentId != null ? tree.nodes.get(node.parentId) : null;
|
||||||
|
if (parent && drawn.has(parent.movement.id)) {
|
||||||
|
const byDate = sy(branchOriginYear(parent, node));
|
||||||
|
const origin = pointOnLimb(
|
||||||
|
parent.movement.id,
|
||||||
|
Math.max(byDate, base.y + MIN_JUNCTION_RISE_PX)
|
||||||
|
);
|
||||||
|
const from = origin.pt;
|
||||||
|
const jdy = base.y - from.y;
|
||||||
|
const jLength = Math.hypot(base.x - from.x, jdy);
|
||||||
|
const jCap = Math.max(3, jLength * MAX_THICKNESS_OF_LENGTH);
|
||||||
|
const wFrom = Math.min(origin.width * 0.92, wBase * 1.25, jCap);
|
||||||
|
const jc1: Pt = { x: from.x, y: from.y + jdy * 0.45 };
|
||||||
|
const jc2: Pt = { x: base.x, y: base.y - jdy * 0.45 };
|
||||||
|
junction = ribbonPath(from, jc1, jc2, base, wFrom, Math.min(wBase, jCap));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roots: only the bottom of a tree, and only when that bottom is in frame.
|
||||||
|
const roots: string[] = [];
|
||||||
|
if (!parent && base.y > -canvas.h && base.y < canvas.h * 2) {
|
||||||
|
const flare = Math.max(22, wBase * 1.4);
|
||||||
|
for (const dir of [-1, -0.35, 0.35, 1]) {
|
||||||
|
const endPt: Pt = { x: base.x + dir * flare, y: base.y + flare * 0.72 };
|
||||||
|
const rc1: Pt = { x: base.x + dir * flare * 0.2, y: base.y + flare * 0.34 };
|
||||||
|
const rc2: Pt = { x: base.x + dir * flare * 0.8, y: base.y + flare * 0.5 };
|
||||||
|
roots.push(ribbonPath(base, rc1, rc2, endPt, wBase * 0.42, 1.5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inView = visibleIds.has(id);
|
||||||
|
const clampedStart = Math.max(start, viewStart);
|
||||||
|
const clampedEnd = Math.min(end, viewEnd);
|
||||||
|
const midYear = (clampedStart + clampedEnd) / 2;
|
||||||
|
const labelY = Math.min(
|
||||||
|
Math.max(sy(midYear), tip.y + MIN_LABEL_HEIGHT_PX / 2),
|
||||||
|
base.y
|
||||||
|
);
|
||||||
|
|
||||||
|
limbList.push({
|
||||||
|
id,
|
||||||
|
name: node.movement.name,
|
||||||
|
color,
|
||||||
|
shade: shadeMovementColor(color),
|
||||||
|
d,
|
||||||
|
junction,
|
||||||
|
tip: { x: tip.x, y: tip.y, r: Math.max(1.5, wTip / 2) },
|
||||||
|
roots,
|
||||||
|
labelX: sx(limbXAtYear(node, midYear)),
|
||||||
|
labelY,
|
||||||
|
labelVisible: inView,
|
||||||
|
yearRange: `${formatYear(start)} – ${formatYear(end)}`,
|
||||||
|
depth: node.depth,
|
||||||
|
inView,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const graftId of node.graftParentIds) {
|
||||||
|
const graftParent = tree.nodes.get(graftId);
|
||||||
|
if (!graftParent || !drawn.has(graftId)) continue;
|
||||||
|
const byDate = sy(branchOriginYear(graftParent, node));
|
||||||
|
const origin = pointOnLimb(graftId, Math.max(byDate, base.y + MIN_JUNCTION_RISE_PX));
|
||||||
|
const from = origin.pt;
|
||||||
|
const gdy = base.y - from.y;
|
||||||
|
const gWidth = Math.max(2.5, Math.min(wBase * 0.3, 9));
|
||||||
|
graftList.push({
|
||||||
|
key: `${graftId}-${id}`,
|
||||||
|
d: ribbonPath(
|
||||||
|
from,
|
||||||
|
{ x: from.x, y: from.y + gdy * 0.55 },
|
||||||
|
{ x: base.x, y: base.y - gdy * 0.3 },
|
||||||
|
base,
|
||||||
|
gWidth * 0.7,
|
||||||
|
gWidth
|
||||||
|
),
|
||||||
|
color: vividMovementColor(graftParent.movement.color),
|
||||||
|
fromId: graftId,
|
||||||
|
toId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideOverlappingLabels(limbList, canvas.w, canvas.h);
|
||||||
|
return { limbs: limbList, grafts: graftList };
|
||||||
|
}, [tree, viewStart, viewEnd, canvas.w, canvas.h, fitScale, visibleIds]);
|
||||||
|
|
||||||
|
/** A hovered movement lights up its whole descent line back to the root. */
|
||||||
|
const lineageIds = useMemo(() => {
|
||||||
|
const ids = new Set<number>();
|
||||||
|
if (hoveredId == null) return ids;
|
||||||
|
let cursor: number | null = hoveredId;
|
||||||
|
let guard = 0;
|
||||||
|
while (cursor != null && guard++ < 64) {
|
||||||
|
ids.add(cursor);
|
||||||
|
const node: MovementTreeNode | undefined = tree.nodes.get(cursor);
|
||||||
|
if (!node) break;
|
||||||
|
for (const graftId of node.graftParentIds) ids.add(graftId);
|
||||||
|
cursor = node.parentId;
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}, [hoveredId, tree]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = canvasRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const { viewStart: vs, viewEnd: ve } = viewRef.current;
|
||||||
|
// Bottom = oldest, so invert the pointer offset before reusing the shared
|
||||||
|
// left-to-right zoom math.
|
||||||
|
const invertedY = rect.height - (e.clientY - rect.top);
|
||||||
|
const next = zoomTimelineView(
|
||||||
|
invertedY,
|
||||||
|
0,
|
||||||
|
rect.height,
|
||||||
|
e.deltaY,
|
||||||
|
vs,
|
||||||
|
ve,
|
||||||
|
absoluteMin,
|
||||||
|
absoluteMax
|
||||||
|
);
|
||||||
|
onViewChangeRef.current(next.start, next.end);
|
||||||
|
};
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false, capture: true });
|
||||||
|
return () => el.removeEventListener('wheel', onWheel, { capture: true });
|
||||||
|
}, [absoluteMin, absoluteMax]);
|
||||||
|
|
||||||
|
const handlePanStart = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setPanning(true);
|
||||||
|
panStart.current = { y: e.clientY, viewStart, viewEnd };
|
||||||
|
},
|
||||||
|
[viewStart, viewEnd]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!panning) return;
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
const dy = e.clientY - panStart.current.y;
|
||||||
|
const next = panTimelineView(
|
||||||
|
-dy,
|
||||||
|
rect.height,
|
||||||
|
panStart.current.viewStart,
|
||||||
|
panStart.current.viewEnd,
|
||||||
|
absoluteMin,
|
||||||
|
absoluteMax
|
||||||
|
);
|
||||||
|
onViewChangeRef.current(next.start, next.end);
|
||||||
|
};
|
||||||
|
const onUp = () => setPanning(false);
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
}, [panning, absoluteMin, absoluteMax]);
|
||||||
|
|
||||||
|
if (movements.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="mtree-empty">
|
||||||
|
<p>No art movements to grow a tree from yet.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const anyInView = limbs.some((limb) => limb.inView);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mtree">
|
||||||
|
<p className="mtree-caption">
|
||||||
|
{t('captionTreeFlow')}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
ref={canvasRef}
|
||||||
|
className={`mtree-canvas${panning ? ' mtree-panning' : ''}`}
|
||||||
|
onMouseDown={handlePanStart}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="mtree-svg"
|
||||||
|
viewBox={`0 0 ${canvas.w} ${canvas.h}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
{limbs.map((limb) => (
|
||||||
|
<linearGradient
|
||||||
|
key={`grad-${limb.id}`}
|
||||||
|
id={`mtree-limb-${limb.id}`}
|
||||||
|
gradientUnits="objectBoundingBox"
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="1"
|
||||||
|
y2="0"
|
||||||
|
>
|
||||||
|
<stop offset="0%" stopColor={limb.shade} />
|
||||||
|
<stop offset="45%" stopColor={limb.color} />
|
||||||
|
<stop offset="100%" stopColor={limb.shade} />
|
||||||
|
</linearGradient>
|
||||||
|
))}
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g className="mtree-grafts">
|
||||||
|
{grafts.map((graft) => (
|
||||||
|
<path
|
||||||
|
key={graft.key}
|
||||||
|
d={graft.d}
|
||||||
|
fill={graft.color}
|
||||||
|
className={`mtree-graft${
|
||||||
|
lineageIds.has(graft.toId) && lineageIds.has(graft.fromId)
|
||||||
|
? ' mtree-graft-lit'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{limbs.map((limb) => {
|
||||||
|
const lit = lineageIds.has(limb.id);
|
||||||
|
const dim = hoveredId != null && !lit;
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={limb.id}
|
||||||
|
className={`mtree-limb${lit ? ' mtree-limb-lit' : ''}${
|
||||||
|
dim ? ' mtree-limb-dim' : ''
|
||||||
|
}`}
|
||||||
|
onMouseEnter={() => setHoveredId(limb.id)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onMovementClick?.(limb.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<title>{`${limb.name} · ${limb.yearRange}`}</title>
|
||||||
|
{limb.roots.map((d, i) => (
|
||||||
|
<path key={`root-${i}`} d={d} fill={limb.shade} className="mtree-root" />
|
||||||
|
))}
|
||||||
|
{limb.junction && (
|
||||||
|
<path d={limb.junction} fill={`url(#mtree-limb-${limb.id})`} />
|
||||||
|
)}
|
||||||
|
<path d={limb.d} fill={`url(#mtree-limb-${limb.id})`} />
|
||||||
|
<circle cx={limb.tip.x} cy={limb.tip.y} r={limb.tip.r} fill={limb.color} />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{limbs
|
||||||
|
.filter((limb) => limb.labelVisible)
|
||||||
|
.map((limb) => (
|
||||||
|
<button
|
||||||
|
key={`label-${limb.id}`}
|
||||||
|
type="button"
|
||||||
|
className={`mtree-label${
|
||||||
|
hoveredId != null && !lineageIds.has(limb.id) ? ' mtree-label-dim' : ''
|
||||||
|
}`}
|
||||||
|
style={{ left: `${limb.labelX}px`, top: `${limb.labelY}px` }}
|
||||||
|
title={`${limb.name} · ${limb.yearRange}`}
|
||||||
|
onMouseEnter={() => setHoveredId(limb.id)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onMovementClick?.(limb.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{limb.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!anyInView && (
|
||||||
|
<div className="mtree-out-of-range">
|
||||||
|
<p>No movements in this time range — zoom out to see the whole tree.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { HistoricalEra } from '../types';
|
import type { HistoricalEra } from '../types';
|
||||||
import {
|
import {
|
||||||
HISTORICAL_EVENTS,
|
HISTORICAL_EVENTS,
|
||||||
@@ -38,6 +39,7 @@ function formatYear(year: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight }: Props) {
|
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight }: Props) {
|
||||||
|
const { t } = useTranslation('home');
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [dragging, setDragging] = useState<'left' | 'right' | 'pan' | null>(null);
|
const [dragging, setDragging] = useState<'left' | 'right' | 'pan' | null>(null);
|
||||||
const [containerWidth, setContainerWidth] = useState(800);
|
const [containerWidth, setContainerWidth] = useState(800);
|
||||||
@@ -365,7 +367,7 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="timeline-hint">
|
<p className="timeline-hint">
|
||||||
Click an era or event to zoom · Scroll to zoom · Drag to pan
|
{t('captionClassicTimeline')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { ArtMovement } from '../types';
|
import type { ArtMovement } from '../types';
|
||||||
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
import { getMovementLineage } from '../data/movement-lineage';
|
||||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||||
|
import { vividMovementColor } from '../utils/movementColor';
|
||||||
import './VerticalMovementBands.css';
|
import './VerticalMovementBands.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -51,73 +53,13 @@ function yearToBottomPercent(year: number, start: number, end: number): number {
|
|||||||
return ((year - start) / (end - start)) * 100;
|
return ((year - start) / (end - start)) * 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseHexColor(hex: string): [number, number, number] {
|
|
||||||
const normalized = hex.replace('#', '');
|
|
||||||
const value =
|
|
||||||
normalized.length === 3
|
|
||||||
? normalized
|
|
||||||
.split('')
|
|
||||||
.map((c) => c + c)
|
|
||||||
.join('')
|
|
||||||
: normalized;
|
|
||||||
const n = parseInt(value, 16);
|
|
||||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
||||||
}
|
|
||||||
|
|
||||||
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
|
||||||
r /= 255;
|
|
||||||
g /= 255;
|
|
||||||
b /= 255;
|
|
||||||
const max = Math.max(r, g, b);
|
|
||||||
const min = Math.min(r, g, b);
|
|
||||||
const l = (max + min) / 2;
|
|
||||||
if (max === min) return [0, 0, l];
|
|
||||||
const d = max - min;
|
|
||||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
||||||
let h = 0;
|
|
||||||
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
|
||||||
else if (max === g) h = ((b - r) / d + 2) / 6;
|
|
||||||
else h = ((r - g) / d + 4) / 6;
|
|
||||||
return [h * 360, s, l];
|
|
||||||
}
|
|
||||||
|
|
||||||
function hslToHex(h: number, s: number, l: number): string {
|
|
||||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
||||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
||||||
const m = l - c / 2;
|
|
||||||
let r = 0;
|
|
||||||
let g = 0;
|
|
||||||
let b = 0;
|
|
||||||
if (h < 60) [r, g, b] = [c, x, 0];
|
|
||||||
else if (h < 120) [r, g, b] = [x, c, 0];
|
|
||||||
else if (h < 180) [r, g, b] = [0, c, x];
|
|
||||||
else if (h < 240) [r, g, b] = [0, x, c];
|
|
||||||
else if (h < 300) [r, g, b] = [x, 0, c];
|
|
||||||
else [r, g, b] = [c, 0, x];
|
|
||||||
const toByte = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
|
||||||
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function vividMovementColor(hex: string): string {
|
|
||||||
try {
|
|
||||||
const [r, g, b] = parseHexColor(hex);
|
|
||||||
const [h, s, l] = rgbToHsl(r, g, b);
|
|
||||||
const s2 = s < 0.1 ? Math.min(0.55, s + 0.42) : Math.min(1, s * 1.65 + 0.08);
|
|
||||||
const l2 =
|
|
||||||
l < 0.22 ? 0.5 : l > 0.78 ? 0.62 : Math.min(0.68, Math.max(0.4, l * 0.75 + 0.28));
|
|
||||||
return hslToHex(h, s2, l2);
|
|
||||||
} catch {
|
|
||||||
return hex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLineageParentMap(
|
function buildLineageParentMap(
|
||||||
visible: ArtMovement[],
|
visible: ArtMovement[],
|
||||||
nameToId: Map<string, number>
|
nameToId: Map<string, number>
|
||||||
): Map<number, number[]> {
|
): Map<number, number[]> {
|
||||||
const parents = new Map<number, number[]>();
|
const parents = new Map<number, number[]>();
|
||||||
const visibleIds = new Set(visible.map((m) => m.id));
|
const visibleIds = new Set(visible.map((m) => m.id));
|
||||||
for (const [parentName, childName] of MOVEMENT_LINEAGE) {
|
for (const [parentName, childName] of getMovementLineage()) {
|
||||||
const parentId = nameToId.get(parentName);
|
const parentId = nameToId.get(parentName);
|
||||||
const childId = nameToId.get(childName);
|
const childId = nameToId.get(childName);
|
||||||
if (parentId == null || childId == null) continue;
|
if (parentId == null || childId == null) continue;
|
||||||
@@ -423,6 +365,7 @@ export default function VerticalMovementBands({
|
|||||||
onViewChange,
|
onViewChange,
|
||||||
onMovementClick,
|
onMovementClick,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation('home');
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
const [canvasSize, setCanvasSize] = useState({ w: 800, h: 600 });
|
const [canvasSize, setCanvasSize] = useState({ w: 800, h: 600 });
|
||||||
const [panning, setPanning] = useState(false);
|
const [panning, setPanning] = useState(false);
|
||||||
@@ -690,7 +633,7 @@ export default function VerticalMovementBands({
|
|||||||
return (
|
return (
|
||||||
<div className="vflow">
|
<div className="vflow">
|
||||||
<p className="vflow-caption">
|
<p className="vflow-caption">
|
||||||
Bottom → top through history · scroll to zoom · drag to pan · click a stream
|
{t('captionVerticalFlow')}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { HistoricalEra } from '../types';
|
import type { HistoricalEra } from '../types';
|
||||||
import {
|
import {
|
||||||
HISTORICAL_EVENTS,
|
HISTORICAL_EVENTS,
|
||||||
@@ -60,6 +61,7 @@ export default function VerticalTimeline({
|
|||||||
absoluteMax,
|
absoluteMax,
|
||||||
lifespanHighlight,
|
lifespanHighlight,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation('home');
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [dragging, setDragging] = useState<'start' | 'end' | 'pan' | null>(null);
|
const [dragging, setDragging] = useState<'start' | 'end' | 'pan' | null>(null);
|
||||||
const [containerHeight, setContainerHeight] = useState(600);
|
const [containerHeight, setContainerHeight] = useState(600);
|
||||||
@@ -409,7 +411,7 @@ export default function VerticalTimeline({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="vtimeline-hint">Bottom → top · Scroll to zoom · Drag to pan</p>
|
<p className="vtimeline-hint">{t('captionVerticalTimeline')}</p>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,12 @@ import type {
|
|||||||
import { galleryImageUrlCandidates, imageUrl, api } from '../api/client';
|
import { galleryImageUrlCandidates, imageUrl, api } from '../api/client';
|
||||||
import { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
|
import { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
|
||||||
import { cloneSurfaceTexture, getSurfaceTexture, type SurfaceTextureKind } from '../utils/galleryProceduralTextures';
|
import { cloneSurfaceTexture, getSurfaceTexture, type SurfaceTextureKind } from '../utils/galleryProceduralTextures';
|
||||||
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
|
import {
|
||||||
|
resolveMovementInteriorStyle,
|
||||||
|
type MovementDetailKind,
|
||||||
|
type MovementInteriorStyle,
|
||||||
|
type GalleryWindowSpec,
|
||||||
|
} from '../data/movement-interior-styles';
|
||||||
import { useTexturedMaterial } from '../hooks/useTexturedMaterial';
|
import { useTexturedMaterial } from '../hooks/useTexturedMaterial';
|
||||||
import MovementHallDetails from './MovementHallDetails';
|
import MovementHallDetails from './MovementHallDetails';
|
||||||
import GalleryWindows, { GalleryTrackLights } from './GalleryWindows';
|
import GalleryWindows, { GalleryTrackLights } from './GalleryWindows';
|
||||||
@@ -84,6 +89,15 @@ interface TourGalleryProps extends BaseGalleryProps {
|
|||||||
|
|
||||||
type Props = ArtistGalleryProps | MovementGalleryProps | TourGalleryProps;
|
type Props = ArtistGalleryProps | MovementGalleryProps | TourGalleryProps;
|
||||||
|
|
||||||
|
/** Period interiors built from steel, concrete or a white cube read better under the city HDR. */
|
||||||
|
const CITY_ENVIRONMENT_DETAILS = new Set<MovementDetailKind>([
|
||||||
|
'futurist',
|
||||||
|
'suprematist',
|
||||||
|
'constructivist',
|
||||||
|
'loft',
|
||||||
|
'white-cube',
|
||||||
|
]);
|
||||||
|
|
||||||
const WALL_HEIGHT = 4.2;
|
const WALL_HEIGHT = 4.2;
|
||||||
const WALL_THICKNESS = 0.18;
|
const WALL_THICKNESS = 0.18;
|
||||||
const MOUNT_OFFSET = 0.16;
|
const MOUNT_OFFSET = 0.16;
|
||||||
@@ -1739,7 +1753,14 @@ function ArtistHall({
|
|||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{interiorStyle && (
|
{interiorStyle && (
|
||||||
<MovementHallDetails style={interiorStyle} width={width} depth={depth} halfW={halfW} halfD={halfD} />
|
<MovementHallDetails
|
||||||
|
style={interiorStyle}
|
||||||
|
width={width}
|
||||||
|
depth={depth}
|
||||||
|
halfW={halfW}
|
||||||
|
halfD={halfD}
|
||||||
|
windows={computedWindows}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
@@ -2156,8 +2177,8 @@ export default function VirtualGallery(props: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const movementHalls = useMemo(
|
const movementHalls = useMemo(
|
||||||
() => (isWingedHall ? buildAllMovementHallLayouts(paintings) : []),
|
() => (isWingedHall ? buildAllMovementHallLayouts(paintings, interiorStyle?.details) : []),
|
||||||
[isWingedHall, paintings]
|
[isWingedHall, paintings, interiorStyle?.details]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [hallIndex, setHallIndex] = useState(0);
|
const [hallIndex, setHallIndex] = useState(0);
|
||||||
@@ -2635,7 +2656,7 @@ export default function VirtualGallery(props: Props) {
|
|||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Environment
|
<Environment
|
||||||
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
|
preset={interiorStyle && CITY_ENVIRONMENT_DETAILS.has(interiorStyle.details) ? 'city' : 'warehouse'}
|
||||||
environmentIntensity={(interiorStyle ? 0.55 : 0.25) * lightScale}
|
environmentIntensity={(interiorStyle ? 0.55 : 0.25) * lightScale}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -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 plain coffered soffit —
|
||||||
|
* no gold ring in every cell.
|
||||||
|
*/
|
||||||
|
export function CinquecentoDetails({ room, trim }: PeriodProps) {
|
||||||
|
const bays = useBayZ(room.depth, room.halfD, 5.2, 6);
|
||||||
|
const marble = '#d8d0c0';
|
||||||
|
const grey = shade(marble, -0.14);
|
||||||
|
|
||||||
|
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.5} metalness={0.08} />
|
||||||
|
<WallBand wall={wall} room={room} y={1.2} height={0.1} proud={0.09} color={marble} roughness={0.46} metalness={0.09} />
|
||||||
|
<WallBand wall={wall} room={room} y={0.07} height={0.14} proud={0.1} color={shade(grey, -0.18)} roughness={0.48} metalness={0.09} />
|
||||||
|
</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.48} metalness={0.08} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 3.82, 0]}>
|
||||||
|
<boxGeometry args={[0.12, 0.1, 0.34]} />
|
||||||
|
<meshStandardMaterial color={shade(marble, 0.06)} roughness={0.46} metalness={0.09} />
|
||||||
|
</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.48} metalness={0.08} />
|
||||||
|
<WallBand wall={wall} room={room} y={4.02} height={0.1} proud={0.12} color={grey} roughness={0.5} metalness={0.08} />
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
<CorniceRing room={room} y={4.12} height={0.09} proud={0.18} color={trim} roughness={0.42} metalness={0.24} />
|
||||||
|
|
||||||
|
{/* Plain coffers — larger cells, no gold rosettes */}
|
||||||
|
<CofferedCeiling
|
||||||
|
room={room}
|
||||||
|
y={4.08}
|
||||||
|
cell={3.4}
|
||||||
|
panelColor="#e6e0d4"
|
||||||
|
ribColor="#cfc4b2"
|
||||||
|
ribWidth={0.22}
|
||||||
|
ribDrop={0.16}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
import {
|
||||||
|
CeilingBeams,
|
||||||
|
CeilingRose,
|
||||||
|
CofferedCeiling,
|
||||||
|
CorniceRing,
|
||||||
|
FlutedShaft,
|
||||||
|
HangingFixture,
|
||||||
|
IonicCapital,
|
||||||
|
ShaftBase,
|
||||||
|
WallBand,
|
||||||
|
} from './primitives';
|
||||||
|
import {
|
||||||
|
shade,
|
||||||
|
useBayZ,
|
||||||
|
useCorners,
|
||||||
|
useRepeat,
|
||||||
|
useWallClearance,
|
||||||
|
DOOR_CLEAR_HALF,
|
||||||
|
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).filter((x) => Math.abs(x) > DOOR_CLEAR_HALF);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Wainscot ground, cap rail and skirting — WallBand clears the exit door */}
|
||||||
|
{(['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
|
||||||
|
* (narrow foot, wide shoulder), shallow banded rustication on the shaft, and a
|
||||||
|
* block capital with a broken cornice. Flush to the wall — no floating edge
|
||||||
|
* strips, no torus capitals that peel into the room.
|
||||||
|
*/
|
||||||
|
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.8, 6);
|
||||||
|
const stone = '#b89a78';
|
||||||
|
const dark = shade(stone, -0.28);
|
||||||
|
const band = shade(stone, -0.14);
|
||||||
|
// Inverted herm taper: foot → shoulder widths along the wall (Z).
|
||||||
|
const hermSegs = [
|
||||||
|
{ y: 1.15, h: 0.7, w: 0.18 },
|
||||||
|
{ y: 1.9, h: 0.8, w: 0.22 },
|
||||||
|
{ y: 2.7, h: 0.8, w: 0.26 },
|
||||||
|
{ y: 3.4, h: 0.6, w: 0.3 },
|
||||||
|
] as const;
|
||||||
|
const bandYs = [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] as const)
|
||||||
|
.filter((side) => (side < 0 ? clearLeft : clearRight)(z, 0.28))
|
||||||
|
.map((side) => (
|
||||||
|
<group key={side} position={[side * (room.halfW - 0.05), 0, z]}>
|
||||||
|
<ShaftBase size={0.11} color={dark} />
|
||||||
|
{/* Stacked tapering shaft (proud ≤ 0.10 m) */}
|
||||||
|
{hermSegs.map((seg) => (
|
||||||
|
<mesh key={seg.y} position={[0, seg.y, 0]}>
|
||||||
|
<boxGeometry args={[0.1, seg.h, seg.w]} />
|
||||||
|
<meshStandardMaterial color={stone} roughness={0.78} metalness={0.05} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
{/* Shallow rustication bands — same depth as shaft, slightly wider */}
|
||||||
|
{bandYs.map((y) => (
|
||||||
|
<mesh key={y} position={[0, y, 0]}>
|
||||||
|
<boxGeometry args={[0.105, 0.16, 0.34]} />
|
||||||
|
<meshStandardMaterial color={band} roughness={0.86} metalness={0.04} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
{/* Block capital / console — clean abacus, no peeling torus */}
|
||||||
|
<mesh position={[0, 3.78, 0]}>
|
||||||
|
<boxGeometry args={[0.11, 0.14, 0.36]} />
|
||||||
|
<meshStandardMaterial color={shade(stone, 0.06)} roughness={0.7} metalness={0.06} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 3.92, 0]}>
|
||||||
|
<boxGeometry args={[0.12, 0.1, 0.42]} />
|
||||||
|
<meshStandardMaterial color={trim} roughness={0.48} metalness={0.28} />
|
||||||
|
</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.28))
|
||||||
|
.map((side) => (
|
||||||
|
<group key={side} position={[side * (room.halfW - 0.06), 3.55, mid]}>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[0.06, 0.28, 0.28]} />
|
||||||
|
<meshStandardMaterial color={shade(stone, 0.1)} roughness={0.7} metalness={0.06} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[side * -0.02, 0, 0]}>
|
||||||
|
<boxGeometry args={[0.04, 0.18, 0.18]} />
|
||||||
|
<meshStandardMaterial color={trim} roughness={0.45} metalness={0.35} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baroque state gallery — Baroque.
|
||||||
|
*
|
||||||
|
* Twisted solomonic half-columns at the corners, gilt-framed velvet field
|
||||||
|
* panels between the bays (true moulding rails, not solid gilt slabs), a
|
||||||
|
* modillion cornice, and a crystal-and-gilt chandelier over the centre.
|
||||||
|
*/
|
||||||
|
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 velvet = '#4a1220';
|
||||||
|
|
||||||
|
const modillions = useRepeat(room.depth, 0.86, 26);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Gilt-framed 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 gap = bays[i + 1] - z;
|
||||||
|
const fieldW = Math.min(1.15, Math.max(0.55, gap * 0.32));
|
||||||
|
const fieldH = 0.9;
|
||||||
|
const rail = 0.07;
|
||||||
|
// Extra margin so rails never crowd / clip window openings.
|
||||||
|
const halfClear = fieldW / 2 + rail + 0.35;
|
||||||
|
return (
|
||||||
|
<group key={i}>
|
||||||
|
{([-1, 1] as const)
|
||||||
|
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, halfClear))
|
||||||
|
.map((side) => (
|
||||||
|
<group key={side} position={[side * (room.halfW - 0.05), 3.15, mid]}>
|
||||||
|
{/* Velvet field — faces the room */}
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[0.028, fieldH, fieldW]} />
|
||||||
|
<meshStandardMaterial color={velvet} roughness={0.92} metalness={0.03} />
|
||||||
|
</mesh>
|
||||||
|
{/* Gilt moulding rails around the field (not a covering slab) */}
|
||||||
|
<mesh position={[0, fieldH / 2 + rail / 2, 0]}>
|
||||||
|
<boxGeometry args={[0.036, rail, fieldW + rail * 2]} />
|
||||||
|
<meshStandardMaterial color={gilt} roughness={0.38} metalness={0.62} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, -(fieldH / 2 + rail / 2), 0]}>
|
||||||
|
<boxGeometry args={[0.036, rail, fieldW + rail * 2]} />
|
||||||
|
<meshStandardMaterial color={gilt} roughness={0.38} metalness={0.62} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0, fieldW / 2 + rail / 2]}>
|
||||||
|
<boxGeometry args={[0.036, fieldH + rail * 2, rail]} />
|
||||||
|
<meshStandardMaterial color={gilt} roughness={0.38} metalness={0.62} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0, -(fieldW / 2 + rail / 2)]}>
|
||||||
|
<boxGeometry args={[0.036, fieldH + rail * 2, rail]} />
|
||||||
|
<meshStandardMaterial color={gilt} roughness={0.38} metalness={0.62} />
|
||||||
|
</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="#e6e2da"
|
||||||
|
ribColor="#cfc8bc"
|
||||||
|
ribWidth={0.18}
|
||||||
|
ribDrop={0.13}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { GalleryWindowSpec } from '../../data/movement-interior-styles';
|
||||||
|
import { bayPositions } from '../../utils/sideColumnKeepOuts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
/**
|
||||||
|
* Half-width of the exit/entrance door clear zone on back/front walls.
|
||||||
|
* Mouldings below ~2.6 m must stop outside ±DOOR_CLEAR_HALF from wall centre.
|
||||||
|
*/
|
||||||
|
export const DOOR_CLEAR_HALF = 1.45;
|
||||||
|
/** Bands whose bottom edge is below this height are treated as crossing the door. */
|
||||||
|
export const DOOR_CLEAR_Y = 2.55;
|
||||||
|
|
||||||
|
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(() => bayPositions(depth, halfD, spacing, max), [depth, halfD, spacing, max]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { bayPositions };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,719 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
CeilingBeams,
|
||||||
|
CorniceRing,
|
||||||
|
HangingFixture,
|
||||||
|
SideRails,
|
||||||
|
WallBand,
|
||||||
|
} from './primitives';
|
||||||
|
import {
|
||||||
|
shade,
|
||||||
|
useBayZ,
|
||||||
|
useCorners,
|
||||||
|
useRepeat,
|
||||||
|
useWallClearance,
|
||||||
|
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 cabaret — Dada.
|
||||||
|
*
|
||||||
|
* Modern Cabaret Voltaire: cream gallery shell with collage panels (newsprint,
|
||||||
|
* ink black, scarlet) hung off-axis, a thin black rail, and open picture
|
||||||
|
* windows. No iron bars, no service pipe, no prison cell.
|
||||||
|
*/
|
||||||
|
export function DadaDetails({ room, trim, windows }: PeriodProps) {
|
||||||
|
const clearLeft = useWallClearance(windows, 'left');
|
||||||
|
const clearRight = useWallClearance(windows, 'right');
|
||||||
|
const ink = '#1a1a1a';
|
||||||
|
const scarlet = '#c8302a';
|
||||||
|
const newsprint = '#e8dfc8';
|
||||||
|
const mustard = '#d4a84b';
|
||||||
|
|
||||||
|
const collage = useMemo(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
{ wall: 'left' as const, z: -0.42, y: 3.15, w: 0.9, h: 0.55, color: scarlet, tilt: 0.12 },
|
||||||
|
{ wall: 'left' as const, z: -0.08, y: 3.55, w: 1.15, h: 0.28, color: ink, tilt: -0.06 },
|
||||||
|
{ wall: 'left' as const, z: 0.35, y: 3.05, w: 0.7, h: 0.7, color: newsprint, tilt: 0.18 },
|
||||||
|
{ wall: 'right' as const, z: -0.3, y: 3.4, w: 1.0, h: 0.4, color: mustard, tilt: -0.1 },
|
||||||
|
{ wall: 'right' as const, z: 0.2, y: 2.95, w: 0.55, h: 0.85, color: ink, tilt: 0.08 },
|
||||||
|
{ wall: 'right' as const, z: 0.48, y: 3.5, w: 0.8, h: 0.32, color: scarlet, tilt: 0.14 },
|
||||||
|
{ wall: 'back' as const, z: -0.35, y: 3.55, w: 0.75, h: 0.35, color: mustard, tilt: -0.05 },
|
||||||
|
{ wall: 'back' as const, z: 0.4, y: 3.2, w: 0.5, h: 0.6, color: ink, tilt: 0.2 },
|
||||||
|
] as const,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Collage panels above the hang — off-axis, cabaret colour */}
|
||||||
|
{collage.map((p, i) => {
|
||||||
|
const along = p.z * (p.wall === 'back' ? room.halfW : room.halfD);
|
||||||
|
const clear =
|
||||||
|
p.wall === 'left' ? clearLeft : p.wall === 'right' ? clearRight : () => true;
|
||||||
|
if (!clear(along, p.w / 2)) return null;
|
||||||
|
const off = room.halfW - 0.06;
|
||||||
|
const pos: [number, number, number] =
|
||||||
|
p.wall === 'left'
|
||||||
|
? [-off, p.y, along]
|
||||||
|
: p.wall === 'right'
|
||||||
|
? [off, p.y, along]
|
||||||
|
: [along, p.y, -room.halfD + 0.06];
|
||||||
|
const rot: [number, number, number] =
|
||||||
|
p.wall === 'left' || p.wall === 'right' ? [p.tilt, 0, 0] : [0, 0, p.tilt];
|
||||||
|
return (
|
||||||
|
<mesh key={i} position={pos} rotation={rot}>
|
||||||
|
<boxGeometry
|
||||||
|
args={
|
||||||
|
p.wall === 'back'
|
||||||
|
? [p.w, p.h, 0.04]
|
||||||
|
: [0.04, p.h, p.w]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<meshStandardMaterial color={p.color} roughness={0.72} metalness={0.04} />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Thin modern black rail — slight intentional skew */}
|
||||||
|
{([-1, 1] as const).map((side) => (
|
||||||
|
<mesh key={side} position={[side * (room.halfW - 0.05), 2.58, 0]} rotation={[side * 0.02, 0, 0]}>
|
||||||
|
<boxGeometry args={[0.05, 0.04, room.depth * 0.82]} />
|
||||||
|
<meshStandardMaterial color={ink} roughness={0.45} metalness={0.35} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Clean modern skirting */}
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<WallBand key={wall} wall={wall} room={room} y={0.08} height={0.16} proud={0.05} color={ink} roughness={0.55} metalness={0.2} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Shadow-gap cornice instead of crumbling moulding */}
|
||||||
|
<CorniceRing room={room} y={4.05} height={0.04} proud={0.03} color="#e6e0d6" roughness={0.85} metalness={0.02} />
|
||||||
|
<CorniceRing room={room} y={4.14} height={0.06} proud={0.1} color={trim} roughness={0.5} metalness={0.25} />
|
||||||
|
|
||||||
|
{/* Geometric hanging mobile (box collage, not a pipe) */}
|
||||||
|
<group position={[room.halfW * 0.28, 3.95, room.halfD * 0.15]}>
|
||||||
|
<mesh position={[0, -0.15, 0]}>
|
||||||
|
<boxGeometry args={[0.02, 0.35, 0.02]} />
|
||||||
|
<meshStandardMaterial color={ink} roughness={0.4} metalness={0.5} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[-0.18, -0.38, 0]} rotation={[0, 0.4, 0.15]}>
|
||||||
|
<boxGeometry args={[0.28, 0.08, 0.08]} />
|
||||||
|
<meshStandardMaterial color={scarlet} roughness={0.6} metalness={0.08} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0.16, -0.48, 0.05]} rotation={[0.2, -0.3, 0]}>
|
||||||
|
<boxGeometry args={[0.2, 0.2, 0.05]} />
|
||||||
|
<meshStandardMaterial color={mustard} roughness={0.65} metalness={0.06} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0.02, -0.62, -0.08]} rotation={[0, 0.6, 0.1]}>
|
||||||
|
<boxGeometry args={[0.14, 0.14, 0.04]} />
|
||||||
|
<meshStandardMaterial color={ink} roughness={0.55} metalness={0.2} />
|
||||||
|
</mesh>
|
||||||
|
<pointLight position={[0, -0.4, 0.2]} intensity={0.55} distance={7} color="#fff0d8" />
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surrealist interior — Surrealism.
|
||||||
|
*
|
||||||
|
* The 1938 Exposition Internationale: sacks slung under the ceiling, drapery
|
||||||
|
* gathered along the cornice, and a brazier glow in one corner. The room is
|
||||||
|
* furnished as an image, not as a gallery — no freestanding corner door.
|
||||||
|
*/
|
||||||
|
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>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Painter loft — Abstract Expressionism.
|
||||||
|
*
|
||||||
|
* A Tenth Street studio after the painters moved in: whitewashed walls, square
|
||||||
|
* timber posts, open wood joists, and wide north-light picture windows. Warm
|
||||||
|
* and open — not cast-iron bars, not a sprinkler cage.
|
||||||
|
*/
|
||||||
|
export function LoftDetails({ room, trim }: PeriodProps) {
|
||||||
|
const corners = useCorners(room.halfW, room.halfD, 0.26);
|
||||||
|
const joists = useBayZ(room.depth, room.halfD, 2.0, 11);
|
||||||
|
const timber = '#8a7358';
|
||||||
|
const timberLight = '#c4a882';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Square timber posts — studio structure, not iron bars */}
|
||||||
|
{corners.map(([x, z], i) => (
|
||||||
|
<group key={i} position={[x, 0, z]}>
|
||||||
|
<mesh position={[0, 0.1, 0]}>
|
||||||
|
<boxGeometry args={[0.34, 0.2, 0.34]} />
|
||||||
|
<meshStandardMaterial color={shade(timber, -0.15)} roughness={0.82} metalness={0.04} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 2.05, 0]}>
|
||||||
|
<boxGeometry args={[0.22, 3.7, 0.22]} />
|
||||||
|
<meshStandardMaterial color={timber} roughness={0.78} metalness={0.05} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 3.98, 0]}>
|
||||||
|
<boxGeometry args={[0.38, 0.16, 0.38]} />
|
||||||
|
<meshStandardMaterial color={shade(timber, 0.08)} roughness={0.76} metalness={0.05} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Open timber joists — painter studio ceiling */}
|
||||||
|
<CeilingBeams
|
||||||
|
room={room}
|
||||||
|
positions={joists}
|
||||||
|
axis="x"
|
||||||
|
width={0.12}
|
||||||
|
height={0.18}
|
||||||
|
y={4.0}
|
||||||
|
color={timberLight}
|
||||||
|
roughness={0.78}
|
||||||
|
metalness={0.04}
|
||||||
|
/>
|
||||||
|
<CeilingBeams
|
||||||
|
room={room}
|
||||||
|
positions={[-room.width * 0.22, room.width * 0.22]}
|
||||||
|
axis="z"
|
||||||
|
width={0.16}
|
||||||
|
height={0.2}
|
||||||
|
y={3.88}
|
||||||
|
color={timber}
|
||||||
|
roughness={0.8}
|
||||||
|
metalness={0.04}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Soft wood skirting */}
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<WallBand
|
||||||
|
key={wall}
|
||||||
|
wall={wall}
|
||||||
|
room={room}
|
||||||
|
y={0.1}
|
||||||
|
height={0.2}
|
||||||
|
proud={0.06}
|
||||||
|
color={trim}
|
||||||
|
roughness={0.78}
|
||||||
|
metalness={0.05}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<CorniceRing room={room} y={4.12} height={0.06} proud={0.08} color={shade(timberLight, 0.12)} roughness={0.8} metalness={0.03} />
|
||||||
|
</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,547 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
CEILING_Y,
|
||||||
|
DOOR_CLEAR_HALF,
|
||||||
|
DOOR_CLEAR_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 thick = proud + EMBED * 2;
|
||||||
|
const off = WALL_INNER + proud / 2 - EMBED;
|
||||||
|
const matProps = {
|
||||||
|
color,
|
||||||
|
roughness,
|
||||||
|
metalness,
|
||||||
|
emissive,
|
||||||
|
emissiveIntensity,
|
||||||
|
};
|
||||||
|
|
||||||
|
// End-wall bands that sit in the door height must leave a clear gap for exits.
|
||||||
|
const crossesDoor =
|
||||||
|
!alongZ && y - height / 2 < DOOR_CLEAR_Y && room.halfW > DOOR_CLEAR_HALF + 0.35;
|
||||||
|
|
||||||
|
if (crossesDoor) {
|
||||||
|
const z = wall === 'back' ? -room.halfD + off : room.halfD - off;
|
||||||
|
const sideLen = room.halfW - DOOR_CLEAR_HALF;
|
||||||
|
const leftX = (-room.halfW - DOOR_CLEAR_HALF) / 2;
|
||||||
|
const rightX = (room.halfW + DOOR_CLEAR_HALF) / 2;
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<mesh position={[leftX, y, z]}>
|
||||||
|
<boxGeometry args={[sideLen, height, thick]} />
|
||||||
|
<meshStandardMaterial {...matProps} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[rightX, y, z]}>
|
||||||
|
<boxGeometry args={[sideLen, height, thick]} />
|
||||||
|
<meshStandardMaterial {...matProps} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const len = (alongZ ? room.depth : room.width) * span;
|
||||||
|
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 {...matProps} />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,11 @@ export type GalleryWindowStyle =
|
|||||||
| 'art-nouveau'
|
| 'art-nouveau'
|
||||||
| 'glass-block'
|
| 'glass-block'
|
||||||
| 'clerestory'
|
| 'clerestory'
|
||||||
| 'round-oculus';
|
| 'round-oculus'
|
||||||
|
/** Thin frame, open glass — no mullion grid (modern / Dada cabaret). */
|
||||||
|
| 'picture'
|
||||||
|
/** Thin steel ribbon frame, open glass — Futurist / modern loft daylight. */
|
||||||
|
| 'ribbon';
|
||||||
|
|
||||||
export interface GalleryWindowSpec {
|
export interface GalleryWindowSpec {
|
||||||
wall: 'back' | 'left' | 'right' | 'ceiling';
|
wall: 'back' | 'left' | 'right' | 'ceiling';
|
||||||
@@ -54,9 +58,42 @@ export interface MovementInteriorStyle {
|
|||||||
trackLights: number;
|
trackLights: number;
|
||||||
/** Scales the shared hall/scene lights. <1 for deliberately dim period interiors. */
|
/** Scales the shared hall/scene lights. <1 for deliberately dim period interiors. */
|
||||||
lightScale: number;
|
lightScale: number;
|
||||||
details: 'palazzo' | 'baroque' | 'gothic' | 'byzantine' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier';
|
/**
|
||||||
|
* 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[] {
|
function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] {
|
||||||
return specs;
|
return specs;
|
||||||
}
|
}
|
||||||
@@ -99,9 +136,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Roman atrium',
|
'Roman atrium',
|
||||||
'Marble-clad villa · mosaic floor · clerestory daylight',
|
'Marble-clad villa · mosaic floor · clerestory daylight',
|
||||||
{ wall: 'limestone', ceiling: 'plaster-warm', floor: 'mosaic-roman' },
|
{ wall: 'limestone', ceiling: 'plaster-warm', floor: 'mosaic-roman' },
|
||||||
{ wall: '#e8e0d0', ceiling: '#f5f0e8', floor: '#c8b898', trim: '#a89878' },
|
{ wall: '#8e8980', ceiling: '#9c9994', floor: '#a08868', 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',
|
titleColor: '#5a4830',
|
||||||
ambient: 0.62,
|
ambient: 0.62,
|
||||||
warmLight: '#fff8e8',
|
warmLight: '#fff8e8',
|
||||||
@@ -175,9 +215,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Florentine palazzo',
|
'Florentine palazzo',
|
||||||
'Lime-washed walls · terracotta accents · arched windows',
|
'Lime-washed walls · terracotta accents · arched windows',
|
||||||
{ wall: 'painted-lime', wallSide: 'stucco-cream', ceiling: 'fresco-worn', floor: 'terracotta-tiles' },
|
{ 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: '#a07050', 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',
|
titleColor: '#6b4423',
|
||||||
windows: windows(
|
windows: windows(
|
||||||
{ wall: 'back', x: -2.2, y: 2.6, width: 1.3, height: 1.7, style: 'roman-arch', lightColor: '#fff4e0', lightIntensity: 3.0 },
|
{ wall: 'back', x: -2.2, y: 2.6, width: 1.3, height: 1.7, style: 'roman-arch', lightColor: '#fff4e0', lightIntensity: 3.0 },
|
||||||
@@ -189,17 +232,20 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
31: mk(
|
31: mk(
|
||||||
'high-renaissance-palazzo',
|
'high-renaissance-palazzo',
|
||||||
'Roman palazzo',
|
'Roman palazzo',
|
||||||
'Marble and stucco · coffered ceiling · checker floor',
|
'Marble and stucco · calm coffered ceiling · veined stone floor',
|
||||||
{ wall: 'marble-veined-carrara', wallSide: 'stucco-cream', ceiling: 'plaster-warm', floor: 'marble-checker' },
|
{ wall: 'marble-veined-carrara', wallSide: 'stucco-cream', ceiling: 'plaster-warm', floor: 'marble-veined-carrara' },
|
||||||
{ wall: '#f0ece4', wallSide: '#efe4d4', ceiling: '#faf6ee', floor: '#ddd8cc', trim: '#c9a227' },
|
{ wall: '#918f8a', wallSide: '#8f8980', ceiling: '#9c9994', floor: '#9a9080', 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',
|
titleColor: '#6b4423',
|
||||||
windows: windows(
|
windows: windows(
|
||||||
{ wall: 'back', x: 0, y: 2.8, width: 2.8, height: 1.8, style: 'baroque-pair', lightColor: '#fff8f0', lightIntensity: 3.8 },
|
{ wall: 'back', x: 0, y: 2.8, width: 2.8, height: 1.8, style: 'baroque-pair', lightColor: '#fff8f0', lightIntensity: 3.8 },
|
||||||
{ wall: 'left', x: 0, y: 3.0, width: 2.0, height: 1.5, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.8 },
|
{ wall: 'left', x: 0, y: 3.0, width: 2.0, height: 1.5, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.8 },
|
||||||
{ wall: 'right', x: 0, y: 3.0, width: 2.0, height: 1.5, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.8 },
|
{ wall: 'right', x: 0, y: 3.0, width: 2.0, height: 1.5, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.8 },
|
||||||
{ wall: 'ceiling', x: 0, y: 0, width: 3.0, height: 2.0, style: 'round-oculus', lightColor: '#ffffff', lightIntensity: 4.0 }
|
{ wall: 'ceiling', x: 0, y: 0, width: 3.0, height: 2.0, style: 'round-oculus', lightColor: '#ffffff', lightIntensity: 3.2 }
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -208,9 +254,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Flemish panel hall',
|
'Flemish panel hall',
|
||||||
'Oak wainscoting · leaded glass · herringbone floor',
|
'Oak wainscoting · leaded glass · herringbone floor',
|
||||||
{ wall: 'oak-panel', wallSide: 'plaster-warm', ceiling: 'dark-wood-panel', floor: 'parquet-herringbone' },
|
{ 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',
|
titleColor: '#f0e8d8',
|
||||||
warmLight: '#ffe8c8',
|
warmLight: '#ffe8c8',
|
||||||
windows: windows(
|
windows: windows(
|
||||||
@@ -226,9 +275,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Mannerist gallery',
|
'Mannerist gallery',
|
||||||
'Dramatic stucco · elongated windows · veined marble',
|
'Dramatic stucco · elongated windows · veined marble',
|
||||||
{ wall: 'stucco-terracotta', ceiling: 'gilded-stucco', floor: 'marble-veined-emerald' },
|
{ wall: 'stucco-terracotta', ceiling: 'gilded-stucco', floor: 'marble-veined-emerald' },
|
||||||
{ wall: '#d8b898', ceiling: '#d8c070', floor: '#dce8e0', trim: '#b8860b' },
|
{ wall: '#98816b', ceiling: '#a79456', floor: '#7a8a82', 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',
|
titleColor: '#4a2818',
|
||||||
windows: windows(
|
windows: windows(
|
||||||
{ wall: 'back', x: -1.8, y: 2.7, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#fff0d8', lightIntensity: 2.8 },
|
{ wall: 'back', x: -1.8, y: 2.7, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#fff0d8', lightIntensity: 2.8 },
|
||||||
@@ -242,7 +294,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Baroque state gallery',
|
'Baroque state gallery',
|
||||||
'Crimson velvet · gilded stucco · grand windows',
|
'Crimson velvet · gilded stucco · grand windows',
|
||||||
{ wall: 'velvet-crimson', ceiling: 'gilded-stucco', floor: 'marble-veined-carrara' },
|
{ wall: 'velvet-crimson', ceiling: 'gilded-stucco', floor: 'marble-veined-carrara' },
|
||||||
{ wall: '#5c1828', ceiling: '#d8c070', floor: '#ece8e0', trim: '#d4af37' },
|
{ wall: '#5c1828', ceiling: '#d8c070', floor: '#8a8078', trim: '#d4af37' },
|
||||||
{
|
{
|
||||||
details: 'baroque',
|
details: 'baroque',
|
||||||
titleColor: '#f0d890',
|
titleColor: '#f0d890',
|
||||||
@@ -264,9 +316,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Rococo salon',
|
'Rococo salon',
|
||||||
'Pastel painted walls · gilt trim · chevron parquet',
|
'Pastel painted walls · gilt trim · chevron parquet',
|
||||||
{ wall: 'painted-oil-satin', ceiling: 'silk-pale', floor: 'parquet-chevrons' },
|
{ wall: 'painted-oil-satin', ceiling: 'silk-pale', floor: 'parquet-chevrons' },
|
||||||
{ wall: '#e8dce8', ceiling: '#faf6f0', floor: '#c8a882', trim: '#d4af37' },
|
{ wall: '#918a91', ceiling: '#9b9995', floor: '#9a7858', 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',
|
titleColor: '#6a4858',
|
||||||
warmLight: '#fff0f0',
|
warmLight: '#fff0f0',
|
||||||
windows: windows(
|
windows: windows(
|
||||||
@@ -279,19 +334,22 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
36: mk(
|
36: mk(
|
||||||
'neoclassical-museum',
|
'neoclassical-museum',
|
||||||
'Neoclassical museum',
|
'Neoclassical museum',
|
||||||
'White painted walls · coffered ceiling · skylit salon',
|
'Cool painted walls · coffered ceiling · subdued stone floor',
|
||||||
{ wall: 'painted-flat', ceiling: 'plaster-white', floor: 'marble-veined-carrara' },
|
{ wall: 'painted-flat', ceiling: 'plaster-white', floor: 'marble-veined-carrara' },
|
||||||
{ wall: '#f2f0ec', ceiling: '#fafafa', floor: '#eceae6', trim: '#b8b0a4' },
|
{ wall: '#908f8d', ceiling: '#9e9e9e', floor: '#8a8680', trim: '#a0988c' },
|
||||||
{
|
{
|
||||||
details: 'neoclassical',
|
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',
|
titleColor: '#4a4844',
|
||||||
ambient: 0.65,
|
ambient: 0.58,
|
||||||
windows: windows(
|
windows: windows(
|
||||||
{ wall: 'back', x: 0, y: 2.6, width: 2.8, height: 2.0, style: 'baroque-pair', lightColor: '#ffffff', lightIntensity: 4.0 },
|
{ wall: 'back', x: 0, y: 2.6, width: 2.8, height: 2.0, style: 'baroque-pair', lightColor: '#f4f2ec', lightIntensity: 3.2 },
|
||||||
{ wall: 'ceiling', x: 0, y: 0, width: 4.0, height: 2.5, style: 'skylight', lightColor: '#ffffff', lightIntensity: 5.0 },
|
{ wall: 'ceiling', x: 0, y: 0, width: 4.0, height: 2.5, style: 'skylight', lightColor: '#f0eee8', lightIntensity: 3.2 },
|
||||||
{ wall: 'left', x: 0, y: 3.0, width: 2.0, height: 1.2, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.5 }
|
{ wall: 'left', x: 0, y: 3.0, width: 2.0, height: 1.2, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.2 }
|
||||||
),
|
),
|
||||||
trackLights: 0.7,
|
trackLights: 0.65,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
37: mk(
|
37: mk(
|
||||||
@@ -301,7 +359,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
{ wall: 'walnut-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
{ wall: 'walnut-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||||
{ wall: '#5a4030', ceiling: '#2a2420', floor: '#6a5040', trim: '#8a7050' },
|
{ wall: '#5a4030', ceiling: '#2a2420', floor: '#6a5040', trim: '#8a7050' },
|
||||||
{
|
{
|
||||||
details: 'salon',
|
details: 'gothic-revival',
|
||||||
titleColor: '#e8dcc8',
|
titleColor: '#e8dcc8',
|
||||||
ambient: 0.5,
|
ambient: 0.5,
|
||||||
warmLight: '#ffe0c0',
|
warmLight: '#ffe0c0',
|
||||||
@@ -317,9 +375,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Realist picture gallery',
|
'Realist picture gallery',
|
||||||
'Warm painted walls · bourgeois salon · oak parquet',
|
'Warm painted walls · bourgeois salon · oak parquet',
|
||||||
{ wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
{ 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(
|
windows: windows(
|
||||||
{ wall: 'back', x: 0, y: 2.5, width: 2.4, height: 1.6, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 3.2 },
|
{ 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 }
|
{ wall: 'left', x: 0, y: 2.7, width: 1.6, height: 1.3, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.4 }
|
||||||
@@ -331,9 +392,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Impressionist salon',
|
'Impressionist salon',
|
||||||
'North-light skylight · pale painted walls · herringbone floor',
|
'North-light skylight · pale painted walls · herringbone floor',
|
||||||
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
|
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
|
||||||
{ wall: '#f0ebe3', ceiling: '#fafafa', floor: '#c8a882', trim: '#c9a96e' },
|
{ wall: '#8f8c87', ceiling: '#9e9e9e', floor: '#9a7858', 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,
|
ambient: 0.68,
|
||||||
windows: windows(
|
windows: windows(
|
||||||
{ wall: 'ceiling', x: 0, y: 0, width: 5.0, height: 3.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 6.0 },
|
{ wall: 'ceiling', x: 0, y: 0, width: 5.0, height: 3.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 6.0 },
|
||||||
@@ -348,11 +412,14 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Montmartre atelier',
|
'Montmartre atelier',
|
||||||
'Painted studio walls · large north window · worn floorboards',
|
'Painted studio walls · large north window · worn floorboards',
|
||||||
{ wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
{ 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(
|
windows: windows(
|
||||||
{ wall: 'left', x: 0, y: 2.4, width: 2.8, height: 2.0, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 4.5 },
|
{ wall: 'left', x: 0, y: 2.4, width: 2.8, height: 2.0, style: 'ribbon', 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 }
|
{ wall: 'back', x: 0, y: 2.8, width: 1.0, height: 1.2, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.0 }
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -364,7 +431,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||||
{ wall: '#1a4030', ceiling: '#1a1814', floor: '#4a3828', trim: '#8a7050' },
|
{ wall: '#1a4030', ceiling: '#1a1814', floor: '#4a3828', trim: '#8a7050' },
|
||||||
{
|
{
|
||||||
details: 'salon',
|
details: 'symbolist',
|
||||||
titleColor: '#d8e8c8',
|
titleColor: '#d8e8c8',
|
||||||
ambient: 0.55,
|
ambient: 0.55,
|
||||||
warmLight: '#ffd898',
|
warmLight: '#ffd898',
|
||||||
@@ -380,9 +447,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Art Nouveau salon',
|
'Art Nouveau salon',
|
||||||
'Painted plaster · stained glass · terrazzo floor',
|
'Painted plaster · stained glass · terrazzo floor',
|
||||||
{ wall: 'painted-oil-satin', ceiling: 'silk-gold', floor: 'terrazzo' },
|
{ wall: 'painted-oil-satin', ceiling: 'silk-gold', floor: 'terrazzo' },
|
||||||
{ wall: '#f0ece4', ceiling: '#d8c890', floor: '#d8d0c4', trim: '#6a9868' },
|
{ wall: '#8f8c87', ceiling: '#a0946b', floor: '#9a9084', 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',
|
titleColor: '#3a5840',
|
||||||
windows: windows(
|
windows: windows(
|
||||||
{ wall: 'back', x: 0, y: 2.5, width: 2.2, height: 2.0, style: 'art-nouveau', lightColor: '#e8ffe8', lightIntensity: 3.2 },
|
{ wall: 'back', x: 0, y: 2.5, width: 2.2, height: 2.0, style: 'art-nouveau', lightColor: '#e8ffe8', lightIntensity: 3.2 },
|
||||||
@@ -396,13 +466,16 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Fauvist studio',
|
'Fauvist studio',
|
||||||
'Bold painted walls · flooded with color and light',
|
'Bold painted walls · flooded with color and light',
|
||||||
{ wall: 'painted-oil-matte', wallSide: 'painted-oil-matte', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
{ 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',
|
titleColor: '#402010',
|
||||||
ambient: 0.7,
|
ambient: 0.7,
|
||||||
windows: windows(
|
windows: windows(
|
||||||
{ wall: 'left', x: 0, y: 2.5, width: 3.0, height: 2.2, style: 'factory', lightColor: '#fff8f0', lightIntensity: 5.0 },
|
{ wall: 'left', x: 0, y: 2.5, width: 3.0, height: 2.2, style: 'ribbon', lightColor: '#fff8f0', lightIntensity: 5.0 },
|
||||||
{ wall: 'back', x: 0, y: 2.8, width: 1.6, height: 1.2, style: 'sash', lightColor: '#fff0e0', lightIntensity: 2.5 }
|
{ wall: 'back', x: 0, y: 2.8, width: 1.6, height: 1.2, style: 'sash', lightColor: '#fff0e0', lightIntensity: 2.5 }
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -414,26 +487,29 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
{ wall: 'dark-wood-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
{ wall: 'dark-wood-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||||
{ wall: '#3a2818', ceiling: '#2a2018', floor: '#5a4030', trim: '#8a6040' },
|
{ wall: '#3a2818', ceiling: '#2a2018', floor: '#5a4030', trim: '#8a6040' },
|
||||||
{
|
{
|
||||||
details: 'atelier',
|
details: 'expressionist',
|
||||||
titleColor: '#f0d8b0',
|
titleColor: '#f0d8b0',
|
||||||
ambient: 0.52,
|
ambient: 0.52,
|
||||||
windows: windows(
|
windows: windows(
|
||||||
{ wall: 'back', x: -1.5, y: 2.6, width: 1.0, height: 1.6, style: 'sash', lightColor: '#ffe8c0', lightIntensity: 2.8 },
|
{ wall: 'back', x: -1.5, y: 2.6, width: 1.0, height: 1.6, style: 'sash', lightColor: '#ffe8c0', lightIntensity: 2.8 },
|
||||||
{ wall: 'right', x: 0, y: 2.4, width: 2.0, height: 1.8, style: 'factory', lightColor: '#fff0d8', lightIntensity: 3.5 }
|
{ wall: 'right', x: 0, y: 2.4, width: 2.0, height: 1.8, style: 'ribbon', lightColor: '#fff0d8', lightIntensity: 3.5 }
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
45: mk(
|
45: mk(
|
||||||
'cubist-studio',
|
'cubist-studio',
|
||||||
'Cubist studio',
|
'Cubist studio',
|
||||||
'Paris atelier · factory windows · painted plaster',
|
'Paris atelier · open ribbon windows · painted plaster',
|
||||||
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
|
{ 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,
|
ambient: 0.65,
|
||||||
windows: windows(
|
windows: windows(
|
||||||
{ wall: 'left', x: 0, y: 2.5, width: 3.5, height: 2.4, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 5.5 },
|
{ wall: 'left', x: 0, y: 2.5, width: 3.5, height: 2.4, style: 'ribbon', lightColor: '#f0f8ff', lightIntensity: 5.5 },
|
||||||
{ wall: 'back', x: 0, y: 3.0, width: 1.8, height: 1.0, style: 'clerestory', lightColor: '#ffffff', lightIntensity: 2.5 }
|
{ wall: 'back', x: 0, y: 3.0, width: 1.8, height: 1.0, style: 'clerestory', lightColor: '#ffffff', lightIntensity: 2.5 }
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -441,17 +517,21 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
46: mk(
|
46: mk(
|
||||||
'futurist-loft',
|
'futurist-loft',
|
||||||
'Futurist loft',
|
'Futurist loft',
|
||||||
'Steel and glass · industrial concrete · sweeping daylight',
|
'Steel and open glass · ribbon windows · industrial concrete',
|
||||||
{ wall: 'concrete-raw', ceiling: 'concrete-raw', floor: 'industrial-floor' },
|
{ 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',
|
titleColor: '#303030',
|
||||||
ambient: 0.62,
|
ambient: 0.62,
|
||||||
fog: '#181818',
|
fog: '#181818',
|
||||||
windows: windows(
|
windows: windows(
|
||||||
{ wall: 'back', x: 0, y: 2.6, width: 4.0, height: 2.4, style: 'factory', lightColor: '#ffffff', lightIntensity: 6.0 },
|
{ wall: 'back', x: 0, y: 2.65, width: 4.2, height: 2.2, style: 'ribbon', lightColor: '#ffffff', lightIntensity: 5.8 },
|
||||||
{ wall: 'left', x: 0, y: 2.8, width: 2.5, height: 1.6, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 3.5 },
|
{ wall: 'left', x: -1.15, y: 2.75, width: 1.9, height: 1.55, style: 'ribbon', lightColor: '#f0f8ff', lightIntensity: 3.4 },
|
||||||
|
{ wall: 'left', x: 1.15, y: 2.75, width: 1.9, height: 1.55, style: 'ribbon', lightColor: '#f0f8ff', lightIntensity: 3.4 },
|
||||||
{ wall: 'ceiling', x: 0, y: 0, width: 3.0, height: 2.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 4.0 }
|
{ wall: 'ceiling', x: 0, y: 0, width: 3.0, height: 2.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 4.0 }
|
||||||
),
|
),
|
||||||
doorWood: ['#606060', '#707070', '#505050'],
|
doorWood: ['#606060', '#707070', '#505050'],
|
||||||
@@ -462,9 +542,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Suprematist white cube',
|
'Suprematist white cube',
|
||||||
'Matte white paint · geometric light · polished floor',
|
'Matte white paint · geometric light · polished floor',
|
||||||
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
|
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
|
||||||
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#e0e0e0', trim: '#cccccc' },
|
{ wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#8e8e8e', 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',
|
titleColor: '#222222',
|
||||||
ambient: 0.72,
|
ambient: 0.72,
|
||||||
fog: '#1a1a1a',
|
fog: '#1a1a1a',
|
||||||
@@ -482,9 +565,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Constructivist space',
|
'Constructivist space',
|
||||||
'Glass block · concrete · angular daylight',
|
'Glass block · concrete · angular daylight',
|
||||||
{ wall: 'concrete-block', ceiling: 'concrete-raw', floor: 'concrete-polished' },
|
{ wall: 'concrete-block', ceiling: 'concrete-raw', floor: 'concrete-polished' },
|
||||||
{ wall: '#b0b0b0', ceiling: '#a0a0a0', floor: '#c0c0c0', trim: '#808080' },
|
{ wall: '#858585', ceiling: '#7a7a7a', floor: '#888888', 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',
|
titleColor: '#303030',
|
||||||
ambient: 0.65,
|
ambient: 0.65,
|
||||||
windows: windows(
|
windows: windows(
|
||||||
@@ -497,18 +583,26 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
),
|
),
|
||||||
49: mk(
|
49: mk(
|
||||||
'dada-salon',
|
'dada-salon',
|
||||||
'Dada salon',
|
'Dada cabaret',
|
||||||
'Eclectic bourgeois room · painted walls and exposed brick',
|
'Modern cabaret · collage panels · open picture windows',
|
||||||
{ wall: 'painted-emulsion', wallSide: 'brick', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
{ wall: 'painted-flat', wallSide: 'painted-emulsion', ceiling: 'painted-flat', floor: 'concrete-polished' },
|
||||||
{ wall: '#e8dcc8', wallSide: '#8a5040', ceiling: '#f0ebe0', floor: '#8a6848', trim: '#6a4830' },
|
{ wall: '#f0ebe3', wallSide: '#e4d8c4', ceiling: '#f5f1ea', floor: '#949088', trim: '#1a1a1a' },
|
||||||
{
|
{
|
||||||
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,
|
||||||
|
titleColor: '#1a1a1a',
|
||||||
|
ambient: 0.68,
|
||||||
|
warmLight: '#fff6ea',
|
||||||
|
sunLight: '#f4f8ff',
|
||||||
windows: windows(
|
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.6, y: 2.55, width: 1.5, height: 1.7, style: 'picture', lightColor: '#eef4ff', lightIntensity: 3.4 },
|
||||||
{ wall: 'back', x: 1.5, y: 2.6, width: 0.8, height: 1.8, style: 'gothic-lancet', lightColor: '#ffe8c0', lightIntensity: 2.0 },
|
{ wall: 'back', x: 1.7, y: 2.7, width: 1.1, height: 1.35, style: 'picture', lightColor: '#fff0d8', lightIntensity: 2.8 },
|
||||||
{ wall: 'right', x: 0, y: 2.4, width: 1.6, height: 1.6, style: 'factory', lightColor: '#ffffff', lightIntensity: 3.0 }
|
{ wall: 'right', x: 0, y: 2.5, width: 2.2, height: 1.9, style: 'picture', lightColor: '#f5f9ff', lightIntensity: 3.8 }
|
||||||
),
|
),
|
||||||
trackLights: 1.0,
|
trackLights: 1.0,
|
||||||
|
doorWood: ['#2a2a2a', '#3a3a3a', '#1a1a1a'],
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
50: mk(
|
50: mk(
|
||||||
@@ -518,7 +612,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||||
{ wall: '#1a2848', ceiling: '#2a2428', floor: '#5a4838', trim: '#8a7050' },
|
{ wall: '#1a2848', ceiling: '#2a2428', floor: '#5a4838', trim: '#8a7050' },
|
||||||
{
|
{
|
||||||
details: 'salon',
|
details: 'surrealist',
|
||||||
titleColor: '#e8dcc8',
|
titleColor: '#e8dcc8',
|
||||||
ambient: 0.58,
|
ambient: 0.58,
|
||||||
warmLight: '#ffd898',
|
warmLight: '#ffd898',
|
||||||
@@ -532,18 +626,25 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
),
|
),
|
||||||
51: mk(
|
51: mk(
|
||||||
'abstract-expressionist-loft',
|
'abstract-expressionist-loft',
|
||||||
'NYC loft',
|
'Painter loft',
|
||||||
'Raw brick and painted plaster · north-light factory windows',
|
'Whitewashed plaster · warm timber · open north-light picture windows',
|
||||||
{ wall: 'brick', wallSide: 'painted-flat', ceiling: 'concrete-raw', floor: 'industrial-floor' },
|
{ wall: 'painted-flat', wallSide: 'painted-emulsion', ceiling: 'plaster-warm', floor: 'industrial-floor' },
|
||||||
{ wall: '#8a5040', wallSide: '#f5f5f5', ceiling: '#989898', floor: '#888880', trim: '#606060' },
|
{ wall: '#e8e2d6', wallSide: '#ddd5c6', ceiling: '#f0ebe2', floor: '#9a9288', trim: '#6a5848' },
|
||||||
{
|
{
|
||||||
details: 'industrial',
|
details: 'loft',
|
||||||
titleColor: '#303030',
|
// Lit like the basilica: shared lights held back so the walls read as
|
||||||
ambient: 0.65,
|
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||||
|
lightScale: 0.3,
|
||||||
|
titleColor: '#3a3028',
|
||||||
|
ambient: 0.72,
|
||||||
|
warmLight: '#fff6e8',
|
||||||
|
sunLight: '#f2f6ff',
|
||||||
windows: windows(
|
windows: windows(
|
||||||
{ wall: 'left', x: 0, y: 2.5, width: 4.0, height: 2.6, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 6.5 },
|
{ wall: 'left', x: -1.2, y: 2.55, width: 2.4, height: 2.3, style: 'picture', lightColor: '#f0f6ff', lightIntensity: 5.5 },
|
||||||
{ wall: 'ceiling', x: 0, y: 0, width: 3.5, height: 2.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 4.5 }
|
{ wall: 'left', x: 1.4, y: 2.55, width: 2.2, height: 2.3, style: 'picture', lightColor: '#f0f6ff', lightIntensity: 5.2 },
|
||||||
|
{ wall: 'ceiling', x: 0, y: 0, width: 3.2, height: 1.8, style: 'skylight', lightColor: '#ffffff', lightIntensity: 4.2 }
|
||||||
),
|
),
|
||||||
|
doorWood: ['#5a4838', '#6a5848', '#4a3828'],
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
52: mk(
|
52: mk(
|
||||||
@@ -551,16 +652,19 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Pop Art gallery',
|
'Pop Art gallery',
|
||||||
'White painted walls · fluorescent daylight · polished concrete',
|
'White painted walls · fluorescent daylight · polished concrete',
|
||||||
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
|
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
|
||||||
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#d8d8d8', trim: '#cccccc' },
|
{ wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#909090', 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',
|
titleColor: '#222222',
|
||||||
ambient: 0.75,
|
ambient: 0.75,
|
||||||
warmLight: '#ffffff',
|
warmLight: '#ffffff',
|
||||||
fog: '#1a1a1a',
|
fog: '#1a1a1a',
|
||||||
background: '#101010',
|
background: '#101010',
|
||||||
windows: windows(
|
windows: windows(
|
||||||
{ wall: 'back', x: 0, y: 2.6, width: 3.0, height: 2.0, style: 'factory', lightColor: '#ffffff', lightIntensity: 5.5 },
|
{ wall: 'back', x: 0, y: 2.6, width: 3.0, height: 2.0, style: 'ribbon', lightColor: '#ffffff', lightIntensity: 5.5 },
|
||||||
{ wall: 'ceiling', x: 0, y: 0, width: 5.0, height: 3.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 6.0 }
|
{ wall: 'ceiling', x: 0, y: 0, width: 5.0, height: 3.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 6.0 }
|
||||||
),
|
),
|
||||||
trackLights: 0.6,
|
trackLights: 0.6,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Documented art-historical movement lineage (predecessor → successor).
|
* Art-historical movement lineage (predecessor → successor).
|
||||||
* Sources: Met Museum essays, TheArtStory, Sotheby's movement guides.
|
* Defaults match museum essays; live data comes from GET /api/movement-links.
|
||||||
*
|
|
||||||
* Multiple parents are allowed (e.g. Post-Impressionism feeding several modern paths).
|
* Multiple parents are allowed (e.g. Post-Impressionism feeding several modern paths).
|
||||||
*/
|
*/
|
||||||
export const MOVEMENT_LINEAGE: ReadonlyArray<readonly [parent: string, child: string]> = [
|
|
||||||
|
export type MovementLineageEdge = readonly [parent: string, child: string];
|
||||||
|
|
||||||
|
export const FALLBACK_MOVEMENT_LINEAGE: ReadonlyArray<MovementLineageEdge> = [
|
||||||
// Classical & medieval
|
// Classical & medieval
|
||||||
['Ancient Greek & Roman', 'Byzantine'],
|
['Ancient Greek & Roman', 'Byzantine'],
|
||||||
['Byzantine', 'Gothic'],
|
['Byzantine', 'Gothic'],
|
||||||
@@ -21,17 +23,17 @@ export const MOVEMENT_LINEAGE: ReadonlyArray<readonly [parent: string, child: st
|
|||||||
['Rococo', 'Neoclassicism'],
|
['Rococo', 'Neoclassicism'],
|
||||||
['Neoclassicism', 'Romanticism'],
|
['Neoclassicism', 'Romanticism'],
|
||||||
['Romanticism', 'Realism'],
|
['Romanticism', 'Realism'],
|
||||||
['Romanticism', 'Impressionism'], // Turner, Delacroix → light and atmosphere
|
['Romanticism', 'Impressionism'],
|
||||||
['Realism', 'Impressionism'],
|
['Realism', 'Impressionism'],
|
||||||
|
|
||||||
// Modern origins (Met / ArtStory: Impressionism → Post-Impressionism → …)
|
// Modern origins
|
||||||
['Impressionism', 'Post-Impressionism'],
|
['Impressionism', 'Post-Impressionism'],
|
||||||
['Post-Impressionism', 'Symbolism'],
|
['Post-Impressionism', 'Symbolism'],
|
||||||
['Post-Impressionism', 'Fauvism'],
|
['Post-Impressionism', 'Fauvism'],
|
||||||
['Post-Impressionism', 'Expressionism'],
|
['Post-Impressionism', 'Expressionism'],
|
||||||
['Post-Impressionism', 'Cubism'], // Cézanne's structure → Picasso/Braque
|
['Post-Impressionism', 'Cubism'],
|
||||||
['Symbolism', 'Art Nouveau'],
|
['Symbolism', 'Art Nouveau'],
|
||||||
['Fauvism', 'Cubism'], // Braque, Matisse → geometric turn
|
['Fauvism', 'Cubism'],
|
||||||
['Cubism', 'Futurism'],
|
['Cubism', 'Futurism'],
|
||||||
['Cubism', 'Suprematism'],
|
['Cubism', 'Suprematism'],
|
||||||
['Cubism', 'Constructivism'],
|
['Cubism', 'Constructivism'],
|
||||||
@@ -43,10 +45,35 @@ export const MOVEMENT_LINEAGE: ReadonlyArray<readonly [parent: string, child: st
|
|||||||
['Abstract Expressionism', 'Pop Art'],
|
['Abstract Expressionism', 'Pop Art'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let activeLineage: ReadonlyArray<MovementLineageEdge> = FALLBACK_MOVEMENT_LINEAGE;
|
||||||
|
|
||||||
|
/** Active edges for timeline / tree layout (API-backed when loaded). */
|
||||||
|
export function getMovementLineage(): ReadonlyArray<MovementLineageEdge> {
|
||||||
|
return activeLineage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply curator DB rows; empty list keeps the static fallback. */
|
||||||
|
export function setMovementLineageFromApi(
|
||||||
|
items: ReadonlyArray<{ source_name: string; target_name: string }>
|
||||||
|
): void {
|
||||||
|
if (!items.length) {
|
||||||
|
activeLineage = FALLBACK_MOVEMENT_LINEAGE;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeLineage = items.map((row) => [row.source_name, row.target_name] as const);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use getMovementLineage() — alias for older imports. */
|
||||||
|
export const MOVEMENT_LINEAGE = FALLBACK_MOVEMENT_LINEAGE;
|
||||||
|
|
||||||
export function lineageParentsByName(childName: string): string[] {
|
export function lineageParentsByName(childName: string): string[] {
|
||||||
return MOVEMENT_LINEAGE.filter(([, child]) => child === childName).map(([parent]) => parent);
|
return getMovementLineage()
|
||||||
|
.filter(([, child]) => child === childName)
|
||||||
|
.map(([parent]) => parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lineageChildrenByName(parentName: string): string[] {
|
export function lineageChildrenByName(parentName: string): string[] {
|
||||||
return MOVEMENT_LINEAGE.filter(([parent]) => parent === parentName).map(([, child]) => child);
|
return getMovementLineage()
|
||||||
|
.filter(([parent]) => parent === parentName)
|
||||||
|
.map(([, child]) => child);
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import enAnnotations from '../locales/en/annotations.json';
|
|||||||
import enDebug from '../locales/en/debug.json';
|
import enDebug from '../locales/en/debug.json';
|
||||||
import enTranslations from '../locales/en/translations.json';
|
import enTranslations from '../locales/en/translations.json';
|
||||||
import enInfluences from '../locales/en/influences.json';
|
import enInfluences from '../locales/en/influences.json';
|
||||||
|
import enMovementLinks from '../locales/en/movementLinks.json';
|
||||||
import enTours from '../locales/en/tours.json';
|
import enTours from '../locales/en/tours.json';
|
||||||
import enUsers from '../locales/en/users.json';
|
import enUsers from '../locales/en/users.json';
|
||||||
import enAudit from '../locales/en/audit.json';
|
import enAudit from '../locales/en/audit.json';
|
||||||
@@ -26,6 +27,7 @@ import ruAnnotations from '../locales/ru/annotations.json';
|
|||||||
import ruDebug from '../locales/ru/debug.json';
|
import ruDebug from '../locales/ru/debug.json';
|
||||||
import ruTranslations from '../locales/ru/translations.json';
|
import ruTranslations from '../locales/ru/translations.json';
|
||||||
import ruInfluences from '../locales/ru/influences.json';
|
import ruInfluences from '../locales/ru/influences.json';
|
||||||
|
import ruMovementLinks from '../locales/ru/movementLinks.json';
|
||||||
import ruTours from '../locales/ru/tours.json';
|
import ruTours from '../locales/ru/tours.json';
|
||||||
import ruUsers from '../locales/ru/users.json';
|
import ruUsers from '../locales/ru/users.json';
|
||||||
import ruAudit from '../locales/ru/audit.json';
|
import ruAudit from '../locales/ru/audit.json';
|
||||||
@@ -37,7 +39,7 @@ void i18n.use(initReactI18next).init({
|
|||||||
lng: initialLocale,
|
lng: initialLocale,
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
supportedLngs: ['en', 'ru'],
|
supportedLngs: ['en', 'ru'],
|
||||||
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours', 'users', 'audit'],
|
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'movementLinks', 'tours', 'users', 'audit'],
|
||||||
defaultNS: 'common',
|
defaultNS: 'common',
|
||||||
resources: {
|
resources: {
|
||||||
en: {
|
en: {
|
||||||
@@ -51,6 +53,7 @@ void i18n.use(initReactI18next).init({
|
|||||||
debug: enDebug,
|
debug: enDebug,
|
||||||
translations: enTranslations,
|
translations: enTranslations,
|
||||||
influences: enInfluences,
|
influences: enInfluences,
|
||||||
|
movementLinks: enMovementLinks,
|
||||||
tours: enTours,
|
tours: enTours,
|
||||||
users: enUsers,
|
users: enUsers,
|
||||||
audit: enAudit,
|
audit: enAudit,
|
||||||
@@ -66,6 +69,7 @@ void i18n.use(initReactI18next).init({
|
|||||||
debug: ruDebug,
|
debug: ruDebug,
|
||||||
translations: ruTranslations,
|
translations: ruTranslations,
|
||||||
influences: ruInfluences,
|
influences: ruInfluences,
|
||||||
|
movementLinks: ruMovementLinks,
|
||||||
tours: ruTours,
|
tours: ruTours,
|
||||||
users: ruUsers,
|
users: ruUsers,
|
||||||
audit: ruAudit,
|
audit: ruAudit,
|
||||||
|
|||||||
@@ -15,12 +15,19 @@
|
|||||||
"checkup": "Checkup",
|
"checkup": "Checkup",
|
||||||
"translations": "Translations",
|
"translations": "Translations",
|
||||||
"influences": "Influences",
|
"influences": "Influences",
|
||||||
|
"movementLinks": "Movement links",
|
||||||
"tours": "Tours",
|
"tours": "Tours",
|
||||||
"toursEditor": "Tour editor",
|
"toursEditor": "Tour editor",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"audit": "Activity",
|
"audit": "Activity",
|
||||||
"layoutHorizontal": "Classic timeline →",
|
"layoutHorizontal": "Classic timeline →",
|
||||||
"layoutVertical": "↑ Vertical timeline",
|
"layoutVertical": "↑ Vertical timeline",
|
||||||
|
"layoutTree": "🌳 Tree of art",
|
||||||
|
"captionClassicTimeline": "Click an era or event to zoom · Scroll to zoom · Drag to pan",
|
||||||
|
"captionClassicFlow": "Scroll to zoom · drag to pan · each movement stream is a solid colour band through history",
|
||||||
|
"captionVerticalTimeline": "Bottom → top · Scroll to zoom · Drag to pan",
|
||||||
|
"captionVerticalFlow": "Bottom → top through history · scroll to zoom · drag to pan · click a stream",
|
||||||
|
"captionTreeFlow": "Roots at the bottom, living movements at the crown · scroll to zoom · drag to pan · click a branch to enter its gallery",
|
||||||
"openingTourGallery": "Opening guided tour…",
|
"openingTourGallery": "Opening guided tour…",
|
||||||
"tourEmpty": "This tour has no paintings yet.",
|
"tourEmpty": "This tour has no paintings yet.",
|
||||||
"tourLoadFailed": "Failed to load the tour.",
|
"tourLoadFailed": "Failed to load the tour.",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "Influence links",
|
"title": "Influence links",
|
||||||
"back": "← Back to gallery",
|
"back": "← Back to gallery",
|
||||||
|
"openMovementLinks": "Movement links →",
|
||||||
"tabList": "List",
|
"tabList": "List",
|
||||||
"tabImport": "Import",
|
"tabImport": "Import",
|
||||||
"tabGraph": "Graph",
|
"tabGraph": "Graph",
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"title": "Movement links",
|
||||||
|
"back": "← Back to gallery",
|
||||||
|
"intro": "Predecessor → successor edges used by the timeline flow and Tree of Art. Every link needs a proof URL (museum essay, literature, or other web reference).",
|
||||||
|
"introCurator": "Curators can vote to delete a link and must give a reason. Only an admin can perform the final delete after reviewing votes.",
|
||||||
|
"introAdmin": "As admin you can delete links directly. Review curator delete votes (reasons) before confirming.",
|
||||||
|
"loadFailed": "Failed to load movement links",
|
||||||
|
"saveFailed": "Failed to save movement link",
|
||||||
|
"deleteFailed": "Failed to delete movement link",
|
||||||
|
"voteFailed": "Failed to cast delete vote",
|
||||||
|
"withdrawFailed": "Failed to withdraw vote",
|
||||||
|
"searchPlaceholder": "Search source, target, proof…",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"loadingVotes": "Loading votes…",
|
||||||
|
"votesLoadFailed": "Could not load votes.",
|
||||||
|
"total": "{{count}} links",
|
||||||
|
"noLinks": "No movement links yet.",
|
||||||
|
"addLink": "Add link",
|
||||||
|
"cancelAdd": "Cancel",
|
||||||
|
"addTitle": "New movement link",
|
||||||
|
"source": "Source (from)",
|
||||||
|
"target": "Target (to)",
|
||||||
|
"pickMovement": "Select movement…",
|
||||||
|
"proofUrl": "Proof URL",
|
||||||
|
"proofLabel": "Proof label",
|
||||||
|
"proofLabelPlaceholder": "e.g. Met — Cubism",
|
||||||
|
"linkYear": "Link date ≈",
|
||||||
|
"linkYearPlaceholder": "e.g. 1907",
|
||||||
|
"notes": "Notes",
|
||||||
|
"saveLink": "Save link",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"addRequiresMovements": "Pick both source and target movements",
|
||||||
|
"proofRequired": "Proof URL is required",
|
||||||
|
"voteTitle": "Vote to delete",
|
||||||
|
"voteReason": "Reason",
|
||||||
|
"voteReasonPlaceholder": "Explain why this link should be removed (min. 10 characters)…",
|
||||||
|
"voteReasonRequired": "Reason must be at least 10 characters",
|
||||||
|
"submitVote": "Submit vote",
|
||||||
|
"cancelVote": "Cancel",
|
||||||
|
"voteDelete": "Vote to delete",
|
||||||
|
"withdrawVote": "Withdraw vote",
|
||||||
|
"voteCount": "{{count}} vote(s)",
|
||||||
|
"colSource": "Source",
|
||||||
|
"colTarget": "Target",
|
||||||
|
"colYear": "Link date ≈",
|
||||||
|
"colProof": "Reference / proof",
|
||||||
|
"colVotes": "Delete votes",
|
||||||
|
"colActions": "Actions",
|
||||||
|
"delete": "Delete",
|
||||||
|
"confirmDelete": "Delete this movement link? Curator vote reasons will be recorded in the audit log."
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"perm_checkup": "Checkup",
|
"perm_checkup": "Checkup",
|
||||||
"perm_curator_notes": "Curator notes",
|
"perm_curator_notes": "Curator notes",
|
||||||
"perm_translations": "Translations",
|
"perm_translations": "Translations",
|
||||||
"perm_influences": "Influences",
|
"perm_influences": "Influences & movement links",
|
||||||
"perm_tours": "Tours",
|
"perm_tours": "Tours",
|
||||||
"perm_users": "Users",
|
"perm_users": "Users",
|
||||||
"adminAllPerms": "Admins have all permissions automatically.",
|
"adminAllPerms": "Admins have all permissions automatically.",
|
||||||
|
|||||||
@@ -15,12 +15,19 @@
|
|||||||
"checkup": "Проверка",
|
"checkup": "Проверка",
|
||||||
"translations": "Переводы",
|
"translations": "Переводы",
|
||||||
"influences": "Влияния",
|
"influences": "Влияния",
|
||||||
|
"movementLinks": "Связи движений",
|
||||||
"tours": "Экскурсии",
|
"tours": "Экскурсии",
|
||||||
"toursEditor": "Редактор экскурсий",
|
"toursEditor": "Редактор экскурсий",
|
||||||
"users": "Пользователи",
|
"users": "Пользователи",
|
||||||
"audit": "Активность",
|
"audit": "Активность",
|
||||||
"layoutHorizontal": "Классическая шкала →",
|
"layoutHorizontal": "Классическая шкала →",
|
||||||
"layoutVertical": "↑ Вертикальная шкала",
|
"layoutVertical": "↑ Вертикальная шкала",
|
||||||
|
"layoutTree": "🌳 Древо искусства",
|
||||||
|
"captionClassicTimeline": "Щёлкните эпоху или событие для приближения · Колесо — масштаб · Перетащите для панорамы",
|
||||||
|
"captionClassicFlow": "Колесо — масштаб · перетащите для панорамы · каждое направление — цветная полоса сквозь историю",
|
||||||
|
"captionVerticalTimeline": "Снизу вверх · Колесо — масштаб · Перетащите для панорамы",
|
||||||
|
"captionVerticalFlow": "Снизу вверх по истории · колесо — масштаб · перетащите для панорамы · щёлкните поток",
|
||||||
|
"captionTreeFlow": "Корни внизу, живые направления в кроне · колесо — масштаб · перетащите для панорамы · щёлкните ветвь, чтобы войти в зал",
|
||||||
"openingTourGallery": "Открытие экскурсии…",
|
"openingTourGallery": "Открытие экскурсии…",
|
||||||
"tourEmpty": "В этой экскурсии пока нет картин.",
|
"tourEmpty": "В этой экскурсии пока нет картин.",
|
||||||
"tourLoadFailed": "Не удалось загрузить экскурсию.",
|
"tourLoadFailed": "Не удалось загрузить экскурсию.",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "Связи влияния",
|
"title": "Связи влияния",
|
||||||
"back": "← Назад в галерею",
|
"back": "← Назад в галерею",
|
||||||
|
"openMovementLinks": "Связи движений →",
|
||||||
"tabList": "Список",
|
"tabList": "Список",
|
||||||
"tabImport": "Импорт",
|
"tabImport": "Импорт",
|
||||||
"tabGraph": "Граф",
|
"tabGraph": "Граф",
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"title": "Связи движений",
|
||||||
|
"back": "← Назад в галерею",
|
||||||
|
"intro": "Связи предшественник → преемник для ленты и «Дерева искусства». Для каждой связи нужна ссылка-доказательство (музейный очерк, литература или интернет).",
|
||||||
|
"introCurator": "Кураторы могут голосовать за удаление связи с указанием причины. Окончательное удаление выполняет только администратор после просмотра голосов.",
|
||||||
|
"introAdmin": "Как администратор вы можете удалять связи напрямую. Перед подтверждением просмотрите голоса кураторов и их причины.",
|
||||||
|
"loadFailed": "Не удалось загрузить связи движений",
|
||||||
|
"saveFailed": "Не удалось сохранить связь",
|
||||||
|
"deleteFailed": "Не удалось удалить связь",
|
||||||
|
"voteFailed": "Не удалось отправить голос",
|
||||||
|
"withdrawFailed": "Не удалось отозвать голос",
|
||||||
|
"searchPlaceholder": "Поиск: источник, цель, ссылка…",
|
||||||
|
"refresh": "Обновить",
|
||||||
|
"loading": "Загрузка…",
|
||||||
|
"loadingVotes": "Загрузка голосов…",
|
||||||
|
"votesLoadFailed": "Не удалось загрузить голоса.",
|
||||||
|
"total": "{{count}} связей",
|
||||||
|
"noLinks": "Связей движений пока нет.",
|
||||||
|
"addLink": "Добавить связь",
|
||||||
|
"cancelAdd": "Отмена",
|
||||||
|
"addTitle": "Новая связь движений",
|
||||||
|
"source": "Источник (от)",
|
||||||
|
"target": "Цель (к)",
|
||||||
|
"pickMovement": "Выберите движение…",
|
||||||
|
"proofUrl": "Ссылка-доказательство",
|
||||||
|
"proofLabel": "Подпись ссылки",
|
||||||
|
"proofLabelPlaceholder": "напр. Met — Cubism",
|
||||||
|
"linkYear": "Дата связи ≈",
|
||||||
|
"linkYearPlaceholder": "напр. 1907",
|
||||||
|
"notes": "Заметки",
|
||||||
|
"saveLink": "Сохранить",
|
||||||
|
"saving": "Сохранение…",
|
||||||
|
"addRequiresMovements": "Выберите исходное и целевое движение",
|
||||||
|
"proofRequired": "Ссылка-доказательство обязательна",
|
||||||
|
"voteTitle": "Голос за удаление",
|
||||||
|
"voteReason": "Причина",
|
||||||
|
"voteReasonPlaceholder": "Почему связь нужно удалить (мин. 10 символов)…",
|
||||||
|
"voteReasonRequired": "Причина должна быть не короче 10 символов",
|
||||||
|
"submitVote": "Отправить голос",
|
||||||
|
"cancelVote": "Отмена",
|
||||||
|
"voteDelete": "Голосовать за удаление",
|
||||||
|
"withdrawVote": "Отозвать голос",
|
||||||
|
"voteCount": "{{count}} голос(ов)",
|
||||||
|
"colSource": "Источник",
|
||||||
|
"colTarget": "Цель",
|
||||||
|
"colYear": "Дата ≈",
|
||||||
|
"colProof": "Ссылка / доказательство",
|
||||||
|
"colVotes": "Голоса за удаление",
|
||||||
|
"colActions": "Действия",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"confirmDelete": "Удалить эту связь движений? Причины голосов кураторов будут записаны в журнал аудита."
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"perm_checkup": "Проверка",
|
"perm_checkup": "Проверка",
|
||||||
"perm_curator_notes": "Заметки куратора",
|
"perm_curator_notes": "Заметки куратора",
|
||||||
"perm_translations": "Переводы",
|
"perm_translations": "Переводы",
|
||||||
"perm_influences": "Влияния",
|
"perm_influences": "Влияния и связи движений",
|
||||||
"perm_tours": "Экскурсии",
|
"perm_tours": "Экскурсии",
|
||||||
"perm_users": "Пользователи",
|
"perm_users": "Пользователи",
|
||||||
"adminAllPerms": "У администраторов все права автоматически.",
|
"adminAllPerms": "У администраторов все права автоматически.",
|
||||||
|
|||||||
@@ -44,6 +44,9 @@
|
|||||||
top: 16px;
|
top: 16px;
|
||||||
left: 16px;
|
left: 16px;
|
||||||
z-index: 120;
|
z-index: 120;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-layout-link {
|
.site-layout-link {
|
||||||
@@ -63,6 +66,17 @@
|
|||||||
color: #f5e6c8;
|
color: #f5e6c8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Link to the alternative tree start page — the one worth noticing. */
|
||||||
|
.site-layout-link-feature {
|
||||||
|
border-color: rgba(201, 169, 110, 0.7);
|
||||||
|
background: rgba(201, 169, 110, 0.16);
|
||||||
|
color: #f5e6c8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-layout-link-feature:hover {
|
||||||
|
background: rgba(201, 169, 110, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
.gallery-session-suspended {
|
.gallery-session-suspended {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -102,8 +116,12 @@
|
|||||||
top: 16px;
|
top: 16px;
|
||||||
right: 16px;
|
right: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
max-width: min(780px, calc(100vw - 24px));
|
||||||
|
z-index: 120;
|
||||||
}
|
}
|
||||||
|
|
||||||
.debug-mode-toggle,
|
.debug-mode-toggle,
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import TimelineEventGuides from '../components/TimelineEventGuides';
|
|||||||
import MovementBands from '../components/MovementBands';
|
import MovementBands from '../components/MovementBands';
|
||||||
import VerticalTimeline from '../components/VerticalTimeline';
|
import VerticalTimeline from '../components/VerticalTimeline';
|
||||||
import VerticalMovementBands from '../components/VerticalMovementBands';
|
import VerticalMovementBands from '../components/VerticalMovementBands';
|
||||||
|
import MovementTree from '../components/MovementTree';
|
||||||
import VirtualGallery from '../components/VirtualGallery';
|
import VirtualGallery from '../components/VirtualGallery';
|
||||||
import PaintingDetailView from '../components/PaintingDetail';
|
import PaintingDetailView from '../components/PaintingDetail';
|
||||||
import ArtistBio from '../components/ArtistBio';
|
import ArtistBio from '../components/ArtistBio';
|
||||||
import CheckupPage from '../pages/CheckupPage';
|
import CheckupPage from '../pages/CheckupPage';
|
||||||
import TranslationsPage from '../pages/TranslationsPage';
|
import TranslationsPage from '../pages/TranslationsPage';
|
||||||
import InfluencesPage from '../pages/InfluencesPage';
|
import InfluencesPage from '../pages/InfluencesPage';
|
||||||
|
import MovementLinksPage from '../pages/MovementLinksPage';
|
||||||
import ToursPage from '../pages/ToursPage';
|
import ToursPage from '../pages/ToursPage';
|
||||||
import UsersPage from '../pages/UsersPage';
|
import UsersPage from '../pages/UsersPage';
|
||||||
import AuditPage from '../pages/AuditPage';
|
import AuditPage from '../pages/AuditPage';
|
||||||
@@ -27,11 +29,13 @@ import '../components/ToursPopup.css';
|
|||||||
import '../components/LocaleSwitcher.css';
|
import '../components/LocaleSwitcher.css';
|
||||||
import '../pages/TranslationsPage.css';
|
import '../pages/TranslationsPage.css';
|
||||||
import '../pages/InfluencesPage.css';
|
import '../pages/InfluencesPage.css';
|
||||||
|
import '../pages/MovementLinksPage.css';
|
||||||
import '../pages/ToursPage.css';
|
import '../pages/ToursPage.css';
|
||||||
import '../pages/UsersPage.css';
|
import '../pages/UsersPage.css';
|
||||||
import '../pages/AuditPage.css';
|
import '../pages/AuditPage.css';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||||
|
import { setMovementLineageFromApi } from '../data/movement-lineage';
|
||||||
import type {
|
import type {
|
||||||
TimelineData,
|
TimelineData,
|
||||||
Artist,
|
Artist,
|
||||||
@@ -50,9 +54,11 @@ import './HomePage.css';
|
|||||||
type View =
|
type View =
|
||||||
| { type: 'timeline' }
|
| { type: 'timeline' }
|
||||||
| { type: 'timeline-vertical' }
|
| { type: 'timeline-vertical' }
|
||||||
|
| { type: 'timeline-tree' }
|
||||||
| { type: 'checkup' }
|
| { type: 'checkup' }
|
||||||
| { type: 'translations' }
|
| { type: 'translations' }
|
||||||
| { type: 'influences' }
|
| { type: 'influences' }
|
||||||
|
| { type: 'movement-links' }
|
||||||
| { type: 'tours' }
|
| { type: 'tours' }
|
||||||
| { type: 'users' }
|
| { type: 'users' }
|
||||||
| { type: 'audit' }
|
| { type: 'audit' }
|
||||||
@@ -67,7 +73,7 @@ type GallerySession =
|
|||||||
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail }
|
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail }
|
||||||
| { kind: 'tour'; tourId: number; data: TourGalleryDetail };
|
| { kind: 'tour'; tourId: number; data: TourGalleryDetail };
|
||||||
|
|
||||||
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | 'users' | 'audit' | null;
|
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'movement-links' | 'tours' | 'users' | 'audit' | null;
|
||||||
|
|
||||||
function patchPaintingInMovementDetail(
|
function patchPaintingInMovementDetail(
|
||||||
detail: MovementGalleryDetail,
|
detail: MovementGalleryDetail,
|
||||||
@@ -157,6 +163,37 @@ function catalogNavigateTarget(
|
|||||||
return idx < remaining.length ? remaining[idx].id : remaining[remaining.length - 1].id;
|
return idx < remaining.length ? remaining[idx].id : remaining[remaining.length - 1].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shareable timeline layout via `?layout=classic|vertical|tree` (classic may omit the param). */
|
||||||
|
type TimelineLayoutId = 'classic' | 'vertical' | 'tree';
|
||||||
|
|
||||||
|
function parseTimelineLayoutParam(raw: string | null): TimelineLayoutId {
|
||||||
|
if (raw === 'vertical' || raw === 'tree') return raw;
|
||||||
|
if (raw === 'classic' || raw === 'horizontal') return 'classic';
|
||||||
|
return 'classic';
|
||||||
|
}
|
||||||
|
|
||||||
|
function timelineViewFromLayout(layout: TimelineLayoutId): View {
|
||||||
|
if (layout === 'vertical') return { type: 'timeline-vertical' };
|
||||||
|
if (layout === 'tree') return { type: 'timeline-tree' };
|
||||||
|
return { type: 'timeline' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTimelineLayoutParam(layout: TimelineLayoutId) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (layout === 'classic') url.searchParams.delete('layout');
|
||||||
|
else url.searchParams.set('layout', layout);
|
||||||
|
const next = `${url.pathname}${url.search}${url.hash}`;
|
||||||
|
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||||
|
if (next !== current) window.history.replaceState(null, '', next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readInitialTimelineView(): View {
|
||||||
|
if (typeof window === 'undefined') return { type: 'timeline' };
|
||||||
|
return timelineViewFromLayout(
|
||||||
|
parseTimelineLayoutParam(new URLSearchParams(window.location.search).get('layout'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { t } = useTranslation('home');
|
const { t } = useTranslation('home');
|
||||||
const { isCurator, isAdmin, username, login, logout, can } = useAuth();
|
const { isCurator, isAdmin, username, login, logout, can } = useAuth();
|
||||||
@@ -167,7 +204,7 @@ export default function HomePage() {
|
|||||||
const canInfluences = can('influences');
|
const canInfluences = can('influences');
|
||||||
const canTours = can('tours');
|
const canTours = can('tours');
|
||||||
const canUsers = can('users');
|
const canUsers = can('users');
|
||||||
const [view, setView] = useState<View>({ type: 'timeline' });
|
const [view, setView] = useState<View>(readInitialTimelineView);
|
||||||
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
|
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
|
||||||
const [bounds, setBounds] = useState({ min: -800, max: 2025 });
|
const [bounds, setBounds] = useState({ min: -800, max: 2025 });
|
||||||
const [viewStart, setViewStart] = useState(-800);
|
const [viewStart, setViewStart] = useState(-800);
|
||||||
@@ -210,7 +247,11 @@ export default function HomePage() {
|
|||||||
setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data });
|
setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data });
|
||||||
} else if (view.type === 'tour-gallery') {
|
} else if (view.type === 'tour-gallery') {
|
||||||
setGallerySession({ kind: 'tour', tourId: view.tourId, data: view.data });
|
setGallerySession({ kind: 'tour', tourId: view.tourId, data: view.data });
|
||||||
} else if (view.type === 'timeline' || view.type === 'timeline-vertical') {
|
} else if (
|
||||||
|
view.type === 'timeline' ||
|
||||||
|
view.type === 'timeline-vertical' ||
|
||||||
|
view.type === 'timeline-tree'
|
||||||
|
) {
|
||||||
setGallerySession(null);
|
setGallerySession(null);
|
||||||
}
|
}
|
||||||
}, [view]);
|
}, [view]);
|
||||||
@@ -222,9 +263,14 @@ export default function HomePage() {
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const catalog = await api.getCatalogBootstrap();
|
const [catalog, lineage] = await Promise.all([
|
||||||
|
api.getCatalogBootstrap(),
|
||||||
|
api.listMovementLinks({ limit: 500 }).catch(() => ({ items: [] as Awaited<ReturnType<typeof api.listMovementLinks>>['items'] })),
|
||||||
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setMovementLineageFromApi(lineage.items);
|
||||||
|
|
||||||
const min = catalog.bounds.min_year ?? -800;
|
const min = catalog.bounds.min_year ?? -800;
|
||||||
const max = catalog.bounds.max_year ?? 2025;
|
const max = catalog.bounds.max_year ?? 2025;
|
||||||
setBounds({ min, max });
|
setBounds({ min, max });
|
||||||
@@ -271,19 +317,33 @@ export default function HomePage() {
|
|||||||
setViewStart(bounds.min);
|
setViewStart(bounds.min);
|
||||||
setViewEnd(bounds.max);
|
setViewEnd(bounds.max);
|
||||||
setGalleryRevision((revision) => revision + 1);
|
setGalleryRevision((revision) => revision + 1);
|
||||||
|
writeTimelineLayoutParam('classic');
|
||||||
setView({ type: 'timeline' });
|
setView({ type: 'timeline' });
|
||||||
}, [bounds.min, bounds.max]);
|
}, [bounds.min, bounds.max]);
|
||||||
|
|
||||||
const openHorizontalTimeline = () => {
|
const openHorizontalTimeline = () => {
|
||||||
|
writeTimelineLayoutParam('classic');
|
||||||
setView({ type: 'timeline' });
|
setView({ type: 'timeline' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const openVerticalTimeline = () => {
|
const openVerticalTimeline = () => {
|
||||||
|
writeTimelineLayoutParam('vertical');
|
||||||
setView({ type: 'timeline-vertical' });
|
setView({ type: 'timeline-vertical' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const isTimelineHome = view.type === 'timeline' || view.type === 'timeline-vertical';
|
const openTreeTimeline = () => {
|
||||||
|
writeTimelineLayoutParam('tree');
|
||||||
|
setView({ type: 'timeline-tree' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTimelineHome =
|
||||||
|
view.type === 'timeline' ||
|
||||||
|
view.type === 'timeline-vertical' ||
|
||||||
|
view.type === 'timeline-tree';
|
||||||
const isVerticalTimeline = view.type === 'timeline-vertical';
|
const isVerticalTimeline = view.type === 'timeline-vertical';
|
||||||
|
const isTreeTimeline = view.type === 'timeline-tree';
|
||||||
|
/** Both alternative layouts run the year axis bottom → top beside the chart. */
|
||||||
|
const isVerticalLayout = isVerticalTimeline || isTreeTimeline;
|
||||||
|
|
||||||
const toggleDebugMode = () => {
|
const toggleDebugMode = () => {
|
||||||
setDebugMode((prev) => {
|
setDebugMode((prev) => {
|
||||||
@@ -312,6 +372,8 @@ export default function HomePage() {
|
|||||||
setView({ type: 'translations' });
|
setView({ type: 'translations' });
|
||||||
} else if (loginRedirect === 'influences') {
|
} else if (loginRedirect === 'influences') {
|
||||||
setView({ type: 'influences' });
|
setView({ type: 'influences' });
|
||||||
|
} else if (loginRedirect === 'movement-links') {
|
||||||
|
setView({ type: 'movement-links' });
|
||||||
} else if (loginRedirect === 'tours') {
|
} else if (loginRedirect === 'tours') {
|
||||||
setView({ type: 'tours' });
|
setView({ type: 'tours' });
|
||||||
} else if (loginRedirect === 'users') {
|
} else if (loginRedirect === 'users') {
|
||||||
@@ -330,6 +392,7 @@ export default function HomePage() {
|
|||||||
view.type === 'checkup' ||
|
view.type === 'checkup' ||
|
||||||
view.type === 'translations' ||
|
view.type === 'translations' ||
|
||||||
view.type === 'influences' ||
|
view.type === 'influences' ||
|
||||||
|
view.type === 'movement-links' ||
|
||||||
view.type === 'tours' ||
|
view.type === 'tours' ||
|
||||||
view.type === 'users' ||
|
view.type === 'users' ||
|
||||||
view.type === 'audit'
|
view.type === 'audit'
|
||||||
@@ -362,6 +425,14 @@ export default function HomePage() {
|
|||||||
setView({ type: 'influences' });
|
setView({ type: 'influences' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openMovementLinks = () => {
|
||||||
|
if (!canInfluences) {
|
||||||
|
openCuratorLogin('movement-links');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setView({ type: 'movement-links' });
|
||||||
|
};
|
||||||
|
|
||||||
const openToursEditor = () => {
|
const openToursEditor = () => {
|
||||||
if (!canTours) {
|
if (!canTours) {
|
||||||
openCuratorLogin('tours');
|
openCuratorLogin('tours');
|
||||||
@@ -1077,7 +1148,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
{view.type === 'influences' && (
|
{view.type === 'influences' && (
|
||||||
canInfluences ? (
|
canInfluences ? (
|
||||||
<InfluencesPage onBack={goToTimelineHome} />
|
<InfluencesPage onBack={goToTimelineHome} onOpenMovementLinks={openMovementLinks} />
|
||||||
) : (
|
) : (
|
||||||
<div className="curator-login-gate">
|
<div className="curator-login-gate">
|
||||||
<h2>{t('curatorRequiredTitle')}</h2>
|
<h2>{t('curatorRequiredTitle')}</h2>
|
||||||
@@ -1094,6 +1165,25 @@ export default function HomePage() {
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{view.type === 'movement-links' && (
|
||||||
|
canInfluences ? (
|
||||||
|
<MovementLinksPage onBack={goToTimelineHome} movements={timelineData.movements} />
|
||||||
|
) : (
|
||||||
|
<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('movement-links')}>
|
||||||
|
{t('curatorLogin')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
|
||||||
|
{t('backToGalleryBtn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
{view.type === 'tours' && (
|
{view.type === 'tours' && (
|
||||||
canTours ? (
|
canTours ? (
|
||||||
<ToursPage onBack={goToTimelineHome} />
|
<ToursPage onBack={goToTimelineHome} />
|
||||||
@@ -1222,16 +1312,25 @@ export default function HomePage() {
|
|||||||
<div className="home-page">
|
<div className="home-page">
|
||||||
<header className="site-header">
|
<header className="site-header">
|
||||||
<div className="site-layout-switch">
|
<div className="site-layout-switch">
|
||||||
{isVerticalTimeline ? (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="site-layout-link"
|
className="site-layout-link site-layout-link-feature"
|
||||||
onClick={openHorizontalTimeline}
|
onClick={openMovementLinks}
|
||||||
title="Switch to classic left-to-right timeline"
|
title="Manage art-movement predecessor → successor links (curator)"
|
||||||
>
|
>
|
||||||
{t('layoutHorizontal')}
|
{t('movementLinks')}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
{!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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="site-layout-link"
|
className="site-layout-link"
|
||||||
@@ -1241,6 +1340,16 @@ export default function HomePage() {
|
|||||||
{t('layoutVertical')}
|
{t('layoutVertical')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!(view.type === 'timeline') && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="site-layout-link"
|
||||||
|
onClick={openHorizontalTimeline}
|
||||||
|
title="Switch to classic left-to-right timeline"
|
||||||
|
>
|
||||||
|
{t('layoutHorizontal')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="site-dev-tools">
|
<div className="site-dev-tools">
|
||||||
{isCurator ? (
|
{isCurator ? (
|
||||||
@@ -1369,7 +1478,7 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className={`home-timeline-stack${isVerticalTimeline ? ' home-timeline-stack-vertical' : ''}`}>
|
<div className={`home-timeline-stack${isVerticalLayout ? ' home-timeline-stack-vertical' : ''}`}>
|
||||||
{loading && (
|
{loading && (
|
||||||
<GalleryLoadingMarker overlay message="Loading art history…" />
|
<GalleryLoadingMarker overlay message="Loading art history…" />
|
||||||
)}
|
)}
|
||||||
@@ -1380,7 +1489,7 @@ export default function HomePage() {
|
|||||||
<GalleryLoadingMarker banner message="Loading portraits…" />
|
<GalleryLoadingMarker banner message="Loading portraits…" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isVerticalTimeline ? (
|
{isVerticalLayout ? (
|
||||||
<>
|
<>
|
||||||
<VerticalTimeline
|
<VerticalTimeline
|
||||||
eras={timelineData.eras}
|
eras={timelineData.eras}
|
||||||
@@ -1394,6 +1503,17 @@ export default function HomePage() {
|
|||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
{!loading && (
|
{!loading && (
|
||||||
<div className="home-movements-section-vertical">
|
<div className="home-movements-section-vertical">
|
||||||
|
{isTreeTimeline ? (
|
||||||
|
<MovementTree
|
||||||
|
movements={timelineData.movements}
|
||||||
|
viewStart={viewStart}
|
||||||
|
viewEnd={viewEnd}
|
||||||
|
absoluteMin={bounds.min}
|
||||||
|
absoluteMax={bounds.max}
|
||||||
|
onViewChange={handleViewChange}
|
||||||
|
onMovementClick={handleMovementClick}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<VerticalMovementBands
|
<VerticalMovementBands
|
||||||
movements={timelineData.movements}
|
movements={timelineData.movements}
|
||||||
viewStart={viewStart}
|
viewStart={viewStart}
|
||||||
@@ -1403,6 +1523,7 @@ export default function HomePage() {
|
|||||||
onViewChange={handleViewChange}
|
onViewChange={handleViewChange}
|
||||||
onMovementClick={handleMovementClick}
|
onMovementClick={handleMovementClick}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type WizardStep = 'upload' | 'mapping' | 'preview' | 'done';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
onOpenMovementLinks?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROLE_OPTIONS = [
|
const ROLE_OPTIONS = [
|
||||||
@@ -29,7 +30,7 @@ const ROLE_OPTIONS = [
|
|||||||
'source_url',
|
'source_url',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export default function InfluencesPage({ onBack }: Props) {
|
export default function InfluencesPage({ onBack, onOpenMovementLinks }: Props) {
|
||||||
const { t } = useTranslation('influences');
|
const { t } = useTranslation('influences');
|
||||||
const [tab, setTab] = useState<Tab>('list');
|
const [tab, setTab] = useState<Tab>('list');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -335,6 +336,11 @@ export default function InfluencesPage({ onBack }: Props) {
|
|||||||
{t('back')}
|
{t('back')}
|
||||||
</button>
|
</button>
|
||||||
<h1>{t('title')}</h1>
|
<h1>{t('title')}</h1>
|
||||||
|
{onOpenMovementLinks && (
|
||||||
|
<button type="button" className="influences-back" onClick={onOpenMovementLinks}>
|
||||||
|
{t('openMovementLinks')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<nav className="influences-tabs">
|
<nav className="influences-tabs">
|
||||||
<button type="button" className={tab === 'list' ? 'active' : ''} onClick={() => setTab('list')}>
|
<button type="button" className={tab === 'list' ? 'active' : ''} onClick={() => setTab('list')}>
|
||||||
{t('tabList')}
|
{t('tabList')}
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
.movement-links-page {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.25rem 1.5rem 3rem;
|
||||||
|
color: #e8d5b5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 650;
|
||||||
|
flex: 1;
|
||||||
|
color: #e8d5b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-back,
|
||||||
|
.movement-links-add-btn,
|
||||||
|
.movement-links-refresh,
|
||||||
|
.movement-links-save,
|
||||||
|
.movement-links-delete {
|
||||||
|
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||||
|
background: rgba(15, 15, 26, 0.85);
|
||||||
|
color: #c9a96e;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-back:hover,
|
||||||
|
.movement-links-add-btn:hover,
|
||||||
|
.movement-links-refresh:hover,
|
||||||
|
.movement-links-save:hover {
|
||||||
|
border-color: #c9a96e;
|
||||||
|
color: #e8d5b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-intro {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
color: rgba(232, 213, 181, 0.75);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
max-width: 52rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-intro-secondary {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
color: rgba(232, 213, 181, 0.55);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
max-width: 52rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-error {
|
||||||
|
background: rgba(139, 0, 0, 0.3);
|
||||||
|
border: 1px solid rgba(255, 170, 170, 0.4);
|
||||||
|
color: #ffaaaa;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-form {
|
||||||
|
border: 1px solid rgba(201, 169, 110, 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem 1.1rem 1.15rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
background: rgba(15, 15, 26, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-form h2 {
|
||||||
|
margin: 0 0 0.85rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem 1rem;
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-form-grid label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(232, 213, 181, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-span-2 {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-form-grid select,
|
||||||
|
.movement-links-form-grid input,
|
||||||
|
.movement-links-form-grid textarea,
|
||||||
|
.movement-links-vote-form textarea,
|
||||||
|
.movement-links-search {
|
||||||
|
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||||
|
background: rgba(10, 10, 20, 0.9);
|
||||||
|
color: #e8d5b5;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-vote-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(232, 213, 181, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-vote-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-vote-btn,
|
||||||
|
.movement-links-votes-toggle {
|
||||||
|
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||||
|
background: rgba(15, 15, 26, 0.85);
|
||||||
|
color: #c9a96e;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-vote-btn:hover,
|
||||||
|
.movement-links-votes-toggle:hover {
|
||||||
|
border-color: #c9a96e;
|
||||||
|
color: #e8d5b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-row-flagged {
|
||||||
|
background: rgba(232, 160, 64, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-vote-list {
|
||||||
|
margin: 0.45rem 0 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
color: rgba(232, 213, 181, 0.7);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-actions {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-save {
|
||||||
|
background: rgba(232, 160, 64, 0.18);
|
||||||
|
border-color: #e8a040;
|
||||||
|
color: #e8d5b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-save:disabled,
|
||||||
|
.movement-links-delete:disabled,
|
||||||
|
.movement-links-refresh:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-search {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-count {
|
||||||
|
color: rgba(232, 213, 181, 0.65);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-muted {
|
||||||
|
color: rgba(232, 213, 181, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid rgba(201, 169, 110, 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-table th,
|
||||||
|
.movement-links-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border-bottom: 1px solid rgba(201, 169, 110, 0.18);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-table th {
|
||||||
|
background: rgba(15, 15, 26, 0.85);
|
||||||
|
color: #c9a96e;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-table tbody tr:nth-child(even) {
|
||||||
|
background: rgba(15, 15, 26, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-table a {
|
||||||
|
color: #e8a040;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-table a:hover {
|
||||||
|
color: #f0c070;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-year {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-notes {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: rgba(232, 213, 181, 0.55);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-delete {
|
||||||
|
color: #ffaaaa;
|
||||||
|
border-color: rgba(255, 170, 170, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-delete:hover:not(:disabled) {
|
||||||
|
border-color: #ffaaaa;
|
||||||
|
color: #ffcccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.movement-links-form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-links-span-2 {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type MovementLineageDeleteVote,
|
||||||
|
type MovementLineageLinkItem,
|
||||||
|
} from '../api/client';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { setMovementLineageFromApi } from '../data/movement-lineage';
|
||||||
|
import type { ArtMovement } from '../types';
|
||||||
|
import './MovementLinksPage.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onBack: () => void;
|
||||||
|
movements: ArtMovement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MovementLinksPage({ onBack, movements }: Props) {
|
||||||
|
const { t } = useTranslation('movementLinks');
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const [items, setItems] = useState<MovementLineageLinkItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
|
||||||
|
const [sourceId, setSourceId] = useState<number | ''>('');
|
||||||
|
const [targetId, setTargetId] = useState<number | ''>('');
|
||||||
|
const [proofUrl, setProofUrl] = useState('');
|
||||||
|
const [proofLabel, setProofLabel] = useState('');
|
||||||
|
const [linkYear, setLinkYear] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
|
||||||
|
const [voteLinkId, setVoteLinkId] = useState<number | null>(null);
|
||||||
|
const [voteReason, setVoteReason] = useState('');
|
||||||
|
const [expandedVotes, setExpandedVotes] = useState<Record<number, MovementLineageDeleteVote[] | 'loading' | 'error'>>({});
|
||||||
|
|
||||||
|
const sortedMovements = useMemo(
|
||||||
|
() => [...movements].sort((a, b) => a.start_year - b.start_year || a.name.localeCompare(b.name)),
|
||||||
|
[movements],
|
||||||
|
);
|
||||||
|
|
||||||
|
const syncLocalLineage = useCallback((rows: MovementLineageLinkItem[]) => {
|
||||||
|
setMovementLineageFromApi(rows);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadList = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [data, all] = await Promise.all([
|
||||||
|
api.listMovementLinks({ q: q || undefined, limit: 500 }),
|
||||||
|
q ? api.listMovementLinks({ limit: 500 }) : Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
setItems(data.items);
|
||||||
|
setTotal(data.total);
|
||||||
|
syncLocalLineage((all ?? data).items);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [q, syncLocalLineage, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadList();
|
||||||
|
}, [loadList]);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setSourceId('');
|
||||||
|
setTargetId('');
|
||||||
|
setProofUrl('');
|
||||||
|
setProofLabel('');
|
||||||
|
setLinkYear('');
|
||||||
|
setNotes('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitAdd = async () => {
|
||||||
|
if (sourceId === '' || targetId === '') {
|
||||||
|
setError(t('addRequiresMovements'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!proofUrl.trim()) {
|
||||||
|
setError(t('proofRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const yearRaw = linkYear.trim();
|
||||||
|
await api.createMovementLink({
|
||||||
|
sourceMovementId: sourceId,
|
||||||
|
targetMovementId: targetId,
|
||||||
|
proofUrl: proofUrl.trim(),
|
||||||
|
proofLabel: proofLabel.trim() || undefined,
|
||||||
|
linkYearApprox: yearRaw === '' ? null : Number(yearRaw),
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
});
|
||||||
|
resetForm();
|
||||||
|
setShowAdd(false);
|
||||||
|
await loadList();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t('saveFailed'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitVote = async () => {
|
||||||
|
if (voteLinkId == null) return;
|
||||||
|
if (voteReason.trim().length < 10) {
|
||||||
|
setError(t('voteReasonRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.voteDeleteMovementLink(voteLinkId, voteReason.trim());
|
||||||
|
setVoteLinkId(null);
|
||||||
|
setVoteReason('');
|
||||||
|
setExpandedVotes((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[voteLinkId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await loadList();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t('voteFailed'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const withdrawVote = async (id: number) => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.withdrawDeleteMovementLinkVote(id);
|
||||||
|
setExpandedVotes((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await loadList();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t('withdrawFailed'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadVotes = async (id: number) => {
|
||||||
|
if (expandedVotes[id] && expandedVotes[id] !== 'error') {
|
||||||
|
setExpandedVotes((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExpandedVotes((prev) => ({ ...prev, [id]: 'loading' }));
|
||||||
|
try {
|
||||||
|
const data = await api.listMovementLinkDeleteVotes(id);
|
||||||
|
setExpandedVotes((prev) => ({ ...prev, [id]: data.votes }));
|
||||||
|
} catch {
|
||||||
|
setExpandedVotes((prev) => ({ ...prev, [id]: 'error' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAdminDelete = async (id: number) => {
|
||||||
|
if (!window.confirm(t('confirmDelete'))) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.deleteMovementLink(id);
|
||||||
|
setExpandedVotes((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await loadList();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t('deleteFailed'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="movement-links-page">
|
||||||
|
<header className="movement-links-header">
|
||||||
|
<button type="button" className="movement-links-back" onClick={onBack}>
|
||||||
|
{t('back')}
|
||||||
|
</button>
|
||||||
|
<h1>{t('title')}</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="movement-links-add-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setShowAdd((v) => !v);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showAdd ? t('cancelAdd') : t('addLink')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="movement-links-refresh" onClick={() => void loadList()} disabled={loading}>
|
||||||
|
{t('refresh')}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p className="movement-links-intro">{t('intro')}</p>
|
||||||
|
<p className="movement-links-intro-secondary">
|
||||||
|
{isAdmin ? t('introAdmin') : t('introCurator')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <div className="movement-links-error">{error}</div>}
|
||||||
|
|
||||||
|
{voteLinkId != null && (
|
||||||
|
<section className="movement-links-form movement-links-vote-form">
|
||||||
|
<h2>{t('voteTitle')}</h2>
|
||||||
|
<label className="movement-links-span-2">
|
||||||
|
{t('voteReason')} *
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={voteReason}
|
||||||
|
onChange={(e) => setVoteReason(e.target.value)}
|
||||||
|
placeholder={t('voteReasonPlaceholder')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="movement-links-vote-actions">
|
||||||
|
<button type="button" className="movement-links-save" onClick={() => void submitVote()} disabled={saving}>
|
||||||
|
{saving ? t('saving') : t('submitVote')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="movement-links-refresh"
|
||||||
|
onClick={() => {
|
||||||
|
setVoteLinkId(null);
|
||||||
|
setVoteReason('');
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t('cancelVote')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAdd && (
|
||||||
|
<section className="movement-links-form">
|
||||||
|
<h2>{t('addTitle')}</h2>
|
||||||
|
<div className="movement-links-form-grid">
|
||||||
|
<label>
|
||||||
|
{t('source')}
|
||||||
|
<select
|
||||||
|
value={sourceId === '' ? '' : String(sourceId)}
|
||||||
|
onChange={(e) => setSourceId(e.target.value ? Number(e.target.value) : '')}
|
||||||
|
>
|
||||||
|
<option value="">{t('pickMovement')}</option>
|
||||||
|
{sortedMovements.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name} ({m.start_year})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t('target')}
|
||||||
|
<select
|
||||||
|
value={targetId === '' ? '' : String(targetId)}
|
||||||
|
onChange={(e) => setTargetId(e.target.value ? Number(e.target.value) : '')}
|
||||||
|
>
|
||||||
|
<option value="">{t('pickMovement')}</option>
|
||||||
|
{sortedMovements.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name} ({m.start_year})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="movement-links-span-2">
|
||||||
|
{t('proofUrl')} *
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
placeholder="https://…"
|
||||||
|
value={proofUrl}
|
||||||
|
onChange={(e) => setProofUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t('proofLabel')}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={proofLabel}
|
||||||
|
onChange={(e) => setProofLabel(e.target.value)}
|
||||||
|
placeholder={t('proofLabelPlaceholder')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t('linkYear')}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={linkYear}
|
||||||
|
onChange={(e) => setLinkYear(e.target.value)}
|
||||||
|
placeholder={t('linkYearPlaceholder')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="movement-links-span-2">
|
||||||
|
{t('notes')}
|
||||||
|
<input type="text" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="movement-links-save" onClick={() => void submitAdd()} disabled={saving}>
|
||||||
|
{saving ? t('saving') : t('saveLink')}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="movement-links-toolbar">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="movement-links-search"
|
||||||
|
placeholder={t('searchPlaceholder')}
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="movement-links-count">{t('total', { count: total })}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="movement-links-muted">{t('loading')}</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="movement-links-muted">{t('noLinks')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="movement-links-table-wrap">
|
||||||
|
<table className="movement-links-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('colSource')}</th>
|
||||||
|
<th>{t('colTarget')}</th>
|
||||||
|
<th>{t('colYear')}</th>
|
||||||
|
<th>{t('colProof')}</th>
|
||||||
|
<th>{t('colVotes')}</th>
|
||||||
|
<th>{t('colActions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((row) => {
|
||||||
|
const voteCount = row.delete_vote_count ?? 0;
|
||||||
|
const votesState = expandedVotes[row.id];
|
||||||
|
return (
|
||||||
|
<tr key={row.id} className={voteCount > 0 ? 'movement-links-row-flagged' : undefined}>
|
||||||
|
<td>{row.source_name}</td>
|
||||||
|
<td>{row.target_name}</td>
|
||||||
|
<td className="movement-links-year">
|
||||||
|
{row.link_year_approx != null ? `~${row.link_year_approx}` : '—'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href={row.proof_url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{row.proof_label || row.proof_url}
|
||||||
|
</a>
|
||||||
|
{row.notes ? <div className="movement-links-notes">{row.notes}</div> : null}
|
||||||
|
{Array.isArray(votesState) && votesState.length > 0 && (
|
||||||
|
<ul className="movement-links-vote-list">
|
||||||
|
{votesState.map((v) => (
|
||||||
|
<li key={v.id}>
|
||||||
|
<strong>{v.username || `#${v.user_id}`}</strong>: {v.reason}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{votesState === 'loading' && (
|
||||||
|
<div className="movement-links-notes">{t('loadingVotes')}</div>
|
||||||
|
)}
|
||||||
|
{votesState === 'error' && (
|
||||||
|
<div className="movement-links-notes">{t('votesLoadFailed')}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="movement-links-year">
|
||||||
|
{voteCount > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="movement-links-votes-toggle"
|
||||||
|
onClick={() => void loadVotes(row.id)}
|
||||||
|
>
|
||||||
|
{t('voteCount', { count: voteCount })}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
'0'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="movement-links-actions">
|
||||||
|
{isAdmin ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="movement-links-delete"
|
||||||
|
onClick={() => void onAdminDelete(row.id)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t('delete')}
|
||||||
|
</button>
|
||||||
|
) : row.viewer_has_delete_vote ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="movement-links-refresh"
|
||||||
|
onClick={() => void withdrawVote(row.id)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t('withdrawVote')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="movement-links-vote-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setVoteLinkId(row.id);
|
||||||
|
setVoteReason('');
|
||||||
|
setError(null);
|
||||||
|
setShowAdd(false);
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t('voteDelete')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/** Shared colour helpers for movement streams (timeline flow, vertical flow, tree). */
|
||||||
|
|
||||||
|
/** True when `hex` is a usable #rgb / #rrggbb / #rrggbbaa-style value. */
|
||||||
|
export function isMovementHexColor(hex: string): boolean {
|
||||||
|
const normalized = hex.replace('#', '').trim();
|
||||||
|
return /^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6,}$/.test(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a catalogue colour to RGB.
|
||||||
|
* 3-digit shorthand expands; 6+ digits keep the first six (so `#rrggbbaa` → `#rrggbb`).
|
||||||
|
* Throws on malformed input so callers can fall back.
|
||||||
|
*/
|
||||||
|
export function parseHexColor(hex: string): [number, number, number] {
|
||||||
|
const normalized = hex.replace('#', '').trim();
|
||||||
|
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6,}$/.test(normalized)) {
|
||||||
|
throw new Error(`invalid hex colour: ${hex}`);
|
||||||
|
}
|
||||||
|
const value =
|
||||||
|
normalized.length === 3
|
||||||
|
? normalized
|
||||||
|
.split('')
|
||||||
|
.map((c) => c + c)
|
||||||
|
.join('')
|
||||||
|
: normalized.slice(0, 6);
|
||||||
|
const n = parseInt(value, 16);
|
||||||
|
if (!Number.isFinite(n)) throw new Error(`invalid hex colour: ${hex}`);
|
||||||
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||||||
|
r /= 255;
|
||||||
|
g /= 255;
|
||||||
|
b /= 255;
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const l = (max + min) / 2;
|
||||||
|
if (max === min) return [0, 0, l];
|
||||||
|
const d = max - min;
|
||||||
|
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
let h = 0;
|
||||||
|
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||||
|
else if (max === g) h = ((b - r) / d + 2) / 6;
|
||||||
|
else h = ((r - g) / d + 4) / 6;
|
||||||
|
return [h * 360, s, l];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hslToHex(h: number, s: number, l: number): string {
|
||||||
|
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||||
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||||
|
const m = l - c / 2;
|
||||||
|
let r = 0;
|
||||||
|
let g = 0;
|
||||||
|
let b = 0;
|
||||||
|
if (h < 60) [r, g, b] = [c, x, 0];
|
||||||
|
else if (h < 120) [r, g, b] = [x, c, 0];
|
||||||
|
else if (h < 180) [r, g, b] = [0, c, x];
|
||||||
|
else if (h < 240) [r, g, b] = [0, x, c];
|
||||||
|
else if (h < 300) [r, g, b] = [x, 0, c];
|
||||||
|
else [r, g, b] = [c, 0, x];
|
||||||
|
const toByte = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
||||||
|
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lift a muted catalogue colour into a saturated stream colour. */
|
||||||
|
export function vividMovementColor(hex: string): string {
|
||||||
|
try {
|
||||||
|
if (!isMovementHexColor(hex)) return hex;
|
||||||
|
const [r, g, b] = parseHexColor(hex);
|
||||||
|
const [h, s, l] = rgbToHsl(r, g, b);
|
||||||
|
const s2 = s < 0.1 ? Math.min(0.55, s + 0.42) : Math.min(1, s * 1.65 + 0.08);
|
||||||
|
const l2 =
|
||||||
|
l < 0.22 ? 0.5 : l > 0.78 ? 0.62 : Math.min(0.68, Math.max(0.4, l * 0.75 + 0.28));
|
||||||
|
return hslToHex(h, s2, l2);
|
||||||
|
} catch {
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Darker variant of a stream colour — used for the shaded side of a tree limb. */
|
||||||
|
export function shadeMovementColor(hex: string, amount = 0.34): string {
|
||||||
|
try {
|
||||||
|
if (!isMovementHexColor(hex)) return hex;
|
||||||
|
const [r, g, b] = parseHexColor(hex);
|
||||||
|
const [h, s, l] = rgbToHsl(r, g, b);
|
||||||
|
return hslToHex(h, Math.min(1, s * 1.05), Math.max(0.08, l * (1 - amount)));
|
||||||
|
} catch {
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Painting } from '../types';
|
import type { Painting } from '../types';
|
||||||
import type { GalleryWindowSpec, MovementInteriorStyle } from '../data/movement-interior-styles';
|
import type { GalleryWindowSpec, MovementDetailKind, MovementInteriorStyle } from '../data/movement-interior-styles';
|
||||||
|
import { sideColumnKeepOutIntervals } from './sideColumnKeepOuts';
|
||||||
|
|
||||||
/** Target capacity per movement wing (50–60 works). */
|
/** Target capacity per movement wing (50–60 works). */
|
||||||
export const MOVEMENT_PAINTINGS_PER_HALL = 55;
|
export const MOVEMENT_PAINTINGS_PER_HALL = 55;
|
||||||
@@ -69,7 +70,7 @@ function frameOuterW(w: number, reviewed: boolean) {
|
|||||||
return w + matBorder * 2 + rail;
|
return w + matBorder * 2 + rail;
|
||||||
}
|
}
|
||||||
|
|
||||||
function layoutRow(paintings: Painting[], span: number) {
|
function layoutRow(paintings: Painting[], span: number, endPad = WALL_PADDING) {
|
||||||
const count = paintings.length;
|
const count = paintings.length;
|
||||||
if (count === 0) return { slots: [] as { offset: number; maxW: number; maxH: number }[], spanNeeded: span };
|
if (count === 0) return { slots: [] as { offset: number; maxW: number; maxH: number }[], spanNeeded: span };
|
||||||
|
|
||||||
@@ -80,7 +81,7 @@ function layoutRow(paintings: Painting[], span: number) {
|
|||||||
total += frameOuterW(frameW, paintingIsReviewed(paintings[i]));
|
total += frameOuterW(frameW, paintingIsReviewed(paintings[i]));
|
||||||
if (i < count - 1) total += FRAME_GAP;
|
if (i < count - 1) total += FRAME_GAP;
|
||||||
}
|
}
|
||||||
if (total <= span - WALL_PADDING) break;
|
if (total <= span - endPad) break;
|
||||||
frameW -= 0.015;
|
frameW -= 0.015;
|
||||||
}
|
}
|
||||||
frameW = Math.max(MIN_FRAME_W, frameW);
|
frameW = Math.max(MIN_FRAME_W, frameW);
|
||||||
@@ -93,7 +94,7 @@ function layoutRow(paintings: Painting[], span: number) {
|
|||||||
slots.push({ offset: cursor + outers[i] / 2, maxW: frameW, maxH: frameH });
|
slots.push({ offset: cursor + outers[i] / 2, maxW: frameW, maxH: frameH });
|
||||||
cursor += outers[i] + FRAME_GAP;
|
cursor += outers[i] + FRAME_GAP;
|
||||||
}
|
}
|
||||||
return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) };
|
return { slots, spanNeeded: Math.max(span, rowWidth + endPad) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatYearLabel(paintings: Painting[]) {
|
function formatYearLabel(paintings: Painting[]) {
|
||||||
@@ -140,21 +141,101 @@ function distributeAcrossWalls(paintings: Painting[]) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Usable runs along ±halfSpan after end padding and column keep-outs. */
|
||||||
|
function sideWallGaps(halfSpan: number, keepOuts: [number, number][]): [number, number][] {
|
||||||
|
const pad = WALL_PADDING / 2;
|
||||||
|
const occupied = mergeIntervals([
|
||||||
|
[-halfSpan - 1, -halfSpan + pad],
|
||||||
|
...keepOuts,
|
||||||
|
[halfSpan - pad, halfSpan + 1],
|
||||||
|
]);
|
||||||
|
const gaps: [number, number][] = [];
|
||||||
|
let cursor = -halfSpan;
|
||||||
|
for (const [a, b] of occupied) {
|
||||||
|
if (a > cursor) gaps.push([cursor, a]);
|
||||||
|
cursor = Math.max(cursor, b);
|
||||||
|
}
|
||||||
|
if (cursor < halfSpan) gaps.push([cursor, halfSpan]);
|
||||||
|
return gaps.filter(([a, b]) => b - a >= MIN_FRAME_W + 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split paintings across gaps in visit order (first gap gets the earliest works). */
|
||||||
|
function distributeIntoGaps(paintings: Painting[], gaps: [number, number][]): Painting[][] {
|
||||||
|
if (paintings.length === 0 || gaps.length === 0) return gaps.map(() => []);
|
||||||
|
const lengths = gaps.map(([a, b]) => b - a);
|
||||||
|
const total = lengths.reduce((s, L) => s + L, 0);
|
||||||
|
const counts = lengths.map((L) => Math.floor((paintings.length * L) / total));
|
||||||
|
let assigned = counts.reduce((s, n) => s + n, 0);
|
||||||
|
// Give leftover works to the widest gaps first.
|
||||||
|
const order = lengths.map((L, i) => ({ i, L })).sort((a, b) => b.L - a.L);
|
||||||
|
let k = 0;
|
||||||
|
while (assigned < paintings.length) {
|
||||||
|
counts[order[k % order.length].i] += 1;
|
||||||
|
assigned += 1;
|
||||||
|
k += 1;
|
||||||
|
}
|
||||||
|
// Prefer empty-avoidance: if a gap got 0 but another has many, leave as-is
|
||||||
|
// (zero-count gaps simply stay empty).
|
||||||
|
const groups: Painting[][] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
for (const n of counts) {
|
||||||
|
groups.push(paintings.slice(cursor, cursor + n));
|
||||||
|
cursor += n;
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotsFitInGaps(paintings: Painting[], gaps: [number, number][]): boolean {
|
||||||
|
if (paintings.length === 0) return true;
|
||||||
|
if (gaps.length === 0) return false;
|
||||||
|
const groups = distributeIntoGaps(paintings, gaps);
|
||||||
|
for (let i = 0; i < gaps.length; i++) {
|
||||||
|
const group = groups[i];
|
||||||
|
if (group.length === 0) continue;
|
||||||
|
const span = gaps[i][1] - gaps[i][0];
|
||||||
|
const { spanNeeded } = layoutRow(group, span, 0);
|
||||||
|
if (spanNeeded > span + 1e-6) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Depth large enough that side-wall hang clears bay columns for this detail kind. */
|
||||||
|
function depthForSideHang(
|
||||||
|
left: Painting[],
|
||||||
|
right: Painting[],
|
||||||
|
details: MovementDetailKind | undefined
|
||||||
|
): number {
|
||||||
|
let depth = Math.max(
|
||||||
|
MIN_HALL_SIZE,
|
||||||
|
layoutRow(left, MIN_HALL_SIZE).spanNeeded,
|
||||||
|
layoutRow(right, MIN_HALL_SIZE).spanNeeded
|
||||||
|
);
|
||||||
|
if (!sideColumnKeepOutIntervals(details, depth, depth / 2).length) return depth;
|
||||||
|
|
||||||
|
for (let guard = 0; guard < 80; guard++) {
|
||||||
|
const halfD = depth / 2;
|
||||||
|
const keepOuts = sideColumnKeepOutIntervals(details, depth, halfD);
|
||||||
|
const gaps = sideWallGaps(halfD, keepOuts);
|
||||||
|
if (slotsFitInGaps(left, gaps) && slotsFitInGaps(right, gaps)) return depth;
|
||||||
|
depth += 0.35;
|
||||||
|
}
|
||||||
|
return depth;
|
||||||
|
}
|
||||||
|
|
||||||
function layoutSideSlots(
|
function layoutSideSlots(
|
||||||
paintings: Painting[],
|
paintings: Painting[],
|
||||||
span: number,
|
span: number,
|
||||||
side: 'left' | 'right',
|
side: 'left' | 'right',
|
||||||
halfW: number,
|
halfW: number,
|
||||||
inset: number
|
inset: number,
|
||||||
|
details?: MovementDetailKind
|
||||||
): FrameSlot[] {
|
): FrameSlot[] {
|
||||||
if (paintings.length === 0) return [];
|
if (paintings.length === 0) return [];
|
||||||
const { slots: rowSlots } = layoutRow(paintings, span);
|
|
||||||
const y = EYE_HEIGHT;
|
const y = EYE_HEIGHT;
|
||||||
return rowSlots.map((s) => {
|
const halfD = span / 2;
|
||||||
// layoutRow places index 0 at negative offset. Flip on the left wall so
|
const keepOuts = sideColumnKeepOutIntervals(details, span, halfD);
|
||||||
// the first painting sits at the entrance (+Z), left of the starting view.
|
|
||||||
const alongWall = side === 'left' ? -s.offset : s.offset;
|
const mapSlot = (alongWall: number, s: { maxW: number; maxH: number }): FrameSlot => ({
|
||||||
return {
|
|
||||||
maxW: s.maxW,
|
maxW: s.maxW,
|
||||||
maxH: s.maxH,
|
maxH: s.maxH,
|
||||||
rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2,
|
rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2,
|
||||||
@@ -163,8 +244,38 @@ function layoutSideSlots(
|
|||||||
side === 'left'
|
side === 'left'
|
||||||
? ([-halfW + inset + WALL_STANDOFF, y, alongWall] as [number, number, number])
|
? ([-halfW + inset + WALL_STANDOFF, y, alongWall] as [number, number, number])
|
||||||
: ([halfW - inset - WALL_STANDOFF, y, alongWall] as [number, number, number]),
|
: ([halfW - inset - WALL_STANDOFF, y, alongWall] as [number, number, number]),
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (keepOuts.length === 0) {
|
||||||
|
const { slots: rowSlots } = layoutRow(paintings, span);
|
||||||
|
return rowSlots.map((s) => {
|
||||||
|
// layoutRow places index 0 at negative offset. Flip on the left wall so
|
||||||
|
// the first painting sits at the entrance (+Z), left of the starting view.
|
||||||
|
const alongWall = side === 'left' ? -s.offset : s.offset;
|
||||||
|
return mapSlot(alongWall, s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visit order along each wall: left entrance→back (+Z→−Z), right back→entrance (−Z→+Z).
|
||||||
|
let gaps = sideWallGaps(halfD, keepOuts);
|
||||||
|
gaps = side === 'left' ? [...gaps].sort((a, b) => b[0] - a[0]) : [...gaps].sort((a, b) => a[0] - b[0]);
|
||||||
|
|
||||||
|
const groups = distributeIntoGaps(paintings, gaps);
|
||||||
|
const out: FrameSlot[] = [];
|
||||||
|
for (let i = 0; i < gaps.length; i++) {
|
||||||
|
const group = groups[i];
|
||||||
|
if (group.length === 0) continue;
|
||||||
|
const [g0, g1] = gaps[i];
|
||||||
|
const gapSpan = g1 - g0;
|
||||||
|
const center = (g0 + g1) / 2;
|
||||||
|
const { slots: rowSlots } = layoutRow(group, gapSpan, 0);
|
||||||
|
for (const s of rowSlots) {
|
||||||
|
// Within a gap, keep the same entrance/back orientation as the continuous row.
|
||||||
|
const alongWall = side === 'left' ? center - s.offset : center + s.offset;
|
||||||
|
out.push(mapSlot(alongWall, s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Far wall ahead of the entrance — full span, or split across door flanks. */
|
/** Far wall ahead of the entrance — full span, or split across door flanks. */
|
||||||
@@ -225,12 +336,11 @@ function layoutBackSlots(
|
|||||||
export function buildMovementHallLayout(
|
export function buildMovementHallLayout(
|
||||||
paintings: Painting[],
|
paintings: Painting[],
|
||||||
hallIndex: number,
|
hallIndex: number,
|
||||||
hallCount: number
|
hallCount: number,
|
||||||
|
details?: MovementDetailKind
|
||||||
): MovementHallLayout {
|
): MovementHallLayout {
|
||||||
const { left, back, right } = distributeAcrossWalls(paintings);
|
const { left, back, right } = distributeAcrossWalls(paintings);
|
||||||
const leftSpan = layoutRow(left, MIN_HALL_SIZE);
|
const depth = depthForSideHang(left, right, details);
|
||||||
const rightSpan = layoutRow(right, MIN_HALL_SIZE);
|
|
||||||
const depth = Math.max(MIN_HALL_SIZE, leftSpan.spanNeeded, rightSpan.spanNeeded);
|
|
||||||
|
|
||||||
// Single-wing halls put the exit on the entrance wall, so the far wall is solid
|
// 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.
|
// and can use its full width for paintings. Multi-wing halls keep a back exit.
|
||||||
@@ -261,13 +371,13 @@ export function buildMovementHallLayout(
|
|||||||
side: 'left',
|
side: 'left',
|
||||||
label: left.length > 0 ? `Wing ${hallIndex + 1} · Left wall` : '',
|
label: left.length > 0 ? `Wing ${hallIndex + 1} · Left wall` : '',
|
||||||
paintings: left,
|
paintings: left,
|
||||||
slots: layoutSideSlots(left, depth, 'left', halfW, inset),
|
slots: layoutSideSlots(left, depth, 'left', halfW, inset, details),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
side: 'right',
|
side: 'right',
|
||||||
label: right.length > 0 ? `Wing ${hallIndex + 1} · Right wall` : '',
|
label: right.length > 0 ? `Wing ${hallIndex + 1} · Right wall` : '',
|
||||||
paintings: right,
|
paintings: right,
|
||||||
slots: layoutSideSlots(right, depth, 'right', halfW, inset),
|
slots: layoutSideSlots(right, depth, 'right', halfW, inset, details),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -283,9 +393,12 @@ export function buildMovementHallLayout(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAllMovementHallLayouts(paintings: Painting[]): MovementHallLayout[] {
|
export function buildAllMovementHallLayouts(
|
||||||
|
paintings: Painting[],
|
||||||
|
details?: MovementDetailKind
|
||||||
|
): MovementHallLayout[] {
|
||||||
const chunks = splitPaintingsIntoMovementHalls(paintings);
|
const chunks = splitPaintingsIntoMovementHalls(paintings);
|
||||||
return chunks.map((chunk, i) => buildMovementHallLayout(chunk, i, chunks.length));
|
return chunks.map((chunk, i) => buildMovementHallLayout(chunk, i, chunks.length, details));
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeIntervals(intervals: [number, number][]): [number, number][] {
|
function mergeIntervals(intervals: [number, number][]): [number, number][] {
|
||||||
@@ -336,15 +449,20 @@ export function computeSideWallWindows(
|
|||||||
const specs: GalleryWindowSpec[] = [];
|
const specs: GalleryWindowSpec[] = [];
|
||||||
const windowY = 3.15;
|
const windowY = 3.15;
|
||||||
const minGap = tmpl.width + 0.6;
|
const minGap = tmpl.width + 0.6;
|
||||||
|
const columnKeepOuts = sideColumnKeepOutIntervals(interiorStyle.details, layout.depth, halfD);
|
||||||
|
|
||||||
for (const side of ['left', 'right'] as const) {
|
for (const side of ['left', 'right'] as const) {
|
||||||
const seg = layout.segments.find((s) => s.side === side);
|
const seg = layout.segments.find((s) => s.side === side);
|
||||||
if (!seg) continue;
|
if (!seg) continue;
|
||||||
|
|
||||||
const occupied = seg.slots.map((s): [number, number] => {
|
const occupied = [
|
||||||
|
...seg.slots.map((s): [number, number] => {
|
||||||
const outerW = frameOuterW(s.maxW, false);
|
const outerW = frameOuterW(s.maxW, false);
|
||||||
return [s.position[2] - outerW / 2 - 0.45, s.position[2] + outerW / 2 + 0.45];
|
return [s.position[2] - outerW / 2 - 0.45, s.position[2] + outerW / 2 + 0.45];
|
||||||
});
|
}),
|
||||||
|
// Keep windows off bay columns so period modules still draw their shafts.
|
||||||
|
...columnKeepOuts,
|
||||||
|
];
|
||||||
|
|
||||||
const gaps = findWallGaps(occupied, halfD, minGap);
|
const gaps = findWallGaps(occupied, halfD, minGap);
|
||||||
const maxWindows = Math.min(3, gaps.length);
|
const maxWindows = Math.min(3, gaps.length);
|
||||||
|
|||||||
@@ -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 { getMovementLineage } 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 getMovementLineage()) {
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { MovementDetailKind } from '../data/movement-interior-styles';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Even bay centres along the depth axis — same formula as historic `useBayZ`.
|
||||||
|
* Pure (no React) so hang layout can reserve the same Z as period columns.
|
||||||
|
*/
|
||||||
|
export function bayPositions(depth: number, halfD: number, spacing = 4.2, max = 9): number[] {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SideColumnRhythm = {
|
||||||
|
/** Bay spacing passed to bayPositions (matches the period module). */
|
||||||
|
spacing: number;
|
||||||
|
max: number;
|
||||||
|
/**
|
||||||
|
* Half-width along the wall (Z) of the keep-out around each bay centre.
|
||||||
|
* Covers shaft/base/capital plus a small gap so frames do not kiss the column.
|
||||||
|
*/
|
||||||
|
halfClear: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mid-wall vertical members that share the hang line on left/right walls.
|
||||||
|
* Corner-only orders are omitted — WALL_PADDING already clears the pockets.
|
||||||
|
*/
|
||||||
|
export const SIDE_WALL_COLUMN_RHYTHMS = {
|
||||||
|
gothic: { spacing: 4.2, max: 9, halfClear: 0.42 },
|
||||||
|
roman: { spacing: 4.0, max: 9, halfClear: 0.3 },
|
||||||
|
quattrocento: { spacing: 3.8, max: 9, halfClear: 0.34 },
|
||||||
|
// Paired pilasters sit at bay±0.19 with r≈0.11
|
||||||
|
cinquecento: { spacing: 5.2, max: 6, halfClear: 0.44 },
|
||||||
|
mannerist: { spacing: 4.8, max: 6, halfClear: 0.36 },
|
||||||
|
neoclassical: { spacing: 4.8, max: 6, halfClear: 0.3 },
|
||||||
|
'gothic-revival': { spacing: 3.8, max: 8, halfClear: 0.24 },
|
||||||
|
'art-nouveau': { spacing: 3.0, max: 10, halfClear: 0.22 },
|
||||||
|
} as const satisfies Partial<Record<MovementDetailKind, SideColumnRhythm>>;
|
||||||
|
|
||||||
|
export type SideColumnDetailKind = keyof typeof SIDE_WALL_COLUMN_RHYTHMS;
|
||||||
|
|
||||||
|
export function sideColumnRhythm(details: MovementDetailKind | undefined): SideColumnRhythm | null {
|
||||||
|
if (!details) return null;
|
||||||
|
return (SIDE_WALL_COLUMN_RHYTHMS as Partial<Record<MovementDetailKind, SideColumnRhythm>>)[details] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merged [z0, z1] intervals that side-wall paintings (and windows) must avoid. */
|
||||||
|
export function sideColumnKeepOutIntervals(
|
||||||
|
details: MovementDetailKind | undefined,
|
||||||
|
depth: number,
|
||||||
|
halfD: number
|
||||||
|
): [number, number][] {
|
||||||
|
const rhythm = sideColumnRhythm(details);
|
||||||
|
if (!rhythm) return [];
|
||||||
|
return bayPositions(depth, halfD, rhythm.spacing, rhythm.max).map(
|
||||||
|
(z) => [z - rhythm.halfClear, z + rhythm.halfClear] as [number, number]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 389 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 911 KiB |
|
After Width: | Height: | Size: 871 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 41 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
-- Curator votes to delete a movement lineage link (admin performs the actual delete).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS movement_lineage_delete_votes (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
link_id INTEGER NOT NULL REFERENCES movement_lineage_links(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT movement_lineage_delete_votes_unique UNIQUE (link_id, user_id),
|
||||||
|
CONSTRAINT movement_lineage_delete_votes_reason_len CHECK (char_length(trim(reason)) >= 10)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS movement_lineage_delete_votes_link_idx
|
||||||
|
ON movement_lineage_delete_votes (link_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS movement_lineage_delete_votes_user_idx
|
||||||
|
ON movement_lineage_delete_votes (user_id);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- Art-movement predecessor → successor edges for timeline / Tree of Art.
|
||||||
|
-- Curators manage these with a required proof URL (literature or web essay).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS movement_lineage_links (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
source_movement_id INTEGER NOT NULL REFERENCES art_movements(id) ON DELETE CASCADE,
|
||||||
|
target_movement_id INTEGER NOT NULL REFERENCES art_movements(id) ON DELETE CASCADE,
|
||||||
|
link_year_approx INTEGER,
|
||||||
|
proof_url TEXT NOT NULL,
|
||||||
|
proof_label TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT movement_lineage_links_distinct CHECK (source_movement_id <> target_movement_id),
|
||||||
|
CONSTRAINT movement_lineage_links_unique UNIQUE (source_movement_id, target_movement_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS movement_lineage_links_source_idx
|
||||||
|
ON movement_lineage_links (source_movement_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS movement_lineage_links_target_idx
|
||||||
|
ON movement_lineage_links (target_movement_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS movement_lineage_links_year_idx
|
||||||
|
ON movement_lineage_links (link_year_approx);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION movement_lineage_links_touch_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS movement_lineage_links_updated_at ON movement_lineage_links;
|
||||||
|
CREATE TRIGGER movement_lineage_links_updated_at
|
||||||
|
BEFORE UPDATE ON movement_lineage_links
|
||||||
|
FOR EACH ROW EXECUTE PROCEDURE movement_lineage_links_touch_updated_at();
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test": "node --test server/__tests__/**/*.test.js",
|
||||||
"dev:web": "node scripts/dev-web.js",
|
"dev:web": "node scripts/dev-web.js",
|
||||||
"dev:server": "nodemon server/index.js",
|
"dev:server": "nodemon server/index.js",
|
||||||
"dev:client": "npm run dev --prefix client",
|
"dev:client": "npm run dev --prefix client",
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Abstract Expressionism loft must read as an open painter's loft, not a barred cell.
|
||||||
|
* node --test server/__tests__/abex-loft-interior.test.js
|
||||||
|
*/
|
||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..', '..');
|
||||||
|
const STYLES = fs.readFileSync(path.join(ROOT, 'client/src/data/movement-interior-styles.ts'), 'utf8');
|
||||||
|
const DETAILS = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/modernism.tsx'), 'utf8');
|
||||||
|
|
||||||
|
function loftStyleBlock() {
|
||||||
|
const start = STYLES.indexOf("'abstract-expressionist-loft'");
|
||||||
|
assert.ok(start >= 0, 'abstract-expressionist-loft style must exist');
|
||||||
|
const next = STYLES.indexOf('mk(', start + 1);
|
||||||
|
return STYLES.slice(start, next > start ? next : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loftDetailsBlock() {
|
||||||
|
const start = DETAILS.indexOf('export function LoftDetails');
|
||||||
|
assert.ok(start >= 0, 'LoftDetails must exist');
|
||||||
|
const next = DETAILS.indexOf('export function ', start + 1);
|
||||||
|
return DETAILS.slice(start, next > start ? next : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Abstract Expressionism loft — open studio, no prison vibe', () => {
|
||||||
|
it('does not use factory windows (iron-bar grid)', () => {
|
||||||
|
const block = loftStyleBlock();
|
||||||
|
assert.doesNotMatch(block, /style: 'factory'/);
|
||||||
|
assert.match(block, /style: 'picture'|style: 'clerestory'|style: 'skylight'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses open painted / warm loft surfaces, not dingy cell brick-only', () => {
|
||||||
|
const block = loftStyleBlock();
|
||||||
|
assert.match(block, /north-light|painter|studio|loft/i);
|
||||||
|
assert.doesNotMatch(block, /north-light factory windows/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('LoftDetails has no cast-iron columns or sprinkler prison pipe', () => {
|
||||||
|
const block = loftDetailsBlock();
|
||||||
|
assert.doesNotMatch(block, /Cast-iron|Sprinkler main|cast-iron/);
|
||||||
|
assert.match(block, /timber|wood|joist|painter|studio/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Baroque upper-wall field panels must be framed velvet, not solid gilt slabs
|
||||||
|
* that wash out beside windows.
|
||||||
|
* node --test server/__tests__/baroque-panels.test.js
|
||||||
|
*/
|
||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..', '..');
|
||||||
|
const DETAILS = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/courtly.tsx'), 'utf8');
|
||||||
|
|
||||||
|
function baroqueBlock() {
|
||||||
|
const start = DETAILS.indexOf('export function BaroqueStateDetails');
|
||||||
|
assert.ok(start >= 0, 'BaroqueStateDetails must exist');
|
||||||
|
const next = DETAILS.indexOf('export function ', start + 1);
|
||||||
|
return DETAILS.slice(start, next > start ? next : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Baroque field panels beside windows', () => {
|
||||||
|
it('does not draw a solid gilt slab covering the velvet field', () => {
|
||||||
|
const block = baroqueBlock();
|
||||||
|
assert.doesNotMatch(block, /boxGeometry args=\{\[0\.04, 1\.12, w \+ 0\.12\]\}/);
|
||||||
|
assert.match(block, /velvet|field/i);
|
||||||
|
assert.match(block, /frame|rail|moulding/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a generous clearance around window openings', () => {
|
||||||
|
const block = baroqueBlock();
|
||||||
|
assert.match(block, /halfClear = fieldW \/ 2 \+ rail \+ 0\.35/);
|
||||||
|
assert.match(block, /\(mid, halfClear\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Dada movement hall must read as a modern cabaret, not a barred cell.
|
||||||
|
* node --test server/__tests__/dada-interior.test.js
|
||||||
|
*/
|
||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..', '..');
|
||||||
|
const STYLES = fs.readFileSync(path.join(ROOT, 'client/src/data/movement-interior-styles.ts'), 'utf8');
|
||||||
|
const WINDOWS = fs.readFileSync(path.join(ROOT, 'client/src/components/GalleryWindows.tsx'), 'utf8');
|
||||||
|
const DETAILS = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/modernism.tsx'), 'utf8');
|
||||||
|
|
||||||
|
function dadaSalonBlock() {
|
||||||
|
const start = STYLES.indexOf("'dada-salon'");
|
||||||
|
assert.ok(start >= 0, 'dada-salon style must exist');
|
||||||
|
const next = STYLES.indexOf('mk(', start + 1);
|
||||||
|
return STYLES.slice(start, next > start ? next : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dadaDetailsBlock() {
|
||||||
|
const start = DETAILS.indexOf('export function DadaDetails');
|
||||||
|
assert.ok(start >= 0, 'DadaDetails must exist');
|
||||||
|
const next = DETAILS.indexOf('export function ', start + 1);
|
||||||
|
return DETAILS.slice(start, next > start ? next : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Dada interior — modern cabaret, no prison vibe', () => {
|
||||||
|
it('declares a picture window style (open glass, no iron grid)', () => {
|
||||||
|
assert.match(STYLES, /\| 'picture'/);
|
||||||
|
assert.match(WINDOWS, /style === 'picture'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not use factory or gothic-lancet windows (barred / cell look)', () => {
|
||||||
|
const block = dadaSalonBlock();
|
||||||
|
assert.doesNotMatch(block, /style: 'factory'/);
|
||||||
|
assert.doesNotMatch(block, /style: 'gothic-lancet'/);
|
||||||
|
assert.match(block, /style: 'picture'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses modern painted surfaces instead of dingy brick cell walls', () => {
|
||||||
|
const block = dadaSalonBlock();
|
||||||
|
assert.doesNotMatch(block, /wallSide: 'brick'/);
|
||||||
|
assert.match(block, /painted-flat|painted-emulsion/);
|
||||||
|
assert.match(block, /Modern|cabaret|collage/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DadaDetails has no service pipe / iron-bar geometry', () => {
|
||||||
|
const block = dadaDetailsBlock();
|
||||||
|
assert.doesNotMatch(block, /Service pipe|cylinderGeometry/);
|
||||||
|
assert.match(block, /collage|cabaret|panel/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Low wall mouldings must not run through exit doors on the end walls.
|
||||||
|
* node --test server/__tests__/doorway-clearance.test.js
|
||||||
|
*/
|
||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..', '..');
|
||||||
|
const PRIMITIVES = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/primitives.tsx'), 'utf8');
|
||||||
|
const GEOMETRY = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/geometry.ts'), 'utf8');
|
||||||
|
const COURTLY = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/courtly.tsx'), 'utf8');
|
||||||
|
|
||||||
|
function flemishBlock() {
|
||||||
|
const start = COURTLY.indexOf('export function FlemishDetails');
|
||||||
|
assert.ok(start >= 0);
|
||||||
|
const next = COURTLY.indexOf('export function ', start + 1);
|
||||||
|
return COURTLY.slice(start, next > start ? next : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Doorway clearance for wall mouldings', () => {
|
||||||
|
it('exports a shared door half-clearance used by WallBand', () => {
|
||||||
|
assert.match(GEOMETRY, /DOOR_CLEAR_HALF\s*=\s*1\.45/);
|
||||||
|
assert.match(PRIMITIVES, /DOOR_CLEAR_HALF/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WallBand splits back/front bands that sit in the door height', () => {
|
||||||
|
assert.match(PRIMITIVES, /crossesDoor/);
|
||||||
|
assert.match(PRIMITIVES, /DOOR_CLEAR_HALF/);
|
||||||
|
assert.match(PRIMITIVES, /leftX|rightX/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Flemish linenfold ribs skip the back-wall doorway', () => {
|
||||||
|
const block = flemishBlock();
|
||||||
|
assert.match(block, /DOOR_CLEAR_HALF|Math\.abs\(x\)\s*>/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* No movement hall floor tint may blow out as near-white.
|
||||||
|
* node --test server/__tests__/floor-brightness.test.js
|
||||||
|
*/
|
||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..', '..');
|
||||||
|
const STYLES = fs.readFileSync(path.join(ROOT, 'client/src/data/movement-interior-styles.ts'), 'utf8');
|
||||||
|
|
||||||
|
/** Soft ceiling — mid stone / warm wood, not gallery flood. */
|
||||||
|
const MAX_FLOOR_LUMINANCE = 0.58;
|
||||||
|
|
||||||
|
function luminance(hex) {
|
||||||
|
const n = parseInt(hex.slice(1), 16);
|
||||||
|
const r = (n >> 16) & 255;
|
||||||
|
const g = (n >> 8) & 255;
|
||||||
|
const b = n & 255;
|
||||||
|
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
function floorTints() {
|
||||||
|
// tints objects inside mk(): wall/ceiling/floor hex triples (optional wallSide)
|
||||||
|
const re =
|
||||||
|
/\{\s*wall:\s*'#[^']+',(?:\s*wallSide:\s*'#[^']+',)?\s*ceiling:\s*'#[^']+',\s*floor:\s*'(#[0-9a-fA-F]{6})'/g;
|
||||||
|
const out = [];
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(STYLES))) out.push(m[1]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Movement floors — no blown-out brightness', () => {
|
||||||
|
it('every authored floor tint stays at or below mid-stone luminance', () => {
|
||||||
|
const floors = floorTints();
|
||||||
|
assert.ok(floors.length >= 20, `expected many floor tints, got ${floors.length}`);
|
||||||
|
const offenders = floors
|
||||||
|
.map((hex) => ({ hex, L: luminance(hex) }))
|
||||||
|
.filter((f) => f.L > MAX_FLOOR_LUMINANCE);
|
||||||
|
assert.deepEqual(
|
||||||
|
offenders,
|
||||||
|
[],
|
||||||
|
offenders.map((f) => `${f.hex} L=${f.L.toFixed(3)}`).join(', ')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Baroque floor is subdued warm marble, not near-white', () => {
|
||||||
|
const start = STYLES.indexOf("'baroque-palace'");
|
||||||
|
assert.ok(start >= 0);
|
||||||
|
const block = STYLES.slice(start, STYLES.indexOf('mk(', start + 1));
|
||||||
|
const m = block.match(/ceiling:\s*'#[^']+',\s*floor:\s*'(#[0-9a-fA-F]{6})'/);
|
||||||
|
assert.ok(m);
|
||||||
|
assert.ok(luminance(m[1]) <= MAX_FLOOR_LUMINANCE);
|
||||||
|
assert.doesNotMatch(block, /floor: '#ece8e0'/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* High Renaissance (cinquecento) must not flood the hall with tiny contrast
|
||||||
|
* tiles or gold ring ornaments in every coffer.
|
||||||
|
* node --test server/__tests__/high-renaissance-interior.test.js
|
||||||
|
*/
|
||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..', '..');
|
||||||
|
const STYLES = fs.readFileSync(path.join(ROOT, 'client/src/data/movement-interior-styles.ts'), 'utf8');
|
||||||
|
const DETAILS = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/classical.tsx'), 'utf8');
|
||||||
|
|
||||||
|
function styleBlock() {
|
||||||
|
const start = STYLES.indexOf("'high-renaissance-palazzo'");
|
||||||
|
assert.ok(start >= 0);
|
||||||
|
return STYLES.slice(start, STYLES.indexOf('mk(', start + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cinquecentoBlock() {
|
||||||
|
const start = DETAILS.indexOf('export function CinquecentoDetails');
|
||||||
|
assert.ok(start >= 0);
|
||||||
|
const next = DETAILS.indexOf('export function ', start + 1);
|
||||||
|
return DETAILS.slice(start, next > start ? next : DETAILS.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('High Renaissance interior — quieter surfaces', () => {
|
||||||
|
it('does not use high-contrast marble-checker flooring', () => {
|
||||||
|
const block = styleBlock();
|
||||||
|
assert.doesNotMatch(block, /floor: 'marble-checker'/);
|
||||||
|
assert.match(block, /floor: 'marble-veined-carrara'|floor: 'terrazzo'|floor: 'parquet/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Cinquecento coffers have no gold rosette rings', () => {
|
||||||
|
const block = cinquecentoBlock();
|
||||||
|
assert.doesNotMatch(block, /rosette=\{trim\}/);
|
||||||
|
assert.match(block, /CofferedCeiling/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Mannerist herm pilasters must read as clean architecture, not flat white strips
|
||||||
|
* with floating edge artifacts.
|
||||||
|
* node --test server/__tests__/mannerist-columns.test.js
|
||||||
|
*/
|
||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..', '..');
|
||||||
|
const DETAILS = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/courtly.tsx'), 'utf8');
|
||||||
|
|
||||||
|
function manneristBlock() {
|
||||||
|
const start = DETAILS.indexOf('export function ManneristDetails');
|
||||||
|
assert.ok(start >= 0, 'ManneristDetails must exist');
|
||||||
|
const next = DETAILS.indexOf('export function ', start + 1);
|
||||||
|
return DETAILS.slice(start, next > start ? next : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Mannerist herm columns', () => {
|
||||||
|
it('does not use the glitchy thin shaft + offset rustication + torus capital', () => {
|
||||||
|
const block = manneristBlock();
|
||||||
|
assert.doesNotMatch(block, /boxGeometry args=\{\[0\.09, 3\.8, 0\.3\]\}/);
|
||||||
|
assert.doesNotMatch(block, /position=\{\[side \* 0\.03, y, 0\]\}/);
|
||||||
|
assert.doesNotMatch(block, /torusGeometry args=\{\[0\.14, 0\.055/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds herm pilasters with clearance and a proper capital', () => {
|
||||||
|
const block = manneristBlock();
|
||||||
|
assert.match(block, /herm|pilaster/i);
|
||||||
|
assert.match(block, /clearLeft|clearRight|useWallClearance/);
|
||||||
|
assert.match(block, /capital|console|abacus/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Tests for movement lineage validation (run before / with the service module).
|
||||||
|
* node --test server/__tests__/movement-lineage-service.test.js
|
||||||
|
*/
|
||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const {
|
||||||
|
isValidProofUrl,
|
||||||
|
validateCreatePayload,
|
||||||
|
validateDeleteVotePayload,
|
||||||
|
DEFAULT_MOVEMENT_LINEAGE,
|
||||||
|
} = require('../movement-lineage-service');
|
||||||
|
|
||||||
|
describe('isValidProofUrl', () => {
|
||||||
|
it('accepts http and https URLs', () => {
|
||||||
|
assert.equal(isValidProofUrl('https://www.metmuseum.org/toah/hd/byza/hd_byza.htm'), true);
|
||||||
|
assert.equal(isValidProofUrl('http://example.com/essay'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty, non-http, and garbage', () => {
|
||||||
|
assert.equal(isValidProofUrl(''), false);
|
||||||
|
assert.equal(isValidProofUrl(' '), false);
|
||||||
|
assert.equal(isValidProofUrl(null), false);
|
||||||
|
assert.equal(isValidProofUrl('ftp://files.example/a'), false);
|
||||||
|
assert.equal(isValidProofUrl('not-a-url'), false);
|
||||||
|
assert.equal(isValidProofUrl('www.metmuseum.org/page'), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateCreatePayload', () => {
|
||||||
|
it('requires source, target, and proof URL', () => {
|
||||||
|
const r = validateCreatePayload({});
|
||||||
|
assert.equal(r.ok, false);
|
||||||
|
assert.match(r.error, /sourceMovementId/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects same source and target', () => {
|
||||||
|
const r = validateCreatePayload({
|
||||||
|
sourceMovementId: 3,
|
||||||
|
targetMovementId: 3,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/cubism/',
|
||||||
|
});
|
||||||
|
assert.equal(r.ok, false);
|
||||||
|
assert.match(r.error, /different/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a valid proof URL', () => {
|
||||||
|
const r = validateCreatePayload({
|
||||||
|
sourceMovementId: 1,
|
||||||
|
targetMovementId: 2,
|
||||||
|
proofUrl: 'see Wikipedia',
|
||||||
|
});
|
||||||
|
assert.equal(r.ok, false);
|
||||||
|
assert.match(r.error, /proof/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a valid payload and normalizes fields', () => {
|
||||||
|
const r = validateCreatePayload({
|
||||||
|
sourceMovementId: '10',
|
||||||
|
targetMovementId: 20,
|
||||||
|
proofUrl: ' https://www.metmuseum.org/essays/cubism ',
|
||||||
|
proofLabel: ' Met Cubism ',
|
||||||
|
linkYearApprox: '1907',
|
||||||
|
notes: ' Cézanne → Picasso ',
|
||||||
|
});
|
||||||
|
assert.equal(r.ok, true);
|
||||||
|
assert.deepEqual(r.value, {
|
||||||
|
sourceMovementId: 10,
|
||||||
|
targetMovementId: 20,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/essays/cubism',
|
||||||
|
proofLabel: 'Met Cubism',
|
||||||
|
linkYearApprox: 1907,
|
||||||
|
notes: 'Cézanne → Picasso',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows null optional year and blank optional label/notes', () => {
|
||||||
|
const r = validateCreatePayload({
|
||||||
|
sourceMovementId: 1,
|
||||||
|
targetMovementId: 2,
|
||||||
|
proofUrl: 'https://example.org/a',
|
||||||
|
proofLabel: '',
|
||||||
|
linkYearApprox: '',
|
||||||
|
notes: null,
|
||||||
|
});
|
||||||
|
assert.equal(r.ok, true);
|
||||||
|
assert.equal(r.value.proofLabel, null);
|
||||||
|
assert.equal(r.value.linkYearApprox, null);
|
||||||
|
assert.equal(r.value.notes, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateDeleteVotePayload', () => {
|
||||||
|
it('requires a non-empty reason', () => {
|
||||||
|
assert.equal(validateDeleteVotePayload({}).ok, false);
|
||||||
|
assert.equal(validateDeleteVotePayload({ reason: ' ' }).ok, false);
|
||||||
|
assert.match(validateDeleteVotePayload({ reason: '' }).error, /reason/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects reasons that are too short', () => {
|
||||||
|
const r = validateDeleteVotePayload({ reason: 'no' });
|
||||||
|
assert.equal(r.ok, false);
|
||||||
|
assert.match(r.error, /reason/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts and trims a valid reason', () => {
|
||||||
|
const r = validateDeleteVotePayload({
|
||||||
|
reason: ' Incorrect lineage: Cubism did not derive from Fauvism. ',
|
||||||
|
});
|
||||||
|
assert.equal(r.ok, true);
|
||||||
|
assert.equal(r.value.reason, 'Incorrect lineage: Cubism did not derive from Fauvism.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DEFAULT_MOVEMENT_LINEAGE', () => {
|
||||||
|
it('has unique source→target pairs with proof URLs', () => {
|
||||||
|
assert.ok(DEFAULT_MOVEMENT_LINEAGE.length >= 20);
|
||||||
|
const keys = new Set();
|
||||||
|
for (const row of DEFAULT_MOVEMENT_LINEAGE) {
|
||||||
|
assert.ok(row.sourceName);
|
||||||
|
assert.ok(row.targetName);
|
||||||
|
assert.notEqual(row.sourceName, row.targetName);
|
||||||
|
assert.equal(isValidProofUrl(row.proofUrl), true, row.proofUrl);
|
||||||
|
const key = `${row.sourceName}→${row.targetName}`;
|
||||||
|
assert.equal(keys.has(key), false, `duplicate ${key}`);
|
||||||
|
keys.add(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Neoclassical museum floor must not blow out as near-white marble.
|
||||||
|
* node --test server/__tests__/neoclassical-interior.test.js
|
||||||
|
*/
|
||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..', '..');
|
||||||
|
const STYLES = fs.readFileSync(path.join(ROOT, 'client/src/data/movement-interior-styles.ts'), 'utf8');
|
||||||
|
|
||||||
|
function neoclassicalBlock() {
|
||||||
|
const start = STYLES.indexOf("'neoclassical-museum'");
|
||||||
|
assert.ok(start >= 0, 'neoclassical-museum style must exist');
|
||||||
|
const next = STYLES.indexOf('mk(', start + 1);
|
||||||
|
return STYLES.slice(start, next > start ? next : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function floorTintHex(block) {
|
||||||
|
const m = block.match(/floor:\s*'#[0-9a-fA-F]{6}'/);
|
||||||
|
assert.ok(m, 'floor tint hex must be present in tints object');
|
||||||
|
// Prefer the tints line: `{ wall: '...', ceiling: '...', floor: '#......', trim: '...' }`
|
||||||
|
const tints = block.match(/\{\s*wall:\s*'#[^']+',\s*ceiling:\s*'#[^']+',\s*floor:\s*'(#[0-9a-fA-F]{6})'/);
|
||||||
|
assert.ok(tints, 'tints.floor must be a hex color');
|
||||||
|
return tints[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function luminance(hex) {
|
||||||
|
const n = parseInt(hex.slice(1), 16);
|
||||||
|
const r = (n >> 16) & 255;
|
||||||
|
const g = (n >> 8) & 255;
|
||||||
|
const b = n & 255;
|
||||||
|
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Neoclassical interior — subdued stone floor', () => {
|
||||||
|
it('floor tint is mid stone grey, not near-white', () => {
|
||||||
|
const block = neoclassicalBlock();
|
||||||
|
const hex = floorTintHex(block);
|
||||||
|
assert.ok(luminance(hex) < 0.62, `floor luminance ${luminance(hex).toFixed(2)} for ${hex} is still too bright`);
|
||||||
|
assert.doesNotMatch(block, /floor: '#eceae6'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skylight is not overdriven for the marble floor', () => {
|
||||||
|
const block = neoclassicalBlock();
|
||||||
|
const sky = block.match(/style: 'skylight',\s*lightColor: '[^']+',\s*lightIntensity:\s*([0-9.]+)/);
|
||||||
|
assert.ok(sky, 'skylight must exist');
|
||||||
|
assert.ok(Number(sky[1]) <= 3.6, `skylight intensity ${sky[1]} is still too strong`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* No movement hall may use factory (iron-bar grid) windows — they read as prison cells.
|
||||||
|
* node --test server/__tests__/no-factory-windows.test.js
|
||||||
|
*/
|
||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..', '..');
|
||||||
|
const STYLES = fs.readFileSync(path.join(ROOT, 'client/src/data/movement-interior-styles.ts'), 'utf8');
|
||||||
|
|
||||||
|
function styleBlock(id) {
|
||||||
|
const start = STYLES.indexOf(`'${id}'`);
|
||||||
|
assert.ok(start >= 0, `${id} must exist`);
|
||||||
|
const next = STYLES.indexOf('mk(', start + 1);
|
||||||
|
return STYLES.slice(start, next > start ? next : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Movement interiors — no factory / iron-bar windows', () => {
|
||||||
|
it('no authored movement style uses factory windows', () => {
|
||||||
|
// Only count style: 'factory' assignments inside BY_MOVEMENT_ID mk() blocks,
|
||||||
|
// not the GalleryWindowStyle union member.
|
||||||
|
const uses = [...STYLES.matchAll(/style:\s*'factory'/g)];
|
||||||
|
assert.equal(uses.length, 0, `found ${uses.length} factory window assignment(s)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Futurism uses open picture or clerestory daylight, not bars', () => {
|
||||||
|
const block = styleBlock('futurist-loft');
|
||||||
|
assert.doesNotMatch(block, /style: 'factory'/);
|
||||||
|
assert.match(block, /style: 'picture'|style: 'ribbon'|style: 'clerestory'|style: 'skylight'/);
|
||||||
|
assert.match(block, /open|ribbon|picture|glass/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Side-wall paintings must not hang in front of bay columns/pilasters.
|
||||||
|
* Columns stay fixed; the hang skips column keep-outs (and windows must too).
|
||||||
|
* node --test server/__tests__/painting-column-clearance.test.js
|
||||||
|
*/
|
||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..', '..');
|
||||||
|
const KEEP = fs.readFileSync(path.join(ROOT, 'client/src/utils/sideColumnKeepOuts.ts'), 'utf8');
|
||||||
|
const LAYOUT = fs.readFileSync(path.join(ROOT, 'client/src/utils/movementHallLayout.ts'), 'utf8');
|
||||||
|
const GEOMETRY = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/geometry.ts'), 'utf8');
|
||||||
|
const GALLERY = fs.readFileSync(path.join(ROOT, 'client/src/components/VirtualGallery.tsx'), 'utf8');
|
||||||
|
|
||||||
|
/** Mirror of bayPositions — must stay in lockstep with sideColumnKeepOuts.ts / geometry.ts */
|
||||||
|
function bayPositions(depth, halfD, spacing = 4.2, max = 9) {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRhythmTable(src) {
|
||||||
|
const start = src.indexOf('SIDE_WALL_COLUMN_RHYTHMS');
|
||||||
|
assert.ok(start >= 0, 'SIDE_WALL_COLUMN_RHYTHMS export missing');
|
||||||
|
const bodyStart = src.indexOf('{', start);
|
||||||
|
const bodyEnd = src.indexOf('} as const', bodyStart);
|
||||||
|
assert.ok(bodyEnd > bodyStart);
|
||||||
|
const body = src.slice(bodyStart, bodyEnd + 1);
|
||||||
|
const rhythms = {};
|
||||||
|
for (const m of body.matchAll(
|
||||||
|
/['"]?([a-z0-9-]+)['"]?:\s*\{\s*spacing:\s*([\d.]+),\s*max:\s*(\d+),\s*halfClear:\s*([\d.]+)\s*\}/g
|
||||||
|
)) {
|
||||||
|
rhythms[m[1]] = { spacing: Number(m[2]), max: Number(m[3]), halfClear: Number(m[4]) };
|
||||||
|
}
|
||||||
|
return rhythms;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Painting ↔ column clearance', () => {
|
||||||
|
it('exports bayPositions shared by geometry and keep-outs', () => {
|
||||||
|
assert.match(KEEP, /export function bayPositions/);
|
||||||
|
assert.match(GEOMETRY, /bayPositions/);
|
||||||
|
assert.match(GEOMETRY, /useBayZ[\s\S]*bayPositions/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers keep-outs for every mid-wall bay column style', () => {
|
||||||
|
const rhythms = parseRhythmTable(KEEP);
|
||||||
|
for (const kind of [
|
||||||
|
'gothic',
|
||||||
|
'roman',
|
||||||
|
'quattrocento',
|
||||||
|
'cinquecento',
|
||||||
|
'mannerist',
|
||||||
|
'neoclassical',
|
||||||
|
'gothic-revival',
|
||||||
|
'art-nouveau',
|
||||||
|
]) {
|
||||||
|
assert.ok(rhythms[kind], `missing rhythm for ${kind}`);
|
||||||
|
assert.ok(rhythms[kind].halfClear >= 0.18, `${kind} halfClear too small`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('layout packs side walls around column keep-outs and grows depth if needed', () => {
|
||||||
|
assert.match(LAYOUT, /sideColumnKeepOutIntervals/);
|
||||||
|
assert.match(LAYOUT, /depthForSideHang|sideWallGaps/);
|
||||||
|
assert.match(LAYOUT, /details\?:\s*MovementDetailKind/);
|
||||||
|
assert.match(LAYOUT, /buildAllMovementHallLayouts\(\s*paintings:\s*Painting\[\],\s*details\?/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('windows treat column keep-outs as occupied so they do not land on columns', () => {
|
||||||
|
const fn = LAYOUT.slice(LAYOUT.indexOf('export function computeSideWallWindows'));
|
||||||
|
assert.match(fn, /sideColumnKeepOutIntervals/);
|
||||||
|
assert.match(fn, /columnKeepOuts/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VirtualGallery passes the movement detail kind into hall layout', () => {
|
||||||
|
assert.match(GALLERY, /buildAllMovementHallLayouts\(\s*paintings\s*,\s*interiorStyle\?\.details\s*\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bay keep-out intervals cover each column centre for a typical hall depth', () => {
|
||||||
|
const rhythms = parseRhythmTable(KEEP);
|
||||||
|
const depth = 14;
|
||||||
|
const halfD = depth / 2;
|
||||||
|
for (const [kind, r] of Object.entries(rhythms)) {
|
||||||
|
const zs = bayPositions(depth, halfD, r.spacing, r.max);
|
||||||
|
assert.ok(zs.length >= 2, kind);
|
||||||
|
for (const z of zs) {
|
||||||
|
const lo = z - r.halfClear;
|
||||||
|
const hi = z + r.halfClear;
|
||||||
|
assert.ok(lo < z && z < hi, `${kind} keep-out must contain bay centre`);
|
||||||
|
// A typical frame half-width centred on the column would collide
|
||||||
|
const halfOuter = 0.35;
|
||||||
|
assert.ok(z - halfOuter < hi && z + halfOuter > lo, `${kind} frame would overlap column`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Surrealist hall must not place a freestanding decorative door in a corner.
|
||||||
|
* node --test server/__tests__/surrealist-interior.test.js
|
||||||
|
*/
|
||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..', '..');
|
||||||
|
const DETAILS = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/modernism.tsx'), 'utf8');
|
||||||
|
const COURTLY = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/courtly.tsx'), 'utf8');
|
||||||
|
const CLASSICAL = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/classical.tsx'), 'utf8');
|
||||||
|
const NINETEENTH = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/nineteenth.tsx'), 'utf8');
|
||||||
|
|
||||||
|
function surrealistBlock() {
|
||||||
|
const start = DETAILS.indexOf('export function SurrealistDetails');
|
||||||
|
assert.ok(start >= 0, 'SurrealistDetails must exist');
|
||||||
|
const next = DETAILS.indexOf('export function ', start + 1);
|
||||||
|
return DETAILS.slice(start, next > start ? next : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Surrealist interior — no corner door', () => {
|
||||||
|
it('does not place a freestanding door in the corner', () => {
|
||||||
|
const block = surrealistBlock();
|
||||||
|
assert.doesNotMatch(block, /door that leads nowhere|standing in the corner pocket/i);
|
||||||
|
assert.doesNotMatch(block, /boxGeometry args=\{\[0\.72, 2\.1/);
|
||||||
|
assert.doesNotMatch(block, /boxGeometry args=\{\[0\.84, 2\.24/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no other period module places a freestanding corner door prop', () => {
|
||||||
|
const sources = [DETAILS, COURTLY, CLASSICAL, NINETEENTH].join('\n');
|
||||||
|
assert.doesNotMatch(sources, /door that leads nowhere|leads nowhere|false door|freestanding door/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,6 +50,7 @@ app.use('/api/users', usersRoutes);
|
|||||||
app.use('/api/audit', auditRoutes);
|
app.use('/api/audit', auditRoutes);
|
||||||
app.use('/api/translations', translationRoutes);
|
app.use('/api/translations', translationRoutes);
|
||||||
app.use('/api/influences', influenceRoutes);
|
app.use('/api/influences', influenceRoutes);
|
||||||
|
app.use('/api/movement-links', require('./routes/movement-links'));
|
||||||
app.use('/api/tours', tourRoutes);
|
app.use('/api/tours', tourRoutes);
|
||||||
app.use(
|
app.use(
|
||||||
'/images',
|
'/images',
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const INCREMENTAL_MIGRATIONS = [
|
|||||||
'migrate-tours.sql',
|
'migrate-tours.sql',
|
||||||
'migrate-curator-notes.sql',
|
'migrate-curator-notes.sql',
|
||||||
'migrate-user-roles.sql',
|
'migrate-user-roles.sql',
|
||||||
|
'migrate-movement-links.sql',
|
||||||
|
'migrate-movement-link-delete-votes.sql',
|
||||||
];
|
];
|
||||||
|
|
||||||
const BOOTSTRAP_ADMIN_PERMISSIONS = [
|
const BOOTSTRAP_ADMIN_PERMISSIONS = [
|
||||||
@@ -80,6 +82,15 @@ async function migrate() {
|
|||||||
console.log('Bootstrapping curator account (if needed) …');
|
console.log('Bootstrapping curator account (if needed) …');
|
||||||
await bootstrapCurator();
|
await bootstrapCurator();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { seedDefaultsIfEmpty } = require('./movement-lineage-service');
|
||||||
|
console.log('Seeding movement lineage defaults (if empty) …');
|
||||||
|
const seed = await seedDefaultsIfEmpty();
|
||||||
|
console.log(` movement lineage: inserted=${seed.inserted}${seed.skipped ? ' (skipped)' : ''}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(` movement lineage seed skipped: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Database migration complete.');
|
console.log('Database migration complete.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,581 @@
|
|||||||
|
/**
|
||||||
|
* Movement predecessor → successor lineage (timeline branches / Tree of Art).
|
||||||
|
* Curators manage rows with required proof URLs; defaults seed from scholarship.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const pool = require('./db');
|
||||||
|
|
||||||
|
/** @typedef {{ sourceName: string, targetName: string, linkYearApprox: number | null, proofUrl: string, proofLabel: string }} DefaultLink */
|
||||||
|
|
||||||
|
/** @type {ReadonlyArray<DefaultLink>} */
|
||||||
|
const DEFAULT_MOVEMENT_LINEAGE = Object.freeze([
|
||||||
|
{
|
||||||
|
sourceName: 'Ancient Greek & Roman',
|
||||||
|
targetName: 'Byzantine',
|
||||||
|
linkYearApprox: 330,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/hd/byza/hd_byza.htm',
|
||||||
|
proofLabel: 'Met — Byzantine art',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Byzantine',
|
||||||
|
targetName: 'Gothic',
|
||||||
|
linkYearApprox: 1150,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/hd/mgot/hd_mgot.htm',
|
||||||
|
proofLabel: 'Met — Gothic art',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Gothic',
|
||||||
|
targetName: 'Early Renaissance',
|
||||||
|
linkYearApprox: 1400,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/hd/elar/hd_elar.htm',
|
||||||
|
proofLabel: 'Met — Florence & Early Renaissance',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Northern Renaissance',
|
||||||
|
targetName: 'Early Renaissance',
|
||||||
|
linkYearApprox: 1420,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/hd/enet/hd_enet.htm',
|
||||||
|
proofLabel: 'Met — Early Netherlandish painting',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Early Renaissance',
|
||||||
|
targetName: 'High Renaissance',
|
||||||
|
linkYearApprox: 1490,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/hd/hiren/hd_hiren.htm',
|
||||||
|
proofLabel: 'Met — High Renaissance',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'High Renaissance',
|
||||||
|
targetName: 'Mannerism',
|
||||||
|
linkYearApprox: 1520,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/hd/zino/hd_zino.htm',
|
||||||
|
proofLabel: 'Met — Mannerism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Mannerism',
|
||||||
|
targetName: 'Baroque',
|
||||||
|
linkYearApprox: 1600,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/hd/baro/hd_baro.htm',
|
||||||
|
proofLabel: 'Met — Baroque Rome',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Baroque',
|
||||||
|
targetName: 'Rococo',
|
||||||
|
linkYearApprox: 1700,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/ht/09/euwf.html',
|
||||||
|
proofLabel: 'Met — France 1600–1800 (Rococo)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Rococo',
|
||||||
|
targetName: 'Neoclassicism',
|
||||||
|
linkYearApprox: 1760,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/ht/09/euwf.html',
|
||||||
|
proofLabel: 'Met — France 1600–1800 (Neoclassicism)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Neoclassicism',
|
||||||
|
targetName: 'Romanticism',
|
||||||
|
linkYearApprox: 1800,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/ht/10/euwf.html',
|
||||||
|
proofLabel: 'Met — France 1800–1900',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Romanticism',
|
||||||
|
targetName: 'Realism',
|
||||||
|
linkYearApprox: 1840,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/hd/gust/hd_gust.htm',
|
||||||
|
proofLabel: 'Met — Gustave Courbet / Realism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Romanticism',
|
||||||
|
targetName: 'Impressionism',
|
||||||
|
linkYearApprox: 1860,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/impressionism/',
|
||||||
|
proofLabel: 'TheArtStory — Impressionism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Realism',
|
||||||
|
targetName: 'Impressionism',
|
||||||
|
linkYearApprox: 1860,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/toah/hd/imml/hd_imml.htm',
|
||||||
|
proofLabel: 'Met — Impressionism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Impressionism',
|
||||||
|
targetName: 'Post-Impressionism',
|
||||||
|
linkYearApprox: 1880,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/post-impressionism/',
|
||||||
|
proofLabel: 'TheArtStory — Post-Impressionism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Post-Impressionism',
|
||||||
|
targetName: 'Symbolism',
|
||||||
|
linkYearApprox: 1880,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/symbolism/',
|
||||||
|
proofLabel: 'TheArtStory — Symbolism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Post-Impressionism',
|
||||||
|
targetName: 'Fauvism',
|
||||||
|
linkYearApprox: 1904,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/fauvism/',
|
||||||
|
proofLabel: 'TheArtStory — Fauvism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Post-Impressionism',
|
||||||
|
targetName: 'Expressionism',
|
||||||
|
linkYearApprox: 1905,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/expressionism/',
|
||||||
|
proofLabel: 'TheArtStory — Expressionism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Post-Impressionism',
|
||||||
|
targetName: 'Cubism',
|
||||||
|
linkYearApprox: 1907,
|
||||||
|
proofUrl: 'https://www.metmuseum.org/essays/cubism',
|
||||||
|
proofLabel: 'Met — Cubism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Symbolism',
|
||||||
|
targetName: 'Art Nouveau',
|
||||||
|
linkYearApprox: 1890,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/art-nouveau/',
|
||||||
|
proofLabel: 'TheArtStory — Art Nouveau',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Fauvism',
|
||||||
|
targetName: 'Cubism',
|
||||||
|
linkYearApprox: 1907,
|
||||||
|
proofUrl:
|
||||||
|
'https://www.christies.com/en/stories/from-fauvism-to-cubism-bc026df2c6774575938a4e26cacb8259',
|
||||||
|
proofLabel: "Christie's — From Fauvism to Cubism",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Cubism',
|
||||||
|
targetName: 'Futurism',
|
||||||
|
linkYearApprox: 1909,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/futurism/',
|
||||||
|
proofLabel: 'TheArtStory — Futurism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Cubism',
|
||||||
|
targetName: 'Suprematism',
|
||||||
|
linkYearApprox: 1913,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/suprematism/',
|
||||||
|
proofLabel: 'TheArtStory — Suprematism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Cubism',
|
||||||
|
targetName: 'Constructivism',
|
||||||
|
linkYearApprox: 1913,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/constructivism/',
|
||||||
|
proofLabel: 'TheArtStory — Constructivism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Expressionism',
|
||||||
|
targetName: 'Abstract Expressionism',
|
||||||
|
linkYearApprox: 1940,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/abstract-expressionism/',
|
||||||
|
proofLabel: 'TheArtStory — Abstract Expressionism',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Dada',
|
||||||
|
targetName: 'Surrealism',
|
||||||
|
linkYearApprox: 1924,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/dada/',
|
||||||
|
proofLabel: 'TheArtStory — Dada',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Surrealism',
|
||||||
|
targetName: 'Abstract Expressionism',
|
||||||
|
linkYearApprox: 1940,
|
||||||
|
proofUrl: 'https://www.theartstory.org/artist/breton-andre/',
|
||||||
|
proofLabel: 'TheArtStory — André Breton',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceName: 'Abstract Expressionism',
|
||||||
|
targetName: 'Pop Art',
|
||||||
|
linkYearApprox: 1955,
|
||||||
|
proofUrl: 'https://www.theartstory.org/movement/pop-art/',
|
||||||
|
proofLabel: 'TheArtStory — Pop Art',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isValidProofUrl(value) {
|
||||||
|
if (typeof value !== 'string') return false;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
try {
|
||||||
|
const u = new URL(trimmed);
|
||||||
|
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveId(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalYear(value) {
|
||||||
|
if (value == null || value === '') return null;
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return NaN;
|
||||||
|
return Math.trunc(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} body
|
||||||
|
* @returns {{ ok: true, value: object } | { ok: false, error: string }}
|
||||||
|
*/
|
||||||
|
function validateCreatePayload(body) {
|
||||||
|
const raw = body && typeof body === 'object' ? body : {};
|
||||||
|
const sourceMovementId = parsePositiveId(raw.sourceMovementId);
|
||||||
|
const targetMovementId = parsePositiveId(raw.targetMovementId);
|
||||||
|
if (!sourceMovementId) {
|
||||||
|
return { ok: false, error: 'sourceMovementId is required' };
|
||||||
|
}
|
||||||
|
if (!targetMovementId) {
|
||||||
|
return { ok: false, error: 'targetMovementId is required' };
|
||||||
|
}
|
||||||
|
if (sourceMovementId === targetMovementId) {
|
||||||
|
return { ok: false, error: 'source and target must be different movements' };
|
||||||
|
}
|
||||||
|
const proofUrlRaw = typeof raw.proofUrl === 'string' ? raw.proofUrl.trim() : '';
|
||||||
|
if (!isValidProofUrl(proofUrlRaw)) {
|
||||||
|
return { ok: false, error: 'proofUrl must be a valid http(s) URL (required)' };
|
||||||
|
}
|
||||||
|
const linkYearApprox = parseOptionalYear(raw.linkYearApprox);
|
||||||
|
if (Number.isNaN(linkYearApprox)) {
|
||||||
|
return { ok: false, error: 'linkYearApprox must be a number when provided' };
|
||||||
|
}
|
||||||
|
const proofLabel =
|
||||||
|
typeof raw.proofLabel === 'string' && raw.proofLabel.trim()
|
||||||
|
? raw.proofLabel.trim()
|
||||||
|
: null;
|
||||||
|
const notes =
|
||||||
|
typeof raw.notes === 'string' && raw.notes.trim() ? raw.notes.trim() : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
sourceMovementId,
|
||||||
|
targetMovementId,
|
||||||
|
proofUrl: proofUrlRaw,
|
||||||
|
proofLabel,
|
||||||
|
linkYearApprox,
|
||||||
|
notes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_DELETE_VOTE_REASON_LEN = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} body
|
||||||
|
* @returns {{ ok: true, value: { reason: string } } | { ok: false, error: string }}
|
||||||
|
*/
|
||||||
|
function validateDeleteVotePayload(body) {
|
||||||
|
const raw = body && typeof body === 'object' ? body : {};
|
||||||
|
const reason = typeof raw.reason === 'string' ? raw.reason.trim() : '';
|
||||||
|
if (!reason) {
|
||||||
|
return { ok: false, error: 'reason is required' };
|
||||||
|
}
|
||||||
|
if (reason.length < MIN_DELETE_VOTE_REASON_LEN) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `reason must be at least ${MIN_DELETE_VOTE_REASON_LEN} characters`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, value: { reason } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIST_SELECT = `
|
||||||
|
SELECT ml.id,
|
||||||
|
ml.source_movement_id,
|
||||||
|
ml.target_movement_id,
|
||||||
|
ml.link_year_approx,
|
||||||
|
ml.proof_url,
|
||||||
|
ml.proof_label,
|
||||||
|
ml.notes,
|
||||||
|
ml.created_at,
|
||||||
|
ml.updated_at,
|
||||||
|
s.name AS source_name,
|
||||||
|
s.start_year AS source_start_year,
|
||||||
|
t.name AS target_name,
|
||||||
|
t.start_year AS target_start_year,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)::int
|
||||||
|
FROM movement_lineage_delete_votes v
|
||||||
|
WHERE v.link_id = ml.id
|
||||||
|
) AS delete_vote_count
|
||||||
|
FROM movement_lineage_links ml
|
||||||
|
JOIN art_movements s ON s.id = ml.source_movement_id
|
||||||
|
JOIN art_movements t ON t.id = ml.target_movement_id
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function listLinks({ q, limit = 500, offset = 0, viewerUserId = null } = {}) {
|
||||||
|
const params = [];
|
||||||
|
const where = [];
|
||||||
|
if (q && typeof q === 'string' && q.trim()) {
|
||||||
|
params.push(`%${q.trim()}%`);
|
||||||
|
const i = params.length;
|
||||||
|
where.push(`(
|
||||||
|
s.name ILIKE $${i} OR t.name ILIKE $${i}
|
||||||
|
OR ml.proof_url ILIKE $${i} OR COALESCE(ml.proof_label, '') ILIKE $${i}
|
||||||
|
OR COALESCE(ml.notes, '') ILIKE $${i}
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||||
|
params.push(Math.min(1000, Math.max(1, Number(limit) || 500)));
|
||||||
|
const limitIdx = params.length;
|
||||||
|
params.push(Math.max(0, Number(offset) || 0));
|
||||||
|
const offsetIdx = params.length;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`${LIST_SELECT}
|
||||||
|
${whereSql}
|
||||||
|
ORDER BY COALESCE(ml.link_year_approx, t.start_year) ASC NULLS LAST, s.name, t.name
|
||||||
|
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
const countParams = params.slice(0, -2);
|
||||||
|
const { rows: countRows } = await pool.query(
|
||||||
|
`SELECT COUNT(*)::int AS n
|
||||||
|
FROM movement_lineage_links ml
|
||||||
|
JOIN art_movements s ON s.id = ml.source_movement_id
|
||||||
|
JOIN art_movements t ON t.id = ml.target_movement_id
|
||||||
|
${whereSql}`,
|
||||||
|
countParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
let items = rows;
|
||||||
|
if (viewerUserId && rows.length) {
|
||||||
|
const ids = rows.map((r) => r.id);
|
||||||
|
const { rows: myVotes } = await pool.query(
|
||||||
|
`SELECT link_id FROM movement_lineage_delete_votes
|
||||||
|
WHERE user_id = $1 AND link_id = ANY($2::int[])`,
|
||||||
|
[viewerUserId, ids],
|
||||||
|
);
|
||||||
|
const voted = new Set(myVotes.map((v) => v.link_id));
|
||||||
|
items = rows.map((r) => ({
|
||||||
|
...r,
|
||||||
|
viewer_has_delete_vote: voted.has(r.id),
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
items = rows.map((r) => ({ ...r, viewer_has_delete_vote: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items, total: countRows[0]?.n ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createLink(payload, { userId } = {}) {
|
||||||
|
const validated = validateCreatePayload(payload);
|
||||||
|
if (!validated.ok) {
|
||||||
|
const err = new Error(validated.error);
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const v = validated.value;
|
||||||
|
|
||||||
|
const movements = await pool.query(
|
||||||
|
`SELECT id FROM art_movements WHERE id = ANY($1::int[])`,
|
||||||
|
[[v.sourceMovementId, v.targetMovementId]],
|
||||||
|
);
|
||||||
|
if (movements.rows.length !== 2) {
|
||||||
|
const err = new Error('source or target movement not found');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO movement_lineage_links
|
||||||
|
(source_movement_id, target_movement_id, link_year_approx, proof_url, proof_label, notes, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
v.sourceMovementId,
|
||||||
|
v.targetMovementId,
|
||||||
|
v.linkYearApprox,
|
||||||
|
v.proofUrl,
|
||||||
|
v.proofLabel,
|
||||||
|
v.notes,
|
||||||
|
userId || null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const { rows: full } = await pool.query(`${LIST_SELECT} WHERE ml.id = $1`, [rows[0].id]);
|
||||||
|
return full[0];
|
||||||
|
} catch (e) {
|
||||||
|
if (e && e.code === '23505') {
|
||||||
|
const err = new Error('A link between these movements already exists');
|
||||||
|
err.status = 409;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteLink(id) {
|
||||||
|
const linkId = parsePositiveId(id);
|
||||||
|
if (!linkId) {
|
||||||
|
const err = new Error('Invalid link id');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`DELETE FROM movement_lineage_links WHERE id = $1 RETURNING id`,
|
||||||
|
[linkId],
|
||||||
|
);
|
||||||
|
if (!rows.length) {
|
||||||
|
const err = new Error('Link not found');
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return { id: linkId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listDeleteVotes(linkId) {
|
||||||
|
const id = parsePositiveId(linkId);
|
||||||
|
if (!id) {
|
||||||
|
const err = new Error('Invalid link id');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const { rows: linkRows } = await pool.query(
|
||||||
|
`SELECT id FROM movement_lineage_links WHERE id = $1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (!linkRows.length) {
|
||||||
|
const err = new Error('Link not found');
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT v.id, v.link_id, v.user_id, v.reason, v.created_at, u.username
|
||||||
|
FROM movement_lineage_delete_votes v
|
||||||
|
JOIN users u ON u.id = v.user_id
|
||||||
|
WHERE v.link_id = $1
|
||||||
|
ORDER BY v.created_at ASC`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
return { linkId: id, votes: rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function castDeleteVote(linkId, body, { userId }) {
|
||||||
|
const id = parsePositiveId(linkId);
|
||||||
|
if (!id) {
|
||||||
|
const err = new Error('Invalid link id');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!userId) {
|
||||||
|
const err = new Error('Curator login required');
|
||||||
|
err.status = 401;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const validated = validateDeleteVotePayload(body);
|
||||||
|
if (!validated.ok) {
|
||||||
|
const err = new Error(validated.error);
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows: linkRows } = await pool.query(
|
||||||
|
`SELECT id FROM movement_lineage_links WHERE id = $1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (!linkRows.length) {
|
||||||
|
const err = new Error('Link not found');
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO movement_lineage_delete_votes (link_id, user_id, reason)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, link_id, user_id, reason, created_at`,
|
||||||
|
[id, userId, validated.value.reason],
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
} catch (e) {
|
||||||
|
if (e && e.code === '23505') {
|
||||||
|
const err = new Error('You already voted to delete this link');
|
||||||
|
err.status = 409;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withdrawDeleteVote(linkId, { userId }) {
|
||||||
|
const id = parsePositiveId(linkId);
|
||||||
|
if (!id || !userId) {
|
||||||
|
const err = new Error('Invalid request');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`DELETE FROM movement_lineage_delete_votes
|
||||||
|
WHERE link_id = $1 AND user_id = $2
|
||||||
|
RETURNING id`,
|
||||||
|
[id, userId],
|
||||||
|
);
|
||||||
|
if (!rows.length) {
|
||||||
|
const err = new Error('Vote not found');
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return { id: rows[0].id, linkId: id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert default scholarship links when the table is empty and movements exist. */
|
||||||
|
async function seedDefaultsIfEmpty() {
|
||||||
|
const { rows: countRows } = await pool.query(
|
||||||
|
`SELECT COUNT(*)::int AS n FROM movement_lineage_links`,
|
||||||
|
);
|
||||||
|
if ((countRows[0]?.n ?? 0) > 0) return { inserted: 0, skipped: true };
|
||||||
|
|
||||||
|
const { rows: movements } = await pool.query(`SELECT id, name FROM art_movements`);
|
||||||
|
if (!movements.length) return { inserted: 0, skipped: true, reason: 'no movements' };
|
||||||
|
|
||||||
|
const byName = new Map(movements.map((m) => [m.name, m.id]));
|
||||||
|
let inserted = 0;
|
||||||
|
let missing = 0;
|
||||||
|
for (const row of DEFAULT_MOVEMENT_LINEAGE) {
|
||||||
|
const sourceId = byName.get(row.sourceName);
|
||||||
|
const targetId = byName.get(row.targetName);
|
||||||
|
if (!sourceId || !targetId) {
|
||||||
|
missing += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO movement_lineage_links
|
||||||
|
(source_movement_id, target_movement_id, link_year_approx, proof_url, proof_label)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (source_movement_id, target_movement_id) DO NOTHING
|
||||||
|
RETURNING id`,
|
||||||
|
[sourceId, targetId, row.linkYearApprox, row.proofUrl, row.proofLabel],
|
||||||
|
);
|
||||||
|
if (result.rows.length) inserted += 1;
|
||||||
|
}
|
||||||
|
return { inserted, missing };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULT_MOVEMENT_LINEAGE,
|
||||||
|
MIN_DELETE_VOTE_REASON_LEN,
|
||||||
|
isValidProofUrl,
|
||||||
|
validateCreatePayload,
|
||||||
|
validateDeleteVotePayload,
|
||||||
|
listLinks,
|
||||||
|
createLink,
|
||||||
|
deleteLink,
|
||||||
|
listDeleteVotes,
|
||||||
|
castDeleteVote,
|
||||||
|
withdrawDeleteVote,
|
||||||
|
seedDefaultsIfEmpty,
|
||||||
|
};
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { requirePermission, requireAdmin } = require('../middleware/auth');
|
||||||
|
const { logCuratorAction } = require('../audit-log');
|
||||||
|
const {
|
||||||
|
listLinks,
|
||||||
|
createLink,
|
||||||
|
deleteLink,
|
||||||
|
listDeleteVotes,
|
||||||
|
castDeleteVote,
|
||||||
|
withdrawDeleteVote,
|
||||||
|
seedDefaultsIfEmpty,
|
||||||
|
} = require('../movement-lineage-service');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/** Optional staff session for vote annotations on list (public otherwise). */
|
||||||
|
async function optionalStaff(req, _res, next) {
|
||||||
|
try {
|
||||||
|
if (req.session?.userId) {
|
||||||
|
const { loadStaffUser } = require('../middleware/auth');
|
||||||
|
const user = await loadStaffUser(req.session.userId);
|
||||||
|
if (user && user.is_active) {
|
||||||
|
req.curatorUser = user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore — list stays public */
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public: timeline / tree consumers. */
|
||||||
|
router.get('/', optionalStaff, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await seedDefaultsIfEmpty();
|
||||||
|
const q = typeof req.query.q === 'string' ? req.query.q : '';
|
||||||
|
const limit = Number(req.query.limit) || 500;
|
||||||
|
const offset = Number(req.query.offset) || 0;
|
||||||
|
const data = await listLinks({
|
||||||
|
q,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
viewerUserId: req.curatorUser?.id || null,
|
||||||
|
});
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to list movement links' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', requirePermission('influences'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const item = await createLink(req.body || {}, { userId: req.session?.userId });
|
||||||
|
await logCuratorAction({
|
||||||
|
userId: req.session.userId,
|
||||||
|
action: 'movement_link.create',
|
||||||
|
resourceType: 'movement_lineage_link',
|
||||||
|
resourceId: item.id,
|
||||||
|
details: {
|
||||||
|
source_movement_id: item.source_movement_id,
|
||||||
|
target_movement_id: item.target_movement_id,
|
||||||
|
proof_url: item.proof_url,
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
res.status(201).json({ item });
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.status || 500;
|
||||||
|
if (status >= 500) console.error(err);
|
||||||
|
res.status(status).json({ error: err.message || 'Failed to create movement link' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id/delete-votes', requirePermission('influences'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await listDeleteVotes(req.params.id);
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.status || 500;
|
||||||
|
if (status >= 500) console.error(err);
|
||||||
|
res.status(status).json({ error: err.message || 'Failed to list delete votes' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/delete-votes', requirePermission('influences'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const vote = await castDeleteVote(req.params.id, req.body || {}, {
|
||||||
|
userId: req.session.userId,
|
||||||
|
});
|
||||||
|
await logCuratorAction({
|
||||||
|
userId: req.session.userId,
|
||||||
|
action: 'movement_link.delete_vote',
|
||||||
|
resourceType: 'movement_lineage_link',
|
||||||
|
resourceId: Number(req.params.id),
|
||||||
|
details: { reason: vote.reason },
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
res.status(201).json({ vote });
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.status || 500;
|
||||||
|
if (status >= 500) console.error(err);
|
||||||
|
res.status(status).json({ error: err.message || 'Failed to cast delete vote' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id/delete-votes/me', requirePermission('influences'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await withdrawDeleteVote(req.params.id, { userId: req.session.userId });
|
||||||
|
await logCuratorAction({
|
||||||
|
userId: req.session.userId,
|
||||||
|
action: 'movement_link.delete_vote_withdraw',
|
||||||
|
resourceType: 'movement_lineage_link',
|
||||||
|
resourceId: result.linkId,
|
||||||
|
details: {},
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.status || 500;
|
||||||
|
if (status >= 500) console.error(err);
|
||||||
|
res.status(status).json({ error: err.message || 'Failed to withdraw delete vote' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Hard delete — admin only. Curators vote instead. */
|
||||||
|
router.delete('/:id', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const votes = await listDeleteVotes(req.params.id).catch(() => ({ votes: [] }));
|
||||||
|
const result = await deleteLink(req.params.id);
|
||||||
|
await logCuratorAction({
|
||||||
|
userId: req.session.userId,
|
||||||
|
action: 'movement_link.delete',
|
||||||
|
resourceType: 'movement_lineage_link',
|
||||||
|
resourceId: result.id,
|
||||||
|
details: {
|
||||||
|
prior_votes: (votes.votes || []).map((v) => ({
|
||||||
|
username: v.username,
|
||||||
|
reason: v.reason,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, id: result.id });
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.status || 500;
|
||||||
|
if (status >= 500) console.error(err);
|
||||||
|
res.status(status).json({ error: err.message || 'Failed to delete movement link' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||