Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2a256132c | ||
|
|
8e52415ea8 | ||
|
|
3fe88ffcfa | ||
|
|
1c9fa20191 | ||
|
|
44092d102b | ||
|
|
33b8ae5a5f |
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
description: Gallery UI interaction and component standards for client screens
|
||||||
|
globs: client/src/**/*.{tsx,css}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# UI interaction standards
|
||||||
|
|
||||||
|
Follow [Documentation/ui-interaction-and-component-standards.md](Documentation/ui-interaction-and-component-standards.md) (MUST / SHOULD / MAY). Do not invent a second nav, toast, or CSS framework.
|
||||||
|
|
||||||
|
## MUST
|
||||||
|
|
||||||
|
- Drill-down via `HomePage` `View` state. Nested screens get an explicit Back; do not rely on the browser Back button.
|
||||||
|
- **Back to Timeline** → `goToTimelineHome()` (clears hall, resets year range). **Back to Gallery** → same hall session (camera/wing kept).
|
||||||
|
- Hide actions the user cannot `can()`; do not leave buttons that 403.
|
||||||
|
- Visitor chrome strings in `locales/{en,ru}`. Movement colours via `utils/movementColor.ts`. Timeline zoom/pan via `utils/timelineView.ts`.
|
||||||
|
- Loading: `GalleryLoadingMarker`. Errors/empty states: visible copy, not a blank canvas.
|
||||||
|
- Modals: `role="dialog"`, visible close, Escape. No nested modals. Destructive actions confirm first.
|
||||||
|
- Colocated CSS; museum gold `#c9a96e` / navy `/ Georgia`. No MUI/Ant Design.
|
||||||
|
|
||||||
|
## SHOULD
|
||||||
|
|
||||||
|
- Reuse `CatalogSearchBar`, `CuratorLoginModal`, `ArtistFilterModal`, `PaintingLightbox`, `DebugSearchResultsModal`, `DebugUploadButton`.
|
||||||
|
- Curator filters above the table; expensive work behind an explicit button.
|
||||||
|
- Update the standards doc when changing Back, search, layout query, or loading behaviour.
|
||||||
+29
-15
@@ -6,8 +6,8 @@ Interactive virtual museum spanning art history: zoomable timeline with event gu
|
|||||||
|
|
||||||
The app is organised as a **drill-down hierarchy**:
|
The app is organised as a **drill-down hierarchy**:
|
||||||
|
|
||||||
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries; classic left→right or vertical bottom→up layout (header link).
|
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries; classic left→right, vertical bottom→up, or **tree** bottom→up layout (header links).
|
||||||
2. **Movement flow** — art movements as SVG streams on the same year axis; documented predecessor→successor branches (classic); portrait thumbnails along each stream.
|
2. **Movement flow** — art movements as SVG streams on the same year axis; documented predecessor→successor branches (classic); portrait thumbnails along each stream. The tree layout redraws the same lineage as a growing tree — see [movement-tree.md](movement-tree.md).
|
||||||
3. **3D gallery** — one personal hall per artist, a **movement gallery** (click a movement name → artist filter → hall), or a **guided tour** hall (timeline → **Tours**): period-themed or tour wings of up to ~55 works, U-shaped hang (left → end wall → right).
|
3. **3D gallery** — one personal hall per artist, a **movement gallery** (click a movement name → artist filter → hall), or a **guided tour** hall (timeline → **Tours**): period-themed or tour wings of up to ~55 works, U-shaped hang (left → end wall → right).
|
||||||
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **curator notes** and **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography.
|
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **curator notes** and **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography.
|
||||||
5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**).
|
5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**).
|
||||||
@@ -37,18 +37,19 @@ Gallery/
|
|||||||
│ │ ├── components/GalleryLoadingMarker.tsx # Loading spinner overlay/banner (catalog, portraits, halls)
|
│ │ ├── components/GalleryLoadingMarker.tsx # Loading spinner overlay/banner (catalog, portraits, halls)
|
||||||
│ │ ├── components/GalleryWindows.tsx # Side-wall daylight windows (movement)
|
│ │ ├── components/GalleryWindows.tsx # Side-wall daylight windows (movement)
|
||||||
│ │ ├── components/HallPassage.tsx # Open archway between movement wings
|
│ │ ├── components/HallPassage.tsx # Open archway between movement wings
|
||||||
│ │ ├── components/MovementHallDetails.tsx # Period architectural details
|
│ │ ├── components/MovementHallDetails.tsx # Period architecture dispatcher (Gothic + Byzantine reference)
|
||||||
|
│ │ ├── components/hall-details/ # One period interior per movement + shared primitives
|
||||||
│ │ ├── data/movement-interior-styles.ts # Per-movement interior themes
|
│ │ ├── data/movement-interior-styles.ts # Per-movement interior themes
|
||||||
│ │ ├── utils/movementHallLayout.ts # Wing split + window gap placement
|
│ │ ├── utils/movementHallLayout.ts # Wing split + window gap placement
|
||||||
│ │ ├── utils/galleryProceduralTextures.ts # Hi-res wall/floor textures
|
│ │ ├── utils/galleryProceduralTextures.ts # Hi-res wall/floor textures
|
||||||
│ │ ├── components/PaintingDetail.tsx # Detail view + debug panel
|
│ │ ├── components/PaintingDetail.tsx # Detail view + debug panel
|
||||||
│ │ ├── components/ArtistBio.tsx # Biography + portrait debug panel
|
│ │ ├── components/ArtistBio.tsx # Biography + portrait debug panel
|
||||||
│ │ ├── components/DebugSearchResultsModal.tsx # “More” search picker (20 results)
|
│ │ ├── components/DebugSearchResultsModal.tsx # “More” search picker (20 results)
|
||||||
│ │ ├── components/Timeline.tsx # Era bar, year ticks, event markers
|
|
||||||
│ │ ├── components/Timeline.tsx # Classic horizontal era bar
|
│ │ ├── components/Timeline.tsx # Classic horizontal era bar
|
||||||
│ │ ├── components/VerticalTimeline.tsx # Bottom-up vertical era rail
|
│ │ ├── components/VerticalTimeline.tsx # Bottom-up vertical era rail
|
||||||
│ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches)
|
│ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches)
|
||||||
│ │ ├── components/VerticalMovementBands.tsx # Bottom-up movement streams
|
│ │ ├── components/VerticalMovementBands.tsx # Bottom-up movement streams
|
||||||
|
│ │ ├── components/MovementTree.tsx # Bottom-up movement tree (alternative start page)
|
||||||
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow
|
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow
|
||||||
│ │ ├── components/CatalogSearchBar.tsx # Timeline header catalog search
|
│ │ ├── components/CatalogSearchBar.tsx # Timeline header catalog search
|
||||||
│ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail
|
│ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail
|
||||||
@@ -60,12 +61,14 @@ Gallery/
|
|||||||
│ │ ├── pages/AuditPage.tsx # Admin curator activity reports
|
│ │ ├── pages/AuditPage.tsx # Admin curator activity reports
|
||||||
│ │ ├── components/ToursPopup.tsx # Public published-tours modal
|
│ │ ├── components/ToursPopup.tsx # Public published-tours modal
|
||||||
│ │ ├── i18n/ # react-i18next bootstrap
|
│ │ ├── i18n/ # react-i18next bootstrap
|
||||||
│ │ └── locales/{en,ru}/ # UI chrome strings
|
│ │ ├── locales/{en,ru}/ # UI chrome strings (incl. timeline layout + captions)
|
||||||
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
|
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
|
||||||
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
|
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
|
||||||
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor
|
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor
|
||||||
│ │ ├── utils/debugMode.ts # Debug mode + “Show more” localStorage prefs
|
│ │ ├── utils/debugMode.ts # Debug mode + “Show more” localStorage prefs
|
||||||
│ │ └── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
|
│ │ ├── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
|
||||||
|
│ │ ├── utils/movementColor.ts # Shared vivid/shade hex helpers for all movement charts
|
||||||
|
│ │ └── utils/movementTree.ts # View-independent Tree of Art layout engine
|
||||||
│ └── dist/ # Production build (served by API when present)
|
│ └── dist/ # Production build (served by API when present)
|
||||||
├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools
|
├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools
|
||||||
│ ├── seed-wikipedia.js
|
│ ├── seed-wikipedia.js
|
||||||
@@ -173,20 +176,25 @@ Implementation: `goToTimelineHome()` in `HomePage.tsx` — do not use the browse
|
|||||||
|
|
||||||
## Timeline and movement flow
|
## Timeline and movement flow
|
||||||
|
|
||||||
The home page shows two linked views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`). A header link switches between layouts:
|
The home page shows linked era + movement views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`). Header links switch among three layouts; the active layout is also deep-linked:
|
||||||
|
|
||||||
| Layout | Era rail | Movement flow | Time direction |
|
| Layout | Era rail | Movement flow | Time direction | URL |
|
||||||
|--------|----------|---------------|----------------|
|
|--------|----------|---------------|----------------|-----|
|
||||||
| **Classic** (default) | `Timeline.tsx` (top bar) | `MovementBands.tsx` | Left → right |
|
| **Classic** (default) | `Timeline.tsx` (top bar) | `MovementBands.tsx` | Left → right | omit or `?layout=classic` |
|
||||||
| **Vertical** | `VerticalTimeline.tsx` (left rail) | `VerticalMovementBands.tsx` (streams + lineage branches; no portraits) | Bottom → top |
|
| **Vertical** | `VerticalTimeline.tsx` (left rail) | `VerticalMovementBands.tsx` (streams + lineage; no portraits) | Bottom → top | `?layout=vertical` |
|
||||||
|
| **Tree of art** | `VerticalTimeline.tsx` (left rail) | `MovementTree.tsx` (lineage as a growing tree; no portraits) | Bottom → top | `?layout=tree` |
|
||||||
|
|
||||||
|
`HomePage` reads `?layout=` once on load and calls `history.replaceState` when the user switches. Classic clears the param so the default URL stays clean. Alias `horizontal` maps to classic.
|
||||||
|
|
||||||
| View | Component | Purpose |
|
| View | Component | Purpose |
|
||||||
|------|-----------|---------|
|
|------|-----------|---------|
|
||||||
| Era bar / rail | `Timeline.tsx` / `VerticalTimeline.tsx` | Historical eras, major event markers, click-to-zoom |
|
| Era bar / rail | `Timeline.tsx` / `VerticalTimeline.tsx` | Historical eras, major event markers, click-to-zoom |
|
||||||
| Movement flow | `MovementBands.tsx` / `VerticalMovementBands.tsx` | Streams per movement, artist portraits (classic also draws lineage branches) |
|
| Movement flow | `MovementBands.tsx` / `VerticalMovementBands.tsx` / `MovementTree.tsx` | Streams or tree limbs per movement; classic also places artist portraits |
|
||||||
|
| Stream colours | `utils/movementColor.ts` | Shared `vividMovementColor` / `shadeMovementColor` for all three charts |
|
||||||
|
|
||||||
Both layouts share zoom/pan behaviour via `client/src/utils/timelineView.ts` (`zoomTimelineView`, `panTimelineView`, `chooseTimelineTickInterval`, `createViewChangeScheduler`). The home page uses a **fixed viewport** (`100vh`). Classic stacks timeline above movements; vertical places the year rail beside the flow (`home-timeline-stack-vertical`).
|
Hint captions under each rail/chart (`captionClassicTimeline`, `captionClassicFlow`, `captionVerticalTimeline`, `captionVerticalFlow`, `captionTreeFlow`) live in `locales/{en,ru}/home.json` and follow the EN|RU toggle.
|
||||||
|
|
||||||
|
All three layouts share zoom/pan behaviour via `client/src/utils/timelineView.ts` (`zoomTimelineView`, `panTimelineView`, `chooseTimelineTickInterval`, `createViewChangeScheduler`). The home page uses a **fixed viewport** (`100vh`). Classic stacks timeline above movements; vertical and tree place the year rail beside the flow (`home-timeline-stack-vertical`). The tree layout keeps its horizontal geometry fixed across zoom — rules in [movement-tree.md](movement-tree.md).
|
||||||
### Catalog search (timeline header)
|
### Catalog search (timeline header)
|
||||||
|
|
||||||
`CatalogSearchBar.tsx` calls `GET /api/search?q=…` (public, no login). The dropdown is stacked above the timeline (`z-index` on `.site-header`) so results are not hidden by movement bands.
|
`CatalogSearchBar.tsx` calls `GET /api/search?q=…` (public, no login). The dropdown is stacked above the timeline (`z-index` on `.site-header`) so results are not hidden by movement bands.
|
||||||
@@ -346,13 +354,14 @@ Enter from the home page by clicking a **movement name** on the movement flow. F
|
|||||||
| Wall order | Same U-shaped hang as artist halls (left → end → right) |
|
| Wall order | Same U-shaped hang as artist halls (left → end → right) |
|
||||||
| Frame captions | **Year · artist** label below each frame |
|
| Frame captions | **Year · artist** label below each frame |
|
||||||
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, Byzantine basilica, NYC loft, white cube, etc.). Style keys use a legacy id map (`DB_ID_TO_STYLE_KEY`: gallery ids **1–26** → authored keys **27–52**, with Northern/High Renaissance swapped) so Byzantine gets the basilica, not Gothic stone |
|
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, Byzantine basilica, NYC loft, white cube, etc.). Style keys use a legacy id map (`DB_ID_TO_STYLE_KEY`: gallery ids **1–26** → authored keys **27–52**, with Northern/High Renaissance swapped) so Byzantine gets the basilica, not Gothic stone |
|
||||||
|
| Period architecture | Each style names a `details` kind (26 of them) that selects one component under `components/hall-details/` — the hall is built from the architecture of the **same period as the movement** (Roman entablature, Brunelleschi arcade, Flemish beam ceiling, Horta whiplash brackets, Rodchenko lattice, 1960s troffer grid). Gothic and Byzantine remain the reference implementations in `MovementHallDetails.tsx`; the geometry budget every module works to is documented in `hall-details/geometry.ts` |
|
||||||
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco, **marble revetment** (framed book-matched panels), **Cosmatesque paving** (`marble-opus-sectile`), **coffered timber** ceilings, **blind-arcaded ashlar** (`gothic-ashlar`) and **ribbed vaulting** (`gothic-vault`) — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes |
|
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco, **marble revetment** (framed book-matched panels), **Cosmatesque paving** (`marble-opus-sectile`), **coffered timber** ceilings, **blind-arcaded ashlar** (`gothic-ashlar`) and **ribbed vaulting** (`gothic-vault`) — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes |
|
||||||
| Architecture in texture | Period halls that need arcades, vaults, or panelling draw that **relief into the texture** rather than adding geometry: `gothic-ashlar` paints a full storey (plinth → blind arcade → string course → triforium → cornice) into one tile whose `metersPerRepeat` equals `WALL_HEIGHT`, so it maps **once vertically** onto the wall; `gothic-vault` paints a quadripartite bay with tiercerons and a boss. Light/dark banding plus a high `normalStrengthFor()` makes it read as carved stone, keeping the mesh and light budget flat |
|
| Architecture in texture | Period halls that need arcades, vaults, or panelling draw that **relief into the texture** rather than adding geometry: `gothic-ashlar` paints a full storey (plinth → blind arcade → string course → triforium → cornice) into one tile whose `metersPerRepeat` equals `WALL_HEIGHT`, so it maps **once vertically** onto the wall; `gothic-vault` paints a quadripartite bay with tiercerons and a boss. Light/dark banding plus a high `normalStrengthFor()` makes it read as carved stone, keeping the mesh and light budget flat |
|
||||||
| Door-flanking panels | Wall segments beside exit doors and passages take the hall's own wall texture (`GalleryWall` accepts `kind`/`tint`) instead of rendering as flat single-colour blocks next to textured walls |
|
| Door-flanking panels | Wall segments beside exit doors and passages take the hall's own wall texture (`GalleryWall` accepts `kind`/`tint`) instead of rendering as flat single-colour blocks next to textured walls |
|
||||||
| Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue |
|
| Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue |
|
||||||
| Windows | **Side walls only** — placed in gaps between frames when possible; if a wall is packed, high **clerestory** windows are still added so the hall keeps daylight (`computeSideWallWindows`). Styles are drawn in `GalleryWindows.tsx`; `gothic-lancet` builds a two-centred arch head from chords with a glazed spandrel, mullions, and transoms |
|
| Windows | **Side walls only** — placed in gaps between frames when possible; if a wall is packed, high **clerestory** windows are still added so the hall keeps daylight (`computeSideWallWindows`). Styles are drawn in `GalleryWindows.tsx`; `gothic-lancet` builds a two-centred arch head from chords with a glazed spandrel, mullions, and transoms |
|
||||||
| Lighting | Shared hall lights only (ambient / hemisphere / directional + capped ceiling track spots + one fill per window). See **light budget** below |
|
| Lighting | Shared hall lights only (ambient / hemisphere / directional + capped ceiling track spots + one fill per window). See **light budget** below |
|
||||||
| Period details | `MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** (never freestanding mid-hall or proud corner shafts that cover frames); `byzantine` adds engaged porphyry colonnettes with basket capitals, a marble revetment dado, and hanging brass polycandela; `gothic` adds bay-spaced compound piers with vault springers, transverse ribs arching across the nave, and a moulded string course — all flush to the side walls |
|
| Period details | `MovementHallDetails.tsx` dispatches to one module per movement. `byzantine` adds engaged porphyry colonnettes with basket capitals, a marble revetment dado, and hanging brass polycandela; `gothic` adds bay-spaced compound piers with vault springers, transverse ribs arching across the nave, and a moulded string course. Every other movement follows the same rules in `components/hall-details/`: orders and piers stay **flush to the side walls** (≤ 0.17 m proud), freestanding masses only in the corner pockets, and no more than two added lights per hall — see `hall-details/geometry.ts` |
|
||||||
| Back wall | Single wing: solid display wall. Multi-wing: **Exit double doors** → **Wing navigator** or **Exit to Timeline** |
|
| Back wall | Single wing: solid display wall. Multi-wing: **Exit double doors** → **Wing navigator** or **Exit to Timeline** |
|
||||||
| Front / entrance wall | Single wing: **Exit double doors** (leave the way you entered). Multi-wing: **“Next wing →”** archway when a later wing exists |
|
| Front / entrance wall | Single wing: **Exit double doors** (leave the way you entered). Multi-wing: **“Next wing →”** archway when a later wing exists |
|
||||||
| Influence lamps | Same golden upside-down emissive fixtures as artist halls when `has_influence_links` is true (shared rail: 40 cm above the tallest frame in the wing) |
|
| Influence lamps | Same golden upside-down emissive fixtures as artist halls when `has_influence_links` is true (shared rail: 40 cm above the tallest frame in the wing) |
|
||||||
@@ -388,7 +397,11 @@ Full guide: [tours.md](tours.md).
|
|||||||
|
|
||||||
**Light budget (critical):** Three.js forward shading cannot compile `MeshStandardMaterial` with dozens of dynamic lights. Large wings used to add **one `spotLight` per painting** (plus influence lamps), which pushed the scene to ~100–200 lights — walls, floor, and ceiling then rendered as a black void while painting canvases (`MeshBasicMaterial`) still showed. Halls therefore use **shared lighting only**: ambient / hemisphere / directional fill, a small set of ceiling track spots (`GalleryTrackLights`, capped), and **one** window spot each. Painting canvases stay unlit (`MeshBasicMaterial`); frames and influence fixtures use **emissive** accents instead of per-frame lights. Intensities are scaled for three.js physical lights (post-r155).
|
**Light budget (critical):** Three.js forward shading cannot compile `MeshStandardMaterial` with dozens of dynamic lights. Large wings used to add **one `spotLight` per painting** (plus influence lamps), which pushed the scene to ~100–200 lights — walls, floor, and ceiling then rendered as a black void while painting canvases (`MeshBasicMaterial`) still showed. Halls therefore use **shared lighting only**: ambient / hemisphere / directional fill, a small set of ceiling track spots (`GalleryTrackLights`, capped), and **one** window spot each. Painting canvases stay unlit (`MeshBasicMaterial`); frames and influence fixtures use **emissive** accents instead of per-frame lights. Intensities are scaled for three.js physical lights (post-r155).
|
||||||
|
|
||||||
Shared lights carry a **brightness floor** so dark period styles never render as a black void. A style that is *meant* to be dim (a basilica lit by window shafts, not flooded) sets **`lightScale`** in `movement-interior-styles.ts` — it multiplies the scene ambient/hemisphere/directional/fill lights, the hall point lights, the ceiling track spots, and the HDR `environmentIntensity`. It defaults to **1**, so only styles that opt in are affected. Window fill lights are deliberately **not** scaled, so the daylight shafts still read against the darker room.
|
Shared lights carry a **brightness floor** so dark period styles never render as a black void. That floor is high enough to blow out a pale wall, so **`lightScale`** in `movement-interior-styles.ts` is the knob that holds a hall back — it multiplies the scene ambient/hemisphere/directional/fill lights, the hall point lights, the ceiling track spots, and the HDR `environmentIntensity`. Window fill lights are deliberately **not** scaled, so daylight shafts still read against the darker room.
|
||||||
|
|
||||||
|
It defaults to **1**, but most halls now set it: Gothic uses **0.26**, Byzantine **0.30**, and the 19 formerly pale interiors (Roman through Pop Art) also use **0.30**. Only the five halls that are already dark by albedo — Symbolist, Expressionist, Surrealist, Baroque, Romantic — stay at 1, since scaling their lights would crush walls that already sit at 0.15–0.27 luminance.
|
||||||
|
|
||||||
|
**Wall brightness is albedo × `lightScale`.** Gallery walls are authored well below white for that reason: the reworked interiors sit around **0.50–0.56** wall luminance (0.66 for the two white-cube halls), landing at an effective 0.14–0.20 — the range Gothic and Byzantine established. Reach for `lightScale` before darkening a tint further; the two multiply, and dimming the hall dims the paintings with it.
|
||||||
|
|
||||||
**Period architecture:** Freestanding columns must not sit in the walkway or in the corner pocket in front of frames (hang margin is only ~0.7 m from each wall end). 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). Classical / neoclassical details use **engaged corner pilasters** flush to the walls (`MovementHallDetails.tsx`).
|
||||||
|
|
||||||
@@ -540,3 +553,4 @@ See [API.md](API.md#authentication) and [data-and-images.md](data-and-images.md#
|
|||||||
| [tours.md](tours.md) | Guided tours — editor, public popup, 3D tour hall |
|
| [tours.md](tours.md) | Guided tours — editor, public popup, 3D tour hall |
|
||||||
| [i18n-russian.md](i18n-russian.md) | Russian UI + entity_translations |
|
| [i18n-russian.md](i18n-russian.md) | Russian UI + entity_translations |
|
||||||
| [data-and-images.md](data-and-images.md) | Image pipeline and seeding |
|
| [data-and-images.md](data-and-images.md) | Image pipeline and seeding |
|
||||||
|
| [ui-interaction-and-component-standards.md](ui-interaction-and-component-standards.md) | UI interaction, navigation, and component standards |
|
||||||
|
|||||||
@@ -278,9 +278,38 @@ To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the cli
|
|||||||
|
|
||||||
## Movement gallery interiors (frontend)
|
## Movement gallery interiors (frontend)
|
||||||
|
|
||||||
Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (pilasters, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts` (colour maps in sRGB, normal maps in linear/`NoColorSpace`). Period mesh details live in `client/src/components/MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** so shafts never cover frames; Byzantine adds porphyry colonnettes / polycandela; Gothic (`details: 'gothic'`) adds bay-spaced compound piers with vault springers and transverse ribs flush to the side walls.
|
Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and the `details` kind that selects its architecture. Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts` (colour maps in sRGB, normal maps in linear/`NoColorSpace`).
|
||||||
|
|
||||||
Gothic surfaces include **`gothic-ashlar`** (full-storey blind arcade mapped once vertically via `metersPerRepeat === WALL_HEIGHT`), **`gothic-vault`** (quadripartite rib bay), and **`gothic-stone-floor`**. Door-flanking wall panels use the hall wall texture via `GalleryWall` `kind`/`tint` so they match the textured walls. Deliberately dim naves set **`lightScale`** (Byzantine / Gothic) so shared lights stay soft while window shafts stay bright.
|
### Period architecture per movement
|
||||||
|
|
||||||
|
Every movement is built from the architecture of **its own period** — no two movements share an interior. `client/src/components/MovementHallDetails.tsx` dispatches on `style.details` (`MovementDetailKind`, 26 values) to one component:
|
||||||
|
|
||||||
|
| Module | Movements |
|
||||||
|
|--------|-----------|
|
||||||
|
| `MovementHallDetails.tsx` (reference implementations) | `gothic` — bay-spaced compound piers, vault springers, transverse ribs · `byzantine` — porphyry colonnettes, basket capitals, revetment dado, hanging polycandela |
|
||||||
|
| `hall-details/classical.tsx` | `roman` (Tuscan half-columns, dentil entablature, Pompeian socle) · `quattrocento` (pietra serena pilasters, blind arcade, tondi) · `cinquecento` (paired fluted Corinthian pilasters, Pantheon coffers) |
|
||||||
|
| `hall-details/courtly.tsx` | `flemish` (linenfold wainscot, beam-and-corbel ceiling) · `mannerist` (herm pilasters, banded rustication, broken cornice) · `baroque` (solomonic corner columns, modillions, velvet field panels) · `rococo` (boiserie, rocaille cartouches, painted cove) · `neoclassical` (fluted Ionic order, meander frieze) |
|
||||||
|
| `hall-details/nineteenth.tsx` | `gothic-revival` · `bourgeois` · `north-light` · `paris-atelier` · `symbolist` · `art-nouveau` |
|
||||||
|
| `hall-details/modernism.tsx` | `fauvist` · `expressionist` · `cubist-atelier` · `futurist` · `suprematist` · `constructivist` · `dada` · `surrealist` · `loft` · `white-cube` |
|
||||||
|
|
||||||
|
`hall-details/primitives.tsx` holds the wall-flush building blocks (`WallBand`, `CorniceRing`, `FlutedShaft`, capitals, `CofferedCeiling`, `HangingFixture`, `WallSconce`); `hall-details/geometry.ts` holds the shared hooks and the **geometry budget** every module has to obey, because frames hang 0.23 m proud of the wall face with a 0.7 m clear margin at each wall end:
|
||||||
|
|
||||||
|
- relief below the frame line stays within **0.11 m** proud; friezes and cornices above 2.6 m within **0.17 m**
|
||||||
|
- freestanding masses only inside the **0.34 m corner pocket**; nothing under 2.6 m within 1.45 m of a wall centre (doorways)
|
||||||
|
- the end wall carries the hall title between **3.4 m and 3.9 m** — keep it clear
|
||||||
|
- side-wall windows are placed at runtime between 2.4 m and 3.9 m, so panels, arches and roundels up there are filtered through **`useWallClearance`** (the hall passes its computed windows into `MovementHallDetails`)
|
||||||
|
- at most **two added lights per hall**, so hall materials stay inside the WebGL light limits the shared track lighting already budgets for
|
||||||
|
|
||||||
|
Gothic surfaces include **`gothic-ashlar`** (full-storey blind arcade mapped once vertically via `metersPerRepeat === WALL_HEIGHT`), **`gothic-vault`** (quadripartite rib bay), and **`gothic-stone-floor`**. Door-flanking wall panels use the hall wall texture via `GalleryWall` `kind`/`tint` so they match the textured walls.
|
||||||
|
|
||||||
|
### Wall brightness
|
||||||
|
|
||||||
|
Halls are lit by a shared rig with a high brightness floor, so a pale wall tint clips to flat white and loses its texture. Two settings control this together, and they **multiply**:
|
||||||
|
|
||||||
|
- **tints** — gallery walls are authored around **0.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.
|
||||||
|
|
||||||
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ Timeline header: **EN | RU** toggle (`LocaleSwitcher`).
|
|||||||
- Passes `?locale=ru` on catalog API requests
|
- Passes `?locale=ru` on catalog API requests
|
||||||
- Refetches bootstrap catalog when locale changes
|
- Refetches bootstrap catalog when locale changes
|
||||||
|
|
||||||
|
Timeline **layout** chrome and chart hints are also localised in `home.json`:
|
||||||
|
|
||||||
|
| Key | Where |
|
||||||
|
|-----|--------|
|
||||||
|
| `layoutHorizontal` / `layoutVertical` / `layoutTree` | Header layout switch |
|
||||||
|
| `captionClassicTimeline` / `captionClassicFlow` | Classic era bar + movement streams |
|
||||||
|
| `captionVerticalTimeline` / `captionVerticalFlow` | Vertical rail + streams |
|
||||||
|
| `captionTreeFlow` | Tree of Art chart |
|
||||||
|
|
||||||
|
Shareable layout URLs (`?layout=tree` etc.) are language-independent; captions follow the active locale.
|
||||||
---
|
---
|
||||||
|
|
||||||
## Setup (dev)
|
## Setup (dev)
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Tree of Art — alternative start page
|
||||||
|
|
||||||
|
An alternative landing layout for the timeline: the year axis runs **bottom → top**
|
||||||
|
and the art movements are drawn as a **growing tree** instead of parallel streams.
|
||||||
|
Reached from the **🌳 Tree of art** link at the top-left of every timeline page
|
||||||
|
(`layoutTree`); the classic and vertical layouts stay untouched and are one click away.
|
||||||
|
Shareable URL: `?layout=tree` (also `vertical` / omit or `classic` for the other layouts).
|
||||||
|
|
||||||
|
| | Classic | Vertical | **Tree** |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Component | [`MovementBands`](../client/src/components/MovementBands.tsx) | [`VerticalMovementBands`](../client/src/components/VerticalMovementBands.tsx) | [`MovementTree`](../client/src/components/MovementTree.tsx) |
|
||||||
|
| Time axis | left → right | bottom → top | bottom → top |
|
||||||
|
| Layout | temporal lanes | centre-out lanes | spanning tree |
|
||||||
|
| Recomputed on zoom | yes | yes | **no — structure is fixed** |
|
||||||
|
| Artist portraits | yes | no | no |
|
||||||
|
|
||||||
|
Both bottom-up layouts share the [`VerticalTimeline`](../client/src/components/VerticalTimeline.tsx)
|
||||||
|
axis, so eras, event marks, zoom and pan behave identically across them.
|
||||||
|
|
||||||
|
## Layout rules
|
||||||
|
|
||||||
|
The geometry splits in two: [`utils/movementTree.ts`](../client/src/utils/movementTree.ts)
|
||||||
|
decides the **shape of the tree** (horizontal, view-independent), and `MovementTree`
|
||||||
|
maps that shape onto the **current year window** (vertical) each frame.
|
||||||
|
|
||||||
|
### Structure — `buildMovementTree()`
|
||||||
|
|
||||||
|
1. **Time grows upward.** Oldest movements at the bottom, newest at the crown.
|
||||||
|
Y is a plain `year → pixel` mapping; the layout engine never touches it.
|
||||||
|
2. **One trunk, at the centre.** `MOVEMENT_LINEAGE` is a DAG, so it is reduced to a
|
||||||
|
spanning tree: each movement keeps its **most immediate predecessor** (the parent
|
||||||
|
with the latest start year that still precedes it) as its structural parent.
|
||||||
|
Ranking by start year first makes cycles impossible by construction.
|
||||||
|
3. **Extra parents become grafts.** The predecessors that lost step 2 are still drawn —
|
||||||
|
as thin, low-opacity limbs behind the tree — so `Post-Impressionism → Cubism`
|
||||||
|
survives even though Cubism hangs structurally off Fauvism.
|
||||||
|
4. **Children split the parent's slot.** Each node reserves a slot as wide as its whole
|
||||||
|
subtree (`max(own limb, Σ children)`); children are packed side by side and centred
|
||||||
|
on the parent. A single-child chain inherits the parent's x exactly — the trunk stays
|
||||||
|
straight until it forks, forks spread symmetrically, and later generations land
|
||||||
|
further from the centre.
|
||||||
|
5. **Leonardo's rule for thickness.** A limb is as thick as the limbs it carries:
|
||||||
|
`base² = own² + Σ child.base²`. The trunk is the thickest thing on screen and every
|
||||||
|
branch tapers as it rises and sheds children. A movement's *own* thickness comes from
|
||||||
|
its `influence_link_count`.
|
||||||
|
6. **Branches lean outward** across their own lifespan, by at most the slack left inside
|
||||||
|
their slot — organic, and collision-free by construction.
|
||||||
|
7. **Unlinked movements are saplings.** A movement with no lineage edge is its own root;
|
||||||
|
extra roots are planted alternately right and left of the trunk, widest subtree first,
|
||||||
|
so the main trunk keeps x = 0 (canvas centre). Roots get a small root flare.
|
||||||
|
|
||||||
|
Because the structure is built from the **whole catalogue**, zooming never reshuffles the
|
||||||
|
tree — you keep your bearings, unlike the lane-packed layouts which re-pack on every view
|
||||||
|
change.
|
||||||
|
|
||||||
|
### Rendering — `MovementTree`
|
||||||
|
|
||||||
|
8. **Spread follows zoom, not the canvas.** 90 % of the catalogue lives in the last 15 %
|
||||||
|
of the time axis, so a tree stretched to full width with all of history in view is one
|
||||||
|
long trunk under a flat bar. The whole-history view draws the tree at
|
||||||
|
`FULL_VIEW_WIDTH_SHARE` (52 %) of the available width, and each zoom step fans the
|
||||||
|
crown out (`(totalSpan / visibleSpan) ^ 0.45`, capped at `MAX_FIT_BOOST`). The chart
|
||||||
|
grows as you walk up it. Whatever is on screen is always clamped to fit the canvas.
|
||||||
|
9. **Readability floors, never date changes.** A 30-year movement is ~8 px tall with 2 900
|
||||||
|
years in view. So a limb is drawn at least `MIN_LIMB_RISE_PX` long, a junction climbs at
|
||||||
|
least `MIN_JUNCTION_RISE_PX` before it spreads sideways, and **a limb is never thicker
|
||||||
|
than 55 % of its own length**. Positions still come from real years; only the drawn
|
||||||
|
length and thickness have a floor, and the junction slides down the parent limb (never
|
||||||
|
off it) to find its rise.
|
||||||
|
10. **Ribbons, not strokes.** Limbs are filled ribbons sampled along a cubic and offset
|
||||||
|
along the curve *normal*, so a junction stays solid even when a zoomed-out view
|
||||||
|
squeezes it almost flat. Shading runs dark → colour → dark across each limb for a
|
||||||
|
rounded, woody read.
|
||||||
|
11. **Greedy label declutter.** Every visible movement asks for a name; closest to the
|
||||||
|
trunk wins, and names that would collide with a placed one — or fall off the canvas —
|
||||||
|
stay hidden until you zoom in on them.
|
||||||
|
12. **Hover lights the descent line.** Hovering a movement brightens its whole path back
|
||||||
|
to the root (grafts included) and dims the rest — the fastest way to read "where did
|
||||||
|
this come from".
|
||||||
|
|
||||||
|
Clicking any limb or label opens the movement's artist picker and then its 3D movement
|
||||||
|
gallery, exactly as the other two layouts do.
|
||||||
|
|
||||||
|
## Colours and captions
|
||||||
|
|
||||||
|
- Limb fill/shading uses [`utils/movementColor.ts`](../client/src/utils/movementColor.ts)
|
||||||
|
(`vividMovementColor`, `shadeMovementColor`) — the same helpers as the classic and
|
||||||
|
vertical charts. Malformed catalogue hex falls back to the input string; values longer
|
||||||
|
than six digits keep the first six (`#rrggbbaa` → `#rrggbb`).
|
||||||
|
- The chart hint under the canvas is `captionTreeFlow` in
|
||||||
|
`locales/{en,ru}/home.json` (same pattern as the other layouts).
|
||||||
|
|
||||||
|
## Tuning
|
||||||
|
|
||||||
|
All constants sit at the top of the two files and are safe to tune:
|
||||||
|
|
||||||
|
| Constant | File | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| `LEAF_SLOT_PX`, `LIMB_GAP_PX` | `movementTree.ts` | how far apart branches sit |
|
||||||
|
| `MIN_LIMB_PX`, `MAX_OWN_LIMB_PX`, `MAX_TRUNK_PX` | `movementTree.ts` | thickness range |
|
||||||
|
| `LEAN_SLACK`, `MAX_LEAN_PX` | `movementTree.ts` | how much limbs bend outward |
|
||||||
|
| `FULL_VIEW_WIDTH_SHARE`, `ZOOM_SPREAD_EXPONENT`, `MAX_FIT_BOOST` | `MovementTree.tsx` | crown spread vs. zoom |
|
||||||
|
| `MIN_LIMB_RISE_PX`, `MIN_JUNCTION_RISE_PX`, `MAX_THICKNESS_OF_LENGTH` | `MovementTree.tsx` | crown legibility at full zoom-out |
|
||||||
|
|
||||||
|
New lineage edges only need adding to
|
||||||
|
[`client/src/data/movement-lineage.ts`](../client/src/data/movement-lineage.ts) — the tree
|
||||||
|
picks up parents, grafts, thickness and spacing from there automatically.
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
# UI Interaction and Component Standards
|
||||||
|
|
||||||
|
**Subject:** Virtual Art Gallery — screen and interaction guidance
|
||||||
|
**Applies to:** Public visitor UI, 3D halls, curator tools
|
||||||
|
**Companion rule:** `.cursor/rules/ui-interaction-standards.mdc`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Introduction
|
||||||
|
|
||||||
|
The Gallery is a single React SPA (`HomePage.tsx` view union — no URL router except `?layout=`). Public visitors browse a museum-dark timeline and 3D halls; curators use the same shell for catalog tools (Checkup, Translations, Influences, Tours, Users, Activity). Work lands in the same header, palette, and back-stack, so a new screen that invents its own chrome, confirmations, or loading pattern fragments the experience.
|
||||||
|
|
||||||
|
This document is the shared contract for **how the UI behaves**. Architecture, APIs, and 3D hall construction live in [basics.md](basics.md), [API.md](API.md), and [data-and-images.md](data-and-images.md). Tree geometry lives in [movement-tree.md](movement-tree.md). Locale strings live in [i18n-russian.md](i18n-russian.md).
|
||||||
|
|
||||||
|
### 0.1 Purpose and audience
|
||||||
|
|
||||||
|
- **Purpose:** Define common UI interaction, navigation, and component standards for the Gallery client.
|
||||||
|
- **Primary audience:** Developers (and agents) adding or changing `client/src` UI; anyone writing curator-tool screens.
|
||||||
|
- **Secondary audience:** QA, product, copy/i18n.
|
||||||
|
|
||||||
|
### 0.2 Scope
|
||||||
|
|
||||||
|
- **In scope:**
|
||||||
|
- Navigation and back-stack behaviour
|
||||||
|
- Timeline / search / 3D / detail / curator-tool interaction patterns
|
||||||
|
- Loading, empty, error, and confirmation behaviour
|
||||||
|
- Permission-based UI (`can()` / RBAC)
|
||||||
|
- Shared component and visual-language rules
|
||||||
|
- **Out of scope:**
|
||||||
|
- REST contracts, retries, and image pipeline internals
|
||||||
|
- Three.js hall architecture, textures, and lighting (see [basics.md](basics.md))
|
||||||
|
- Pixel-perfect branding kit (no separate design-system package; follow existing CSS)
|
||||||
|
|
||||||
|
### 0.3 Requirement levels
|
||||||
|
|
||||||
|
- **MUST:** Mandatory for new work and for fixes that touch the same screen.
|
||||||
|
- **SHOULD:** Recommended; deviate only with a short note in the PR or screen section.
|
||||||
|
- **MAY:** Optional pattern when the screen specification calls for it.
|
||||||
|
|
||||||
|
### 0.4 Surface map
|
||||||
|
|
||||||
|
| Surface | Typical components | Visitors | Curators |
|
||||||
|
|---------|--------------------|----------|----------|
|
||||||
|
| Timeline home | `Timeline`, `VerticalTimeline`, `MovementBands`, `VerticalMovementBands`, `MovementTree`, `CatalogSearchBar` | yes | yes |
|
||||||
|
| 3D hall | `VirtualGallery`, wing navigator, exit overlays | yes | yes |
|
||||||
|
| Painting / bio | `PaintingDetail`, `PaintingLightbox`, `ArtistBio`, `PaintingAnnotations` | yes | + debug panel when `can('images')` |
|
||||||
|
| Overlays | `CuratorLoginModal`, `ArtistFilterModal`, `ToursPopup`, `DebugSearchResultsModal` | some | all |
|
||||||
|
| Curator tools | `CheckupPage`, `TranslationsPage`, `InfluencesPage`, `ToursPage`, `UsersPage`, `AuditPage` | no | permission-gated |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Design Goals and Principles
|
||||||
|
|
||||||
|
### 1.1 Problem statement
|
||||||
|
|
||||||
|
- Statement: Visitors and curators share one shell. Inconsistent back labels, ad-hoc modals, English-only captions, and one-off loading/error treatment make the museum feel like several apps glued together.
|
||||||
|
- Scope: All `client/src` screens and overlays.
|
||||||
|
- Rationale: The product is a gallery, not an admin console with a visitor skin. Predictable chrome is part of the exhibit.
|
||||||
|
- Verification: Compare a new screen’s header, back control, loading marker, and locale keys against this document.
|
||||||
|
|
||||||
|
### 1.2 Functional design principles
|
||||||
|
|
||||||
|
- Principle 1: One museum, two roles
|
||||||
|
- Statement: Public browse chrome MUST stay museum-dark (navy / gold / Georgia). Curator tools MAY be denser but MUST reuse the same header back pattern, gold accent, and permission hiding — they MUST NOT look like a separate product.
|
||||||
|
- Rationale: Curators enter from the same timeline; a visual cliff breaks trust.
|
||||||
|
- Verification: Side-by-side with timeline header and Checkup / Tours editor.
|
||||||
|
|
||||||
|
- Principle 2: Predictable drill-down and return
|
||||||
|
- Statement: Navigation MUST follow Timeline → (movement picker) → hall → painting/bio, with an explicit in-app Back that restores the intended session — not the browser history stack.
|
||||||
|
- Rationale: `HomePage` owns view state; the browser Back button is not wired.
|
||||||
|
- Verification: Walk the [basics.md navigation flow](basics.md#user-navigation-flow) and the Back table in §2.4.
|
||||||
|
|
||||||
|
- Principle 3: Clarity of actions and feedback
|
||||||
|
- Statement: Every user-initiated load, save, delete, or failed request MUST show a loading, success, or error state the user can see without opening the console.
|
||||||
|
- Rationale: 3D and image work is slow; silent failure looks like a broken hall.
|
||||||
|
- Verification: Trigger catalog load, hall open, form save, and a failed API call.
|
||||||
|
|
||||||
|
- Principle 4: Minimize cognitive load
|
||||||
|
- Statement: Timeline charts MUST keep zoom/pan/click hints visible. Curator tables MUST put filter/search above the grid. Destructive actions MUST be confirmed.
|
||||||
|
- Rationale: Dense history data and catalog tables are easy to mis-click.
|
||||||
|
- Verification: Hint captions present; filters above tables; delete paths show a confirm.
|
||||||
|
|
||||||
|
- Principle 5: Locale and permission are first-class
|
||||||
|
- Statement: New visitor-facing copy MUST go through `react-i18next` (`locales/{en,ru}`). Actions the user cannot perform MUST be hidden, not disabled-without-explanation.
|
||||||
|
- Rationale: EN/RU is a product requirement; exposing forbidden tools invites errors.
|
||||||
|
- Verification: Toggle EN|RU; log in as a curator without the relevant `can()` and confirm the control is absent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Navigation Principles
|
||||||
|
|
||||||
|
### 2.1 Application chrome (no left module rail)
|
||||||
|
|
||||||
|
The Gallery has **no persistent left navigation menu**. Primary wayfinding is the **timeline header** on home, and an explicit **Back** control on every nested view.
|
||||||
|
|
||||||
|
- Statement: The timeline home MUST keep layout switch, catalog search, locale switcher, and role-appropriate tools in the site header.
|
||||||
|
- Scope: `HomePage` when `view` is `timeline` / `timeline-vertical` / `timeline-tree`.
|
||||||
|
- Rationale: Visitors need search and layout without hunting; curators need tools without leaving the museum frame.
|
||||||
|
- Verification: Header remains usable at 100vh; search dropdown stacks above movement bands (`z-index` on `.site-header`).
|
||||||
|
|
||||||
|
- Statement: Layout switch links MUST stay in the top-left (`.site-layout-switch`). The Tree of Art control SHOULD use `.site-layout-link-feature`.
|
||||||
|
- Scope: Timeline home.
|
||||||
|
- Rationale: Three layouts must stay discoverable; Tree is the featured alternative start page.
|
||||||
|
- Verification: Classic / Vertical / Tree links match [basics.md](basics.md#timeline-and-movement-flow).
|
||||||
|
|
||||||
|
- Statement: Nested views (hall, painting, bio, curator pages) MUST NOT reintroduce a second global nav. They MUST show a single primary Back control in the page header.
|
||||||
|
- Scope: All non-home views.
|
||||||
|
- Rationale: Avoid competing menus; the drill-down is the nav.
|
||||||
|
- Verification: No duplicate “home” plus “modules” rails on curator pages.
|
||||||
|
|
||||||
|
### 2.2 Page hierarchy
|
||||||
|
|
||||||
|
- Statement: Screens MUST stay within this hierarchy (max four levels):
|
||||||
|
|
||||||
|
1. Timeline home (classic / vertical / tree)
|
||||||
|
2. Overlay or picker (artist filter, tours popup, login) **or** curator tool page
|
||||||
|
3. 3D hall (artist / movement / tour)
|
||||||
|
4. Painting detail or artist bio (optional lightbox on top of detail)
|
||||||
|
|
||||||
|
- Scope: All visitor and curator flows.
|
||||||
|
- Rationale: Matches the existing drill-down; deeper stacks become unrecoverable without a router.
|
||||||
|
- Verification: New views are added to the `View` union in `HomePage.tsx` with a defined parent and Back handler.
|
||||||
|
|
||||||
|
- Statement: Curator tools MUST open as siblings of the timeline (replace the home canvas), not as a fifth level under a hall.
|
||||||
|
- Scope: Checkup, Translations, Influences, Tours editor, Users, Activity.
|
||||||
|
- Rationale: Tools operate on the catalog, not on a hall session.
|
||||||
|
- Verification: Opening Checkup from a hall is not required; from timeline header, Back returns to timeline home.
|
||||||
|
|
||||||
|
### 2.3 Location awareness (no breadcrumbs)
|
||||||
|
|
||||||
|
- Statement: The app MUST NOT add a breadcrumb trail unless a future router lands. Until then, the Back label MUST name the destination (`← Back to Timeline`, `← Back to Gallery`, `← Back`).
|
||||||
|
- Scope: All nested views.
|
||||||
|
- Rationale: There is no URL path to reflect; a fake breadcrumb would lie.
|
||||||
|
- Verification: Labels match §2.4; they are i18n keys (`backToTimeline`, `backToGallery`, …).
|
||||||
|
|
||||||
|
- Statement: Timeline layout MUST be deep-linkable via `?layout=classic|vertical|tree` (omit param for classic). Other views MUST NOT pretend to be bookmarkable until a router exists.
|
||||||
|
- Scope: Timeline home.
|
||||||
|
- Rationale: Layout is the one shareable start-page choice; halls and tools are session state.
|
||||||
|
- Verification: Load `?layout=tree`, switch layouts, confirm `history.replaceState` updates the query.
|
||||||
|
|
||||||
|
### 2.4 Back navigation
|
||||||
|
|
||||||
|
| Control | MUST return to |
|
||||||
|
|---------|----------------|
|
||||||
|
| **← Back to Timeline** (hall header, movement Exit to Timeline, painting opened from search) | Home timeline via `goToTimelineHome()` — unmount hall, clear session, reset year window to full catalog bounds |
|
||||||
|
| **← Back to Gallery** (painting from a hall) | Same hall session (camera / wing preserved) |
|
||||||
|
| **← Back** (artist bio) | `returnTo` view (usually the hall that opened bio) |
|
||||||
|
| Curator page Back | Timeline home |
|
||||||
|
|
||||||
|
- Statement: Back MUST be an in-app control. The browser Back button MUST NOT be relied on (it is not wired to `View` state).
|
||||||
|
- Scope: All nested views.
|
||||||
|
- Rationale: `setView` is the router.
|
||||||
|
- Verification: From painting detail, in-app Back restores the hall; browser Back does not need to.
|
||||||
|
|
||||||
|
- Statement: `goToTimelineHome()` MUST be the single implementation for “leave everything and show the timeline.” New exits MUST call it rather than duplicating reset logic.
|
||||||
|
- Scope: Halls, search-opened paintings, curator Back.
|
||||||
|
- Rationale: Year-range reset and session clear must stay consistent.
|
||||||
|
- Verification: After Exit to Timeline, `viewStart`/`viewEnd` equal catalog bounds.
|
||||||
|
|
||||||
|
### 2.5 State preservation
|
||||||
|
|
||||||
|
- Statement: Timeline pan/zoom (`viewStart` / `viewEnd`) MUST persist while the user stays on a timeline layout. Switching classic ↔ vertical ↔ tree MUST keep the same year window.
|
||||||
|
- Scope: Timeline home.
|
||||||
|
- Rationale: Layout is a lens, not a new dataset.
|
||||||
|
- Verification: Zoom, switch to Tree, confirm the year rail range is unchanged.
|
||||||
|
|
||||||
|
- Statement: Returning to timeline via `goToTimelineHome()` MUST reset the year window to full catalog bounds.
|
||||||
|
- Scope: Hall / search / curator exits that call `goToTimelineHome()`.
|
||||||
|
- Rationale: Documented in [basics.md](basics.md#back-navigation); visitors expect a fresh overview, not a leftover zoom.
|
||||||
|
- Verification: Zoom in, enter a hall, Back to Timeline → full span.
|
||||||
|
|
||||||
|
- Statement: Hall camera and wing MUST be preserved across **Back to Gallery** from painting detail. They MUST be discarded on **Back to Timeline**.
|
||||||
|
- Scope: Artist, movement, and tour halls.
|
||||||
|
- Rationale: Inspecting a painting is a detour; leaving the museum is not.
|
||||||
|
- Verification: Move in the hall, open a painting, Back to Gallery → same viewpoint.
|
||||||
|
|
||||||
|
- Statement: Catalog search input MAY clear when the dropdown closes. It MUST NOT change timeline zoom by itself.
|
||||||
|
- Scope: `CatalogSearchBar`.
|
||||||
|
- Rationale: Search is a jump, not a filter on the chart.
|
||||||
|
- Verification: Type a query, Escape; year window unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Common UI Interaction Patterns
|
||||||
|
|
||||||
|
### 3.1 Catalog search (visitor)
|
||||||
|
|
||||||
|
- Statement: Timeline search MUST live in the header, require **2** trimmed characters, debounce **300 ms**, and group results into Artists, Movements, Paintings.
|
||||||
|
- Scope: `CatalogSearchBar.tsx`.
|
||||||
|
- Rationale: Documented product behaviour; keeps `/api/search` load reasonable.
|
||||||
|
- Verification: 1 character shows no fetch; 2+ after debounce shows groups.
|
||||||
|
|
||||||
|
- Statement: Keyboard MUST support `↑`/`↓` highlight, `Enter` to open, `Escape` to close.
|
||||||
|
- Scope: Catalog search dropdown.
|
||||||
|
- Rationale: Timeline is pointer-heavy; search should still be keyboardable.
|
||||||
|
- Verification: Keyboard-only open of an artist, movement, and painting.
|
||||||
|
|
||||||
|
- Statement: Opening a painting from search MUST set `returnTo` timeline so Back is **← Back to Timeline**, not Gallery.
|
||||||
|
- Scope: Search → painting detail.
|
||||||
|
- Rationale: There is no hall session.
|
||||||
|
- Verification: Search a title, open, Back → home timeline.
|
||||||
|
|
||||||
|
### 3.2 Timeline charts (zoom, pan, click)
|
||||||
|
|
||||||
|
- Statement: Scroll MUST zoom, drag MUST pan, click on a stream/limb/label MUST open the movement (artist filter → hall). All three layouts MUST use `zoomTimelineView` / `panTimelineView`.
|
||||||
|
- Scope: `MovementBands`, `VerticalMovementBands`, `MovementTree`, era rails.
|
||||||
|
- Rationale: Shared year window; one mental model.
|
||||||
|
- Verification: Same wheel/drag behaviour on classic, vertical, and tree.
|
||||||
|
|
||||||
|
- Statement: Each chart MUST show a localised hint caption (`captionClassicTimeline`, `captionClassicFlow`, `captionVerticalTimeline`, `captionVerticalFlow`, `captionTreeFlow`).
|
||||||
|
- Scope: Timeline home.
|
||||||
|
- Rationale: First-time visitors cannot discover zoom/pan otherwise.
|
||||||
|
- Verification: EN and RU captions change with `LocaleSwitcher`.
|
||||||
|
|
||||||
|
- Statement: Timeline layout shifts (lane packing, tree fit scale) SHOULD animate rather than snap.
|
||||||
|
- Scope: Movement charts.
|
||||||
|
- Rationale: Unexplained jumps look like bugs.
|
||||||
|
- Verification: Zoom/pan does not teleport streams.
|
||||||
|
|
||||||
|
### 3.3 Filters and search (curator tables)
|
||||||
|
|
||||||
|
- Statement: Filter/search controls MUST sit in a toolbar **above** the table, not in a column header hack or a page footer.
|
||||||
|
- Scope: Checkup, Translations, Influences, Tours editor, Users, Activity.
|
||||||
|
- Rationale: Matches Checkup (`checkup-toolbar`) and keeps the grid scannable.
|
||||||
|
- Verification: Filters remain visible while the table scrolls.
|
||||||
|
|
||||||
|
- Statement: Simple text filters MAY apply as the user types. Expensive operations (image search, import, refetch) MUST require an explicit button (e.g. Checkup **Search visible**).
|
||||||
|
- Scope: Curator list screens.
|
||||||
|
- Rationale: Checkup search is rate-limited and slow; typing must not fire it.
|
||||||
|
- Verification: Typing in Checkup filter does not start image search.
|
||||||
|
|
||||||
|
- Statement: When a filter hides rows, the toolbar SHOULD show how many rows are visible (e.g. `N shown`).
|
||||||
|
- Scope: Filtered tables.
|
||||||
|
- Rationale: Empty-looking tables need an explanation.
|
||||||
|
- Verification: Filter to zero rows → empty state plus count.
|
||||||
|
|
||||||
|
### 3.4 Date selection
|
||||||
|
|
||||||
|
- Statement: Year fields in the catalog and timeline MUST use numeric years (negative = BCE). They MUST NOT switch to locale-specific calendar widgets for historical BCE dates.
|
||||||
|
- Scope: Timeline bounds, artist lifespan, painting years, curator year filters.
|
||||||
|
- Rationale: The catalog spans −800 to the present; HTML date inputs cannot represent BCE.
|
||||||
|
- Verification: Ancient era still filters correctly.
|
||||||
|
|
||||||
|
- Statement: If a future screen needs a civil date (e.g. audit log day), it SHOULD use ISO `YYYY-MM-DD` and validate start ≤ end for ranges.
|
||||||
|
- Scope: Activity / audit and any new timestamp filters.
|
||||||
|
- Rationale: Consistent with API timestamps; avoids DD/MM ambiguity.
|
||||||
|
- Verification: Invalid range shows a field-level message.
|
||||||
|
|
||||||
|
### 3.5 Tables and lists (curator)
|
||||||
|
|
||||||
|
- Statement: Structured curator datasets MUST use a labeled HTML table (or existing page table classes), one logical record per row.
|
||||||
|
- Scope: Checkup, Users, Influences worklists, Tours list, Translations worklist, Activity.
|
||||||
|
- Rationale: Comparison and row actions need columns, not cards.
|
||||||
|
- Verification: Column headers present; row click/action affects one record.
|
||||||
|
|
||||||
|
- Statement: Visitor-facing catalog MUST NOT be presented as a spreadsheet. Timeline streams, tree limbs, and 3D hangs are the list metaphor.
|
||||||
|
- Scope: Public home and halls.
|
||||||
|
- Rationale: The product is a gallery, not a DAM table.
|
||||||
|
- Verification: No “all paintings” data grid on the public home.
|
||||||
|
|
||||||
|
- Statement: Tables MAY omit pagination while the dataset is curator-sized and client-filtered. If a list grows past comfortable scrolling, it SHOULD paginate or virtualise rather than rendering thousands of DOM rows.
|
||||||
|
- Scope: Curator tools.
|
||||||
|
- Rationale: Checkup is already filter-then-scroll; unbounded paint is a future foot-gun.
|
||||||
|
- Verification: New tools with large lists have a documented paging or virtualisation plan.
|
||||||
|
|
||||||
|
- Statement: Row actions MUST sit in a dedicated column or overflow control, not as random icons in every cell.
|
||||||
|
- Scope: Interactive curator tables.
|
||||||
|
- Rationale: Scanability.
|
||||||
|
- Verification: Action column or consistent button set per row.
|
||||||
|
|
||||||
|
### 3.6 Multi-row and per-record actions
|
||||||
|
|
||||||
|
- Statement: Bulk actions MUST use a leading checkbox column, Select All for **visible** rows only, and a confirmation that includes the affected count for destructive work.
|
||||||
|
- Scope: Any new bulk-enabled table. (Today: ArtistFilterModal multi-select is a picker, not a bulk delete.)
|
||||||
|
- Rationale: Same as the Logistics template; prevent silent mass edits.
|
||||||
|
- Verification: Select All does not imply “all matching in the database” unless explicitly labelled.
|
||||||
|
|
||||||
|
- Statement: Artist filter before a movement hall MUST be a modal checklist with explicit proceed/cancel, not a bulk-edit of the catalog.
|
||||||
|
- Scope: `ArtistFilterModal`.
|
||||||
|
- Rationale: It only chooses who appears in the hall.
|
||||||
|
- Verification: Cancel leaves the user on the timeline; proceed opens the hall.
|
||||||
|
|
||||||
|
- Statement: Primary row/object action SHOULD be the name/title (open painting, open user, open tour). Secondary actions SHOULD stay in the row’s action controls.
|
||||||
|
- Scope: Curator tables and search results.
|
||||||
|
- Rationale: Matches search-result click-to-open.
|
||||||
|
- Verification: Clicking a Checkup title opens the painting when that handler exists.
|
||||||
|
|
||||||
|
- Statement: Unavailable-by-permission actions MUST be hidden. Unavailable-by-record-state SHOULD be disabled with a `title`/tooltip explaining why.
|
||||||
|
- Scope: All tools.
|
||||||
|
- Rationale: RBAC vs workflow are different signals.
|
||||||
|
- Verification: Non-admin does not see Users; a disabled Fix button states why.
|
||||||
|
|
||||||
|
### 3.7 Forms
|
||||||
|
|
||||||
|
- Statement: Short auth and picker flows MUST use a modal. Multi-section catalog editors (Users create/edit, Tours editor, Influences wizard, Translations worklist) MUST be full-page (or the existing page layout), not nested modals.
|
||||||
|
- Scope: All forms.
|
||||||
|
- Rationale: Halls and timeline need to stay the “place”; heavy edit needs space.
|
||||||
|
- Verification: Login is modal; Users is a page.
|
||||||
|
|
||||||
|
- Statement: Forms SHOULD be a single column. Related fields MAY group under a heading when there are more than five inputs.
|
||||||
|
- Scope: Curator forms.
|
||||||
|
- Rationale: Scanning beats dense multi-column on museum-width pages.
|
||||||
|
- Verification: Users create form remains vertically grouped.
|
||||||
|
|
||||||
|
- Statement: Required fields MUST use the native `required` attribute and/or a visible marker; validation errors MUST appear next to the field or as a form-level error the submit control does not obscure.
|
||||||
|
- Scope: Login, Users, Tours, Influences, Translations.
|
||||||
|
- Rationale: Silent submit-disable is not enough.
|
||||||
|
- Verification: Submit empty login → field or form error, not a blank modal.
|
||||||
|
|
||||||
|
- Statement: After successful save, the system MUST show an inline success message (or equivalent) and keep the user on the tool unless the spec says to return to timeline.
|
||||||
|
- Scope: Curator mutations.
|
||||||
|
- Rationale: Users page already uses `message` / `error` banners.
|
||||||
|
- Verification: Save permissions → success text; failed save → error text.
|
||||||
|
|
||||||
|
- Statement: Unsaved-change guards SHOULD be added when a form is long enough that accidental Back would lose work (Tours editor, Translations). Login and tiny pickers MAY skip this.
|
||||||
|
- Scope: Heavy editors.
|
||||||
|
- Rationale: `window.confirm` on delete already exists; abandon-edit is the remaining hole.
|
||||||
|
- Verification: Dirty Tours editor + Back prompts or discards explicitly.
|
||||||
|
|
||||||
|
### 3.8 Edit interaction pattern selection
|
||||||
|
|
||||||
|
| Pattern | Use when |
|
||||||
|
|---------|----------|
|
||||||
|
| **Modal** | Login, artist filter, tours list popup, debug image picker, lightbox, hall exit/wing overlays |
|
||||||
|
| **Inline** | Checkup flags, debug **Checked** / **Fix it** on painting detail — small, reversible |
|
||||||
|
| **Full page** | Curator tools, painting detail, artist bio, 3D hall |
|
||||||
|
| **Drawer** | MUST NOT be introduced unless a spec adds a shared drawer component |
|
||||||
|
|
||||||
|
- Statement: Nested modals MUST NOT be used (no modal opened from another modal). The debug “More” picker MAY stack on painting detail because detail is a full page, not a modal.
|
||||||
|
- Scope: All overlays.
|
||||||
|
- Rationale: Focus traps and Back labels break.
|
||||||
|
- Verification: Login does not open another dialog.
|
||||||
|
|
||||||
|
### 3.9 Modals and overlays
|
||||||
|
|
||||||
|
- Statement: Modals MUST use `role="dialog"` and `aria-modal="true"`, a visible close/cancel, and **Escape** to dismiss unless a submit is in flight.
|
||||||
|
- Scope: `CuratorLoginModal`, `ArtistFilterModal`, `ToursPopup`, `DebugSearchResultsModal`, `PaintingLightbox`, hall exit overlays.
|
||||||
|
- Rationale: Accessibility and parity with search.
|
||||||
|
- Verification: Esc closes lightbox and debug picker; backdrop click matches existing login behaviour.
|
||||||
|
|
||||||
|
- Statement: Backdrop click MAY close pickers and login. It MUST NOT close a modal that is applying a destructive or long-running action.
|
||||||
|
- Scope: Overlays.
|
||||||
|
- Rationale: Accidental dismiss during Fix/upload is costly.
|
||||||
|
- Verification: Click outside login closes; do not dismiss mid-upload.
|
||||||
|
|
||||||
|
### 3.10 Loading, empty, and error states
|
||||||
|
|
||||||
|
- Statement: Catalog, portrait, and hall loads MUST use `GalleryLoadingMarker` (overlay or banner), not an ad-hoc spinner per screen unless the marker cannot cover the region.
|
||||||
|
- Scope: Home, halls, painting/bio image work.
|
||||||
|
- Rationale: One recognisable “the museum is fetching” treatment.
|
||||||
|
- Verification: First visit shows “Loading art history…”; hall open uses the same marker family.
|
||||||
|
|
||||||
|
- Statement: Loading SHOULD be scoped to the affected region. Full-viewport overlay MUST be used only when the user cannot usefully interact (first catalog load, hall WebGL init).
|
||||||
|
- Scope: All loads.
|
||||||
|
- Rationale: Portrait banner vs full-page overlay already follows this.
|
||||||
|
- Verification: Timeline remains visible while “Loading portraits…” banners.
|
||||||
|
|
||||||
|
- Statement: Empty datasets MUST explain themselves (e.g. vertical flow: no movements in range; search: no matches; Checkup: no rows for filter).
|
||||||
|
- Scope: Charts, search, tables.
|
||||||
|
- Rationale: Blank gold-on-navy reads as a crash.
|
||||||
|
- Verification: Zoom to a year with no movements; search a nonsense string.
|
||||||
|
|
||||||
|
- Statement: Failed loads MUST set a visible error string (`error-banner`, form error, or page error) — never `console.error` alone.
|
||||||
|
- Scope: All data-fetching views.
|
||||||
|
- Rationale: Visitors have no console.
|
||||||
|
- Verification: Stop the API and confirm home shows a load failure message.
|
||||||
|
|
||||||
|
### 3.11 3D hall interaction
|
||||||
|
|
||||||
|
- Statement: Halls MUST keep **← Back to Timeline**, pointer-lock / click-to-move as already implemented, and **E** (or documented key) for exit/wing navigation. New hall UI MUST not steal those keys without updating this section.
|
||||||
|
- Scope: `VirtualGallery`.
|
||||||
|
- Rationale: Muscle memory across artist, movement, and tour halls.
|
||||||
|
- Verification: Same Back label and exit overlay pattern in all three hall kinds.
|
||||||
|
|
||||||
|
- Statement: Clicking a framed painting MUST open painting detail with `returnTo` the current hall. Missing images MUST show the draped-canvas placeholder, not a broken `<img>`.
|
||||||
|
- Scope: Halls.
|
||||||
|
- Rationale: Documented in [basics.md](basics.md).
|
||||||
|
- Verification: Work without a file still shows a frame cover.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Behavioral Standards (Screen UX)
|
||||||
|
|
||||||
|
### 4.1 Permission-based rendering (RBAC)
|
||||||
|
|
||||||
|
- Statement: Header tools and debug controls MUST render only when `can('<permission>')` (and login for curator). Anonymous visitors MUST see browse + Tours popup + locale, not Checkup/Users/etc.
|
||||||
|
- Scope: `HomePage` header, painting/bio debug panel.
|
||||||
|
- Rationale: Security UX: hide, don’t tease.
|
||||||
|
- Verification: Logged-out home; curator without `users` cannot open Users.
|
||||||
|
|
||||||
|
- Statement: Route-like views that require a role MUST show the existing “Curator access required” panel with login and back-to-gallery actions — they MUST NOT render an empty privileged page.
|
||||||
|
- Scope: Checkup, Translations, Influences, Tours editor, Users, Activity.
|
||||||
|
- Rationale: Deep view state can still be set; the gate must hold.
|
||||||
|
- Verification: Set view to Users while logged out → curator required copy.
|
||||||
|
|
||||||
|
- Statement: The same permission MUST hide the same action on every surface (header, painting debug, API). Do not leave a visible button that 403s.
|
||||||
|
- Scope: All mutations.
|
||||||
|
- Rationale: Predictable roles.
|
||||||
|
- Verification: `can('images')` off → no debug panel and no Fix buttons.
|
||||||
|
|
||||||
|
### 4.2 Notifications and user feedback
|
||||||
|
|
||||||
|
- Statement: Curator mutations MUST show success or error text in the page’s existing banner/message area. The app has no global toast system; new screens MUST NOT invent a third notification widget without replacing this standard.
|
||||||
|
- Scope: Curator pages.
|
||||||
|
- Rationale: Users/Influences already use inline `message` / `error`.
|
||||||
|
- Verification: One visual treatment per page, consistent placement under the header.
|
||||||
|
|
||||||
|
- Statement: Visitor-facing destructive actions (painting **Remove entry** in debug) MUST confirm and then show failure inline if the API rejects.
|
||||||
|
- Scope: Debug painting tools.
|
||||||
|
- Rationale: Catalog deletes are irreversible.
|
||||||
|
- Verification: Cancel confirm → no delete.
|
||||||
|
|
||||||
|
- Statement: Copy MUST be concise and, for errors, actionable (“Is the server running?”, “Sign in as a curator…”).
|
||||||
|
- Scope: All user-visible strings.
|
||||||
|
- Rationale: Support load.
|
||||||
|
- Verification: Home catalog failure string remains understandable.
|
||||||
|
|
||||||
|
### 4.3 Confirmation and cancellation
|
||||||
|
|
||||||
|
- Statement: Destructive curator actions (delete influence, delete tour, remove painting, deactivate user if offered) MUST confirm before the request. `window.confirm` with an i18n string is the current standard (`confirmDelete`); a shared modal MAY replace it later but MUST stay one pattern.
|
||||||
|
- Scope: Influences, Tours, painting remove, Users.
|
||||||
|
- Rationale: Accidental clicks on dense tables.
|
||||||
|
- Verification: Delete tour → confirm; Cancel → no API call.
|
||||||
|
|
||||||
|
- Statement: Cancel on a confirm MUST leave filters, selection, and unsaved fields unchanged.
|
||||||
|
- Scope: All confirms.
|
||||||
|
- Rationale: Context preservation.
|
||||||
|
- Verification: Filtered Checkup, cancel a destructive action → filter still applied.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Component Consistency Rules
|
||||||
|
|
||||||
|
### 5.1 Shared interaction pattern usage
|
||||||
|
|
||||||
|
- Statement: New UI MUST reuse existing components before creating parallels: `GalleryLoadingMarker`, `CatalogSearchBar`, `LocaleSwitcher`, `CuratorLoginModal`, `ArtistFilterModal`, `PaintingLightbox`, `DebugSearchResultsModal`, `DebugUploadButton`.
|
||||||
|
- Scope: `client/src`.
|
||||||
|
- Rationale: Duplicate spinners and dialogs already caused drift.
|
||||||
|
- Verification: PR does not add a second login modal or loading overlay.
|
||||||
|
|
||||||
|
- Statement: Movement colours MUST go through `utils/movementColor.ts` (`vividMovementColor`, `shadeMovementColor`). Hex parsing MUST tolerate `#rgb` / `#rrggbb` and keep the first six digits of longer values.
|
||||||
|
- Scope: Timeline charts and any movement swatch.
|
||||||
|
- Rationale: Classic, vertical, and tree already share this util.
|
||||||
|
- Verification: No local `parseHexColor` copies in components.
|
||||||
|
|
||||||
|
- Statement: Timeline zoom/pan MUST use `utils/timelineView.ts`. Tree horizontal layout MUST use `utils/movementTree.ts` (view-independent structure).
|
||||||
|
- Scope: Timeline surfaces.
|
||||||
|
- Rationale: Layouts share one year window.
|
||||||
|
- Verification: No one-off wheel handlers that bypass the util.
|
||||||
|
|
||||||
|
### 5.2 Component and style selection
|
||||||
|
|
||||||
|
- Statement: There is **no Ant Design / MUI**. UI MUST be React + colocated CSS (`ComponentName.tsx` + `ComponentName.css`). Global museum tokens SHOULD reuse existing values:
|
||||||
|
|
||||||
|
| Token | Typical value | Use |
|
||||||
|
|-------|----------------|-----|
|
||||||
|
| Gold | `#c9a96e` / `rgba(201, 169, 110, …)` | Accents, borders, links |
|
||||||
|
| Ink / navy | `#0f0f1a`, `#1a1a2e`, `#16213e` | Page background |
|
||||||
|
| Type | Georgia, serif | Titles, captions, layout links |
|
||||||
|
| Viewport | `100vh`, `overflow: hidden` on home | No document scroll for the museum shell |
|
||||||
|
|
||||||
|
- Scope: All client UI.
|
||||||
|
- Rationale: The look *is* the design system.
|
||||||
|
- Verification: New CSS does not introduce a bright Bootstrap theme.
|
||||||
|
|
||||||
|
- Statement: Prefer semantic `<button type="button">` for actions and native `<form>` for submits. Do not use `<div onClick>` for primary actions.
|
||||||
|
- Scope: All interactive chrome.
|
||||||
|
- Rationale: Keyboard and a11y.
|
||||||
|
- Verification: Header tools are buttons.
|
||||||
|
|
||||||
|
- Statement: Colocate styles; do not add a CSS-in-JS runtime. Shared layout classes live in `HomePage.css` / page CSS, not inline theme objects.
|
||||||
|
- Scope: Client.
|
||||||
|
- Rationale: Matches the repo.
|
||||||
|
- Verification: New component ships a `.css` file or uses an existing one.
|
||||||
|
|
||||||
|
### 5.3 i18n
|
||||||
|
|
||||||
|
- Statement: New user-visible chrome MUST add keys to `client/src/locales/en/*.json` and `ru/*.json` (namespace that matches the screen: `home`, `common`, `debug`, `users`, …). Hardcoded English is allowed only for curator-only screens that are not yet migrated, and those SHOULD be migrated when the screen is touched.
|
||||||
|
- Scope: All new copy.
|
||||||
|
- Rationale: [i18n-russian.md](i18n-russian.md).
|
||||||
|
- Verification: Locale toggle changes the new string.
|
||||||
|
|
||||||
|
- Statement: Catalog entities (names, titles, bios) MUST use API locale resolution, not a second client-side dictionary.
|
||||||
|
- Scope: Timeline labels, search, halls, detail.
|
||||||
|
- Rationale: `entity_translations` is canonical for RU catalog text.
|
||||||
|
- Verification: RU locale shows published translations.
|
||||||
|
|
||||||
|
### 5.4 Exception process
|
||||||
|
|
||||||
|
- Statement: Deviations (new overlay type, toast system, left nav, router, design library) MUST be documented in this file or in the feature’s `Documentation/*.md` with rationale before they spread to a second screen.
|
||||||
|
- Scope: Client-wide patterns.
|
||||||
|
- Rationale: One exception is an experiment; two without a write-up is fragmentation.
|
||||||
|
- Verification: PR description links the exception note.
|
||||||
|
|
||||||
|
- Statement: Known current exceptions (do not cargo-cult; fix when touching the file):
|
||||||
|
- Browser history is not a router.
|
||||||
|
- Only `?layout=` is deep-linked.
|
||||||
|
- Some curator pages (e.g. Checkup) still have English chrome.
|
||||||
|
- Some modals may still omit Escape (login); new modals MUST include it.
|
||||||
|
- Destructive confirms use `window.confirm` rather than a shared dialog component.
|
||||||
|
|
||||||
|
### 5.5 Versioning of behaviour
|
||||||
|
|
||||||
|
- Statement: Behaviour changes to shared patterns (Back, search debounce, layout query, loading marker) MUST update this document in the same change.
|
||||||
|
- Scope: Shared components and `HomePage` navigation.
|
||||||
|
- Rationale: Agents and humans use this as the spec.
|
||||||
|
- Verification: Doc diff accompanies the code diff.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Screen-Level Checklist
|
||||||
|
|
||||||
|
Use this before submitting a new view, overlay, or curator tool. Unchecked items need a note.
|
||||||
|
|
||||||
|
- [ ] Surface type is identified (timeline / hall / detail / overlay / curator page).
|
||||||
|
- [ ] Parent view and Back label/destination are defined (`returnTo` or `goToTimelineHome`).
|
||||||
|
- [ ] Permissions: controls hidden unless `can(…)` / signed in as required.
|
||||||
|
- [ ] Loading uses `GalleryLoadingMarker` or a justified scoped indicator.
|
||||||
|
- [ ] Empty and error states are visible copy, not a blank canvas.
|
||||||
|
- [ ] Destructive actions confirm; cancel leaves state unchanged.
|
||||||
|
- [ ] Visitor chrome strings are in `locales/{en,ru}`; catalog text uses API locale.
|
||||||
|
- [ ] No new CSS framework; gold/navy/Georgia; colocated CSS.
|
||||||
|
- [ ] Search/filter (if any) sits above the list; expensive work is explicit-button.
|
||||||
|
- [ ] Modals: `role="dialog"`, close control, Escape.
|
||||||
|
- [ ] Timeline work uses `timelineView` / `movementColor` / `movementTree` as applicable.
|
||||||
|
- [ ] Hall work preserves camera on Back to Gallery and resets on Back to Timeline.
|
||||||
|
- [ ] This document updated if a shared pattern changed.
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
# Art Gallery
|
# Art Gallery
|
||||||
|
|
||||||
Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, **curator-gated** debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**; painting detail also **Remove entry**), optional **Show more** auto-opens the search picker, Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. Anonymous visitors browse freely; curators sign in via **Curator login** in the header.
|
Interactive virtual art gallery: zoomable historical timeline (classic left→right, vertical, or **Tree of Art** lineage chart — shareable via `?layout=`) with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, **curator-gated** debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**; painting detail also **Remove entry**), optional **Show more** auto-opens the search picker, Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. Anonymous visitors browse freely; curators sign in via **Curator login** in the header.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
| Document | Purpose |
|
| Document | Purpose |
|
||||||
|----------|---------|
|
|----------|---------|
|
||||||
| [Documentation/basics.md](Documentation/basics.md) | Architecture, layout, user flow |
|
| [Documentation/basics.md](Documentation/basics.md) | Architecture, layout, user flow |
|
||||||
|
| [Documentation/ui-interaction-and-component-standards.md](Documentation/ui-interaction-and-component-standards.md) | UI interaction, navigation, and component standards |
|
||||||
|
| [Documentation/movement-tree.md](Documentation/movement-tree.md) | **Tree of Art** start page — tree layout rules |
|
||||||
| [Documentation/FAC.md](Documentation/FAC.md) | **Command cheat sheet** — start/stop, import, deploy |
|
| [Documentation/FAC.md](Documentation/FAC.md) | **Command cheat sheet** — start/stop, import, deploy |
|
||||||
| [Documentation/environments.md](Documentation/environments.md) | Dev/prod URLs, DB split, deploy, sync |
|
| [Documentation/environments.md](Documentation/environments.md) | Dev/prod URLs, DB split, deploy, sync |
|
||||||
| [Documentation/setup.md](Documentation/setup.md) | Install, env, npm scripts |
|
| [Documentation/setup.md](Documentation/setup.md) | Install, env, npm scripts |
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState, memo } from 'react';
|
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState, memo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { ArtMovement, Artist } from '../types';
|
import type { ArtMovement, Artist } from '../types';
|
||||||
import { portraitThumbUrl } from '../api/client';
|
import { portraitThumbUrl } from '../api/client';
|
||||||
import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc';
|
import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc';
|
||||||
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
||||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||||
|
import { parseHexColor, rgbToHsl, hslToHex, vividMovementColor } from '../utils/movementColor';
|
||||||
import './MovementBands.css';
|
import './MovementBands.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -244,74 +246,6 @@ function buildArtistPlacements(
|
|||||||
return placements;
|
return placements;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseHexColor(hex: string): [number, number, number] {
|
|
||||||
const normalized = hex.replace('#', '');
|
|
||||||
const value =
|
|
||||||
normalized.length === 3
|
|
||||||
? normalized
|
|
||||||
.split('')
|
|
||||||
.map((c) => c + c)
|
|
||||||
.join('')
|
|
||||||
: normalized.padStart(6, '0').slice(0, 6);
|
|
||||||
return [
|
|
||||||
parseInt(value.slice(0, 2), 16),
|
|
||||||
parseInt(value.slice(2, 4), 16),
|
|
||||||
parseInt(value.slice(4, 6), 16),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
|
||||||
const rn = r / 255;
|
|
||||||
const gn = g / 255;
|
|
||||||
const bn = b / 255;
|
|
||||||
const max = Math.max(rn, gn, bn);
|
|
||||||
const min = Math.min(rn, gn, bn);
|
|
||||||
const l = (max + min) / 2;
|
|
||||||
if (max === min) return [0, 0, l];
|
|
||||||
const d = max - min;
|
|
||||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
||||||
let h = 0;
|
|
||||||
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
|
|
||||||
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
|
|
||||||
else h = ((rn - gn) / d + 4) / 6;
|
|
||||||
return [h * 360, s, l];
|
|
||||||
}
|
|
||||||
|
|
||||||
function hslToHex(h: number, s: number, l: number): string {
|
|
||||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
||||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
||||||
const m = l - c / 2;
|
|
||||||
let r = 0;
|
|
||||||
let g = 0;
|
|
||||||
let b = 0;
|
|
||||||
if (h < 60) [r, g, b] = [c, x, 0];
|
|
||||||
else if (h < 120) [r, g, b] = [x, c, 0];
|
|
||||||
else if (h < 180) [r, g, b] = [0, c, x];
|
|
||||||
else if (h < 240) [r, g, b] = [0, x, c];
|
|
||||||
else if (h < 300) [r, g, b] = [x, 0, c];
|
|
||||||
else [r, g, b] = [c, 0, x];
|
|
||||||
const toByte = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
|
||||||
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Boost saturation / mid lightness so streams read vividly on the dark flow canvas. */
|
|
||||||
function vividMovementColor(hex: string): string {
|
|
||||||
try {
|
|
||||||
const [r, g, b] = parseHexColor(hex);
|
|
||||||
const [h, s, l] = rgbToHsl(r, g, b);
|
|
||||||
const s2 = s < 0.1 ? Math.min(0.55, s + 0.42) : Math.min(1, s * 1.65 + 0.08);
|
|
||||||
const l2 =
|
|
||||||
l < 0.22
|
|
||||||
? 0.5
|
|
||||||
: l > 0.78
|
|
||||||
? 0.62
|
|
||||||
: Math.min(0.68, Math.max(0.4, l * 0.75 + 0.28));
|
|
||||||
return hslToHex(h, s2, l2);
|
|
||||||
} catch {
|
|
||||||
return hex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function artistLifespanColor(baseColor: string, laneIndex: number, laneCount: number): string {
|
function artistLifespanColor(baseColor: string, laneIndex: number, laneCount: number): string {
|
||||||
if (laneCount <= 1) return baseColor;
|
if (laneCount <= 1) return baseColor;
|
||||||
try {
|
try {
|
||||||
@@ -1762,6 +1696,7 @@ export default function MovementBands({
|
|||||||
onArtistHover,
|
onArtistHover,
|
||||||
onPortraitsLoadingChange,
|
onPortraitsLoadingChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation('home');
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
const pendingPortraitsRef = useRef(0);
|
const pendingPortraitsRef = useRef(0);
|
||||||
const [portraitsLoading, setPortraitsLoading] = useState(false);
|
const [portraitsLoading, setPortraitsLoading] = useState(false);
|
||||||
@@ -2260,7 +2195,7 @@ export default function MovementBands({
|
|||||||
return (
|
return (
|
||||||
<div className="movements-flow">
|
<div className="movements-flow">
|
||||||
<p className="movements-flow-caption">
|
<p className="movements-flow-caption">
|
||||||
Scroll to zoom · drag to pan · each movement stream is a solid colour band through history
|
{t('captionClassicFlow')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,6 +1,38 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import * as THREE from 'three';
|
import type { MovementInteriorStyle, GalleryWindowSpec } from '../data/movement-interior-styles';
|
||||||
import type { MovementInteriorStyle } from '../data/movement-interior-styles';
|
import type { RoomDims } from './hall-details/geometry';
|
||||||
|
import {
|
||||||
|
CinquecentoDetails,
|
||||||
|
QuattrocentoDetails,
|
||||||
|
RomanAtriumDetails,
|
||||||
|
} from './hall-details/classical';
|
||||||
|
import {
|
||||||
|
BaroqueStateDetails,
|
||||||
|
FlemishDetails,
|
||||||
|
ManneristDetails,
|
||||||
|
NeoclassicalDetails,
|
||||||
|
RococoDetails,
|
||||||
|
} from './hall-details/courtly';
|
||||||
|
import {
|
||||||
|
ArtNouveauDetails,
|
||||||
|
BourgeoisSalonDetails,
|
||||||
|
GothicRevivalDetails,
|
||||||
|
NorthLightSalonDetails,
|
||||||
|
ParisAtelierDetails,
|
||||||
|
SymbolistDetails,
|
||||||
|
} from './hall-details/nineteenth';
|
||||||
|
import {
|
||||||
|
ConstructivistDetails,
|
||||||
|
CubistAtelierDetails,
|
||||||
|
DadaDetails,
|
||||||
|
ExpressionistDetails,
|
||||||
|
FauvistDetails,
|
||||||
|
FuturistDetails,
|
||||||
|
LoftDetails,
|
||||||
|
SuprematistDetails,
|
||||||
|
SurrealistDetails,
|
||||||
|
WhiteCubeDetails,
|
||||||
|
} from './hall-details/modernism';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
style: MovementInteriorStyle;
|
style: MovementInteriorStyle;
|
||||||
@@ -8,99 +40,11 @@ interface Props {
|
|||||||
depth: number;
|
depth: number;
|
||||||
halfW: number;
|
halfW: number;
|
||||||
halfD: number;
|
halfD: number;
|
||||||
|
/** Runtime window placement, so upper-wall relief can step around openings. */
|
||||||
|
windows?: GalleryWindowSpec[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function PalazzoDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
|
function GothicDetails({ width, depth, halfW, halfD, trim }: Omit<Props, 'style' | 'windows'> & { trim: string }) {
|
||||||
const pilasterPositions = useMemo(
|
|
||||||
() =>
|
|
||||||
[
|
|
||||||
[-halfW + 0.35, -halfD + 0.35],
|
|
||||||
[halfW - 0.35, -halfD + 0.35],
|
|
||||||
[-halfW + 0.35, halfD - 0.35],
|
|
||||||
[halfW - 0.35, halfD - 0.35],
|
|
||||||
] as [number, number][],
|
|
||||||
[halfW, halfD]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
{pilasterPositions.map(([x, z], i) => (
|
|
||||||
<group key={i} position={[x, 0, z]}>
|
|
||||||
<mesh position={[0, 1.8, 0]}>
|
|
||||||
<boxGeometry args={[0.22, 3.6, 0.22]} />
|
|
||||||
<meshStandardMaterial color="#e8dcc8" roughness={0.85} metalness={0.05} />
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0, 3.65, 0]}>
|
|
||||||
<boxGeometry args={[0.28, 0.14, 0.28]} />
|
|
||||||
<meshStandardMaterial color={trim} roughness={0.35} metalness={0.55} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Wainscoting rail */}
|
|
||||||
{[
|
|
||||||
[0, -halfD + 0.09, width, 0.12] as const,
|
|
||||||
[0, halfD - 0.09, width, 0.12] as const,
|
|
||||||
[-halfW + 0.09, 0, 0.12, depth] as const,
|
|
||||||
[halfW - 0.09, 0, 0.12, depth] as const,
|
|
||||||
].map(([x, z, w, d], i) => (
|
|
||||||
<mesh key={i} position={[x, 1.05, z]}>
|
|
||||||
<boxGeometry args={[w, 0.08, d]} />
|
|
||||||
<meshStandardMaterial color="#ddd0b8" roughness={0.78} metalness={0.08} />
|
|
||||||
</mesh>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Coffered ceiling */}
|
|
||||||
{Array.from({ length: Math.min(6, Math.floor(width / 2.5)) }, (_, col) =>
|
|
||||||
Array.from({ length: Math.min(5, Math.floor(depth / 2.8)) }, (_, row) => {
|
|
||||||
const cx = -halfW + 1.4 + col * 2.4;
|
|
||||||
const cz = -halfD + 1.5 + row * 2.6;
|
|
||||||
return (
|
|
||||||
<mesh key={`${col}-${row}`} position={[cx, 4.12, cz]} rotation={[Math.PI / 2, 0, 0]}>
|
|
||||||
<boxGeometry args={[2.0, 2.2, 0.06]} />
|
|
||||||
<meshStandardMaterial color="#f5efe4" roughness={0.88} />
|
|
||||||
</mesh>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BaroqueDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
{/* Gilded cornice ring */}
|
|
||||||
{[
|
|
||||||
[0, -halfD + 0.06, width, 0.1] as const,
|
|
||||||
[0, halfD - 0.06, width, 0.1] as const,
|
|
||||||
[-halfW + 0.06, 0, 0.1, depth] as const,
|
|
||||||
[halfW - 0.06, 0, 0.1, depth] as const,
|
|
||||||
].map(([x, z, w, d], i) => (
|
|
||||||
<mesh key={i} position={[x, 3.95, z]}>
|
|
||||||
<boxGeometry args={[w, 0.18, d]} />
|
|
||||||
<meshStandardMaterial color={trim} roughness={0.25} metalness={0.75} emissive="#3a2808" emissiveIntensity={0.08} />
|
|
||||||
</mesh>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Wall panels */}
|
|
||||||
{[-halfW + 0.12, halfW - 0.12].map((x, i) => (
|
|
||||||
<mesh key={i} position={[x, 2.1, 0]} rotation={[0, Math.PI / 2, 0]}>
|
|
||||||
<boxGeometry args={[depth * 0.85, 2.8, 0.04]} />
|
|
||||||
<meshStandardMaterial color="#3a1820" roughness={0.88} metalness={0.06} />
|
|
||||||
</mesh>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<pointLight position={[0, 3.6, 0]} intensity={0.9} distance={Math.max(width, depth)} color="#ffd080" />
|
|
||||||
<mesh position={[0, 3.5, 0]}>
|
|
||||||
<torusGeometry args={[0.55, 0.04, 8, 24]} />
|
|
||||||
<meshStandardMaterial color={trim} roughness={0.2} metalness={0.85} emissive="#5a4010" emissiveIntensity={0.15} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GothicDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
|
|
||||||
// Compound piers along the side walls, spaced by bay, each rising into the
|
// Compound piers along the side walls, spaced by bay, each rising into the
|
||||||
// vault springer. Shafts stay flush against the wall (0.16 m proud) so they
|
// vault springer. Shafts stay flush against the wall (0.16 m proud) so they
|
||||||
// never intrude on the ~0.7 m frame hang margin.
|
// never intrude on the ~0.7 m frame hang margin.
|
||||||
@@ -292,102 +236,75 @@ function ByzantineDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NeoclassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
|
/**
|
||||||
// Shallow engaged corner pilasters only. Freestanding columns (~0.5m into the
|
* Period architecture for a movement hall.
|
||||||
// room) overlap the outermost frames — hang margin is only ~0.7m from each end.
|
*
|
||||||
const pilasters = useMemo(
|
* Every movement has its own interior, built from the architecture of the same
|
||||||
() =>
|
* period as the movement itself. The Gothic nave and Byzantine basilica below
|
||||||
[
|
* are the reference implementations; everything else lives in
|
||||||
[-halfW + 0.06, -halfD + 0.06],
|
* `hall-details/`, sharing the geometry budget documented in
|
||||||
[halfW - 0.06, -halfD + 0.06],
|
* `hall-details/primitives.tsx`.
|
||||||
[-halfW + 0.06, halfD - 0.06],
|
*/
|
||||||
[halfW - 0.06, halfD - 0.06],
|
export default function MovementHallDetails({ style, width, depth, halfW, halfD, windows }: Props) {
|
||||||
] as [number, number][],
|
|
||||||
[halfW, halfD]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
{pilasters.map(([x, z], i) => (
|
|
||||||
<group key={i} position={[x, 0, z]}>
|
|
||||||
<mesh position={[0, 1.85, 0]}>
|
|
||||||
<boxGeometry args={[0.12, 3.7, 0.12]} />
|
|
||||||
<meshStandardMaterial color="#f0ece8" roughness={0.72} metalness={0.12} />
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0, 3.78, 0]}>
|
|
||||||
<boxGeometry args={[0.18, 0.1, 0.18]} />
|
|
||||||
<meshStandardMaterial color={trim} roughness={0.4} metalness={0.35} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
))}
|
|
||||||
{/* Cornice above wall title (title sits ~y 3.75) */}
|
|
||||||
<mesh position={[0, 4.08, -halfD + 0.1]}>
|
|
||||||
<boxGeometry args={[Math.max(2.4, halfW * 2 - 0.5), 0.1, 0.16]} />
|
|
||||||
<meshStandardMaterial color={trim} roughness={0.45} metalness={0.3} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ClassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
|
|
||||||
// Engaged corner shafts only — anything proud of the corner pocket covers frames.
|
|
||||||
const columns = useMemo(
|
|
||||||
() =>
|
|
||||||
[
|
|
||||||
[-halfW + 0.06, -halfD + 0.06],
|
|
||||||
[halfW - 0.06, -halfD + 0.06],
|
|
||||||
[-halfW + 0.06, halfD - 0.06],
|
|
||||||
[halfW - 0.06, halfD - 0.06],
|
|
||||||
] as [number, number][],
|
|
||||||
[halfW, halfD]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
{columns.map(([x, z], i) => (
|
|
||||||
<group key={i} position={[x, 0, z]}>
|
|
||||||
<mesh position={[0, 2, 0]}>
|
|
||||||
<cylinderGeometry args={[0.08, 0.09, 4, 10]} />
|
|
||||||
<meshStandardMaterial color="#e0d8c8" roughness={0.88} />
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0, 4.05, 0]}>
|
|
||||||
<boxGeometry args={[0.18, 0.1, 0.18]} />
|
|
||||||
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.25} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
))}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SalonDetails({ trim }: { trim: string }) {
|
|
||||||
return (
|
|
||||||
<mesh position={[0, 4.05, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
|
||||||
<ringGeometry args={[0.3, 1.2, 32]} />
|
|
||||||
<meshStandardMaterial color={trim} roughness={0.35} metalness={0.5} side={THREE.DoubleSide} />
|
|
||||||
</mesh>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MovementHallDetails({ style, width, depth, halfW, halfD }: Props) {
|
|
||||||
const trim = style.tints.trim;
|
const trim = style.tints.trim;
|
||||||
|
const room: RoomDims = useMemo(
|
||||||
|
() => ({ width, depth, halfW, halfD }),
|
||||||
|
[width, depth, halfW, halfD]
|
||||||
|
);
|
||||||
|
const period = { room, trim, windows };
|
||||||
|
|
||||||
switch (style.details) {
|
switch (style.details) {
|
||||||
case 'palazzo':
|
|
||||||
return <PalazzoDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
|
||||||
case 'baroque':
|
|
||||||
return <BaroqueDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
|
||||||
case 'gothic':
|
case 'gothic':
|
||||||
return <GothicDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
return <GothicDetails width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
||||||
case 'byzantine':
|
case 'byzantine':
|
||||||
return <ByzantineDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
return <ByzantineDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
||||||
|
case 'roman':
|
||||||
|
return <RomanAtriumDetails {...period} />;
|
||||||
|
case 'quattrocento':
|
||||||
|
return <QuattrocentoDetails {...period} />;
|
||||||
|
case 'cinquecento':
|
||||||
|
return <CinquecentoDetails {...period} />;
|
||||||
|
case 'flemish':
|
||||||
|
return <FlemishDetails {...period} />;
|
||||||
|
case 'mannerist':
|
||||||
|
return <ManneristDetails {...period} />;
|
||||||
|
case 'baroque':
|
||||||
|
return <BaroqueStateDetails {...period} />;
|
||||||
|
case 'rococo':
|
||||||
|
return <RococoDetails {...period} />;
|
||||||
case 'neoclassical':
|
case 'neoclassical':
|
||||||
return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
return <NeoclassicalDetails {...period} />;
|
||||||
case 'classical':
|
case 'gothic-revival':
|
||||||
return <ClassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
return <GothicRevivalDetails {...period} />;
|
||||||
case 'salon':
|
case 'bourgeois':
|
||||||
return <SalonDetails trim={trim} />;
|
return <BourgeoisSalonDetails {...period} />;
|
||||||
default:
|
case 'north-light':
|
||||||
return null;
|
return <NorthLightSalonDetails {...period} />;
|
||||||
|
case 'paris-atelier':
|
||||||
|
return <ParisAtelierDetails {...period} />;
|
||||||
|
case 'symbolist':
|
||||||
|
return <SymbolistDetails {...period} />;
|
||||||
|
case 'art-nouveau':
|
||||||
|
return <ArtNouveauDetails {...period} />;
|
||||||
|
case 'fauvist':
|
||||||
|
return <FauvistDetails {...period} />;
|
||||||
|
case 'expressionist':
|
||||||
|
return <ExpressionistDetails {...period} />;
|
||||||
|
case 'cubist-atelier':
|
||||||
|
return <CubistAtelierDetails {...period} />;
|
||||||
|
case 'futurist':
|
||||||
|
return <FuturistDetails {...period} />;
|
||||||
|
case 'suprematist':
|
||||||
|
return <SuprematistDetails {...period} />;
|
||||||
|
case 'constructivist':
|
||||||
|
return <ConstructivistDetails {...period} />;
|
||||||
|
case 'dada':
|
||||||
|
return <DadaDetails {...period} />;
|
||||||
|
case 'surrealist':
|
||||||
|
return <SurrealistDetails {...period} />;
|
||||||
|
case 'loft':
|
||||||
|
return <LoftDetails {...period} />;
|
||||||
|
case 'white-cube':
|
||||||
|
return <WhiteCubeDetails {...period} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
.mtree {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 8px 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-caption {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: italic;
|
||||||
|
color: rgba(201, 169, 110, 0.65);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(201, 169, 110, 0.6);
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-canvas {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 60% 45% at 50% 100%, rgba(201, 169, 110, 0.1), transparent 72%),
|
||||||
|
radial-gradient(ellipse 80% 60% at 50% 0%, rgba(120, 150, 190, 0.08), transparent 70%),
|
||||||
|
rgba(0, 0, 0, 0.28);
|
||||||
|
border: 1px solid rgba(201, 169, 110, 0.12);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-canvas.mtree-panning {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-svg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-limb {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.9;
|
||||||
|
transition: opacity 0.18s ease, filter 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-limb-lit {
|
||||||
|
opacity: 1;
|
||||||
|
filter: brightness(1.22) drop-shadow(0 0 6px rgba(255, 226, 170, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-limb-dim {
|
||||||
|
opacity: 0.34;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-root {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-graft {
|
||||||
|
opacity: 0.3;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-graft-lit {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-label {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 4;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
margin: 0;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(12, 12, 22, 0.74);
|
||||||
|
color: rgba(245, 230, 200, 0.95);
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: opacity 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-label:hover {
|
||||||
|
background: rgba(30, 28, 40, 0.92);
|
||||||
|
color: #fff6e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-label-dim {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mtree-out-of-range {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(201, 169, 110, 0.7);
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { ArtMovement } from '../types';
|
||||||
|
import {
|
||||||
|
branchOriginYear,
|
||||||
|
buildMovementTree,
|
||||||
|
limbXAtYear,
|
||||||
|
type MovementTreeNode,
|
||||||
|
} from '../utils/movementTree';
|
||||||
|
import { shadeMovementColor, vividMovementColor } from '../utils/movementColor';
|
||||||
|
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||||
|
import './MovementTree.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
movements: ArtMovement[];
|
||||||
|
viewStart: number;
|
||||||
|
viewEnd: number;
|
||||||
|
absoluteMin: number;
|
||||||
|
absoluteMax: number;
|
||||||
|
onViewChange: (start: number, end: number) => void;
|
||||||
|
onMovementClick?: (movementId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pt {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIDE_PAD = 56;
|
||||||
|
/**
|
||||||
|
* Horizontal spread is tied to the zoom, not to the canvas.
|
||||||
|
*
|
||||||
|
* 90 % of the catalogue lives in the last 15 % of the time axis, so a tree
|
||||||
|
* stretched to full width with all of history in view is one long trunk under a
|
||||||
|
* flat bar. Instead the whole-history view draws a narrow tree, and every zoom
|
||||||
|
* step fans the crown out — the chart grows as you walk up it.
|
||||||
|
*/
|
||||||
|
const FULL_VIEW_WIDTH_SHARE = 0.52;
|
||||||
|
const ZOOM_SPREAD_EXPONENT = 0.45;
|
||||||
|
/** How far past "everything fits" the tree may be blown up when zoomed in. */
|
||||||
|
const MAX_FIT_BOOST = 2.4;
|
||||||
|
/** Exponential chase rate (1/s) for the horizontal fit, so zoom reads as growth. */
|
||||||
|
const FIT_ANIM_RATE = 9;
|
||||||
|
const RIBBON_SAMPLES = 18;
|
||||||
|
/** Vertical room a label needs. */
|
||||||
|
const MIN_LABEL_HEIGHT_PX = 20;
|
||||||
|
/**
|
||||||
|
* Readability floors. A 30-year movement is 8 px tall when the whole of
|
||||||
|
* history is on screen; without these the modern crown fuses into one bar.
|
||||||
|
*/
|
||||||
|
const MIN_LIMB_RISE_PX = 30;
|
||||||
|
const MIN_JUNCTION_RISE_PX = 38;
|
||||||
|
/** A limb is never drawn thicker than this share of its own length. */
|
||||||
|
const MAX_THICKNESS_OF_LENGTH = 0.55;
|
||||||
|
|
||||||
|
function cubicAt(p0: Pt, c1: Pt, c2: Pt, p3: Pt, t: number): Pt {
|
||||||
|
const u = 1 - t;
|
||||||
|
const a = u * u * u;
|
||||||
|
const b = 3 * u * u * t;
|
||||||
|
const c = 3 * u * t * t;
|
||||||
|
const d = t * t * t;
|
||||||
|
return {
|
||||||
|
x: a * p0.x + b * c1.x + c * c2.x + d * p3.x,
|
||||||
|
y: a * p0.y + b * c1.y + c * c2.y + d * p3.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cubicTangent(p0: Pt, c1: Pt, c2: Pt, p3: Pt, t: number): Pt {
|
||||||
|
const u = 1 - t;
|
||||||
|
const a = 3 * u * u;
|
||||||
|
const b = 6 * u * t;
|
||||||
|
const c = 3 * t * t;
|
||||||
|
return {
|
||||||
|
x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p3.x - c2.x),
|
||||||
|
y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filled ribbon of varying width along a cubic. Offsetting along the curve
|
||||||
|
* normal (rather than horizontally) keeps branch junctions solid even when a
|
||||||
|
* zoomed-out view squeezes them almost flat.
|
||||||
|
*/
|
||||||
|
function ribbonPath(p0: Pt, c1: Pt, c2: Pt, p3: Pt, w0: number, w1: number): string {
|
||||||
|
const left: Pt[] = [];
|
||||||
|
const right: Pt[] = [];
|
||||||
|
for (let i = 0; i <= RIBBON_SAMPLES; i++) {
|
||||||
|
const t = i / RIBBON_SAMPLES;
|
||||||
|
const p = cubicAt(p0, c1, c2, p3, t);
|
||||||
|
const d = cubicTangent(p0, c1, c2, p3, t);
|
||||||
|
const len = Math.hypot(d.x, d.y) || 1;
|
||||||
|
const nx = -d.y / len;
|
||||||
|
const ny = d.x / len;
|
||||||
|
const half = (w0 + (w1 - w0) * t) / 2;
|
||||||
|
left.push({ x: p.x + nx * half, y: p.y + ny * half });
|
||||||
|
right.push({ x: p.x - nx * half, y: p.y - ny * half });
|
||||||
|
}
|
||||||
|
const fmt = (pt: Pt) => `${pt.x.toFixed(2)} ${pt.y.toFixed(2)}`;
|
||||||
|
const forward = left.map((pt, i) => `${i === 0 ? 'M' : 'L'} ${fmt(pt)}`).join(' ');
|
||||||
|
const back = right
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((pt) => `L ${fmt(pt)}`)
|
||||||
|
.join(' ');
|
||||||
|
return `${forward} ${back} Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LimbShape {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
shade: string;
|
||||||
|
/** Trunk / limb body. */
|
||||||
|
d: string;
|
||||||
|
/** Junction ribbon growing out of the structural parent (may be empty). */
|
||||||
|
junction: string;
|
||||||
|
/** Rounded tip cap. */
|
||||||
|
tip: Pt & { r: number };
|
||||||
|
/** Root flare under a tree root, drawn only when the base is on screen. */
|
||||||
|
roots: string[];
|
||||||
|
labelX: number;
|
||||||
|
labelY: number;
|
||||||
|
labelVisible: boolean;
|
||||||
|
yearRange: string;
|
||||||
|
depth: number;
|
||||||
|
inView: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraftShape {
|
||||||
|
key: string;
|
||||||
|
d: string;
|
||||||
|
color: string;
|
||||||
|
fromId: number;
|
||||||
|
toId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatYear(year: number): string {
|
||||||
|
return year < 0 ? `${Math.abs(year)} BCE` : `${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Greedy declutter: closer to the trunk wins. Every visible movement asks for a
|
||||||
|
* name, and the ones that would collide with an already-placed name — or fall
|
||||||
|
* off the canvas — stay anonymous until you zoom in on them.
|
||||||
|
*/
|
||||||
|
function hideOverlappingLabels(limbs: LimbShape[], width: number, height: number): void {
|
||||||
|
const placed: { x0: number; y0: number; x1: number; y1: number }[] = [];
|
||||||
|
const candidates = limbs
|
||||||
|
.map((limb, index) => ({ limb, index }))
|
||||||
|
.filter(({ limb }) => limb.labelVisible)
|
||||||
|
.sort((a, b) => a.limb.depth - b.limb.depth || b.limb.labelY - a.limb.labelY);
|
||||||
|
|
||||||
|
for (const { limb } of candidates) {
|
||||||
|
const halfW = (limb.name.length * 6.6 + 16) / 2;
|
||||||
|
const halfH = MIN_LABEL_HEIGHT_PX / 2;
|
||||||
|
const box = {
|
||||||
|
x0: limb.labelX - halfW,
|
||||||
|
y0: limb.labelY - halfH,
|
||||||
|
x1: limb.labelX + halfW,
|
||||||
|
y1: limb.labelY + halfH,
|
||||||
|
};
|
||||||
|
const offCanvas = box.x0 < 2 || box.x1 > width - 2 || box.y0 < 2 || box.y1 > height - 2;
|
||||||
|
const collides = placed.some(
|
||||||
|
(p) => box.x0 < p.x1 && box.x1 > p.x0 && box.y0 < p.y1 && box.y1 > p.y0
|
||||||
|
);
|
||||||
|
if (offCanvas || collides) {
|
||||||
|
limb.labelVisible = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
placed.push(box);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MovementTree({
|
||||||
|
movements,
|
||||||
|
viewStart,
|
||||||
|
viewEnd,
|
||||||
|
absoluteMin,
|
||||||
|
absoluteMax,
|
||||||
|
onViewChange,
|
||||||
|
onMovementClick,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation('home');
|
||||||
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [canvas, setCanvas] = useState({ w: 900, h: 600 });
|
||||||
|
const [panning, setPanning] = useState(false);
|
||||||
|
const [hoveredId, setHoveredId] = useState<number | null>(null);
|
||||||
|
const [fitScale, setFitScale] = useState(1);
|
||||||
|
const panStart = useRef({ y: 0, viewStart: 0, viewEnd: 0 });
|
||||||
|
const viewRef = useRef({ viewStart, viewEnd });
|
||||||
|
const onViewChangeRef = useRef(onViewChange);
|
||||||
|
const fitRef = useRef(1);
|
||||||
|
const fitReadyRef = useRef(false);
|
||||||
|
const fitTargetRef = useRef(1);
|
||||||
|
const fitRafRef = useRef<number | null>(null);
|
||||||
|
const fitLastTsRef = useRef(0);
|
||||||
|
viewRef.current = { viewStart, viewEnd };
|
||||||
|
onViewChangeRef.current = onViewChange;
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = canvasRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const measure = () => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (rect.width > 0 && rect.height > 0) {
|
||||||
|
setCanvas({ w: Math.round(rect.width), h: Math.round(rect.height) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Structure is catalogue-wide and view-independent: zooming must not reshape
|
||||||
|
// the tree, only travel along it.
|
||||||
|
const tree = useMemo(() => buildMovementTree(movements), [movements]);
|
||||||
|
|
||||||
|
const visibleIds = useMemo(() => {
|
||||||
|
const ids = new Set<number>();
|
||||||
|
for (const node of tree.nodes.values()) {
|
||||||
|
const { start_year: s, end_year: e } = node.movement;
|
||||||
|
if (e > viewStart && s < viewEnd) ids.add(node.movement.id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}, [tree, viewStart, viewEnd]);
|
||||||
|
|
||||||
|
const targetScale = useMemo(() => {
|
||||||
|
const usable = Math.max(240, canvas.w - SIDE_PAD * 2);
|
||||||
|
const fitAll = usable / (2 * Math.max(1, tree.halfSpan));
|
||||||
|
const visibleSpan = Math.max(1, viewEnd - viewStart);
|
||||||
|
const totalSpan = Math.max(visibleSpan, absoluteMax - absoluteMin);
|
||||||
|
const zoomSpread = Math.pow(totalSpan / visibleSpan, ZOOM_SPREAD_EXPONENT);
|
||||||
|
const spread = Math.min(
|
||||||
|
MAX_FIT_BOOST,
|
||||||
|
Math.max(FULL_VIEW_WIDTH_SHARE, FULL_VIEW_WIDTH_SHARE * zoomSpread)
|
||||||
|
);
|
||||||
|
|
||||||
|
let visibleHalfSpan = 0;
|
||||||
|
for (const id of visibleIds) {
|
||||||
|
const node = tree.nodes.get(id);
|
||||||
|
if (!node) continue;
|
||||||
|
visibleHalfSpan = Math.max(
|
||||||
|
visibleHalfSpan,
|
||||||
|
Math.abs(node.x) + Math.abs(node.lean) + node.baseWidth / 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Never let what is on screen spill off the canvas.
|
||||||
|
const overflowCap = visibleHalfSpan > 0 ? usable / (2 * visibleHalfSpan) : Infinity;
|
||||||
|
return Math.min(fitAll * spread, overflowCap);
|
||||||
|
}, [canvas.w, tree, visibleIds, viewStart, viewEnd, absoluteMin, absoluteMax]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
fitTargetRef.current = targetScale;
|
||||||
|
if (!fitReadyRef.current) {
|
||||||
|
// First measured layout — adopt it instead of animating in from nothing.
|
||||||
|
fitReadyRef.current = true;
|
||||||
|
fitRef.current = targetScale;
|
||||||
|
setFitScale(targetScale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fitRafRef.current != null) return;
|
||||||
|
|
||||||
|
fitLastTsRef.current = performance.now();
|
||||||
|
const step = (now: number) => {
|
||||||
|
const dt = Math.min(0.05, Math.max(0, (now - fitLastTsRef.current) / 1000));
|
||||||
|
fitLastTsRef.current = now;
|
||||||
|
const t = 1 - Math.exp(-FIT_ANIM_RATE * dt);
|
||||||
|
const next = fitRef.current + (fitTargetRef.current - fitRef.current) * t;
|
||||||
|
if (Math.abs(fitTargetRef.current - next) < 0.002) {
|
||||||
|
fitRef.current = fitTargetRef.current;
|
||||||
|
setFitScale(fitTargetRef.current);
|
||||||
|
fitRafRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fitRef.current = next;
|
||||||
|
setFitScale(next);
|
||||||
|
fitRafRef.current = requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
fitRafRef.current = requestAnimationFrame(step);
|
||||||
|
}, [targetScale]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (fitRafRef.current != null) cancelAnimationFrame(fitRafRef.current);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { limbs, grafts } = useMemo(() => {
|
||||||
|
const span = viewEnd - viewStart || 1;
|
||||||
|
const centerX = canvas.w / 2;
|
||||||
|
const widthScale = Math.min(1.7, Math.max(0.55, fitScale));
|
||||||
|
const sx = (treeX: number) => centerX + treeX * fitScale;
|
||||||
|
const sy = (year: number) => canvas.h - ((year - viewStart) / span) * canvas.h;
|
||||||
|
|
||||||
|
const limbList: LimbShape[] = [];
|
||||||
|
const graftList: GraftShape[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pass 1 — screen geometry per movement.
|
||||||
|
*
|
||||||
|
* Two readability floors apply here, and only here: the structure and the
|
||||||
|
* dates stay untouched. A limb is drawn at least `MIN_LIMB_RISE_PX` long,
|
||||||
|
* and never thicker than it is long, so 2 500 years of trunk and 30 years
|
||||||
|
* of Fauvism can share one linear axis without the modern crown fusing
|
||||||
|
* into a solid bar.
|
||||||
|
*/
|
||||||
|
const drawn = new Map<
|
||||||
|
number,
|
||||||
|
{ base: Pt; c1: Pt; c2: Pt; tip: Pt; wBase: number; wTip: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const id of tree.drawOrder) {
|
||||||
|
const node = tree.nodes.get(id)!;
|
||||||
|
const { start_year: start, end_year: end } = node.movement;
|
||||||
|
if (end <= start) continue;
|
||||||
|
|
||||||
|
const base: Pt = { x: sx(limbXAtYear(node, start)), y: sy(start) };
|
||||||
|
const trueTipY = sy(end);
|
||||||
|
const tip: Pt = {
|
||||||
|
x: sx(limbXAtYear(node, end)),
|
||||||
|
y: Math.min(trueTipY, base.y - MIN_LIMB_RISE_PX),
|
||||||
|
};
|
||||||
|
const lengthPx = Math.hypot(tip.x - base.x, tip.y - base.y);
|
||||||
|
const cap = Math.max(3, lengthPx * MAX_THICKNESS_OF_LENGTH);
|
||||||
|
const wBase = Math.min(node.baseWidth * widthScale, cap);
|
||||||
|
const wTip = Math.min(node.tipWidth * widthScale, cap * 0.82);
|
||||||
|
const dy = tip.y - base.y;
|
||||||
|
drawn.set(id, {
|
||||||
|
base,
|
||||||
|
c1: { x: base.x, y: base.y + dy * 0.42 },
|
||||||
|
c2: { x: tip.x, y: tip.y - dy * 0.34 },
|
||||||
|
tip,
|
||||||
|
wBase,
|
||||||
|
wTip,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point on a drawn limb at the screen height closest to `targetY`. */
|
||||||
|
const pointOnLimb = (parentId: number, targetY: number) => {
|
||||||
|
const p = drawn.get(parentId)!;
|
||||||
|
const total = p.base.y - p.tip.y || 1;
|
||||||
|
const t = Math.min(1, Math.max(0, (p.base.y - targetY) / total));
|
||||||
|
return {
|
||||||
|
pt: cubicAt(p.base, p.c1, p.c2, p.tip, t),
|
||||||
|
width: p.wBase + (p.wTip - p.wBase) * t,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pass 2 — ribbons.
|
||||||
|
for (const id of tree.drawOrder) {
|
||||||
|
const node = tree.nodes.get(id)!;
|
||||||
|
const shape = drawn.get(id);
|
||||||
|
if (!shape) continue;
|
||||||
|
const { base, c1, c2, tip, wBase, wTip } = shape;
|
||||||
|
const { start_year: start, end_year: end } = node.movement;
|
||||||
|
|
||||||
|
const color = vividMovementColor(node.movement.color);
|
||||||
|
const d = ribbonPath(base, c1, c2, tip, wBase, wTip);
|
||||||
|
|
||||||
|
// Junction: the limb grows out of its parent a little before its own
|
||||||
|
// date, and always climbs far enough to read as a fork.
|
||||||
|
let junction = '';
|
||||||
|
const parent = node.parentId != null ? tree.nodes.get(node.parentId) : null;
|
||||||
|
if (parent && drawn.has(parent.movement.id)) {
|
||||||
|
const byDate = sy(branchOriginYear(parent, node));
|
||||||
|
const origin = pointOnLimb(
|
||||||
|
parent.movement.id,
|
||||||
|
Math.max(byDate, base.y + MIN_JUNCTION_RISE_PX)
|
||||||
|
);
|
||||||
|
const from = origin.pt;
|
||||||
|
const jdy = base.y - from.y;
|
||||||
|
const jLength = Math.hypot(base.x - from.x, jdy);
|
||||||
|
const jCap = Math.max(3, jLength * MAX_THICKNESS_OF_LENGTH);
|
||||||
|
const wFrom = Math.min(origin.width * 0.92, wBase * 1.25, jCap);
|
||||||
|
const jc1: Pt = { x: from.x, y: from.y + jdy * 0.45 };
|
||||||
|
const jc2: Pt = { x: base.x, y: base.y - jdy * 0.45 };
|
||||||
|
junction = ribbonPath(from, jc1, jc2, base, wFrom, Math.min(wBase, jCap));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roots: only the bottom of a tree, and only when that bottom is in frame.
|
||||||
|
const roots: string[] = [];
|
||||||
|
if (!parent && base.y > -canvas.h && base.y < canvas.h * 2) {
|
||||||
|
const flare = Math.max(22, wBase * 1.4);
|
||||||
|
for (const dir of [-1, -0.35, 0.35, 1]) {
|
||||||
|
const endPt: Pt = { x: base.x + dir * flare, y: base.y + flare * 0.72 };
|
||||||
|
const rc1: Pt = { x: base.x + dir * flare * 0.2, y: base.y + flare * 0.34 };
|
||||||
|
const rc2: Pt = { x: base.x + dir * flare * 0.8, y: base.y + flare * 0.5 };
|
||||||
|
roots.push(ribbonPath(base, rc1, rc2, endPt, wBase * 0.42, 1.5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inView = visibleIds.has(id);
|
||||||
|
const clampedStart = Math.max(start, viewStart);
|
||||||
|
const clampedEnd = Math.min(end, viewEnd);
|
||||||
|
const midYear = (clampedStart + clampedEnd) / 2;
|
||||||
|
const labelY = Math.min(
|
||||||
|
Math.max(sy(midYear), tip.y + MIN_LABEL_HEIGHT_PX / 2),
|
||||||
|
base.y
|
||||||
|
);
|
||||||
|
|
||||||
|
limbList.push({
|
||||||
|
id,
|
||||||
|
name: node.movement.name,
|
||||||
|
color,
|
||||||
|
shade: shadeMovementColor(color),
|
||||||
|
d,
|
||||||
|
junction,
|
||||||
|
tip: { x: tip.x, y: tip.y, r: Math.max(1.5, wTip / 2) },
|
||||||
|
roots,
|
||||||
|
labelX: sx(limbXAtYear(node, midYear)),
|
||||||
|
labelY,
|
||||||
|
labelVisible: inView,
|
||||||
|
yearRange: `${formatYear(start)} – ${formatYear(end)}`,
|
||||||
|
depth: node.depth,
|
||||||
|
inView,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const graftId of node.graftParentIds) {
|
||||||
|
const graftParent = tree.nodes.get(graftId);
|
||||||
|
if (!graftParent || !drawn.has(graftId)) continue;
|
||||||
|
const byDate = sy(branchOriginYear(graftParent, node));
|
||||||
|
const origin = pointOnLimb(graftId, Math.max(byDate, base.y + MIN_JUNCTION_RISE_PX));
|
||||||
|
const from = origin.pt;
|
||||||
|
const gdy = base.y - from.y;
|
||||||
|
const gWidth = Math.max(2.5, Math.min(wBase * 0.3, 9));
|
||||||
|
graftList.push({
|
||||||
|
key: `${graftId}-${id}`,
|
||||||
|
d: ribbonPath(
|
||||||
|
from,
|
||||||
|
{ x: from.x, y: from.y + gdy * 0.55 },
|
||||||
|
{ x: base.x, y: base.y - gdy * 0.3 },
|
||||||
|
base,
|
||||||
|
gWidth * 0.7,
|
||||||
|
gWidth
|
||||||
|
),
|
||||||
|
color: vividMovementColor(graftParent.movement.color),
|
||||||
|
fromId: graftId,
|
||||||
|
toId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideOverlappingLabels(limbList, canvas.w, canvas.h);
|
||||||
|
return { limbs: limbList, grafts: graftList };
|
||||||
|
}, [tree, viewStart, viewEnd, canvas.w, canvas.h, fitScale, visibleIds]);
|
||||||
|
|
||||||
|
/** A hovered movement lights up its whole descent line back to the root. */
|
||||||
|
const lineageIds = useMemo(() => {
|
||||||
|
const ids = new Set<number>();
|
||||||
|
if (hoveredId == null) return ids;
|
||||||
|
let cursor: number | null = hoveredId;
|
||||||
|
let guard = 0;
|
||||||
|
while (cursor != null && guard++ < 64) {
|
||||||
|
ids.add(cursor);
|
||||||
|
const node: MovementTreeNode | undefined = tree.nodes.get(cursor);
|
||||||
|
if (!node) break;
|
||||||
|
for (const graftId of node.graftParentIds) ids.add(graftId);
|
||||||
|
cursor = node.parentId;
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}, [hoveredId, tree]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = canvasRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const { viewStart: vs, viewEnd: ve } = viewRef.current;
|
||||||
|
// Bottom = oldest, so invert the pointer offset before reusing the shared
|
||||||
|
// left-to-right zoom math.
|
||||||
|
const invertedY = rect.height - (e.clientY - rect.top);
|
||||||
|
const next = zoomTimelineView(
|
||||||
|
invertedY,
|
||||||
|
0,
|
||||||
|
rect.height,
|
||||||
|
e.deltaY,
|
||||||
|
vs,
|
||||||
|
ve,
|
||||||
|
absoluteMin,
|
||||||
|
absoluteMax
|
||||||
|
);
|
||||||
|
onViewChangeRef.current(next.start, next.end);
|
||||||
|
};
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false, capture: true });
|
||||||
|
return () => el.removeEventListener('wheel', onWheel, { capture: true });
|
||||||
|
}, [absoluteMin, absoluteMax]);
|
||||||
|
|
||||||
|
const handlePanStart = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setPanning(true);
|
||||||
|
panStart.current = { y: e.clientY, viewStart, viewEnd };
|
||||||
|
},
|
||||||
|
[viewStart, viewEnd]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!panning) return;
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
const dy = e.clientY - panStart.current.y;
|
||||||
|
const next = panTimelineView(
|
||||||
|
-dy,
|
||||||
|
rect.height,
|
||||||
|
panStart.current.viewStart,
|
||||||
|
panStart.current.viewEnd,
|
||||||
|
absoluteMin,
|
||||||
|
absoluteMax
|
||||||
|
);
|
||||||
|
onViewChangeRef.current(next.start, next.end);
|
||||||
|
};
|
||||||
|
const onUp = () => setPanning(false);
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
}, [panning, absoluteMin, absoluteMax]);
|
||||||
|
|
||||||
|
if (movements.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="mtree-empty">
|
||||||
|
<p>No art movements to grow a tree from yet.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const anyInView = limbs.some((limb) => limb.inView);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mtree">
|
||||||
|
<p className="mtree-caption">
|
||||||
|
{t('captionTreeFlow')}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
ref={canvasRef}
|
||||||
|
className={`mtree-canvas${panning ? ' mtree-panning' : ''}`}
|
||||||
|
onMouseDown={handlePanStart}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="mtree-svg"
|
||||||
|
viewBox={`0 0 ${canvas.w} ${canvas.h}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
{limbs.map((limb) => (
|
||||||
|
<linearGradient
|
||||||
|
key={`grad-${limb.id}`}
|
||||||
|
id={`mtree-limb-${limb.id}`}
|
||||||
|
gradientUnits="objectBoundingBox"
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="1"
|
||||||
|
y2="0"
|
||||||
|
>
|
||||||
|
<stop offset="0%" stopColor={limb.shade} />
|
||||||
|
<stop offset="45%" stopColor={limb.color} />
|
||||||
|
<stop offset="100%" stopColor={limb.shade} />
|
||||||
|
</linearGradient>
|
||||||
|
))}
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g className="mtree-grafts">
|
||||||
|
{grafts.map((graft) => (
|
||||||
|
<path
|
||||||
|
key={graft.key}
|
||||||
|
d={graft.d}
|
||||||
|
fill={graft.color}
|
||||||
|
className={`mtree-graft${
|
||||||
|
lineageIds.has(graft.toId) && lineageIds.has(graft.fromId)
|
||||||
|
? ' mtree-graft-lit'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{limbs.map((limb) => {
|
||||||
|
const lit = lineageIds.has(limb.id);
|
||||||
|
const dim = hoveredId != null && !lit;
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={limb.id}
|
||||||
|
className={`mtree-limb${lit ? ' mtree-limb-lit' : ''}${
|
||||||
|
dim ? ' mtree-limb-dim' : ''
|
||||||
|
}`}
|
||||||
|
onMouseEnter={() => setHoveredId(limb.id)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onMovementClick?.(limb.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<title>{`${limb.name} · ${limb.yearRange}`}</title>
|
||||||
|
{limb.roots.map((d, i) => (
|
||||||
|
<path key={`root-${i}`} d={d} fill={limb.shade} className="mtree-root" />
|
||||||
|
))}
|
||||||
|
{limb.junction && (
|
||||||
|
<path d={limb.junction} fill={`url(#mtree-limb-${limb.id})`} />
|
||||||
|
)}
|
||||||
|
<path d={limb.d} fill={`url(#mtree-limb-${limb.id})`} />
|
||||||
|
<circle cx={limb.tip.x} cy={limb.tip.y} r={limb.tip.r} fill={limb.color} />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{limbs
|
||||||
|
.filter((limb) => limb.labelVisible)
|
||||||
|
.map((limb) => (
|
||||||
|
<button
|
||||||
|
key={`label-${limb.id}`}
|
||||||
|
type="button"
|
||||||
|
className={`mtree-label${
|
||||||
|
hoveredId != null && !lineageIds.has(limb.id) ? ' mtree-label-dim' : ''
|
||||||
|
}`}
|
||||||
|
style={{ left: `${limb.labelX}px`, top: `${limb.labelY}px` }}
|
||||||
|
title={`${limb.name} · ${limb.yearRange}`}
|
||||||
|
onMouseEnter={() => setHoveredId(limb.id)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onMovementClick?.(limb.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{limb.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!anyInView && (
|
||||||
|
<div className="mtree-out-of-range">
|
||||||
|
<p>No movements in this time range — zoom out to see the whole tree.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { HistoricalEra } from '../types';
|
import type { HistoricalEra } from '../types';
|
||||||
import {
|
import {
|
||||||
HISTORICAL_EVENTS,
|
HISTORICAL_EVENTS,
|
||||||
@@ -38,6 +39,7 @@ function formatYear(year: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight }: Props) {
|
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight }: Props) {
|
||||||
|
const { t } = useTranslation('home');
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [dragging, setDragging] = useState<'left' | 'right' | 'pan' | null>(null);
|
const [dragging, setDragging] = useState<'left' | 'right' | 'pan' | null>(null);
|
||||||
const [containerWidth, setContainerWidth] = useState(800);
|
const [containerWidth, setContainerWidth] = useState(800);
|
||||||
@@ -365,7 +367,7 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="timeline-hint">
|
<p className="timeline-hint">
|
||||||
Click an era or event to zoom · Scroll to zoom · Drag to pan
|
{t('captionClassicTimeline')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { ArtMovement } from '../types';
|
import type { ArtMovement } from '../types';
|
||||||
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
||||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||||
|
import { vividMovementColor } from '../utils/movementColor';
|
||||||
import './VerticalMovementBands.css';
|
import './VerticalMovementBands.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -51,66 +53,6 @@ function yearToBottomPercent(year: number, start: number, end: number): number {
|
|||||||
return ((year - start) / (end - start)) * 100;
|
return ((year - start) / (end - start)) * 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseHexColor(hex: string): [number, number, number] {
|
|
||||||
const normalized = hex.replace('#', '');
|
|
||||||
const value =
|
|
||||||
normalized.length === 3
|
|
||||||
? normalized
|
|
||||||
.split('')
|
|
||||||
.map((c) => c + c)
|
|
||||||
.join('')
|
|
||||||
: normalized;
|
|
||||||
const n = parseInt(value, 16);
|
|
||||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
||||||
}
|
|
||||||
|
|
||||||
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
|
||||||
r /= 255;
|
|
||||||
g /= 255;
|
|
||||||
b /= 255;
|
|
||||||
const max = Math.max(r, g, b);
|
|
||||||
const min = Math.min(r, g, b);
|
|
||||||
const l = (max + min) / 2;
|
|
||||||
if (max === min) return [0, 0, l];
|
|
||||||
const d = max - min;
|
|
||||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
||||||
let h = 0;
|
|
||||||
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
|
||||||
else if (max === g) h = ((b - r) / d + 2) / 6;
|
|
||||||
else h = ((r - g) / d + 4) / 6;
|
|
||||||
return [h * 360, s, l];
|
|
||||||
}
|
|
||||||
|
|
||||||
function hslToHex(h: number, s: number, l: number): string {
|
|
||||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
||||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
||||||
const m = l - c / 2;
|
|
||||||
let r = 0;
|
|
||||||
let g = 0;
|
|
||||||
let b = 0;
|
|
||||||
if (h < 60) [r, g, b] = [c, x, 0];
|
|
||||||
else if (h < 120) [r, g, b] = [x, c, 0];
|
|
||||||
else if (h < 180) [r, g, b] = [0, c, x];
|
|
||||||
else if (h < 240) [r, g, b] = [0, x, c];
|
|
||||||
else if (h < 300) [r, g, b] = [x, 0, c];
|
|
||||||
else [r, g, b] = [c, 0, x];
|
|
||||||
const toByte = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
|
||||||
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function vividMovementColor(hex: string): string {
|
|
||||||
try {
|
|
||||||
const [r, g, b] = parseHexColor(hex);
|
|
||||||
const [h, s, l] = rgbToHsl(r, g, b);
|
|
||||||
const s2 = s < 0.1 ? Math.min(0.55, s + 0.42) : Math.min(1, s * 1.65 + 0.08);
|
|
||||||
const l2 =
|
|
||||||
l < 0.22 ? 0.5 : l > 0.78 ? 0.62 : Math.min(0.68, Math.max(0.4, l * 0.75 + 0.28));
|
|
||||||
return hslToHex(h, s2, l2);
|
|
||||||
} catch {
|
|
||||||
return hex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLineageParentMap(
|
function buildLineageParentMap(
|
||||||
visible: ArtMovement[],
|
visible: ArtMovement[],
|
||||||
nameToId: Map<string, number>
|
nameToId: Map<string, number>
|
||||||
@@ -423,6 +365,7 @@ export default function VerticalMovementBands({
|
|||||||
onViewChange,
|
onViewChange,
|
||||||
onMovementClick,
|
onMovementClick,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation('home');
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
const [canvasSize, setCanvasSize] = useState({ w: 800, h: 600 });
|
const [canvasSize, setCanvasSize] = useState({ w: 800, h: 600 });
|
||||||
const [panning, setPanning] = useState(false);
|
const [panning, setPanning] = useState(false);
|
||||||
@@ -690,7 +633,7 @@ export default function VerticalMovementBands({
|
|||||||
return (
|
return (
|
||||||
<div className="vflow">
|
<div className="vflow">
|
||||||
<p className="vflow-caption">
|
<p className="vflow-caption">
|
||||||
Bottom → top through history · scroll to zoom · drag to pan · click a stream
|
{t('captionVerticalFlow')}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { HistoricalEra } from '../types';
|
import type { HistoricalEra } from '../types';
|
||||||
import {
|
import {
|
||||||
HISTORICAL_EVENTS,
|
HISTORICAL_EVENTS,
|
||||||
@@ -60,6 +61,7 @@ export default function VerticalTimeline({
|
|||||||
absoluteMax,
|
absoluteMax,
|
||||||
lifespanHighlight,
|
lifespanHighlight,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation('home');
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [dragging, setDragging] = useState<'start' | 'end' | 'pan' | null>(null);
|
const [dragging, setDragging] = useState<'start' | 'end' | 'pan' | null>(null);
|
||||||
const [containerHeight, setContainerHeight] = useState(600);
|
const [containerHeight, setContainerHeight] = useState(600);
|
||||||
@@ -409,7 +411,7 @@ export default function VerticalTimeline({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="vtimeline-hint">Bottom → top · Scroll to zoom · Drag to pan</p>
|
<p className="vtimeline-hint">{t('captionVerticalTimeline')}</p>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ import type {
|
|||||||
import { galleryImageUrlCandidates, imageUrl, api } from '../api/client';
|
import { galleryImageUrlCandidates, imageUrl, api } from '../api/client';
|
||||||
import { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
|
import { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
|
||||||
import { cloneSurfaceTexture, getSurfaceTexture, type SurfaceTextureKind } from '../utils/galleryProceduralTextures';
|
import { cloneSurfaceTexture, getSurfaceTexture, type SurfaceTextureKind } from '../utils/galleryProceduralTextures';
|
||||||
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
|
import {
|
||||||
|
resolveMovementInteriorStyle,
|
||||||
|
type MovementDetailKind,
|
||||||
|
type MovementInteriorStyle,
|
||||||
|
type GalleryWindowSpec,
|
||||||
|
} from '../data/movement-interior-styles';
|
||||||
import { useTexturedMaterial } from '../hooks/useTexturedMaterial';
|
import { useTexturedMaterial } from '../hooks/useTexturedMaterial';
|
||||||
import MovementHallDetails from './MovementHallDetails';
|
import MovementHallDetails from './MovementHallDetails';
|
||||||
import GalleryWindows, { GalleryTrackLights } from './GalleryWindows';
|
import GalleryWindows, { GalleryTrackLights } from './GalleryWindows';
|
||||||
@@ -84,6 +89,15 @@ interface TourGalleryProps extends BaseGalleryProps {
|
|||||||
|
|
||||||
type Props = ArtistGalleryProps | MovementGalleryProps | TourGalleryProps;
|
type Props = ArtistGalleryProps | MovementGalleryProps | TourGalleryProps;
|
||||||
|
|
||||||
|
/** Period interiors built from steel, concrete or a white cube read better under the city HDR. */
|
||||||
|
const CITY_ENVIRONMENT_DETAILS = new Set<MovementDetailKind>([
|
||||||
|
'futurist',
|
||||||
|
'suprematist',
|
||||||
|
'constructivist',
|
||||||
|
'loft',
|
||||||
|
'white-cube',
|
||||||
|
]);
|
||||||
|
|
||||||
const WALL_HEIGHT = 4.2;
|
const WALL_HEIGHT = 4.2;
|
||||||
const WALL_THICKNESS = 0.18;
|
const WALL_THICKNESS = 0.18;
|
||||||
const MOUNT_OFFSET = 0.16;
|
const MOUNT_OFFSET = 0.16;
|
||||||
@@ -1739,7 +1753,14 @@ function ArtistHall({
|
|||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{interiorStyle && (
|
{interiorStyle && (
|
||||||
<MovementHallDetails style={interiorStyle} width={width} depth={depth} halfW={halfW} halfD={halfD} />
|
<MovementHallDetails
|
||||||
|
style={interiorStyle}
|
||||||
|
width={width}
|
||||||
|
depth={depth}
|
||||||
|
halfW={halfW}
|
||||||
|
halfD={halfD}
|
||||||
|
windows={computedWindows}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
@@ -2635,7 +2656,7 @@ export default function VirtualGallery(props: Props) {
|
|||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Environment
|
<Environment
|
||||||
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
|
preset={interiorStyle && CITY_ENVIRONMENT_DETAILS.has(interiorStyle.details) ? 'city' : 'warehouse'}
|
||||||
environmentIntensity={(interiorStyle ? 0.55 : 0.25) * lightScale}
|
environmentIntensity={(interiorStyle ? 0.55 : 0.25) * lightScale}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import {
|
||||||
|
CofferedCeiling,
|
||||||
|
CorniceRing,
|
||||||
|
FlutedShaft,
|
||||||
|
HangingFixture,
|
||||||
|
RoundArch,
|
||||||
|
ShaftBase,
|
||||||
|
TuscanCapital,
|
||||||
|
WallBand,
|
||||||
|
} from './primitives';
|
||||||
|
import {
|
||||||
|
shade,
|
||||||
|
useBayZ,
|
||||||
|
useCorners,
|
||||||
|
useRepeat,
|
||||||
|
useWallClearance,
|
||||||
|
type PeriodProps,
|
||||||
|
} from './geometry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roman atrium — Ancient / Classical.
|
||||||
|
*
|
||||||
|
* Engaged Tuscan half-columns on a bay rhythm carry a compressed but complete
|
||||||
|
* entablature (architrave, plain frieze, dentil course, projecting cornice).
|
||||||
|
* Below the frame line the walls carry the Pompeian red socle of a Fourth Style
|
||||||
|
* room; a bronze polycandelon hangs over the centre in place of ceiling lights.
|
||||||
|
*/
|
||||||
|
export function RomanAtriumDetails({ room, trim }: PeriodProps) {
|
||||||
|
const bays = useBayZ(room.depth, room.halfD, 4.0, 9);
|
||||||
|
const corners = useCorners(room.halfW, room.halfD, 0.1);
|
||||||
|
const stone = '#e6dcc6';
|
||||||
|
const shadowStone = shade(stone, -0.14);
|
||||||
|
const socle = '#7c3227';
|
||||||
|
|
||||||
|
const dentils = useRepeat(room.depth, 0.46, 30);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Pompeian socle with a black skirting course */}
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<group key={wall}>
|
||||||
|
<WallBand wall={wall} room={room} y={0.62} height={1.16} proud={0.07} color={socle} roughness={0.8} metalness={0.04} />
|
||||||
|
<WallBand wall={wall} room={room} y={0.09} height={0.18} proud={0.09} color="#241a14" roughness={0.7} metalness={0.06} />
|
||||||
|
<WallBand wall={wall} room={room} y={1.24} height={0.08} proud={0.1} color={stone} roughness={0.66} metalness={0.06} />
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Engaged half-columns on the bay rhythm */}
|
||||||
|
{bays.map((z, i) => (
|
||||||
|
<group key={i}>
|
||||||
|
{[-1, 1].map((side) => (
|
||||||
|
<group key={side} position={[side * (room.halfW - 0.115), 0, z]}>
|
||||||
|
<ShaftBase size={0.15} color={shadowStone} />
|
||||||
|
<group position={[0, 0.26, 0]}>
|
||||||
|
<FlutedShaft height={3.34} radius={0.135} color={stone} flutes={10} facing="x" />
|
||||||
|
</group>
|
||||||
|
<TuscanCapital y={3.72} size={0.15} color={stone} />
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Entablature: architrave, frieze and dentils along the colonnaded walls */}
|
||||||
|
{(['left', 'right'] as const).map((wall) => (
|
||||||
|
<group key={wall}>
|
||||||
|
<WallBand wall={wall} room={room} y={3.9} height={0.1} proud={0.16} color={shadowStone} roughness={0.7} />
|
||||||
|
<WallBand wall={wall} room={room} y={3.99} height={0.11} proud={0.13} color={stone} roughness={0.74} />
|
||||||
|
{dentils.map((z, i) => (
|
||||||
|
<mesh
|
||||||
|
key={i}
|
||||||
|
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.16), 4.07, z]}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[0.16, 0.07, 0.16]} />
|
||||||
|
<meshStandardMaterial color={shade(stone, 0.06)} roughness={0.7} metalness={0.05} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Projecting cornice returning around the whole room */}
|
||||||
|
<CorniceRing room={room} y={4.13} height={0.09} proud={0.19} color={trim} roughness={0.62} metalness={0.12} />
|
||||||
|
|
||||||
|
{/* Corner antae — square pilasters closing the order at each corner */}
|
||||||
|
{corners.map(([x, z], i) => (
|
||||||
|
<mesh key={i} position={[x, 1.95, z]}>
|
||||||
|
<boxGeometry args={[0.2, 3.9, 0.2]} />
|
||||||
|
<meshStandardMaterial color={shade(stone, 0.04)} roughness={0.76} metalness={0.04} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<HangingFixture
|
||||||
|
position={[0, 4.05, 0]}
|
||||||
|
dropLength={0.72}
|
||||||
|
radius={0.46}
|
||||||
|
arms={8}
|
||||||
|
metalColor={trim}
|
||||||
|
light
|
||||||
|
lightColor="#ffb862"
|
||||||
|
lightIntensity={0.9}
|
||||||
|
lightDistance={9}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Florentine palazzo — Early Renaissance.
|
||||||
|
*
|
||||||
|
* Brunelleschi's grammar: flat pietra serena pilasters set against lime plaster,
|
||||||
|
* a blind arcade of semicircular arches springing between them, glazed roundels
|
||||||
|
* in the spandrels, and a painted-and-beamed quattrocento ceiling.
|
||||||
|
*/
|
||||||
|
export function QuattrocentoDetails({ room, trim, windows }: PeriodProps) {
|
||||||
|
const bays = useBayZ(room.depth, room.halfD, 3.8, 9);
|
||||||
|
const clearLeft = useWallClearance(windows, 'left');
|
||||||
|
const clearRight = useWallClearance(windows, 'right');
|
||||||
|
const serena = '#8b8f8a';
|
||||||
|
const serenaLight = shade(serena, 0.16);
|
||||||
|
const step = bays.length > 1 ? bays[1] - bays[0] : room.depth / 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Pietra serena dado with a moulded cap */}
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<group key={wall}>
|
||||||
|
<WallBand wall={wall} room={room} y={0.6} height={1.2} proud={0.05} color={shade(serena, 0.24)} roughness={0.72} metalness={0.05} />
|
||||||
|
<WallBand wall={wall} room={room} y={1.24} height={0.09} proud={0.08} color={serena} roughness={0.62} metalness={0.07} />
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Flat pilasters carrying the arcade */}
|
||||||
|
{bays.map((z, i) => (
|
||||||
|
<group key={i}>
|
||||||
|
{[-1, 1].map((side) => (
|
||||||
|
<group key={side} position={[side * (room.halfW - 0.05), 0, z]}>
|
||||||
|
<mesh position={[0, 1.78, 0]}>
|
||||||
|
<boxGeometry args={[0.08, 3.56, 0.34]} />
|
||||||
|
<meshStandardMaterial color={serena} roughness={0.66} metalness={0.07} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.11, 0]}>
|
||||||
|
<boxGeometry args={[0.11, 0.22, 0.44]} />
|
||||||
|
<meshStandardMaterial color={shade(serena, -0.08)} roughness={0.7} metalness={0.06} />
|
||||||
|
</mesh>
|
||||||
|
{/* Simplified Corinthian capital */}
|
||||||
|
<mesh position={[0, 3.68, 0]}>
|
||||||
|
<boxGeometry args={[0.1, 0.2, 0.44]} />
|
||||||
|
<meshStandardMaterial color={serenaLight} roughness={0.6} metalness={0.08} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 3.82, 0]}>
|
||||||
|
<boxGeometry args={[0.12, 0.09, 0.5]} />
|
||||||
|
<meshStandardMaterial color={serenaLight} roughness={0.58} metalness={0.08} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Blind arcade between the pilasters, plus a roundel in each spandrel */}
|
||||||
|
{bays.slice(0, -1).map((z, i) => {
|
||||||
|
const mid = z + step / 2;
|
||||||
|
return (
|
||||||
|
<group key={i}>
|
||||||
|
{([-1, 1] as const).map((side) => (
|
||||||
|
<group key={side} position={[side * (room.halfW - 0.05), 3.88, mid]} rotation={[0, Math.PI / 2, 0]}>
|
||||||
|
<RoundArch
|
||||||
|
span={step - 0.34}
|
||||||
|
rise={0.24}
|
||||||
|
thickness={0.12}
|
||||||
|
depth={0.09}
|
||||||
|
color={serena}
|
||||||
|
segments={9}
|
||||||
|
roughness={0.64}
|
||||||
|
metalness={0.07}
|
||||||
|
/>
|
||||||
|
{/* Glazed tondo, only where no window claims the bay */}
|
||||||
|
{(side < 0 ? clearLeft : clearRight)(mid, 0.22) && (
|
||||||
|
<mesh position={[0, -0.5, 0]}>
|
||||||
|
<torusGeometry args={[0.17, 0.035, 8, 20]} />
|
||||||
|
<meshStandardMaterial color={trim} roughness={0.42} metalness={0.35} />
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Painted beam ceiling over the arcade */}
|
||||||
|
<CorniceRing room={room} y={4.11} height={0.1} proud={0.14} color={serena} roughness={0.64} metalness={0.07} />
|
||||||
|
<CofferedCeiling
|
||||||
|
room={room}
|
||||||
|
y={4.09}
|
||||||
|
cell={2.0}
|
||||||
|
panelColor="#e2d3ba"
|
||||||
|
ribColor="#8a5a30"
|
||||||
|
ribWidth={0.14}
|
||||||
|
ribDrop={0.14}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roman palazzo — High Renaissance.
|
||||||
|
*
|
||||||
|
* Bramante's cinquecento order: paired fluted Corinthian pilasters on a wide
|
||||||
|
* bay, a full marble entablature, panelled dado, and a rosetted coffer field
|
||||||
|
* modelled on the Pantheon soffit.
|
||||||
|
*/
|
||||||
|
export function CinquecentoDetails({ room, trim }: PeriodProps) {
|
||||||
|
const bays = useBayZ(room.depth, room.halfD, 5.2, 6);
|
||||||
|
const marble = '#efe9dd';
|
||||||
|
const grey = shade(marble, -0.16);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Panelled marble dado */}
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<group key={wall}>
|
||||||
|
<WallBand wall={wall} room={room} y={0.58} height={1.16} proud={0.06} color={grey} roughness={0.44} metalness={0.1} />
|
||||||
|
<WallBand wall={wall} room={room} y={1.2} height={0.1} proud={0.09} color={marble} roughness={0.38} metalness={0.12} />
|
||||||
|
<WallBand wall={wall} room={room} y={0.07} height={0.14} proud={0.1} color={shade(grey, -0.18)} roughness={0.4} metalness={0.12} />
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Paired fluted pilasters */}
|
||||||
|
{bays.map((z, i) => (
|
||||||
|
<group key={i}>
|
||||||
|
{[-1, 1].map((side) =>
|
||||||
|
[-0.19, 0.19].map((dz) => (
|
||||||
|
<group key={`${side}-${dz}`} position={[side * (room.halfW - 0.13), 0, z + dz]}>
|
||||||
|
<ShaftBase size={0.12} color={grey} />
|
||||||
|
<group position={[0, 0.24, 0]}>
|
||||||
|
<FlutedShaft height={3.32} radius={0.11} color={marble} flutes={8} facing="x" />
|
||||||
|
</group>
|
||||||
|
<mesh position={[0, 3.66, 0]}>
|
||||||
|
<cylinderGeometry args={[0.15, 0.1, 0.24, 12]} />
|
||||||
|
<meshStandardMaterial color={marble} roughness={0.42} metalness={0.1} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 3.82, 0]}>
|
||||||
|
<boxGeometry args={[0.12, 0.1, 0.34]} />
|
||||||
|
<meshStandardMaterial color={shade(marble, 0.06)} roughness={0.4} metalness={0.11} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Full entablature on the pilastered walls, cornice all round */}
|
||||||
|
{(['left', 'right'] as const).map((wall) => (
|
||||||
|
<group key={wall}>
|
||||||
|
<WallBand wall={wall} room={room} y={3.92} height={0.1} proud={0.15} color={marble} roughness={0.42} metalness={0.1} />
|
||||||
|
<WallBand wall={wall} room={room} y={4.02} height={0.1} proud={0.12} color={grey} roughness={0.46} metalness={0.1} />
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
<CorniceRing room={room} y={4.12} height={0.09} proud={0.18} color={trim} roughness={0.4} metalness={0.28} />
|
||||||
|
|
||||||
|
<CofferedCeiling
|
||||||
|
room={room}
|
||||||
|
y={4.08}
|
||||||
|
cell={2.6}
|
||||||
|
panelColor="#f2ede3"
|
||||||
|
ribColor="#ded5c4"
|
||||||
|
ribWidth={0.2}
|
||||||
|
ribDrop={0.18}
|
||||||
|
rosette={trim}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import {
|
||||||
|
CeilingBeams,
|
||||||
|
CeilingRose,
|
||||||
|
CofferedCeiling,
|
||||||
|
CorniceRing,
|
||||||
|
FlutedShaft,
|
||||||
|
HangingFixture,
|
||||||
|
IonicCapital,
|
||||||
|
ShaftBase,
|
||||||
|
WallBand,
|
||||||
|
} from './primitives';
|
||||||
|
import {
|
||||||
|
shade,
|
||||||
|
useBayZ,
|
||||||
|
useCorners,
|
||||||
|
useRepeat,
|
||||||
|
useWallClearance,
|
||||||
|
type PeriodProps,
|
||||||
|
} from './geometry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flemish panel hall — Northern Renaissance.
|
||||||
|
*
|
||||||
|
* Oak linenfold wainscot under a moulded cap rail, carved corbels carrying an
|
||||||
|
* exposed beam-and-joist ceiling, and the brass ring chandelier that hangs in
|
||||||
|
* the background of half the period's interiors.
|
||||||
|
*/
|
||||||
|
export function FlemishDetails({ room, trim }: PeriodProps) {
|
||||||
|
const beams = useBayZ(room.depth, room.halfD, 2.3, 10);
|
||||||
|
const oak = '#6f4a2c';
|
||||||
|
const oakLight = shade(oak, 0.2);
|
||||||
|
|
||||||
|
const folds = useRepeat(room.depth, 0.55, 34);
|
||||||
|
|
||||||
|
const backFolds = useRepeat(room.width, 0.55, 34);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Wainscot ground, cap rail and skirting */}
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<group key={wall}>
|
||||||
|
<WallBand wall={wall} room={room} y={0.66} height={1.24} proud={0.06} color={oak} roughness={0.66} metalness={0.08} />
|
||||||
|
<WallBand wall={wall} room={room} y={1.32} height={0.12} proud={0.1} color={oakLight} roughness={0.54} metalness={0.1} />
|
||||||
|
<WallBand wall={wall} room={room} y={0.08} height={0.16} proud={0.09} color={shade(oak, -0.3)} roughness={0.6} metalness={0.09} />
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Linenfold ribs across the wainscot */}
|
||||||
|
{(['left', 'right'] as const).map((wall) =>
|
||||||
|
folds.map((z, i) => (
|
||||||
|
<mesh
|
||||||
|
key={`${wall}-${i}`}
|
||||||
|
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.07), 0.68, z]}
|
||||||
|
>
|
||||||
|
<cylinderGeometry args={[0.035, 0.035, 1.18, 6]} />
|
||||||
|
<meshStandardMaterial color={oakLight} roughness={0.6} metalness={0.09} />
|
||||||
|
</mesh>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{backFolds.map((x, i) => (
|
||||||
|
<mesh key={`b-${i}`} position={[x, 0.68, -room.halfD + 0.07]}>
|
||||||
|
<cylinderGeometry args={[0.035, 0.035, 1.18, 6]} />
|
||||||
|
<meshStandardMaterial color={oakLight} roughness={0.6} metalness={0.09} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Carved corbels carrying the beams */}
|
||||||
|
{beams.map((z, i) => (
|
||||||
|
<group key={i}>
|
||||||
|
{[-1, 1].map((side) => (
|
||||||
|
<mesh key={side} position={[side * (room.halfW - 0.11), 3.84, z]}>
|
||||||
|
<boxGeometry args={[0.2, 0.24, 0.2]} />
|
||||||
|
<meshStandardMaterial color={shade(oak, -0.12)} roughness={0.66} metalness={0.08} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<CeilingBeams room={room} positions={beams} axis="x" width={0.18} height={0.26} y={3.98} color={shade(oak, -0.2)} roughness={0.72} metalness={0.06} />
|
||||||
|
<CorniceRing room={room} y={4.14} height={0.08} proud={0.12} color={oakLight} roughness={0.56} metalness={0.1} />
|
||||||
|
|
||||||
|
<HangingFixture
|
||||||
|
position={[0, 3.94, 0]}
|
||||||
|
dropLength={0.5}
|
||||||
|
radius={0.4}
|
||||||
|
arms={8}
|
||||||
|
metalColor={trim}
|
||||||
|
flameColor="#ffe0a0"
|
||||||
|
light
|
||||||
|
lightColor="#ffca80"
|
||||||
|
lightIntensity={0.85}
|
||||||
|
lightDistance={8}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mannerist gallery — Mannerism.
|
||||||
|
*
|
||||||
|
* The order used against itself: herm pilasters that taper the wrong way,
|
||||||
|
* banded rustication blocking the shafts, oversized scroll consoles, and a
|
||||||
|
* cornice deliberately broken over each bay.
|
||||||
|
*/
|
||||||
|
export function ManneristDetails({ room, trim, windows }: PeriodProps) {
|
||||||
|
const clearLeft = useWallClearance(windows, 'left');
|
||||||
|
const clearRight = useWallClearance(windows, 'right');
|
||||||
|
const bays = useBayZ(room.depth, room.halfD, 4.4, 7);
|
||||||
|
const stone = '#ddc9ae';
|
||||||
|
const dark = shade(stone, -0.34);
|
||||||
|
const bands = [0.55, 1.55, 2.35, 3.05];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Heavy blocked socle */}
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<WallBand key={wall} wall={wall} room={room} y={0.5} height={1.0} proud={0.08} color={dark} roughness={0.82} metalness={0.05} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Herm pilasters — wider at the shoulder than at the foot */}
|
||||||
|
{bays.map((z, i) => (
|
||||||
|
<group key={i}>
|
||||||
|
{[-1, 1].map((side) => (
|
||||||
|
<group key={side} position={[side * (room.halfW - 0.05), 0, z]}>
|
||||||
|
<mesh position={[0, 1.9, 0]}>
|
||||||
|
<boxGeometry args={[0.09, 3.8, 0.3]} />
|
||||||
|
<meshStandardMaterial color={stone} roughness={0.78} metalness={0.05} />
|
||||||
|
</mesh>
|
||||||
|
{/* Rustication blocks interrupting the shaft */}
|
||||||
|
{bands.map((y) => (
|
||||||
|
<mesh key={y} position={[side * 0.03, y, 0]}>
|
||||||
|
<boxGeometry args={[0.14, 0.3, 0.44]} />
|
||||||
|
<meshStandardMaterial color={shade(stone, -0.16)} roughness={0.86} metalness={0.04} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
{/* Scroll console instead of a capital */}
|
||||||
|
<mesh position={[0, 3.86, 0]} rotation={[0, Math.PI / 2, 0]}>
|
||||||
|
<torusGeometry args={[0.14, 0.055, 8, 16, Math.PI * 1.5]} />
|
||||||
|
<meshStandardMaterial color={shade(stone, 0.12)} roughness={0.66} metalness={0.08} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 4.03, 0]}>
|
||||||
|
<boxGeometry args={[0.16, 0.12, 0.46]} />
|
||||||
|
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.24} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Broken cornice — segments that stop short of each bay */}
|
||||||
|
{(['left', 'right'] as const).map((wall) => (
|
||||||
|
<group key={wall}>
|
||||||
|
<WallBand wall={wall} room={room} y={3.96} height={0.12} proud={0.14} span={0.62} color={stone} roughness={0.72} metalness={0.06} />
|
||||||
|
<WallBand wall={wall} room={room} y={4.1} height={0.08} proud={0.17} span={0.86} color={trim} roughness={0.52} metalness={0.26} />
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
<CorniceRing room={room} y={4.1} height={0.08} proud={0.15} walls={['back', 'front']} color={trim} roughness={0.52} metalness={0.26} />
|
||||||
|
|
||||||
|
{/* Cartouches slung between the bays, clear of the window openings */}
|
||||||
|
{bays.slice(0, -1).map((z, i) => {
|
||||||
|
const mid = (z + bays[i + 1]) / 2;
|
||||||
|
return (
|
||||||
|
<group key={i}>
|
||||||
|
{([-1, 1] as const)
|
||||||
|
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, 0.24))
|
||||||
|
.map((side) => (
|
||||||
|
<group key={side} position={[side * (room.halfW - 0.07), 3.6, mid]} rotation={[0, Math.PI / 2, 0]}>
|
||||||
|
<mesh>
|
||||||
|
<sphereGeometry args={[0.17, 12, 8]} />
|
||||||
|
<meshStandardMaterial color={shade(stone, 0.16)} roughness={0.66} metalness={0.08} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0, 0.04]}>
|
||||||
|
<torusGeometry args={[0.19, 0.03, 6, 18]} />
|
||||||
|
<meshStandardMaterial color={trim} roughness={0.44} metalness={0.4} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baroque state gallery — Baroque.
|
||||||
|
*
|
||||||
|
* Twisted solomonic half-columns at the corners, gilt-framed velvet field
|
||||||
|
* panels between the bays, a modillion cornice, and a crystal-and-gilt
|
||||||
|
* chandelier over the centre of the room.
|
||||||
|
*/
|
||||||
|
export function BaroqueStateDetails({ room, trim, windows }: PeriodProps) {
|
||||||
|
const bays = useBayZ(room.depth, room.halfD, 4.2, 8);
|
||||||
|
const corners = useCorners(room.halfW, room.halfD, 0.16);
|
||||||
|
const clearLeft = useWallClearance(windows, 'left');
|
||||||
|
const clearRight = useWallClearance(windows, 'right');
|
||||||
|
const gilt = trim;
|
||||||
|
|
||||||
|
const modillions = useRepeat(room.depth, 0.86, 26);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Gilt-edged velvet field panels between the bays, clear of the windows */}
|
||||||
|
{bays.slice(0, -1).map((z, i) => {
|
||||||
|
const mid = (z + bays[i + 1]) / 2;
|
||||||
|
const w = Math.min(1.5, Math.max(0.7, (bays[i + 1] - z) * 0.4));
|
||||||
|
return (
|
||||||
|
<group key={i}>
|
||||||
|
{([-1, 1] as const)
|
||||||
|
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, w / 2 + 0.1))
|
||||||
|
.map((side) => (
|
||||||
|
<group key={side} position={[side * (room.halfW - 0.055), 3.2, mid]}>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[0.03, 1.0, w]} />
|
||||||
|
<meshStandardMaterial color="#4a1220" roughness={0.9} metalness={0.04} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[-side * 0.015, 0, 0]}>
|
||||||
|
<boxGeometry args={[0.04, 1.12, w + 0.12]} />
|
||||||
|
<meshStandardMaterial color={gilt} roughness={0.3} metalness={0.7} emissive="#3a2808" emissiveIntensity={0.06} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Solomonic corner columns — twist read as stacked, rotated drums */}
|
||||||
|
{corners.map(([x, z], i) => (
|
||||||
|
<group key={i} position={[x, 0, z]}>
|
||||||
|
<ShaftBase size={0.13} color="#2a1a12" />
|
||||||
|
{Array.from({ length: 14 }, (_, d) => (
|
||||||
|
<mesh key={d} position={[0, 0.34 + d * 0.24, 0]} rotation={[0, d * 0.42, 0]}>
|
||||||
|
<boxGeometry args={[0.2, 0.24, 0.13]} />
|
||||||
|
<meshStandardMaterial color={gilt} roughness={0.32} metalness={0.68} emissive="#2e2006" emissiveIntensity={0.05} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
<mesh position={[0, 3.86, 0]}>
|
||||||
|
<cylinderGeometry args={[0.2, 0.13, 0.28, 10]} />
|
||||||
|
<meshStandardMaterial color={gilt} roughness={0.28} metalness={0.75} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Modillion brackets under a heavy gilt cornice */}
|
||||||
|
{(['left', 'right'] as const).map((wall) =>
|
||||||
|
modillions.map((z, i) => (
|
||||||
|
<mesh key={`${wall}-${i}`} position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.17), 3.92, z]}>
|
||||||
|
<boxGeometry args={[0.18, 0.16, 0.12]} />
|
||||||
|
<meshStandardMaterial color={shade(gilt, 0.1)} roughness={0.34} metalness={0.62} />
|
||||||
|
</mesh>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<CorniceRing room={room} y={4.06} height={0.18} proud={0.19} color={gilt} roughness={0.24} metalness={0.78} emissive="#3a2808" emissiveIntensity={0.08} />
|
||||||
|
|
||||||
|
<CeilingRose y={4.1} radius={1.15} color={gilt} rings={4} />
|
||||||
|
<HangingFixture
|
||||||
|
position={[0, 4.05, 0]}
|
||||||
|
dropLength={0.5}
|
||||||
|
radius={0.6}
|
||||||
|
arms={10}
|
||||||
|
metalColor={gilt}
|
||||||
|
flameColor="#fff0c0"
|
||||||
|
glowColor="#ffbe58"
|
||||||
|
light
|
||||||
|
lightColor="#ffd08a"
|
||||||
|
lightIntensity={1.0}
|
||||||
|
lightDistance={11}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rococo salon — Rococo.
|
||||||
|
*
|
||||||
|
* Boiserie panelling with rounded corner arcs, asymmetric rocaille cartouches,
|
||||||
|
* a deep painted cove instead of a cornice, and a light gilt lustre. Everything
|
||||||
|
* is kept thin and pale: the period's relief is shallow by design.
|
||||||
|
*/
|
||||||
|
export function RococoDetails({ room, trim }: PeriodProps) {
|
||||||
|
const bays = useBayZ(room.depth, room.halfD, 3.4, 9);
|
||||||
|
const gilt = trim;
|
||||||
|
const boiserie = '#efe6ee';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Boiserie dado with a moulded chair rail */}
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<group key={wall}>
|
||||||
|
<WallBand wall={wall} room={room} y={0.55} height={1.04} proud={0.045} color={boiserie} roughness={0.6} metalness={0.08} />
|
||||||
|
<WallBand wall={wall} room={room} y={1.12} height={0.07} proud={0.08} color={gilt} roughness={0.34} metalness={0.52} />
|
||||||
|
<WallBand wall={wall} room={room} y={0.07} height={0.14} proud={0.08} color={boiserie} roughness={0.62} metalness={0.07} />
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Panel styles with quarter-round corners, and a rocaille cartouche above */}
|
||||||
|
{bays.map((z, i) => (
|
||||||
|
<group key={i}>
|
||||||
|
{[-1, 1].map((side) => (
|
||||||
|
<group key={side} position={[side * (room.halfW - 0.06), 0, z]}>
|
||||||
|
<mesh position={[0, 2.05, 0]}>
|
||||||
|
<boxGeometry args={[0.04, 4.0, 0.07]} />
|
||||||
|
<meshStandardMaterial color={gilt} roughness={0.36} metalness={0.5} />
|
||||||
|
</mesh>
|
||||||
|
<group position={[0, 3.5, 0]} rotation={[0, Math.PI / 2, 0]}>
|
||||||
|
<mesh>
|
||||||
|
<torusGeometry args={[0.2, 0.028, 6, 18, Math.PI * 1.35]} />
|
||||||
|
<meshStandardMaterial color={gilt} roughness={0.3} metalness={0.6} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0.1, -0.14, 0]} rotation={[0, 0, 0.7]}>
|
||||||
|
<torusGeometry args={[0.12, 0.022, 6, 14, Math.PI * 1.1]} />
|
||||||
|
<meshStandardMaterial color={gilt} roughness={0.3} metalness={0.6} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Painted cove: two stacked, slightly stepped bands reading as a curve */}
|
||||||
|
<CorniceRing room={room} y={3.86} height={0.16} proud={0.08} color={shade(boiserie, 0.04)} roughness={0.72} metalness={0.05} />
|
||||||
|
<CorniceRing room={room} y={4.0} height={0.16} proud={0.14} color={boiserie} roughness={0.7} metalness={0.05} />
|
||||||
|
<CorniceRing room={room} y={4.13} height={0.07} proud={0.17} color={gilt} roughness={0.3} metalness={0.6} />
|
||||||
|
|
||||||
|
<CeilingRose y={4.09} radius={0.9} color={gilt} rings={3} />
|
||||||
|
<HangingFixture
|
||||||
|
position={[0, 4.02, 0]}
|
||||||
|
dropLength={0.42}
|
||||||
|
radius={0.42}
|
||||||
|
arms={8}
|
||||||
|
metalColor={gilt}
|
||||||
|
flameColor="#fff4dc"
|
||||||
|
glowColor="#ffd58c"
|
||||||
|
light
|
||||||
|
lightColor="#ffe6c0"
|
||||||
|
lightIntensity={0.8}
|
||||||
|
lightDistance={10}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neoclassical museum — Neoclassicism.
|
||||||
|
*
|
||||||
|
* Schinkel's museum grammar: fluted Ionic pilasters on a wide bay, a Greek-key
|
||||||
|
* meander frieze, a dentilled cornice, and a plain coffered soffit around the
|
||||||
|
* lantern. Deliberately cool and flat next to the Baroque hall.
|
||||||
|
*/
|
||||||
|
export function NeoclassicalDetails({ room, trim }: PeriodProps) {
|
||||||
|
const bays = useBayZ(room.depth, room.halfD, 4.8, 6);
|
||||||
|
const stone = '#f1eee8';
|
||||||
|
const shadowStone = shade(stone, -0.14);
|
||||||
|
|
||||||
|
const meander = useRepeat(room.depth, 0.72, 24);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Plinth course under the hang */}
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<group key={wall}>
|
||||||
|
<WallBand wall={wall} room={room} y={0.36} height={0.72} proud={0.05} color={shadowStone} roughness={0.72} metalness={0.08} />
|
||||||
|
<WallBand wall={wall} room={room} y={0.75} height={0.08} proud={0.08} color={stone} roughness={0.62} metalness={0.1} />
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Fluted Ionic pilasters */}
|
||||||
|
{bays.map((z, i) => (
|
||||||
|
<group key={i}>
|
||||||
|
{[-1, 1].map((side) => (
|
||||||
|
<group key={side} position={[side * (room.halfW - 0.13), 0, z]}>
|
||||||
|
<ShaftBase size={0.13} color={shadowStone} />
|
||||||
|
<group position={[0, 0.26, 0]}>
|
||||||
|
<FlutedShaft height={3.3} radius={0.12} color={stone} flutes={10} facing="x" />
|
||||||
|
</group>
|
||||||
|
<IonicCapital y={3.68} size={0.15} color={stone} axis="z" />
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Meander frieze — alternating key blocks read as a running fret */}
|
||||||
|
{(['left', 'right'] as const).map((wall) => (
|
||||||
|
<group key={wall}>
|
||||||
|
<WallBand wall={wall} room={room} y={3.9} height={0.24} proud={0.1} color={shade(stone, 0.03)} roughness={0.66} metalness={0.09} />
|
||||||
|
{meander.map((z, i) => (
|
||||||
|
<group key={i} position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.14), 3.9, z]}>
|
||||||
|
<mesh position={[0, i % 2 === 0 ? 0.06 : -0.06, 0]}>
|
||||||
|
<boxGeometry args={[0.06, 0.05, 0.34]} />
|
||||||
|
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.28} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0, 0.15]}>
|
||||||
|
<boxGeometry args={[0.06, 0.16, 0.05]} />
|
||||||
|
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.28} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<CorniceRing room={room} y={4.07} height={0.1} proud={0.16} color={stone} roughness={0.6} metalness={0.1} />
|
||||||
|
<CorniceRing room={room} y={4.15} height={0.06} proud={0.19} color={trim} roughness={0.48} metalness={0.24} />
|
||||||
|
|
||||||
|
<CofferedCeiling
|
||||||
|
room={room}
|
||||||
|
y={4.06}
|
||||||
|
cell={2.9}
|
||||||
|
panelColor="#f7f6f2"
|
||||||
|
ribColor="#e2ded4"
|
||||||
|
ribWidth={0.18}
|
||||||
|
ribDrop={0.13}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { GalleryWindowSpec } from '../../data/movement-interior-styles';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared geometry budget for every period interior.
|
||||||
|
*
|
||||||
|
* Movement halls hang frames 0.23 m proud of the inner wall face
|
||||||
|
* (`MOUNT_OFFSET` + `WALL_STANDOFF`), centred at eye height with tops around
|
||||||
|
* 2.4 m, and each wall keeps a 0.7 m clear margin at both ends
|
||||||
|
* (`WALL_PADDING` in `movementHallLayout.ts`). Period architecture therefore
|
||||||
|
* has to stay inside these limits, exactly as the Gothic nave and Byzantine
|
||||||
|
* basilica do:
|
||||||
|
*
|
||||||
|
* - relief below the frame line: at most 0.11 m proud of the wall face
|
||||||
|
* - friezes and cornices: 2.6 m and above, at most 0.17 m proud
|
||||||
|
* - freestanding masses: only inside the 0.34 m corner pocket at a corner
|
||||||
|
* - doorways: nothing below 2.6 m within 1.45 m of a wall centre
|
||||||
|
* - the end wall carries the hall title from 3.4 m to 3.9 m — keep it clear
|
||||||
|
* - side-wall windows are placed at runtime between 2.4 m and 3.9 m, so
|
||||||
|
* anything wider than a mullion up there goes through `useWallClearance`
|
||||||
|
* - added lights: at most two per hall, so hall materials stay inside the
|
||||||
|
* WebGL light limits the shared track lighting already budgets for
|
||||||
|
*/
|
||||||
|
/** Half of WALL_THICKNESS — distance from a wall's centre plane to its inner face. */
|
||||||
|
export const WALL_INNER = 0.09;
|
||||||
|
/** Highest a detail can sit before it breaks the ceiling plane at 4.2 m. */
|
||||||
|
export const CEILING_Y = 4.13;
|
||||||
|
/** Sink bands this far into the wall so they never z-fight with the surface. */
|
||||||
|
export const EMBED = 0.02;
|
||||||
|
|
||||||
|
export type WallId = 'back' | 'front' | 'left' | 'right';
|
||||||
|
|
||||||
|
export interface RoomDims {
|
||||||
|
width: number;
|
||||||
|
depth: number;
|
||||||
|
halfW: number;
|
||||||
|
halfD: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every period module takes the same three inputs. */
|
||||||
|
export interface PeriodProps {
|
||||||
|
room: RoomDims;
|
||||||
|
/** The movement's trim colour, already blended with its accent. */
|
||||||
|
trim: string;
|
||||||
|
/** Windows the hall placed at runtime, so upper-wall relief can avoid them. */
|
||||||
|
windows?: GalleryWindowSpec[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lighten (amount > 0) or darken (amount < 0) a hex colour. */
|
||||||
|
export function shade(hex: string, amount: number): string {
|
||||||
|
const n = parseInt(hex.replace('#', ''), 16);
|
||||||
|
const ch = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((c) =>
|
||||||
|
Math.max(0, Math.min(255, Math.round(amount >= 0 ? c + (255 - c) * amount : c * (1 + amount))))
|
||||||
|
);
|
||||||
|
return `#${((ch[0] << 16) | (ch[1] << 8) | ch[2]).toString(16).padStart(6, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bay rhythm along the depth axis — the spacing that drives piers and beams. */
|
||||||
|
export function useBayZ(depth: number, halfD: number, spacing = 4.2, max = 9): number[] {
|
||||||
|
return useMemo(() => {
|
||||||
|
const count = Math.max(2, Math.min(max, Math.floor(depth / spacing)));
|
||||||
|
const step = depth / (count + 1);
|
||||||
|
return Array.from({ length: count }, (_, i) => -halfD + step * (i + 1));
|
||||||
|
}, [depth, halfD, spacing, max]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evenly spaced positions centred on a wall, for the repeating small members —
|
||||||
|
* dentils, linenfold ribs, modillions, stencil motifs, quatrefoils.
|
||||||
|
*/
|
||||||
|
export function useRepeat(span: number, step: number, max: number): number[] {
|
||||||
|
return useMemo(() => {
|
||||||
|
const count = Math.min(max, Math.max(3, Math.floor((span - 0.6) / step)));
|
||||||
|
const run = (count - 1) * step;
|
||||||
|
return Array.from({ length: count }, (_, i) => -run / 2 + i * step);
|
||||||
|
}, [span, step, max]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Side-wall windows are placed at runtime in whatever gaps the hang leaves, so
|
||||||
|
* any panel, arch or roundel a period module paints across the upper wall has
|
||||||
|
* to step around them. Returns a predicate: true when the span is clear.
|
||||||
|
*/
|
||||||
|
export function useWallClearance(
|
||||||
|
windows: GalleryWindowSpec[] | undefined,
|
||||||
|
wall: 'left' | 'right' | 'back'
|
||||||
|
) {
|
||||||
|
return useMemo(() => {
|
||||||
|
const spans = (windows ?? [])
|
||||||
|
.filter((w) => w.wall === wall)
|
||||||
|
.map((w) => [w.x - w.width / 2 - 0.18, w.x + w.width / 2 + 0.18] as [number, number]);
|
||||||
|
return (pos: number, halfSpan = 0.2) =>
|
||||||
|
!spans.some(([a, b]) => pos + halfSpan > a && pos - halfSpan < b);
|
||||||
|
}, [windows, wall]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The four corner columns of the room, each inside its hang-margin pocket. */
|
||||||
|
export function useCorners(halfW: number, halfD: number, inset = 0.08): [number, number][] {
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
[-halfW + inset, -halfD + inset],
|
||||||
|
[halfW - inset, -halfD + inset],
|
||||||
|
[-halfW + inset, halfD - inset],
|
||||||
|
[halfW - inset, halfD - inset],
|
||||||
|
] as [number, number][],
|
||||||
|
[halfW, halfD, inset]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,717 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
CeilingBeams,
|
||||||
|
CorniceRing,
|
||||||
|
HangingFixture,
|
||||||
|
SideRails,
|
||||||
|
WallBand,
|
||||||
|
} from './primitives';
|
||||||
|
import {
|
||||||
|
shade,
|
||||||
|
useBayZ,
|
||||||
|
useCorners,
|
||||||
|
useRepeat,
|
||||||
|
type PeriodProps,
|
||||||
|
} from './geometry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fauvist studio — Fauvism.
|
||||||
|
*
|
||||||
|
* The Collioure summer room: close-set whitewashed rafters, plank shutters
|
||||||
|
* folded back against the wall beside every window, and a plain painted rail.
|
||||||
|
* The architecture stays vernacular so the wall colour does the work.
|
||||||
|
*/
|
||||||
|
export function FauvistDetails({ room, trim, windows }: PeriodProps) {
|
||||||
|
const rafters = useBayZ(room.depth, room.halfD, 1.5, 14);
|
||||||
|
const timber = '#d8c8a8';
|
||||||
|
|
||||||
|
const shutters = useMemo(
|
||||||
|
() =>
|
||||||
|
(windows ?? [])
|
||||||
|
.filter((w) => w.wall === 'left' || w.wall === 'right')
|
||||||
|
.flatMap((w) =>
|
||||||
|
[-1, 1].map((dir) => ({
|
||||||
|
wall: w.wall as 'left' | 'right',
|
||||||
|
z: w.x + dir * (w.width / 2 + w.width * 0.28),
|
||||||
|
y: w.y,
|
||||||
|
h: w.height,
|
||||||
|
w: w.width * 0.5,
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
[windows]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<CeilingBeams
|
||||||
|
room={room}
|
||||||
|
positions={rafters}
|
||||||
|
axis="x"
|
||||||
|
width={0.1}
|
||||||
|
height={0.16}
|
||||||
|
y={4.02}
|
||||||
|
color={timber}
|
||||||
|
roughness={0.88}
|
||||||
|
metalness={0.02}
|
||||||
|
/>
|
||||||
|
<CeilingBeams
|
||||||
|
room={room}
|
||||||
|
positions={[0]}
|
||||||
|
axis="z"
|
||||||
|
width={0.2}
|
||||||
|
height={0.24}
|
||||||
|
y={3.86}
|
||||||
|
color={shade(timber, -0.24)}
|
||||||
|
roughness={0.88}
|
||||||
|
metalness={0.02}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Folded-back plank shutters flanking the windows */}
|
||||||
|
{shutters.map((s, i) => (
|
||||||
|
<group
|
||||||
|
key={i}
|
||||||
|
position={[(s.wall === 'left' ? -1 : 1) * (room.halfW - 0.13), s.y, s.z]}
|
||||||
|
>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[0.06, s.h, s.w]} />
|
||||||
|
<meshStandardMaterial color={trim} roughness={0.8} metalness={0.04} />
|
||||||
|
</mesh>
|
||||||
|
{[-0.3, 0, 0.3].map((t) => (
|
||||||
|
<mesh key={t} position={[0.04, s.h * t, 0]}>
|
||||||
|
<boxGeometry args={[0.02, s.h * 0.22, s.w * 0.86]} />
|
||||||
|
<meshStandardMaterial color={shade(trim, -0.22)} roughness={0.84} metalness={0.03} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<SideRails room={room} y={2.6} height={0.08} proud={0.11} color={timber} roughness={0.82} metalness={0.03} />
|
||||||
|
<WallBand wall="back" room={room} y={2.6} height={0.08} proud={0.11} color={timber} roughness={0.82} metalness={0.03} />
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<WallBand key={wall} wall={wall} room={room} y={0.12} height={0.24} proud={0.07} color={trim} roughness={0.8} metalness={0.04} />
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expressionist room — Expressionism.
|
||||||
|
*
|
||||||
|
* Die Brücke's carved-plank interior pushed towards the raked sets of the
|
||||||
|
* period's cinema: battens canted at alternating angles above the hang, a
|
||||||
|
* zig-zag frieze, and ceiling members that refuse to run square.
|
||||||
|
*/
|
||||||
|
export function ExpressionistDetails({ room, trim }: PeriodProps) {
|
||||||
|
const bays = useBayZ(room.depth, room.halfD, 2.2, 11);
|
||||||
|
const plank = '#5a3f27';
|
||||||
|
const light = shade(plank, 0.3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Canted battens, alternating lean */}
|
||||||
|
{bays.map((z, i) => (
|
||||||
|
<group key={i}>
|
||||||
|
{[-1, 1].map((side) => (
|
||||||
|
<mesh
|
||||||
|
key={side}
|
||||||
|
position={[side * (room.halfW - 0.07), 3.28, z]}
|
||||||
|
rotation={[i % 2 === 0 ? 0.3 : -0.3, 0, 0]}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[0.08, 1.5, 0.17]} />
|
||||||
|
<meshStandardMaterial color={i % 2 === 0 ? plank : light} roughness={0.84} metalness={0.04} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Zig-zag frieze under the ceiling */}
|
||||||
|
{(['left', 'right'] as const).map((wall) =>
|
||||||
|
bays.map((z, i) => (
|
||||||
|
<mesh
|
||||||
|
key={`${wall}-${i}`}
|
||||||
|
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.08), 4.06, z]}
|
||||||
|
rotation={[i % 2 === 0 ? 0.62 : -0.62, 0, 0]}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[0.1, 0.42, 0.1]} />
|
||||||
|
<meshStandardMaterial color={trim} roughness={0.7} metalness={0.1} />
|
||||||
|
</mesh>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ceiling members deliberately off-square */}
|
||||||
|
{bays
|
||||||
|
.filter((_, i) => i % 3 === 0)
|
||||||
|
.map((z, i) => (
|
||||||
|
<mesh key={i} position={[0, 3.98, z]} rotation={[0, i % 2 === 0 ? 0.14 : -0.14, 0]}>
|
||||||
|
<boxGeometry args={[room.width * 0.97, 0.2, 0.15]} />
|
||||||
|
<meshStandardMaterial color={shade(plank, -0.24)} roughness={0.88} metalness={0.03} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<WallBand key={wall} wall={wall} room={room} y={0.13} height={0.26} proud={0.09} color={shade(plank, -0.3)} roughness={0.86} metalness={0.03} />
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cubist studio — Cubism.
|
||||||
|
*
|
||||||
|
* The Bateau-Lavoir atelier: slim cast-iron columns in the corners, riveted tie
|
||||||
|
* rods across the room, and a cornice broken into faceted plaster planes — the
|
||||||
|
* building itself analysed into flat facets.
|
||||||
|
*/
|
||||||
|
export function CubistAtelierDetails({ room, trim }: PeriodProps) {
|
||||||
|
const corners = useCorners(room.halfW, room.halfD, 0.24);
|
||||||
|
const ties = useBayZ(room.depth, room.halfD, 3.6, 8);
|
||||||
|
const iron = '#4d4a45';
|
||||||
|
const facet = '#dedad2';
|
||||||
|
|
||||||
|
const facets = useRepeat(room.depth, 1.15, 22);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Cast-iron columns in the corner pockets */}
|
||||||
|
{corners.map(([x, z], i) => (
|
||||||
|
<group key={i} position={[x, 0, z]}>
|
||||||
|
<mesh position={[0, 0.1, 0]}>
|
||||||
|
<boxGeometry args={[0.3, 0.2, 0.3]} />
|
||||||
|
<meshStandardMaterial color={shade(iron, -0.2)} roughness={0.6} metalness={0.45} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 2.1, 0]}>
|
||||||
|
<cylinderGeometry args={[0.09, 0.11, 3.8, 10]} />
|
||||||
|
<meshStandardMaterial color={iron} roughness={0.52} metalness={0.55} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 4.02, 0]}>
|
||||||
|
<boxGeometry args={[0.34, 0.18, 0.34]} />
|
||||||
|
<meshStandardMaterial color={iron} roughness={0.5} metalness={0.58} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Riveted tie rods spanning the room, with a turnbuckle at mid-span */}
|
||||||
|
{ties.map((z, i) => (
|
||||||
|
<group key={i} position={[0, 3.8, z]}>
|
||||||
|
<mesh rotation={[0, 0, Math.PI / 2]}>
|
||||||
|
<cylinderGeometry args={[0.035, 0.035, room.width - 0.2, 8]} />
|
||||||
|
<meshStandardMaterial color={iron} roughness={0.46} metalness={0.62} />
|
||||||
|
</mesh>
|
||||||
|
<mesh rotation={[0, 0, Math.PI / 2]}>
|
||||||
|
<cylinderGeometry args={[0.06, 0.06, 0.3, 8]} />
|
||||||
|
<meshStandardMaterial color={shade(iron, -0.2)} roughness={0.4} metalness={0.7} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Faceted cornice: flat planes tilted alternately */}
|
||||||
|
{(['left', 'right'] as const).map((wall) =>
|
||||||
|
facets.map((z, i) => (
|
||||||
|
<mesh
|
||||||
|
key={`${wall}-${i}`}
|
||||||
|
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.1), 4.02, z]}
|
||||||
|
rotation={[0, 0, i % 2 === 0 ? 0.34 : -0.34]}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[0.2, 0.22, 0.86]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={i % 3 === 0 ? shade(facet, -0.14) : facet}
|
||||||
|
roughness={0.82}
|
||||||
|
metalness={0.05}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<CorniceRing room={room} y={4.15} height={0.08} proud={0.13} color={trim} roughness={0.6} metalness={0.2} />
|
||||||
|
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<WallBand key={wall} wall={wall} room={room} y={0.1} height={0.2} proud={0.07} color={shade(iron, 0.24)} roughness={0.7} metalness={0.16} />
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Futurist loft — Futurism.
|
||||||
|
*
|
||||||
|
* Sant'Elia's Città Nuova reduced to one room: riveted steel stanchions,
|
||||||
|
* diagonal wind bracing across the upper wall, and a gantry rail running the
|
||||||
|
* length of the hall. Structure celebrated, nothing concealed.
|
||||||
|
*/
|
||||||
|
export function FuturistDetails({ room, trim }: PeriodProps) {
|
||||||
|
const bays = useBayZ(room.depth, room.halfD, 4.0, 8);
|
||||||
|
const steel = '#8f9298';
|
||||||
|
const dark = shade(steel, -0.42);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Riveted stanchions */}
|
||||||
|
{bays.map((z, i) => (
|
||||||
|
<group key={i}>
|
||||||
|
{[-1, 1].map((side) => (
|
||||||
|
<group key={side} position={[side * (room.halfW - 0.06), 0, z]}>
|
||||||
|
<mesh position={[0, 2.05, 0]}>
|
||||||
|
<boxGeometry args={[0.09, 4.1, 0.36]} />
|
||||||
|
<meshStandardMaterial color={steel} roughness={0.42} metalness={0.72} />
|
||||||
|
</mesh>
|
||||||
|
{[0.6, 1.7, 2.8, 3.8].map((y) => (
|
||||||
|
<mesh key={y} position={[side * -0.03, y, 0]}>
|
||||||
|
<boxGeometry args={[0.06, 0.1, 0.44]} />
|
||||||
|
<meshStandardMaterial color={dark} roughness={0.38} metalness={0.8} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Diagonal wind bracing between the stanchions, above the hang */}
|
||||||
|
{bays.slice(0, -1).map((z, i) => {
|
||||||
|
const mid = (z + bays[i + 1]) / 2;
|
||||||
|
const run = bays[i + 1] - z;
|
||||||
|
return (
|
||||||
|
<group key={i}>
|
||||||
|
{[-1, 1].map((side) =>
|
||||||
|
[-1, 1].map((dir) => (
|
||||||
|
<mesh
|
||||||
|
key={`${side}-${dir}`}
|
||||||
|
position={[side * (room.halfW - 0.1), 3.4, mid]}
|
||||||
|
rotation={[dir * Math.atan2(run - 0.4, 0.9), 0, 0]}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[0.06, Math.hypot(run - 0.4, 0.9), 0.06]} />
|
||||||
|
<meshStandardMaterial color={steel} roughness={0.4} metalness={0.75} />
|
||||||
|
</mesh>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Gantry rail and its brackets */}
|
||||||
|
<SideRails room={room} y={2.72} height={0.12} proud={0.15} color={dark} roughness={0.36} metalness={0.82} />
|
||||||
|
<CorniceRing room={room} y={4.13} height={0.1} proud={0.16} color={trim} roughness={0.34} metalness={0.7} />
|
||||||
|
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<WallBand key={wall} wall={wall} room={room} y={0.11} height={0.22} proud={0.09} color={dark} roughness={0.4} metalness={0.7} />
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suprematist white cube — Suprematism.
|
||||||
|
*
|
||||||
|
* The 0,10 room: a bare white shell, a shelf slung across the upper corner
|
||||||
|
* where Malevich hung the Black Square in the icon position, and thin black and
|
||||||
|
* red geometric bands drifting off the axis of the walls.
|
||||||
|
*/
|
||||||
|
export function SuprematistDetails({ room, trim }: PeriodProps) {
|
||||||
|
const bars = useMemo(() => {
|
||||||
|
const seeds = [-0.62, -0.24, 0.18, 0.55];
|
||||||
|
return seeds.map((t, i) => ({
|
||||||
|
z: t,
|
||||||
|
y: 3.0 + (i % 3) * 0.28,
|
||||||
|
len: 0.9 + (i % 2) * 0.7,
|
||||||
|
tilt: i % 2 === 0 ? 0.22 : -0.34,
|
||||||
|
color: i % 3 === 0 ? '#c8302a' : '#141414',
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Icon-corner shelf, carried on two plain brackets */}
|
||||||
|
<group position={[room.halfW - 0.46, 3.3, -room.halfD + 0.46]} rotation={[0, Math.PI / 4, 0]}>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[1.16, 0.05, 0.34]} />
|
||||||
|
<meshStandardMaterial color="#f2f2f2" roughness={0.72} metalness={0.04} />
|
||||||
|
</mesh>
|
||||||
|
{[-0.4, 0.4].map((t) => (
|
||||||
|
<mesh key={t} position={[t, -0.16, 0.02]} rotation={[0, 0, 0]}>
|
||||||
|
<boxGeometry args={[0.05, 0.28, 0.24]} />
|
||||||
|
<meshStandardMaterial color="#e2e2e2" roughness={0.74} metalness={0.04} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{/* Drifting bands above the hang */}
|
||||||
|
{(['left', 'right'] as const).map((wall) =>
|
||||||
|
bars.map((b, i) => (
|
||||||
|
<mesh
|
||||||
|
key={`${wall}-${i}`}
|
||||||
|
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.07), b.y, b.z * room.halfD]}
|
||||||
|
rotation={[b.tilt, 0, 0]}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[0.04, 0.14, b.len]} />
|
||||||
|
<meshStandardMaterial color={b.color} roughness={0.66} metalness={0.06} />
|
||||||
|
</mesh>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Shadow-gap reveal instead of a cornice */}
|
||||||
|
<CorniceRing room={room} y={4.04} height={0.05} proud={0.03} color="#d2d2d2" roughness={0.8} metalness={0.04} />
|
||||||
|
<CorniceRing room={room} y={4.16} height={0.06} proud={0.1} color={trim} roughness={0.7} metalness={0.08} />
|
||||||
|
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<WallBand key={wall} wall={wall} room={room} y={0.07} height={0.14} proud={0.06} color="#dcdcdc" roughness={0.76} metalness={0.05} />
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructivist space — Constructivism.
|
||||||
|
*
|
||||||
|
* Rodchenko's lattice made architecture: red-painted diagonal trusses under the
|
||||||
|
* ceiling, angled Proun panels folded into the corners, and exposed steel ties.
|
||||||
|
* Every member is doing structural work and says so.
|
||||||
|
*/
|
||||||
|
export function ConstructivistDetails({ room, trim }: PeriodProps) {
|
||||||
|
const bays = useBayZ(room.depth, room.halfD, 2.8, 10);
|
||||||
|
const corners = useCorners(room.halfW, room.halfD, 0.3);
|
||||||
|
const red = '#b8342a';
|
||||||
|
const steel = '#9a9a96';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Lattice truss under the ceiling — top chord, bottom chord, diagonals */}
|
||||||
|
<CeilingBeams room={room} positions={[-0.9, 0.9]} axis="z" width={0.1} height={0.12} y={4.06} color={steel} roughness={0.44} metalness={0.6} />
|
||||||
|
<CeilingBeams room={room} positions={[-0.9, 0.9]} axis="z" width={0.09} height={0.1} y={3.66} color={steel} roughness={0.44} metalness={0.6} />
|
||||||
|
{bays.map((z, i) =>
|
||||||
|
[-0.9, 0.9].map((x) => (
|
||||||
|
<mesh key={`${i}-${x}`} position={[x, 3.86, z]} rotation={[i % 2 === 0 ? 0.62 : -0.62, 0, 0]}>
|
||||||
|
<boxGeometry args={[0.07, 0.62, 0.07]} />
|
||||||
|
<meshStandardMaterial color={red} roughness={0.56} metalness={0.3} />
|
||||||
|
</mesh>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Proun panels folded into the corners */}
|
||||||
|
{corners.map(([x, z], i) => (
|
||||||
|
<group key={i} position={[x, 2.9, z]} rotation={[0, Math.PI / 4 + (i % 2) * 0.2, 0]}>
|
||||||
|
<mesh rotation={[0, 0, 0.18]}>
|
||||||
|
<boxGeometry args={[0.66, 1.5, 0.05]} />
|
||||||
|
<meshStandardMaterial color={i % 2 === 0 ? red : '#141414'} roughness={0.66} metalness={0.12} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0.1, -0.3, 0.05]} rotation={[0, 0, -0.5]}>
|
||||||
|
<boxGeometry args={[0.5, 0.14, 0.04]} />
|
||||||
|
<meshStandardMaterial color={steel} roughness={0.42} metalness={0.6} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Steel tie rails at the hang line and the skirting */}
|
||||||
|
<SideRails room={room} y={2.64} height={0.07} proud={0.12} color={steel} roughness={0.4} metalness={0.68} />
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<WallBand key={wall} wall={wall} room={room} y={0.1} height={0.2} proud={0.08} color={red} roughness={0.62} metalness={0.2} />
|
||||||
|
))}
|
||||||
|
<CorniceRing room={room} y={4.16} height={0.06} proud={0.11} color={trim} roughness={0.5} metalness={0.4} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dada salon — Dada.
|
||||||
|
*
|
||||||
|
* A bourgeois room being dismantled: cornice fragments that stop and restart at
|
||||||
|
* different heights, a picture rail hung visibly out of level, and a service
|
||||||
|
* pipe left running across the wall where the moulding should be.
|
||||||
|
*/
|
||||||
|
export function DadaDetails({ room, trim }: PeriodProps) {
|
||||||
|
const fragments = useMemo(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
{ wall: 'left' as const, y: 4.04, span: 0.3, proud: 0.14 },
|
||||||
|
{ wall: 'left' as const, y: 3.78, span: 0.18, proud: 0.1 },
|
||||||
|
{ wall: 'right' as const, y: 4.12, span: 0.44, proud: 0.16 },
|
||||||
|
{ wall: 'right' as const, y: 3.9, span: 0.22, proud: 0.09 },
|
||||||
|
{ wall: 'back' as const, y: 4.06, span: 0.35, proud: 0.13 },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const plaster = '#e6dac4';
|
||||||
|
const pipe = '#6a6259';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Cornice, in pieces */}
|
||||||
|
{fragments.map((f, i) => (
|
||||||
|
<WallBand
|
||||||
|
key={i}
|
||||||
|
wall={f.wall}
|
||||||
|
room={room}
|
||||||
|
y={f.y}
|
||||||
|
height={0.13}
|
||||||
|
proud={f.proud}
|
||||||
|
span={f.span}
|
||||||
|
color={i % 2 === 0 ? plaster : shade(plaster, -0.18)}
|
||||||
|
roughness={0.76}
|
||||||
|
metalness={0.05}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Picture rail, visibly out of level */}
|
||||||
|
{[-1, 1].map((side) => (
|
||||||
|
<mesh key={side} position={[side * (room.halfW - 0.07), 2.62, 0]} rotation={[side * 0.035, 0, 0]}>
|
||||||
|
<boxGeometry args={[0.1, 0.08, room.depth * 0.86]} />
|
||||||
|
<meshStandardMaterial color={trim} roughness={0.6} metalness={0.18} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Service pipe run, with a clumsy elbow into the ceiling */}
|
||||||
|
<mesh position={[-room.halfW + 0.16, 3.34, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||||
|
<cylinderGeometry args={[0.06, 0.06, room.depth * 0.8, 10]} />
|
||||||
|
<meshStandardMaterial color={pipe} roughness={0.64} metalness={0.4} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[-room.halfW + 0.16, 3.7, room.depth * 0.4]}>
|
||||||
|
<cylinderGeometry args={[0.06, 0.06, 0.75, 10]} />
|
||||||
|
<meshStandardMaterial color={pipe} roughness={0.64} metalness={0.4} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Skirting that changes its mind halfway along */}
|
||||||
|
<WallBand wall="left" room={room} y={0.11} height={0.22} proud={0.08} span={0.55} color={plaster} roughness={0.78} metalness={0.04} />
|
||||||
|
<WallBand wall="right" room={room} y={0.15} height={0.3} proud={0.1} span={0.8} color={shade(plaster, -0.26)} roughness={0.78} metalness={0.04} />
|
||||||
|
<WallBand wall="back" room={room} y={0.11} height={0.22} proud={0.08} color={plaster} roughness={0.78} metalness={0.04} />
|
||||||
|
|
||||||
|
<HangingFixture
|
||||||
|
position={[room.halfW * 0.32, 3.9, room.halfD * 0.2]}
|
||||||
|
dropLength={0.7}
|
||||||
|
radius={0.2}
|
||||||
|
arms={3}
|
||||||
|
metalColor={trim}
|
||||||
|
flameColor="#fff0cc"
|
||||||
|
glowColor="#ffb45a"
|
||||||
|
light
|
||||||
|
lightColor="#ffd0a0"
|
||||||
|
lightIntensity={0.7}
|
||||||
|
lightDistance={8}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surrealist interior — Surrealism.
|
||||||
|
*
|
||||||
|
* The 1938 Exposition Internationale: sacks slung under the ceiling, drapery
|
||||||
|
* pulled across the cornice, and a door standing in a corner where no door can
|
||||||
|
* lead anywhere. The room is furnished as an image, not as a gallery.
|
||||||
|
*/
|
||||||
|
export function SurrealistDetails({ room, trim }: PeriodProps) {
|
||||||
|
const sacks = useBayZ(room.depth, room.halfD, 2.6, 9);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Sacks hanging under the ceiling */}
|
||||||
|
{sacks.map((z, i) =>
|
||||||
|
[-1, 0, 1].map((c) => (
|
||||||
|
<mesh
|
||||||
|
key={`${i}-${c}`}
|
||||||
|
position={[c * room.width * 0.26, 3.86 - ((i + c) % 3) * 0.09, z]}
|
||||||
|
rotation={[0, ((i + c) % 4) * 0.4, 0.1 * ((i % 2) * 2 - 1)]}
|
||||||
|
>
|
||||||
|
<sphereGeometry args={[0.34, 10, 8]} />
|
||||||
|
<meshStandardMaterial color={i % 2 === 0 ? '#3a3128' : '#2c261f'} roughness={0.96} metalness={0.02} />
|
||||||
|
</mesh>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Drapery gathered along the top of the side walls */}
|
||||||
|
{(['left', 'right'] as const).map((wall) =>
|
||||||
|
sacks.map((z, i) => (
|
||||||
|
<mesh
|
||||||
|
key={`${wall}-${i}`}
|
||||||
|
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.13), 3.7, z]}
|
||||||
|
rotation={[0, 0, 0]}
|
||||||
|
>
|
||||||
|
<cylinderGeometry args={[0.13, 0.09, 0.86, 8]} />
|
||||||
|
<meshStandardMaterial color="#38304a" roughness={0.92} metalness={0.03} />
|
||||||
|
</mesh>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* A door that leads nowhere, standing in the corner pocket */}
|
||||||
|
<group position={[-room.halfW + 0.34, 0, -room.halfD + 0.34]} rotation={[0, Math.PI / 4, 0]}>
|
||||||
|
<mesh position={[0, 1.05, 0]}>
|
||||||
|
<boxGeometry args={[0.72, 2.1, 0.07]} />
|
||||||
|
<meshStandardMaterial color="#4a3a2a" roughness={0.78} metalness={0.06} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 1.12, 0]}>
|
||||||
|
<boxGeometry args={[0.84, 2.24, 0.05]} />
|
||||||
|
<meshStandardMaterial color={shade('#4a3a2a', -0.3)} roughness={0.8} metalness={0.05} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0.26, 1.02, 0.06]}>
|
||||||
|
<sphereGeometry args={[0.05, 10, 10]} />
|
||||||
|
<meshStandardMaterial color={trim} roughness={0.34} metalness={0.66} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<CorniceRing room={room} y={4.14} height={0.09} proud={0.12} color={trim} roughness={0.62} metalness={0.2} />
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<WallBand key={wall} wall={wall} room={room} y={0.11} height={0.22} proud={0.08} color="#2e2822" roughness={0.82} metalness={0.05} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* A brazier glow in the opposite corner */}
|
||||||
|
<group position={[room.halfW - 0.44, 0, room.halfD - 0.44]}>
|
||||||
|
<mesh position={[0, 0.34, 0]}>
|
||||||
|
<cylinderGeometry args={[0.22, 0.15, 0.28, 10]} />
|
||||||
|
<meshStandardMaterial color="#2a2622" roughness={0.7} metalness={0.35} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.5, 0]}>
|
||||||
|
<sphereGeometry args={[0.16, 10, 8]} />
|
||||||
|
<meshStandardMaterial color="#ff9a40" emissive="#ff6a12" emissiveIntensity={1.1} toneMapped={false} />
|
||||||
|
</mesh>
|
||||||
|
<pointLight position={[0, 0.7, 0]} intensity={0.8} distance={7} color="#ff8a3c" />
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NYC loft — Abstract Expressionism.
|
||||||
|
*
|
||||||
|
* A Tenth Street cast-iron building: fluted iron columns on their corbelled
|
||||||
|
* caps, a pressed-tin ceiling field, and the sprinkler main running the length
|
||||||
|
* of the room. Industrial fabric left exactly as the painters found it.
|
||||||
|
*/
|
||||||
|
export function LoftDetails({ room, trim }: PeriodProps) {
|
||||||
|
const corners = useCorners(room.halfW, room.halfD, 0.26);
|
||||||
|
const joists = useBayZ(room.depth, room.halfD, 1.7, 13);
|
||||||
|
const iron = '#5c5852';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Cast-iron columns with corbelled caps */}
|
||||||
|
{corners.map(([x, z], i) => (
|
||||||
|
<group key={i} position={[x, 0, z]}>
|
||||||
|
<mesh position={[0, 0.12, 0]}>
|
||||||
|
<cylinderGeometry args={[0.2, 0.24, 0.24, 12]} />
|
||||||
|
<meshStandardMaterial color={shade(iron, -0.2)} roughness={0.62} metalness={0.42} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 2.05, 0]}>
|
||||||
|
<cylinderGeometry args={[0.11, 0.13, 3.6, 12]} />
|
||||||
|
<meshStandardMaterial color={iron} roughness={0.56} metalness={0.48} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 3.92, 0]}>
|
||||||
|
<cylinderGeometry args={[0.22, 0.13, 0.2, 12]} />
|
||||||
|
<meshStandardMaterial color={iron} roughness={0.54} metalness={0.5} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 4.06, 0]}>
|
||||||
|
<boxGeometry args={[0.4, 0.14, 0.4]} />
|
||||||
|
<meshStandardMaterial color={shade(iron, 0.1)} roughness={0.54} metalness={0.5} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Pressed-tin ceiling field on close-set joists */}
|
||||||
|
<CeilingBeams
|
||||||
|
room={room}
|
||||||
|
positions={joists}
|
||||||
|
axis="x"
|
||||||
|
width={0.08}
|
||||||
|
height={0.09}
|
||||||
|
y={4.05}
|
||||||
|
color="#b4b0a6"
|
||||||
|
roughness={0.48}
|
||||||
|
metalness={0.42}
|
||||||
|
/>
|
||||||
|
<CeilingBeams
|
||||||
|
room={room}
|
||||||
|
positions={[-room.width * 0.24, 0, room.width * 0.24]}
|
||||||
|
axis="z"
|
||||||
|
width={0.14}
|
||||||
|
height={0.16}
|
||||||
|
y={3.96}
|
||||||
|
color={iron}
|
||||||
|
roughness={0.6}
|
||||||
|
metalness={0.4}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sprinkler main with drops */}
|
||||||
|
<mesh position={[0, 3.78, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||||
|
<cylinderGeometry args={[0.055, 0.055, room.depth * 0.92, 10]} />
|
||||||
|
<meshStandardMaterial color="#7a3f2c" roughness={0.66} metalness={0.4} />
|
||||||
|
</mesh>
|
||||||
|
{joists
|
||||||
|
.filter((_, i) => i % 4 === 0)
|
||||||
|
.map((z, i) => (
|
||||||
|
<mesh key={i} position={[0, 3.68, z]}>
|
||||||
|
<cylinderGeometry args={[0.02, 0.025, 0.16, 6]} />
|
||||||
|
<meshStandardMaterial color="#8a4a34" roughness={0.6} metalness={0.45} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Steel skirting angle */}
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<WallBand key={wall} wall={wall} room={room} y={0.09} height={0.18} proud={0.08} color={trim} roughness={0.56} metalness={0.44} />
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pop Art gallery — Pop Art.
|
||||||
|
*
|
||||||
|
* The 1960s dealer's room: a flat white shell, recessed fluorescent troffers in
|
||||||
|
* a ceiling grid, an aluminium reveal at the floor, and a shadow gap where a
|
||||||
|
* cornice used to be. The architecture is engineered to disappear.
|
||||||
|
*/
|
||||||
|
export function WhiteCubeDetails({ room, trim }: PeriodProps) {
|
||||||
|
const troffers = useMemo(() => {
|
||||||
|
const cols = Math.max(2, Math.min(3, Math.round(room.width / 4.5)));
|
||||||
|
const rows = Math.max(2, Math.min(6, Math.round(room.depth / 3.4)));
|
||||||
|
const out: [number, number][] = [];
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
out.push([
|
||||||
|
-room.halfW + (room.width / (cols + 1)) * (c + 1),
|
||||||
|
-room.halfD + (room.depth / (rows + 1)) * (r + 1),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [room.width, room.depth, room.halfW, room.halfD]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Recessed fluorescent troffers */}
|
||||||
|
{troffers.map(([x, z], i) => (
|
||||||
|
<group key={i} position={[x, 4.08, z]}>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[1.24, 0.06, 0.62]} />
|
||||||
|
<meshStandardMaterial color="#c8c8c8" roughness={0.4} metalness={0.5} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, -0.04, 0]}>
|
||||||
|
<boxGeometry args={[1.1, 0.03, 0.5]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#ffffff"
|
||||||
|
emissive="#f4f8ff"
|
||||||
|
emissiveIntensity={0.85}
|
||||||
|
toneMapped={false}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Ceiling grid tees between the fittings */}
|
||||||
|
<CeilingBeams
|
||||||
|
room={room}
|
||||||
|
positions={troffers.map(([, z]) => z).filter((z, i, a) => a.indexOf(z) === i)}
|
||||||
|
axis="x"
|
||||||
|
width={0.05}
|
||||||
|
height={0.04}
|
||||||
|
y={4.1}
|
||||||
|
color="#d6d6d6"
|
||||||
|
roughness={0.5}
|
||||||
|
metalness={0.36}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Shadow gap and aluminium skirting reveal */}
|
||||||
|
<CorniceRing room={room} y={4.02} height={0.06} proud={0.02} color="#c0c0c0" roughness={0.66} metalness={0.16} />
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<group key={wall}>
|
||||||
|
<WallBand wall={wall} room={room} y={0.06} height={0.12} proud={0.05} color={trim} roughness={0.42} metalness={0.5} />
|
||||||
|
<WallBand wall={wall} room={room} y={0.14} height={0.03} proud={0.02} color="#9a9a9a" roughness={0.6} metalness={0.3} />
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
import {
|
||||||
|
CeilingBeams,
|
||||||
|
CeilingRose,
|
||||||
|
CorniceRing,
|
||||||
|
HangingFixture,
|
||||||
|
SideRails,
|
||||||
|
WallBand,
|
||||||
|
WallSconce,
|
||||||
|
} from './primitives';
|
||||||
|
import {
|
||||||
|
shade,
|
||||||
|
useBayZ,
|
||||||
|
useRepeat,
|
||||||
|
useWallClearance,
|
||||||
|
type PeriodProps,
|
||||||
|
} from './geometry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Romantic gallery — Romanticism (Gothic Revival).
|
||||||
|
*
|
||||||
|
* Pugin's revival vocabulary rather than a real nave: dark walnut linenfold
|
||||||
|
* panelling, a cusped blind arcade of pointed arches, a quatrefoil frieze, and
|
||||||
|
* gasoliers. The pointed arch here is applied ornament, which is exactly what
|
||||||
|
* separates the revival from the Gothic hall it quotes.
|
||||||
|
*/
|
||||||
|
export function GothicRevivalDetails({ room, trim, windows }: PeriodProps) {
|
||||||
|
const bays = useBayZ(room.depth, room.halfD, 3.8, 8);
|
||||||
|
const clearLeft = useWallClearance(windows, 'left');
|
||||||
|
const clearRight = useWallClearance(windows, 'right');
|
||||||
|
const walnut = '#4c3323';
|
||||||
|
const walnutLight = shade(walnut, 0.22);
|
||||||
|
|
||||||
|
const quatrefoils = useRepeat(room.depth, 0.95, 24);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Panelled dado with a heavy moulded rail */}
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<group key={wall}>
|
||||||
|
<WallBand wall={wall} room={room} y={0.62} height={1.2} proud={0.06} color={walnut} roughness={0.68} metalness={0.08} />
|
||||||
|
<WallBand wall={wall} room={room} y={1.28} height={0.13} proud={0.11} color={walnutLight} roughness={0.52} metalness={0.12} />
|
||||||
|
<WallBand wall={wall} room={room} y={0.09} height={0.18} proud={0.1} color={shade(walnut, -0.35)} roughness={0.62} metalness={0.08} />
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Colonnettes between the bays, carrying an applied pointed arcade */}
|
||||||
|
{bays.map((z, i) => (
|
||||||
|
<group key={i}>
|
||||||
|
{[-1, 1].map((side) => (
|
||||||
|
<group key={side} position={[side * (room.halfW - 0.06), 0, z]}>
|
||||||
|
<mesh position={[0, 2.0, 0]}>
|
||||||
|
<cylinderGeometry args={[0.055, 0.065, 3.9, 8]} />
|
||||||
|
<meshStandardMaterial color={walnutLight} roughness={0.6} metalness={0.1} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 3.98, 0]}>
|
||||||
|
<boxGeometry args={[0.13, 0.16, 0.22]} />
|
||||||
|
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.2} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Cusped pointed arches over each bay, stepping around the windows */}
|
||||||
|
{bays.slice(0, -1).map((z, i) => {
|
||||||
|
const mid = (z + bays[i + 1]) / 2;
|
||||||
|
// Capped so the raking limbs stay under the ceiling in deep halls.
|
||||||
|
const half = Math.min(1.1, (bays[i + 1] - z) / 2 - 0.1);
|
||||||
|
return (
|
||||||
|
<group key={i}>
|
||||||
|
{([-1, 1] as const)
|
||||||
|
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, half))
|
||||||
|
.map((side) => (
|
||||||
|
<group key={side} position={[side * (room.halfW - 0.06), 0, mid]}>
|
||||||
|
{[-1, 1].map((dir) => (
|
||||||
|
<mesh
|
||||||
|
key={dir}
|
||||||
|
position={[0, 3.62, (dir * half) / 2]}
|
||||||
|
rotation={[dir * 0.42, 0, 0]}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[0.09, half * 1.12, 0.09]} />
|
||||||
|
<meshStandardMaterial color={walnutLight} roughness={0.58} metalness={0.1} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
<mesh position={[0, 3.9, 0]}>
|
||||||
|
<sphereGeometry args={[0.07, 8, 8]} />
|
||||||
|
<meshStandardMaterial color={trim} roughness={0.44} metalness={0.28} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Quatrefoil frieze under the cornice */}
|
||||||
|
{(['left', 'right'] as const).map((wall) =>
|
||||||
|
quatrefoils.map((z, i) => (
|
||||||
|
<group
|
||||||
|
key={`${wall}-${i}`}
|
||||||
|
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.1), 4.02, z]}
|
||||||
|
rotation={[0, Math.PI / 2, 0]}
|
||||||
|
>
|
||||||
|
{[0, 1, 2, 3].map((q) => {
|
||||||
|
const a = (q / 4) * Math.PI * 2 + Math.PI / 4;
|
||||||
|
return (
|
||||||
|
<mesh key={q} position={[Math.cos(a) * 0.07, Math.sin(a) * 0.07, 0]}>
|
||||||
|
<sphereGeometry args={[0.055, 8, 6]} />
|
||||||
|
<meshStandardMaterial color={shade(walnut, 0.34)} roughness={0.6} metalness={0.1} />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</group>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CorniceRing room={room} y={4.14} height={0.1} proud={0.15} color={walnutLight} roughness={0.5} metalness={0.14} />
|
||||||
|
|
||||||
|
{[-1, 1].map((side) => (
|
||||||
|
<WallSconce
|
||||||
|
key={side}
|
||||||
|
wall={side < 0 ? 'left' : 'right'}
|
||||||
|
room={room}
|
||||||
|
along={side * room.halfD * 0.3}
|
||||||
|
y={2.9}
|
||||||
|
armColor={trim}
|
||||||
|
glowColor="#ff9c46"
|
||||||
|
shadeColor="#ffd8a0"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<HangingFixture
|
||||||
|
position={[0, 4.0, 0]}
|
||||||
|
dropLength={0.55}
|
||||||
|
radius={0.36}
|
||||||
|
arms={6}
|
||||||
|
metalColor={trim}
|
||||||
|
flameColor="#ffdca0"
|
||||||
|
glowColor="#ff9a3c"
|
||||||
|
light
|
||||||
|
lightColor="#ffb268"
|
||||||
|
lightIntensity={0.8}
|
||||||
|
lightDistance={9}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realist picture gallery — Realism.
|
||||||
|
*
|
||||||
|
* The mid-century bourgeois interior the Realists exhibited against: a plaster
|
||||||
|
* dado and picture rail, run cornice with a moulded frieze, a ceiling rose, and
|
||||||
|
* gas brackets. Restrained, machine-made mouldings — no carving anywhere.
|
||||||
|
*/
|
||||||
|
export function BourgeoisSalonDetails({ room, trim }: PeriodProps) {
|
||||||
|
const plaster = '#efe8dc';
|
||||||
|
const shadow = shade(plaster, -0.12);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Dado, chair rail, skirting */}
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<group key={wall}>
|
||||||
|
<WallBand wall={wall} room={room} y={0.5} height={0.96} proud={0.04} color={shadow} roughness={0.78} metalness={0.05} />
|
||||||
|
<WallBand wall={wall} room={room} y={1.03} height={0.09} proud={0.09} color={plaster} roughness={0.7} metalness={0.06} />
|
||||||
|
<WallBand wall={wall} room={room} y={0.09} height={0.18} proud={0.1} color={plaster} roughness={0.74} metalness={0.05} />
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Picture rail just above the hang, as the period actually hung its walls */}
|
||||||
|
<SideRails room={room} y={2.52} height={0.07} proud={0.09} color={trim} roughness={0.55} metalness={0.2} />
|
||||||
|
|
||||||
|
{/* Run cornice: cavetto, bead and a moulded frieze */}
|
||||||
|
<CorniceRing room={room} y={3.9} height={0.1} proud={0.07} color={shadow} roughness={0.76} metalness={0.05} />
|
||||||
|
<CorniceRing room={room} y={4.0} height={0.12} proud={0.13} color={plaster} roughness={0.72} metalness={0.05} />
|
||||||
|
<CorniceRing room={room} y={4.13} height={0.07} proud={0.17} color={shade(plaster, 0.06)} roughness={0.7} metalness={0.06} />
|
||||||
|
|
||||||
|
<CeilingRose y={4.09} radius={0.85} color={shade(plaster, 0.04)} rings={3} />
|
||||||
|
|
||||||
|
{[-1, 1].map((side) => (
|
||||||
|
<WallSconce
|
||||||
|
key={side}
|
||||||
|
wall={side < 0 ? 'left' : 'right'}
|
||||||
|
room={room}
|
||||||
|
along={side * room.halfD * 0.34}
|
||||||
|
y={2.78}
|
||||||
|
armColor={trim}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<HangingFixture
|
||||||
|
position={[0, 4.04, 0]}
|
||||||
|
dropLength={0.62}
|
||||||
|
radius={0.3}
|
||||||
|
arms={4}
|
||||||
|
metalColor={trim}
|
||||||
|
flameColor="#fff0cc"
|
||||||
|
glowColor="#ffbe6a"
|
||||||
|
light
|
||||||
|
lightColor="#ffd8a8"
|
||||||
|
lightIntensity={0.75}
|
||||||
|
lightDistance={9}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Impressionist salon — Impressionism.
|
||||||
|
*
|
||||||
|
* The 1874 Boulevard des Capucines idea of a hung room: an iron-and-glass
|
||||||
|
* lantern overhead, a coved plaster cornice, and a velvet-faced picture rail
|
||||||
|
* with visible hanging rods above the frames. Nothing carved, everything pale.
|
||||||
|
*/
|
||||||
|
export function NorthLightSalonDetails({ room, trim }: PeriodProps) {
|
||||||
|
const iron = '#6d6a63';
|
||||||
|
const plaster = '#f4f1ea';
|
||||||
|
const trusses = useBayZ(room.depth, room.halfD, 2.4, 11);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Skylight lantern: glazing bars across a shallow raised centre */}
|
||||||
|
<mesh position={[0, 4.11, 0]}>
|
||||||
|
<boxGeometry args={[Math.min(room.width - 2.2, 6.4), 0.06, Math.min(room.depth - 2.2, 14)]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#ffffff"
|
||||||
|
emissive="#eaf2ff"
|
||||||
|
emissiveIntensity={0.55}
|
||||||
|
roughness={0.4}
|
||||||
|
metalness={0.05}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
<CeilingBeams
|
||||||
|
room={room}
|
||||||
|
positions={trusses}
|
||||||
|
axis="x"
|
||||||
|
width={0.07}
|
||||||
|
height={0.1}
|
||||||
|
y={4.05}
|
||||||
|
color={iron}
|
||||||
|
roughness={0.5}
|
||||||
|
metalness={0.55}
|
||||||
|
/>
|
||||||
|
{[-1, 1].map((side) => (
|
||||||
|
<mesh key={side} position={[(side * Math.min(room.width - 2.2, 6.4)) / 2, 4.03, 0]}>
|
||||||
|
<boxGeometry args={[0.1, 0.18, Math.min(room.depth - 2.2, 14)]} />
|
||||||
|
<meshStandardMaterial color={iron} roughness={0.48} metalness={0.6} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Picture rail with hanging rods dropping to the frame line */}
|
||||||
|
<SideRails room={room} y={2.6} height={0.09} proud={0.1} color={trim} roughness={0.52} metalness={0.24} />
|
||||||
|
<WallBand wall="back" room={room} y={2.6} height={0.09} proud={0.1} color={trim} roughness={0.52} metalness={0.24} />
|
||||||
|
|
||||||
|
{/* Coved cornice */}
|
||||||
|
<CorniceRing room={room} y={3.94} height={0.14} proud={0.06} color={shade(plaster, -0.06)} roughness={0.8} metalness={0.04} />
|
||||||
|
<CorniceRing room={room} y={4.07} height={0.1} proud={0.12} color={plaster} roughness={0.78} metalness={0.04} />
|
||||||
|
|
||||||
|
{/* Dark skirting so the pale walls sit on something */}
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<WallBand key={wall} wall={wall} room={room} y={0.1} height={0.2} proud={0.09} color="#b6a88f" roughness={0.7} metalness={0.06} />
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Montmartre atelier — Post-Impressionism.
|
||||||
|
*
|
||||||
|
* A working painter's room rather than a gallery: a king-post roof truss under
|
||||||
|
* the slope, a cast-iron stove flue climbing the end wall, plain board skirting
|
||||||
|
* and a rough shelf rail carrying the studio's clutter line.
|
||||||
|
*/
|
||||||
|
export function ParisAtelierDetails({ room, trim }: PeriodProps) {
|
||||||
|
const trusses = useBayZ(room.depth, room.halfD, 3.1, 8);
|
||||||
|
const timber = '#8a6a48';
|
||||||
|
const iron = '#3a3632';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Roof trusses: tie beam, king post and raking struts */}
|
||||||
|
{trusses.map((z, i) => {
|
||||||
|
// Struts run from the head of the king post down to the tie beam, and
|
||||||
|
// have to stay inside the 0.3 m the ceiling leaves above the beam.
|
||||||
|
const run = room.halfW - 0.6;
|
||||||
|
const rise = 0.16;
|
||||||
|
const strut = Math.hypot(run, rise);
|
||||||
|
return (
|
||||||
|
<group key={i} position={[0, 0, z]}>
|
||||||
|
<mesh position={[0, 3.9, 0]}>
|
||||||
|
<boxGeometry args={[room.width - 0.1, 0.16, 0.16]} />
|
||||||
|
<meshStandardMaterial color={timber} roughness={0.86} metalness={0.03} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 4.02, 0]}>
|
||||||
|
<boxGeometry args={[0.13, 0.24, 0.13]} />
|
||||||
|
<meshStandardMaterial color={timber} roughness={0.86} metalness={0.03} />
|
||||||
|
</mesh>
|
||||||
|
{[-1, 1].map((side) => (
|
||||||
|
<mesh
|
||||||
|
key={side}
|
||||||
|
position={[(side * run) / 2, 4.06, 0]}
|
||||||
|
rotation={[0, 0, side * Math.atan2(-rise, run)]}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[strut, 0.09, 0.11]} />
|
||||||
|
<meshStandardMaterial color={shade(timber, -0.16)} roughness={0.88} metalness={0.03} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Stove flue running up the end wall and out along the ceiling */}
|
||||||
|
<mesh position={[room.halfW - 0.28, 2.1, -room.halfD + 0.28]}>
|
||||||
|
<cylinderGeometry args={[0.09, 0.09, 4.0, 10]} />
|
||||||
|
<meshStandardMaterial color={iron} roughness={0.62} metalness={0.42} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[room.halfW - 0.28, 4.02, -room.halfD + 0.28]}>
|
||||||
|
<cylinderGeometry args={[0.12, 0.12, 0.14, 10]} />
|
||||||
|
<meshStandardMaterial color={shade(iron, 0.14)} roughness={0.58} metalness={0.5} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Board skirting and a rough shelf rail above the hang */}
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<group key={wall}>
|
||||||
|
<WallBand wall={wall} room={room} y={0.12} height={0.24} proud={0.06} color={shade(timber, -0.2)} roughness={0.88} metalness={0.03} />
|
||||||
|
<WallBand wall={wall} room={room} y={2.66} height={0.06} proud={0.13} color={timber} roughness={0.84} metalness={0.04} />
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* A single bare bulb on a flex, the way the ateliers were actually lit */}
|
||||||
|
<mesh position={[0, 3.7, 0]}>
|
||||||
|
<cylinderGeometry args={[0.008, 0.008, 0.5, 6]} />
|
||||||
|
<meshStandardMaterial color={iron} roughness={0.6} metalness={0.3} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 3.42, 0]}>
|
||||||
|
<sphereGeometry args={[0.09, 12, 12]} />
|
||||||
|
<meshStandardMaterial color="#fff2d0" emissive="#ffc061" emissiveIntensity={0.95} toneMapped={false} />
|
||||||
|
</mesh>
|
||||||
|
<pointLight position={[0, 3.4, 0]} intensity={0.9} distance={9} color={trim} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symbolist chamber — Symbolism.
|
||||||
|
*
|
||||||
|
* The aesthetic interior: a stencilled frieze of repeating motifs, a heavy
|
||||||
|
* portière rail, dark lacquered dado, and low amber sconces. Light is kept
|
||||||
|
* pooled and low, which is the whole point of the period's rooms.
|
||||||
|
*/
|
||||||
|
export function SymbolistDetails({ room, trim }: PeriodProps) {
|
||||||
|
const lacquer = '#12281f';
|
||||||
|
const stencil = shade(trim, 0.1);
|
||||||
|
|
||||||
|
const motifs = useRepeat(room.depth, 0.8, 26);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Lacquered dado under a gilt bead */}
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<group key={wall}>
|
||||||
|
<WallBand wall={wall} room={room} y={0.54} height={1.04} proud={0.05} color={lacquer} roughness={0.42} metalness={0.16} />
|
||||||
|
<WallBand wall={wall} room={room} y={1.1} height={0.06} proud={0.09} color={trim} roughness={0.38} metalness={0.44} />
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Stencilled frieze — a repeating lotus/flame motif under the cornice */}
|
||||||
|
{(['left', 'right'] as const).map((wall) => (
|
||||||
|
<group key={wall}>
|
||||||
|
<WallBand wall={wall} room={room} y={3.86} height={0.34} proud={0.04} color={shade(lacquer, 0.1)} roughness={0.6} metalness={0.12} />
|
||||||
|
{motifs.map((z, i) => (
|
||||||
|
<group key={i} position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.11), 3.86, z]} rotation={[0, Math.PI / 2, 0]}>
|
||||||
|
<mesh>
|
||||||
|
<coneGeometry args={[0.09, 0.24, 5]} />
|
||||||
|
<meshStandardMaterial color={stencil} roughness={0.44} metalness={0.36} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, -0.16, 0]}>
|
||||||
|
<sphereGeometry args={[0.045, 8, 6]} />
|
||||||
|
<meshStandardMaterial color={stencil} roughness={0.44} metalness={0.36} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<CorniceRing room={room} y={4.08} height={0.12} proud={0.13} color={shade(lacquer, 0.16)} roughness={0.5} metalness={0.18} />
|
||||||
|
<CorniceRing room={room} y={4.16} height={0.05} proud={0.16} color={trim} roughness={0.36} metalness={0.5} />
|
||||||
|
|
||||||
|
{/* Portière rail across the end wall */}
|
||||||
|
<WallBand wall="back" room={room} y={2.72} height={0.08} proud={0.12} color={trim} roughness={0.4} metalness={0.44} />
|
||||||
|
|
||||||
|
{[-0.36, 0.36].map((t) => (
|
||||||
|
<WallSconce
|
||||||
|
key={t}
|
||||||
|
wall={t < 0 ? 'left' : 'right'}
|
||||||
|
room={room}
|
||||||
|
along={t * room.halfD}
|
||||||
|
y={2.94}
|
||||||
|
armColor={trim}
|
||||||
|
glowColor="#ff8a2e"
|
||||||
|
shadeColor="#ffcf94"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<HangingFixture
|
||||||
|
position={[0, 4.04, 0]}
|
||||||
|
dropLength={0.66}
|
||||||
|
radius={0.26}
|
||||||
|
arms={5}
|
||||||
|
metalColor={trim}
|
||||||
|
flameColor="#ffd9a0"
|
||||||
|
glowColor="#ff8f2c"
|
||||||
|
light
|
||||||
|
lightColor="#ffa050"
|
||||||
|
lightIntensity={0.7}
|
||||||
|
lightDistance={8}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Art Nouveau salon — Art Nouveau.
|
||||||
|
*
|
||||||
|
* Horta's structural line: whiplash brackets springing from the wall into the
|
||||||
|
* ceiling, tendrils running the cornice, and organic sconces. The curve is
|
||||||
|
* carried by the structure itself rather than applied as ornament.
|
||||||
|
*/
|
||||||
|
export function ArtNouveauDetails({ room, trim }: PeriodProps) {
|
||||||
|
const bays = useBayZ(room.depth, room.halfD, 3.0, 10);
|
||||||
|
const iron = shade(trim, -0.24);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Whiplash brackets: a stem up the wall curling into the ceiling */}
|
||||||
|
{bays.map((z, i) => (
|
||||||
|
<group key={i}>
|
||||||
|
{[-1, 1].map((side) => (
|
||||||
|
<group key={side} position={[side * (room.halfW - 0.07), 0, z]}>
|
||||||
|
<mesh position={[0, 2.05, 0]}>
|
||||||
|
<cylinderGeometry args={[0.045, 0.06, 4.0, 8]} />
|
||||||
|
<meshStandardMaterial color={iron} roughness={0.44} metalness={0.42} />
|
||||||
|
</mesh>
|
||||||
|
{[0, 1, 2, 3].map((s) => {
|
||||||
|
const t = (s + 1) / 4;
|
||||||
|
return (
|
||||||
|
<mesh
|
||||||
|
key={s}
|
||||||
|
position={[-side * (0.1 + t * 0.32), 4.02 - t * t * 0.22, 0]}
|
||||||
|
rotation={[0, 0, side * (0.35 + t * 0.7)]}
|
||||||
|
>
|
||||||
|
<cylinderGeometry args={[0.035, 0.045, 0.34, 6]} />
|
||||||
|
<meshStandardMaterial color={iron} roughness={0.44} metalness={0.42} />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Bud terminal */}
|
||||||
|
<mesh position={[-side * 0.52, 3.78, 0]}>
|
||||||
|
<sphereGeometry args={[0.07, 10, 8]} />
|
||||||
|
<meshStandardMaterial color={trim} roughness={0.36} metalness={0.5} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Tendril cornice — a wave riding the top of every wall */}
|
||||||
|
<CorniceRing room={room} y={4.05} height={0.11} proud={0.11} color={shade(trim, 0.18)} roughness={0.5} metalness={0.3} />
|
||||||
|
<CorniceRing room={room} y={4.15} height={0.06} proud={0.15} color={trim} roughness={0.42} metalness={0.42} />
|
||||||
|
|
||||||
|
{/* Sinuous dado line */}
|
||||||
|
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||||
|
<group key={wall}>
|
||||||
|
<WallBand wall={wall} room={room} y={0.11} height={0.22} proud={0.08} color={shade(trim, -0.3)} roughness={0.52} metalness={0.28} />
|
||||||
|
<WallBand wall={wall} room={room} y={0.86} height={0.05} proud={0.07} color={trim} roughness={0.44} metalness={0.4} />
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{[-0.3, 0.3].map((t) => (
|
||||||
|
<WallSconce
|
||||||
|
key={t}
|
||||||
|
wall={t < 0 ? 'left' : 'right'}
|
||||||
|
room={room}
|
||||||
|
along={t * room.halfD}
|
||||||
|
y={2.88}
|
||||||
|
armColor={trim}
|
||||||
|
glowColor="#8fd66a"
|
||||||
|
shadeColor="#e8ffd8"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<HangingFixture
|
||||||
|
position={[0, 4.0, 0]}
|
||||||
|
dropLength={0.5}
|
||||||
|
radius={0.44}
|
||||||
|
arms={7}
|
||||||
|
metalColor={trim}
|
||||||
|
flameColor="#f2ffe0"
|
||||||
|
glowColor="#a8d86a"
|
||||||
|
light
|
||||||
|
lightColor="#dff2c0"
|
||||||
|
lightIntensity={0.75}
|
||||||
|
lightDistance={9}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { CEILING_Y, EMBED, WALL_INNER, shade, type RoomDims, type WallId } from './geometry';
|
||||||
|
|
||||||
|
/** Wall-flush building blocks shared by the period interiors in this folder. */
|
||||||
|
interface SurfaceProps {
|
||||||
|
color: string;
|
||||||
|
roughness?: number;
|
||||||
|
metalness?: number;
|
||||||
|
emissive?: string;
|
||||||
|
emissiveIntensity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A moulding running the length of one wall, flush against its inner face. */
|
||||||
|
export function WallBand({
|
||||||
|
wall,
|
||||||
|
room,
|
||||||
|
y,
|
||||||
|
height,
|
||||||
|
proud = 0.1,
|
||||||
|
span = 1,
|
||||||
|
color,
|
||||||
|
roughness = 0.7,
|
||||||
|
metalness = 0.06,
|
||||||
|
emissive,
|
||||||
|
emissiveIntensity,
|
||||||
|
}: SurfaceProps & {
|
||||||
|
wall: WallId;
|
||||||
|
room: RoomDims;
|
||||||
|
y: number;
|
||||||
|
height: number;
|
||||||
|
proud?: number;
|
||||||
|
span?: number;
|
||||||
|
}) {
|
||||||
|
const alongZ = wall === 'left' || wall === 'right';
|
||||||
|
const len = (alongZ ? room.depth : room.width) * span;
|
||||||
|
const thick = proud + EMBED * 2;
|
||||||
|
const off = WALL_INNER + proud / 2 - EMBED;
|
||||||
|
const args: [number, number, number] = alongZ ? [thick, height, len] : [len, height, thick];
|
||||||
|
const pos: [number, number, number] =
|
||||||
|
wall === 'back'
|
||||||
|
? [0, y, -room.halfD + off]
|
||||||
|
: wall === 'front'
|
||||||
|
? [0, y, room.halfD - off]
|
||||||
|
: wall === 'left'
|
||||||
|
? [-room.halfW + off, y, 0]
|
||||||
|
: [room.halfW - off, y, 0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh position={pos}>
|
||||||
|
<boxGeometry args={args} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={color}
|
||||||
|
roughness={roughness}
|
||||||
|
metalness={metalness}
|
||||||
|
emissive={emissive}
|
||||||
|
emissiveIntensity={emissiveIntensity}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A moulding ring running right around the room at one height. */
|
||||||
|
export function CorniceRing({
|
||||||
|
room,
|
||||||
|
y,
|
||||||
|
height,
|
||||||
|
proud = 0.12,
|
||||||
|
walls = ['back', 'front', 'left', 'right'],
|
||||||
|
...surface
|
||||||
|
}: SurfaceProps & {
|
||||||
|
room: RoomDims;
|
||||||
|
y: number;
|
||||||
|
height: number;
|
||||||
|
proud?: number;
|
||||||
|
walls?: WallId[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{walls.map((wall) => (
|
||||||
|
<WallBand key={wall} wall={wall} room={room} y={y} height={height} proud={proud} {...surface} />
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** String course / picture rail on the two side walls only. */
|
||||||
|
export function SideRails({
|
||||||
|
room,
|
||||||
|
y,
|
||||||
|
height,
|
||||||
|
proud = 0.1,
|
||||||
|
...surface
|
||||||
|
}: SurfaceProps & { room: RoomDims; y: number; height: number; proud?: number }) {
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{(['left', 'right'] as const).map((wall) => (
|
||||||
|
<WallBand key={wall} wall={wall} room={room} y={y} height={height} proud={proud} {...surface} />
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertical shaft with carved flutes, read as alternating light stone and
|
||||||
|
* shadowed grooves. Sits flush: the caller places it against a wall face.
|
||||||
|
*/
|
||||||
|
export function FlutedShaft({
|
||||||
|
height,
|
||||||
|
radius,
|
||||||
|
color,
|
||||||
|
flutes = 12,
|
||||||
|
taper = 0.9,
|
||||||
|
facing = 'x',
|
||||||
|
}: {
|
||||||
|
height: number;
|
||||||
|
radius: number;
|
||||||
|
color: string;
|
||||||
|
flutes?: number;
|
||||||
|
taper?: number;
|
||||||
|
/** Wall normal axis — grooves are only cut on the exposed half. */
|
||||||
|
facing?: 'x' | 'z';
|
||||||
|
}) {
|
||||||
|
const groove = shade(color, -0.28);
|
||||||
|
const arcs = useMemo(
|
||||||
|
() => Array.from({ length: flutes }, (_, i) => (i / flutes) * Math.PI * 2),
|
||||||
|
[flutes]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<mesh position={[0, height / 2, 0]}>
|
||||||
|
<cylinderGeometry args={[radius * taper, radius, height, 16]} />
|
||||||
|
<meshStandardMaterial color={color} roughness={0.74} metalness={0.05} />
|
||||||
|
</mesh>
|
||||||
|
{arcs.map((a, i) => {
|
||||||
|
const dx = Math.cos(a) * radius * 0.94;
|
||||||
|
const dz = Math.sin(a) * radius * 0.94;
|
||||||
|
// Skip the grooves buried in the wall behind the shaft.
|
||||||
|
if (facing === 'x' ? dx > 0 : dz > 0) return null;
|
||||||
|
return (
|
||||||
|
<mesh key={i} position={[dx, height / 2, dz]}>
|
||||||
|
<cylinderGeometry args={[radius * 0.16, radius * 0.16, height * 0.98, 6]} />
|
||||||
|
<meshStandardMaterial color={groove} roughness={0.88} metalness={0.03} />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TuscanCapital({ y, size, color }: { y: number; size: number; color: string }) {
|
||||||
|
return (
|
||||||
|
<group position={[0, y, 0]}>
|
||||||
|
<mesh>
|
||||||
|
<cylinderGeometry args={[size * 1.15, size * 0.85, size * 0.42, 14]} />
|
||||||
|
<meshStandardMaterial color={color} roughness={0.7} metalness={0.05} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, size * 0.36, 0]}>
|
||||||
|
<boxGeometry args={[size * 2.5, size * 0.28, size * 2.5]} />
|
||||||
|
<meshStandardMaterial color={shade(color, 0.08)} roughness={0.68} metalness={0.05} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ionic capital — abacus over a pair of spiral volutes. */
|
||||||
|
export function IonicCapital({
|
||||||
|
y,
|
||||||
|
size,
|
||||||
|
color,
|
||||||
|
axis = 'z',
|
||||||
|
}: {
|
||||||
|
y: number;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
/** Axis the volute pair spreads along. */
|
||||||
|
axis?: 'x' | 'z';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<group position={[0, y, 0]}>
|
||||||
|
<mesh position={[0, size * 0.1, 0]}>
|
||||||
|
<boxGeometry args={[size * 2.4, size * 0.34, size * 1.5]} />
|
||||||
|
<meshStandardMaterial color={color} roughness={0.66} metalness={0.06} />
|
||||||
|
</mesh>
|
||||||
|
{[-1, 1].map((s) => (
|
||||||
|
<mesh
|
||||||
|
key={s}
|
||||||
|
position={axis === 'z' ? [0, -size * 0.12, s * size * 0.85] : [s * size * 0.85, -size * 0.12, 0]}
|
||||||
|
rotation={axis === 'z' ? [0, Math.PI / 2, 0] : [0, 0, 0]}
|
||||||
|
>
|
||||||
|
<torusGeometry args={[size * 0.38, size * 0.14, 8, 16]} />
|
||||||
|
<meshStandardMaterial color={shade(color, 0.06)} roughness={0.62} metalness={0.07} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moulded plinth under a shaft. */
|
||||||
|
export function ShaftBase({ size, color }: { size: number; color: string }) {
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<mesh position={[0, 0.08, 0]}>
|
||||||
|
<boxGeometry args={[size * 2.4, 0.16, size * 2.4]} />
|
||||||
|
<meshStandardMaterial color={color} roughness={0.78} metalness={0.04} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.21, 0]}>
|
||||||
|
<cylinderGeometry args={[size * 1.0, size * 1.15, 0.1, 14]} />
|
||||||
|
<meshStandardMaterial color={shade(color, 0.06)} roughness={0.72} metalness={0.05} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Semicircular arch drawn as a chord of straight voussoir blocks. */
|
||||||
|
export function RoundArch({
|
||||||
|
span,
|
||||||
|
rise,
|
||||||
|
thickness = 0.14,
|
||||||
|
depth = 0.16,
|
||||||
|
color,
|
||||||
|
segments = 11,
|
||||||
|
roughness = 0.72,
|
||||||
|
metalness = 0.05,
|
||||||
|
}: {
|
||||||
|
span: number;
|
||||||
|
rise: number;
|
||||||
|
thickness?: number;
|
||||||
|
depth?: number;
|
||||||
|
color: string;
|
||||||
|
segments?: number;
|
||||||
|
roughness?: number;
|
||||||
|
metalness?: number;
|
||||||
|
}) {
|
||||||
|
const blocks = useMemo(() => {
|
||||||
|
const out: { x: number; y: number; len: number; rot: number }[] = [];
|
||||||
|
for (let s = 0; s < segments; s++) {
|
||||||
|
const t0 = s / segments;
|
||||||
|
const t1 = (s + 1) / segments;
|
||||||
|
const at = (t: number) => {
|
||||||
|
const a = Math.PI * (1 - t);
|
||||||
|
return { x: Math.cos(a) * (span / 2), y: Math.sin(a) * rise };
|
||||||
|
};
|
||||||
|
const p0 = at(t0);
|
||||||
|
const p1 = at(t1);
|
||||||
|
out.push({
|
||||||
|
x: (p0.x + p1.x) / 2,
|
||||||
|
y: (p0.y + p1.y) / 2,
|
||||||
|
len: Math.hypot(p1.x - p0.x, p1.y - p0.y) * 1.08,
|
||||||
|
rot: Math.atan2(p1.y - p0.y, p1.x - p0.x),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [span, rise, segments]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{blocks.map((b, i) => (
|
||||||
|
<mesh key={i} position={[b.x, b.y, 0]} rotation={[0, 0, b.rot]}>
|
||||||
|
<boxGeometry args={[b.len, thickness, depth]} />
|
||||||
|
<meshStandardMaterial color={color} roughness={roughness} metalness={metalness} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recessed coffer grid under the ceiling plane. */
|
||||||
|
export function CofferedCeiling({
|
||||||
|
room,
|
||||||
|
y = CEILING_Y,
|
||||||
|
cell = 2.3,
|
||||||
|
panelColor,
|
||||||
|
ribColor,
|
||||||
|
ribWidth = 0.16,
|
||||||
|
ribDrop = 0.12,
|
||||||
|
rosette,
|
||||||
|
}: {
|
||||||
|
room: RoomDims;
|
||||||
|
y?: number;
|
||||||
|
cell?: number;
|
||||||
|
panelColor: string;
|
||||||
|
ribColor: string;
|
||||||
|
ribWidth?: number;
|
||||||
|
ribDrop?: number;
|
||||||
|
rosette?: string;
|
||||||
|
}) {
|
||||||
|
const grid = useMemo(() => {
|
||||||
|
const cols = Math.max(2, Math.min(6, Math.round(room.width / cell)));
|
||||||
|
const rows = Math.max(2, Math.min(8, Math.round(room.depth / cell)));
|
||||||
|
const cw = room.width / cols;
|
||||||
|
const cd = room.depth / rows;
|
||||||
|
const cells: { x: number; z: number }[] = [];
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
cells.push({ x: -room.halfW + cw * (c + 0.5), z: -room.halfD + cd * (r + 0.5) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { cells, cw, cd, cols, rows };
|
||||||
|
}, [room.width, room.depth, room.halfW, room.halfD, cell]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{grid.cells.map((c, i) => (
|
||||||
|
<group key={i} position={[c.x, y, c.z]}>
|
||||||
|
<mesh position={[0, 0.01, 0]}>
|
||||||
|
<boxGeometry args={[grid.cw - ribWidth, 0.03, grid.cd - ribWidth]} />
|
||||||
|
<meshStandardMaterial color={panelColor} roughness={0.86} metalness={0.04} />
|
||||||
|
</mesh>
|
||||||
|
{rosette && (
|
||||||
|
<mesh position={[0, -ribDrop * 0.35, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||||
|
<ringGeometry args={[grid.cw * 0.06, grid.cw * 0.15, 12]} />
|
||||||
|
<meshStandardMaterial color={rosette} roughness={0.4} metalness={0.5} side={2} />
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
{/* Ribs between the coffers */}
|
||||||
|
{Array.from({ length: grid.cols + 1 }, (_, c) => (
|
||||||
|
<mesh key={`c${c}`} position={[-room.halfW + grid.cw * c, y - ribDrop / 2, 0]}>
|
||||||
|
<boxGeometry args={[ribWidth, ribDrop, room.depth]} />
|
||||||
|
<meshStandardMaterial color={ribColor} roughness={0.7} metalness={0.08} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
{Array.from({ length: grid.rows + 1 }, (_, r) => (
|
||||||
|
<mesh key={`r${r}`} position={[0, y - ribDrop / 2, -room.halfD + grid.cd * r]}>
|
||||||
|
<boxGeometry args={[room.width, ribDrop, ribWidth]} />
|
||||||
|
<meshStandardMaterial color={ribColor} roughness={0.7} metalness={0.08} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exposed structural beams spanning the room. */
|
||||||
|
export function CeilingBeams({
|
||||||
|
room,
|
||||||
|
positions,
|
||||||
|
axis = 'x',
|
||||||
|
width = 0.22,
|
||||||
|
height = 0.3,
|
||||||
|
y = CEILING_Y - 0.16,
|
||||||
|
color,
|
||||||
|
roughness = 0.82,
|
||||||
|
metalness = 0.05,
|
||||||
|
}: SurfaceProps & {
|
||||||
|
room: RoomDims;
|
||||||
|
positions: number[];
|
||||||
|
/** Axis the beams run along. */
|
||||||
|
axis?: 'x' | 'z';
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
y?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{positions.map((p, i) => (
|
||||||
|
<mesh key={i} position={axis === 'x' ? [0, y, p] : [p, y, 0]}>
|
||||||
|
<boxGeometry
|
||||||
|
args={axis === 'x' ? [room.width, height, width] : [width, height, room.depth]}
|
||||||
|
/>
|
||||||
|
<meshStandardMaterial color={color} roughness={roughness} metalness={metalness} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plaster ceiling rose, the anchor a salon chandelier hangs from. */
|
||||||
|
export function CeilingRose({
|
||||||
|
y = CEILING_Y,
|
||||||
|
radius = 1.0,
|
||||||
|
color,
|
||||||
|
rings = 3,
|
||||||
|
}: {
|
||||||
|
y?: number;
|
||||||
|
radius?: number;
|
||||||
|
color: string;
|
||||||
|
rings?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<group position={[0, y, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||||
|
{Array.from({ length: rings }, (_, i) => {
|
||||||
|
const t = (i + 1) / rings;
|
||||||
|
return (
|
||||||
|
<mesh key={i} position={[0, 0, i * 0.012]}>
|
||||||
|
<ringGeometry args={[radius * t * 0.55, radius * t, 32]} />
|
||||||
|
<meshStandardMaterial color={shade(color, i * 0.06)} roughness={0.7} metalness={0.12} side={2} />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hanging light fixture. `light` adds a real point light — keep at most two per
|
||||||
|
* hall so the shared track lighting stays inside the WebGL light budget.
|
||||||
|
*/
|
||||||
|
export function HangingFixture({
|
||||||
|
position,
|
||||||
|
dropLength = 0.6,
|
||||||
|
radius = 0.42,
|
||||||
|
arms = 6,
|
||||||
|
metalColor,
|
||||||
|
flameColor = '#ffcf80',
|
||||||
|
glowColor = '#ff9830',
|
||||||
|
light = false,
|
||||||
|
lightColor = '#ffb060',
|
||||||
|
lightIntensity = 0.8,
|
||||||
|
lightDistance = 7,
|
||||||
|
}: {
|
||||||
|
position: [number, number, number];
|
||||||
|
dropLength?: number;
|
||||||
|
radius?: number;
|
||||||
|
arms?: number;
|
||||||
|
metalColor: string;
|
||||||
|
flameColor?: string;
|
||||||
|
glowColor?: string;
|
||||||
|
light?: boolean;
|
||||||
|
lightColor?: string;
|
||||||
|
lightIntensity?: number;
|
||||||
|
lightDistance?: number;
|
||||||
|
}) {
|
||||||
|
const ringY = position[1] - dropLength;
|
||||||
|
return (
|
||||||
|
<group position={[position[0], 0, position[2]]}>
|
||||||
|
<mesh position={[0, position[1] - dropLength / 2, 0]}>
|
||||||
|
<cylinderGeometry args={[0.012, 0.012, dropLength, 6]} />
|
||||||
|
<meshStandardMaterial color={shade(metalColor, -0.4)} roughness={0.42} metalness={0.7} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, ringY, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||||
|
<torusGeometry args={[radius, 0.028, 8, 22]} />
|
||||||
|
<meshStandardMaterial color={metalColor} roughness={0.26} metalness={0.82} />
|
||||||
|
</mesh>
|
||||||
|
{Array.from({ length: arms }, (_, i) => {
|
||||||
|
const a = (i / arms) * Math.PI * 2;
|
||||||
|
return (
|
||||||
|
<mesh key={i} position={[Math.cos(a) * radius, ringY - 0.09, Math.sin(a) * radius]}>
|
||||||
|
<sphereGeometry args={[0.05, 8, 8]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={flameColor}
|
||||||
|
emissive={glowColor}
|
||||||
|
emissiveIntensity={0.9}
|
||||||
|
toneMapped={false}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{light && (
|
||||||
|
<pointLight
|
||||||
|
position={[0, ringY - 0.09, 0]}
|
||||||
|
intensity={lightIntensity}
|
||||||
|
distance={lightDistance}
|
||||||
|
color={lightColor}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wall light mounted above the frame line. Emissive only — sconces are for
|
||||||
|
* period character, not for lighting the hall.
|
||||||
|
*/
|
||||||
|
export function WallSconce({
|
||||||
|
wall,
|
||||||
|
room,
|
||||||
|
along,
|
||||||
|
y = 2.86,
|
||||||
|
armColor,
|
||||||
|
glowColor = '#ffb45a',
|
||||||
|
shadeColor = '#ffdca8',
|
||||||
|
}: {
|
||||||
|
wall: 'left' | 'right' | 'back';
|
||||||
|
room: RoomDims;
|
||||||
|
/** Offset along the wall from its centre. */
|
||||||
|
along: number;
|
||||||
|
y?: number;
|
||||||
|
armColor: string;
|
||||||
|
glowColor?: string;
|
||||||
|
shadeColor?: string;
|
||||||
|
}) {
|
||||||
|
const pos: [number, number, number] =
|
||||||
|
wall === 'back'
|
||||||
|
? [along, y, -room.halfD + WALL_INNER + 0.02]
|
||||||
|
: wall === 'left'
|
||||||
|
? [-room.halfW + WALL_INNER + 0.02, y, along]
|
||||||
|
: [room.halfW - WALL_INNER - 0.02, y, along];
|
||||||
|
const rot: [number, number, number] =
|
||||||
|
wall === 'back' ? [0, 0, 0] : wall === 'left' ? [0, Math.PI / 2, 0] : [0, -Math.PI / 2, 0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={pos} rotation={rot}>
|
||||||
|
<mesh position={[0, 0, 0.03]}>
|
||||||
|
<boxGeometry args={[0.16, 0.22, 0.05]} />
|
||||||
|
<meshStandardMaterial color={armColor} roughness={0.34} metalness={0.68} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.06, 0.09]} rotation={[Math.PI / 2.6, 0, 0]}>
|
||||||
|
<cylinderGeometry args={[0.012, 0.012, 0.16, 6]} />
|
||||||
|
<meshStandardMaterial color={armColor} roughness={0.34} metalness={0.68} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.15, 0.13]}>
|
||||||
|
<sphereGeometry args={[0.075, 10, 10]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={shadeColor}
|
||||||
|
emissive={glowColor}
|
||||||
|
emissiveIntensity={0.75}
|
||||||
|
toneMapped={false}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -54,9 +54,42 @@ export interface MovementInteriorStyle {
|
|||||||
trackLights: number;
|
trackLights: number;
|
||||||
/** Scales the shared hall/scene lights. <1 for deliberately dim period interiors. */
|
/** Scales the shared hall/scene lights. <1 for deliberately dim period interiors. */
|
||||||
lightScale: number;
|
lightScale: number;
|
||||||
details: 'palazzo' | 'baroque' | 'gothic' | 'byzantine' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier';
|
/**
|
||||||
|
* Which period architecture the hall is built from. Every movement has its
|
||||||
|
* own — the interiors are not shared between movements — and each kind maps
|
||||||
|
* to one component under `components/hall-details/`.
|
||||||
|
*/
|
||||||
|
details: MovementDetailKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MovementDetailKind =
|
||||||
|
| 'roman'
|
||||||
|
| 'byzantine'
|
||||||
|
| 'gothic'
|
||||||
|
| 'quattrocento'
|
||||||
|
| 'cinquecento'
|
||||||
|
| 'flemish'
|
||||||
|
| 'mannerist'
|
||||||
|
| 'baroque'
|
||||||
|
| 'rococo'
|
||||||
|
| 'neoclassical'
|
||||||
|
| 'gothic-revival'
|
||||||
|
| 'bourgeois'
|
||||||
|
| 'north-light'
|
||||||
|
| 'paris-atelier'
|
||||||
|
| 'symbolist'
|
||||||
|
| 'art-nouveau'
|
||||||
|
| 'fauvist'
|
||||||
|
| 'expressionist'
|
||||||
|
| 'cubist-atelier'
|
||||||
|
| 'futurist'
|
||||||
|
| 'suprematist'
|
||||||
|
| 'constructivist'
|
||||||
|
| 'dada'
|
||||||
|
| 'surrealist'
|
||||||
|
| 'loft'
|
||||||
|
| 'white-cube';
|
||||||
|
|
||||||
function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] {
|
function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] {
|
||||||
return specs;
|
return specs;
|
||||||
}
|
}
|
||||||
@@ -99,9 +132,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Roman atrium',
|
'Roman atrium',
|
||||||
'Marble-clad villa · mosaic floor · clerestory daylight',
|
'Marble-clad villa · mosaic floor · clerestory daylight',
|
||||||
{ wall: 'limestone', ceiling: 'plaster-warm', floor: 'mosaic-roman' },
|
{ wall: 'limestone', ceiling: 'plaster-warm', floor: 'mosaic-roman' },
|
||||||
{ wall: '#e8e0d0', ceiling: '#f5f0e8', floor: '#c8b898', trim: '#a89878' },
|
{ wall: '#8e8980', ceiling: '#9c9994', floor: '#c8b898', trim: '#a89878' },
|
||||||
{
|
{
|
||||||
details: 'classical',
|
details: 'roman',
|
||||||
|
// Lit like the basilica: shared lights held back so the walls read as
|
||||||
|
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||||
|
lightScale: 0.3,
|
||||||
titleColor: '#5a4830',
|
titleColor: '#5a4830',
|
||||||
ambient: 0.62,
|
ambient: 0.62,
|
||||||
warmLight: '#fff8e8',
|
warmLight: '#fff8e8',
|
||||||
@@ -175,9 +211,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Florentine palazzo',
|
'Florentine palazzo',
|
||||||
'Lime-washed walls · terracotta accents · arched windows',
|
'Lime-washed walls · terracotta accents · arched windows',
|
||||||
{ wall: 'painted-lime', wallSide: 'stucco-cream', ceiling: 'fresco-worn', floor: 'terracotta-tiles' },
|
{ wall: 'painted-lime', wallSide: 'stucco-cream', ceiling: 'fresco-worn', floor: 'terracotta-tiles' },
|
||||||
{ wall: '#f4ebe0', ceiling: '#faf6ee', floor: '#c89070', trim: '#c9a227' },
|
{ wall: '#89847e', ceiling: '#96948f', floor: '#c89070', trim: '#c9a227' },
|
||||||
{
|
{
|
||||||
details: 'palazzo',
|
details: 'quattrocento',
|
||||||
|
// Lit like the basilica: shared lights held back so the walls read as
|
||||||
|
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||||
|
lightScale: 0.3,
|
||||||
titleColor: '#6b4423',
|
titleColor: '#6b4423',
|
||||||
windows: windows(
|
windows: windows(
|
||||||
{ wall: 'back', x: -2.2, y: 2.6, width: 1.3, height: 1.7, style: 'roman-arch', lightColor: '#fff4e0', lightIntensity: 3.0 },
|
{ wall: 'back', x: -2.2, y: 2.6, width: 1.3, height: 1.7, style: 'roman-arch', lightColor: '#fff4e0', lightIntensity: 3.0 },
|
||||||
@@ -191,9 +230,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Roman palazzo',
|
'Roman palazzo',
|
||||||
'Marble and stucco · coffered ceiling · checker floor',
|
'Marble and stucco · coffered ceiling · checker floor',
|
||||||
{ wall: 'marble-veined-carrara', wallSide: 'stucco-cream', ceiling: 'plaster-warm', floor: 'marble-checker' },
|
{ wall: 'marble-veined-carrara', wallSide: 'stucco-cream', ceiling: 'plaster-warm', floor: 'marble-checker' },
|
||||||
{ wall: '#f0ece4', wallSide: '#efe4d4', ceiling: '#faf6ee', floor: '#ddd8cc', trim: '#c9a227' },
|
{ wall: '#918f8a', wallSide: '#8f8980', ceiling: '#9c9994', floor: '#ddd8cc', trim: '#c9a227' },
|
||||||
{
|
{
|
||||||
details: 'palazzo',
|
details: 'cinquecento',
|
||||||
|
// Lit like the basilica: shared lights held back so the walls read as
|
||||||
|
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||||
|
lightScale: 0.3,
|
||||||
titleColor: '#6b4423',
|
titleColor: '#6b4423',
|
||||||
windows: windows(
|
windows: windows(
|
||||||
{ wall: 'back', x: 0, y: 2.8, width: 2.8, height: 1.8, style: 'baroque-pair', lightColor: '#fff8f0', lightIntensity: 3.8 },
|
{ wall: 'back', x: 0, y: 2.8, width: 2.8, height: 1.8, style: 'baroque-pair', lightColor: '#fff8f0', lightIntensity: 3.8 },
|
||||||
@@ -208,9 +250,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Flemish panel hall',
|
'Flemish panel hall',
|
||||||
'Oak wainscoting · leaded glass · herringbone floor',
|
'Oak wainscoting · leaded glass · herringbone floor',
|
||||||
{ wall: 'oak-panel', wallSide: 'plaster-warm', ceiling: 'dark-wood-panel', floor: 'parquet-herringbone' },
|
{ wall: 'oak-panel', wallSide: 'plaster-warm', ceiling: 'dark-wood-panel', floor: 'parquet-herringbone' },
|
||||||
{ wall: '#8a6848', wallSide: '#f0ebe3', ceiling: '#3a2818', floor: '#8a6848', trim: '#5c4030' },
|
{ wall: '#8a6848', wallSide: '#8c8985', ceiling: '#3a2818', floor: '#8a6848', trim: '#5c4030' },
|
||||||
{
|
{
|
||||||
details: 'salon',
|
details: 'flemish',
|
||||||
|
// Lit like the basilica: shared lights held back so the walls read as
|
||||||
|
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||||
|
lightScale: 0.3,
|
||||||
titleColor: '#f0e8d8',
|
titleColor: '#f0e8d8',
|
||||||
warmLight: '#ffe8c8',
|
warmLight: '#ffe8c8',
|
||||||
windows: windows(
|
windows: windows(
|
||||||
@@ -226,9 +271,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Mannerist gallery',
|
'Mannerist gallery',
|
||||||
'Dramatic stucco · elongated windows · veined marble',
|
'Dramatic stucco · elongated windows · veined marble',
|
||||||
{ wall: 'stucco-terracotta', ceiling: 'gilded-stucco', floor: 'marble-veined-emerald' },
|
{ wall: 'stucco-terracotta', ceiling: 'gilded-stucco', floor: 'marble-veined-emerald' },
|
||||||
{ wall: '#d8b898', ceiling: '#d8c070', floor: '#dce8e0', trim: '#b8860b' },
|
{ wall: '#98816b', ceiling: '#a79456', floor: '#dce8e0', trim: '#b8860b' },
|
||||||
{
|
{
|
||||||
details: 'baroque',
|
details: 'mannerist',
|
||||||
|
// Lit like the basilica: shared lights held back so the walls read as
|
||||||
|
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||||
|
lightScale: 0.3,
|
||||||
titleColor: '#4a2818',
|
titleColor: '#4a2818',
|
||||||
windows: windows(
|
windows: windows(
|
||||||
{ wall: 'back', x: -1.8, y: 2.7, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#fff0d8', lightIntensity: 2.8 },
|
{ wall: 'back', x: -1.8, y: 2.7, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#fff0d8', lightIntensity: 2.8 },
|
||||||
@@ -264,9 +312,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Rococo salon',
|
'Rococo salon',
|
||||||
'Pastel painted walls · gilt trim · chevron parquet',
|
'Pastel painted walls · gilt trim · chevron parquet',
|
||||||
{ wall: 'painted-oil-satin', ceiling: 'silk-pale', floor: 'parquet-chevrons' },
|
{ wall: 'painted-oil-satin', ceiling: 'silk-pale', floor: 'parquet-chevrons' },
|
||||||
{ wall: '#e8dce8', ceiling: '#faf6f0', floor: '#c8a882', trim: '#d4af37' },
|
{ wall: '#918a91', ceiling: '#9b9995', floor: '#c8a882', trim: '#d4af37' },
|
||||||
{
|
{
|
||||||
details: 'salon',
|
details: 'rococo',
|
||||||
|
// Lit like the basilica: shared lights held back so the walls read as
|
||||||
|
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||||
|
lightScale: 0.3,
|
||||||
titleColor: '#6a4858',
|
titleColor: '#6a4858',
|
||||||
warmLight: '#fff0f0',
|
warmLight: '#fff0f0',
|
||||||
windows: windows(
|
windows: windows(
|
||||||
@@ -281,9 +332,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Neoclassical museum',
|
'Neoclassical museum',
|
||||||
'White painted walls · coffered ceiling · skylit salon',
|
'White painted walls · coffered ceiling · skylit salon',
|
||||||
{ wall: 'painted-flat', ceiling: 'plaster-white', floor: 'marble-veined-carrara' },
|
{ wall: 'painted-flat', ceiling: 'plaster-white', floor: 'marble-veined-carrara' },
|
||||||
{ wall: '#f2f0ec', ceiling: '#fafafa', floor: '#eceae6', trim: '#b8b0a4' },
|
{ wall: '#908f8d', ceiling: '#9e9e9e', floor: '#eceae6', trim: '#b8b0a4' },
|
||||||
{
|
{
|
||||||
details: 'neoclassical',
|
details: 'neoclassical',
|
||||||
|
// Lit like the basilica: shared lights held back so the walls read as
|
||||||
|
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||||
|
lightScale: 0.3,
|
||||||
titleColor: '#4a4844',
|
titleColor: '#4a4844',
|
||||||
ambient: 0.65,
|
ambient: 0.65,
|
||||||
windows: windows(
|
windows: windows(
|
||||||
@@ -301,7 +355,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
{ wall: 'walnut-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
{ wall: 'walnut-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||||
{ wall: '#5a4030', ceiling: '#2a2420', floor: '#6a5040', trim: '#8a7050' },
|
{ wall: '#5a4030', ceiling: '#2a2420', floor: '#6a5040', trim: '#8a7050' },
|
||||||
{
|
{
|
||||||
details: 'salon',
|
details: 'gothic-revival',
|
||||||
titleColor: '#e8dcc8',
|
titleColor: '#e8dcc8',
|
||||||
ambient: 0.5,
|
ambient: 0.5,
|
||||||
warmLight: '#ffe0c0',
|
warmLight: '#ffe0c0',
|
||||||
@@ -317,9 +371,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Realist picture gallery',
|
'Realist picture gallery',
|
||||||
'Warm painted walls · bourgeois salon · oak parquet',
|
'Warm painted walls · bourgeois salon · oak parquet',
|
||||||
{ wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
{ wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
||||||
{ wall: '#e8e2d8', ceiling: '#f5f0e8', floor: '#a08060', trim: '#8a7050' },
|
{ wall: '#88847e', ceiling: '#97948f', floor: '#a08060', trim: '#8a7050' },
|
||||||
{
|
{
|
||||||
details: 'salon',
|
details: 'bourgeois',
|
||||||
|
// Lit like the basilica: shared lights held back so the walls read as
|
||||||
|
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||||
|
lightScale: 0.3,
|
||||||
windows: windows(
|
windows: windows(
|
||||||
{ wall: 'back', x: 0, y: 2.5, width: 2.4, height: 1.6, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 3.2 },
|
{ wall: 'back', x: 0, y: 2.5, width: 2.4, height: 1.6, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 3.2 },
|
||||||
{ wall: 'left', x: 0, y: 2.7, width: 1.6, height: 1.3, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.4 }
|
{ wall: 'left', x: 0, y: 2.7, width: 1.6, height: 1.3, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.4 }
|
||||||
@@ -331,9 +388,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Impressionist salon',
|
'Impressionist salon',
|
||||||
'North-light skylight · pale painted walls · herringbone floor',
|
'North-light skylight · pale painted walls · herringbone floor',
|
||||||
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
|
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
|
||||||
{ wall: '#f0ebe3', ceiling: '#fafafa', floor: '#c8a882', trim: '#c9a96e' },
|
{ wall: '#8f8c87', ceiling: '#9e9e9e', floor: '#c8a882', trim: '#c9a96e' },
|
||||||
{
|
{
|
||||||
details: 'salon',
|
details: 'north-light',
|
||||||
|
// Lit like the basilica: shared lights held back so the walls read as
|
||||||
|
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||||
|
lightScale: 0.3,
|
||||||
ambient: 0.68,
|
ambient: 0.68,
|
||||||
windows: windows(
|
windows: windows(
|
||||||
{ wall: 'ceiling', x: 0, y: 0, width: 5.0, height: 3.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 6.0 },
|
{ wall: 'ceiling', x: 0, y: 0, width: 5.0, height: 3.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 6.0 },
|
||||||
@@ -348,9 +408,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Montmartre atelier',
|
'Montmartre atelier',
|
||||||
'Painted studio walls · large north window · worn floorboards',
|
'Painted studio walls · large north window · worn floorboards',
|
||||||
{ wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
{ wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
||||||
{ wall: '#e8e0d0', ceiling: '#f0ebe0', floor: '#9a7858', trim: '#8a6848' },
|
{ wall: '#847f76', ceiling: '#918f88', floor: '#9a7858', trim: '#8a6848' },
|
||||||
{
|
{
|
||||||
details: 'atelier',
|
details: 'paris-atelier',
|
||||||
|
// Lit like the basilica: shared lights held back so the walls read as
|
||||||
|
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||||
|
lightScale: 0.3,
|
||||||
windows: windows(
|
windows: windows(
|
||||||
{ wall: 'left', x: 0, y: 2.4, width: 2.8, height: 2.0, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 4.5 },
|
{ wall: 'left', x: 0, y: 2.4, width: 2.8, height: 2.0, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 4.5 },
|
||||||
{ wall: 'back', x: 0, y: 2.8, width: 1.0, height: 1.2, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.0 }
|
{ wall: 'back', x: 0, y: 2.8, width: 1.0, height: 1.2, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.0 }
|
||||||
@@ -364,7 +427,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||||
{ wall: '#1a4030', ceiling: '#1a1814', floor: '#4a3828', trim: '#8a7050' },
|
{ wall: '#1a4030', ceiling: '#1a1814', floor: '#4a3828', trim: '#8a7050' },
|
||||||
{
|
{
|
||||||
details: 'salon',
|
details: 'symbolist',
|
||||||
titleColor: '#d8e8c8',
|
titleColor: '#d8e8c8',
|
||||||
ambient: 0.55,
|
ambient: 0.55,
|
||||||
warmLight: '#ffd898',
|
warmLight: '#ffd898',
|
||||||
@@ -380,9 +443,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Art Nouveau salon',
|
'Art Nouveau salon',
|
||||||
'Painted plaster · stained glass · terrazzo floor',
|
'Painted plaster · stained glass · terrazzo floor',
|
||||||
{ wall: 'painted-oil-satin', ceiling: 'silk-gold', floor: 'terrazzo' },
|
{ wall: 'painted-oil-satin', ceiling: 'silk-gold', floor: 'terrazzo' },
|
||||||
{ wall: '#f0ece4', ceiling: '#d8c890', floor: '#d8d0c4', trim: '#6a9868' },
|
{ wall: '#8f8c87', ceiling: '#a0946b', floor: '#d8d0c4', trim: '#6a9868' },
|
||||||
{
|
{
|
||||||
details: 'salon',
|
details: 'art-nouveau',
|
||||||
|
// Lit like the basilica: shared lights held back so the walls read as
|
||||||
|
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||||
|
lightScale: 0.3,
|
||||||
titleColor: '#3a5840',
|
titleColor: '#3a5840',
|
||||||
windows: windows(
|
windows: windows(
|
||||||
{ wall: 'back', x: 0, y: 2.5, width: 2.2, height: 2.0, style: 'art-nouveau', lightColor: '#e8ffe8', lightIntensity: 3.2 },
|
{ wall: 'back', x: 0, y: 2.5, width: 2.2, height: 2.0, style: 'art-nouveau', lightColor: '#e8ffe8', lightIntensity: 3.2 },
|
||||||
@@ -396,9 +462,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Fauvist studio',
|
'Fauvist studio',
|
||||||
'Bold painted walls · flooded with color and light',
|
'Bold painted walls · flooded with color and light',
|
||||||
{ wall: 'painted-oil-matte', wallSide: 'painted-oil-matte', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
{ wall: 'painted-oil-matte', wallSide: 'painted-oil-matte', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
||||||
{ wall: '#e89060', wallSide: '#e8c860', ceiling: '#f8f0e0', floor: '#a07048', trim: '#c04020' },
|
{ wall: '#e89060', wallSide: '#e8c860', ceiling: '#9e998e', floor: '#a07048', trim: '#c04020' },
|
||||||
{
|
{
|
||||||
details: 'atelier',
|
details: 'fauvist',
|
||||||
|
// Lit like the basilica: shared lights held back so the walls read as
|
||||||
|
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||||
|
lightScale: 0.3,
|
||||||
titleColor: '#402010',
|
titleColor: '#402010',
|
||||||
ambient: 0.7,
|
ambient: 0.7,
|
||||||
windows: windows(
|
windows: windows(
|
||||||
@@ -414,7 +483,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
{ wall: 'dark-wood-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
{ wall: 'dark-wood-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||||
{ wall: '#3a2818', ceiling: '#2a2018', floor: '#5a4030', trim: '#8a6040' },
|
{ wall: '#3a2818', ceiling: '#2a2018', floor: '#5a4030', trim: '#8a6040' },
|
||||||
{
|
{
|
||||||
details: 'atelier',
|
details: 'expressionist',
|
||||||
titleColor: '#f0d8b0',
|
titleColor: '#f0d8b0',
|
||||||
ambient: 0.52,
|
ambient: 0.52,
|
||||||
windows: windows(
|
windows: windows(
|
||||||
@@ -428,9 +497,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Cubist studio',
|
'Cubist studio',
|
||||||
'Paris atelier · factory windows · painted plaster',
|
'Paris atelier · factory windows · painted plaster',
|
||||||
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
|
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
|
||||||
{ wall: '#e8e4dc', ceiling: '#f5f5f5', floor: '#9a8870', trim: '#888888' },
|
{ wall: '#8c8a85', ceiling: '#999999', floor: '#9a8870', trim: '#888888' },
|
||||||
{
|
{
|
||||||
details: 'atelier',
|
details: 'cubist-atelier',
|
||||||
|
// Lit like the basilica: shared lights held back so the walls read as
|
||||||
|
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||||
|
lightScale: 0.3,
|
||||||
ambient: 0.65,
|
ambient: 0.65,
|
||||||
windows: windows(
|
windows: windows(
|
||||||
{ wall: 'left', x: 0, y: 2.5, width: 3.5, height: 2.4, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 5.5 },
|
{ wall: 'left', x: 0, y: 2.5, width: 3.5, height: 2.4, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 5.5 },
|
||||||
@@ -443,9 +515,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Futurist loft',
|
'Futurist loft',
|
||||||
'Steel and glass · industrial concrete · sweeping daylight',
|
'Steel and glass · industrial concrete · sweeping daylight',
|
||||||
{ wall: 'concrete-raw', ceiling: 'concrete-raw', floor: 'industrial-floor' },
|
{ wall: 'concrete-raw', ceiling: 'concrete-raw', floor: 'industrial-floor' },
|
||||||
{ wall: '#a8a8a8', ceiling: '#989898', floor: '#888880', trim: '#606060' },
|
{ wall: '#858585', ceiling: '#7a7a7a', floor: '#888880', trim: '#606060' },
|
||||||
{
|
{
|
||||||
details: 'industrial',
|
details: 'futurist',
|
||||||
|
// Lit like the basilica: shared lights held back so the walls read as
|
||||||
|
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||||
|
lightScale: 0.3,
|
||||||
titleColor: '#303030',
|
titleColor: '#303030',
|
||||||
ambient: 0.62,
|
ambient: 0.62,
|
||||||
fog: '#181818',
|
fog: '#181818',
|
||||||
@@ -462,9 +537,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Suprematist white cube',
|
'Suprematist white cube',
|
||||||
'Matte white paint · geometric light · polished floor',
|
'Matte white paint · geometric light · polished floor',
|
||||||
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
|
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
|
||||||
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#e0e0e0', trim: '#cccccc' },
|
{ wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#e0e0e0', trim: '#cccccc' },
|
||||||
{
|
{
|
||||||
details: 'modern',
|
details: 'suprematist',
|
||||||
|
// Lit like the basilica: shared lights held back so the walls read as
|
||||||
|
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||||
|
lightScale: 0.3,
|
||||||
titleColor: '#222222',
|
titleColor: '#222222',
|
||||||
ambient: 0.72,
|
ambient: 0.72,
|
||||||
fog: '#1a1a1a',
|
fog: '#1a1a1a',
|
||||||
@@ -482,9 +560,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Constructivist space',
|
'Constructivist space',
|
||||||
'Glass block · concrete · angular daylight',
|
'Glass block · concrete · angular daylight',
|
||||||
{ wall: 'concrete-block', ceiling: 'concrete-raw', floor: 'concrete-polished' },
|
{ wall: 'concrete-block', ceiling: 'concrete-raw', floor: 'concrete-polished' },
|
||||||
{ wall: '#b0b0b0', ceiling: '#a0a0a0', floor: '#c0c0c0', trim: '#808080' },
|
{ wall: '#858585', ceiling: '#7a7a7a', floor: '#c0c0c0', trim: '#808080' },
|
||||||
{
|
{
|
||||||
details: 'industrial',
|
details: 'constructivist',
|
||||||
|
// Lit like the basilica: shared lights held back so the walls read as
|
||||||
|
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||||
|
lightScale: 0.3,
|
||||||
titleColor: '#303030',
|
titleColor: '#303030',
|
||||||
ambient: 0.65,
|
ambient: 0.65,
|
||||||
windows: windows(
|
windows: windows(
|
||||||
@@ -500,9 +581,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Dada salon',
|
'Dada salon',
|
||||||
'Eclectic bourgeois room · painted walls and exposed brick',
|
'Eclectic bourgeois room · painted walls and exposed brick',
|
||||||
{ wall: 'painted-emulsion', wallSide: 'brick', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
{ wall: 'painted-emulsion', wallSide: 'brick', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
||||||
{ wall: '#e8dcc8', wallSide: '#8a5040', ceiling: '#f0ebe0', floor: '#8a6848', trim: '#6a4830' },
|
{ wall: '#8b8478', wallSide: '#8a5040', ceiling: '#97948d', floor: '#8a6848', trim: '#6a4830' },
|
||||||
{
|
{
|
||||||
details: 'salon',
|
details: 'dada',
|
||||||
|
// Lit like the basilica: shared lights held back so the walls read as
|
||||||
|
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||||
|
lightScale: 0.3,
|
||||||
windows: windows(
|
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.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: 'back', x: 1.5, y: 2.6, width: 0.8, height: 1.8, style: 'gothic-lancet', lightColor: '#ffe8c0', lightIntensity: 2.0 },
|
||||||
@@ -518,7 +602,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||||
{ wall: '#1a2848', ceiling: '#2a2428', floor: '#5a4838', trim: '#8a7050' },
|
{ wall: '#1a2848', ceiling: '#2a2428', floor: '#5a4838', trim: '#8a7050' },
|
||||||
{
|
{
|
||||||
details: 'salon',
|
details: 'surrealist',
|
||||||
titleColor: '#e8dcc8',
|
titleColor: '#e8dcc8',
|
||||||
ambient: 0.58,
|
ambient: 0.58,
|
||||||
warmLight: '#ffd898',
|
warmLight: '#ffd898',
|
||||||
@@ -535,9 +619,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'NYC loft',
|
'NYC loft',
|
||||||
'Raw brick and painted plaster · north-light factory windows',
|
'Raw brick and painted plaster · north-light factory windows',
|
||||||
{ wall: 'brick', wallSide: 'painted-flat', ceiling: 'concrete-raw', floor: 'industrial-floor' },
|
{ wall: 'brick', wallSide: 'painted-flat', ceiling: 'concrete-raw', floor: 'industrial-floor' },
|
||||||
{ wall: '#8a5040', wallSide: '#f5f5f5', ceiling: '#989898', floor: '#888880', trim: '#606060' },
|
{ wall: '#8a5040', wallSide: '#858585', ceiling: '#989898', floor: '#888880', trim: '#606060' },
|
||||||
{
|
{
|
||||||
details: 'industrial',
|
details: 'loft',
|
||||||
|
// Lit like the basilica: shared lights held back so the walls read as
|
||||||
|
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||||
|
lightScale: 0.3,
|
||||||
titleColor: '#303030',
|
titleColor: '#303030',
|
||||||
ambient: 0.65,
|
ambient: 0.65,
|
||||||
windows: windows(
|
windows: windows(
|
||||||
@@ -551,9 +638,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
'Pop Art gallery',
|
'Pop Art gallery',
|
||||||
'White painted walls · fluorescent daylight · polished concrete',
|
'White painted walls · fluorescent daylight · polished concrete',
|
||||||
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
|
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
|
||||||
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#d8d8d8', trim: '#cccccc' },
|
{ wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#d8d8d8', trim: '#cccccc' },
|
||||||
{
|
{
|
||||||
details: 'modern',
|
details: 'white-cube',
|
||||||
|
// Lit like the basilica: shared lights held back so the walls read as
|
||||||
|
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||||
|
lightScale: 0.3,
|
||||||
titleColor: '#222222',
|
titleColor: '#222222',
|
||||||
ambient: 0.75,
|
ambient: 0.75,
|
||||||
warmLight: '#ffffff',
|
warmLight: '#ffffff',
|
||||||
|
|||||||
@@ -21,6 +21,12 @@
|
|||||||
"audit": "Activity",
|
"audit": "Activity",
|
||||||
"layoutHorizontal": "Classic timeline →",
|
"layoutHorizontal": "Classic timeline →",
|
||||||
"layoutVertical": "↑ Vertical timeline",
|
"layoutVertical": "↑ Vertical timeline",
|
||||||
|
"layoutTree": "🌳 Tree of art",
|
||||||
|
"captionClassicTimeline": "Click an era or event to zoom · Scroll to zoom · Drag to pan",
|
||||||
|
"captionClassicFlow": "Scroll to zoom · drag to pan · each movement stream is a solid colour band through history",
|
||||||
|
"captionVerticalTimeline": "Bottom → top · Scroll to zoom · Drag to pan",
|
||||||
|
"captionVerticalFlow": "Bottom → top through history · scroll to zoom · drag to pan · click a stream",
|
||||||
|
"captionTreeFlow": "Roots at the bottom, living movements at the crown · scroll to zoom · drag to pan · click a branch to enter its gallery",
|
||||||
"openingTourGallery": "Opening guided tour…",
|
"openingTourGallery": "Opening guided tour…",
|
||||||
"tourEmpty": "This tour has no paintings yet.",
|
"tourEmpty": "This tour has no paintings yet.",
|
||||||
"tourLoadFailed": "Failed to load the tour.",
|
"tourLoadFailed": "Failed to load the tour.",
|
||||||
|
|||||||
@@ -21,6 +21,12 @@
|
|||||||
"audit": "Активность",
|
"audit": "Активность",
|
||||||
"layoutHorizontal": "Классическая шкала →",
|
"layoutHorizontal": "Классическая шкала →",
|
||||||
"layoutVertical": "↑ Вертикальная шкала",
|
"layoutVertical": "↑ Вертикальная шкала",
|
||||||
|
"layoutTree": "🌳 Древо искусства",
|
||||||
|
"captionClassicTimeline": "Щёлкните эпоху или событие для приближения · Колесо — масштаб · Перетащите для панорамы",
|
||||||
|
"captionClassicFlow": "Колесо — масштаб · перетащите для панорамы · каждое направление — цветная полоса сквозь историю",
|
||||||
|
"captionVerticalTimeline": "Снизу вверх · Колесо — масштаб · Перетащите для панорамы",
|
||||||
|
"captionVerticalFlow": "Снизу вверх по истории · колесо — масштаб · перетащите для панорамы · щёлкните поток",
|
||||||
|
"captionTreeFlow": "Корни внизу, живые направления в кроне · колесо — масштаб · перетащите для панорамы · щёлкните ветвь, чтобы войти в зал",
|
||||||
"openingTourGallery": "Открытие экскурсии…",
|
"openingTourGallery": "Открытие экскурсии…",
|
||||||
"tourEmpty": "В этой экскурсии пока нет картин.",
|
"tourEmpty": "В этой экскурсии пока нет картин.",
|
||||||
"tourLoadFailed": "Не удалось загрузить экскурсию.",
|
"tourLoadFailed": "Не удалось загрузить экскурсию.",
|
||||||
|
|||||||
@@ -44,6 +44,9 @@
|
|||||||
top: 16px;
|
top: 16px;
|
||||||
left: 16px;
|
left: 16px;
|
||||||
z-index: 120;
|
z-index: 120;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-layout-link {
|
.site-layout-link {
|
||||||
@@ -63,6 +66,17 @@
|
|||||||
color: #f5e6c8;
|
color: #f5e6c8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Link to the alternative tree start page — the one worth noticing. */
|
||||||
|
.site-layout-link-feature {
|
||||||
|
border-color: rgba(201, 169, 110, 0.7);
|
||||||
|
background: rgba(201, 169, 110, 0.16);
|
||||||
|
color: #f5e6c8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-layout-link-feature:hover {
|
||||||
|
background: rgba(201, 169, 110, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
.gallery-session-suspended {
|
.gallery-session-suspended {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import TimelineEventGuides from '../components/TimelineEventGuides';
|
|||||||
import MovementBands from '../components/MovementBands';
|
import MovementBands from '../components/MovementBands';
|
||||||
import VerticalTimeline from '../components/VerticalTimeline';
|
import VerticalTimeline from '../components/VerticalTimeline';
|
||||||
import VerticalMovementBands from '../components/VerticalMovementBands';
|
import VerticalMovementBands from '../components/VerticalMovementBands';
|
||||||
|
import MovementTree from '../components/MovementTree';
|
||||||
import VirtualGallery from '../components/VirtualGallery';
|
import VirtualGallery from '../components/VirtualGallery';
|
||||||
import PaintingDetailView from '../components/PaintingDetail';
|
import PaintingDetailView from '../components/PaintingDetail';
|
||||||
import ArtistBio from '../components/ArtistBio';
|
import ArtistBio from '../components/ArtistBio';
|
||||||
@@ -50,6 +51,7 @@ import './HomePage.css';
|
|||||||
type View =
|
type View =
|
||||||
| { type: 'timeline' }
|
| { type: 'timeline' }
|
||||||
| { type: 'timeline-vertical' }
|
| { type: 'timeline-vertical' }
|
||||||
|
| { type: 'timeline-tree' }
|
||||||
| { type: 'checkup' }
|
| { type: 'checkup' }
|
||||||
| { type: 'translations' }
|
| { type: 'translations' }
|
||||||
| { type: 'influences' }
|
| { type: 'influences' }
|
||||||
@@ -157,6 +159,37 @@ function catalogNavigateTarget(
|
|||||||
return idx < remaining.length ? remaining[idx].id : remaining[remaining.length - 1].id;
|
return idx < remaining.length ? remaining[idx].id : remaining[remaining.length - 1].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shareable timeline layout via `?layout=classic|vertical|tree` (classic may omit the param). */
|
||||||
|
type TimelineLayoutId = 'classic' | 'vertical' | 'tree';
|
||||||
|
|
||||||
|
function parseTimelineLayoutParam(raw: string | null): TimelineLayoutId {
|
||||||
|
if (raw === 'vertical' || raw === 'tree') return raw;
|
||||||
|
if (raw === 'classic' || raw === 'horizontal') return 'classic';
|
||||||
|
return 'classic';
|
||||||
|
}
|
||||||
|
|
||||||
|
function timelineViewFromLayout(layout: TimelineLayoutId): View {
|
||||||
|
if (layout === 'vertical') return { type: 'timeline-vertical' };
|
||||||
|
if (layout === 'tree') return { type: 'timeline-tree' };
|
||||||
|
return { type: 'timeline' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTimelineLayoutParam(layout: TimelineLayoutId) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (layout === 'classic') url.searchParams.delete('layout');
|
||||||
|
else url.searchParams.set('layout', layout);
|
||||||
|
const next = `${url.pathname}${url.search}${url.hash}`;
|
||||||
|
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||||
|
if (next !== current) window.history.replaceState(null, '', next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readInitialTimelineView(): View {
|
||||||
|
if (typeof window === 'undefined') return { type: 'timeline' };
|
||||||
|
return timelineViewFromLayout(
|
||||||
|
parseTimelineLayoutParam(new URLSearchParams(window.location.search).get('layout'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { t } = useTranslation('home');
|
const { t } = useTranslation('home');
|
||||||
const { isCurator, isAdmin, username, login, logout, can } = useAuth();
|
const { isCurator, isAdmin, username, login, logout, can } = useAuth();
|
||||||
@@ -167,7 +200,7 @@ export default function HomePage() {
|
|||||||
const canInfluences = can('influences');
|
const canInfluences = can('influences');
|
||||||
const canTours = can('tours');
|
const canTours = can('tours');
|
||||||
const canUsers = can('users');
|
const canUsers = can('users');
|
||||||
const [view, setView] = useState<View>({ type: 'timeline' });
|
const [view, setView] = useState<View>(readInitialTimelineView);
|
||||||
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
|
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
|
||||||
const [bounds, setBounds] = useState({ min: -800, max: 2025 });
|
const [bounds, setBounds] = useState({ min: -800, max: 2025 });
|
||||||
const [viewStart, setViewStart] = useState(-800);
|
const [viewStart, setViewStart] = useState(-800);
|
||||||
@@ -210,7 +243,11 @@ export default function HomePage() {
|
|||||||
setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data });
|
setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data });
|
||||||
} else if (view.type === 'tour-gallery') {
|
} else if (view.type === 'tour-gallery') {
|
||||||
setGallerySession({ kind: 'tour', tourId: view.tourId, data: view.data });
|
setGallerySession({ kind: 'tour', tourId: view.tourId, data: view.data });
|
||||||
} else if (view.type === 'timeline' || view.type === 'timeline-vertical') {
|
} else if (
|
||||||
|
view.type === 'timeline' ||
|
||||||
|
view.type === 'timeline-vertical' ||
|
||||||
|
view.type === 'timeline-tree'
|
||||||
|
) {
|
||||||
setGallerySession(null);
|
setGallerySession(null);
|
||||||
}
|
}
|
||||||
}, [view]);
|
}, [view]);
|
||||||
@@ -271,19 +308,33 @@ export default function HomePage() {
|
|||||||
setViewStart(bounds.min);
|
setViewStart(bounds.min);
|
||||||
setViewEnd(bounds.max);
|
setViewEnd(bounds.max);
|
||||||
setGalleryRevision((revision) => revision + 1);
|
setGalleryRevision((revision) => revision + 1);
|
||||||
|
writeTimelineLayoutParam('classic');
|
||||||
setView({ type: 'timeline' });
|
setView({ type: 'timeline' });
|
||||||
}, [bounds.min, bounds.max]);
|
}, [bounds.min, bounds.max]);
|
||||||
|
|
||||||
const openHorizontalTimeline = () => {
|
const openHorizontalTimeline = () => {
|
||||||
|
writeTimelineLayoutParam('classic');
|
||||||
setView({ type: 'timeline' });
|
setView({ type: 'timeline' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const openVerticalTimeline = () => {
|
const openVerticalTimeline = () => {
|
||||||
|
writeTimelineLayoutParam('vertical');
|
||||||
setView({ type: 'timeline-vertical' });
|
setView({ type: 'timeline-vertical' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const isTimelineHome = view.type === 'timeline' || view.type === 'timeline-vertical';
|
const openTreeTimeline = () => {
|
||||||
|
writeTimelineLayoutParam('tree');
|
||||||
|
setView({ type: 'timeline-tree' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTimelineHome =
|
||||||
|
view.type === 'timeline' ||
|
||||||
|
view.type === 'timeline-vertical' ||
|
||||||
|
view.type === 'timeline-tree';
|
||||||
const isVerticalTimeline = view.type === 'timeline-vertical';
|
const isVerticalTimeline = view.type === 'timeline-vertical';
|
||||||
|
const isTreeTimeline = view.type === 'timeline-tree';
|
||||||
|
/** Both alternative layouts run the year axis bottom → top beside the chart. */
|
||||||
|
const isVerticalLayout = isVerticalTimeline || isTreeTimeline;
|
||||||
|
|
||||||
const toggleDebugMode = () => {
|
const toggleDebugMode = () => {
|
||||||
setDebugMode((prev) => {
|
setDebugMode((prev) => {
|
||||||
@@ -1222,16 +1273,17 @@ export default function HomePage() {
|
|||||||
<div className="home-page">
|
<div className="home-page">
|
||||||
<header className="site-header">
|
<header className="site-header">
|
||||||
<div className="site-layout-switch">
|
<div className="site-layout-switch">
|
||||||
{isVerticalTimeline ? (
|
{!isTreeTimeline && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="site-layout-link"
|
className="site-layout-link site-layout-link-feature"
|
||||||
onClick={openHorizontalTimeline}
|
onClick={openTreeTimeline}
|
||||||
title="Switch to classic left-to-right timeline"
|
title="Open the alternative start page: bottom-up timeline with movements drawn as a growing tree"
|
||||||
>
|
>
|
||||||
{t('layoutHorizontal')}
|
{t('layoutTree')}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
)}
|
||||||
|
{!isVerticalTimeline && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="site-layout-link"
|
className="site-layout-link"
|
||||||
@@ -1241,6 +1293,16 @@ export default function HomePage() {
|
|||||||
{t('layoutVertical')}
|
{t('layoutVertical')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!(view.type === 'timeline') && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="site-layout-link"
|
||||||
|
onClick={openHorizontalTimeline}
|
||||||
|
title="Switch to classic left-to-right timeline"
|
||||||
|
>
|
||||||
|
{t('layoutHorizontal')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="site-dev-tools">
|
<div className="site-dev-tools">
|
||||||
{isCurator ? (
|
{isCurator ? (
|
||||||
@@ -1369,7 +1431,7 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className={`home-timeline-stack${isVerticalTimeline ? ' home-timeline-stack-vertical' : ''}`}>
|
<div className={`home-timeline-stack${isVerticalLayout ? ' home-timeline-stack-vertical' : ''}`}>
|
||||||
{loading && (
|
{loading && (
|
||||||
<GalleryLoadingMarker overlay message="Loading art history…" />
|
<GalleryLoadingMarker overlay message="Loading art history…" />
|
||||||
)}
|
)}
|
||||||
@@ -1380,7 +1442,7 @@ export default function HomePage() {
|
|||||||
<GalleryLoadingMarker banner message="Loading portraits…" />
|
<GalleryLoadingMarker banner message="Loading portraits…" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isVerticalTimeline ? (
|
{isVerticalLayout ? (
|
||||||
<>
|
<>
|
||||||
<VerticalTimeline
|
<VerticalTimeline
|
||||||
eras={timelineData.eras}
|
eras={timelineData.eras}
|
||||||
@@ -1394,6 +1456,17 @@ export default function HomePage() {
|
|||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
{!loading && (
|
{!loading && (
|
||||||
<div className="home-movements-section-vertical">
|
<div className="home-movements-section-vertical">
|
||||||
|
{isTreeTimeline ? (
|
||||||
|
<MovementTree
|
||||||
|
movements={timelineData.movements}
|
||||||
|
viewStart={viewStart}
|
||||||
|
viewEnd={viewEnd}
|
||||||
|
absoluteMin={bounds.min}
|
||||||
|
absoluteMax={bounds.max}
|
||||||
|
onViewChange={handleViewChange}
|
||||||
|
onMovementClick={handleMovementClick}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<VerticalMovementBands
|
<VerticalMovementBands
|
||||||
movements={timelineData.movements}
|
movements={timelineData.movements}
|
||||||
viewStart={viewStart}
|
viewStart={viewStart}
|
||||||
@@ -1403,6 +1476,7 @@ export default function HomePage() {
|
|||||||
onViewChange={handleViewChange}
|
onViewChange={handleViewChange}
|
||||||
onMovementClick={handleMovementClick}
|
onMovementClick={handleMovementClick}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/** Shared colour helpers for movement streams (timeline flow, vertical flow, tree). */
|
||||||
|
|
||||||
|
/** True when `hex` is a usable #rgb / #rrggbb / #rrggbbaa-style value. */
|
||||||
|
export function isMovementHexColor(hex: string): boolean {
|
||||||
|
const normalized = hex.replace('#', '').trim();
|
||||||
|
return /^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6,}$/.test(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a catalogue colour to RGB.
|
||||||
|
* 3-digit shorthand expands; 6+ digits keep the first six (so `#rrggbbaa` → `#rrggbb`).
|
||||||
|
* Throws on malformed input so callers can fall back.
|
||||||
|
*/
|
||||||
|
export function parseHexColor(hex: string): [number, number, number] {
|
||||||
|
const normalized = hex.replace('#', '').trim();
|
||||||
|
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6,}$/.test(normalized)) {
|
||||||
|
throw new Error(`invalid hex colour: ${hex}`);
|
||||||
|
}
|
||||||
|
const value =
|
||||||
|
normalized.length === 3
|
||||||
|
? normalized
|
||||||
|
.split('')
|
||||||
|
.map((c) => c + c)
|
||||||
|
.join('')
|
||||||
|
: normalized.slice(0, 6);
|
||||||
|
const n = parseInt(value, 16);
|
||||||
|
if (!Number.isFinite(n)) throw new Error(`invalid hex colour: ${hex}`);
|
||||||
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||||||
|
r /= 255;
|
||||||
|
g /= 255;
|
||||||
|
b /= 255;
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const l = (max + min) / 2;
|
||||||
|
if (max === min) return [0, 0, l];
|
||||||
|
const d = max - min;
|
||||||
|
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
let h = 0;
|
||||||
|
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||||
|
else if (max === g) h = ((b - r) / d + 2) / 6;
|
||||||
|
else h = ((r - g) / d + 4) / 6;
|
||||||
|
return [h * 360, s, l];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hslToHex(h: number, s: number, l: number): string {
|
||||||
|
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||||
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||||
|
const m = l - c / 2;
|
||||||
|
let r = 0;
|
||||||
|
let g = 0;
|
||||||
|
let b = 0;
|
||||||
|
if (h < 60) [r, g, b] = [c, x, 0];
|
||||||
|
else if (h < 120) [r, g, b] = [x, c, 0];
|
||||||
|
else if (h < 180) [r, g, b] = [0, c, x];
|
||||||
|
else if (h < 240) [r, g, b] = [0, x, c];
|
||||||
|
else if (h < 300) [r, g, b] = [x, 0, c];
|
||||||
|
else [r, g, b] = [c, 0, x];
|
||||||
|
const toByte = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
||||||
|
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lift a muted catalogue colour into a saturated stream colour. */
|
||||||
|
export function vividMovementColor(hex: string): string {
|
||||||
|
try {
|
||||||
|
if (!isMovementHexColor(hex)) return hex;
|
||||||
|
const [r, g, b] = parseHexColor(hex);
|
||||||
|
const [h, s, l] = rgbToHsl(r, g, b);
|
||||||
|
const s2 = s < 0.1 ? Math.min(0.55, s + 0.42) : Math.min(1, s * 1.65 + 0.08);
|
||||||
|
const l2 =
|
||||||
|
l < 0.22 ? 0.5 : l > 0.78 ? 0.62 : Math.min(0.68, Math.max(0.4, l * 0.75 + 0.28));
|
||||||
|
return hslToHex(h, s2, l2);
|
||||||
|
} catch {
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Darker variant of a stream colour — used for the shaded side of a tree limb. */
|
||||||
|
export function shadeMovementColor(hex: string, amount = 0.34): string {
|
||||||
|
try {
|
||||||
|
if (!isMovementHexColor(hex)) return hex;
|
||||||
|
const [r, g, b] = parseHexColor(hex);
|
||||||
|
const [h, s, l] = rgbToHsl(r, g, b);
|
||||||
|
return hslToHex(h, Math.min(1, s * 1.05), Math.max(0.08, l * (1 - amount)));
|
||||||
|
} catch {
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
/**
|
||||||
|
* Tree layout rules for the "Tree of Art" start page.
|
||||||
|
*
|
||||||
|
* The classic flow chart packs movements into lanes and lets the lanes drift as
|
||||||
|
* you pan. A tree needs the opposite: a shape you can recognise again after a
|
||||||
|
* zoom. So the horizontal geometry here is computed **once from the whole
|
||||||
|
* catalogue** and never depends on the visible year window — only the vertical
|
||||||
|
* (time) axis reacts to pan/zoom.
|
||||||
|
*
|
||||||
|
* Rules
|
||||||
|
* -----
|
||||||
|
* 1. **Time grows upward.** The oldest movements sit at the bottom, the newest
|
||||||
|
* at the top. Y is purely `year → pixel`; this file never computes it.
|
||||||
|
* 2. **One trunk, at the centre.** `MOVEMENT_LINEAGE` is a DAG, so it is first
|
||||||
|
* reduced to a spanning tree: each movement keeps its *most immediate
|
||||||
|
* predecessor* (the parent with the latest start year that still precedes
|
||||||
|
* it) as its structural parent. Remaining parents survive as **grafts** —
|
||||||
|
* thin secondary limbs the renderer draws behind the tree.
|
||||||
|
* 3. **Children split the parent's slot.** Every node reserves a horizontal
|
||||||
|
* slot as wide as its whole subtree (`max(own limb, Σ children)`), and its
|
||||||
|
* children are packed side by side and centred on the parent. A single-child
|
||||||
|
* chain therefore inherits the parent's x exactly — the trunk stays straight
|
||||||
|
* until it actually forks, and every fork spreads symmetrically, so later
|
||||||
|
* generations end up further from the centre.
|
||||||
|
* 4. **Leonardo's rule for thickness.** A limb is as thick as the limbs it
|
||||||
|
* carries: `base² = own² + Σ child.base²`. The trunk at the bottom is the
|
||||||
|
* thickest thing on screen and every branch tapers as it rises and sheds
|
||||||
|
* children. A movement's *own* thickness comes from its influence-link count.
|
||||||
|
* 5. **Branches lean outward.** A limb drifts sideways across its own lifespan,
|
||||||
|
* away from its parent, by at most the slack left inside its slot — so limbs
|
||||||
|
* look grown rather than extruded, and can never collide with a sibling.
|
||||||
|
* 6. **Unlinked movements are saplings.** A movement with no lineage edge is its
|
||||||
|
* own root; extra roots are planted alternately right and left of the trunk,
|
||||||
|
* widest subtree first, so the main trunk keeps x = 0 (canvas centre).
|
||||||
|
*/
|
||||||
|
import type { ArtMovement } from '../types';
|
||||||
|
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
||||||
|
|
||||||
|
/** All values are tree-space pixels; the renderer scales them to the canvas. */
|
||||||
|
export const TREE_LAYOUT = {
|
||||||
|
/** Horizontal room a childless limb claims. */
|
||||||
|
LEAF_SLOT_PX: 104,
|
||||||
|
/** Clear space kept around a limb inside its own slot. */
|
||||||
|
LIMB_GAP_PX: 34,
|
||||||
|
/** Thinnest a limb may be drawn. */
|
||||||
|
MIN_LIMB_PX: 13,
|
||||||
|
/** Thickest a limb can get from its own influence count alone. */
|
||||||
|
MAX_OWN_LIMB_PX: 36,
|
||||||
|
/** Ceiling for the accumulated (Leonardo) thickness of the trunk. */
|
||||||
|
MAX_TRUNK_PX: 96,
|
||||||
|
/** How much of the slack inside a slot a limb may lean into. */
|
||||||
|
LEAN_SLACK: 0.55,
|
||||||
|
MAX_LEAN_PX: 28,
|
||||||
|
/** A limb ends its life this much thinner than it started it. */
|
||||||
|
TIP_TAPER: 0.66,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface MovementTreeNode {
|
||||||
|
movement: ArtMovement;
|
||||||
|
/** Structural parent in the spanning tree (`null` for roots). */
|
||||||
|
parentId: number | null;
|
||||||
|
/** Documented predecessors that lost to the structural parent. */
|
||||||
|
graftParentIds: number[];
|
||||||
|
childIds: number[];
|
||||||
|
depth: number;
|
||||||
|
descendants: number;
|
||||||
|
/** Thickness the movement earns on its own (influence links). */
|
||||||
|
ownWidth: number;
|
||||||
|
/** Thickness where the limb leaves its parent — carries every descendant. */
|
||||||
|
baseWidth: number;
|
||||||
|
/** Thickness where the limb ends. */
|
||||||
|
tipWidth: number;
|
||||||
|
/** Horizontal slot reserved for this node and everything under it. */
|
||||||
|
subtreeWidth: number;
|
||||||
|
/** Tree-space x of the limb base. The main trunk sits at 0. */
|
||||||
|
x: number;
|
||||||
|
/** Lateral drift from base to tip, px (signed). */
|
||||||
|
lean: number;
|
||||||
|
side: -1 | 0 | 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MovementTree {
|
||||||
|
nodes: Map<number, MovementTreeNode>;
|
||||||
|
rootIds: number[];
|
||||||
|
/** Ids ordered thickest-first, so thin branches paint over the trunk. */
|
||||||
|
drawOrder: number[];
|
||||||
|
/** Half the horizontal extent actually occupied, px (>= 1). */
|
||||||
|
halfSpan: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable per-id value in [-1, 1] — organic drift without randomness. */
|
||||||
|
function idDrift(id: number): number {
|
||||||
|
const n = Math.sin(id * 12.9898) * 43758.5453;
|
||||||
|
return (n - Math.floor(n)) * 2 - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function influenceCount(m: ArtMovement): number {
|
||||||
|
const n = m.influence_link_count;
|
||||||
|
return typeof n === 'number' && Number.isFinite(n) ? Math.max(0, n) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** child id → documented parent ids, restricted to movements in the catalogue. */
|
||||||
|
function buildParentMap(movements: ArtMovement[]): Map<number, number[]> {
|
||||||
|
const nameToId = new Map(movements.map((m) => [m.name, m.id]));
|
||||||
|
const parents = new Map<number, number[]>();
|
||||||
|
for (const [parentName, childName] of MOVEMENT_LINEAGE) {
|
||||||
|
const parentId = nameToId.get(parentName);
|
||||||
|
const childId = nameToId.get(childName);
|
||||||
|
if (parentId == null || childId == null || parentId === childId) continue;
|
||||||
|
const list = parents.get(childId) ?? [];
|
||||||
|
if (!list.includes(parentId)) list.push(parentId);
|
||||||
|
parents.set(childId, list);
|
||||||
|
}
|
||||||
|
return parents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduce the lineage DAG to a spanning tree. Ranking every movement by start
|
||||||
|
* year first means a parent is always strictly earlier in the ranking than its
|
||||||
|
* child, so the result cannot contain a cycle.
|
||||||
|
*/
|
||||||
|
function chooseStructuralParents(
|
||||||
|
movements: ArtMovement[],
|
||||||
|
parentMap: Map<number, number[]>
|
||||||
|
): Map<number, { parentId: number | null; grafts: number[] }> {
|
||||||
|
const ranked = [...movements].sort(
|
||||||
|
(a, b) => a.start_year - b.start_year || a.id - b.id
|
||||||
|
);
|
||||||
|
const rank = new Map(ranked.map((m, index) => [m.id, index]));
|
||||||
|
|
||||||
|
const chosen = new Map<number, { parentId: number | null; grafts: number[] }>();
|
||||||
|
for (const m of movements) {
|
||||||
|
const candidates = (parentMap.get(m.id) ?? []).filter(
|
||||||
|
(pid) => (rank.get(pid) ?? Infinity) < (rank.get(m.id) ?? -Infinity)
|
||||||
|
);
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
chosen.set(m.id, { parentId: null, grafts: [] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Most immediate predecessor carries the branch; older ones become grafts.
|
||||||
|
const sorted = [...candidates].sort(
|
||||||
|
(a, b) => (rank.get(b) ?? 0) - (rank.get(a) ?? 0)
|
||||||
|
);
|
||||||
|
chosen.set(m.id, { parentId: sorted[0], grafts: sorted.slice(1) });
|
||||||
|
}
|
||||||
|
return chosen;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMovementTree(movements: ArtMovement[]): MovementTree {
|
||||||
|
const nodes = new Map<number, MovementTreeNode>();
|
||||||
|
if (movements.length === 0) {
|
||||||
|
return { nodes, rootIds: [], drawOrder: [], halfSpan: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentMap = buildParentMap(movements);
|
||||||
|
const structure = chooseStructuralParents(movements, parentMap);
|
||||||
|
const maxInfluence = Math.max(0, ...movements.map(influenceCount));
|
||||||
|
|
||||||
|
for (const movement of movements) {
|
||||||
|
const { parentId, grafts } = structure.get(movement.id) ?? {
|
||||||
|
parentId: null,
|
||||||
|
grafts: [],
|
||||||
|
};
|
||||||
|
const ownWidth =
|
||||||
|
maxInfluence > 0
|
||||||
|
? TREE_LAYOUT.MIN_LIMB_PX +
|
||||||
|
(influenceCount(movement) / maxInfluence) *
|
||||||
|
(TREE_LAYOUT.MAX_OWN_LIMB_PX - TREE_LAYOUT.MIN_LIMB_PX)
|
||||||
|
: (TREE_LAYOUT.MIN_LIMB_PX + TREE_LAYOUT.MAX_OWN_LIMB_PX) / 2;
|
||||||
|
|
||||||
|
nodes.set(movement.id, {
|
||||||
|
movement,
|
||||||
|
parentId,
|
||||||
|
graftParentIds: grafts,
|
||||||
|
childIds: [],
|
||||||
|
depth: 0,
|
||||||
|
descendants: 0,
|
||||||
|
ownWidth,
|
||||||
|
baseWidth: ownWidth,
|
||||||
|
tipWidth: Math.max(TREE_LAYOUT.MIN_LIMB_PX * 0.6, ownWidth * TREE_LAYOUT.TIP_TAPER),
|
||||||
|
subtreeWidth: TREE_LAYOUT.LEAF_SLOT_PX,
|
||||||
|
x: 0,
|
||||||
|
lean: 0,
|
||||||
|
side: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootIds: number[] = [];
|
||||||
|
for (const node of nodes.values()) {
|
||||||
|
if (node.parentId != null && nodes.has(node.parentId)) {
|
||||||
|
nodes.get(node.parentId)!.childIds.push(node.movement.id);
|
||||||
|
} else {
|
||||||
|
node.parentId = null;
|
||||||
|
rootIds.push(node.movement.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of nodes.values()) {
|
||||||
|
node.childIds.sort((a, b) => {
|
||||||
|
const ma = nodes.get(a)!.movement;
|
||||||
|
const mb = nodes.get(b)!.movement;
|
||||||
|
return ma.start_year - mb.start_year || ma.name.localeCompare(mb.name);
|
||||||
|
});
|
||||||
|
node.graftParentIds = node.graftParentIds.filter((id) => nodes.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-order: depth, descendant count, Leonardo thickness, slot width.
|
||||||
|
const measure = (id: number, depth: number): void => {
|
||||||
|
const node = nodes.get(id)!;
|
||||||
|
node.depth = depth;
|
||||||
|
let descendants = 0;
|
||||||
|
let childrenWidth = 0;
|
||||||
|
let carried = node.ownWidth * node.ownWidth;
|
||||||
|
for (const childId of node.childIds) {
|
||||||
|
measure(childId, depth + 1);
|
||||||
|
const child = nodes.get(childId)!;
|
||||||
|
descendants += 1 + child.descendants;
|
||||||
|
childrenWidth += child.subtreeWidth;
|
||||||
|
carried += child.baseWidth * child.baseWidth;
|
||||||
|
}
|
||||||
|
node.descendants = descendants;
|
||||||
|
node.baseWidth = Math.min(TREE_LAYOUT.MAX_TRUNK_PX, Math.sqrt(carried));
|
||||||
|
node.subtreeWidth = Math.max(
|
||||||
|
node.baseWidth + TREE_LAYOUT.LIMB_GAP_PX,
|
||||||
|
node.childIds.length === 0 ? TREE_LAYOUT.LEAF_SLOT_PX : childrenWidth
|
||||||
|
);
|
||||||
|
};
|
||||||
|
for (const id of rootIds) measure(id, 0);
|
||||||
|
|
||||||
|
// Widest tree takes the centre; the rest are planted alternately right / left.
|
||||||
|
rootIds.sort((a, b) => {
|
||||||
|
const na = nodes.get(a)!;
|
||||||
|
const nb = nodes.get(b)!;
|
||||||
|
return (
|
||||||
|
nb.descendants - na.descendants ||
|
||||||
|
na.movement.start_year - nb.movement.start_year ||
|
||||||
|
na.movement.name.localeCompare(nb.movement.name)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const place = (id: number, x: number): void => {
|
||||||
|
const node = nodes.get(id)!;
|
||||||
|
node.x = x;
|
||||||
|
const total = node.childIds.reduce((sum, cid) => sum + nodes.get(cid)!.subtreeWidth, 0);
|
||||||
|
let cursor = x - total / 2;
|
||||||
|
for (const childId of node.childIds) {
|
||||||
|
const child = nodes.get(childId)!;
|
||||||
|
place(childId, cursor + child.subtreeWidth / 2);
|
||||||
|
cursor += child.subtreeWidth;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (rootIds.length > 0) {
|
||||||
|
const trunk = nodes.get(rootIds[0])!;
|
||||||
|
place(rootIds[0], 0);
|
||||||
|
let rightEdge = trunk.subtreeWidth / 2;
|
||||||
|
let leftEdge = -trunk.subtreeWidth / 2;
|
||||||
|
rootIds.slice(1).forEach((id, index) => {
|
||||||
|
const node = nodes.get(id)!;
|
||||||
|
if (index % 2 === 0) {
|
||||||
|
place(id, rightEdge + node.subtreeWidth / 2);
|
||||||
|
rightEdge += node.subtreeWidth;
|
||||||
|
} else {
|
||||||
|
place(id, leftEdge - node.subtreeWidth / 2);
|
||||||
|
leftEdge -= node.subtreeWidth;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lean: outward from the parent, capped by the slack left inside the slot.
|
||||||
|
for (const node of nodes.values()) {
|
||||||
|
const parent = node.parentId != null ? nodes.get(node.parentId) : null;
|
||||||
|
const slack = Math.max(0, (node.subtreeWidth - node.baseWidth) / 2);
|
||||||
|
const side = parent ? (Math.sign(node.x - parent.x) as -1 | 0 | 1) : 0;
|
||||||
|
node.side = side;
|
||||||
|
node.lean =
|
||||||
|
side !== 0
|
||||||
|
? side * Math.min(TREE_LAYOUT.MAX_LEAN_PX, slack * TREE_LAYOUT.LEAN_SLACK)
|
||||||
|
: idDrift(node.movement.id) * Math.min(9, slack * 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
let halfSpan = 1;
|
||||||
|
for (const node of nodes.values()) {
|
||||||
|
halfSpan = Math.max(
|
||||||
|
halfSpan,
|
||||||
|
Math.abs(node.x) + Math.abs(node.lean) + node.baseWidth / 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawOrder = [...nodes.keys()].sort((a, b) => {
|
||||||
|
const na = nodes.get(a)!;
|
||||||
|
const nb = nodes.get(b)!;
|
||||||
|
return nb.baseWidth - na.baseWidth || na.depth - nb.depth;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { nodes, rootIds, drawOrder, halfSpan };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fraction of a movement's lifespan elapsed at `year`, clamped to [0, 1]. */
|
||||||
|
export function lifeProgress(node: MovementTreeNode, year: number): number {
|
||||||
|
const { start_year: start, end_year: end } = node.movement;
|
||||||
|
if (end <= start) return 0;
|
||||||
|
return Math.min(1, Math.max(0, (year - start) / (end - start)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tree-space x of a limb's centreline at `year` (accounts for the lean). */
|
||||||
|
export function limbXAtYear(node: MovementTreeNode, year: number): number {
|
||||||
|
return node.x + node.lean * lifeProgress(node, year);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Limb thickness at `year`, tapering from base to tip. */
|
||||||
|
export function limbWidthAtYear(node: MovementTreeNode, year: number): number {
|
||||||
|
const t = lifeProgress(node, year);
|
||||||
|
return node.baseWidth + (node.tipWidth - node.baseWidth) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Year at which a child limb leaves its parent. Branches split a little before
|
||||||
|
* the successor movement is dated, which is both how lineage works and what
|
||||||
|
* keeps the junction from looking like a right angle.
|
||||||
|
*/
|
||||||
|
export function branchOriginYear(parent: MovementTreeNode, child: MovementTreeNode): number {
|
||||||
|
const childStart = child.movement.start_year;
|
||||||
|
const parentStart = parent.movement.start_year;
|
||||||
|
const parentEnd = parent.movement.end_year;
|
||||||
|
const lead = Math.min(60, Math.max(6, (childStart - parentStart) * 0.22));
|
||||||
|
return Math.min(parentEnd, Math.max(parentStart, childStart - lead));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user