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**:
|
||||
|
||||
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries; classic left→right or vertical bottom→up layout (header link).
|
||||
2. **Movement flow** — art movements as SVG streams on the same year axis; documented predecessor→successor branches (classic); portrait thumbnails along each stream.
|
||||
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries; classic left→right, vertical bottom→up, or **tree** bottom→up layout (header links).
|
||||
2. **Movement flow** — art movements as SVG streams on the same year axis; documented predecessor→successor branches (classic); portrait thumbnails along each stream. The tree layout redraws the same lineage as a growing tree — see [movement-tree.md](movement-tree.md).
|
||||
3. **3D gallery** — one personal hall per artist, a **movement gallery** (click a movement name → artist filter → hall), or a **guided tour** hall (timeline → **Tours**): period-themed or tour wings of up to ~55 works, U-shaped hang (left → end wall → right).
|
||||
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **curator notes** and **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography.
|
||||
5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**).
|
||||
@@ -37,18 +37,19 @@ Gallery/
|
||||
│ │ ├── components/GalleryLoadingMarker.tsx # Loading spinner overlay/banner (catalog, portraits, halls)
|
||||
│ │ ├── components/GalleryWindows.tsx # Side-wall daylight windows (movement)
|
||||
│ │ ├── components/HallPassage.tsx # Open archway between movement wings
|
||||
│ │ ├── components/MovementHallDetails.tsx # Period architectural details
|
||||
│ │ ├── components/MovementHallDetails.tsx # Period architecture dispatcher (Gothic + Byzantine reference)
|
||||
│ │ ├── components/hall-details/ # One period interior per movement + shared primitives
|
||||
│ │ ├── data/movement-interior-styles.ts # Per-movement interior themes
|
||||
│ │ ├── utils/movementHallLayout.ts # Wing split + window gap placement
|
||||
│ │ ├── utils/galleryProceduralTextures.ts # Hi-res wall/floor textures
|
||||
│ │ ├── components/PaintingDetail.tsx # Detail view + debug panel
|
||||
│ │ ├── components/ArtistBio.tsx # Biography + portrait debug panel
|
||||
│ │ ├── components/DebugSearchResultsModal.tsx # “More” search picker (20 results)
|
||||
│ │ ├── components/Timeline.tsx # Era bar, year ticks, event markers
|
||||
│ │ ├── components/Timeline.tsx # Classic horizontal era bar
|
||||
│ │ ├── components/VerticalTimeline.tsx # Bottom-up vertical era rail
|
||||
│ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches)
|
||||
│ │ ├── components/VerticalMovementBands.tsx # Bottom-up movement streams
|
||||
│ │ ├── components/MovementTree.tsx # Bottom-up movement tree (alternative start page)
|
||||
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow
|
||||
│ │ ├── components/CatalogSearchBar.tsx # Timeline header catalog search
|
||||
│ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail
|
||||
@@ -60,12 +61,14 @@ Gallery/
|
||||
│ │ ├── pages/AuditPage.tsx # Admin curator activity reports
|
||||
│ │ ├── components/ToursPopup.tsx # Public published-tours modal
|
||||
│ │ ├── i18n/ # react-i18next bootstrap
|
||||
│ │ └── locales/{en,ru}/ # UI chrome strings
|
||||
│ │ ├── locales/{en,ru}/ # UI chrome strings (incl. timeline layout + captions)
|
||||
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
|
||||
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
|
||||
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor
|
||||
│ │ ├── utils/debugMode.ts # Debug mode + “Show more” localStorage prefs
|
||||
│ │ └── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
|
||||
│ │ ├── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
|
||||
│ │ ├── utils/movementColor.ts # Shared vivid/shade hex helpers for all movement charts
|
||||
│ │ └── utils/movementTree.ts # View-independent Tree of Art layout engine
|
||||
│ └── dist/ # Production build (served by API when present)
|
||||
├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools
|
||||
│ ├── seed-wikipedia.js
|
||||
@@ -173,20 +176,25 @@ Implementation: `goToTimelineHome()` in `HomePage.tsx` — do not use the browse
|
||||
|
||||
## Timeline and movement flow
|
||||
|
||||
The home page shows two linked views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`). 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 |
|
||||
|--------|----------|---------------|----------------|
|
||||
| **Classic** (default) | `Timeline.tsx` (top bar) | `MovementBands.tsx` | Left → right |
|
||||
| **Vertical** | `VerticalTimeline.tsx` (left rail) | `VerticalMovementBands.tsx` (streams + lineage branches; no portraits) | Bottom → top |
|
||||
| Layout | Era rail | Movement flow | Time direction | URL |
|
||||
|--------|----------|---------------|----------------|-----|
|
||||
| **Classic** (default) | `Timeline.tsx` (top bar) | `MovementBands.tsx` | Left → right | omit or `?layout=classic` |
|
||||
| **Vertical** | `VerticalTimeline.tsx` (left rail) | `VerticalMovementBands.tsx` (streams + lineage; no portraits) | Bottom → top | `?layout=vertical` |
|
||||
| **Tree of art** | `VerticalTimeline.tsx` (left rail) | `MovementTree.tsx` (lineage as a growing tree; no portraits) | Bottom → top | `?layout=tree` |
|
||||
|
||||
`HomePage` reads `?layout=` once on load and calls `history.replaceState` when the user switches. Classic clears the param so the default URL stays clean. Alias `horizontal` maps to classic.
|
||||
|
||||
| View | Component | Purpose |
|
||||
|------|-----------|---------|
|
||||
| Era bar / 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)
|
||||
|
||||
`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) |
|
||||
| Frame captions | **Year · artist** label below each frame |
|
||||
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, Byzantine basilica, NYC loft, white cube, etc.). Style keys use a legacy id map (`DB_ID_TO_STYLE_KEY`: gallery ids **1–26** → authored keys **27–52**, with Northern/High Renaissance swapped) so Byzantine gets the basilica, not Gothic stone |
|
||||
| Period architecture | Each style names a `details` kind (26 of them) that selects one component under `components/hall-details/` — the hall is built from the architecture of the **same period as the movement** (Roman entablature, Brunelleschi arcade, Flemish beam ceiling, Horta whiplash brackets, Rodchenko lattice, 1960s troffer grid). Gothic and Byzantine remain the reference implementations in `MovementHallDetails.tsx`; the geometry budget every module works to is documented in `hall-details/geometry.ts` |
|
||||
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco, **marble revetment** (framed book-matched panels), **Cosmatesque paving** (`marble-opus-sectile`), **coffered timber** ceilings, **blind-arcaded ashlar** (`gothic-ashlar`) and **ribbed vaulting** (`gothic-vault`) — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes |
|
||||
| Architecture in texture | Period halls that need arcades, vaults, or panelling draw that **relief into the texture** rather than adding geometry: `gothic-ashlar` paints a full storey (plinth → blind arcade → string course → triforium → cornice) into one tile whose `metersPerRepeat` equals `WALL_HEIGHT`, so it maps **once vertically** onto the wall; `gothic-vault` paints a quadripartite bay with tiercerons and a boss. Light/dark banding plus a high `normalStrengthFor()` makes it read as carved stone, keeping the mesh and light budget flat |
|
||||
| Door-flanking panels | Wall segments beside exit doors and passages take the hall's own wall texture (`GalleryWall` accepts `kind`/`tint`) instead of rendering as flat single-colour blocks next to textured walls |
|
||||
| Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue |
|
||||
| Windows | **Side walls only** — placed in gaps between frames when possible; if a wall is packed, high **clerestory** windows are still added so the hall keeps daylight (`computeSideWallWindows`). Styles are drawn in `GalleryWindows.tsx`; `gothic-lancet` builds a two-centred arch head from chords with a glazed spandrel, mullions, and transoms |
|
||||
| Lighting | Shared hall lights only (ambient / hemisphere / directional + capped ceiling track spots + one fill per window). See **light budget** below |
|
||||
| Period details | `MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** (never freestanding mid-hall or proud corner shafts that cover frames); `byzantine` adds engaged porphyry colonnettes with basket capitals, a marble revetment dado, and hanging brass polycandela; `gothic` adds bay-spaced compound piers with vault springers, transverse ribs arching across the nave, and a moulded string course — all flush to the side walls |
|
||||
| Period details | `MovementHallDetails.tsx` dispatches to one module per movement. `byzantine` adds engaged porphyry colonnettes with basket capitals, a marble revetment dado, and hanging brass polycandela; `gothic` adds bay-spaced compound piers with vault springers, transverse ribs arching across the nave, and a moulded string course. Every other movement follows the same rules in `components/hall-details/`: orders and piers stay **flush to the side walls** (≤ 0.17 m proud), freestanding masses only in the corner pockets, and no more than two added lights per hall — see `hall-details/geometry.ts` |
|
||||
| Back wall | Single wing: solid display wall. Multi-wing: **Exit double doors** → **Wing navigator** or **Exit to Timeline** |
|
||||
| Front / entrance wall | Single wing: **Exit double doors** (leave the way you entered). Multi-wing: **“Next wing →”** archway when a later wing exists |
|
||||
| Influence lamps | Same golden upside-down emissive fixtures as artist halls when `has_influence_links` is true (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).
|
||||
|
||||
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`).
|
||||
|
||||
@@ -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 |
|
||||
| [i18n-russian.md](i18n-russian.md) | Russian UI + entity_translations |
|
||||
| [data-and-images.md](data-and-images.md) | Image pipeline and seeding |
|
||||
| [ui-interaction-and-component-standards.md](ui-interaction-and-component-standards.md) | UI interaction, navigation, and component standards |
|
||||
|
||||
@@ -278,9 +278,38 @@ To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the cli
|
||||
|
||||
## Movement gallery interiors (frontend)
|
||||
|
||||
Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (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.
|
||||
|
||||
|
||||
@@ -25,6 +25,16 @@ Timeline header: **EN | RU** toggle (`LocaleSwitcher`).
|
||||
- Passes `?locale=ru` on catalog API requests
|
||||
- Refetches bootstrap catalog when locale changes
|
||||
|
||||
Timeline **layout** chrome and chart hints are also localised in `home.json`:
|
||||
|
||||
| Key | Where |
|
||||
|-----|--------|
|
||||
| `layoutHorizontal` / `layoutVertical` / `layoutTree` | Header layout switch |
|
||||
| `captionClassicTimeline` / `captionClassicFlow` | Classic era bar + movement streams |
|
||||
| `captionVerticalTimeline` / `captionVerticalFlow` | Vertical rail + streams |
|
||||
| `captionTreeFlow` | Tree of Art chart |
|
||||
|
||||
Shareable layout URLs (`?layout=tree` etc.) are language-independent; captions follow the active locale.
|
||||
---
|
||||
|
||||
## Setup (dev)
|
||||
|
||||
@@ -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
|
||||
|
||||
Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, **curator-gated** debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**; painting detail also **Remove entry**), optional **Show more** auto-opens the search picker, Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. Anonymous visitors browse freely; curators sign in via **Curator login** in the header.
|
||||
Interactive virtual art gallery: zoomable historical timeline (classic left→right, vertical, or **Tree of Art** lineage chart — shareable via `?layout=`) with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, **curator-gated** debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**; painting detail also **Remove entry**), optional **Show more** auto-opens the search picker, Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. Anonymous visitors browse freely; curators sign in via **Curator login** in the header.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| [Documentation/basics.md](Documentation/basics.md) | Architecture, layout, user flow |
|
||||
| [Documentation/ui-interaction-and-component-standards.md](Documentation/ui-interaction-and-component-standards.md) | UI interaction, navigation, and component standards |
|
||||
| [Documentation/movement-tree.md](Documentation/movement-tree.md) | **Tree of Art** start page — tree layout rules |
|
||||
| [Documentation/FAC.md](Documentation/FAC.md) | **Command cheat sheet** — start/stop, import, deploy |
|
||||
| [Documentation/environments.md](Documentation/environments.md) | Dev/prod URLs, DB split, deploy, sync |
|
||||
| [Documentation/setup.md](Documentation/setup.md) | Install, env, npm scripts |
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState, memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ArtMovement, Artist } from '../types';
|
||||
import { portraitThumbUrl } from '../api/client';
|
||||
import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc';
|
||||
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||
import { parseHexColor, rgbToHsl, hslToHex, vividMovementColor } from '../utils/movementColor';
|
||||
import './MovementBands.css';
|
||||
|
||||
interface Props {
|
||||
@@ -244,74 +246,6 @@ function buildArtistPlacements(
|
||||
return placements;
|
||||
}
|
||||
|
||||
function parseHexColor(hex: string): [number, number, number] {
|
||||
const normalized = hex.replace('#', '');
|
||||
const value =
|
||||
normalized.length === 3
|
||||
? normalized
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('')
|
||||
: normalized.padStart(6, '0').slice(0, 6);
|
||||
return [
|
||||
parseInt(value.slice(0, 2), 16),
|
||||
parseInt(value.slice(2, 4), 16),
|
||||
parseInt(value.slice(4, 6), 16),
|
||||
];
|
||||
}
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||||
const rn = r / 255;
|
||||
const gn = g / 255;
|
||||
const bn = b / 255;
|
||||
const max = Math.max(rn, gn, bn);
|
||||
const min = Math.min(rn, gn, bn);
|
||||
const l = (max + min) / 2;
|
||||
if (max === min) return [0, 0, l];
|
||||
const d = max - min;
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
let h = 0;
|
||||
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
|
||||
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
|
||||
else h = ((rn - gn) / d + 4) / 6;
|
||||
return [h * 360, s, l];
|
||||
}
|
||||
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
if (h < 60) [r, g, b] = [c, x, 0];
|
||||
else if (h < 120) [r, g, b] = [x, c, 0];
|
||||
else if (h < 180) [r, g, b] = [0, c, x];
|
||||
else if (h < 240) [r, g, b] = [0, x, c];
|
||||
else if (h < 300) [r, g, b] = [x, 0, c];
|
||||
else [r, g, b] = [c, 0, x];
|
||||
const toByte = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
||||
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
|
||||
}
|
||||
|
||||
/** Boost saturation / mid lightness so streams read vividly on the dark flow canvas. */
|
||||
function vividMovementColor(hex: string): string {
|
||||
try {
|
||||
const [r, g, b] = parseHexColor(hex);
|
||||
const [h, s, l] = rgbToHsl(r, g, b);
|
||||
const s2 = s < 0.1 ? Math.min(0.55, s + 0.42) : Math.min(1, s * 1.65 + 0.08);
|
||||
const l2 =
|
||||
l < 0.22
|
||||
? 0.5
|
||||
: l > 0.78
|
||||
? 0.62
|
||||
: Math.min(0.68, Math.max(0.4, l * 0.75 + 0.28));
|
||||
return hslToHex(h, s2, l2);
|
||||
} catch {
|
||||
return hex;
|
||||
}
|
||||
}
|
||||
|
||||
function artistLifespanColor(baseColor: string, laneIndex: number, laneCount: number): string {
|
||||
if (laneCount <= 1) return baseColor;
|
||||
try {
|
||||
@@ -1762,6 +1696,7 @@ export default function MovementBands({
|
||||
onArtistHover,
|
||||
onPortraitsLoadingChange,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('home');
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const pendingPortraitsRef = useRef(0);
|
||||
const [portraitsLoading, setPortraitsLoading] = useState(false);
|
||||
@@ -2260,7 +2195,7 @@ export default function MovementBands({
|
||||
return (
|
||||
<div className="movements-flow">
|
||||
<p className="movements-flow-caption">
|
||||
Scroll to zoom · drag to pan · each movement stream is a solid colour band through history
|
||||
{t('captionClassicFlow')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,38 @@
|
||||
import { useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import type { MovementInteriorStyle } from '../data/movement-interior-styles';
|
||||
import type { MovementInteriorStyle, GalleryWindowSpec } from '../data/movement-interior-styles';
|
||||
import type { RoomDims } from './hall-details/geometry';
|
||||
import {
|
||||
CinquecentoDetails,
|
||||
QuattrocentoDetails,
|
||||
RomanAtriumDetails,
|
||||
} from './hall-details/classical';
|
||||
import {
|
||||
BaroqueStateDetails,
|
||||
FlemishDetails,
|
||||
ManneristDetails,
|
||||
NeoclassicalDetails,
|
||||
RococoDetails,
|
||||
} from './hall-details/courtly';
|
||||
import {
|
||||
ArtNouveauDetails,
|
||||
BourgeoisSalonDetails,
|
||||
GothicRevivalDetails,
|
||||
NorthLightSalonDetails,
|
||||
ParisAtelierDetails,
|
||||
SymbolistDetails,
|
||||
} from './hall-details/nineteenth';
|
||||
import {
|
||||
ConstructivistDetails,
|
||||
CubistAtelierDetails,
|
||||
DadaDetails,
|
||||
ExpressionistDetails,
|
||||
FauvistDetails,
|
||||
FuturistDetails,
|
||||
LoftDetails,
|
||||
SuprematistDetails,
|
||||
SurrealistDetails,
|
||||
WhiteCubeDetails,
|
||||
} from './hall-details/modernism';
|
||||
|
||||
interface Props {
|
||||
style: MovementInteriorStyle;
|
||||
@@ -8,99 +40,11 @@ interface Props {
|
||||
depth: number;
|
||||
halfW: number;
|
||||
halfD: number;
|
||||
/** Runtime window placement, so upper-wall relief can step around openings. */
|
||||
windows?: GalleryWindowSpec[];
|
||||
}
|
||||
|
||||
function PalazzoDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
|
||||
const pilasterPositions = useMemo(
|
||||
() =>
|
||||
[
|
||||
[-halfW + 0.35, -halfD + 0.35],
|
||||
[halfW - 0.35, -halfD + 0.35],
|
||||
[-halfW + 0.35, halfD - 0.35],
|
||||
[halfW - 0.35, halfD - 0.35],
|
||||
] as [number, number][],
|
||||
[halfW, halfD]
|
||||
);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{pilasterPositions.map(([x, z], i) => (
|
||||
<group key={i} position={[x, 0, z]}>
|
||||
<mesh position={[0, 1.8, 0]}>
|
||||
<boxGeometry args={[0.22, 3.6, 0.22]} />
|
||||
<meshStandardMaterial color="#e8dcc8" roughness={0.85} metalness={0.05} />
|
||||
</mesh>
|
||||
<mesh position={[0, 3.65, 0]}>
|
||||
<boxGeometry args={[0.28, 0.14, 0.28]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.35} metalness={0.55} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Wainscoting rail */}
|
||||
{[
|
||||
[0, -halfD + 0.09, width, 0.12] as const,
|
||||
[0, halfD - 0.09, width, 0.12] as const,
|
||||
[-halfW + 0.09, 0, 0.12, depth] as const,
|
||||
[halfW - 0.09, 0, 0.12, depth] as const,
|
||||
].map(([x, z, w, d], i) => (
|
||||
<mesh key={i} position={[x, 1.05, z]}>
|
||||
<boxGeometry args={[w, 0.08, d]} />
|
||||
<meshStandardMaterial color="#ddd0b8" roughness={0.78} metalness={0.08} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Coffered ceiling */}
|
||||
{Array.from({ length: Math.min(6, Math.floor(width / 2.5)) }, (_, col) =>
|
||||
Array.from({ length: Math.min(5, Math.floor(depth / 2.8)) }, (_, row) => {
|
||||
const cx = -halfW + 1.4 + col * 2.4;
|
||||
const cz = -halfD + 1.5 + row * 2.6;
|
||||
return (
|
||||
<mesh key={`${col}-${row}`} position={[cx, 4.12, cz]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<boxGeometry args={[2.0, 2.2, 0.06]} />
|
||||
<meshStandardMaterial color="#f5efe4" roughness={0.88} />
|
||||
</mesh>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function BaroqueDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
|
||||
return (
|
||||
<group>
|
||||
{/* Gilded cornice ring */}
|
||||
{[
|
||||
[0, -halfD + 0.06, width, 0.1] as const,
|
||||
[0, halfD - 0.06, width, 0.1] as const,
|
||||
[-halfW + 0.06, 0, 0.1, depth] as const,
|
||||
[halfW - 0.06, 0, 0.1, depth] as const,
|
||||
].map(([x, z, w, d], i) => (
|
||||
<mesh key={i} position={[x, 3.95, z]}>
|
||||
<boxGeometry args={[w, 0.18, d]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.25} metalness={0.75} emissive="#3a2808" emissiveIntensity={0.08} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Wall panels */}
|
||||
{[-halfW + 0.12, halfW - 0.12].map((x, i) => (
|
||||
<mesh key={i} position={[x, 2.1, 0]} rotation={[0, Math.PI / 2, 0]}>
|
||||
<boxGeometry args={[depth * 0.85, 2.8, 0.04]} />
|
||||
<meshStandardMaterial color="#3a1820" roughness={0.88} metalness={0.06} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
<pointLight position={[0, 3.6, 0]} intensity={0.9} distance={Math.max(width, depth)} color="#ffd080" />
|
||||
<mesh position={[0, 3.5, 0]}>
|
||||
<torusGeometry args={[0.55, 0.04, 8, 24]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.2} metalness={0.85} emissive="#5a4010" emissiveIntensity={0.15} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function GothicDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
|
||||
function GothicDetails({ width, depth, halfW, halfD, trim }: Omit<Props, 'style' | 'windows'> & { trim: string }) {
|
||||
// Compound piers along the side walls, spaced by bay, each rising into the
|
||||
// vault springer. Shafts stay flush against the wall (0.16 m proud) so they
|
||||
// never intrude on the ~0.7 m frame hang margin.
|
||||
@@ -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
|
||||
// room) overlap the outermost frames — hang margin is only ~0.7m from each end.
|
||||
const pilasters = useMemo(
|
||||
() =>
|
||||
[
|
||||
[-halfW + 0.06, -halfD + 0.06],
|
||||
[halfW - 0.06, -halfD + 0.06],
|
||||
[-halfW + 0.06, halfD - 0.06],
|
||||
[halfW - 0.06, halfD - 0.06],
|
||||
] as [number, number][],
|
||||
[halfW, halfD]
|
||||
);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{pilasters.map(([x, z], i) => (
|
||||
<group key={i} position={[x, 0, z]}>
|
||||
<mesh position={[0, 1.85, 0]}>
|
||||
<boxGeometry args={[0.12, 3.7, 0.12]} />
|
||||
<meshStandardMaterial color="#f0ece8" roughness={0.72} metalness={0.12} />
|
||||
</mesh>
|
||||
<mesh position={[0, 3.78, 0]}>
|
||||
<boxGeometry args={[0.18, 0.1, 0.18]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.4} metalness={0.35} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
{/* Cornice above wall title (title sits ~y 3.75) */}
|
||||
<mesh position={[0, 4.08, -halfD + 0.1]}>
|
||||
<boxGeometry args={[Math.max(2.4, halfW * 2 - 0.5), 0.1, 0.16]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.45} metalness={0.3} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
|
||||
// Engaged corner shafts only — anything proud of the corner pocket covers frames.
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
[
|
||||
[-halfW + 0.06, -halfD + 0.06],
|
||||
[halfW - 0.06, -halfD + 0.06],
|
||||
[-halfW + 0.06, halfD - 0.06],
|
||||
[halfW - 0.06, halfD - 0.06],
|
||||
] as [number, number][],
|
||||
[halfW, halfD]
|
||||
);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{columns.map(([x, z], i) => (
|
||||
<group key={i} position={[x, 0, z]}>
|
||||
<mesh position={[0, 2, 0]}>
|
||||
<cylinderGeometry args={[0.08, 0.09, 4, 10]} />
|
||||
<meshStandardMaterial color="#e0d8c8" roughness={0.88} />
|
||||
</mesh>
|
||||
<mesh position={[0, 4.05, 0]}>
|
||||
<boxGeometry args={[0.18, 0.1, 0.18]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.25} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function SalonDetails({ trim }: { trim: string }) {
|
||||
return (
|
||||
<mesh position={[0, 4.05, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.3, 1.2, 32]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.35} metalness={0.5} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MovementHallDetails({ style, width, depth, halfW, halfD }: Props) {
|
||||
/**
|
||||
* Period architecture for a movement hall.
|
||||
*
|
||||
* Every movement has its own interior, built from the architecture of the same
|
||||
* period as the movement itself. The Gothic nave and Byzantine basilica below
|
||||
* are the reference implementations; everything else lives in
|
||||
* `hall-details/`, sharing the geometry budget documented in
|
||||
* `hall-details/primitives.tsx`.
|
||||
*/
|
||||
export default function MovementHallDetails({ style, width, depth, halfW, halfD, windows }: Props) {
|
||||
const trim = style.tints.trim;
|
||||
const room: RoomDims = useMemo(
|
||||
() => ({ width, depth, halfW, halfD }),
|
||||
[width, depth, halfW, halfD]
|
||||
);
|
||||
const period = { room, trim, windows };
|
||||
|
||||
switch (style.details) {
|
||||
case 'palazzo':
|
||||
return <PalazzoDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
case 'baroque':
|
||||
return <BaroqueDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
case 'gothic':
|
||||
return <GothicDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
return <GothicDetails width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
case 'byzantine':
|
||||
return <ByzantineDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
case 'roman':
|
||||
return <RomanAtriumDetails {...period} />;
|
||||
case 'quattrocento':
|
||||
return <QuattrocentoDetails {...period} />;
|
||||
case 'cinquecento':
|
||||
return <CinquecentoDetails {...period} />;
|
||||
case 'flemish':
|
||||
return <FlemishDetails {...period} />;
|
||||
case 'mannerist':
|
||||
return <ManneristDetails {...period} />;
|
||||
case 'baroque':
|
||||
return <BaroqueStateDetails {...period} />;
|
||||
case 'rococo':
|
||||
return <RococoDetails {...period} />;
|
||||
case 'neoclassical':
|
||||
return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
case 'classical':
|
||||
return <ClassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
case 'salon':
|
||||
return <SalonDetails trim={trim} />;
|
||||
default:
|
||||
return null;
|
||||
return <NeoclassicalDetails {...period} />;
|
||||
case 'gothic-revival':
|
||||
return <GothicRevivalDetails {...period} />;
|
||||
case 'bourgeois':
|
||||
return <BourgeoisSalonDetails {...period} />;
|
||||
case 'north-light':
|
||||
return <NorthLightSalonDetails {...period} />;
|
||||
case 'paris-atelier':
|
||||
return <ParisAtelierDetails {...period} />;
|
||||
case 'symbolist':
|
||||
return <SymbolistDetails {...period} />;
|
||||
case 'art-nouveau':
|
||||
return <ArtNouveauDetails {...period} />;
|
||||
case 'fauvist':
|
||||
return <FauvistDetails {...period} />;
|
||||
case 'expressionist':
|
||||
return <ExpressionistDetails {...period} />;
|
||||
case 'cubist-atelier':
|
||||
return <CubistAtelierDetails {...period} />;
|
||||
case 'futurist':
|
||||
return <FuturistDetails {...period} />;
|
||||
case 'suprematist':
|
||||
return <SuprematistDetails {...period} />;
|
||||
case 'constructivist':
|
||||
return <ConstructivistDetails {...period} />;
|
||||
case 'dada':
|
||||
return <DadaDetails {...period} />;
|
||||
case 'surrealist':
|
||||
return <SurrealistDetails {...period} />;
|
||||
case 'loft':
|
||||
return <LoftDetails {...period} />;
|
||||
case 'white-cube':
|
||||
return <WhiteCubeDetails {...period} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
.mtree {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 12px 12px;
|
||||
}
|
||||
|
||||
.mtree-caption {
|
||||
margin: 0 0 8px;
|
||||
text-align: center;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
color: rgba(201, 169, 110, 0.65);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mtree-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: rgba(201, 169, 110, 0.6);
|
||||
font-family: 'Georgia', serif;
|
||||
}
|
||||
|
||||
.mtree-canvas {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
background:
|
||||
radial-gradient(ellipse 60% 45% at 50% 100%, rgba(201, 169, 110, 0.1), transparent 72%),
|
||||
radial-gradient(ellipse 80% 60% at 50% 0%, rgba(120, 150, 190, 0.08), transparent 70%),
|
||||
rgba(0, 0, 0, 0.28);
|
||||
border: 1px solid rgba(201, 169, 110, 0.12);
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.mtree-canvas.mtree-panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.mtree-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.mtree-limb {
|
||||
cursor: pointer;
|
||||
opacity: 0.9;
|
||||
transition: opacity 0.18s ease, filter 0.18s ease;
|
||||
}
|
||||
|
||||
.mtree-limb-lit {
|
||||
opacity: 1;
|
||||
filter: brightness(1.22) drop-shadow(0 0 6px rgba(255, 226, 170, 0.35));
|
||||
}
|
||||
|
||||
.mtree-limb-dim {
|
||||
opacity: 0.34;
|
||||
}
|
||||
|
||||
.mtree-root {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.mtree-graft {
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.mtree-graft-lit {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.mtree-label {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
transform: translate(-50%, -50%);
|
||||
margin: 0;
|
||||
padding: 2px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: rgba(12, 12, 22, 0.74);
|
||||
color: rgba(245, 230, 200, 0.95);
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.mtree-label:hover {
|
||||
background: rgba(30, 28, 40, 0.92);
|
||||
color: #fff6e0;
|
||||
}
|
||||
|
||||
.mtree-label-dim {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.mtree-out-of-range {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
color: rgba(201, 169, 110, 0.7);
|
||||
font-family: 'Georgia', serif;
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ArtMovement } from '../types';
|
||||
import {
|
||||
branchOriginYear,
|
||||
buildMovementTree,
|
||||
limbXAtYear,
|
||||
type MovementTreeNode,
|
||||
} from '../utils/movementTree';
|
||||
import { shadeMovementColor, vividMovementColor } from '../utils/movementColor';
|
||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||
import './MovementTree.css';
|
||||
|
||||
interface Props {
|
||||
movements: ArtMovement[];
|
||||
viewStart: number;
|
||||
viewEnd: number;
|
||||
absoluteMin: number;
|
||||
absoluteMax: number;
|
||||
onViewChange: (start: number, end: number) => void;
|
||||
onMovementClick?: (movementId: number) => void;
|
||||
}
|
||||
|
||||
interface Pt {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const SIDE_PAD = 56;
|
||||
/**
|
||||
* Horizontal spread is tied to the zoom, not to the canvas.
|
||||
*
|
||||
* 90 % of the catalogue lives in the last 15 % of the time axis, so a tree
|
||||
* stretched to full width with all of history in view is one long trunk under a
|
||||
* flat bar. Instead the whole-history view draws a narrow tree, and every zoom
|
||||
* step fans the crown out — the chart grows as you walk up it.
|
||||
*/
|
||||
const FULL_VIEW_WIDTH_SHARE = 0.52;
|
||||
const ZOOM_SPREAD_EXPONENT = 0.45;
|
||||
/** How far past "everything fits" the tree may be blown up when zoomed in. */
|
||||
const MAX_FIT_BOOST = 2.4;
|
||||
/** Exponential chase rate (1/s) for the horizontal fit, so zoom reads as growth. */
|
||||
const FIT_ANIM_RATE = 9;
|
||||
const RIBBON_SAMPLES = 18;
|
||||
/** Vertical room a label needs. */
|
||||
const MIN_LABEL_HEIGHT_PX = 20;
|
||||
/**
|
||||
* Readability floors. A 30-year movement is 8 px tall when the whole of
|
||||
* history is on screen; without these the modern crown fuses into one bar.
|
||||
*/
|
||||
const MIN_LIMB_RISE_PX = 30;
|
||||
const MIN_JUNCTION_RISE_PX = 38;
|
||||
/** A limb is never drawn thicker than this share of its own length. */
|
||||
const MAX_THICKNESS_OF_LENGTH = 0.55;
|
||||
|
||||
function cubicAt(p0: Pt, c1: Pt, c2: Pt, p3: Pt, t: number): Pt {
|
||||
const u = 1 - t;
|
||||
const a = u * u * u;
|
||||
const b = 3 * u * u * t;
|
||||
const c = 3 * u * t * t;
|
||||
const d = t * t * t;
|
||||
return {
|
||||
x: a * p0.x + b * c1.x + c * c2.x + d * p3.x,
|
||||
y: a * p0.y + b * c1.y + c * c2.y + d * p3.y,
|
||||
};
|
||||
}
|
||||
|
||||
function cubicTangent(p0: Pt, c1: Pt, c2: Pt, p3: Pt, t: number): Pt {
|
||||
const u = 1 - t;
|
||||
const a = 3 * u * u;
|
||||
const b = 6 * u * t;
|
||||
const c = 3 * t * t;
|
||||
return {
|
||||
x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p3.x - c2.x),
|
||||
y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filled ribbon of varying width along a cubic. Offsetting along the curve
|
||||
* normal (rather than horizontally) keeps branch junctions solid even when a
|
||||
* zoomed-out view squeezes them almost flat.
|
||||
*/
|
||||
function ribbonPath(p0: Pt, c1: Pt, c2: Pt, p3: Pt, w0: number, w1: number): string {
|
||||
const left: Pt[] = [];
|
||||
const right: Pt[] = [];
|
||||
for (let i = 0; i <= RIBBON_SAMPLES; i++) {
|
||||
const t = i / RIBBON_SAMPLES;
|
||||
const p = cubicAt(p0, c1, c2, p3, t);
|
||||
const d = cubicTangent(p0, c1, c2, p3, t);
|
||||
const len = Math.hypot(d.x, d.y) || 1;
|
||||
const nx = -d.y / len;
|
||||
const ny = d.x / len;
|
||||
const half = (w0 + (w1 - w0) * t) / 2;
|
||||
left.push({ x: p.x + nx * half, y: p.y + ny * half });
|
||||
right.push({ x: p.x - nx * half, y: p.y - ny * half });
|
||||
}
|
||||
const fmt = (pt: Pt) => `${pt.x.toFixed(2)} ${pt.y.toFixed(2)}`;
|
||||
const forward = left.map((pt, i) => `${i === 0 ? 'M' : 'L'} ${fmt(pt)}`).join(' ');
|
||||
const back = right
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((pt) => `L ${fmt(pt)}`)
|
||||
.join(' ');
|
||||
return `${forward} ${back} Z`;
|
||||
}
|
||||
|
||||
interface LimbShape {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
shade: string;
|
||||
/** Trunk / limb body. */
|
||||
d: string;
|
||||
/** Junction ribbon growing out of the structural parent (may be empty). */
|
||||
junction: string;
|
||||
/** Rounded tip cap. */
|
||||
tip: Pt & { r: number };
|
||||
/** Root flare under a tree root, drawn only when the base is on screen. */
|
||||
roots: string[];
|
||||
labelX: number;
|
||||
labelY: number;
|
||||
labelVisible: boolean;
|
||||
yearRange: string;
|
||||
depth: number;
|
||||
inView: boolean;
|
||||
}
|
||||
|
||||
interface GraftShape {
|
||||
key: string;
|
||||
d: string;
|
||||
color: string;
|
||||
fromId: number;
|
||||
toId: number;
|
||||
}
|
||||
|
||||
function formatYear(year: number): string {
|
||||
return year < 0 ? `${Math.abs(year)} BCE` : `${year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedy declutter: closer to the trunk wins. Every visible movement asks for a
|
||||
* name, and the ones that would collide with an already-placed name — or fall
|
||||
* off the canvas — stay anonymous until you zoom in on them.
|
||||
*/
|
||||
function hideOverlappingLabels(limbs: LimbShape[], width: number, height: number): void {
|
||||
const placed: { x0: number; y0: number; x1: number; y1: number }[] = [];
|
||||
const candidates = limbs
|
||||
.map((limb, index) => ({ limb, index }))
|
||||
.filter(({ limb }) => limb.labelVisible)
|
||||
.sort((a, b) => a.limb.depth - b.limb.depth || b.limb.labelY - a.limb.labelY);
|
||||
|
||||
for (const { limb } of candidates) {
|
||||
const halfW = (limb.name.length * 6.6 + 16) / 2;
|
||||
const halfH = MIN_LABEL_HEIGHT_PX / 2;
|
||||
const box = {
|
||||
x0: limb.labelX - halfW,
|
||||
y0: limb.labelY - halfH,
|
||||
x1: limb.labelX + halfW,
|
||||
y1: limb.labelY + halfH,
|
||||
};
|
||||
const offCanvas = box.x0 < 2 || box.x1 > width - 2 || box.y0 < 2 || box.y1 > height - 2;
|
||||
const collides = placed.some(
|
||||
(p) => box.x0 < p.x1 && box.x1 > p.x0 && box.y0 < p.y1 && box.y1 > p.y0
|
||||
);
|
||||
if (offCanvas || collides) {
|
||||
limb.labelVisible = false;
|
||||
continue;
|
||||
}
|
||||
placed.push(box);
|
||||
}
|
||||
}
|
||||
|
||||
export default function MovementTree({
|
||||
movements,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
absoluteMin,
|
||||
absoluteMax,
|
||||
onViewChange,
|
||||
onMovementClick,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('home');
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const [canvas, setCanvas] = useState({ w: 900, h: 600 });
|
||||
const [panning, setPanning] = useState(false);
|
||||
const [hoveredId, setHoveredId] = useState<number | null>(null);
|
||||
const [fitScale, setFitScale] = useState(1);
|
||||
const panStart = useRef({ y: 0, viewStart: 0, viewEnd: 0 });
|
||||
const viewRef = useRef({ viewStart, viewEnd });
|
||||
const onViewChangeRef = useRef(onViewChange);
|
||||
const fitRef = useRef(1);
|
||||
const fitReadyRef = useRef(false);
|
||||
const fitTargetRef = useRef(1);
|
||||
const fitRafRef = useRef<number | null>(null);
|
||||
const fitLastTsRef = useRef(0);
|
||||
viewRef.current = { viewStart, viewEnd };
|
||||
onViewChangeRef.current = onViewChange;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = canvasRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
setCanvas({ w: Math.round(rect.width), h: Math.round(rect.height) });
|
||||
}
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Structure is catalogue-wide and view-independent: zooming must not reshape
|
||||
// the tree, only travel along it.
|
||||
const tree = useMemo(() => buildMovementTree(movements), [movements]);
|
||||
|
||||
const visibleIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
for (const node of tree.nodes.values()) {
|
||||
const { start_year: s, end_year: e } = node.movement;
|
||||
if (e > viewStart && s < viewEnd) ids.add(node.movement.id);
|
||||
}
|
||||
return ids;
|
||||
}, [tree, viewStart, viewEnd]);
|
||||
|
||||
const targetScale = useMemo(() => {
|
||||
const usable = Math.max(240, canvas.w - SIDE_PAD * 2);
|
||||
const fitAll = usable / (2 * Math.max(1, tree.halfSpan));
|
||||
const visibleSpan = Math.max(1, viewEnd - viewStart);
|
||||
const totalSpan = Math.max(visibleSpan, absoluteMax - absoluteMin);
|
||||
const zoomSpread = Math.pow(totalSpan / visibleSpan, ZOOM_SPREAD_EXPONENT);
|
||||
const spread = Math.min(
|
||||
MAX_FIT_BOOST,
|
||||
Math.max(FULL_VIEW_WIDTH_SHARE, FULL_VIEW_WIDTH_SHARE * zoomSpread)
|
||||
);
|
||||
|
||||
let visibleHalfSpan = 0;
|
||||
for (const id of visibleIds) {
|
||||
const node = tree.nodes.get(id);
|
||||
if (!node) continue;
|
||||
visibleHalfSpan = Math.max(
|
||||
visibleHalfSpan,
|
||||
Math.abs(node.x) + Math.abs(node.lean) + node.baseWidth / 2
|
||||
);
|
||||
}
|
||||
// Never let what is on screen spill off the canvas.
|
||||
const overflowCap = visibleHalfSpan > 0 ? usable / (2 * visibleHalfSpan) : Infinity;
|
||||
return Math.min(fitAll * spread, overflowCap);
|
||||
}, [canvas.w, tree, visibleIds, viewStart, viewEnd, absoluteMin, absoluteMax]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
fitTargetRef.current = targetScale;
|
||||
if (!fitReadyRef.current) {
|
||||
// First measured layout — adopt it instead of animating in from nothing.
|
||||
fitReadyRef.current = true;
|
||||
fitRef.current = targetScale;
|
||||
setFitScale(targetScale);
|
||||
return;
|
||||
}
|
||||
if (fitRafRef.current != null) return;
|
||||
|
||||
fitLastTsRef.current = performance.now();
|
||||
const step = (now: number) => {
|
||||
const dt = Math.min(0.05, Math.max(0, (now - fitLastTsRef.current) / 1000));
|
||||
fitLastTsRef.current = now;
|
||||
const t = 1 - Math.exp(-FIT_ANIM_RATE * dt);
|
||||
const next = fitRef.current + (fitTargetRef.current - fitRef.current) * t;
|
||||
if (Math.abs(fitTargetRef.current - next) < 0.002) {
|
||||
fitRef.current = fitTargetRef.current;
|
||||
setFitScale(fitTargetRef.current);
|
||||
fitRafRef.current = null;
|
||||
return;
|
||||
}
|
||||
fitRef.current = next;
|
||||
setFitScale(next);
|
||||
fitRafRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
fitRafRef.current = requestAnimationFrame(step);
|
||||
}, [targetScale]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (fitRafRef.current != null) cancelAnimationFrame(fitRafRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const { limbs, grafts } = useMemo(() => {
|
||||
const span = viewEnd - viewStart || 1;
|
||||
const centerX = canvas.w / 2;
|
||||
const widthScale = Math.min(1.7, Math.max(0.55, fitScale));
|
||||
const sx = (treeX: number) => centerX + treeX * fitScale;
|
||||
const sy = (year: number) => canvas.h - ((year - viewStart) / span) * canvas.h;
|
||||
|
||||
const limbList: LimbShape[] = [];
|
||||
const graftList: GraftShape[] = [];
|
||||
|
||||
/**
|
||||
* Pass 1 — screen geometry per movement.
|
||||
*
|
||||
* Two readability floors apply here, and only here: the structure and the
|
||||
* dates stay untouched. A limb is drawn at least `MIN_LIMB_RISE_PX` long,
|
||||
* and never thicker than it is long, so 2 500 years of trunk and 30 years
|
||||
* of Fauvism can share one linear axis without the modern crown fusing
|
||||
* into a solid bar.
|
||||
*/
|
||||
const drawn = new Map<
|
||||
number,
|
||||
{ base: Pt; c1: Pt; c2: Pt; tip: Pt; wBase: number; wTip: number }
|
||||
>();
|
||||
|
||||
for (const id of tree.drawOrder) {
|
||||
const node = tree.nodes.get(id)!;
|
||||
const { start_year: start, end_year: end } = node.movement;
|
||||
if (end <= start) continue;
|
||||
|
||||
const base: Pt = { x: sx(limbXAtYear(node, start)), y: sy(start) };
|
||||
const trueTipY = sy(end);
|
||||
const tip: Pt = {
|
||||
x: sx(limbXAtYear(node, end)),
|
||||
y: Math.min(trueTipY, base.y - MIN_LIMB_RISE_PX),
|
||||
};
|
||||
const lengthPx = Math.hypot(tip.x - base.x, tip.y - base.y);
|
||||
const cap = Math.max(3, lengthPx * MAX_THICKNESS_OF_LENGTH);
|
||||
const wBase = Math.min(node.baseWidth * widthScale, cap);
|
||||
const wTip = Math.min(node.tipWidth * widthScale, cap * 0.82);
|
||||
const dy = tip.y - base.y;
|
||||
drawn.set(id, {
|
||||
base,
|
||||
c1: { x: base.x, y: base.y + dy * 0.42 },
|
||||
c2: { x: tip.x, y: tip.y - dy * 0.34 },
|
||||
tip,
|
||||
wBase,
|
||||
wTip,
|
||||
});
|
||||
}
|
||||
|
||||
/** Point on a drawn limb at the screen height closest to `targetY`. */
|
||||
const pointOnLimb = (parentId: number, targetY: number) => {
|
||||
const p = drawn.get(parentId)!;
|
||||
const total = p.base.y - p.tip.y || 1;
|
||||
const t = Math.min(1, Math.max(0, (p.base.y - targetY) / total));
|
||||
return {
|
||||
pt: cubicAt(p.base, p.c1, p.c2, p.tip, t),
|
||||
width: p.wBase + (p.wTip - p.wBase) * t,
|
||||
};
|
||||
};
|
||||
|
||||
// Pass 2 — ribbons.
|
||||
for (const id of tree.drawOrder) {
|
||||
const node = tree.nodes.get(id)!;
|
||||
const shape = drawn.get(id);
|
||||
if (!shape) continue;
|
||||
const { base, c1, c2, tip, wBase, wTip } = shape;
|
||||
const { start_year: start, end_year: end } = node.movement;
|
||||
|
||||
const color = vividMovementColor(node.movement.color);
|
||||
const d = ribbonPath(base, c1, c2, tip, wBase, wTip);
|
||||
|
||||
// Junction: the limb grows out of its parent a little before its own
|
||||
// date, and always climbs far enough to read as a fork.
|
||||
let junction = '';
|
||||
const parent = node.parentId != null ? tree.nodes.get(node.parentId) : null;
|
||||
if (parent && drawn.has(parent.movement.id)) {
|
||||
const byDate = sy(branchOriginYear(parent, node));
|
||||
const origin = pointOnLimb(
|
||||
parent.movement.id,
|
||||
Math.max(byDate, base.y + MIN_JUNCTION_RISE_PX)
|
||||
);
|
||||
const from = origin.pt;
|
||||
const jdy = base.y - from.y;
|
||||
const jLength = Math.hypot(base.x - from.x, jdy);
|
||||
const jCap = Math.max(3, jLength * MAX_THICKNESS_OF_LENGTH);
|
||||
const wFrom = Math.min(origin.width * 0.92, wBase * 1.25, jCap);
|
||||
const jc1: Pt = { x: from.x, y: from.y + jdy * 0.45 };
|
||||
const jc2: Pt = { x: base.x, y: base.y - jdy * 0.45 };
|
||||
junction = ribbonPath(from, jc1, jc2, base, wFrom, Math.min(wBase, jCap));
|
||||
}
|
||||
|
||||
// Roots: only the bottom of a tree, and only when that bottom is in frame.
|
||||
const roots: string[] = [];
|
||||
if (!parent && base.y > -canvas.h && base.y < canvas.h * 2) {
|
||||
const flare = Math.max(22, wBase * 1.4);
|
||||
for (const dir of [-1, -0.35, 0.35, 1]) {
|
||||
const endPt: Pt = { x: base.x + dir * flare, y: base.y + flare * 0.72 };
|
||||
const rc1: Pt = { x: base.x + dir * flare * 0.2, y: base.y + flare * 0.34 };
|
||||
const rc2: Pt = { x: base.x + dir * flare * 0.8, y: base.y + flare * 0.5 };
|
||||
roots.push(ribbonPath(base, rc1, rc2, endPt, wBase * 0.42, 1.5));
|
||||
}
|
||||
}
|
||||
|
||||
const inView = visibleIds.has(id);
|
||||
const clampedStart = Math.max(start, viewStart);
|
||||
const clampedEnd = Math.min(end, viewEnd);
|
||||
const midYear = (clampedStart + clampedEnd) / 2;
|
||||
const labelY = Math.min(
|
||||
Math.max(sy(midYear), tip.y + MIN_LABEL_HEIGHT_PX / 2),
|
||||
base.y
|
||||
);
|
||||
|
||||
limbList.push({
|
||||
id,
|
||||
name: node.movement.name,
|
||||
color,
|
||||
shade: shadeMovementColor(color),
|
||||
d,
|
||||
junction,
|
||||
tip: { x: tip.x, y: tip.y, r: Math.max(1.5, wTip / 2) },
|
||||
roots,
|
||||
labelX: sx(limbXAtYear(node, midYear)),
|
||||
labelY,
|
||||
labelVisible: inView,
|
||||
yearRange: `${formatYear(start)} – ${formatYear(end)}`,
|
||||
depth: node.depth,
|
||||
inView,
|
||||
});
|
||||
|
||||
for (const graftId of node.graftParentIds) {
|
||||
const graftParent = tree.nodes.get(graftId);
|
||||
if (!graftParent || !drawn.has(graftId)) continue;
|
||||
const byDate = sy(branchOriginYear(graftParent, node));
|
||||
const origin = pointOnLimb(graftId, Math.max(byDate, base.y + MIN_JUNCTION_RISE_PX));
|
||||
const from = origin.pt;
|
||||
const gdy = base.y - from.y;
|
||||
const gWidth = Math.max(2.5, Math.min(wBase * 0.3, 9));
|
||||
graftList.push({
|
||||
key: `${graftId}-${id}`,
|
||||
d: ribbonPath(
|
||||
from,
|
||||
{ x: from.x, y: from.y + gdy * 0.55 },
|
||||
{ x: base.x, y: base.y - gdy * 0.3 },
|
||||
base,
|
||||
gWidth * 0.7,
|
||||
gWidth
|
||||
),
|
||||
color: vividMovementColor(graftParent.movement.color),
|
||||
fromId: graftId,
|
||||
toId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
hideOverlappingLabels(limbList, canvas.w, canvas.h);
|
||||
return { limbs: limbList, grafts: graftList };
|
||||
}, [tree, viewStart, viewEnd, canvas.w, canvas.h, fitScale, visibleIds]);
|
||||
|
||||
/** A hovered movement lights up its whole descent line back to the root. */
|
||||
const lineageIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
if (hoveredId == null) return ids;
|
||||
let cursor: number | null = hoveredId;
|
||||
let guard = 0;
|
||||
while (cursor != null && guard++ < 64) {
|
||||
ids.add(cursor);
|
||||
const node: MovementTreeNode | undefined = tree.nodes.get(cursor);
|
||||
if (!node) break;
|
||||
for (const graftId of node.graftParentIds) ids.add(graftId);
|
||||
cursor = node.parentId;
|
||||
}
|
||||
return ids;
|
||||
}, [hoveredId, tree]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = canvasRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = el.getBoundingClientRect();
|
||||
const { viewStart: vs, viewEnd: ve } = viewRef.current;
|
||||
// Bottom = oldest, so invert the pointer offset before reusing the shared
|
||||
// left-to-right zoom math.
|
||||
const invertedY = rect.height - (e.clientY - rect.top);
|
||||
const next = zoomTimelineView(
|
||||
invertedY,
|
||||
0,
|
||||
rect.height,
|
||||
e.deltaY,
|
||||
vs,
|
||||
ve,
|
||||
absoluteMin,
|
||||
absoluteMax
|
||||
);
|
||||
onViewChangeRef.current(next.start, next.end);
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false, capture: true });
|
||||
return () => el.removeEventListener('wheel', onWheel, { capture: true });
|
||||
}, [absoluteMin, absoluteMax]);
|
||||
|
||||
const handlePanStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
setPanning(true);
|
||||
panStart.current = { y: e.clientY, viewStart, viewEnd };
|
||||
},
|
||||
[viewStart, viewEnd]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!panning) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const dy = e.clientY - panStart.current.y;
|
||||
const next = panTimelineView(
|
||||
-dy,
|
||||
rect.height,
|
||||
panStart.current.viewStart,
|
||||
panStart.current.viewEnd,
|
||||
absoluteMin,
|
||||
absoluteMax
|
||||
);
|
||||
onViewChangeRef.current(next.start, next.end);
|
||||
};
|
||||
const onUp = () => setPanning(false);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [panning, absoluteMin, absoluteMax]);
|
||||
|
||||
if (movements.length === 0) {
|
||||
return (
|
||||
<div className="mtree-empty">
|
||||
<p>No art movements to grow a tree from yet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const anyInView = limbs.some((limb) => limb.inView);
|
||||
|
||||
return (
|
||||
<div className="mtree">
|
||||
<p className="mtree-caption">
|
||||
{t('captionTreeFlow')}
|
||||
</p>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className={`mtree-canvas${panning ? ' mtree-panning' : ''}`}
|
||||
onMouseDown={handlePanStart}
|
||||
>
|
||||
<svg
|
||||
className="mtree-svg"
|
||||
viewBox={`0 0 ${canvas.w} ${canvas.h}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
{limbs.map((limb) => (
|
||||
<linearGradient
|
||||
key={`grad-${limb.id}`}
|
||||
id={`mtree-limb-${limb.id}`}
|
||||
gradientUnits="objectBoundingBox"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="0"
|
||||
>
|
||||
<stop offset="0%" stopColor={limb.shade} />
|
||||
<stop offset="45%" stopColor={limb.color} />
|
||||
<stop offset="100%" stopColor={limb.shade} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
|
||||
<g className="mtree-grafts">
|
||||
{grafts.map((graft) => (
|
||||
<path
|
||||
key={graft.key}
|
||||
d={graft.d}
|
||||
fill={graft.color}
|
||||
className={`mtree-graft${
|
||||
lineageIds.has(graft.toId) && lineageIds.has(graft.fromId)
|
||||
? ' mtree-graft-lit'
|
||||
: ''
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
|
||||
{limbs.map((limb) => {
|
||||
const lit = lineageIds.has(limb.id);
|
||||
const dim = hoveredId != null && !lit;
|
||||
return (
|
||||
<g
|
||||
key={limb.id}
|
||||
className={`mtree-limb${lit ? ' mtree-limb-lit' : ''}${
|
||||
dim ? ' mtree-limb-dim' : ''
|
||||
}`}
|
||||
onMouseEnter={() => setHoveredId(limb.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMovementClick?.(limb.id);
|
||||
}}
|
||||
>
|
||||
<title>{`${limb.name} · ${limb.yearRange}`}</title>
|
||||
{limb.roots.map((d, i) => (
|
||||
<path key={`root-${i}`} d={d} fill={limb.shade} className="mtree-root" />
|
||||
))}
|
||||
{limb.junction && (
|
||||
<path d={limb.junction} fill={`url(#mtree-limb-${limb.id})`} />
|
||||
)}
|
||||
<path d={limb.d} fill={`url(#mtree-limb-${limb.id})`} />
|
||||
<circle cx={limb.tip.x} cy={limb.tip.y} r={limb.tip.r} fill={limb.color} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{limbs
|
||||
.filter((limb) => limb.labelVisible)
|
||||
.map((limb) => (
|
||||
<button
|
||||
key={`label-${limb.id}`}
|
||||
type="button"
|
||||
className={`mtree-label${
|
||||
hoveredId != null && !lineageIds.has(limb.id) ? ' mtree-label-dim' : ''
|
||||
}`}
|
||||
style={{ left: `${limb.labelX}px`, top: `${limb.labelY}px` }}
|
||||
title={`${limb.name} · ${limb.yearRange}`}
|
||||
onMouseEnter={() => setHoveredId(limb.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMovementClick?.(limb.id);
|
||||
}}
|
||||
>
|
||||
{limb.name}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{!anyInView && (
|
||||
<div className="mtree-out-of-range">
|
||||
<p>No movements in this time range — zoom out to see the whole tree.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { HistoricalEra } from '../types';
|
||||
import {
|
||||
HISTORICAL_EVENTS,
|
||||
@@ -38,6 +39,7 @@ function formatYear(year: number): string {
|
||||
}
|
||||
|
||||
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight }: Props) {
|
||||
const { t } = useTranslation('home');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState<'left' | 'right' | 'pan' | null>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(800);
|
||||
@@ -365,7 +367,7 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
|
||||
</div>
|
||||
|
||||
<p className="timeline-hint">
|
||||
Click an era or event to zoom · Scroll to zoom · Drag to pan
|
||||
{t('captionClassicTimeline')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ArtMovement } from '../types';
|
||||
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||
import { vividMovementColor } from '../utils/movementColor';
|
||||
import './VerticalMovementBands.css';
|
||||
|
||||
interface Props {
|
||||
@@ -51,66 +53,6 @@ function yearToBottomPercent(year: number, start: number, end: number): number {
|
||||
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(
|
||||
visible: ArtMovement[],
|
||||
nameToId: Map<string, number>
|
||||
@@ -423,6 +365,7 @@ export default function VerticalMovementBands({
|
||||
onViewChange,
|
||||
onMovementClick,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('home');
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const [canvasSize, setCanvasSize] = useState({ w: 800, h: 600 });
|
||||
const [panning, setPanning] = useState(false);
|
||||
@@ -690,7 +633,7 @@ export default function VerticalMovementBands({
|
||||
return (
|
||||
<div className="vflow">
|
||||
<p className="vflow-caption">
|
||||
Bottom → top through history · scroll to zoom · drag to pan · click a stream
|
||||
{t('captionVerticalFlow')}
|
||||
</p>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { HistoricalEra } from '../types';
|
||||
import {
|
||||
HISTORICAL_EVENTS,
|
||||
@@ -60,6 +61,7 @@ export default function VerticalTimeline({
|
||||
absoluteMax,
|
||||
lifespanHighlight,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('home');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState<'start' | 'end' | 'pan' | null>(null);
|
||||
const [containerHeight, setContainerHeight] = useState(600);
|
||||
@@ -409,7 +411,7 @@ export default function VerticalTimeline({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="vtimeline-hint">Bottom → top · Scroll to zoom · Drag to pan</p>
|
||||
<p className="vtimeline-hint">{t('captionVerticalTimeline')}</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,12 @@ import type {
|
||||
import { galleryImageUrlCandidates, imageUrl, api } from '../api/client';
|
||||
import { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
|
||||
import { cloneSurfaceTexture, getSurfaceTexture, type SurfaceTextureKind } from '../utils/galleryProceduralTextures';
|
||||
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
|
||||
import {
|
||||
resolveMovementInteriorStyle,
|
||||
type MovementDetailKind,
|
||||
type MovementInteriorStyle,
|
||||
type GalleryWindowSpec,
|
||||
} from '../data/movement-interior-styles';
|
||||
import { useTexturedMaterial } from '../hooks/useTexturedMaterial';
|
||||
import MovementHallDetails from './MovementHallDetails';
|
||||
import GalleryWindows, { GalleryTrackLights } from './GalleryWindows';
|
||||
@@ -84,6 +89,15 @@ interface TourGalleryProps extends BaseGalleryProps {
|
||||
|
||||
type Props = ArtistGalleryProps | MovementGalleryProps | TourGalleryProps;
|
||||
|
||||
/** Period interiors built from steel, concrete or a white cube read better under the city HDR. */
|
||||
const CITY_ENVIRONMENT_DETAILS = new Set<MovementDetailKind>([
|
||||
'futurist',
|
||||
'suprematist',
|
||||
'constructivist',
|
||||
'loft',
|
||||
'white-cube',
|
||||
]);
|
||||
|
||||
const WALL_HEIGHT = 4.2;
|
||||
const WALL_THICKNESS = 0.18;
|
||||
const MOUNT_OFFSET = 0.16;
|
||||
@@ -1739,7 +1753,14 @@ function ArtistHall({
|
||||
</mesh>
|
||||
|
||||
{interiorStyle && (
|
||||
<MovementHallDetails style={interiorStyle} width={width} depth={depth} halfW={halfW} halfD={halfD} />
|
||||
<MovementHallDetails
|
||||
style={interiorStyle}
|
||||
width={width}
|
||||
depth={depth}
|
||||
halfW={halfW}
|
||||
halfD={halfD}
|
||||
windows={computedWindows}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Text
|
||||
@@ -2635,7 +2656,7 @@ export default function VirtualGallery(props: Props) {
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<Environment
|
||||
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
|
||||
preset={interiorStyle && CITY_ENVIRONMENT_DETAILS.has(interiorStyle.details) ? 'city' : 'warehouse'}
|
||||
environmentIntensity={(interiorStyle ? 0.55 : 0.25) * lightScale}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
CofferedCeiling,
|
||||
CorniceRing,
|
||||
FlutedShaft,
|
||||
HangingFixture,
|
||||
RoundArch,
|
||||
ShaftBase,
|
||||
TuscanCapital,
|
||||
WallBand,
|
||||
} from './primitives';
|
||||
import {
|
||||
shade,
|
||||
useBayZ,
|
||||
useCorners,
|
||||
useRepeat,
|
||||
useWallClearance,
|
||||
type PeriodProps,
|
||||
} from './geometry';
|
||||
|
||||
/**
|
||||
* Roman atrium — Ancient / Classical.
|
||||
*
|
||||
* Engaged Tuscan half-columns on a bay rhythm carry a compressed but complete
|
||||
* entablature (architrave, plain frieze, dentil course, projecting cornice).
|
||||
* Below the frame line the walls carry the Pompeian red socle of a Fourth Style
|
||||
* room; a bronze polycandelon hangs over the centre in place of ceiling lights.
|
||||
*/
|
||||
export function RomanAtriumDetails({ room, trim }: PeriodProps) {
|
||||
const bays = useBayZ(room.depth, room.halfD, 4.0, 9);
|
||||
const corners = useCorners(room.halfW, room.halfD, 0.1);
|
||||
const stone = '#e6dcc6';
|
||||
const shadowStone = shade(stone, -0.14);
|
||||
const socle = '#7c3227';
|
||||
|
||||
const dentils = useRepeat(room.depth, 0.46, 30);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Pompeian socle with a black skirting course */}
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<group key={wall}>
|
||||
<WallBand wall={wall} room={room} y={0.62} height={1.16} proud={0.07} color={socle} roughness={0.8} metalness={0.04} />
|
||||
<WallBand wall={wall} room={room} y={0.09} height={0.18} proud={0.09} color="#241a14" roughness={0.7} metalness={0.06} />
|
||||
<WallBand wall={wall} room={room} y={1.24} height={0.08} proud={0.1} color={stone} roughness={0.66} metalness={0.06} />
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Engaged half-columns on the bay rhythm */}
|
||||
{bays.map((z, i) => (
|
||||
<group key={i}>
|
||||
{[-1, 1].map((side) => (
|
||||
<group key={side} position={[side * (room.halfW - 0.115), 0, z]}>
|
||||
<ShaftBase size={0.15} color={shadowStone} />
|
||||
<group position={[0, 0.26, 0]}>
|
||||
<FlutedShaft height={3.34} radius={0.135} color={stone} flutes={10} facing="x" />
|
||||
</group>
|
||||
<TuscanCapital y={3.72} size={0.15} color={stone} />
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Entablature: architrave, frieze and dentils along the colonnaded walls */}
|
||||
{(['left', 'right'] as const).map((wall) => (
|
||||
<group key={wall}>
|
||||
<WallBand wall={wall} room={room} y={3.9} height={0.1} proud={0.16} color={shadowStone} roughness={0.7} />
|
||||
<WallBand wall={wall} room={room} y={3.99} height={0.11} proud={0.13} color={stone} roughness={0.74} />
|
||||
{dentils.map((z, i) => (
|
||||
<mesh
|
||||
key={i}
|
||||
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.16), 4.07, z]}
|
||||
>
|
||||
<boxGeometry args={[0.16, 0.07, 0.16]} />
|
||||
<meshStandardMaterial color={shade(stone, 0.06)} roughness={0.7} metalness={0.05} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Projecting cornice returning around the whole room */}
|
||||
<CorniceRing room={room} y={4.13} height={0.09} proud={0.19} color={trim} roughness={0.62} metalness={0.12} />
|
||||
|
||||
{/* Corner antae — square pilasters closing the order at each corner */}
|
||||
{corners.map(([x, z], i) => (
|
||||
<mesh key={i} position={[x, 1.95, z]}>
|
||||
<boxGeometry args={[0.2, 3.9, 0.2]} />
|
||||
<meshStandardMaterial color={shade(stone, 0.04)} roughness={0.76} metalness={0.04} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
<HangingFixture
|
||||
position={[0, 4.05, 0]}
|
||||
dropLength={0.72}
|
||||
radius={0.46}
|
||||
arms={8}
|
||||
metalColor={trim}
|
||||
light
|
||||
lightColor="#ffb862"
|
||||
lightIntensity={0.9}
|
||||
lightDistance={9}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Florentine palazzo — Early Renaissance.
|
||||
*
|
||||
* Brunelleschi's grammar: flat pietra serena pilasters set against lime plaster,
|
||||
* a blind arcade of semicircular arches springing between them, glazed roundels
|
||||
* in the spandrels, and a painted-and-beamed quattrocento ceiling.
|
||||
*/
|
||||
export function QuattrocentoDetails({ room, trim, windows }: PeriodProps) {
|
||||
const bays = useBayZ(room.depth, room.halfD, 3.8, 9);
|
||||
const clearLeft = useWallClearance(windows, 'left');
|
||||
const clearRight = useWallClearance(windows, 'right');
|
||||
const serena = '#8b8f8a';
|
||||
const serenaLight = shade(serena, 0.16);
|
||||
const step = bays.length > 1 ? bays[1] - bays[0] : room.depth / 2;
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Pietra serena dado with a moulded cap */}
|
||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
||||
<group key={wall}>
|
||||
<WallBand wall={wall} room={room} y={0.6} height={1.2} proud={0.05} color={shade(serena, 0.24)} roughness={0.72} metalness={0.05} />
|
||||
<WallBand wall={wall} room={room} y={1.24} height={0.09} proud={0.08} color={serena} roughness={0.62} metalness={0.07} />
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Flat pilasters carrying the arcade */}
|
||||
{bays.map((z, i) => (
|
||||
<group key={i}>
|
||||
{[-1, 1].map((side) => (
|
||||
<group key={side} position={[side * (room.halfW - 0.05), 0, z]}>
|
||||
<mesh position={[0, 1.78, 0]}>
|
||||
<boxGeometry args={[0.08, 3.56, 0.34]} />
|
||||
<meshStandardMaterial color={serena} roughness={0.66} metalness={0.07} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.11, 0]}>
|
||||
<boxGeometry args={[0.11, 0.22, 0.44]} />
|
||||
<meshStandardMaterial color={shade(serena, -0.08)} roughness={0.7} metalness={0.06} />
|
||||
</mesh>
|
||||
{/* Simplified Corinthian capital */}
|
||||
<mesh position={[0, 3.68, 0]}>
|
||||
<boxGeometry args={[0.1, 0.2, 0.44]} />
|
||||
<meshStandardMaterial color={serenaLight} roughness={0.6} metalness={0.08} />
|
||||
</mesh>
|
||||
<mesh position={[0, 3.82, 0]}>
|
||||
<boxGeometry args={[0.12, 0.09, 0.5]} />
|
||||
<meshStandardMaterial color={serenaLight} roughness={0.58} metalness={0.08} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Blind arcade between the pilasters, plus a roundel in each spandrel */}
|
||||
{bays.slice(0, -1).map((z, i) => {
|
||||
const mid = z + step / 2;
|
||||
return (
|
||||
<group key={i}>
|
||||
{([-1, 1] as const).map((side) => (
|
||||
<group key={side} position={[side * (room.halfW - 0.05), 3.88, mid]} rotation={[0, Math.PI / 2, 0]}>
|
||||
<RoundArch
|
||||
span={step - 0.34}
|
||||
rise={0.24}
|
||||
thickness={0.12}
|
||||
depth={0.09}
|
||||
color={serena}
|
||||
segments={9}
|
||||
roughness={0.64}
|
||||
metalness={0.07}
|
||||
/>
|
||||
{/* Glazed tondo, only where no window claims the bay */}
|
||||
{(side < 0 ? clearLeft : clearRight)(mid, 0.22) && (
|
||||
<mesh position={[0, -0.5, 0]}>
|
||||
<torusGeometry args={[0.17, 0.035, 8, 20]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.42} metalness={0.35} />
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Painted beam ceiling over the arcade */}
|
||||
<CorniceRing room={room} y={4.11} height={0.1} proud={0.14} color={serena} roughness={0.64} metalness={0.07} />
|
||||
<CofferedCeiling
|
||||
room={room}
|
||||
y={4.09}
|
||||
cell={2.0}
|
||||
panelColor="#e2d3ba"
|
||||
ribColor="#8a5a30"
|
||||
ribWidth={0.14}
|
||||
ribDrop={0.14}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Roman palazzo — High Renaissance.
|
||||
*
|
||||
* Bramante's cinquecento order: paired fluted Corinthian pilasters on a wide
|
||||
* bay, a full marble entablature, panelled dado, and a 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;
|
||||
/** Scales the shared hall/scene lights. <1 for deliberately dim period interiors. */
|
||||
lightScale: number;
|
||||
details: 'palazzo' | 'baroque' | 'gothic' | 'byzantine' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier';
|
||||
/**
|
||||
* Which period architecture the hall is built from. Every movement has its
|
||||
* own — the interiors are not shared between movements — and each kind maps
|
||||
* to one component under `components/hall-details/`.
|
||||
*/
|
||||
details: MovementDetailKind;
|
||||
}
|
||||
|
||||
export type MovementDetailKind =
|
||||
| 'roman'
|
||||
| 'byzantine'
|
||||
| 'gothic'
|
||||
| 'quattrocento'
|
||||
| 'cinquecento'
|
||||
| 'flemish'
|
||||
| 'mannerist'
|
||||
| 'baroque'
|
||||
| 'rococo'
|
||||
| 'neoclassical'
|
||||
| 'gothic-revival'
|
||||
| 'bourgeois'
|
||||
| 'north-light'
|
||||
| 'paris-atelier'
|
||||
| 'symbolist'
|
||||
| 'art-nouveau'
|
||||
| 'fauvist'
|
||||
| 'expressionist'
|
||||
| 'cubist-atelier'
|
||||
| 'futurist'
|
||||
| 'suprematist'
|
||||
| 'constructivist'
|
||||
| 'dada'
|
||||
| 'surrealist'
|
||||
| 'loft'
|
||||
| 'white-cube';
|
||||
|
||||
function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] {
|
||||
return specs;
|
||||
}
|
||||
@@ -99,9 +132,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Roman atrium',
|
||||
'Marble-clad villa · mosaic floor · clerestory daylight',
|
||||
{ wall: 'limestone', ceiling: 'plaster-warm', floor: 'mosaic-roman' },
|
||||
{ wall: '#e8e0d0', ceiling: '#f5f0e8', floor: '#c8b898', trim: '#a89878' },
|
||||
{ wall: '#8e8980', ceiling: '#9c9994', floor: '#c8b898', trim: '#a89878' },
|
||||
{
|
||||
details: 'classical',
|
||||
details: 'roman',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#5a4830',
|
||||
ambient: 0.62,
|
||||
warmLight: '#fff8e8',
|
||||
@@ -175,9 +211,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Florentine palazzo',
|
||||
'Lime-washed walls · terracotta accents · arched windows',
|
||||
{ wall: 'painted-lime', wallSide: 'stucco-cream', ceiling: 'fresco-worn', floor: 'terracotta-tiles' },
|
||||
{ wall: '#f4ebe0', ceiling: '#faf6ee', floor: '#c89070', trim: '#c9a227' },
|
||||
{ wall: '#89847e', ceiling: '#96948f', floor: '#c89070', trim: '#c9a227' },
|
||||
{
|
||||
details: 'palazzo',
|
||||
details: 'quattrocento',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#6b4423',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: -2.2, y: 2.6, width: 1.3, height: 1.7, style: 'roman-arch', lightColor: '#fff4e0', lightIntensity: 3.0 },
|
||||
@@ -191,9 +230,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Roman palazzo',
|
||||
'Marble and stucco · coffered ceiling · checker floor',
|
||||
{ wall: 'marble-veined-carrara', wallSide: 'stucco-cream', ceiling: 'plaster-warm', floor: 'marble-checker' },
|
||||
{ wall: '#f0ece4', wallSide: '#efe4d4', ceiling: '#faf6ee', floor: '#ddd8cc', trim: '#c9a227' },
|
||||
{ wall: '#918f8a', wallSide: '#8f8980', ceiling: '#9c9994', floor: '#ddd8cc', trim: '#c9a227' },
|
||||
{
|
||||
details: 'palazzo',
|
||||
details: 'cinquecento',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#6b4423',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.8, width: 2.8, height: 1.8, style: 'baroque-pair', lightColor: '#fff8f0', lightIntensity: 3.8 },
|
||||
@@ -208,9 +250,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Flemish panel hall',
|
||||
'Oak wainscoting · leaded glass · herringbone floor',
|
||||
{ wall: 'oak-panel', wallSide: 'plaster-warm', ceiling: 'dark-wood-panel', floor: 'parquet-herringbone' },
|
||||
{ wall: '#8a6848', wallSide: '#f0ebe3', ceiling: '#3a2818', floor: '#8a6848', trim: '#5c4030' },
|
||||
{ wall: '#8a6848', wallSide: '#8c8985', ceiling: '#3a2818', floor: '#8a6848', trim: '#5c4030' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'flemish',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#f0e8d8',
|
||||
warmLight: '#ffe8c8',
|
||||
windows: windows(
|
||||
@@ -226,9 +271,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Mannerist gallery',
|
||||
'Dramatic stucco · elongated windows · veined marble',
|
||||
{ wall: 'stucco-terracotta', ceiling: 'gilded-stucco', floor: 'marble-veined-emerald' },
|
||||
{ wall: '#d8b898', ceiling: '#d8c070', floor: '#dce8e0', trim: '#b8860b' },
|
||||
{ wall: '#98816b', ceiling: '#a79456', floor: '#dce8e0', trim: '#b8860b' },
|
||||
{
|
||||
details: 'baroque',
|
||||
details: 'mannerist',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#4a2818',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: -1.8, y: 2.7, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#fff0d8', lightIntensity: 2.8 },
|
||||
@@ -264,9 +312,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Rococo salon',
|
||||
'Pastel painted walls · gilt trim · chevron parquet',
|
||||
{ wall: 'painted-oil-satin', ceiling: 'silk-pale', floor: 'parquet-chevrons' },
|
||||
{ wall: '#e8dce8', ceiling: '#faf6f0', floor: '#c8a882', trim: '#d4af37' },
|
||||
{ wall: '#918a91', ceiling: '#9b9995', floor: '#c8a882', trim: '#d4af37' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'rococo',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#6a4858',
|
||||
warmLight: '#fff0f0',
|
||||
windows: windows(
|
||||
@@ -281,9 +332,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Neoclassical museum',
|
||||
'White painted walls · coffered ceiling · skylit salon',
|
||||
{ wall: 'painted-flat', ceiling: 'plaster-white', floor: 'marble-veined-carrara' },
|
||||
{ wall: '#f2f0ec', ceiling: '#fafafa', floor: '#eceae6', trim: '#b8b0a4' },
|
||||
{ wall: '#908f8d', ceiling: '#9e9e9e', floor: '#eceae6', trim: '#b8b0a4' },
|
||||
{
|
||||
details: 'neoclassical',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#4a4844',
|
||||
ambient: 0.65,
|
||||
windows: windows(
|
||||
@@ -301,7 +355,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
{ wall: 'walnut-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||
{ wall: '#5a4030', ceiling: '#2a2420', floor: '#6a5040', trim: '#8a7050' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'gothic-revival',
|
||||
titleColor: '#e8dcc8',
|
||||
ambient: 0.5,
|
||||
warmLight: '#ffe0c0',
|
||||
@@ -317,9 +371,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Realist picture gallery',
|
||||
'Warm painted walls · bourgeois salon · oak parquet',
|
||||
{ wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e8e2d8', ceiling: '#f5f0e8', floor: '#a08060', trim: '#8a7050' },
|
||||
{ wall: '#88847e', ceiling: '#97948f', floor: '#a08060', trim: '#8a7050' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'bourgeois',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.5, width: 2.4, height: 1.6, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 3.2 },
|
||||
{ wall: 'left', x: 0, y: 2.7, width: 1.6, height: 1.3, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.4 }
|
||||
@@ -331,9 +388,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Impressionist salon',
|
||||
'North-light skylight · pale painted walls · herringbone floor',
|
||||
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
|
||||
{ wall: '#f0ebe3', ceiling: '#fafafa', floor: '#c8a882', trim: '#c9a96e' },
|
||||
{ wall: '#8f8c87', ceiling: '#9e9e9e', floor: '#c8a882', trim: '#c9a96e' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'north-light',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
ambient: 0.68,
|
||||
windows: windows(
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 5.0, height: 3.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 6.0 },
|
||||
@@ -348,9 +408,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Montmartre atelier',
|
||||
'Painted studio walls · large north window · worn floorboards',
|
||||
{ wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e8e0d0', ceiling: '#f0ebe0', floor: '#9a7858', trim: '#8a6848' },
|
||||
{ wall: '#847f76', ceiling: '#918f88', floor: '#9a7858', trim: '#8a6848' },
|
||||
{
|
||||
details: 'atelier',
|
||||
details: 'paris-atelier',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
windows: windows(
|
||||
{ wall: 'left', x: 0, y: 2.4, width: 2.8, height: 2.0, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 4.5 },
|
||||
{ wall: 'back', x: 0, y: 2.8, width: 1.0, height: 1.2, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.0 }
|
||||
@@ -364,7 +427,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||
{ wall: '#1a4030', ceiling: '#1a1814', floor: '#4a3828', trim: '#8a7050' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'symbolist',
|
||||
titleColor: '#d8e8c8',
|
||||
ambient: 0.55,
|
||||
warmLight: '#ffd898',
|
||||
@@ -380,9 +443,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Art Nouveau salon',
|
||||
'Painted plaster · stained glass · terrazzo floor',
|
||||
{ wall: 'painted-oil-satin', ceiling: 'silk-gold', floor: 'terrazzo' },
|
||||
{ wall: '#f0ece4', ceiling: '#d8c890', floor: '#d8d0c4', trim: '#6a9868' },
|
||||
{ wall: '#8f8c87', ceiling: '#a0946b', floor: '#d8d0c4', trim: '#6a9868' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'art-nouveau',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#3a5840',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.5, width: 2.2, height: 2.0, style: 'art-nouveau', lightColor: '#e8ffe8', lightIntensity: 3.2 },
|
||||
@@ -396,9 +462,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Fauvist studio',
|
||||
'Bold painted walls · flooded with color and light',
|
||||
{ wall: 'painted-oil-matte', wallSide: 'painted-oil-matte', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e89060', wallSide: '#e8c860', ceiling: '#f8f0e0', floor: '#a07048', trim: '#c04020' },
|
||||
{ wall: '#e89060', wallSide: '#e8c860', ceiling: '#9e998e', floor: '#a07048', trim: '#c04020' },
|
||||
{
|
||||
details: 'atelier',
|
||||
details: 'fauvist',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#402010',
|
||||
ambient: 0.7,
|
||||
windows: windows(
|
||||
@@ -414,7 +483,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
{ wall: 'dark-wood-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||
{ wall: '#3a2818', ceiling: '#2a2018', floor: '#5a4030', trim: '#8a6040' },
|
||||
{
|
||||
details: 'atelier',
|
||||
details: 'expressionist',
|
||||
titleColor: '#f0d8b0',
|
||||
ambient: 0.52,
|
||||
windows: windows(
|
||||
@@ -428,9 +497,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Cubist studio',
|
||||
'Paris atelier · factory windows · painted plaster',
|
||||
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e8e4dc', ceiling: '#f5f5f5', floor: '#9a8870', trim: '#888888' },
|
||||
{ wall: '#8c8a85', ceiling: '#999999', floor: '#9a8870', trim: '#888888' },
|
||||
{
|
||||
details: 'atelier',
|
||||
details: 'cubist-atelier',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
ambient: 0.65,
|
||||
windows: windows(
|
||||
{ wall: 'left', x: 0, y: 2.5, width: 3.5, height: 2.4, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 5.5 },
|
||||
@@ -443,9 +515,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Futurist loft',
|
||||
'Steel and glass · industrial concrete · sweeping daylight',
|
||||
{ wall: 'concrete-raw', ceiling: 'concrete-raw', floor: 'industrial-floor' },
|
||||
{ wall: '#a8a8a8', ceiling: '#989898', floor: '#888880', trim: '#606060' },
|
||||
{ wall: '#858585', ceiling: '#7a7a7a', floor: '#888880', trim: '#606060' },
|
||||
{
|
||||
details: 'industrial',
|
||||
details: 'futurist',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#303030',
|
||||
ambient: 0.62,
|
||||
fog: '#181818',
|
||||
@@ -462,9 +537,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Suprematist white cube',
|
||||
'Matte white paint · geometric light · polished floor',
|
||||
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
|
||||
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#e0e0e0', trim: '#cccccc' },
|
||||
{ wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#e0e0e0', trim: '#cccccc' },
|
||||
{
|
||||
details: 'modern',
|
||||
details: 'suprematist',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#222222',
|
||||
ambient: 0.72,
|
||||
fog: '#1a1a1a',
|
||||
@@ -482,9 +560,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Constructivist space',
|
||||
'Glass block · concrete · angular daylight',
|
||||
{ wall: 'concrete-block', ceiling: 'concrete-raw', floor: 'concrete-polished' },
|
||||
{ wall: '#b0b0b0', ceiling: '#a0a0a0', floor: '#c0c0c0', trim: '#808080' },
|
||||
{ wall: '#858585', ceiling: '#7a7a7a', floor: '#c0c0c0', trim: '#808080' },
|
||||
{
|
||||
details: 'industrial',
|
||||
details: 'constructivist',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#303030',
|
||||
ambient: 0.65,
|
||||
windows: windows(
|
||||
@@ -500,9 +581,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Dada salon',
|
||||
'Eclectic bourgeois room · painted walls and exposed brick',
|
||||
{ wall: 'painted-emulsion', wallSide: 'brick', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e8dcc8', wallSide: '#8a5040', ceiling: '#f0ebe0', floor: '#8a6848', trim: '#6a4830' },
|
||||
{ wall: '#8b8478', wallSide: '#8a5040', ceiling: '#97948d', floor: '#8a6848', trim: '#6a4830' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'dada',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
windows: windows(
|
||||
{ wall: 'back', x: -1.5, y: 2.5, width: 1.0, height: 1.4, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.5 },
|
||||
{ wall: 'back', x: 1.5, y: 2.6, width: 0.8, height: 1.8, style: 'gothic-lancet', lightColor: '#ffe8c0', lightIntensity: 2.0 },
|
||||
@@ -518,7 +602,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||
{ wall: '#1a2848', ceiling: '#2a2428', floor: '#5a4838', trim: '#8a7050' },
|
||||
{
|
||||
details: 'salon',
|
||||
details: 'surrealist',
|
||||
titleColor: '#e8dcc8',
|
||||
ambient: 0.58,
|
||||
warmLight: '#ffd898',
|
||||
@@ -535,9 +619,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'NYC loft',
|
||||
'Raw brick and painted plaster · north-light factory windows',
|
||||
{ wall: 'brick', wallSide: 'painted-flat', ceiling: 'concrete-raw', floor: 'industrial-floor' },
|
||||
{ wall: '#8a5040', wallSide: '#f5f5f5', ceiling: '#989898', floor: '#888880', trim: '#606060' },
|
||||
{ wall: '#8a5040', wallSide: '#858585', ceiling: '#989898', floor: '#888880', trim: '#606060' },
|
||||
{
|
||||
details: 'industrial',
|
||||
details: 'loft',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#303030',
|
||||
ambient: 0.65,
|
||||
windows: windows(
|
||||
@@ -551,9 +638,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
'Pop Art gallery',
|
||||
'White painted walls · fluorescent daylight · polished concrete',
|
||||
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
|
||||
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#d8d8d8', trim: '#cccccc' },
|
||||
{ wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#d8d8d8', trim: '#cccccc' },
|
||||
{
|
||||
details: 'modern',
|
||||
details: 'white-cube',
|
||||
// Lit like the basilica: shared lights held back so the walls read as
|
||||
// surfaces rather than blowing out. Window shafts are not scaled.
|
||||
lightScale: 0.3,
|
||||
titleColor: '#222222',
|
||||
ambient: 0.75,
|
||||
warmLight: '#ffffff',
|
||||
|
||||
@@ -21,6 +21,12 @@
|
||||
"audit": "Activity",
|
||||
"layoutHorizontal": "Classic timeline →",
|
||||
"layoutVertical": "↑ Vertical timeline",
|
||||
"layoutTree": "🌳 Tree of art",
|
||||
"captionClassicTimeline": "Click an era or event to zoom · Scroll to zoom · Drag to pan",
|
||||
"captionClassicFlow": "Scroll to zoom · drag to pan · each movement stream is a solid colour band through history",
|
||||
"captionVerticalTimeline": "Bottom → top · Scroll to zoom · Drag to pan",
|
||||
"captionVerticalFlow": "Bottom → top through history · scroll to zoom · drag to pan · click a stream",
|
||||
"captionTreeFlow": "Roots at the bottom, living movements at the crown · scroll to zoom · drag to pan · click a branch to enter its gallery",
|
||||
"openingTourGallery": "Opening guided tour…",
|
||||
"tourEmpty": "This tour has no paintings yet.",
|
||||
"tourLoadFailed": "Failed to load the tour.",
|
||||
|
||||
@@ -21,6 +21,12 @@
|
||||
"audit": "Активность",
|
||||
"layoutHorizontal": "Классическая шкала →",
|
||||
"layoutVertical": "↑ Вертикальная шкала",
|
||||
"layoutTree": "🌳 Древо искусства",
|
||||
"captionClassicTimeline": "Щёлкните эпоху или событие для приближения · Колесо — масштаб · Перетащите для панорамы",
|
||||
"captionClassicFlow": "Колесо — масштаб · перетащите для панорамы · каждое направление — цветная полоса сквозь историю",
|
||||
"captionVerticalTimeline": "Снизу вверх · Колесо — масштаб · Перетащите для панорамы",
|
||||
"captionVerticalFlow": "Снизу вверх по истории · колесо — масштаб · перетащите для панорамы · щёлкните поток",
|
||||
"captionTreeFlow": "Корни внизу, живые направления в кроне · колесо — масштаб · перетащите для панорамы · щёлкните ветвь, чтобы войти в зал",
|
||||
"openingTourGallery": "Открытие экскурсии…",
|
||||
"tourEmpty": "В этой экскурсии пока нет картин.",
|
||||
"tourLoadFailed": "Не удалось загрузить экскурсию.",
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
z-index: 120;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.site-layout-link {
|
||||
@@ -63,6 +66,17 @@
|
||||
color: #f5e6c8;
|
||||
}
|
||||
|
||||
/* Link to the alternative tree start page — the one worth noticing. */
|
||||
.site-layout-link-feature {
|
||||
border-color: rgba(201, 169, 110, 0.7);
|
||||
background: rgba(201, 169, 110, 0.16);
|
||||
color: #f5e6c8;
|
||||
}
|
||||
|
||||
.site-layout-link-feature:hover {
|
||||
background: rgba(201, 169, 110, 0.3);
|
||||
}
|
||||
|
||||
.gallery-session-suspended {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -5,6 +5,7 @@ import TimelineEventGuides from '../components/TimelineEventGuides';
|
||||
import MovementBands from '../components/MovementBands';
|
||||
import VerticalTimeline from '../components/VerticalTimeline';
|
||||
import VerticalMovementBands from '../components/VerticalMovementBands';
|
||||
import MovementTree from '../components/MovementTree';
|
||||
import VirtualGallery from '../components/VirtualGallery';
|
||||
import PaintingDetailView from '../components/PaintingDetail';
|
||||
import ArtistBio from '../components/ArtistBio';
|
||||
@@ -50,6 +51,7 @@ import './HomePage.css';
|
||||
type View =
|
||||
| { type: 'timeline' }
|
||||
| { type: 'timeline-vertical' }
|
||||
| { type: 'timeline-tree' }
|
||||
| { type: 'checkup' }
|
||||
| { type: 'translations' }
|
||||
| { type: 'influences' }
|
||||
@@ -157,6 +159,37 @@ function catalogNavigateTarget(
|
||||
return idx < remaining.length ? remaining[idx].id : remaining[remaining.length - 1].id;
|
||||
}
|
||||
|
||||
/** Shareable timeline layout via `?layout=classic|vertical|tree` (classic may omit the param). */
|
||||
type TimelineLayoutId = 'classic' | 'vertical' | 'tree';
|
||||
|
||||
function parseTimelineLayoutParam(raw: string | null): TimelineLayoutId {
|
||||
if (raw === 'vertical' || raw === 'tree') return raw;
|
||||
if (raw === 'classic' || raw === 'horizontal') return 'classic';
|
||||
return 'classic';
|
||||
}
|
||||
|
||||
function timelineViewFromLayout(layout: TimelineLayoutId): View {
|
||||
if (layout === 'vertical') return { type: 'timeline-vertical' };
|
||||
if (layout === 'tree') return { type: 'timeline-tree' };
|
||||
return { type: 'timeline' };
|
||||
}
|
||||
|
||||
function writeTimelineLayoutParam(layout: TimelineLayoutId) {
|
||||
const url = new URL(window.location.href);
|
||||
if (layout === 'classic') url.searchParams.delete('layout');
|
||||
else url.searchParams.set('layout', layout);
|
||||
const next = `${url.pathname}${url.search}${url.hash}`;
|
||||
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
if (next !== current) window.history.replaceState(null, '', next);
|
||||
}
|
||||
|
||||
function readInitialTimelineView(): View {
|
||||
if (typeof window === 'undefined') return { type: 'timeline' };
|
||||
return timelineViewFromLayout(
|
||||
parseTimelineLayoutParam(new URLSearchParams(window.location.search).get('layout'))
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const { t } = useTranslation('home');
|
||||
const { isCurator, isAdmin, username, login, logout, can } = useAuth();
|
||||
@@ -167,7 +200,7 @@ export default function HomePage() {
|
||||
const canInfluences = can('influences');
|
||||
const canTours = can('tours');
|
||||
const canUsers = can('users');
|
||||
const [view, setView] = useState<View>({ type: 'timeline' });
|
||||
const [view, setView] = useState<View>(readInitialTimelineView);
|
||||
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
|
||||
const [bounds, setBounds] = useState({ min: -800, max: 2025 });
|
||||
const [viewStart, setViewStart] = useState(-800);
|
||||
@@ -210,7 +243,11 @@ export default function HomePage() {
|
||||
setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data });
|
||||
} else if (view.type === 'tour-gallery') {
|
||||
setGallerySession({ kind: 'tour', tourId: view.tourId, data: view.data });
|
||||
} else if (view.type === 'timeline' || view.type === 'timeline-vertical') {
|
||||
} else if (
|
||||
view.type === 'timeline' ||
|
||||
view.type === 'timeline-vertical' ||
|
||||
view.type === 'timeline-tree'
|
||||
) {
|
||||
setGallerySession(null);
|
||||
}
|
||||
}, [view]);
|
||||
@@ -271,19 +308,33 @@ export default function HomePage() {
|
||||
setViewStart(bounds.min);
|
||||
setViewEnd(bounds.max);
|
||||
setGalleryRevision((revision) => revision + 1);
|
||||
writeTimelineLayoutParam('classic');
|
||||
setView({ type: 'timeline' });
|
||||
}, [bounds.min, bounds.max]);
|
||||
|
||||
const openHorizontalTimeline = () => {
|
||||
writeTimelineLayoutParam('classic');
|
||||
setView({ type: 'timeline' });
|
||||
};
|
||||
|
||||
const openVerticalTimeline = () => {
|
||||
writeTimelineLayoutParam('vertical');
|
||||
setView({ type: 'timeline-vertical' });
|
||||
};
|
||||
|
||||
const 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 isTreeTimeline = view.type === 'timeline-tree';
|
||||
/** Both alternative layouts run the year axis bottom → top beside the chart. */
|
||||
const isVerticalLayout = isVerticalTimeline || isTreeTimeline;
|
||||
|
||||
const toggleDebugMode = () => {
|
||||
setDebugMode((prev) => {
|
||||
@@ -1222,16 +1273,17 @@ export default function HomePage() {
|
||||
<div className="home-page">
|
||||
<header className="site-header">
|
||||
<div className="site-layout-switch">
|
||||
{isVerticalTimeline ? (
|
||||
{!isTreeTimeline && (
|
||||
<button
|
||||
type="button"
|
||||
className="site-layout-link"
|
||||
onClick={openHorizontalTimeline}
|
||||
title="Switch to classic left-to-right timeline"
|
||||
className="site-layout-link site-layout-link-feature"
|
||||
onClick={openTreeTimeline}
|
||||
title="Open the alternative start page: bottom-up timeline with movements drawn as a growing tree"
|
||||
>
|
||||
{t('layoutHorizontal')}
|
||||
{t('layoutTree')}
|
||||
</button>
|
||||
) : (
|
||||
)}
|
||||
{!isVerticalTimeline && (
|
||||
<button
|
||||
type="button"
|
||||
className="site-layout-link"
|
||||
@@ -1241,6 +1293,16 @@ export default function HomePage() {
|
||||
{t('layoutVertical')}
|
||||
</button>
|
||||
)}
|
||||
{!(view.type === 'timeline') && (
|
||||
<button
|
||||
type="button"
|
||||
className="site-layout-link"
|
||||
onClick={openHorizontalTimeline}
|
||||
title="Switch to classic left-to-right timeline"
|
||||
>
|
||||
{t('layoutHorizontal')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="site-dev-tools">
|
||||
{isCurator ? (
|
||||
@@ -1369,7 +1431,7 @@ export default function HomePage() {
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div className={`home-timeline-stack${isVerticalTimeline ? ' home-timeline-stack-vertical' : ''}`}>
|
||||
<div className={`home-timeline-stack${isVerticalLayout ? ' home-timeline-stack-vertical' : ''}`}>
|
||||
{loading && (
|
||||
<GalleryLoadingMarker overlay message="Loading art history…" />
|
||||
)}
|
||||
@@ -1380,7 +1442,7 @@ export default function HomePage() {
|
||||
<GalleryLoadingMarker banner message="Loading portraits…" />
|
||||
)}
|
||||
|
||||
{isVerticalTimeline ? (
|
||||
{isVerticalLayout ? (
|
||||
<>
|
||||
<VerticalTimeline
|
||||
eras={timelineData.eras}
|
||||
@@ -1394,6 +1456,17 @@ export default function HomePage() {
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{!loading && (
|
||||
<div className="home-movements-section-vertical">
|
||||
{isTreeTimeline ? (
|
||||
<MovementTree
|
||||
movements={timelineData.movements}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
absoluteMin={bounds.min}
|
||||
absoluteMax={bounds.max}
|
||||
onViewChange={handleViewChange}
|
||||
onMovementClick={handleMovementClick}
|
||||
/>
|
||||
) : (
|
||||
<VerticalMovementBands
|
||||
movements={timelineData.movements}
|
||||
viewStart={viewStart}
|
||||
@@ -1403,6 +1476,7 @@ export default function HomePage() {
|
||||
onViewChange={handleViewChange}
|
||||
onMovementClick={handleMovementClick}
|
||||
/>
|
||||
)}
|
||||
</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