Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72fe45fa52 | ||
|
|
9f96b3835d | ||
|
|
13f6f088b6 | ||
|
|
1b760faf0d | ||
|
|
2b4cdc5a9f | ||
|
|
2960af35bc | ||
|
|
7300091bb6 | ||
|
|
f2a256132c | ||
|
|
8e52415ea8 | ||
|
|
3fe88ffcfa | ||
|
|
1c9fa20191 | ||
|
|
44092d102b | ||
|
|
33b8ae5a5f | ||
|
|
971c1e8dd8 | ||
|
|
4088d7d57b | ||
|
|
f20f811f21 |
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` |
|
||||
| `/api/translations/*` | `translations` | `translation.*` |
|
||||
| `/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.*` |
|
||||
| `/api/users/*` | `users` | `user.*` |
|
||||
| `/api/audit/*` | admin role | — (read) |
|
||||
|
||||
**Public** (no login): all catalog `GET` routes, `POST /api/artists/:id/preload-images`, `POST /api/movements/:id/preload-images` (local file linking for 3D halls), `/images`, SPA static.
|
||||
**Public** (no login): all catalog `GET` routes, `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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
Base path: `/api/tours`. Full guide: [tours.md](tours.md).
|
||||
@@ -494,7 +514,7 @@ Each painting includes:
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `has_influence_links` | `true` when the work appears in any influence row — 3D gallery shows a golden lamp above the frame |
|
||||
| `has_influence_links` | `true` when the work appears in any influence row — 3D gallery shows a golden lamp on a shared rail (40 cm above the tallest frame in the hall) |
|
||||
| `checkup_checked` | Reviewed in checkup / debug workflow (gold frame in 3D when true) |
|
||||
| `checkup_fixed` | Image replaced via **Fix it** |
|
||||
|
||||
|
||||
@@ -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
|
||||
artists ||--o{ painting_influence_sources : artist_source
|
||||
art_movements ||--o{ painting_influence_sources : movement_source
|
||||
art_movements ||--o{ movement_lineage_links : source
|
||||
art_movements ||--o{ movement_lineage_links : target
|
||||
```
|
||||
|
||||
## 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`.
|
||||
|
||||
### `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`
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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**).
|
||||
|
||||
@@ -103,9 +103,9 @@ If login fails after changing `CURATOR_PASSWORD` in `.env`, run `npm run dev:res
|
||||
|
||||
| Role | Access |
|
||||
|------|--------|
|
||||
| Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios |
|
||||
| Curator | Public browse + assigned permission flags (`images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`) |
|
||||
| Admin | All curator tools + **Users** + **Activity** audit reports |
|
||||
| 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`). With `influences`: Influences + Movement links create/vote-to-delete |
|
||||
| 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
```powershell
|
||||
@@ -373,6 +395,9 @@ Remote: https://gitea.mysuperlab.netcraze.pro/Danilka/Art-gallery
|
||||
|
||||
| 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) |
|
||||
| [environments.md](environments.md) | Full dev/prod walkthrough |
|
||||
| [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`
|
||||
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)
|
||||
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**:
|
||||
|
||||
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries.
|
||||
2. **Movement flow** — art movements as curved SVG streams on the same year axis; documented predecessor→successor branches; portrait thumbnails placed along each stream.
|
||||
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries; classic left→right, vertical bottom→up, or **tree** bottom→up layout (header links).
|
||||
2. **Movement flow** — art movements as SVG streams on the same year axis; documented predecessor→successor branches (classic); portrait thumbnails along each stream. The tree layout redraws the same lineage as a growing tree — see [movement-tree.md](movement-tree.md).
|
||||
3. **3D gallery** — one personal hall per artist, a **movement gallery** (click a movement name → artist filter → hall), or a **guided tour** hall (timeline → **Tours**): period-themed or tour wings of up to ~55 works, U-shaped hang (left → end wall → right).
|
||||
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **curator notes** and **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography.
|
||||
5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**).
|
||||
@@ -37,32 +37,40 @@ Gallery/
|
||||
│ │ ├── components/GalleryLoadingMarker.tsx # Loading spinner overlay/banner (catalog, portraits, halls)
|
||||
│ │ ├── components/GalleryWindows.tsx # Side-wall daylight windows (movement)
|
||||
│ │ ├── components/HallPassage.tsx # Open archway between movement wings
|
||||
│ │ ├── components/MovementHallDetails.tsx # Period architectural details
|
||||
│ │ ├── components/MovementHallDetails.tsx # Period architecture dispatcher (Gothic + Byzantine reference)
|
||||
│ │ ├── components/hall-details/ # One period interior per movement + shared primitives
|
||||
│ │ ├── data/movement-interior-styles.ts # Per-movement interior themes
|
||||
│ │ ├── utils/movementHallLayout.ts # Wing split + window gap placement
|
||||
│ │ ├── utils/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
|
||||
│ │ ├── components/PaintingDetail.tsx # Detail view + debug panel
|
||||
│ │ ├── components/ArtistBio.tsx # Biography + portrait debug panel
|
||||
│ │ ├── components/DebugSearchResultsModal.tsx # “More” search picker (20 results)
|
||||
│ │ ├── components/Timeline.tsx # Era bar, year ticks, event markers
|
||||
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow
|
||||
│ │ ├── components/Timeline.tsx # Classic horizontal era bar
|
||||
│ │ ├── components/VerticalTimeline.tsx # Bottom-up vertical era rail
|
||||
│ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches)
|
||||
│ │ ├── components/VerticalMovementBands.tsx # Bottom-up movement streams
|
||||
│ │ ├── components/MovementTree.tsx # Bottom-up movement tree (alternative start page)
|
||||
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow
|
||||
│ │ ├── components/CatalogSearchBar.tsx # Timeline header catalog search
|
||||
│ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail
|
||||
│ │ ├── pages/CheckupPage.tsx # Image audit table
|
||||
│ │ ├── pages/TranslationsPage.tsx # Russian translation review
|
||||
│ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard
|
||||
│ │ ├── pages/MovementLinksPage.tsx # Movement lineage CRUD + delete votes
|
||||
│ │ ├── pages/ToursPage.tsx # Guided tour editor
|
||||
│ │ ├── pages/UsersPage.tsx # Staff accounts
|
||||
│ │ ├── pages/AuditPage.tsx # Admin curator activity reports
|
||||
│ │ ├── components/ToursPopup.tsx # Public published-tours modal
|
||||
│ │ ├── i18n/ # react-i18next bootstrap
|
||||
│ │ └── locales/{en,ru}/ # UI chrome strings
|
||||
│ │ ├── locales/{en,ru}/ # UI chrome strings (incl. timeline layout + captions)
|
||||
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
|
||||
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
|
||||
│ │ ├── data/movement-lineage.ts # Fallback movement predecessor links (UI)
|
||||
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor
|
||||
│ │ ├── utils/debugMode.ts # Debug mode + “Show more” localStorage prefs
|
||||
│ │ └── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
|
||||
│ │ ├── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
|
||||
│ │ ├── utils/movementColor.ts # Shared vivid/shade hex helpers for all movement charts
|
||||
│ │ └── utils/movementTree.ts # View-independent Tree of Art layout engine
|
||||
│ └── dist/ # Production build (served by API when present)
|
||||
├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools
|
||||
│ ├── seed-wikipedia.js
|
||||
@@ -170,15 +178,25 @@ Implementation: `goToTimelineHome()` in `HomePage.tsx` — do not use the browse
|
||||
|
||||
## Timeline and movement flow
|
||||
|
||||
The home page shows two linked views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`):
|
||||
The home page shows linked era + movement views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`). Header links switch among three layouts; the active layout is also deep-linked:
|
||||
|
||||
| Layout | Era rail | Movement flow | Time direction | URL |
|
||||
|--------|----------|---------------|----------------|-----|
|
||||
| **Classic** (default) | `Timeline.tsx` (top bar) | `MovementBands.tsx` | Left → right | omit or `?layout=classic` |
|
||||
| **Vertical** | `VerticalTimeline.tsx` (left rail) | `VerticalMovementBands.tsx` (streams + lineage; no portraits) | Bottom → top | `?layout=vertical` |
|
||||
| **Tree of art** | `VerticalTimeline.tsx` (left rail) | `MovementTree.tsx` (lineage as a growing tree; no portraits) | Bottom → top | `?layout=tree` |
|
||||
|
||||
`HomePage` reads `?layout=` once on load and calls `history.replaceState` when the user switches. Classic clears the param so the default URL stays clean. Alias `horizontal` maps to classic.
|
||||
|
||||
| View | Component | Purpose |
|
||||
|------|-----------|---------|
|
||||
| Era bar | `Timeline.tsx` | Historical eras, major event markers, click-to-zoom |
|
||||
| Movement flow | `MovementBands.tsx` | Curved streams per movement, lineage branches, artist portraits |
|
||||
| Era bar / rail | `Timeline.tsx` / `VerticalTimeline.tsx` | Historical eras, major event markers, click-to-zoom |
|
||||
| Movement flow | `MovementBands.tsx` / `VerticalMovementBands.tsx` / `MovementTree.tsx` | Streams or tree limbs per movement; classic also places artist portraits |
|
||||
| Stream colours | `utils/movementColor.ts` | Shared `vividMovementColor` / `shadeMovementColor` for all three charts |
|
||||
|
||||
Both views share zoom/pan behaviour via `client/src/utils/timelineView.ts` (`zoomTimelineView`, `panTimelineView`, `chooseTimelineTickInterval`, `createViewChangeScheduler`). The home page uses a **fixed viewport** (`100vh`): timeline + movement flow sit in a shared `home-timeline-stack` so event guide lines can extend from the era bar down through the movement canvas. The movement flow compresses vertically so all movements in the visible year range fit without page scrolling.
|
||||
Hint captions under each rail/chart (`captionClassicTimeline`, `captionClassicFlow`, `captionVerticalTimeline`, `captionVerticalFlow`, `captionTreeFlow`) live in `locales/{en,ru}/home.json` and follow the EN|RU toggle.
|
||||
|
||||
All three layouts share zoom/pan behaviour via `client/src/utils/timelineView.ts` (`zoomTimelineView`, `panTimelineView`, `chooseTimelineTickInterval`, `createViewChangeScheduler`). The home page uses a **fixed viewport** (`100vh`). Classic stacks timeline above movements; vertical and tree place the year rail beside the flow (`home-timeline-stack-vertical`). The tree layout keeps its horizontal geometry fixed across zoom — rules in [movement-tree.md](movement-tree.md).
|
||||
### Catalog search (timeline header)
|
||||
|
||||
`CatalogSearchBar.tsx` calls `GET /api/search?q=…` (public, no login). The dropdown is stacked above the timeline (`z-index` on `.site-header`) so results are not hidden by movement bands.
|
||||
@@ -247,7 +265,7 @@ Each visible movement is drawn as a **portrait-width curved stream** (~54 px str
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -280,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.
|
||||
|
||||
**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)
|
||||
|
||||
@@ -301,7 +319,7 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi
|
||||
| Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) |
|
||||
| Wall tint | Gallery walls blend the artist’s **movement colour** into cream plaster tones |
|
||||
| Frame finish | **Unchecked** works: black moulding; **Reviewed** (`checkup_checked`): bright gold moulding at **double width** |
|
||||
| Influence lamps | A golden picture light appears **above frames** whose work has any influence-graph edge (`has_influence_links` from the API); fixture is mounted **upside down** and is **emissive-only** (no per-frame lights — see light budget below) |
|
||||
| Influence lamps | A golden picture light marks works with any influence-graph edge (`has_influence_links` from the API). All lamps in a hall share one rail height: **40 cm above the tallest allocated frame** in that hall (so they line up regardless of canvas size). Fixture is mounted **upside down** and is **emissive-only** (no per-frame lights — see light budget below) |
|
||||
| Curator-note plates | A small brass plate hangs **beneath frames** that have non-empty `curator_notes` |
|
||||
| Eye-level viewing | Frame centres sit at **eye height (~1.65 m)**; the camera stays **level with the floor** (no pitch up/down) |
|
||||
| Open centre | Floor and ceiling only — no freestanding columns or pedestals in the walkway |
|
||||
@@ -336,18 +354,21 @@ 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 |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Architecture in texture | Period halls that need arcades, vaults, or panelling draw that **relief into the texture** rather than adding geometry: `gothic-ashlar` paints a full storey (plinth → blind arcade → string course → triforium → cornice) into one tile whose `metersPerRepeat` equals `WALL_HEIGHT`, so it maps **once vertically** onto the wall; `gothic-vault` paints a quadripartite bay with tiercerons and a boss. Light/dark banding plus a high `normalStrengthFor()` makes it read as carved stone, keeping the mesh and light budget flat |
|
||||
| Door-flanking panels | Wall segments beside exit doors and passages take the hall's own wall texture (`GalleryWall` accepts `kind`/`tint`) instead of rendering as flat single-colour blocks next to textured walls |
|
||||
| Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue |
|
||||
| Windows | **Side walls only** — placed in gaps between frames when possible; if a wall is packed, high **clerestory** windows are still added so the hall keeps daylight (`computeSideWallWindows`). Styles are drawn in `GalleryWindows.tsx`; `gothic-lancet` builds a two-centred arch head from chords with a glazed spandrel, mullions, and transoms |
|
||||
| Lighting | Shared hall lights only (ambient / hemisphere / directional + capped ceiling track spots + one fill per window). See **light budget** below |
|
||||
| Period details | `MovementHallDetails.tsx` — 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** |
|
||||
| Front / entrance wall | Single wing: **Exit double doors** (leave the way you entered). Multi-wing: **“Next wing →”** archway when a later wing exists |
|
||||
| Influence lamps | Same golden upside-down emissive fixtures as artist halls when `has_influence_links` is true |
|
||||
| Influence lamps | Same golden upside-down emissive fixtures as artist halls when `has_influence_links` is true (shared rail: 40 cm above the tallest frame in the wing) |
|
||||
| Missing images | Draped canvas cover in frame |
|
||||
| Detail return | **Back to Gallery** from painting detail returns to the same wing with camera preserved; **Back to Timeline** exits the hall entirely |
|
||||
|
||||
@@ -380,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).
|
||||
|
||||
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.
|
||||
|
||||
@@ -438,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()`.
|
||||
- **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 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.
|
||||
- **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).
|
||||
@@ -532,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 |
|
||||
| [i18n-russian.md](i18n-russian.md) | Russian UI + entity_translations |
|
||||
| [data-and-images.md](data-and-images.md) | Image pipeline and seeding |
|
||||
| [ui-interaction-and-component-standards.md](ui-interaction-and-component-standards.md) | UI interaction, navigation, and component standards |
|
||||
@@ -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.
|
||||
|
||||
## 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 |
|
||||
|--------|--------|
|
||||
| Storage | TypeScript module in the client — **not** a database table |
|
||||
| Format | `[parentMovementName, childMovementName]` tuples; names must match `art_movements.name` from the seed |
|
||||
| Multiple parents | Allowed (e.g. Post-Impressionism feeding several modern paths) |
|
||||
| Sources | Curator notes in the file reference Met essays, TheArtStory, and similar |
|
||||
| Storage | PostgreSQL `movement_lineage_links` (`db/migrate-movement-links.sql`) |
|
||||
| Required proof | `proof_url` must be a valid `http(s)` URL (museum essay, literature, web reference) |
|
||||
| Optional | `link_year_approx`, `proof_label`, `notes` |
|
||||
| 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)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -25,6 +25,17 @@ Timeline header: **EN | RU** toggle (`LocaleSwitcher`).
|
||||
- Passes `?locale=ru` on catalog API requests
|
||||
- Refetches bootstrap catalog when locale changes
|
||||
|
||||
Timeline **layout** chrome and chart hints are also localised in `home.json`:
|
||||
|
||||
| Key | Where |
|
||||
|-----|--------|
|
||||
| `layoutHorizontal` / `layoutVertical` / `layoutTree` | Header layout switch |
|
||||
| `captionClassicTimeline` / `captionClassicFlow` | Classic era bar + movement streams |
|
||||
| `captionVerticalTimeline` / `captionVerticalFlow` | Vertical rail + streams |
|
||||
| `captionTreeFlow` | Tree of Art chart |
|
||||
|
||||
Shareable layout URLs (`?layout=tree` etc.) are language-independent; captions follow the active locale.
|
||||
|
||||
---
|
||||
|
||||
## Setup (dev)
|
||||
|
||||
@@ -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
|
||||
|
||||
- `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
|
||||
|
||||
- **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
|
||||
- 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`.
|
||||
|
||||
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).
|
||||
|
||||
@@ -107,6 +107,7 @@ Image fetch can take hours if you run it for the entire catalog. The first line
|
||||
|
||||
| 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 prod:docker:publish` | Build + push prod image to Gitea |
|
||||
| `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: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:
|
||||
|
||||
@@ -239,6 +240,8 @@ After clone: copy `.env.example` → `.env`, install dependencies, run [one-time
|
||||
| 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) |
|
||||
| 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 |
|
||||
| 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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, **curator-gated** debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**; painting detail also **Remove entry**), optional **Show more** auto-opens the search picker, Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. Anonymous visitors browse freely; curators sign in via **Curator login** in the header.
|
||||
Interactive virtual art gallery: zoomable historical timeline (classic left→right, vertical, or **Tree of Art** lineage chart — shareable via `?layout=`) with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, **curator-gated** debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**; painting detail also **Remove entry**), optional **Show more** auto-opens the search picker, Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. Anonymous visitors browse freely; curators sign in via **Curator login** in the header.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| [Documentation/Bible.md](Documentation/Bible.md) | Agent instructions (primary source for AI assistants) |
|
||||
| [Documentation/basics.md](Documentation/basics.md) | Architecture, layout, user flow |
|
||||
| [Documentation/ui-interaction-and-component-standards.md](Documentation/ui-interaction-and-component-standards.md) | UI interaction, navigation, and component standards |
|
||||
| [Documentation/movement-tree.md](Documentation/movement-tree.md) | **Tree of Art** start page — tree layout rules |
|
||||
| [Documentation/FAC.md](Documentation/FAC.md) | **Command cheat sheet** — start/stop, import, deploy |
|
||||
| [Documentation/environments.md](Documentation/environments.md) | Dev/prod URLs, DB split, deploy, sync |
|
||||
| [Documentation/setup.md](Documentation/setup.md) | Install, env, npm scripts |
|
||||
@@ -83,6 +86,14 @@ Operator guide: [Documentation/environments.md](Documentation/environments.md).
|
||||
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:**
|
||||
|
||||
```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 }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.artistId) qs.set('artistId', String(params.artistId));
|
||||
@@ -1032,6 +1107,33 @@ export interface InfluenceEdgeItem {
|
||||
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 {
|
||||
focus: { artistId: number; paintingId: number | null; label: string };
|
||||
nodes: Array<{ id: string; type: string; label: string; focus?: boolean; artistId?: number; paintingId?: number; movementId?: number }>;
|
||||
|
||||
@@ -61,11 +61,13 @@ function WindowFrame({
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Outer frame */}
|
||||
{/* Outer frame — picture/ribbon draw their own thin reveals */}
|
||||
{style !== 'picture' && style !== 'ribbon' && (
|
||||
<mesh position={[0, 0, -depth / 2]}>
|
||||
<boxGeometry args={[width + frameW * 2, height + frameW * 2, depth]} />
|
||||
<meshStandardMaterial color={trimColor} roughness={0.35} metalness={0.45} />
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{arch && style === 'gothic-lancet' && (
|
||||
<>
|
||||
@@ -180,6 +182,33 @@ function WindowFrame({
|
||||
<meshStandardMaterial color={trimColor} roughness={0.3} metalness={0.55} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState, memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ArtMovement, Artist } from '../types';
|
||||
import { portraitThumbUrl } from '../api/client';
|
||||
import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc';
|
||||
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
||||
import { getMovementLineage } from '../data/movement-lineage';
|
||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||
import { parseHexColor, rgbToHsl, hslToHex, vividMovementColor } from '../utils/movementColor';
|
||||
import './MovementBands.css';
|
||||
|
||||
interface Props {
|
||||
@@ -244,74 +246,6 @@ function buildArtistPlacements(
|
||||
return placements;
|
||||
}
|
||||
|
||||
function parseHexColor(hex: string): [number, number, number] {
|
||||
const normalized = hex.replace('#', '');
|
||||
const value =
|
||||
normalized.length === 3
|
||||
? normalized
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('')
|
||||
: normalized.padStart(6, '0').slice(0, 6);
|
||||
return [
|
||||
parseInt(value.slice(0, 2), 16),
|
||||
parseInt(value.slice(2, 4), 16),
|
||||
parseInt(value.slice(4, 6), 16),
|
||||
];
|
||||
}
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||||
const rn = r / 255;
|
||||
const gn = g / 255;
|
||||
const bn = b / 255;
|
||||
const max = Math.max(rn, gn, bn);
|
||||
const min = Math.min(rn, gn, bn);
|
||||
const l = (max + min) / 2;
|
||||
if (max === min) return [0, 0, l];
|
||||
const d = max - min;
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
let h = 0;
|
||||
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
|
||||
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
|
||||
else h = ((rn - gn) / d + 4) / 6;
|
||||
return [h * 360, s, l];
|
||||
}
|
||||
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
if (h < 60) [r, g, b] = [c, x, 0];
|
||||
else if (h < 120) [r, g, b] = [x, c, 0];
|
||||
else if (h < 180) [r, g, b] = [0, c, x];
|
||||
else if (h < 240) [r, g, b] = [0, x, c];
|
||||
else if (h < 300) [r, g, b] = [x, 0, c];
|
||||
else [r, g, b] = [c, 0, x];
|
||||
const toByte = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
||||
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
|
||||
}
|
||||
|
||||
/** Boost saturation / mid lightness so streams read vividly on the dark flow canvas. */
|
||||
function vividMovementColor(hex: string): string {
|
||||
try {
|
||||
const [r, g, b] = parseHexColor(hex);
|
||||
const [h, s, l] = rgbToHsl(r, g, b);
|
||||
const s2 = s < 0.1 ? Math.min(0.55, s + 0.42) : Math.min(1, s * 1.65 + 0.08);
|
||||
const l2 =
|
||||
l < 0.22
|
||||
? 0.5
|
||||
: l > 0.78
|
||||
? 0.62
|
||||
: Math.min(0.68, Math.max(0.4, l * 0.75 + 0.28));
|
||||
return hslToHex(h, s2, l2);
|
||||
} catch {
|
||||
return hex;
|
||||
}
|
||||
}
|
||||
|
||||
function artistLifespanColor(baseColor: string, laneIndex: number, laneCount: number): string {
|
||||
if (laneCount <= 1) return baseColor;
|
||||
try {
|
||||
@@ -335,7 +269,7 @@ function buildLineageParentMap(
|
||||
const visibleIds = new Set(visible.map((m) => m.id));
|
||||
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 childId = nameToId.get(childName);
|
||||
if (!parentId || !childId) continue;
|
||||
@@ -1762,6 +1696,7 @@ export default function MovementBands({
|
||||
onArtistHover,
|
||||
onPortraitsLoadingChange,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('home');
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const pendingPortraitsRef = useRef(0);
|
||||
const [portraitsLoading, setPortraitsLoading] = useState(false);
|
||||
@@ -2260,7 +2195,7 @@ export default function MovementBands({
|
||||
return (
|
||||
<div className="movements-flow">
|
||||
<p className="movements-flow-caption">
|
||||
Scroll to zoom · drag to pan · each movement stream is a solid colour band through history
|
||||
{t('captionClassicFlow')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,39 @@
|
||||
import { useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import type { MovementInteriorStyle } from '../data/movement-interior-styles';
|
||||
import type { MovementInteriorStyle, GalleryWindowSpec } from '../data/movement-interior-styles';
|
||||
import type { RoomDims } from './hall-details/geometry';
|
||||
import { 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 {
|
||||
style: MovementInteriorStyle;
|
||||
@@ -8,108 +41,15 @@ interface Props {
|
||||
depth: number;
|
||||
halfW: number;
|
||||
halfD: number;
|
||||
/** Runtime window placement, so upper-wall relief can step around openings. */
|
||||
windows?: GalleryWindowSpec[];
|
||||
}
|
||||
|
||||
function PalazzoDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
|
||||
const pilasterPositions = useMemo(
|
||||
() =>
|
||||
[
|
||||
[-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 }) {
|
||||
function GothicDetails({ width, depth, halfW, halfD, trim }: Omit<Props, 'style' | 'windows'> & { trim: string }) {
|
||||
// Compound piers along the side walls, spaced by bay, each rising into the
|
||||
// vault springer. Shafts stay flush against the wall (0.16 m proud) so they
|
||||
// never intrude on the ~0.7 m frame hang margin.
|
||||
const bays = useMemo(() => {
|
||||
const spacing = 4.2;
|
||||
const count = Math.max(2, Math.min(9, Math.floor(depth / spacing)));
|
||||
const step = depth / (count + 1);
|
||||
return Array.from({ length: count }, (_, i) => -halfD + step * (i + 1));
|
||||
}, [depth, halfD]);
|
||||
const bays = useMemo(() => bayPositions(depth, halfD, 4.2, 9), [depth, halfD]);
|
||||
|
||||
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
|
||||
// room) overlap the outermost frames — hang margin is only ~0.7m from each end.
|
||||
const pilasters = 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>
|
||||
{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) {
|
||||
/**
|
||||
* Period architecture for a movement hall.
|
||||
*
|
||||
* Every movement has its own interior, built from the architecture of the same
|
||||
* period as the movement itself. The Gothic nave and Byzantine basilica below
|
||||
* are the reference implementations; everything else lives in
|
||||
* `hall-details/`, sharing the geometry budget documented in
|
||||
* `hall-details/primitives.tsx`.
|
||||
*/
|
||||
export default function MovementHallDetails({ style, width, depth, halfW, halfD, windows }: Props) {
|
||||
const trim = style.tints.trim;
|
||||
const room: RoomDims = useMemo(
|
||||
() => ({ width, depth, halfW, halfD }),
|
||||
[width, depth, halfW, halfD]
|
||||
);
|
||||
const period = { room, trim, windows };
|
||||
|
||||
switch (style.details) {
|
||||
case 'palazzo':
|
||||
return <PalazzoDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
case 'baroque':
|
||||
return <BaroqueDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
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':
|
||||
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':
|
||||
return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
case 'classical':
|
||||
return <ClassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
case 'salon':
|
||||
return <SalonDetails trim={trim} />;
|
||||
default:
|
||||
return null;
|
||||
return <NeoclassicalDetails {...period} />;
|
||||
case 'gothic-revival':
|
||||
return <GothicRevivalDetails {...period} />;
|
||||
case 'bourgeois':
|
||||
return <BourgeoisSalonDetails {...period} />;
|
||||
case 'north-light':
|
||||
return <NorthLightSalonDetails {...period} />;
|
||||
case 'paris-atelier':
|
||||
return <ParisAtelierDetails {...period} />;
|
||||
case 'symbolist':
|
||||
return <SymbolistDetails {...period} />;
|
||||
case 'art-nouveau':
|
||||
return <ArtNouveauDetails {...period} />;
|
||||
case 'fauvist':
|
||||
return <FauvistDetails {...period} />;
|
||||
case 'expressionist':
|
||||
return <ExpressionistDetails {...period} />;
|
||||
case 'cubist-atelier':
|
||||
return <CubistAtelierDetails {...period} />;
|
||||
case 'futurist':
|
||||
return <FuturistDetails {...period} />;
|
||||
case 'suprematist':
|
||||
return <SuprematistDetails {...period} />;
|
||||
case 'constructivist':
|
||||
return <ConstructivistDetails {...period} />;
|
||||
case 'dada':
|
||||
return <DadaDetails {...period} />;
|
||||
case 'surrealist':
|
||||
return <SurrealistDetails {...period} />;
|
||||
case 'loft':
|
||||
return <LoftDetails {...period} />;
|
||||
case 'white-cube':
|
||||
return <WhiteCubeDetails {...period} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
.mtree {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 12px 12px;
|
||||
}
|
||||
|
||||
.mtree-caption {
|
||||
margin: 0 0 8px;
|
||||
text-align: center;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
color: rgba(201, 169, 110, 0.65);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mtree-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: rgba(201, 169, 110, 0.6);
|
||||
font-family: 'Georgia', serif;
|
||||
}
|
||||
|
||||
.mtree-canvas {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
background:
|
||||
radial-gradient(ellipse 60% 45% at 50% 100%, rgba(201, 169, 110, 0.1), transparent 72%),
|
||||
radial-gradient(ellipse 80% 60% at 50% 0%, rgba(120, 150, 190, 0.08), transparent 70%),
|
||||
rgba(0, 0, 0, 0.28);
|
||||
border: 1px solid rgba(201, 169, 110, 0.12);
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.mtree-canvas.mtree-panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.mtree-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.mtree-limb {
|
||||
cursor: pointer;
|
||||
opacity: 0.9;
|
||||
transition: opacity 0.18s ease, filter 0.18s ease;
|
||||
}
|
||||
|
||||
.mtree-limb-lit {
|
||||
opacity: 1;
|
||||
filter: brightness(1.22) drop-shadow(0 0 6px rgba(255, 226, 170, 0.35));
|
||||
}
|
||||
|
||||
.mtree-limb-dim {
|
||||
opacity: 0.34;
|
||||
}
|
||||
|
||||
.mtree-root {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.mtree-graft {
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.mtree-graft-lit {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.mtree-label {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
transform: translate(-50%, -50%);
|
||||
margin: 0;
|
||||
padding: 2px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: rgba(12, 12, 22, 0.74);
|
||||
color: rgba(245, 230, 200, 0.95);
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.mtree-label:hover {
|
||||
background: rgba(30, 28, 40, 0.92);
|
||||
color: #fff6e0;
|
||||
}
|
||||
|
||||
.mtree-label-dim {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.mtree-out-of-range {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
color: rgba(201, 169, 110, 0.7);
|
||||
font-family: 'Georgia', serif;
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ArtMovement } from '../types';
|
||||
import {
|
||||
branchOriginYear,
|
||||
buildMovementTree,
|
||||
limbXAtYear,
|
||||
type MovementTreeNode,
|
||||
} from '../utils/movementTree';
|
||||
import { shadeMovementColor, vividMovementColor } from '../utils/movementColor';
|
||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||
import './MovementTree.css';
|
||||
|
||||
interface Props {
|
||||
movements: ArtMovement[];
|
||||
viewStart: number;
|
||||
viewEnd: number;
|
||||
absoluteMin: number;
|
||||
absoluteMax: number;
|
||||
onViewChange: (start: number, end: number) => void;
|
||||
onMovementClick?: (movementId: number) => void;
|
||||
}
|
||||
|
||||
interface Pt {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const SIDE_PAD = 56;
|
||||
/**
|
||||
* Horizontal spread is tied to the zoom, not to the canvas.
|
||||
*
|
||||
* 90 % of the catalogue lives in the last 15 % of the time axis, so a tree
|
||||
* stretched to full width with all of history in view is one long trunk under a
|
||||
* flat bar. Instead the whole-history view draws a narrow tree, and every zoom
|
||||
* step fans the crown out — the chart grows as you walk up it.
|
||||
*/
|
||||
const FULL_VIEW_WIDTH_SHARE = 0.52;
|
||||
const ZOOM_SPREAD_EXPONENT = 0.45;
|
||||
/** How far past "everything fits" the tree may be blown up when zoomed in. */
|
||||
const MAX_FIT_BOOST = 2.4;
|
||||
/** Exponential chase rate (1/s) for the horizontal fit, so zoom reads as growth. */
|
||||
const FIT_ANIM_RATE = 9;
|
||||
const RIBBON_SAMPLES = 18;
|
||||
/** Vertical room a label needs. */
|
||||
const MIN_LABEL_HEIGHT_PX = 20;
|
||||
/**
|
||||
* Readability floors. A 30-year movement is 8 px tall when the whole of
|
||||
* history is on screen; without these the modern crown fuses into one bar.
|
||||
*/
|
||||
const MIN_LIMB_RISE_PX = 30;
|
||||
const MIN_JUNCTION_RISE_PX = 38;
|
||||
/** A limb is never drawn thicker than this share of its own length. */
|
||||
const MAX_THICKNESS_OF_LENGTH = 0.55;
|
||||
|
||||
function cubicAt(p0: Pt, c1: Pt, c2: Pt, p3: Pt, t: number): Pt {
|
||||
const u = 1 - t;
|
||||
const a = u * u * u;
|
||||
const b = 3 * u * u * t;
|
||||
const c = 3 * u * t * t;
|
||||
const d = t * t * t;
|
||||
return {
|
||||
x: a * p0.x + b * c1.x + c * c2.x + d * p3.x,
|
||||
y: a * p0.y + b * c1.y + c * c2.y + d * p3.y,
|
||||
};
|
||||
}
|
||||
|
||||
function cubicTangent(p0: Pt, c1: Pt, c2: Pt, p3: Pt, t: number): Pt {
|
||||
const u = 1 - t;
|
||||
const a = 3 * u * u;
|
||||
const b = 6 * u * t;
|
||||
const c = 3 * t * t;
|
||||
return {
|
||||
x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p3.x - c2.x),
|
||||
y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filled ribbon of varying width along a cubic. Offsetting along the curve
|
||||
* normal (rather than horizontally) keeps branch junctions solid even when a
|
||||
* zoomed-out view squeezes them almost flat.
|
||||
*/
|
||||
function ribbonPath(p0: Pt, c1: Pt, c2: Pt, p3: Pt, w0: number, w1: number): string {
|
||||
const left: Pt[] = [];
|
||||
const right: Pt[] = [];
|
||||
for (let i = 0; i <= RIBBON_SAMPLES; i++) {
|
||||
const t = i / RIBBON_SAMPLES;
|
||||
const p = cubicAt(p0, c1, c2, p3, t);
|
||||
const d = cubicTangent(p0, c1, c2, p3, t);
|
||||
const len = Math.hypot(d.x, d.y) || 1;
|
||||
const nx = -d.y / len;
|
||||
const ny = d.x / len;
|
||||
const half = (w0 + (w1 - w0) * t) / 2;
|
||||
left.push({ x: p.x + nx * half, y: p.y + ny * half });
|
||||
right.push({ x: p.x - nx * half, y: p.y - ny * half });
|
||||
}
|
||||
const fmt = (pt: Pt) => `${pt.x.toFixed(2)} ${pt.y.toFixed(2)}`;
|
||||
const forward = left.map((pt, i) => `${i === 0 ? 'M' : 'L'} ${fmt(pt)}`).join(' ');
|
||||
const back = right
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((pt) => `L ${fmt(pt)}`)
|
||||
.join(' ');
|
||||
return `${forward} ${back} Z`;
|
||||
}
|
||||
|
||||
interface LimbShape {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
shade: string;
|
||||
/** Trunk / limb body. */
|
||||
d: string;
|
||||
/** Junction ribbon growing out of the structural parent (may be empty). */
|
||||
junction: string;
|
||||
/** Rounded tip cap. */
|
||||
tip: Pt & { r: number };
|
||||
/** Root flare under a tree root, drawn only when the base is on screen. */
|
||||
roots: string[];
|
||||
labelX: number;
|
||||
labelY: number;
|
||||
labelVisible: boolean;
|
||||
yearRange: string;
|
||||
depth: number;
|
||||
inView: boolean;
|
||||
}
|
||||
|
||||
interface GraftShape {
|
||||
key: string;
|
||||
d: string;
|
||||
color: string;
|
||||
fromId: number;
|
||||
toId: number;
|
||||
}
|
||||
|
||||
function formatYear(year: number): string {
|
||||
return year < 0 ? `${Math.abs(year)} BCE` : `${year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedy declutter: closer to the trunk wins. Every visible movement asks for a
|
||||
* name, and the ones that would collide with an already-placed name — or fall
|
||||
* off the canvas — stay anonymous until you zoom in on them.
|
||||
*/
|
||||
function hideOverlappingLabels(limbs: LimbShape[], width: number, height: number): void {
|
||||
const placed: { x0: number; y0: number; x1: number; y1: number }[] = [];
|
||||
const candidates = limbs
|
||||
.map((limb, index) => ({ limb, index }))
|
||||
.filter(({ limb }) => limb.labelVisible)
|
||||
.sort((a, b) => a.limb.depth - b.limb.depth || b.limb.labelY - a.limb.labelY);
|
||||
|
||||
for (const { limb } of candidates) {
|
||||
const halfW = (limb.name.length * 6.6 + 16) / 2;
|
||||
const halfH = MIN_LABEL_HEIGHT_PX / 2;
|
||||
const box = {
|
||||
x0: limb.labelX - halfW,
|
||||
y0: limb.labelY - halfH,
|
||||
x1: limb.labelX + halfW,
|
||||
y1: limb.labelY + halfH,
|
||||
};
|
||||
const offCanvas = box.x0 < 2 || box.x1 > width - 2 || box.y0 < 2 || box.y1 > height - 2;
|
||||
const collides = placed.some(
|
||||
(p) => box.x0 < p.x1 && box.x1 > p.x0 && box.y0 < p.y1 && box.y1 > p.y0
|
||||
);
|
||||
if (offCanvas || collides) {
|
||||
limb.labelVisible = false;
|
||||
continue;
|
||||
}
|
||||
placed.push(box);
|
||||
}
|
||||
}
|
||||
|
||||
export default function MovementTree({
|
||||
movements,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
absoluteMin,
|
||||
absoluteMax,
|
||||
onViewChange,
|
||||
onMovementClick,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('home');
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const [canvas, setCanvas] = useState({ w: 900, h: 600 });
|
||||
const [panning, setPanning] = useState(false);
|
||||
const [hoveredId, setHoveredId] = useState<number | null>(null);
|
||||
const [fitScale, setFitScale] = useState(1);
|
||||
const panStart = useRef({ y: 0, viewStart: 0, viewEnd: 0 });
|
||||
const viewRef = useRef({ viewStart, viewEnd });
|
||||
const onViewChangeRef = useRef(onViewChange);
|
||||
const fitRef = useRef(1);
|
||||
const fitReadyRef = useRef(false);
|
||||
const fitTargetRef = useRef(1);
|
||||
const fitRafRef = useRef<number | null>(null);
|
||||
const fitLastTsRef = useRef(0);
|
||||
viewRef.current = { viewStart, viewEnd };
|
||||
onViewChangeRef.current = onViewChange;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = canvasRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
setCanvas({ w: Math.round(rect.width), h: Math.round(rect.height) });
|
||||
}
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Structure is catalogue-wide and view-independent: zooming must not reshape
|
||||
// the tree, only travel along it.
|
||||
const tree = useMemo(() => buildMovementTree(movements), [movements]);
|
||||
|
||||
const visibleIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
for (const node of tree.nodes.values()) {
|
||||
const { start_year: s, end_year: e } = node.movement;
|
||||
if (e > viewStart && s < viewEnd) ids.add(node.movement.id);
|
||||
}
|
||||
return ids;
|
||||
}, [tree, viewStart, viewEnd]);
|
||||
|
||||
const targetScale = useMemo(() => {
|
||||
const usable = Math.max(240, canvas.w - SIDE_PAD * 2);
|
||||
const fitAll = usable / (2 * Math.max(1, tree.halfSpan));
|
||||
const visibleSpan = Math.max(1, viewEnd - viewStart);
|
||||
const totalSpan = Math.max(visibleSpan, absoluteMax - absoluteMin);
|
||||
const zoomSpread = Math.pow(totalSpan / visibleSpan, ZOOM_SPREAD_EXPONENT);
|
||||
const spread = Math.min(
|
||||
MAX_FIT_BOOST,
|
||||
Math.max(FULL_VIEW_WIDTH_SHARE, FULL_VIEW_WIDTH_SHARE * zoomSpread)
|
||||
);
|
||||
|
||||
let visibleHalfSpan = 0;
|
||||
for (const id of visibleIds) {
|
||||
const node = tree.nodes.get(id);
|
||||
if (!node) continue;
|
||||
visibleHalfSpan = Math.max(
|
||||
visibleHalfSpan,
|
||||
Math.abs(node.x) + Math.abs(node.lean) + node.baseWidth / 2
|
||||
);
|
||||
}
|
||||
// Never let what is on screen spill off the canvas.
|
||||
const overflowCap = visibleHalfSpan > 0 ? usable / (2 * visibleHalfSpan) : Infinity;
|
||||
return Math.min(fitAll * spread, overflowCap);
|
||||
}, [canvas.w, tree, visibleIds, viewStart, viewEnd, absoluteMin, absoluteMax]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
fitTargetRef.current = targetScale;
|
||||
if (!fitReadyRef.current) {
|
||||
// First measured layout — adopt it instead of animating in from nothing.
|
||||
fitReadyRef.current = true;
|
||||
fitRef.current = targetScale;
|
||||
setFitScale(targetScale);
|
||||
return;
|
||||
}
|
||||
if (fitRafRef.current != null) return;
|
||||
|
||||
fitLastTsRef.current = performance.now();
|
||||
const step = (now: number) => {
|
||||
const dt = Math.min(0.05, Math.max(0, (now - fitLastTsRef.current) / 1000));
|
||||
fitLastTsRef.current = now;
|
||||
const t = 1 - Math.exp(-FIT_ANIM_RATE * dt);
|
||||
const next = fitRef.current + (fitTargetRef.current - fitRef.current) * t;
|
||||
if (Math.abs(fitTargetRef.current - next) < 0.002) {
|
||||
fitRef.current = fitTargetRef.current;
|
||||
setFitScale(fitTargetRef.current);
|
||||
fitRafRef.current = null;
|
||||
return;
|
||||
}
|
||||
fitRef.current = next;
|
||||
setFitScale(next);
|
||||
fitRafRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
fitRafRef.current = requestAnimationFrame(step);
|
||||
}, [targetScale]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (fitRafRef.current != null) cancelAnimationFrame(fitRafRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const { limbs, grafts } = useMemo(() => {
|
||||
const span = viewEnd - viewStart || 1;
|
||||
const centerX = canvas.w / 2;
|
||||
const widthScale = Math.min(1.7, Math.max(0.55, fitScale));
|
||||
const sx = (treeX: number) => centerX + treeX * fitScale;
|
||||
const sy = (year: number) => canvas.h - ((year - viewStart) / span) * canvas.h;
|
||||
|
||||
const limbList: LimbShape[] = [];
|
||||
const graftList: GraftShape[] = [];
|
||||
|
||||
/**
|
||||
* Pass 1 — screen geometry per movement.
|
||||
*
|
||||
* Two readability floors apply here, and only here: the structure and the
|
||||
* dates stay untouched. A limb is drawn at least `MIN_LIMB_RISE_PX` long,
|
||||
* and never thicker than it is long, so 2 500 years of trunk and 30 years
|
||||
* of Fauvism can share one linear axis without the modern crown fusing
|
||||
* into a solid bar.
|
||||
*/
|
||||
const drawn = new Map<
|
||||
number,
|
||||
{ base: Pt; c1: Pt; c2: Pt; tip: Pt; wBase: number; wTip: number }
|
||||
>();
|
||||
|
||||
for (const id of tree.drawOrder) {
|
||||
const node = tree.nodes.get(id)!;
|
||||
const { start_year: start, end_year: end } = node.movement;
|
||||
if (end <= start) continue;
|
||||
|
||||
const base: Pt = { x: sx(limbXAtYear(node, start)), y: sy(start) };
|
||||
const trueTipY = sy(end);
|
||||
const tip: Pt = {
|
||||
x: sx(limbXAtYear(node, end)),
|
||||
y: Math.min(trueTipY, base.y - MIN_LIMB_RISE_PX),
|
||||
};
|
||||
const lengthPx = Math.hypot(tip.x - base.x, tip.y - base.y);
|
||||
const cap = Math.max(3, lengthPx * MAX_THICKNESS_OF_LENGTH);
|
||||
const wBase = Math.min(node.baseWidth * widthScale, cap);
|
||||
const wTip = Math.min(node.tipWidth * widthScale, cap * 0.82);
|
||||
const dy = tip.y - base.y;
|
||||
drawn.set(id, {
|
||||
base,
|
||||
c1: { x: base.x, y: base.y + dy * 0.42 },
|
||||
c2: { x: tip.x, y: tip.y - dy * 0.34 },
|
||||
tip,
|
||||
wBase,
|
||||
wTip,
|
||||
});
|
||||
}
|
||||
|
||||
/** Point on a drawn limb at the screen height closest to `targetY`. */
|
||||
const pointOnLimb = (parentId: number, targetY: number) => {
|
||||
const p = drawn.get(parentId)!;
|
||||
const total = p.base.y - p.tip.y || 1;
|
||||
const t = Math.min(1, Math.max(0, (p.base.y - targetY) / total));
|
||||
return {
|
||||
pt: cubicAt(p.base, p.c1, p.c2, p.tip, t),
|
||||
width: p.wBase + (p.wTip - p.wBase) * t,
|
||||
};
|
||||
};
|
||||
|
||||
// Pass 2 — ribbons.
|
||||
for (const id of tree.drawOrder) {
|
||||
const node = tree.nodes.get(id)!;
|
||||
const shape = drawn.get(id);
|
||||
if (!shape) continue;
|
||||
const { base, c1, c2, tip, wBase, wTip } = shape;
|
||||
const { start_year: start, end_year: end } = node.movement;
|
||||
|
||||
const color = vividMovementColor(node.movement.color);
|
||||
const d = ribbonPath(base, c1, c2, tip, wBase, wTip);
|
||||
|
||||
// Junction: the limb grows out of its parent a little before its own
|
||||
// date, and always climbs far enough to read as a fork.
|
||||
let junction = '';
|
||||
const parent = node.parentId != null ? tree.nodes.get(node.parentId) : null;
|
||||
if (parent && drawn.has(parent.movement.id)) {
|
||||
const byDate = sy(branchOriginYear(parent, node));
|
||||
const origin = pointOnLimb(
|
||||
parent.movement.id,
|
||||
Math.max(byDate, base.y + MIN_JUNCTION_RISE_PX)
|
||||
);
|
||||
const from = origin.pt;
|
||||
const jdy = base.y - from.y;
|
||||
const jLength = Math.hypot(base.x - from.x, jdy);
|
||||
const jCap = Math.max(3, jLength * MAX_THICKNESS_OF_LENGTH);
|
||||
const wFrom = Math.min(origin.width * 0.92, wBase * 1.25, jCap);
|
||||
const jc1: Pt = { x: from.x, y: from.y + jdy * 0.45 };
|
||||
const jc2: Pt = { x: base.x, y: base.y - jdy * 0.45 };
|
||||
junction = ribbonPath(from, jc1, jc2, base, wFrom, Math.min(wBase, jCap));
|
||||
}
|
||||
|
||||
// Roots: only the bottom of a tree, and only when that bottom is in frame.
|
||||
const roots: string[] = [];
|
||||
if (!parent && base.y > -canvas.h && base.y < canvas.h * 2) {
|
||||
const flare = Math.max(22, wBase * 1.4);
|
||||
for (const dir of [-1, -0.35, 0.35, 1]) {
|
||||
const endPt: Pt = { x: base.x + dir * flare, y: base.y + flare * 0.72 };
|
||||
const rc1: Pt = { x: base.x + dir * flare * 0.2, y: base.y + flare * 0.34 };
|
||||
const rc2: Pt = { x: base.x + dir * flare * 0.8, y: base.y + flare * 0.5 };
|
||||
roots.push(ribbonPath(base, rc1, rc2, endPt, wBase * 0.42, 1.5));
|
||||
}
|
||||
}
|
||||
|
||||
const inView = visibleIds.has(id);
|
||||
const clampedStart = Math.max(start, viewStart);
|
||||
const clampedEnd = Math.min(end, viewEnd);
|
||||
const midYear = (clampedStart + clampedEnd) / 2;
|
||||
const labelY = Math.min(
|
||||
Math.max(sy(midYear), tip.y + MIN_LABEL_HEIGHT_PX / 2),
|
||||
base.y
|
||||
);
|
||||
|
||||
limbList.push({
|
||||
id,
|
||||
name: node.movement.name,
|
||||
color,
|
||||
shade: shadeMovementColor(color),
|
||||
d,
|
||||
junction,
|
||||
tip: { x: tip.x, y: tip.y, r: Math.max(1.5, wTip / 2) },
|
||||
roots,
|
||||
labelX: sx(limbXAtYear(node, midYear)),
|
||||
labelY,
|
||||
labelVisible: inView,
|
||||
yearRange: `${formatYear(start)} – ${formatYear(end)}`,
|
||||
depth: node.depth,
|
||||
inView,
|
||||
});
|
||||
|
||||
for (const graftId of node.graftParentIds) {
|
||||
const graftParent = tree.nodes.get(graftId);
|
||||
if (!graftParent || !drawn.has(graftId)) continue;
|
||||
const byDate = sy(branchOriginYear(graftParent, node));
|
||||
const origin = pointOnLimb(graftId, Math.max(byDate, base.y + MIN_JUNCTION_RISE_PX));
|
||||
const from = origin.pt;
|
||||
const gdy = base.y - from.y;
|
||||
const gWidth = Math.max(2.5, Math.min(wBase * 0.3, 9));
|
||||
graftList.push({
|
||||
key: `${graftId}-${id}`,
|
||||
d: ribbonPath(
|
||||
from,
|
||||
{ x: from.x, y: from.y + gdy * 0.55 },
|
||||
{ x: base.x, y: base.y - gdy * 0.3 },
|
||||
base,
|
||||
gWidth * 0.7,
|
||||
gWidth
|
||||
),
|
||||
color: vividMovementColor(graftParent.movement.color),
|
||||
fromId: graftId,
|
||||
toId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
hideOverlappingLabels(limbList, canvas.w, canvas.h);
|
||||
return { limbs: limbList, grafts: graftList };
|
||||
}, [tree, viewStart, viewEnd, canvas.w, canvas.h, fitScale, visibleIds]);
|
||||
|
||||
/** A hovered movement lights up its whole descent line back to the root. */
|
||||
const lineageIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
if (hoveredId == null) return ids;
|
||||
let cursor: number | null = hoveredId;
|
||||
let guard = 0;
|
||||
while (cursor != null && guard++ < 64) {
|
||||
ids.add(cursor);
|
||||
const node: MovementTreeNode | undefined = tree.nodes.get(cursor);
|
||||
if (!node) break;
|
||||
for (const graftId of node.graftParentIds) ids.add(graftId);
|
||||
cursor = node.parentId;
|
||||
}
|
||||
return ids;
|
||||
}, [hoveredId, tree]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = canvasRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = el.getBoundingClientRect();
|
||||
const { viewStart: vs, viewEnd: ve } = viewRef.current;
|
||||
// Bottom = oldest, so invert the pointer offset before reusing the shared
|
||||
// left-to-right zoom math.
|
||||
const invertedY = rect.height - (e.clientY - rect.top);
|
||||
const next = zoomTimelineView(
|
||||
invertedY,
|
||||
0,
|
||||
rect.height,
|
||||
e.deltaY,
|
||||
vs,
|
||||
ve,
|
||||
absoluteMin,
|
||||
absoluteMax
|
||||
);
|
||||
onViewChangeRef.current(next.start, next.end);
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false, capture: true });
|
||||
return () => el.removeEventListener('wheel', onWheel, { capture: true });
|
||||
}, [absoluteMin, absoluteMax]);
|
||||
|
||||
const handlePanStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
setPanning(true);
|
||||
panStart.current = { y: e.clientY, viewStart, viewEnd };
|
||||
},
|
||||
[viewStart, viewEnd]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!panning) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const dy = e.clientY - panStart.current.y;
|
||||
const next = panTimelineView(
|
||||
-dy,
|
||||
rect.height,
|
||||
panStart.current.viewStart,
|
||||
panStart.current.viewEnd,
|
||||
absoluteMin,
|
||||
absoluteMax
|
||||
);
|
||||
onViewChangeRef.current(next.start, next.end);
|
||||
};
|
||||
const onUp = () => setPanning(false);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [panning, absoluteMin, absoluteMax]);
|
||||
|
||||
if (movements.length === 0) {
|
||||
return (
|
||||
<div className="mtree-empty">
|
||||
<p>No art movements to grow a tree from yet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const anyInView = limbs.some((limb) => limb.inView);
|
||||
|
||||
return (
|
||||
<div className="mtree">
|
||||
<p className="mtree-caption">
|
||||
{t('captionTreeFlow')}
|
||||
</p>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className={`mtree-canvas${panning ? ' mtree-panning' : ''}`}
|
||||
onMouseDown={handlePanStart}
|
||||
>
|
||||
<svg
|
||||
className="mtree-svg"
|
||||
viewBox={`0 0 ${canvas.w} ${canvas.h}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
{limbs.map((limb) => (
|
||||
<linearGradient
|
||||
key={`grad-${limb.id}`}
|
||||
id={`mtree-limb-${limb.id}`}
|
||||
gradientUnits="objectBoundingBox"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="0"
|
||||
>
|
||||
<stop offset="0%" stopColor={limb.shade} />
|
||||
<stop offset="45%" stopColor={limb.color} />
|
||||
<stop offset="100%" stopColor={limb.shade} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
|
||||
<g className="mtree-grafts">
|
||||
{grafts.map((graft) => (
|
||||
<path
|
||||
key={graft.key}
|
||||
d={graft.d}
|
||||
fill={graft.color}
|
||||
className={`mtree-graft${
|
||||
lineageIds.has(graft.toId) && lineageIds.has(graft.fromId)
|
||||
? ' mtree-graft-lit'
|
||||
: ''
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
|
||||
{limbs.map((limb) => {
|
||||
const lit = lineageIds.has(limb.id);
|
||||
const dim = hoveredId != null && !lit;
|
||||
return (
|
||||
<g
|
||||
key={limb.id}
|
||||
className={`mtree-limb${lit ? ' mtree-limb-lit' : ''}${
|
||||
dim ? ' mtree-limb-dim' : ''
|
||||
}`}
|
||||
onMouseEnter={() => setHoveredId(limb.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMovementClick?.(limb.id);
|
||||
}}
|
||||
>
|
||||
<title>{`${limb.name} · ${limb.yearRange}`}</title>
|
||||
{limb.roots.map((d, i) => (
|
||||
<path key={`root-${i}`} d={d} fill={limb.shade} className="mtree-root" />
|
||||
))}
|
||||
{limb.junction && (
|
||||
<path d={limb.junction} fill={`url(#mtree-limb-${limb.id})`} />
|
||||
)}
|
||||
<path d={limb.d} fill={`url(#mtree-limb-${limb.id})`} />
|
||||
<circle cx={limb.tip.x} cy={limb.tip.y} r={limb.tip.r} fill={limb.color} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{limbs
|
||||
.filter((limb) => limb.labelVisible)
|
||||
.map((limb) => (
|
||||
<button
|
||||
key={`label-${limb.id}`}
|
||||
type="button"
|
||||
className={`mtree-label${
|
||||
hoveredId != null && !lineageIds.has(limb.id) ? ' mtree-label-dim' : ''
|
||||
}`}
|
||||
style={{ left: `${limb.labelX}px`, top: `${limb.labelY}px` }}
|
||||
title={`${limb.name} · ${limb.yearRange}`}
|
||||
onMouseEnter={() => setHoveredId(limb.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMovementClick?.(limb.id);
|
||||
}}
|
||||
>
|
||||
{limb.name}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{!anyInView && (
|
||||
<div className="mtree-out-of-range">
|
||||
<p>No movements in this time range — zoom out to see the whole tree.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { HistoricalEra } from '../types';
|
||||
import {
|
||||
HISTORICAL_EVENTS,
|
||||
@@ -38,6 +39,7 @@ function formatYear(year: number): string {
|
||||
}
|
||||
|
||||
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight }: Props) {
|
||||
const { t } = useTranslation('home');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState<'left' | 'right' | 'pan' | null>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(800);
|
||||
@@ -365,7 +367,7 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
|
||||
</div>
|
||||
|
||||
<p className="timeline-hint">
|
||||
Click an era or event to zoom · Scroll to zoom · Drag to pan
|
||||
{t('captionClassicTimeline')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
.vflow {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 12px 12px;
|
||||
}
|
||||
|
||||
.vflow-caption {
|
||||
margin: 0 0 8px;
|
||||
text-align: center;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
color: rgba(201, 169, 110, 0.65);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vflow-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: rgba(201, 169, 110, 0.6);
|
||||
font-family: 'Georgia', serif;
|
||||
}
|
||||
|
||||
.vflow-canvas {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 80% at 50% 0%, rgba(201, 169, 110, 0.07), transparent 70%),
|
||||
rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(201, 169, 110, 0.12);
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.vflow-canvas.vflow-panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.vflow-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.vflow-stream {
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 0.72;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease, filter 0.15s ease;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.vflow-stream-highlighted {
|
||||
opacity: 1;
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.vflow-branch {
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 0.95;
|
||||
pointer-events: none;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.vflow-branch-highlighted {
|
||||
opacity: 1;
|
||||
filter: brightness(1.25) drop-shadow(0 0 4px rgba(255, 230, 180, 0.45));
|
||||
}
|
||||
|
||||
.vflow-label {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
transform: translate(-50%, 50%);
|
||||
margin: 0;
|
||||
padding: 2px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: rgba(12, 12, 22, 0.72);
|
||||
color: rgba(245, 230, 200, 0.95);
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.vflow-label:hover {
|
||||
background: rgba(30, 28, 40, 0.9);
|
||||
color: #fff6e0;
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ArtMovement } from '../types';
|
||||
import { getMovementLineage } from '../data/movement-lineage';
|
||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||
import { vividMovementColor } from '../utils/movementColor';
|
||||
import './VerticalMovementBands.css';
|
||||
|
||||
interface Props {
|
||||
movements: ArtMovement[];
|
||||
viewStart: number;
|
||||
viewEnd: number;
|
||||
absoluteMin: number;
|
||||
absoluteMax: number;
|
||||
onViewChange: (start: number, end: number) => void;
|
||||
onMovementClick?: (movementId: number) => void;
|
||||
}
|
||||
|
||||
interface VLayout {
|
||||
movement: ArtMovement;
|
||||
/** Year span as % from bottom (earlier = lower). */
|
||||
yStart: number;
|
||||
yEnd: number;
|
||||
/** Lane center as % from left. */
|
||||
x: number;
|
||||
strokePx: number;
|
||||
displayColor: string;
|
||||
parentIds: number[];
|
||||
}
|
||||
|
||||
interface VBranch {
|
||||
key: string;
|
||||
d: string;
|
||||
colorFrom: string;
|
||||
colorTo: string;
|
||||
strokePx: number;
|
||||
fromId: number;
|
||||
toId: number;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
const MAX_STROKE = 108; // ~300% of prior max (36)
|
||||
const MIN_STROKE = 54; // ~300% of prior min (18)
|
||||
const SIDE_PAD = 24;
|
||||
/** Preferred centre-to-centre spacing: stroke + small gap (keeps columns tight). */
|
||||
const PREFERRED_LANE_PITCH = MAX_STROKE + 16;
|
||||
const LANE_MIN_GAP_YEARS = 2;
|
||||
|
||||
function yearToBottomPercent(year: number, start: number, end: number): number {
|
||||
return ((year - start) / (end - start)) * 100;
|
||||
}
|
||||
|
||||
function buildLineageParentMap(
|
||||
visible: ArtMovement[],
|
||||
nameToId: Map<string, number>
|
||||
): Map<number, number[]> {
|
||||
const parents = new Map<number, number[]>();
|
||||
const visibleIds = new Set(visible.map((m) => m.id));
|
||||
for (const [parentName, childName] of getMovementLineage()) {
|
||||
const parentId = nameToId.get(parentName);
|
||||
const childId = nameToId.get(childName);
|
||||
if (parentId == null || childId == null) continue;
|
||||
if (!visibleIds.has(parentId) || !visibleIds.has(childId)) continue;
|
||||
const list = parents.get(childId) || [];
|
||||
if (!list.includes(parentId)) list.push(parentId);
|
||||
parents.set(childId, list);
|
||||
}
|
||||
return parents;
|
||||
}
|
||||
|
||||
function assignDepths(
|
||||
group: ArtMovement[],
|
||||
lineageParents: Map<number, number[]>
|
||||
): Map<number, number> {
|
||||
const depths = new Map<number, number>();
|
||||
const visiting = new Set<number>();
|
||||
const visit = (id: number): number => {
|
||||
if (depths.has(id)) return depths.get(id)!;
|
||||
if (visiting.has(id)) return 0;
|
||||
visiting.add(id);
|
||||
const parents = lineageParents.get(id) || [];
|
||||
const d = parents.length ? 1 + Math.max(...parents.map(visit)) : 0;
|
||||
visiting.delete(id);
|
||||
depths.set(id, d);
|
||||
return d;
|
||||
};
|
||||
for (const m of group) visit(m.id);
|
||||
return depths;
|
||||
}
|
||||
|
||||
/** Prefer center, then alternate right / left: 0, +1, -1, +2, -2, … */
|
||||
function centerOutOffsets(max = 64): number[] {
|
||||
const out = [0];
|
||||
for (let d = 1; d <= max; d++) out.push(d, -d);
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickNearestFreeLane(preferred: number, isFree: (lane: number) => boolean): number {
|
||||
for (const delta of centerOutOffsets()) {
|
||||
const lane = preferred + delta;
|
||||
if (isFree(lane)) return lane;
|
||||
}
|
||||
return preferred;
|
||||
}
|
||||
|
||||
/** Collapse signed lane indices to contiguous 0..n-1 (left → right). */
|
||||
function compactSignedLanes(laneById: Map<number, number>): void {
|
||||
const usedSorted = [...new Set(laneById.values())].sort((a, b) => a - b);
|
||||
const remap = new Map(usedSorted.map((lane, index) => [lane, index]));
|
||||
for (const [id, lane] of laneById) {
|
||||
laneById.set(id, remap.get(lane) ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
function assignTemporalLanes(
|
||||
group: ArtMovement[],
|
||||
viewStart: number,
|
||||
viewEnd: number,
|
||||
lineageParents: Map<number, number[]>
|
||||
): Map<number, number> {
|
||||
const depths = assignDepths(group, lineageParents);
|
||||
const spans = group
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
start: Math.max(m.start_year, viewStart),
|
||||
end: Math.min(m.end_year, viewEnd),
|
||||
depth: depths.get(m.id) ?? 0,
|
||||
}))
|
||||
.filter((s) => s.end > s.start)
|
||||
.sort((a, b) => a.depth - b.depth || a.start - b.start || a.end - b.end);
|
||||
|
||||
/** Signed lane → year when that lane frees up. */
|
||||
const laneEnds = new Map<number, number>();
|
||||
const laneById = new Map<number, number>();
|
||||
|
||||
for (const span of spans) {
|
||||
const parentLanes = (lineageParents.get(span.id) || [])
|
||||
.map((pid) => laneById.get(pid))
|
||||
.filter((lane): lane is number => lane != null);
|
||||
const preferred =
|
||||
parentLanes.length > 0
|
||||
? Math.round(parentLanes.reduce((s, l) => s + l, 0) / parentLanes.length)
|
||||
: 0;
|
||||
|
||||
const lane = pickNearestFreeLane(preferred, (candidate) => {
|
||||
const end = laneEnds.get(candidate);
|
||||
return end == null || end + LANE_MIN_GAP_YEARS <= span.start;
|
||||
});
|
||||
laneEnds.set(lane, span.end);
|
||||
laneById.set(span.id, lane);
|
||||
}
|
||||
compactSignedLanes(laneById);
|
||||
return laneById;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer one column per movement when the canvas is wide enough, so streams
|
||||
* do not stack in the same vertical lane. Fall back to temporal packing only
|
||||
* when there is not enough horizontal room.
|
||||
*
|
||||
* Lanes grow from the center outward (0, +1, −1, …) so the layout reads as a tree.
|
||||
*/
|
||||
function assignVerticalLanes(
|
||||
group: ArtMovement[],
|
||||
viewStart: number,
|
||||
viewEnd: number,
|
||||
lineageParents: Map<number, number[]>,
|
||||
canvasWidth: number
|
||||
): Map<number, number> {
|
||||
const usable = Math.max(200, canvasWidth - SIDE_PAD * 2);
|
||||
const minLanePx = PREFERRED_LANE_PITCH;
|
||||
const maxExclusive = Math.max(1, Math.floor(usable / minLanePx));
|
||||
|
||||
if (group.length <= maxExclusive) {
|
||||
const depths = assignDepths(group, lineageParents);
|
||||
// Roots first so the trunk claims center; children then fan around parents.
|
||||
const sorted = [...group].sort(
|
||||
(a, b) =>
|
||||
(depths.get(a.id) ?? 0) - (depths.get(b.id) ?? 0) ||
|
||||
a.start_year - b.start_year ||
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
const laneById = new Map<number, number>();
|
||||
const used = new Set<number>();
|
||||
for (const m of sorted) {
|
||||
const parentLanes = (lineageParents.get(m.id) || [])
|
||||
.map((pid) => laneById.get(pid))
|
||||
.filter((lane): lane is number => lane != null);
|
||||
const preferred =
|
||||
parentLanes.length > 0
|
||||
? Math.round(parentLanes.reduce((s, l) => s + l, 0) / parentLanes.length)
|
||||
: 0;
|
||||
const lane = pickNearestFreeLane(preferred, (candidate) => !used.has(candidate));
|
||||
used.add(lane);
|
||||
laneById.set(m.id, lane);
|
||||
}
|
||||
compactSignedLanes(laneById);
|
||||
return laneById;
|
||||
}
|
||||
|
||||
return assignTemporalLanes(group, viewStart, viewEnd, lineageParents);
|
||||
}
|
||||
|
||||
function influenceCount(m: ArtMovement): number {
|
||||
const n = m.influence_link_count;
|
||||
return typeof n === 'number' && Number.isFinite(n) ? Math.max(0, n) : 0;
|
||||
}
|
||||
|
||||
function pctToSvg(xPct: number, yBottomPct: number, widthPx: number, heightPx: number) {
|
||||
return {
|
||||
x: (xPct / 100) * widthPx,
|
||||
y: ((100 - yBottomPct) / 100) * heightPx,
|
||||
};
|
||||
}
|
||||
|
||||
/** Vertical stream path: time along Y (SVG y grows down → invert bottom%). */
|
||||
function verticalStreamPath(
|
||||
xPct: number,
|
||||
yStartPct: number,
|
||||
yEndPct: number,
|
||||
heightPx: number,
|
||||
widthPx: number
|
||||
): string {
|
||||
const start = pctToSvg(xPct, yEndPct, widthPx, heightPx);
|
||||
const end = pctToSvg(xPct, yStartPct, widthPx, heightPx);
|
||||
const midY = (start.y + end.y) / 2;
|
||||
const bulge = Math.min(18, Math.abs(end.y - start.y) * 0.06);
|
||||
return `M ${start.x} ${start.y} C ${start.x + bulge} ${midY}, ${end.x - bulge} ${midY}, ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
/** Absolute-year anchors so pan/scroll keeps a constant connection angle. */
|
||||
function branchAnchorYears(
|
||||
parent: ArtMovement,
|
||||
child: ArtMovement,
|
||||
childIndex: number,
|
||||
childCount: number
|
||||
): { originYear: number; targetYear: number } | null {
|
||||
const parentSpan = parent.end_year - parent.start_year;
|
||||
if (parentSpan <= 0) return null;
|
||||
|
||||
const tBase = childCount === 1 ? 0.38 : 0.28 + (childIndex / Math.max(1, childCount - 1)) * 0.22;
|
||||
let originYear = parent.start_year + parentSpan * tBase;
|
||||
const targetYear = child.start_year;
|
||||
|
||||
if (originYear >= targetYear) {
|
||||
originYear = Math.min(parent.start_year + parentSpan * 0.2, targetYear - 1);
|
||||
}
|
||||
originYear = Math.max(parent.start_year, Math.min(parent.end_year, originYear));
|
||||
if (originYear >= targetYear) return null;
|
||||
|
||||
return { originYear, targetYear };
|
||||
}
|
||||
|
||||
function branchPath(x1: number, y1: number, x2: number, y2: number): string {
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
// Pull control points along the diagonal so the curve reads as a waterfall, not an L-stair.
|
||||
const c1x = x1 + dx * 0.35;
|
||||
const c1y = y1 + dy * 0.55;
|
||||
const c2x = x2 - dx * 0.25;
|
||||
const c2y = y2 - dy * 0.2;
|
||||
return `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
/** Exponential chase rate (1/s) so lane / view shifts read as motion, not snaps. */
|
||||
const LAYOUT_ANIM_RATE = 14;
|
||||
const LAYOUT_ANIM_EPS = 0.06;
|
||||
|
||||
interface AnimatedVFlow {
|
||||
layouts: VLayout[];
|
||||
branches: VBranch[];
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function geomSettled(a: number, b: number, eps = LAYOUT_ANIM_EPS): boolean {
|
||||
return Math.abs(a - b) <= eps;
|
||||
}
|
||||
|
||||
function lerpVLayout(from: VLayout, to: VLayout, t: number): VLayout {
|
||||
return {
|
||||
...to,
|
||||
yStart: lerp(from.yStart, to.yStart, t),
|
||||
yEnd: lerp(from.yEnd, to.yEnd, t),
|
||||
x: lerp(from.x, to.x, t),
|
||||
strokePx: lerp(from.strokePx, to.strokePx, t),
|
||||
};
|
||||
}
|
||||
|
||||
function vLayoutSettled(a: VLayout, b: VLayout): boolean {
|
||||
return (
|
||||
geomSettled(a.yStart, b.yStart) &&
|
||||
geomSettled(a.yEnd, b.yEnd) &&
|
||||
geomSettled(a.x, b.x) &&
|
||||
geomSettled(a.strokePx, b.strokePx, 0.35)
|
||||
);
|
||||
}
|
||||
|
||||
function lerpVBranch(from: VBranch, to: VBranch, t: number): VBranch {
|
||||
const x1 = lerp(from.x1, to.x1, t);
|
||||
const y1 = lerp(from.y1, to.y1, t);
|
||||
const x2 = lerp(from.x2, to.x2, t);
|
||||
const y2 = lerp(from.y2, to.y2, t);
|
||||
const strokePx = lerp(from.strokePx, to.strokePx, t);
|
||||
return {
|
||||
...to,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
strokePx,
|
||||
d: branchPath(x1, y1, x2, y2),
|
||||
};
|
||||
}
|
||||
|
||||
function vBranchSettled(a: VBranch, b: VBranch): boolean {
|
||||
return (
|
||||
geomSettled(a.x1, b.x1) &&
|
||||
geomSettled(a.y1, b.y1) &&
|
||||
geomSettled(a.x2, b.x2) &&
|
||||
geomSettled(a.y2, b.y2) &&
|
||||
geomSettled(a.strokePx, b.strokePx, 0.35)
|
||||
);
|
||||
}
|
||||
|
||||
function blendVFlow(
|
||||
from: AnimatedVFlow,
|
||||
to: AnimatedVFlow,
|
||||
t: number
|
||||
): { next: AnimatedVFlow; settled: boolean } {
|
||||
const fromLayouts = new Map(from.layouts.map((l) => [l.movement.id, l]));
|
||||
const fromBranches = new Map(from.branches.map((b) => [b.key, b]));
|
||||
let settled = true;
|
||||
|
||||
const layouts = to.layouts.map((target) => {
|
||||
const prev = fromLayouts.get(target.movement.id);
|
||||
if (!prev) return target;
|
||||
if (vLayoutSettled(prev, target)) return target;
|
||||
settled = false;
|
||||
return lerpVLayout(prev, target, t);
|
||||
});
|
||||
|
||||
const branches = to.branches.map((target) => {
|
||||
const prev = fromBranches.get(target.key);
|
||||
if (!prev) return target;
|
||||
if (vBranchSettled(prev, target)) return target;
|
||||
settled = false;
|
||||
return lerpVBranch(prev, target, t);
|
||||
});
|
||||
|
||||
return { next: { layouts, branches }, settled };
|
||||
}
|
||||
|
||||
export default function VerticalMovementBands({
|
||||
movements,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
absoluteMin,
|
||||
absoluteMax,
|
||||
onViewChange,
|
||||
onMovementClick,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('home');
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const [canvasSize, setCanvasSize] = useState({ w: 800, h: 600 });
|
||||
const [panning, setPanning] = useState(false);
|
||||
const [hoveredMovementId, setHoveredMovementId] = useState<number | null>(null);
|
||||
const [flowVisual, setFlowVisual] = useState<AnimatedVFlow | null>(null);
|
||||
const panStart = useRef({ y: 0, viewStart: 0, viewEnd: 0 });
|
||||
const viewRef = useRef({ viewStart, viewEnd });
|
||||
const onViewChangeRef = useRef(onViewChange);
|
||||
const flowVisualRef = useRef<AnimatedVFlow | null>(null);
|
||||
const flowTargetRef = useRef<AnimatedVFlow | null>(null);
|
||||
const flowRafRef = useRef<number | null>(null);
|
||||
const flowLastTsRef = useRef(0);
|
||||
viewRef.current = { viewStart, viewEnd };
|
||||
onViewChangeRef.current = onViewChange;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = canvasRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
setCanvasSize({ w: Math.round(rect.width), h: Math.round(rect.height) });
|
||||
}
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const visibleMovements = useMemo(
|
||||
() => movements.filter((m) => m.end_year > viewStart && m.start_year < viewEnd),
|
||||
[movements, viewStart, viewEnd]
|
||||
);
|
||||
|
||||
const { layouts, branches } = useMemo(() => {
|
||||
if (visibleMovements.length === 0) {
|
||||
return { layouts: [] as VLayout[], branches: [] as VBranch[] };
|
||||
}
|
||||
const nameToId = new Map(movements.map((m) => [m.name, m.id]));
|
||||
const lineageParents = buildLineageParentMap(visibleMovements, nameToId);
|
||||
const laneIndex = assignVerticalLanes(
|
||||
visibleMovements,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
lineageParents,
|
||||
canvasSize.w
|
||||
);
|
||||
|
||||
let maxLanes = 0;
|
||||
for (const m of visibleMovements) {
|
||||
const lane = laneIndex.get(m.id) ?? 0;
|
||||
maxLanes = Math.max(maxLanes, lane + 1);
|
||||
}
|
||||
maxLanes = Math.max(1, maxLanes);
|
||||
|
||||
const usable = Math.max(200, canvasSize.w - SIDE_PAD * 2);
|
||||
// Pack columns tightly; only stretch if the canvas is narrower than the preferred cluster.
|
||||
const lanePitch = Math.min(usable / maxLanes, PREFERRED_LANE_PITCH);
|
||||
const clusterWidth = lanePitch * maxLanes;
|
||||
const startX = SIDE_PAD + Math.max(0, (usable - clusterWidth) / 2);
|
||||
const laneCentersPct: number[] = [];
|
||||
for (let lane = 0; lane < maxLanes; lane++) {
|
||||
const centerPx = startX + lanePitch * lane + lanePitch / 2;
|
||||
laneCentersPct[lane] = (centerPx / Math.max(1, canvasSize.w)) * 100;
|
||||
}
|
||||
|
||||
const maxInf = Math.max(1, ...visibleMovements.map(influenceCount));
|
||||
const layoutById = new Map<number, VLayout>();
|
||||
for (const m of visibleMovements) {
|
||||
const yStart = yearToBottomPercent(Math.max(m.start_year, viewStart), viewStart, viewEnd);
|
||||
const yEnd = yearToBottomPercent(Math.min(m.end_year, viewEnd), viewStart, viewEnd);
|
||||
if (yEnd <= yStart) continue;
|
||||
const lane = laneIndex.get(m.id) ?? 0;
|
||||
const baseStroke =
|
||||
MIN_STROKE + (influenceCount(m) / maxInf) * (MAX_STROKE - MIN_STROKE);
|
||||
// Allow nearly full preferred stroke; only shrink if the pitch is forced smaller.
|
||||
const strokePx = Math.min(MAX_STROKE, Math.max(MIN_STROKE, baseStroke), lanePitch * 0.88);
|
||||
layoutById.set(m.id, {
|
||||
movement: m,
|
||||
yStart,
|
||||
yEnd,
|
||||
x: laneCentersPct[lane] ?? 50,
|
||||
strokePx,
|
||||
displayColor: vividMovementColor(m.color),
|
||||
parentIds: lineageParents.get(m.id) || [],
|
||||
});
|
||||
}
|
||||
|
||||
const childIdsByParent = new Map<number, number[]>();
|
||||
for (const layout of layoutById.values()) {
|
||||
for (const parentId of layout.parentIds) {
|
||||
if (!layoutById.has(parentId)) continue;
|
||||
const children = childIdsByParent.get(parentId) || [];
|
||||
children.push(layout.movement.id);
|
||||
childIdsByParent.set(parentId, children);
|
||||
}
|
||||
}
|
||||
for (const children of childIdsByParent.values()) {
|
||||
children.sort((a, b) => {
|
||||
const la = layoutById.get(a)!;
|
||||
const lb = layoutById.get(b)!;
|
||||
return la.x - lb.x || la.movement.start_year - lb.movement.start_year;
|
||||
});
|
||||
}
|
||||
|
||||
const branchList: VBranch[] = [];
|
||||
for (const layout of layoutById.values()) {
|
||||
for (const parentId of layout.parentIds) {
|
||||
const parent = layoutById.get(parentId);
|
||||
if (!parent) continue;
|
||||
const children = childIdsByParent.get(parentId) || [layout.movement.id];
|
||||
const childIndex = children.indexOf(layout.movement.id);
|
||||
const anchors = branchAnchorYears(
|
||||
parent.movement,
|
||||
layout.movement,
|
||||
childIndex,
|
||||
children.length
|
||||
);
|
||||
if (!anchors) continue;
|
||||
|
||||
// Map fixed calendar years → current view % so pan keeps dx/dy (and angle) stable.
|
||||
const originY = yearToBottomPercent(anchors.originYear, viewStart, viewEnd);
|
||||
const targetY = yearToBottomPercent(anchors.targetYear, viewStart, viewEnd);
|
||||
const from = pctToSvg(parent.x, originY, canvasSize.w, canvasSize.h);
|
||||
const to = pctToSvg(layout.x, targetY, canvasSize.w, canvasSize.h);
|
||||
branchList.push({
|
||||
key: `${parentId}-${layout.movement.id}`,
|
||||
d: branchPath(from.x, from.y, to.x, to.y),
|
||||
colorFrom: parent.displayColor,
|
||||
colorTo: layout.displayColor,
|
||||
strokePx: Math.max(14, Math.min(parent.strokePx, layout.strokePx) * 0.55),
|
||||
fromId: parentId,
|
||||
toId: layout.movement.id,
|
||||
x1: from.x,
|
||||
y1: from.y,
|
||||
x2: to.x,
|
||||
y2: to.y,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
layouts: [...layoutById.values()].sort((a, b) => a.movement.start_year - b.movement.start_year),
|
||||
branches: branchList,
|
||||
};
|
||||
}, [visibleMovements, movements, viewStart, viewEnd, canvasSize.w, canvasSize.h]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const target: AnimatedVFlow = { layouts, branches };
|
||||
flowTargetRef.current = target;
|
||||
|
||||
if (!flowVisualRef.current) {
|
||||
flowVisualRef.current = target;
|
||||
setFlowVisual(target);
|
||||
return;
|
||||
}
|
||||
|
||||
if (flowRafRef.current != null) return;
|
||||
|
||||
flowLastTsRef.current = performance.now();
|
||||
const step = (now: number) => {
|
||||
const prev = flowVisualRef.current;
|
||||
const goal = flowTargetRef.current;
|
||||
if (!prev || !goal) {
|
||||
flowRafRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const dt = Math.min(0.048, Math.max(0, (now - flowLastTsRef.current) / 1000));
|
||||
flowLastTsRef.current = now;
|
||||
const t = 1 - Math.exp(-LAYOUT_ANIM_RATE * dt);
|
||||
const { next, settled } = blendVFlow(prev, goal, t);
|
||||
flowVisualRef.current = settled ? goal : next;
|
||||
setFlowVisual(flowVisualRef.current);
|
||||
|
||||
if (settled) {
|
||||
flowRafRef.current = null;
|
||||
return;
|
||||
}
|
||||
flowRafRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
flowRafRef.current = requestAnimationFrame(step);
|
||||
}, [layouts, branches]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (flowRafRef.current != null) {
|
||||
cancelAnimationFrame(flowRafRef.current);
|
||||
flowRafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = canvasRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = el.getBoundingClientRect();
|
||||
const { viewStart: vs, viewEnd: ve } = viewRef.current;
|
||||
const invertedClientY = rect.bottom - (e.clientY - rect.top);
|
||||
const next = zoomTimelineView(
|
||||
invertedClientY,
|
||||
0,
|
||||
rect.height,
|
||||
e.deltaY,
|
||||
vs,
|
||||
ve,
|
||||
absoluteMin,
|
||||
absoluteMax
|
||||
);
|
||||
onViewChangeRef.current(next.start, next.end);
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false, capture: true });
|
||||
return () => el.removeEventListener('wheel', onWheel, { capture: true });
|
||||
}, [absoluteMin, absoluteMax]);
|
||||
|
||||
const handlePanStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
setPanning(true);
|
||||
panStart.current = { y: e.clientY, viewStart, viewEnd };
|
||||
},
|
||||
[viewStart, viewEnd]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!panning) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const dy = e.clientY - panStart.current.y;
|
||||
const next = panTimelineView(
|
||||
-dy,
|
||||
rect.height,
|
||||
panStart.current.viewStart,
|
||||
panStart.current.viewEnd,
|
||||
absoluteMin,
|
||||
absoluteMax
|
||||
);
|
||||
onViewChangeRef.current(next.start, next.end);
|
||||
};
|
||||
const onUp = () => setPanning(false);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [panning, absoluteMin, absoluteMax]);
|
||||
|
||||
if (visibleMovements.length === 0) {
|
||||
return (
|
||||
<div className="vflow-empty">
|
||||
<p>No art movements in this time range. Zoom out to explore more periods.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayLayouts = flowVisual?.layouts ?? layouts;
|
||||
const displayBranches = flowVisual?.branches ?? branches;
|
||||
|
||||
return (
|
||||
<div className="vflow">
|
||||
<p className="vflow-caption">
|
||||
{t('captionVerticalFlow')}
|
||||
</p>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className={`vflow-canvas${panning ? ' vflow-panning' : ''}`}
|
||||
onMouseDown={handlePanStart}
|
||||
>
|
||||
<svg
|
||||
className="vflow-svg"
|
||||
viewBox={`0 0 ${canvasSize.w} ${canvasSize.h}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
{displayBranches.map((branch) => (
|
||||
<linearGradient
|
||||
key={`grad-${branch.key}`}
|
||||
id={`vflow-branch-grad-${branch.key}`}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1={branch.x1}
|
||||
y1={branch.y1}
|
||||
x2={branch.x2}
|
||||
y2={branch.y2}
|
||||
>
|
||||
<stop offset="0%" stopColor={branch.colorFrom} stopOpacity={0.9} />
|
||||
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0.9} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
|
||||
{displayLayouts.map((layout) => {
|
||||
const d = verticalStreamPath(
|
||||
layout.x,
|
||||
layout.yStart,
|
||||
layout.yEnd,
|
||||
canvasSize.h,
|
||||
canvasSize.w
|
||||
);
|
||||
const highlighted = hoveredMovementId === layout.movement.id;
|
||||
return (
|
||||
<path
|
||||
key={layout.movement.id}
|
||||
d={d}
|
||||
className={`vflow-stream${highlighted ? ' vflow-stream-highlighted' : ''}`}
|
||||
stroke={layout.displayColor}
|
||||
fill="none"
|
||||
style={{ strokeWidth: layout.strokePx }}
|
||||
onMouseEnter={() => setHoveredMovementId(layout.movement.id)}
|
||||
onMouseLeave={() => setHoveredMovementId(null)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMovementClick?.(layout.movement.id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{displayBranches.map((branch) => {
|
||||
const highlighted =
|
||||
hoveredMovementId != null &&
|
||||
(branch.fromId === hoveredMovementId || branch.toId === hoveredMovementId);
|
||||
return (
|
||||
<path
|
||||
key={branch.key}
|
||||
d={branch.d}
|
||||
className={`vflow-branch${highlighted ? ' vflow-branch-highlighted' : ''}`}
|
||||
stroke={`url(#vflow-branch-grad-${branch.key})`}
|
||||
fill="none"
|
||||
style={{ strokeWidth: branch.strokePx }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{displayLayouts.map((layout) => {
|
||||
const midY = (layout.yStart + layout.yEnd) / 2;
|
||||
return (
|
||||
<button
|
||||
key={`label-${layout.movement.id}`}
|
||||
type="button"
|
||||
className="vflow-label"
|
||||
style={{
|
||||
left: `${layout.x}%`,
|
||||
bottom: `${midY}%`,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMovementClick?.(layout.movement.id);
|
||||
}}
|
||||
onMouseEnter={() => setHoveredMovementId(layout.movement.id)}
|
||||
onMouseLeave={() => setHoveredMovementId(null)}
|
||||
>
|
||||
{layout.movement.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
.vtimeline-wrapper {
|
||||
flex-shrink: 0;
|
||||
width: 148px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(90deg, #1a1a2e 0%, #16213e 100%);
|
||||
border-right: 2px solid #c9a96e;
|
||||
padding: 8px 8px 12px;
|
||||
z-index: 100;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.vtimeline-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vtimeline-controls button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid #c9a96e;
|
||||
background: rgba(201, 169, 110, 0.15);
|
||||
color: #e8d5b5;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.vtimeline-controls button:hover {
|
||||
background: rgba(201, 169, 110, 0.35);
|
||||
}
|
||||
|
||||
.vtimeline-range {
|
||||
width: 100%;
|
||||
color: #f5e6c8;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.vtimeline-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 120px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
border: 1px solid rgba(201, 169, 110, 0.3);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vtimeline-container:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.vtimeline-track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.vtimeline-era-block {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 28px;
|
||||
margin: 0;
|
||||
padding: 4px 2px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vtimeline-era-label {
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 245, 220, 0.92);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vtimeline-lifespan-overlays {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vtimeline-lifespan-dim {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.vtimeline-lifespan-highlight {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.05) 0%,
|
||||
rgba(255, 255, 255, 0.28) 50%,
|
||||
rgba(255, 255, 255, 0.05) 100%
|
||||
);
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 240, 200, 0.5);
|
||||
border-top: 2px solid rgba(255, 230, 180, 0.75);
|
||||
border-bottom: 2px solid rgba(255, 230, 180, 0.75);
|
||||
}
|
||||
|
||||
.vtimeline-events {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vtimeline-event-mark,
|
||||
.vtimeline-event-span {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
right: 30px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: rgba(232, 196, 120, 0.55);
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vtimeline-event-mark {
|
||||
height: 3px;
|
||||
transform: translateY(50%);
|
||||
}
|
||||
|
||||
.vtimeline-event-span {
|
||||
min-height: 4px;
|
||||
background: rgba(232, 196, 120, 0.28);
|
||||
border-left: 2px solid rgba(232, 196, 120, 0.7);
|
||||
}
|
||||
|
||||
.vtimeline-event-label {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
margin-left: 4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
writing-mode: horizontal-tb;
|
||||
font-size: 9px;
|
||||
color: rgba(245, 230, 200, 0.85);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vtimeline-ticks {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vtimeline-tick {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: auto;
|
||||
width: 26px;
|
||||
transform: translateY(50%);
|
||||
border-bottom: 1px solid rgba(201, 169, 110, 0.35);
|
||||
text-align: right;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.vtimeline-tick span {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: rgba(232, 213, 181, 0.85);
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
line-height: 1;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
.vtimeline-brush {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 10px;
|
||||
z-index: 8;
|
||||
cursor: ns-resize;
|
||||
background: rgba(201, 169, 110, 0.25);
|
||||
}
|
||||
|
||||
.vtimeline-brush-start {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.vtimeline-brush-end {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.vtimeline-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
color: rgba(201, 169, 110, 0.55);
|
||||
font-family: 'Georgia', serif;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.vtimeline-wrapper {
|
||||
width: 112px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { HistoricalEra } from '../types';
|
||||
import {
|
||||
HISTORICAL_EVENTS,
|
||||
eventEndYear,
|
||||
eventInView,
|
||||
type HistoricalEvent,
|
||||
} from '../data/historical-events';
|
||||
import {
|
||||
buildTimelineTickYears,
|
||||
chooseTimelineTickInterval,
|
||||
} from '../utils/timelineView';
|
||||
import './VerticalTimeline.css';
|
||||
|
||||
interface LifespanHighlight {
|
||||
birthYear: number;
|
||||
deathYear: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
eras: HistoricalEra[];
|
||||
viewStart: number;
|
||||
viewEnd: number;
|
||||
onViewChange: (start: number, end: number) => void;
|
||||
absoluteMin: number;
|
||||
absoluteMax: number;
|
||||
lifespanHighlight?: LifespanHighlight | null;
|
||||
}
|
||||
|
||||
/** Earlier years at the bottom (0%), later at the top (100%). */
|
||||
function yearToBottomPercent(year: number, start: number, end: number): number {
|
||||
return ((year - start) / (end - start)) * 100;
|
||||
}
|
||||
|
||||
function formatYear(year: number): string {
|
||||
if (year < 0) return `${Math.abs(year)} BCE`;
|
||||
return `${year} CE`;
|
||||
}
|
||||
|
||||
function getEraColor(name: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
Ancient: 'rgba(139,115,85,0.7)',
|
||||
Medieval: 'rgba(74,85,104,0.7)',
|
||||
Renaissance: 'rgba(184,134,11,0.7)',
|
||||
Baroque: 'rgba(139,0,0,0.6)',
|
||||
'Neoclassicism & Romanticism': 'rgba(70,130,180,0.6)',
|
||||
Modern: 'rgba(100,100,120,0.6)',
|
||||
Contemporary: 'rgba(60,60,80,0.7)',
|
||||
};
|
||||
return colors[name] || 'rgba(100,100,100,0.5)';
|
||||
}
|
||||
|
||||
export default function VerticalTimeline({
|
||||
eras,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
onViewChange,
|
||||
absoluteMin,
|
||||
absoluteMax,
|
||||
lifespanHighlight,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('home');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState<'start' | 'end' | 'pan' | null>(null);
|
||||
const [containerHeight, setContainerHeight] = useState(600);
|
||||
const dragStart = useRef({ y: 0, viewStart: 0, viewEnd: 0 });
|
||||
|
||||
const span = viewEnd - viewStart;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const h = el.getBoundingClientRect().height;
|
||||
if (h > 0) setContainerHeight(Math.round(h));
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(() => measure());
|
||||
ro.observe(el);
|
||||
window.addEventListener('resize', measure);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener('resize', measure);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const tickInterval = useMemo(
|
||||
() => chooseTimelineTickInterval(span, containerHeight, 56),
|
||||
[span, containerHeight]
|
||||
);
|
||||
|
||||
const ticks = useMemo(
|
||||
() => buildTimelineTickYears(viewStart, viewEnd, tickInterval),
|
||||
[viewStart, viewEnd, tickInterval]
|
||||
);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
// Bottom = early: invert Y ratio so scroll-at-bottom zooms around early years.
|
||||
const ratioFromTop = (e.clientY - rect.top) / rect.height;
|
||||
const ratio = 1 - ratioFromTop;
|
||||
const centerYear = viewStart + ratio * span;
|
||||
const factor = e.deltaY > 0 ? 1.15 : 0.85;
|
||||
const newSpan = Math.max(10, Math.min(absoluteMax - absoluteMin, span * factor));
|
||||
let newStart = centerYear - ratio * newSpan;
|
||||
let newEnd = centerYear + (1 - ratio) * newSpan;
|
||||
if (newStart < absoluteMin) {
|
||||
newEnd += absoluteMin - newStart;
|
||||
newStart = absoluteMin;
|
||||
}
|
||||
if (newEnd > absoluteMax) {
|
||||
newStart -= newEnd - absoluteMax;
|
||||
newEnd = absoluteMax;
|
||||
}
|
||||
onViewChange(Math.round(newStart), Math.round(newEnd));
|
||||
},
|
||||
[viewStart, span, absoluteMin, absoluteMax, onViewChange]
|
||||
);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent, mode: 'start' | 'end' | 'pan') => {
|
||||
e.preventDefault();
|
||||
setDragging(mode);
|
||||
dragStart.current = { y: e.clientY, viewStart, viewEnd };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
// Drag up (negative clientY delta) → toward later years at top → increase view.
|
||||
const dy = e.clientY - dragStart.current.y;
|
||||
const yearDelta = -(dy / rect.height) * span;
|
||||
|
||||
if (dragging === 'pan') {
|
||||
let ns = dragStart.current.viewStart - yearDelta;
|
||||
let ne = dragStart.current.viewEnd - yearDelta;
|
||||
if (ns < absoluteMin) {
|
||||
ne += absoluteMin - ns;
|
||||
ns = absoluteMin;
|
||||
}
|
||||
if (ne > absoluteMax) {
|
||||
ns -= ne - absoluteMax;
|
||||
ne = absoluteMax;
|
||||
}
|
||||
onViewChange(Math.round(ns), Math.round(ne));
|
||||
} else if (dragging === 'start') {
|
||||
const ns = Math.min(dragStart.current.viewEnd - 10, dragStart.current.viewStart + yearDelta);
|
||||
onViewChange(Math.round(ns), viewEnd);
|
||||
} else {
|
||||
const ne = Math.max(dragStart.current.viewStart + 10, dragStart.current.viewEnd + yearDelta);
|
||||
onViewChange(viewStart, Math.round(ne));
|
||||
}
|
||||
};
|
||||
const onUp = () => setDragging(null);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [dragging, span, viewStart, viewEnd, absoluteMin, absoluteMax, onViewChange]);
|
||||
|
||||
const zoomIn = () => {
|
||||
const center = (viewStart + viewEnd) / 2;
|
||||
const newSpan = Math.max(10, span * 0.5);
|
||||
onViewChange(Math.round(center - newSpan / 2), Math.round(center + newSpan / 2));
|
||||
};
|
||||
|
||||
const zoomOut = () => {
|
||||
const center = (viewStart + viewEnd) / 2;
|
||||
const newSpan = Math.min(absoluteMax - absoluteMin, span * 2);
|
||||
let ns = center - newSpan / 2;
|
||||
let ne = center + newSpan / 2;
|
||||
if (ns < absoluteMin) {
|
||||
ne += absoluteMin - ns;
|
||||
ns = absoluteMin;
|
||||
}
|
||||
if (ne > absoluteMax) {
|
||||
ns -= ne - absoluteMax;
|
||||
ne = absoluteMax;
|
||||
}
|
||||
onViewChange(Math.round(ns), Math.round(ne));
|
||||
};
|
||||
|
||||
const resetView = () => onViewChange(absoluteMin, absoluteMax);
|
||||
|
||||
const zoomToEra = useCallback(
|
||||
(era: HistoricalEra, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const eraSpan = era.end_year - era.start_year;
|
||||
const padding = Math.max(5, Math.round(eraSpan * 0.03));
|
||||
let start = Math.max(absoluteMin, era.start_year - padding);
|
||||
let end = Math.min(absoluteMax, era.end_year + padding);
|
||||
if (end - start < 10) {
|
||||
const center = (era.start_year + era.end_year) / 2;
|
||||
start = Math.max(absoluteMin, Math.round(center - 5));
|
||||
end = Math.min(absoluteMax, Math.round(center + 5));
|
||||
}
|
||||
onViewChange(Math.round(start), Math.round(end));
|
||||
},
|
||||
[absoluteMin, absoluteMax, onViewChange]
|
||||
);
|
||||
|
||||
const zoomToEvent = useCallback(
|
||||
(event: HistoricalEvent, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const end = eventEndYear(event);
|
||||
const eventSpan = Math.max(end - event.startYear, 1);
|
||||
const padding = Math.max(8, Math.round(eventSpan * 0.2));
|
||||
let start = Math.max(absoluteMin, event.startYear - padding);
|
||||
let endView = Math.min(absoluteMax, end + padding);
|
||||
if (endView - start < 10) {
|
||||
const center = (event.startYear + end) / 2;
|
||||
start = Math.max(absoluteMin, Math.round(center - 5));
|
||||
endView = Math.min(absoluteMax, Math.round(center + 5));
|
||||
}
|
||||
onViewChange(Math.round(start), Math.round(endView));
|
||||
},
|
||||
[absoluteMin, absoluteMax, onViewChange]
|
||||
);
|
||||
|
||||
const visibleEvents = useMemo(() => {
|
||||
const inView = HISTORICAL_EVENTS.filter((event) => eventInView(event, viewStart, viewEnd));
|
||||
const minLabelGapYears = span > 200 ? 40 : span > 80 ? 18 : span > 30 ? 10 : 5;
|
||||
let lastLabelYear = -Infinity;
|
||||
return inView.map((event) => {
|
||||
const end = eventEndYear(event);
|
||||
const labelAnchor = event.endYear ? (event.startYear + end) / 2 : event.startYear;
|
||||
const showLabel = labelAnchor - lastLabelYear >= minLabelGapYears;
|
||||
if (showLabel) lastLabelYear = labelAnchor;
|
||||
return { event, showLabel };
|
||||
});
|
||||
}, [viewStart, viewEnd, span]);
|
||||
|
||||
const lifespanBand = useMemo(() => {
|
||||
if (!lifespanHighlight) return null;
|
||||
const bottom = yearToBottomPercent(
|
||||
Math.max(lifespanHighlight.birthYear, viewStart),
|
||||
viewStart,
|
||||
viewEnd
|
||||
);
|
||||
const top = yearToBottomPercent(
|
||||
Math.min(lifespanHighlight.deathYear, viewEnd),
|
||||
viewStart,
|
||||
viewEnd
|
||||
);
|
||||
const height = top - bottom;
|
||||
if (height <= 0) return null;
|
||||
return { bottom, height, color: lifespanHighlight.color };
|
||||
}, [lifespanHighlight, viewStart, viewEnd]);
|
||||
|
||||
return (
|
||||
<aside className="vtimeline-wrapper">
|
||||
<div className="vtimeline-controls">
|
||||
<button type="button" onClick={zoomIn} title="Zoom in">
|
||||
+
|
||||
</button>
|
||||
<button type="button" onClick={zoomOut} title="Zoom out">
|
||||
−
|
||||
</button>
|
||||
<button type="button" onClick={resetView} title="Reset view">
|
||||
⟲
|
||||
</button>
|
||||
<span className="vtimeline-range">
|
||||
{formatYear(viewStart)}
|
||||
<br />—<br />
|
||||
{formatYear(viewEnd)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`vtimeline-container${lifespanBand ? ' vtimeline-container-lifespan-hover' : ''}`}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={(e) => handleMouseDown(e, 'pan')}
|
||||
>
|
||||
<div className="vtimeline-track">
|
||||
{eras.map((era) => {
|
||||
const bottom = yearToBottomPercent(Math.max(era.start_year, viewStart), viewStart, viewEnd);
|
||||
const top = yearToBottomPercent(Math.min(era.end_year, viewEnd), viewStart, viewEnd);
|
||||
if (top <= 0 || bottom >= 100) return null;
|
||||
const height = Math.min(100, top) - Math.max(0, bottom);
|
||||
return (
|
||||
<button
|
||||
key={era.id}
|
||||
type="button"
|
||||
className="vtimeline-era-block"
|
||||
style={{
|
||||
bottom: `${Math.max(0, bottom)}%`,
|
||||
height: `${height}%`,
|
||||
borderBottom: era.start_definite ? '2px solid rgba(255,255,255,0.6)' : undefined,
|
||||
borderTop: era.end_definite ? '2px solid rgba(255,255,255,0.6)' : undefined,
|
||||
background: `linear-gradient(0deg,
|
||||
${era.start_definite ? 'var(--era-color)' : 'transparent'} 0%,
|
||||
var(--era-color) 15%,
|
||||
var(--era-color) 85%,
|
||||
${era.end_definite ? 'var(--era-color)' : 'transparent'} 100%)`,
|
||||
['--era-color' as string]: getEraColor(era.name),
|
||||
}}
|
||||
title={`${era.name}: ${formatYear(era.start_year)} – ${formatYear(era.end_year)}`}
|
||||
onClick={(e) => zoomToEra(era, e)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="vtimeline-era-label">{era.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{lifespanBand && (
|
||||
<div className="vtimeline-lifespan-overlays" aria-hidden>
|
||||
{lifespanBand.bottom > 0 && (
|
||||
<div className="vtimeline-lifespan-dim" style={{ bottom: 0, height: `${lifespanBand.bottom}%` }} />
|
||||
)}
|
||||
{lifespanBand.bottom + lifespanBand.height < 100 && (
|
||||
<div
|
||||
className="vtimeline-lifespan-dim"
|
||||
style={{
|
||||
bottom: `${lifespanBand.bottom + lifespanBand.height}%`,
|
||||
height: `${100 - lifespanBand.bottom - lifespanBand.height}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="vtimeline-lifespan-highlight"
|
||||
style={{
|
||||
bottom: `${lifespanBand.bottom}%`,
|
||||
height: `${lifespanBand.height}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="vtimeline-events">
|
||||
{visibleEvents.map(({ event, showLabel }) => {
|
||||
const end = eventEndYear(event);
|
||||
const isSpan = event.endYear != null && event.endYear !== event.startYear;
|
||||
if (isSpan) {
|
||||
const bottom = yearToBottomPercent(Math.max(event.startYear, viewStart), viewStart, viewEnd);
|
||||
const top = yearToBottomPercent(Math.min(end, viewEnd), viewStart, viewEnd);
|
||||
if (top <= bottom) return null;
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
className="vtimeline-event-span"
|
||||
style={{ bottom: `${bottom}%`, height: `${top - bottom}%` }}
|
||||
title={event.name}
|
||||
onClick={(e) => zoomToEvent(event, e)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{showLabel && (
|
||||
<span className="vtimeline-event-label">{event.shortLabel ?? event.name}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const bottom = yearToBottomPercent(event.startYear, viewStart, viewEnd);
|
||||
if (bottom < 0 || bottom > 100) return null;
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
className="vtimeline-event-mark"
|
||||
style={{ bottom: `${bottom}%` }}
|
||||
title={event.name}
|
||||
onClick={(e) => zoomToEvent(event, e)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{showLabel && (
|
||||
<span className="vtimeline-event-label">{event.shortLabel ?? event.name}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="vtimeline-ticks">
|
||||
{ticks.map((year) => (
|
||||
<div
|
||||
key={year}
|
||||
className="vtimeline-tick"
|
||||
style={{ bottom: `${yearToBottomPercent(year, viewStart, viewEnd)}%` }}
|
||||
>
|
||||
<span>{formatYear(year)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="vtimeline-brush vtimeline-brush-start"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMouseDown(e, 'start');
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="vtimeline-brush vtimeline-brush-end"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMouseDown(e, 'end');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="vtimeline-hint">{t('captionVerticalTimeline')}</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,12 @@ import type {
|
||||
import { galleryImageUrlCandidates, imageUrl, api } from '../api/client';
|
||||
import { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
|
||||
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 MovementHallDetails from './MovementHallDetails';
|
||||
import GalleryWindows, { GalleryTrackLights } from './GalleryWindows';
|
||||
@@ -84,6 +89,15 @@ interface TourGalleryProps extends BaseGalleryProps {
|
||||
|
||||
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_THICKNESS = 0.18;
|
||||
const MOUNT_OFFSET = 0.16;
|
||||
@@ -191,8 +205,8 @@ function galleryWallColors(movementColor?: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Height above frame top edge — keeps fixture out of the viewer's line of sight. */
|
||||
const INFLUENCE_LAMP_ABOVE_FRAME = 0.46;
|
||||
/** Clearance above the tallest frame in the hall for influence lamps (metres). */
|
||||
const INFLUENCE_LAMP_ABOVE_HIGHEST_M = 0.4;
|
||||
|
||||
type WallSide = 'back' | 'left' | 'right';
|
||||
|
||||
@@ -304,6 +318,20 @@ function frameOuterH(canvasH: number, reviewed: boolean): number {
|
||||
return canvasH + matBorder * 2 + rail;
|
||||
}
|
||||
|
||||
/** World Y of the top edge of the tallest allocated frame in the hall. */
|
||||
function highestPaintingTopY(segments: WallSegment[]): number {
|
||||
let maxTop = EYE_HEIGHT;
|
||||
for (const seg of segments) {
|
||||
for (let i = 0; i < seg.paintings.length; i++) {
|
||||
const slot = seg.slots[i];
|
||||
if (!slot) continue;
|
||||
const top = slot.position[1] + frameOuterH(slot.maxH, paintingIsReviewed(seg.paintings[i])) / 2;
|
||||
if (top > maxTop) maxTop = top;
|
||||
}
|
||||
}
|
||||
return maxTop;
|
||||
}
|
||||
|
||||
function layoutRow(paintings: Painting[], span: number) {
|
||||
const count = paintings.length;
|
||||
if (count === 0) return { slots: [], spanNeeded: span };
|
||||
@@ -803,17 +831,19 @@ function usePaintingTexture(urls: string[] | string | null) {
|
||||
}
|
||||
|
||||
function InfluencePictureLamp({
|
||||
frameHeight,
|
||||
matBorder,
|
||||
frameWorldY,
|
||||
lampWorldY,
|
||||
frameDepth,
|
||||
highlighted,
|
||||
}: {
|
||||
frameHeight: number;
|
||||
matBorder: number;
|
||||
/** Painting group world Y — local lamp offset = lampWorldY − frameWorldY. */
|
||||
frameWorldY: number;
|
||||
/** Shared hall rail: 40 cm above the tallest frame. */
|
||||
lampWorldY: number;
|
||||
frameDepth: number;
|
||||
highlighted: boolean;
|
||||
}) {
|
||||
const mountY = frameHeight / 2 + matBorder + INFLUENCE_LAMP_ABOVE_FRAME;
|
||||
const mountY = lampWorldY - frameWorldY;
|
||||
const glow = highlighted ? 1.6 : 1.15;
|
||||
const scale = 1.35;
|
||||
|
||||
@@ -918,6 +948,7 @@ function PaintingFrame({
|
||||
wallSide,
|
||||
imageRevision,
|
||||
caption,
|
||||
influenceLampWorldY,
|
||||
onClick,
|
||||
}: {
|
||||
painting: Painting;
|
||||
@@ -928,6 +959,7 @@ function PaintingFrame({
|
||||
wallSide: WallSide;
|
||||
imageRevision?: number;
|
||||
caption?: string;
|
||||
influenceLampWorldY: number;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
@@ -1018,8 +1050,8 @@ function PaintingFrame({
|
||||
|
||||
{hasInfluenceLinks && (
|
||||
<InfluencePictureLamp
|
||||
frameHeight={height}
|
||||
matBorder={matBorder}
|
||||
frameWorldY={position[1]}
|
||||
lampWorldY={influenceLampWorldY}
|
||||
frameDepth={frameDepth}
|
||||
highlighted={hovered}
|
||||
/>
|
||||
@@ -1505,6 +1537,10 @@ function ArtistHall({
|
||||
const hallLightScale = interiorStyle?.lightScale ?? 1;
|
||||
const wallRoughness = interiorStyle ? 0.75 : 0.92;
|
||||
const wallMetalness = interiorStyle ? 0.08 : 0.06;
|
||||
const influenceLampWorldY = useMemo(
|
||||
() => highestPaintingTopY(segments) + INFLUENCE_LAMP_ABOVE_HIGHEST_M,
|
||||
[segments]
|
||||
);
|
||||
|
||||
const wallMaterial = (color: string) => (
|
||||
<meshStandardMaterial color={color} roughness={wallRoughness} metalness={wallMetalness} />
|
||||
@@ -1717,7 +1753,14 @@ function ArtistHall({
|
||||
</mesh>
|
||||
|
||||
{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
|
||||
@@ -1752,6 +1795,7 @@ function ArtistHall({
|
||||
wallSide={seg.slots[i].side}
|
||||
imageRevision={imageRevisions?.[painting.id]}
|
||||
caption={showCaptions ? paintingWallCaption(painting) : undefined}
|
||||
influenceLampWorldY={influenceLampWorldY}
|
||||
onClick={() => onPaintingClick(painting.id)}
|
||||
/>
|
||||
))}
|
||||
@@ -2133,8 +2177,8 @@ export default function VirtualGallery(props: Props) {
|
||||
);
|
||||
|
||||
const movementHalls = useMemo(
|
||||
() => (isWingedHall ? buildAllMovementHallLayouts(paintings) : []),
|
||||
[isWingedHall, paintings]
|
||||
() => (isWingedHall ? buildAllMovementHallLayouts(paintings, interiorStyle?.details) : []),
|
||||
[isWingedHall, paintings, interiorStyle?.details]
|
||||
);
|
||||
|
||||
const [hallIndex, setHallIndex] = useState(0);
|
||||
@@ -2612,7 +2656,7 @@ export default function VirtualGallery(props: Props) {
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<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}
|
||||
/>
|
||||
</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'
|
||||
| 'glass-block'
|
||||
| '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 {
|
||||
wall: 'back' | 'left' | 'right' | 'ceiling';
|
||||
@@ -54,9 +58,42 @@ export interface MovementInteriorStyle {
|
||||
trackLights: number;
|
||||
/** Scales the shared hall/scene lights. <1 for deliberately dim period interiors. */
|
||||
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[] {
|
||||
return specs;
|
||||
}
|
||||
@@ -99,9 +136,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Roman atrium',
|
||||
'Marble-clad villa · mosaic floor · clerestory daylight',
|
||||
{ wall: 'limestone', ceiling: 'plaster-warm', floor: 'mosaic-roman' },
|
||||
{ wall: '#e8e0d0', ceiling: '#f5f0e8', floor: '#c8b898', trim: '#a89878' },
|
||||
{ wall: '#8e8980', ceiling: '#9c9994', floor: '#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',
|
||||
ambient: 0.62,
|
||||
warmLight: '#fff8e8',
|
||||
@@ -175,9 +215,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Florentine palazzo',
|
||||
'Lime-washed walls · terracotta accents · arched windows',
|
||||
{ wall: 'painted-lime', wallSide: 'stucco-cream', ceiling: 'fresco-worn', floor: 'terracotta-tiles' },
|
||||
{ wall: '#f4ebe0', ceiling: '#faf6ee', floor: '#c89070', trim: '#c9a227' },
|
||||
{ wall: '#89847e', ceiling: '#96948f', floor: '#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',
|
||||
windows: windows(
|
||||
{ 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(
|
||||
'high-renaissance-palazzo',
|
||||
'Roman palazzo',
|
||||
'Marble and stucco · coffered ceiling · checker floor',
|
||||
{ wall: 'marble-veined-carrara', wallSide: 'stucco-cream', ceiling: 'plaster-warm', floor: 'marble-checker' },
|
||||
{ wall: '#f0ece4', wallSide: '#efe4d4', ceiling: '#faf6ee', floor: '#ddd8cc', trim: '#c9a227' },
|
||||
'Marble and stucco · calm coffered ceiling · veined stone floor',
|
||||
{ wall: 'marble-veined-carrara', wallSide: 'stucco-cream', ceiling: 'plaster-warm', floor: 'marble-veined-carrara' },
|
||||
{ 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',
|
||||
windows: windows(
|
||||
{ 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: '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',
|
||||
'Oak wainscoting · leaded glass · herringbone floor',
|
||||
{ wall: 'oak-panel', wallSide: 'plaster-warm', ceiling: 'dark-wood-panel', floor: 'parquet-herringbone' },
|
||||
{ wall: '#8a6848', wallSide: '#f0ebe3', ceiling: '#3a2818', floor: '#8a6848', trim: '#5c4030' },
|
||||
{ wall: '#8a6848', wallSide: '#8c8985', ceiling: '#3a2818', floor: '#8a6848', trim: '#5c4030' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'flemish',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#f0e8d8',
|
||||
warmLight: '#ffe8c8',
|
||||
windows: windows(
|
||||
@@ -226,9 +275,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Mannerist gallery',
|
||||
'Dramatic stucco · elongated windows · veined marble',
|
||||
{ wall: 'stucco-terracotta', ceiling: 'gilded-stucco', floor: 'marble-veined-emerald' },
|
||||
{ wall: '#d8b898', ceiling: '#d8c070', floor: '#dce8e0', trim: '#b8860b' },
|
||||
{ wall: '#98816b', ceiling: '#a79456', floor: '#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',
|
||||
windows: windows(
|
||||
{ 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',
|
||||
'Crimson velvet · gilded stucco · grand windows',
|
||||
{ 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',
|
||||
titleColor: '#f0d890',
|
||||
@@ -264,9 +316,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Rococo salon',
|
||||
'Pastel painted walls · gilt trim · chevron parquet',
|
||||
{ wall: 'painted-oil-satin', ceiling: 'silk-pale', floor: 'parquet-chevrons' },
|
||||
{ wall: '#e8dce8', ceiling: '#faf6f0', floor: '#c8a882', trim: '#d4af37' },
|
||||
{ wall: '#918a91', ceiling: '#9b9995', floor: '#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',
|
||||
warmLight: '#fff0f0',
|
||||
windows: windows(
|
||||
@@ -279,19 +334,22 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
36: mk(
|
||||
'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: '#f2f0ec', ceiling: '#fafafa', floor: '#eceae6', trim: '#b8b0a4' },
|
||||
{ wall: '#908f8d', ceiling: '#9e9e9e', floor: '#8a8680', trim: '#a0988c' },
|
||||
{
|
||||
details: 'neoclassical',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#4a4844',
|
||||
ambient: 0.65,
|
||||
ambient: 0.58,
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.6, width: 2.8, height: 2.0, style: 'baroque-pair', lightColor: '#ffffff', lightIntensity: 4.0 },
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 4.0, height: 2.5, style: 'skylight', lightColor: '#ffffff', lightIntensity: 5.0 },
|
||||
{ wall: 'left', x: 0, y: 3.0, width: 2.0, height: 1.2, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.5 }
|
||||
{ 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: '#f0eee8', lightIntensity: 3.2 },
|
||||
{ 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(
|
||||
@@ -301,7 +359,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
{ wall: 'walnut-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||
{ wall: '#5a4030', ceiling: '#2a2420', floor: '#6a5040', trim: '#8a7050' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'gothic-revival',
|
||||
titleColor: '#e8dcc8',
|
||||
ambient: 0.5,
|
||||
warmLight: '#ffe0c0',
|
||||
@@ -317,9 +375,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Realist picture gallery',
|
||||
'Warm painted walls · bourgeois salon · oak parquet',
|
||||
{ wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e8e2d8', ceiling: '#f5f0e8', floor: '#a08060', trim: '#8a7050' },
|
||||
{ wall: '#88847e', ceiling: '#97948f', floor: '#a08060', trim: '#8a7050' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'bourgeois',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.5, width: 2.4, height: 1.6, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 3.2 },
|
||||
{ wall: 'left', x: 0, y: 2.7, width: 1.6, height: 1.3, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.4 }
|
||||
@@ -331,9 +392,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Impressionist salon',
|
||||
'North-light skylight · pale painted walls · herringbone floor',
|
||||
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
|
||||
{ wall: '#f0ebe3', ceiling: '#fafafa', floor: '#c8a882', trim: '#c9a96e' },
|
||||
{ wall: '#8f8c87', ceiling: '#9e9e9e', floor: '#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,
|
||||
windows: windows(
|
||||
{ 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',
|
||||
'Painted studio walls · large north window · worn floorboards',
|
||||
{ wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e8e0d0', ceiling: '#f0ebe0', floor: '#9a7858', trim: '#8a6848' },
|
||||
{ wall: '#847f76', ceiling: '#918f88', floor: '#9a7858', trim: '#8a6848' },
|
||||
{
|
||||
details: 'atelier',
|
||||
details: 'paris-atelier',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
windows: windows(
|
||||
{ wall: 'left', x: 0, y: 2.4, width: 2.8, height: 2.0, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 4.5 },
|
||||
{ wall: '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 }
|
||||
),
|
||||
}
|
||||
@@ -364,7 +431,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||
{ wall: '#1a4030', ceiling: '#1a1814', floor: '#4a3828', trim: '#8a7050' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'symbolist',
|
||||
titleColor: '#d8e8c8',
|
||||
ambient: 0.55,
|
||||
warmLight: '#ffd898',
|
||||
@@ -380,9 +447,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Art Nouveau salon',
|
||||
'Painted plaster · stained glass · terrazzo floor',
|
||||
{ wall: 'painted-oil-satin', ceiling: 'silk-gold', floor: 'terrazzo' },
|
||||
{ wall: '#f0ece4', ceiling: '#d8c890', floor: '#d8d0c4', trim: '#6a9868' },
|
||||
{ wall: '#8f8c87', ceiling: '#a0946b', floor: '#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',
|
||||
windows: windows(
|
||||
{ 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',
|
||||
'Bold painted walls · flooded with color and light',
|
||||
{ wall: 'painted-oil-matte', wallSide: 'painted-oil-matte', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e89060', wallSide: '#e8c860', ceiling: '#f8f0e0', floor: '#a07048', trim: '#c04020' },
|
||||
{ wall: '#e89060', wallSide: '#e8c860', ceiling: '#9e998e', floor: '#a07048', trim: '#c04020' },
|
||||
{
|
||||
details: 'atelier',
|
||||
details: 'fauvist',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#402010',
|
||||
ambient: 0.7,
|
||||
windows: windows(
|
||||
{ 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 }
|
||||
),
|
||||
}
|
||||
@@ -414,26 +487,29 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
{ wall: 'dark-wood-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||
{ wall: '#3a2818', ceiling: '#2a2018', floor: '#5a4030', trim: '#8a6040' },
|
||||
{
|
||||
details: 'atelier',
|
||||
details: 'expressionist',
|
||||
titleColor: '#f0d8b0',
|
||||
ambient: 0.52,
|
||||
windows: windows(
|
||||
{ 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(
|
||||
'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: '#e8e4dc', ceiling: '#f5f5f5', floor: '#9a8870', trim: '#888888' },
|
||||
{ wall: '#8c8a85', ceiling: '#999999', floor: '#9a8870', trim: '#888888' },
|
||||
{
|
||||
details: 'atelier',
|
||||
details: 'cubist-atelier',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
ambient: 0.65,
|
||||
windows: windows(
|
||||
{ wall: 'left', x: 0, y: 2.5, width: 3.5, height: 2.4, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 5.5 },
|
||||
{ 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 }
|
||||
),
|
||||
}
|
||||
@@ -441,17 +517,21 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
46: mk(
|
||||
'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: '#a8a8a8', ceiling: '#989898', floor: '#888880', trim: '#606060' },
|
||||
{ wall: '#858585', ceiling: '#7a7a7a', floor: '#888880', trim: '#606060' },
|
||||
{
|
||||
details: 'industrial',
|
||||
details: 'futurist',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#303030',
|
||||
ambient: 0.62,
|
||||
fog: '#181818',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.6, width: 4.0, height: 2.4, style: 'factory', lightColor: '#ffffff', lightIntensity: 6.0 },
|
||||
{ wall: 'left', x: 0, y: 2.8, width: 2.5, height: 1.6, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 3.5 },
|
||||
{ wall: 'back', x: 0, y: 2.65, width: 4.2, height: 2.2, style: 'ribbon', lightColor: '#ffffff', lightIntensity: 5.8 },
|
||||
{ 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 }
|
||||
),
|
||||
doorWood: ['#606060', '#707070', '#505050'],
|
||||
@@ -462,9 +542,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Suprematist white cube',
|
||||
'Matte white paint · geometric light · polished floor',
|
||||
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
|
||||
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#e0e0e0', trim: '#cccccc' },
|
||||
{ wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#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',
|
||||
ambient: 0.72,
|
||||
fog: '#1a1a1a',
|
||||
@@ -482,9 +565,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Constructivist space',
|
||||
'Glass block · concrete · angular daylight',
|
||||
{ wall: 'concrete-block', ceiling: 'concrete-raw', floor: 'concrete-polished' },
|
||||
{ wall: '#b0b0b0', ceiling: '#a0a0a0', floor: '#c0c0c0', trim: '#808080' },
|
||||
{ wall: '#858585', ceiling: '#7a7a7a', floor: '#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',
|
||||
ambient: 0.65,
|
||||
windows: windows(
|
||||
@@ -497,18 +583,26 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
),
|
||||
49: mk(
|
||||
'dada-salon',
|
||||
'Dada salon',
|
||||
'Eclectic bourgeois room · painted walls and exposed brick',
|
||||
{ wall: 'painted-emulsion', wallSide: 'brick', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e8dcc8', wallSide: '#8a5040', ceiling: '#f0ebe0', floor: '#8a6848', trim: '#6a4830' },
|
||||
'Dada cabaret',
|
||||
'Modern cabaret · collage panels · open picture windows',
|
||||
{ wall: 'painted-flat', wallSide: 'painted-emulsion', ceiling: 'painted-flat', floor: 'concrete-polished' },
|
||||
{ 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(
|
||||
{ wall: 'back', x: -1.5, y: 2.5, width: 1.0, height: 1.4, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.5 },
|
||||
{ wall: 'back', x: 1.5, y: 2.6, width: 0.8, height: 1.8, style: 'gothic-lancet', lightColor: '#ffe8c0', lightIntensity: 2.0 },
|
||||
{ wall: 'right', x: 0, y: 2.4, width: 1.6, height: 1.6, style: 'factory', lightColor: '#ffffff', lightIntensity: 3.0 }
|
||||
{ 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.7, y: 2.7, width: 1.1, height: 1.35, style: 'picture', lightColor: '#fff0d8', lightIntensity: 2.8 },
|
||||
{ wall: 'right', x: 0, y: 2.5, width: 2.2, height: 1.9, style: 'picture', lightColor: '#f5f9ff', lightIntensity: 3.8 }
|
||||
),
|
||||
trackLights: 1.0,
|
||||
doorWood: ['#2a2a2a', '#3a3a3a', '#1a1a1a'],
|
||||
}
|
||||
),
|
||||
50: mk(
|
||||
@@ -518,7 +612,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||
{ wall: '#1a2848', ceiling: '#2a2428', floor: '#5a4838', trim: '#8a7050' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'surrealist',
|
||||
titleColor: '#e8dcc8',
|
||||
ambient: 0.58,
|
||||
warmLight: '#ffd898',
|
||||
@@ -532,18 +626,25 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
),
|
||||
51: mk(
|
||||
'abstract-expressionist-loft',
|
||||
'NYC loft',
|
||||
'Raw brick and painted plaster · north-light factory windows',
|
||||
{ wall: 'brick', wallSide: 'painted-flat', ceiling: 'concrete-raw', floor: 'industrial-floor' },
|
||||
{ wall: '#8a5040', wallSide: '#f5f5f5', ceiling: '#989898', floor: '#888880', trim: '#606060' },
|
||||
'Painter loft',
|
||||
'Whitewashed plaster · warm timber · open north-light picture windows',
|
||||
{ wall: 'painted-flat', wallSide: 'painted-emulsion', ceiling: 'plaster-warm', floor: 'industrial-floor' },
|
||||
{ wall: '#e8e2d6', wallSide: '#ddd5c6', ceiling: '#f0ebe2', floor: '#9a9288', trim: '#6a5848' },
|
||||
{
|
||||
details: 'industrial',
|
||||
titleColor: '#303030',
|
||||
ambient: 0.65,
|
||||
details: 'loft',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#3a3028',
|
||||
ambient: 0.72,
|
||||
warmLight: '#fff6e8',
|
||||
sunLight: '#f2f6ff',
|
||||
windows: windows(
|
||||
{ wall: 'left', x: 0, y: 2.5, width: 4.0, height: 2.6, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 6.5 },
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 3.5, height: 2.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 4.5 }
|
||||
{ wall: 'left', x: -1.2, y: 2.55, width: 2.4, height: 2.3, style: 'picture', lightColor: '#f0f6ff', lightIntensity: 5.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(
|
||||
@@ -551,16 +652,19 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Pop Art gallery',
|
||||
'White painted walls · fluorescent daylight · polished concrete',
|
||||
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
|
||||
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#d8d8d8', trim: '#cccccc' },
|
||||
{ wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#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',
|
||||
ambient: 0.75,
|
||||
warmLight: '#ffffff',
|
||||
fog: '#1a1a1a',
|
||||
background: '#101010',
|
||||
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 }
|
||||
),
|
||||
trackLights: 0.6,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Documented art-historical movement lineage (predecessor → successor).
|
||||
* Sources: Met Museum essays, TheArtStory, Sotheby's movement guides.
|
||||
*
|
||||
* Art-historical movement lineage (predecessor → successor).
|
||||
* Defaults match museum essays; live data comes from GET /api/movement-links.
|
||||
* 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
|
||||
['Ancient Greek & Roman', 'Byzantine'],
|
||||
['Byzantine', 'Gothic'],
|
||||
@@ -21,17 +23,17 @@ export const MOVEMENT_LINEAGE: ReadonlyArray<readonly [parent: string, child: st
|
||||
['Rococo', 'Neoclassicism'],
|
||||
['Neoclassicism', 'Romanticism'],
|
||||
['Romanticism', 'Realism'],
|
||||
['Romanticism', 'Impressionism'], // Turner, Delacroix → light and atmosphere
|
||||
['Romanticism', 'Impressionism'],
|
||||
['Realism', 'Impressionism'],
|
||||
|
||||
// Modern origins (Met / ArtStory: Impressionism → Post-Impressionism → …)
|
||||
// Modern origins
|
||||
['Impressionism', 'Post-Impressionism'],
|
||||
['Post-Impressionism', 'Symbolism'],
|
||||
['Post-Impressionism', 'Fauvism'],
|
||||
['Post-Impressionism', 'Expressionism'],
|
||||
['Post-Impressionism', 'Cubism'], // Cézanne's structure → Picasso/Braque
|
||||
['Post-Impressionism', 'Cubism'],
|
||||
['Symbolism', 'Art Nouveau'],
|
||||
['Fauvism', 'Cubism'], // Braque, Matisse → geometric turn
|
||||
['Fauvism', 'Cubism'],
|
||||
['Cubism', 'Futurism'],
|
||||
['Cubism', 'Suprematism'],
|
||||
['Cubism', 'Constructivism'],
|
||||
@@ -43,10 +45,35 @@ export const MOVEMENT_LINEAGE: ReadonlyArray<readonly [parent: string, child: st
|
||||
['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[] {
|
||||
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[] {
|
||||
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 enTranslations from '../locales/en/translations.json';
|
||||
import enInfluences from '../locales/en/influences.json';
|
||||
import enMovementLinks from '../locales/en/movementLinks.json';
|
||||
import enTours from '../locales/en/tours.json';
|
||||
import enUsers from '../locales/en/users.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 ruTranslations from '../locales/ru/translations.json';
|
||||
import ruInfluences from '../locales/ru/influences.json';
|
||||
import ruMovementLinks from '../locales/ru/movementLinks.json';
|
||||
import ruTours from '../locales/ru/tours.json';
|
||||
import ruUsers from '../locales/ru/users.json';
|
||||
import ruAudit from '../locales/ru/audit.json';
|
||||
@@ -37,7 +39,7 @@ void i18n.use(initReactI18next).init({
|
||||
lng: initialLocale,
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['en', 'ru'],
|
||||
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours', 'users', 'audit'],
|
||||
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'movementLinks', 'tours', 'users', 'audit'],
|
||||
defaultNS: 'common',
|
||||
resources: {
|
||||
en: {
|
||||
@@ -51,6 +53,7 @@ void i18n.use(initReactI18next).init({
|
||||
debug: enDebug,
|
||||
translations: enTranslations,
|
||||
influences: enInfluences,
|
||||
movementLinks: enMovementLinks,
|
||||
tours: enTours,
|
||||
users: enUsers,
|
||||
audit: enAudit,
|
||||
@@ -66,6 +69,7 @@ void i18n.use(initReactI18next).init({
|
||||
debug: ruDebug,
|
||||
translations: ruTranslations,
|
||||
influences: ruInfluences,
|
||||
movementLinks: ruMovementLinks,
|
||||
tours: ruTours,
|
||||
users: ruUsers,
|
||||
audit: ruAudit,
|
||||
|
||||
@@ -15,10 +15,19 @@
|
||||
"checkup": "Checkup",
|
||||
"translations": "Translations",
|
||||
"influences": "Influences",
|
||||
"movementLinks": "Movement links",
|
||||
"tours": "Tours",
|
||||
"toursEditor": "Tour editor",
|
||||
"users": "Users",
|
||||
"audit": "Activity",
|
||||
"layoutHorizontal": "Classic timeline →",
|
||||
"layoutVertical": "↑ Vertical timeline",
|
||||
"layoutTree": "🌳 Tree of art",
|
||||
"captionClassicTimeline": "Click an era or event to zoom · Scroll to zoom · Drag to pan",
|
||||
"captionClassicFlow": "Scroll to zoom · drag to pan · each movement stream is a solid colour band through history",
|
||||
"captionVerticalTimeline": "Bottom → top · Scroll to zoom · Drag to pan",
|
||||
"captionVerticalFlow": "Bottom → top through history · scroll to zoom · drag to pan · click a stream",
|
||||
"captionTreeFlow": "Roots at the bottom, living movements at the crown · scroll to zoom · drag to pan · click a branch to enter its gallery",
|
||||
"openingTourGallery": "Opening guided tour…",
|
||||
"tourEmpty": "This tour has no paintings yet.",
|
||||
"tourLoadFailed": "Failed to load the tour.",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"title": "Influence links",
|
||||
"back": "← Back to gallery",
|
||||
"openMovementLinks": "Movement links →",
|
||||
"tabList": "List",
|
||||
"tabImport": "Import",
|
||||
"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_curator_notes": "Curator notes",
|
||||
"perm_translations": "Translations",
|
||||
"perm_influences": "Influences",
|
||||
"perm_influences": "Influences & movement links",
|
||||
"perm_tours": "Tours",
|
||||
"perm_users": "Users",
|
||||
"adminAllPerms": "Admins have all permissions automatically.",
|
||||
|
||||
@@ -15,10 +15,19 @@
|
||||
"checkup": "Проверка",
|
||||
"translations": "Переводы",
|
||||
"influences": "Влияния",
|
||||
"movementLinks": "Связи движений",
|
||||
"tours": "Экскурсии",
|
||||
"toursEditor": "Редактор экскурсий",
|
||||
"users": "Пользователи",
|
||||
"audit": "Активность",
|
||||
"layoutHorizontal": "Классическая шкала →",
|
||||
"layoutVertical": "↑ Вертикальная шкала",
|
||||
"layoutTree": "🌳 Древо искусства",
|
||||
"captionClassicTimeline": "Щёлкните эпоху или событие для приближения · Колесо — масштаб · Перетащите для панорамы",
|
||||
"captionClassicFlow": "Колесо — масштаб · перетащите для панорамы · каждое направление — цветная полоса сквозь историю",
|
||||
"captionVerticalTimeline": "Снизу вверх · Колесо — масштаб · Перетащите для панорамы",
|
||||
"captionVerticalFlow": "Снизу вверх по истории · колесо — масштаб · перетащите для панорамы · щёлкните поток",
|
||||
"captionTreeFlow": "Корни внизу, живые направления в кроне · колесо — масштаб · перетащите для панорамы · щёлкните ветвь, чтобы войти в зал",
|
||||
"openingTourGallery": "Открытие экскурсии…",
|
||||
"tourEmpty": "В этой экскурсии пока нет картин.",
|
||||
"tourLoadFailed": "Не удалось загрузить экскурсию.",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"title": "Связи влияния",
|
||||
"back": "← Назад в галерею",
|
||||
"openMovementLinks": "Связи движений →",
|
||||
"tabList": "Список",
|
||||
"tabImport": "Импорт",
|
||||
"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_curator_notes": "Заметки куратора",
|
||||
"perm_translations": "Переводы",
|
||||
"perm_influences": "Влияния",
|
||||
"perm_influences": "Влияния и связи движений",
|
||||
"perm_tours": "Экскурсии",
|
||||
"perm_users": "Пользователи",
|
||||
"adminAllPerms": "У администраторов все права автоматически.",
|
||||
|
||||
@@ -24,6 +24,59 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.home-timeline-stack-vertical {
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.home-movements-section-vertical {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.site-layout-switch {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
z-index: 120;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.site-layout-link {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: rgba(15, 15, 26, 0.85);
|
||||
color: rgba(201, 169, 110, 0.9);
|
||||
font-size: 12px;
|
||||
font-family: 'Georgia', serif;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site-layout-link:hover {
|
||||
background: rgba(201, 169, 110, 0.18);
|
||||
color: #f5e6c8;
|
||||
}
|
||||
|
||||
/* Link to the alternative tree start page — the one worth noticing. */
|
||||
.site-layout-link-feature {
|
||||
border-color: rgba(201, 169, 110, 0.7);
|
||||
background: rgba(201, 169, 110, 0.16);
|
||||
color: #f5e6c8;
|
||||
}
|
||||
|
||||
.site-layout-link-feature:hover {
|
||||
background: rgba(201, 169, 110, 0.3);
|
||||
}
|
||||
|
||||
.gallery-session-suspended {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -63,8 +116,12 @@
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
max-width: min(780px, calc(100vw - 24px));
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
.debug-mode-toggle,
|
||||
|
||||
@@ -3,12 +3,16 @@ import { useTranslation } from 'react-i18next';
|
||||
import Timeline from '../components/Timeline';
|
||||
import TimelineEventGuides from '../components/TimelineEventGuides';
|
||||
import MovementBands from '../components/MovementBands';
|
||||
import VerticalTimeline from '../components/VerticalTimeline';
|
||||
import VerticalMovementBands from '../components/VerticalMovementBands';
|
||||
import MovementTree from '../components/MovementTree';
|
||||
import VirtualGallery from '../components/VirtualGallery';
|
||||
import PaintingDetailView from '../components/PaintingDetail';
|
||||
import ArtistBio from '../components/ArtistBio';
|
||||
import CheckupPage from '../pages/CheckupPage';
|
||||
import TranslationsPage from '../pages/TranslationsPage';
|
||||
import InfluencesPage from '../pages/InfluencesPage';
|
||||
import MovementLinksPage from '../pages/MovementLinksPage';
|
||||
import ToursPage from '../pages/ToursPage';
|
||||
import UsersPage from '../pages/UsersPage';
|
||||
import AuditPage from '../pages/AuditPage';
|
||||
@@ -25,11 +29,13 @@ import '../components/ToursPopup.css';
|
||||
import '../components/LocaleSwitcher.css';
|
||||
import '../pages/TranslationsPage.css';
|
||||
import '../pages/InfluencesPage.css';
|
||||
import '../pages/MovementLinksPage.css';
|
||||
import '../pages/ToursPage.css';
|
||||
import '../pages/UsersPage.css';
|
||||
import '../pages/AuditPage.css';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||
import { setMovementLineageFromApi } from '../data/movement-lineage';
|
||||
import type {
|
||||
TimelineData,
|
||||
Artist,
|
||||
@@ -47,9 +53,12 @@ import './HomePage.css';
|
||||
|
||||
type View =
|
||||
| { type: 'timeline' }
|
||||
| { type: 'timeline-vertical' }
|
||||
| { type: 'timeline-tree' }
|
||||
| { type: 'checkup' }
|
||||
| { type: 'translations' }
|
||||
| { type: 'influences' }
|
||||
| { type: 'movement-links' }
|
||||
| { type: 'tours' }
|
||||
| { type: 'users' }
|
||||
| { type: 'audit' }
|
||||
@@ -64,7 +73,7 @@ type GallerySession =
|
||||
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail }
|
||||
| { 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(
|
||||
detail: MovementGalleryDetail,
|
||||
@@ -154,6 +163,37 @@ function catalogNavigateTarget(
|
||||
return idx < remaining.length ? remaining[idx].id : remaining[remaining.length - 1].id;
|
||||
}
|
||||
|
||||
/** Shareable timeline layout via `?layout=classic|vertical|tree` (classic may omit the param). */
|
||||
type TimelineLayoutId = 'classic' | 'vertical' | 'tree';
|
||||
|
||||
function parseTimelineLayoutParam(raw: string | null): TimelineLayoutId {
|
||||
if (raw === 'vertical' || raw === 'tree') return raw;
|
||||
if (raw === 'classic' || raw === 'horizontal') return 'classic';
|
||||
return 'classic';
|
||||
}
|
||||
|
||||
function timelineViewFromLayout(layout: TimelineLayoutId): View {
|
||||
if (layout === 'vertical') return { type: 'timeline-vertical' };
|
||||
if (layout === 'tree') return { type: 'timeline-tree' };
|
||||
return { type: 'timeline' };
|
||||
}
|
||||
|
||||
function writeTimelineLayoutParam(layout: TimelineLayoutId) {
|
||||
const url = new URL(window.location.href);
|
||||
if (layout === 'classic') url.searchParams.delete('layout');
|
||||
else url.searchParams.set('layout', layout);
|
||||
const next = `${url.pathname}${url.search}${url.hash}`;
|
||||
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
if (next !== current) window.history.replaceState(null, '', next);
|
||||
}
|
||||
|
||||
function readInitialTimelineView(): View {
|
||||
if (typeof window === 'undefined') return { type: 'timeline' };
|
||||
return timelineViewFromLayout(
|
||||
parseTimelineLayoutParam(new URLSearchParams(window.location.search).get('layout'))
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const { t } = useTranslation('home');
|
||||
const { isCurator, isAdmin, username, login, logout, can } = useAuth();
|
||||
@@ -164,7 +204,7 @@ export default function HomePage() {
|
||||
const canInfluences = can('influences');
|
||||
const canTours = can('tours');
|
||||
const canUsers = can('users');
|
||||
const [view, setView] = useState<View>({ type: 'timeline' });
|
||||
const [view, setView] = useState<View>(readInitialTimelineView);
|
||||
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
|
||||
const [bounds, setBounds] = useState({ min: -800, max: 2025 });
|
||||
const [viewStart, setViewStart] = useState(-800);
|
||||
@@ -207,7 +247,11 @@ export default function HomePage() {
|
||||
setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data });
|
||||
} else if (view.type === 'tour-gallery') {
|
||||
setGallerySession({ kind: 'tour', tourId: view.tourId, data: view.data });
|
||||
} else if (view.type === 'timeline') {
|
||||
} else if (
|
||||
view.type === 'timeline' ||
|
||||
view.type === 'timeline-vertical' ||
|
||||
view.type === 'timeline-tree'
|
||||
) {
|
||||
setGallerySession(null);
|
||||
}
|
||||
}, [view]);
|
||||
@@ -219,9 +263,14 @@ export default function HomePage() {
|
||||
(async () => {
|
||||
try {
|
||||
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;
|
||||
|
||||
setMovementLineageFromApi(lineage.items);
|
||||
|
||||
const min = catalog.bounds.min_year ?? -800;
|
||||
const max = catalog.bounds.max_year ?? 2025;
|
||||
setBounds({ min, max });
|
||||
@@ -268,9 +317,34 @@ export default function HomePage() {
|
||||
setViewStart(bounds.min);
|
||||
setViewEnd(bounds.max);
|
||||
setGalleryRevision((revision) => revision + 1);
|
||||
writeTimelineLayoutParam('classic');
|
||||
setView({ type: 'timeline' });
|
||||
}, [bounds.min, bounds.max]);
|
||||
|
||||
const openHorizontalTimeline = () => {
|
||||
writeTimelineLayoutParam('classic');
|
||||
setView({ type: 'timeline' });
|
||||
};
|
||||
|
||||
const openVerticalTimeline = () => {
|
||||
writeTimelineLayoutParam('vertical');
|
||||
setView({ type: 'timeline-vertical' });
|
||||
};
|
||||
|
||||
const openTreeTimeline = () => {
|
||||
writeTimelineLayoutParam('tree');
|
||||
setView({ type: 'timeline-tree' });
|
||||
};
|
||||
|
||||
const isTimelineHome =
|
||||
view.type === 'timeline' ||
|
||||
view.type === 'timeline-vertical' ||
|
||||
view.type === 'timeline-tree';
|
||||
const isVerticalTimeline = view.type === 'timeline-vertical';
|
||||
const isTreeTimeline = view.type === 'timeline-tree';
|
||||
/** Both alternative layouts run the year axis bottom → top beside the chart. */
|
||||
const isVerticalLayout = isVerticalTimeline || isTreeTimeline;
|
||||
|
||||
const toggleDebugMode = () => {
|
||||
setDebugMode((prev) => {
|
||||
const next = !prev;
|
||||
@@ -298,6 +372,8 @@ export default function HomePage() {
|
||||
setView({ type: 'translations' });
|
||||
} else if (loginRedirect === 'influences') {
|
||||
setView({ type: 'influences' });
|
||||
} else if (loginRedirect === 'movement-links') {
|
||||
setView({ type: 'movement-links' });
|
||||
} else if (loginRedirect === 'tours') {
|
||||
setView({ type: 'tours' });
|
||||
} else if (loginRedirect === 'users') {
|
||||
@@ -316,6 +392,7 @@ export default function HomePage() {
|
||||
view.type === 'checkup' ||
|
||||
view.type === 'translations' ||
|
||||
view.type === 'influences' ||
|
||||
view.type === 'movement-links' ||
|
||||
view.type === 'tours' ||
|
||||
view.type === 'users' ||
|
||||
view.type === 'audit'
|
||||
@@ -348,6 +425,14 @@ export default function HomePage() {
|
||||
setView({ type: 'influences' });
|
||||
};
|
||||
|
||||
const openMovementLinks = () => {
|
||||
if (!canInfluences) {
|
||||
openCuratorLogin('movement-links');
|
||||
return;
|
||||
}
|
||||
setView({ type: 'movement-links' });
|
||||
};
|
||||
|
||||
const openToursEditor = () => {
|
||||
if (!canTours) {
|
||||
openCuratorLogin('tours');
|
||||
@@ -1063,7 +1148,7 @@ export default function HomePage() {
|
||||
|
||||
{view.type === 'influences' && (
|
||||
canInfluences ? (
|
||||
<InfluencesPage onBack={goToTimelineHome} />
|
||||
<InfluencesPage onBack={goToTimelineHome} onOpenMovementLinks={openMovementLinks} />
|
||||
) : (
|
||||
<div className="curator-login-gate">
|
||||
<h2>{t('curatorRequiredTitle')}</h2>
|
||||
@@ -1080,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' && (
|
||||
canTours ? (
|
||||
<ToursPage onBack={goToTimelineHome} />
|
||||
@@ -1204,9 +1308,49 @@ export default function HomePage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{view.type === 'timeline' && (
|
||||
{isTimelineHome && (
|
||||
<div className="home-page">
|
||||
<header className="site-header">
|
||||
<div className="site-layout-switch">
|
||||
<button
|
||||
type="button"
|
||||
className="site-layout-link site-layout-link-feature"
|
||||
onClick={openMovementLinks}
|
||||
title="Manage art-movement predecessor → successor links (curator)"
|
||||
>
|
||||
{t('movementLinks')}
|
||||
</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
|
||||
type="button"
|
||||
className="site-layout-link"
|
||||
onClick={openVerticalTimeline}
|
||||
title="Switch to bottom-up vertical timeline"
|
||||
>
|
||||
{t('layoutVertical')}
|
||||
</button>
|
||||
)}
|
||||
{!(view.type === 'timeline') && (
|
||||
<button
|
||||
type="button"
|
||||
className="site-layout-link"
|
||||
onClick={openHorizontalTimeline}
|
||||
title="Switch to classic left-to-right timeline"
|
||||
>
|
||||
{t('layoutHorizontal')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="site-dev-tools">
|
||||
{isCurator ? (
|
||||
<>
|
||||
@@ -1334,7 +1478,7 @@ export default function HomePage() {
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div className="home-timeline-stack">
|
||||
<div className={`home-timeline-stack${isVerticalLayout ? ' home-timeline-stack-vertical' : ''}`}>
|
||||
{loading && (
|
||||
<GalleryLoadingMarker overlay message="Loading art history…" />
|
||||
)}
|
||||
@@ -1345,6 +1489,46 @@ export default function HomePage() {
|
||||
<GalleryLoadingMarker banner message="Loading portraits…" />
|
||||
)}
|
||||
|
||||
{isVerticalLayout ? (
|
||||
<>
|
||||
<VerticalTimeline
|
||||
eras={timelineData.eras}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
onViewChange={handleViewChange}
|
||||
absoluteMin={bounds.min}
|
||||
absoluteMax={bounds.max}
|
||||
lifespanHighlight={hoveredLifespan}
|
||||
/>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{!loading && (
|
||||
<div className="home-movements-section-vertical">
|
||||
{isTreeTimeline ? (
|
||||
<MovementTree
|
||||
movements={timelineData.movements}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
absoluteMin={bounds.min}
|
||||
absoluteMax={bounds.max}
|
||||
onViewChange={handleViewChange}
|
||||
onMovementClick={handleMovementClick}
|
||||
/>
|
||||
) : (
|
||||
<VerticalMovementBands
|
||||
movements={timelineData.movements}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
absoluteMin={bounds.min}
|
||||
absoluteMax={bounds.max}
|
||||
onViewChange={handleViewChange}
|
||||
onMovementClick={handleMovementClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Timeline
|
||||
eras={timelineData.eras}
|
||||
viewStart={viewStart}
|
||||
@@ -1378,6 +1562,8 @@ export default function HomePage() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,6 +16,7 @@ type WizardStep = 'upload' | 'mapping' | 'preview' | 'done';
|
||||
|
||||
interface Props {
|
||||
onBack: () => void;
|
||||
onOpenMovementLinks?: () => void;
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
@@ -29,7 +30,7 @@ const ROLE_OPTIONS = [
|
||||
'source_url',
|
||||
] as const;
|
||||
|
||||
export default function InfluencesPage({ onBack }: Props) {
|
||||
export default function InfluencesPage({ onBack, onOpenMovementLinks }: Props) {
|
||||
const { t } = useTranslation('influences');
|
||||
const [tab, setTab] = useState<Tab>('list');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -335,6 +336,11 @@ export default function InfluencesPage({ onBack }: Props) {
|
||||
{t('back')}
|
||||
</button>
|
||||
<h1>{t('title')}</h1>
|
||||
{onOpenMovementLinks && (
|
||||
<button type="button" className="influences-back" onClick={onOpenMovementLinks}>
|
||||
{t('openMovementLinks')}
|
||||
</button>
|
||||
)}
|
||||
<nav className="influences-tabs">
|
||||
<button type="button" className={tab === 'list' ? 'active' : ''} onClick={() => setTab('list')}>
|
||||
{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 { 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). */
|
||||
export const MOVEMENT_PAINTINGS_PER_HALL = 55;
|
||||
@@ -69,7 +70,7 @@ function frameOuterW(w: number, reviewed: boolean) {
|
||||
return w + matBorder * 2 + rail;
|
||||
}
|
||||
|
||||
function layoutRow(paintings: Painting[], span: number) {
|
||||
function layoutRow(paintings: Painting[], span: number, endPad = WALL_PADDING) {
|
||||
const count = paintings.length;
|
||||
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]));
|
||||
if (i < count - 1) total += FRAME_GAP;
|
||||
}
|
||||
if (total <= span - WALL_PADDING) break;
|
||||
if (total <= span - endPad) break;
|
||||
frameW -= 0.015;
|
||||
}
|
||||
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 });
|
||||
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[]) {
|
||||
@@ -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(
|
||||
paintings: Painting[],
|
||||
span: number,
|
||||
side: 'left' | 'right',
|
||||
halfW: number,
|
||||
inset: number
|
||||
inset: number,
|
||||
details?: MovementDetailKind
|
||||
): FrameSlot[] {
|
||||
if (paintings.length === 0) return [];
|
||||
const { slots: rowSlots } = layoutRow(paintings, span);
|
||||
const y = EYE_HEIGHT;
|
||||
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 {
|
||||
const halfD = span / 2;
|
||||
const keepOuts = sideColumnKeepOutIntervals(details, span, halfD);
|
||||
|
||||
const mapSlot = (alongWall: number, s: { maxW: number; maxH: number }): FrameSlot => ({
|
||||
maxW: s.maxW,
|
||||
maxH: s.maxH,
|
||||
rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2,
|
||||
@@ -163,8 +244,38 @@ function layoutSideSlots(
|
||||
side === 'left'
|
||||
? ([-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. */
|
||||
@@ -225,12 +336,11 @@ function layoutBackSlots(
|
||||
export function buildMovementHallLayout(
|
||||
paintings: Painting[],
|
||||
hallIndex: number,
|
||||
hallCount: number
|
||||
hallCount: number,
|
||||
details?: MovementDetailKind
|
||||
): MovementHallLayout {
|
||||
const { left, back, right } = distributeAcrossWalls(paintings);
|
||||
const leftSpan = layoutRow(left, MIN_HALL_SIZE);
|
||||
const rightSpan = layoutRow(right, MIN_HALL_SIZE);
|
||||
const depth = Math.max(MIN_HALL_SIZE, leftSpan.spanNeeded, rightSpan.spanNeeded);
|
||||
const depth = depthForSideHang(left, right, details);
|
||||
|
||||
// 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.
|
||||
@@ -261,13 +371,13 @@ export function buildMovementHallLayout(
|
||||
side: 'left',
|
||||
label: left.length > 0 ? `Wing ${hallIndex + 1} · Left wall` : '',
|
||||
paintings: left,
|
||||
slots: layoutSideSlots(left, depth, 'left', halfW, inset),
|
||||
slots: layoutSideSlots(left, depth, 'left', halfW, inset, details),
|
||||
},
|
||||
{
|
||||
side: 'right',
|
||||
label: right.length > 0 ? `Wing ${hallIndex + 1} · Right wall` : '',
|
||||
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);
|
||||
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][] {
|
||||
@@ -336,15 +449,20 @@ export function computeSideWallWindows(
|
||||
const specs: GalleryWindowSpec[] = [];
|
||||
const windowY = 3.15;
|
||||
const minGap = tmpl.width + 0.6;
|
||||
const columnKeepOuts = sideColumnKeepOutIntervals(interiorStyle.details, layout.depth, halfD);
|
||||
|
||||
for (const side of ['left', 'right'] as const) {
|
||||
const seg = layout.segments.find((s) => s.side === side);
|
||||
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);
|
||||
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 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": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "node --test server/__tests__/**/*.test.js",
|
||||
"dev:web": "node scripts/dev-web.js",
|
||||
"dev:server": "nodemon server/index.js",
|
||||
"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/translations', translationRoutes);
|
||||
app.use('/api/influences', influenceRoutes);
|
||||
app.use('/api/movement-links', require('./routes/movement-links'));
|
||||
app.use('/api/tours', tourRoutes);
|
||||
app.use(
|
||||
'/images',
|
||||
|
||||
@@ -18,6 +18,8 @@ const INCREMENTAL_MIGRATIONS = [
|
||||
'migrate-tours.sql',
|
||||
'migrate-curator-notes.sql',
|
||||
'migrate-user-roles.sql',
|
||||
'migrate-movement-links.sql',
|
||||
'migrate-movement-link-delete-votes.sql',
|
||||
];
|
||||
|
||||
const BOOTSTRAP_ADMIN_PERMISSIONS = [
|
||||
@@ -80,6 +82,15 @@ async function migrate() {
|
||||
console.log('Bootstrapping curator account (if needed) …');
|
||||
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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||