Author SHA1 Message Date
Danila KhodjaefandClaude Opus 5 72fe45fa52 Document the test suite in the command and setup docs.
`package.json` has had a `test` script since the interior-polish work landed,
and 12 files under `server/__tests__/` now guard hall geometry, clearance,
brightness, window styles and the movement-links validators. Nothing outside
`data-and-images.md` said the suite existed, and no document said how to run
it — despite Bible.md requiring tests to be written before the code.

FAC.md gains a Tests section between Verify and Build frontend: the command,
what the suite covers by area, and the two properties that make it cheap to
run — no test framework (Node's built-in runner) and no database or server,
since the tests read the client's style and geometry modules from disk.

setup.md lists `npm test` first in the Run table and notes on the Node
prerequisite that the script depends on `node --test` expanding `**` itself.
README.md gets the command beside the dev-server ones, and the interiors
section of data-and-images.md now points at the suite that enforces its rules.

Docs only; no source changes. Verified by running the suite (15 suites, 41
tests, all passing) and by confirming from PowerShell — which does not expand
globs — that the runner does its own pattern matching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 22:04:59 +03:00
Danila KhodjaefandCursor 9f96b3835d Add seven painting images and matching hall thumbnails.
Covers Picabia, Miró, Vlaminck, Cézanne, Renoir, van Gogh, and Kandinsky works newly on disk.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:08:00 +03:00
Danila KhodjaefandCursor 13f6f088b6 Polish movement hall interiors: clear doors and bay columns, tone floors and windows.
Document hang keep-outs and period-module fixes; leave columns fixed while packing frames into gaps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:02:10 +03:00
Danila KhodjaefandCursor 1b760faf0d Add curator Movement links config with proof URLs and admin-gated delete votes.
Persist lineage in PostgreSQL, seed documented edges, and keep timeline/tree consumers API-backed with a static fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 14:37:20 +03:00
Danila Khodjaef 2b4cdc5a9f Merge branch 'movement-hall-interiors' 2026-09-02 14:13:28 +03:00
Danila KhodjaefandCursor 7300091bb6 Cross-link timeline and UI standards docs; record completed layout work in Plans.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 13:42:46 +03:00
74 changed files with 3242 additions and 363 deletions

No files matched your search

+21 -1
View File
@@ -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).
+32
View File
@@ -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`.
+29 -4
View File
@@ -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 |
+5
View File
@@ -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)
+11 -7
View File
@@ -40,7 +40,8 @@ Gallery/
│ │ ├── 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
@@ -56,6 +57,7 @@ Gallery/
│ │ ├── 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
@@ -63,7 +65,7 @@ Gallery/
│ │ ├── i18n/ # react-i18next bootstrap
│ │ ├── 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
@@ -263,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 |
@@ -296,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)
@@ -352,9 +354,11 @@ 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` |
| 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 |
@@ -403,7 +407,7 @@ It defaults to **1**, but most halls now set it: Gothic uses **0.26**, Byzantine
**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). Classical / neoclassical details use **engaged corner pilasters** flush to the walls (`MovementHallDetails.tsx`).
**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.
@@ -459,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).
+32 -15
View File
@@ -263,18 +263,23 @@ 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)
@@ -287,31 +292,43 @@ Every movement is built from the architecture of **its own period** — no two m
| Module | Movements |
|--------|-----------|
| `MovementHallDetails.tsx` (reference implementations) | `gothic` — bay-spaced compound piers, vault springers, transverse ribs · `byzantine` — porphyry colonnettes, basket capitals, revetment dado, hanging polycandela |
| `hall-details/classical.tsx` | `roman` (Tuscan half-columns, dentil entablature, Pompeian socle) · `quattrocento` (pietra serena pilasters, blind arcade, tondi) · `cinquecento` (paired fluted Corinthian pilasters, Pantheon coffers) |
| `hall-details/courtly.tsx` | `flemish` (linenfold wainscot, beam-and-corbel ceiling) · `mannerist` (herm pilasters, banded rustication, broken cornice) · `baroque` (solomonic corner columns, modillions, velvet field panels) · `rococo` (boiserie, rocaille cartouches, painted cove) · `neoclassical` (fluted Ionic order, meander frieze) |
| `hall-details/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` · `surrealist` · `loft` · `white-cube` |
| `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 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:
`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 1.45 m of a wall centre (doorways)
- 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 brightness
### 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 this together, and they **multiply**:
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.
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.
### 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)
+1
View File
@@ -35,6 +35,7 @@ Timeline **layout** chrome and chart hints are also localised in `home.json`:
| `captionTreeFlow` | Tree of Art chart |
Shareable layout URLs (`?layout=tree` etc.) are language-independent; captions follow the active locale.
---
## Setup (dev)
+17
View File
@@ -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)
+5 -3
View File
@@ -102,6 +102,8 @@ All constants sit at the top of the two files and are safe to tune:
| `FULL_VIEW_WIDTH_SHARE`, `ZOOM_SPREAD_EXPONENT`, `MAX_FIT_BOOST` | `MovementTree.tsx` | crown spread vs. zoom |
| `MIN_LIMB_RISE_PX`, `MIN_JUNCTION_RISE_PX`, `MAX_THICKNESS_OF_LENGTH` | `MovementTree.tsx` | crown legibility at full zoom-out |
New lineage edges only need adding to
[`client/src/data/movement-lineage.ts`](../client/src/data/movement-lineage.ts) — the tree
picks up parents, grafts, thickness and spacing from there automatically.
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.
+6 -3
View File
@@ -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` |
+8
View File
@@ -86,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
+102
View File
@@ -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 }>;
+34 -5
View File
@@ -61,11 +61,13 @@ function WindowFrame({
return (
<group>
{/* Outer frame */}
<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>
{/* 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>
);
}
+2 -2
View File
@@ -3,7 +3,7 @@ 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';
@@ -269,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;
@@ -1,6 +1,7 @@
import { useMemo } from 'react';
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,
@@ -48,12 +49,7 @@ function GothicDetails({ width, depth, halfW, halfD, trim }: Omit<Props, 'style'
// 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;
@@ -1,7 +1,7 @@
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { ArtMovement } from '../types';
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
import { getMovementLineage } from '../data/movement-lineage';
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
import { vividMovementColor } from '../utils/movementColor';
import './VerticalMovementBands.css';
@@ -59,7 +59,7 @@ function buildLineageParentMap(
): Map<number, number[]> {
const parents = new Map<number, number[]>();
const visibleIds = new Set(visible.map((m) => m.id));
for (const [parentName, childName] of MOVEMENT_LINEAGE) {
for (const [parentName, childName] of getMovementLineage()) {
const parentId = nameToId.get(parentName);
const childId = nameToId.get(childName);
if (parentId == null || childId == null) continue;
+2 -2
View File
@@ -2177,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);
@@ -204,22 +204,22 @@ export function QuattrocentoDetails({ room, trim, windows }: PeriodProps) {
* Roman palazzo — High Renaissance.
*
* Bramante's cinquecento order: paired fluted Corinthian pilasters on a wide
* bay, a full marble entablature, panelled dado, and a rosetted coffer field
* modelled on the Pantheon soffit.
* 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 = '#efe9dd';
const grey = shade(marble, -0.16);
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.44} metalness={0.1} />
<WallBand wall={wall} room={room} y={1.2} height={0.1} proud={0.09} color={marble} roughness={0.38} metalness={0.12} />
<WallBand wall={wall} room={room} y={0.07} height={0.14} proud={0.1} color={shade(grey, -0.18)} roughness={0.4} metalness={0.12} />
<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>
))}
@@ -235,11 +235,11 @@ export function CinquecentoDetails({ room, trim }: PeriodProps) {
</group>
<mesh position={[0, 3.66, 0]}>
<cylinderGeometry args={[0.15, 0.1, 0.24, 12]} />
<meshStandardMaterial color={marble} roughness={0.42} metalness={0.1} />
<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.4} metalness={0.11} />
<meshStandardMaterial color={shade(marble, 0.06)} roughness={0.46} metalness={0.09} />
</mesh>
</group>
))
@@ -250,21 +250,21 @@ export function CinquecentoDetails({ room, trim }: PeriodProps) {
{/* Full entablature on the pilastered walls, cornice all round */}
{(['left', 'right'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={3.92} height={0.1} proud={0.15} color={marble} roughness={0.42} metalness={0.1} />
<WallBand wall={wall} room={room} y={4.02} height={0.1} proud={0.12} color={grey} roughness={0.46} metalness={0.1} />
<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.4} metalness={0.28} />
<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={2.6}
panelColor="#f2ede3"
ribColor="#ded5c4"
ribWidth={0.2}
ribDrop={0.18}
rosette={trim}
cell={3.4}
panelColor="#e6e0d4"
ribColor="#cfc4b2"
ribWidth={0.22}
ribDrop={0.16}
/>
</group>
);
+93 -57
View File
@@ -15,6 +15,7 @@ import {
useCorners,
useRepeat,
useWallClearance,
DOOR_CLEAR_HALF,
type PeriodProps,
} from './geometry';
@@ -32,11 +33,11 @@ export function FlemishDetails({ room, trim }: PeriodProps) {
const folds = useRepeat(room.depth, 0.55, 34);
const backFolds = useRepeat(room.width, 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 */}
{/* 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} />
@@ -98,17 +99,26 @@ export function FlemishDetails({ room, trim }: PeriodProps) {
/**
* Mannerist gallery — Mannerism.
*
* The order used against itself: herm pilasters that taper the wrong way,
* banded rustication blocking the shafts, oversized scroll consoles, and a
* cornice deliberately broken over each bay.
* 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.4, 7);
const stone = '#ddc9ae';
const dark = shade(stone, -0.34);
const bands = [0.55, 1.55, 2.35, 3.05];
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>
@@ -120,30 +130,36 @@ export function ManneristDetails({ room, trim, windows }: PeriodProps) {
{/* Herm pilasters — wider at the shoulder than at the foot */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.05), 0, z]}>
<mesh position={[0, 1.9, 0]}>
<boxGeometry args={[0.09, 3.8, 0.3]} />
<meshStandardMaterial color={stone} roughness={0.78} metalness={0.05} />
</mesh>
{/* Rustication blocks interrupting the shaft */}
{bands.map((y) => (
<mesh key={y} position={[side * 0.03, y, 0]}>
<boxGeometry args={[0.14, 0.3, 0.44]} />
<meshStandardMaterial color={shade(stone, -0.16)} roughness={0.86} metalness={0.04} />
{([-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>
))}
{/* Scroll console instead of a capital */}
<mesh position={[0, 3.86, 0]} rotation={[0, Math.PI / 2, 0]}>
<torusGeometry args={[0.14, 0.055, 8, 16, Math.PI * 1.5]} />
<meshStandardMaterial color={shade(stone, 0.12)} roughness={0.66} metalness={0.08} />
</mesh>
<mesh position={[0, 4.03, 0]}>
<boxGeometry args={[0.16, 0.12, 0.46]} />
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.24} />
</mesh>
</group>
))}
<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>
))}
@@ -162,19 +178,19 @@ export function ManneristDetails({ room, trim, windows }: PeriodProps) {
return (
<group key={i}>
{([-1, 1] as const)
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, 0.24))
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, 0.28))
.map((side) => (
<group key={side} position={[side * (room.halfW - 0.07), 3.6, mid]} rotation={[0, Math.PI / 2, 0]}>
<mesh>
<sphereGeometry args={[0.17, 12, 8]} />
<meshStandardMaterial color={shade(stone, 0.16)} roughness={0.66} metalness={0.08} />
</mesh>
<mesh position={[0, 0, 0.04]}>
<torusGeometry args={[0.19, 0.03, 6, 18]} />
<meshStandardMaterial color={trim} roughness={0.44} metalness={0.4} />
</mesh>
</group>
))}
<group 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>
);
})}
@@ -186,8 +202,8 @@ export function ManneristDetails({ room, trim, windows }: PeriodProps) {
* Baroque state gallery — Baroque.
*
* Twisted solomonic half-columns at the corners, gilt-framed velvet field
* panels between the bays, a modillion cornice, and a crystal-and-gilt
* chandelier over the centre of the room.
* 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);
@@ -195,28 +211,48 @@ export function BaroqueStateDetails({ room, trim, windows }: PeriodProps) {
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-edged velvet field panels between the bays, clear of the windows */}
{/* 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 w = Math.min(1.5, Math.max(0.7, (bays[i + 1] - z) * 0.4));
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, w / 2 + 0.1))
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, halfClear))
.map((side) => (
<group key={side} position={[side * (room.halfW - 0.055), 3.2, mid]}>
<group key={side} position={[side * (room.halfW - 0.05), 3.15, mid]}>
{/* Velvet field — faces the room */}
<mesh>
<boxGeometry args={[0.03, 1.0, w]} />
<meshStandardMaterial color="#4a1220" roughness={0.9} metalness={0.04} />
<boxGeometry args={[0.028, fieldH, fieldW]} />
<meshStandardMaterial color={velvet} roughness={0.92} metalness={0.03} />
</mesh>
<mesh position={[-side * 0.015, 0, 0]}>
<boxGeometry args={[0.04, 1.12, w + 0.12]} />
<meshStandardMaterial color={gilt} roughness={0.3} metalness={0.7} emissive="#3a2808" emissiveIntensity={0.06} />
{/* 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>
))}
@@ -405,8 +441,8 @@ export function NeoclassicalDetails({ room, trim }: PeriodProps) {
room={room}
y={4.06}
cell={2.9}
panelColor="#f7f6f2"
ribColor="#e2ded4"
panelColor="#e6e2da"
ribColor="#cfc8bc"
ribWidth={0.18}
ribDrop={0.13}
/>
+11 -5
View File
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import type { GalleryWindowSpec } from '../../data/movement-interior-styles';
import { bayPositions } from '../../utils/sideColumnKeepOuts';
/**
* Shared geometry budget for every period interior.
@@ -27,6 +28,13 @@ export const WALL_INNER = 0.09;
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';
@@ -57,13 +65,11 @@ export function shade(hex: string, amount: number): string {
/** Bay rhythm along the depth axis — the spacing that drives piers and beams. */
export function useBayZ(depth: number, halfD: number, spacing = 4.2, max = 9): number[] {
return useMemo(() => {
const count = Math.max(2, Math.min(max, Math.floor(depth / spacing)));
const step = depth / (count + 1);
return Array.from({ length: count }, (_, i) => -halfD + step * (i + 1));
}, [depth, halfD, spacing, max]);
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.
+129 -127
View File
@@ -11,6 +11,7 @@ import {
useBayZ,
useCorners,
useRepeat,
useWallClearance,
type PeriodProps,
} from './geometry';
@@ -409,81 +410,103 @@ export function ConstructivistDetails({ room, trim }: PeriodProps) {
}
/**
* Dada salon — Dada.
* Dada cabaret — Dada.
*
* A bourgeois room being dismantled: cornice fragments that stop and restart at
* different heights, a picture rail hung visibly out of level, and a service
* pipe left running across the wall where the moulding should be.
* 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 }: PeriodProps) {
const fragments = useMemo(
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, y: 4.04, span: 0.3, proud: 0.14 },
{ wall: 'left' as const, y: 3.78, span: 0.18, proud: 0.1 },
{ wall: 'right' as const, y: 4.12, span: 0.44, proud: 0.16 },
{ wall: 'right' as const, y: 3.9, span: 0.22, proud: 0.09 },
{ wall: 'back' as const, y: 4.06, span: 0.35, proud: 0.13 },
],
{ 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,
[]
);
const plaster = '#e6dac4';
const pipe = '#6a6259';
return (
<group>
{/* Cornice, in pieces */}
{fragments.map((f, i) => (
<WallBand
key={i}
wall={f.wall}
room={room}
y={f.y}
height={0.13}
proud={f.proud}
span={f.span}
color={i % 2 === 0 ? plaster : shade(plaster, -0.18)}
roughness={0.76}
metalness={0.05}
/>
))}
{/* 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>
);
})}
{/* Picture rail, visibly out of level */}
{[-1, 1].map((side) => (
<mesh key={side} position={[side * (room.halfW - 0.07), 2.62, 0]} rotation={[side * 0.035, 0, 0]}>
<boxGeometry args={[0.1, 0.08, room.depth * 0.86]} />
<meshStandardMaterial color={trim} roughness={0.6} metalness={0.18} />
{/* 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>
))}
{/* Service pipe run, with a clumsy elbow into the ceiling */}
<mesh position={[-room.halfW + 0.16, 3.34, 0]} rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[0.06, 0.06, room.depth * 0.8, 10]} />
<meshStandardMaterial color={pipe} roughness={0.64} metalness={0.4} />
</mesh>
<mesh position={[-room.halfW + 0.16, 3.7, room.depth * 0.4]}>
<cylinderGeometry args={[0.06, 0.06, 0.75, 10]} />
<meshStandardMaterial color={pipe} roughness={0.64} metalness={0.4} />
</mesh>
{/* 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} />
))}
{/* Skirting that changes its mind halfway along */}
<WallBand wall="left" room={room} y={0.11} height={0.22} proud={0.08} span={0.55} color={plaster} roughness={0.78} metalness={0.04} />
<WallBand wall="right" room={room} y={0.15} height={0.3} proud={0.1} span={0.8} color={shade(plaster, -0.26)} roughness={0.78} metalness={0.04} />
<WallBand wall="back" room={room} y={0.11} height={0.22} proud={0.08} color={plaster} roughness={0.78} metalness={0.04} />
{/* 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} />
<HangingFixture
position={[room.halfW * 0.32, 3.9, room.halfD * 0.2]}
dropLength={0.7}
radius={0.2}
arms={3}
metalColor={trim}
flameColor="#fff0cc"
glowColor="#ffb45a"
light
lightColor="#ffd0a0"
lightIntensity={0.7}
lightDistance={8}
/>
{/* 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>
);
}
@@ -492,8 +515,8 @@ export function DadaDetails({ room, trim }: PeriodProps) {
* Surrealist interior — Surrealism.
*
* The 1938 Exposition Internationale: sacks slung under the ceiling, drapery
* pulled across the cornice, and a door standing in a corner where no door can
* lead anywhere. The room is furnished as an image, not as a gallery.
* 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);
@@ -528,22 +551,6 @@ export function SurrealistDetails({ room, trim }: PeriodProps) {
))
)}
{/* A door that leads nowhere, standing in the corner pocket */}
<group position={[-room.halfW + 0.34, 0, -room.halfD + 0.34]} rotation={[0, Math.PI / 4, 0]}>
<mesh position={[0, 1.05, 0]}>
<boxGeometry args={[0.72, 2.1, 0.07]} />
<meshStandardMaterial color="#4a3a2a" roughness={0.78} metalness={0.06} />
</mesh>
<mesh position={[0, 1.12, 0]}>
<boxGeometry args={[0.84, 2.24, 0.05]} />
<meshStandardMaterial color={shade('#4a3a2a', -0.3)} roughness={0.8} metalness={0.05} />
</mesh>
<mesh position={[0.26, 1.02, 0.06]}>
<sphereGeometry args={[0.05, 10, 10]} />
<meshStandardMaterial color={trim} roughness={0.34} metalness={0.66} />
</mesh>
</group>
<CorniceRing room={room} y={4.14} height={0.09} proud={0.12} color={trim} roughness={0.62} metalness={0.2} />
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.11} height={0.22} proud={0.08} color="#2e2822" roughness={0.82} metalness={0.05} />
@@ -566,83 +573,78 @@ export function SurrealistDetails({ room, trim }: PeriodProps) {
}
/**
* NYC loft — Abstract Expressionism.
* Painter loft — Abstract Expressionism.
*
* A Tenth Street cast-iron building: fluted iron columns on their corbelled
* caps, a pressed-tin ceiling field, and the sprinkler main running the length
* of the room. Industrial fabric left exactly as the painters found it.
* 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, 1.7, 13);
const iron = '#5c5852';
const joists = useBayZ(room.depth, room.halfD, 2.0, 11);
const timber = '#8a7358';
const timberLight = '#c4a882';
return (
<group>
{/* Cast-iron columns with corbelled caps */}
{/* Square timber posts — studio structure, not iron bars */}
{corners.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 0.12, 0]}>
<cylinderGeometry args={[0.2, 0.24, 0.24, 12]} />
<meshStandardMaterial color={shade(iron, -0.2)} roughness={0.62} metalness={0.42} />
<mesh 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]}>
<cylinderGeometry args={[0.11, 0.13, 3.6, 12]} />
<meshStandardMaterial color={iron} roughness={0.56} metalness={0.48} />
<boxGeometry args={[0.22, 3.7, 0.22]} />
<meshStandardMaterial color={timber} roughness={0.78} metalness={0.05} />
</mesh>
<mesh position={[0, 3.92, 0]}>
<cylinderGeometry args={[0.22, 0.13, 0.2, 12]} />
<meshStandardMaterial color={iron} roughness={0.54} metalness={0.5} />
</mesh>
<mesh position={[0, 4.06, 0]}>
<boxGeometry args={[0.4, 0.14, 0.4]} />
<meshStandardMaterial color={shade(iron, 0.1)} roughness={0.54} metalness={0.5} />
<mesh 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>
))}
{/* Pressed-tin ceiling field on close-set joists */}
{/* Open timber joists — painter studio ceiling */}
<CeilingBeams
room={room}
positions={joists}
axis="x"
width={0.08}
height={0.09}
y={4.05}
color="#b4b0a6"
roughness={0.48}
metalness={0.42}
width={0.12}
height={0.18}
y={4.0}
color={timberLight}
roughness={0.78}
metalness={0.04}
/>
<CeilingBeams
room={room}
positions={[-room.width * 0.24, 0, room.width * 0.24]}
positions={[-room.width * 0.22, room.width * 0.22]}
axis="z"
width={0.14}
height={0.16}
y={3.96}
color={iron}
roughness={0.6}
metalness={0.4}
width={0.16}
height={0.2}
y={3.88}
color={timber}
roughness={0.8}
metalness={0.04}
/>
{/* Sprinkler main with drops */}
<mesh position={[0, 3.78, 0]} rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[0.055, 0.055, room.depth * 0.92, 10]} />
<meshStandardMaterial color="#7a3f2c" roughness={0.66} metalness={0.4} />
</mesh>
{joists
.filter((_, i) => i % 4 === 0)
.map((z, i) => (
<mesh key={i} position={[0, 3.68, z]}>
<cylinderGeometry args={[0.02, 0.025, 0.16, 6]} />
<meshStandardMaterial color="#8a4a34" roughness={0.6} metalness={0.45} />
</mesh>
))}
{/* Steel skirting angle */}
{/* Soft wood skirting */}
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.09} height={0.18} proud={0.08} color={trim} roughness={0.56} metalness={0.44} />
<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>
);
}
@@ -1,5 +1,14 @@
import { useMemo } from 'react';
import { CEILING_Y, EMBED, WALL_INNER, shade, type RoomDims, type WallId } from './geometry';
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 {
@@ -32,9 +41,40 @@ export function WallBand({
span?: number;
}) {
const alongZ = wall === 'left' || wall === 'right';
const len = (alongZ ? room.depth : room.width) * span;
const thick = proud + EMBED * 2;
const off = WALL_INNER + proud / 2 - EMBED;
const 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'
@@ -48,13 +88,7 @@ export function WallBand({
return (
<mesh position={pos}>
<boxGeometry args={args} />
<meshStandardMaterial
color={color}
roughness={roughness}
metalness={metalness}
emissive={emissive}
emissiveIntensity={emissiveIntensity}
/>
<meshStandardMaterial {...matProps} />
</mesh>
);
}
+60 -46
View File
@@ -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';
@@ -132,7 +136,7 @@ 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: '#8e8980', ceiling: '#9c9994', floor: '#c8b898', trim: '#a89878' },
{ wall: '#8e8980', ceiling: '#9c9994', floor: '#a08868', trim: '#a89878' },
{
details: 'roman',
// Lit like the basilica: shared lights held back so the walls read as
@@ -211,7 +215,7 @@ 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: '#89847e', ceiling: '#96948f', floor: '#c89070', trim: '#c9a227' },
{ wall: '#89847e', ceiling: '#96948f', floor: '#a07050', trim: '#c9a227' },
{
details: 'quattrocento',
// Lit like the basilica: shared lights held back so the walls read as
@@ -228,9 +232,9 @@ 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: '#918f8a', wallSide: '#8f8980', ceiling: '#9c9994', 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: 'cinquecento',
// Lit like the basilica: shared lights held back so the walls read as
@@ -241,7 +245,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
{ 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 }
),
}
),
@@ -271,7 +275,7 @@ 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: '#98816b', ceiling: '#a79456', floor: '#dce8e0', trim: '#b8860b' },
{ wall: '#98816b', ceiling: '#a79456', floor: '#7a8a82', trim: '#b8860b' },
{
details: 'mannerist',
// Lit like the basilica: shared lights held back so the walls read as
@@ -290,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',
@@ -312,7 +316,7 @@ 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: '#918a91', ceiling: '#9b9995', floor: '#c8a882', trim: '#d4af37' },
{ wall: '#918a91', ceiling: '#9b9995', floor: '#9a7858', trim: '#d4af37' },
{
details: 'rococo',
// Lit like the basilica: shared lights held back so the walls read as
@@ -330,22 +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: '#908f8d', ceiling: '#9e9e9e', 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(
@@ -388,7 +392,7 @@ 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: '#8f8c87', ceiling: '#9e9e9e', floor: '#c8a882', trim: '#c9a96e' },
{ wall: '#8f8c87', ceiling: '#9e9e9e', floor: '#9a7858', trim: '#c9a96e' },
{
details: 'north-light',
// Lit like the basilica: shared lights held back so the walls read as
@@ -415,7 +419,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
// 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 }
),
}
@@ -443,7 +447,7 @@ 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: '#8f8c87', ceiling: '#a0946b', floor: '#d8d0c4', trim: '#6a9868' },
{ wall: '#8f8c87', ceiling: '#a0946b', floor: '#9a9084', trim: '#6a9868' },
{
details: 'art-nouveau',
// Lit like the basilica: shared lights held back so the walls read as
@@ -471,7 +475,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
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 }
),
}
@@ -488,14 +492,14 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
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: '#8c8a85', ceiling: '#999999', floor: '#9a8870', trim: '#888888' },
{
@@ -505,7 +509,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
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 }
),
}
@@ -513,7 +517,7 @@ 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: '#858585', ceiling: '#7a7a7a', floor: '#888880', trim: '#606060' },
{
@@ -525,8 +529,9 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
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'],
@@ -537,7 +542,7 @@ 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: '#a8a8a8', ceiling: '#b3b3b3', floor: '#e0e0e0', trim: '#cccccc' },
{ wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#8e8e8e', trim: '#cccccc' },
{
details: 'suprematist',
// Lit like the basilica: shared lights held back so the walls read as
@@ -560,7 +565,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Constructivist space',
'Glass block · concrete · angular daylight',
{ wall: 'concrete-block', ceiling: 'concrete-raw', floor: 'concrete-polished' },
{ wall: '#858585', ceiling: '#7a7a7a', floor: '#c0c0c0', trim: '#808080' },
{ wall: '#858585', ceiling: '#7a7a7a', floor: '#888888', trim: '#808080' },
{
details: 'constructivist',
// Lit like the basilica: shared lights held back so the walls read as
@@ -578,21 +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: '#8b8478', wallSide: '#8a5040', ceiling: '#97948d', 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: '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(
@@ -616,21 +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: '#858585', 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: 'loft',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#303030',
ambient: 0.65,
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(
@@ -638,7 +652,7 @@ 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: '#a8a8a8', ceiling: '#b3b3b3', floor: '#d8d8d8', trim: '#cccccc' },
{ wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#909090', trim: '#cccccc' },
{
details: 'white-cube',
// Lit like the basilica: shared lights held back so the walls read as
@@ -650,7 +664,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
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,
+37 -10
View File
@@ -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);
}
+5 -1
View File
@@ -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,
+1
View File
@@ -15,6 +15,7 @@
"checkup": "Checkup",
"translations": "Translations",
"influences": "Influences",
"movementLinks": "Movement links",
"tours": "Tours",
"toursEditor": "Tour editor",
"users": "Users",
+1
View File
@@ -1,6 +1,7 @@
{
"title": "Influence links",
"back": "← Back to gallery",
"openMovementLinks": "Movement links →",
"tabList": "List",
"tabImport": "Import",
"tabGraph": "Graph",
+52
View File
@@ -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."
}
+1 -1
View File
@@ -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.",
+1
View File
@@ -15,6 +15,7 @@
"checkup": "Проверка",
"translations": "Переводы",
"influences": "Влияния",
"movementLinks": "Связи движений",
"tours": "Экскурсии",
"toursEditor": "Редактор экскурсий",
"users": "Пользователи",
+1
View File
@@ -1,6 +1,7 @@
{
"title": "Связи влияния",
"back": "← Назад в галерею",
"openMovementLinks": "Связи движений →",
"tabList": "Список",
"tabImport": "Импорт",
"tabGraph": "Граф",
+52
View File
@@ -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": "Удалить эту связь движений? Причины голосов кураторов будут записаны в журнал аудита."
}
+1 -1
View File
@@ -25,7 +25,7 @@
"perm_checkup": "Проверка",
"perm_curator_notes": "Заметки куратора",
"perm_translations": "Переводы",
"perm_influences": "Влияния",
"perm_influences": "Влияния и связи движений",
"perm_tours": "Экскурсии",
"perm_users": "Пользователи",
"adminAllPerms": "У администраторов все права автоматически.",
+4
View File
@@ -116,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,
+50 -3
View File
@@ -12,6 +12,7 @@ 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';
@@ -28,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,
@@ -55,6 +58,7 @@ type View =
| { type: 'checkup' }
| { type: 'translations' }
| { type: 'influences' }
| { type: 'movement-links' }
| { type: 'tours' }
| { type: 'users' }
| { type: 'audit' }
@@ -69,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,
@@ -259,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 });
@@ -363,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') {
@@ -381,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'
@@ -413,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');
@@ -1128,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>
@@ -1145,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} />
@@ -1273,6 +1312,14 @@ export default function HomePage() {
<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"
+7 -1
View File
@@ -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')}
+265
View File
@@ -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;
}
}
+439
View File
@@ -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>
);
}
+150 -32
View File
@@ -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,31 +141,141 @@ 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 {
maxW: s.maxW,
maxH: s.maxH,
rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2,
side,
position:
side === 'left'
? ([-halfW + inset + WALL_STANDOFF, y, alongWall] as [number, number, number])
: ([halfW - inset - WALL_STANDOFF, y, alongWall] as [number, number, number]),
};
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,
side,
position:
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 outerW = frameOuterW(s.maxW, false);
return [s.position[2] - outerW / 2 - 0.45, s.position[2] + outerW / 2 + 0.45];
});
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);
+2 -2
View File
@@ -34,7 +34,7 @@
* widest subtree first, so the main trunk keeps x = 0 (canvas centre).
*/
import type { ArtMovement } from '../types';
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
import { getMovementLineage } from '../data/movement-lineage';
/** All values are tree-space pixels; the renderer scales them to the canvas. */
export const TREE_LAYOUT = {
@@ -103,7 +103,7 @@ function influenceCount(m: ArtMovement): number {
function buildParentMap(movements: ArtMovement[]): Map<number, number[]> {
const nameToId = new Map(movements.map((m) => [m.name, m.id]));
const parents = new Map<number, number[]>();
for (const [parentName, childName] of MOVEMENT_LINEAGE) {
for (const [parentName, childName] of getMovementLineage()) {
const parentId = nameToId.get(parentName);
const childId = nameToId.get(childName);
if (parentId == null || childId == null || parentId === childId) continue;
+58
View File
@@ -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]
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 871 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+16
View File
@@ -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);
+37
View File
@@ -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();
+1
View File
@@ -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);
});
});
+34
View File
@@ -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\)/);
});
});
+54
View File
@@ -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*>/);
});
});
+57
View File
@@ -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);
});
});
+1
View File
@@ -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',
+11
View File
@@ -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.');
}
+581
View File
@@ -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,
};
+152
View File
@@ -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;