Add movement gallery wings with period interiors and expand timeline features.
Movement galleries split large catalogs into chronological wings (~55 works), use era-themed 3D interiors with side-wall windows, wing navigator on the back exit, and front archways between wings. Also adds painting annotations, timeline event guides, portrait hover highlights, and documentation/API updates. Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -76,6 +76,54 @@ Artists belonging to a single movement.
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/movements/:id/gallery`
|
||||
|
||||
Full **movement gallery** payload for the 3D movement wings view (opened by clicking a movement name on the home page).
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"movement": {
|
||||
"id": 34,
|
||||
"name": "Baroque",
|
||||
"start_year": 1600,
|
||||
"end_year": 1750,
|
||||
"era_id": 6,
|
||||
"era_name": "Baroque",
|
||||
"color": "#8B4513",
|
||||
...
|
||||
},
|
||||
"paintings": [
|
||||
{
|
||||
"id": 120,
|
||||
"title": "...",
|
||||
"year": 1640,
|
||||
"artist_id": 5,
|
||||
"artist_name": "Rembrandt",
|
||||
"image_path": "paintings/...",
|
||||
"thumbnail_path": "paintings/thumbs/...",
|
||||
"checkup_checked": false,
|
||||
"checkup_fixed": false,
|
||||
"has_influence_links": true,
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `movement.era_name` | Joined from `historical_eras` — used to pick period interior styling |
|
||||
| `paintings[].artist_name` | Artist display name for frame captions (`year · artist`) |
|
||||
| `paintings` order | Chronological: `year`, then `sort_order`, artist birth year, title |
|
||||
|
||||
Includes all paintings whose `artist.movement_id` matches `:id`. Returns **404** if the movement does not exist.
|
||||
|
||||
**Client:** `api.getMovementGallery(id)` in `client/src/api/client.ts`; rendered by `VirtualGallery` in `mode: 'movement'`.
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/artists/:id`
|
||||
|
||||
Full artist profile for the bio page and 3D gallery entry.
|
||||
@@ -152,11 +200,13 @@ Up to 20 ranked portrait candidates for the **More** picker modal.
|
||||
"searchUrl": "https://…",
|
||||
"source": "google-custom-search",
|
||||
"results": [
|
||||
{ "imageUrl": "https://…", "thumbUrl": "https://…", "source": "google-custom-search" }
|
||||
{ "imageUrl": "https://…", "thumbUrl": "https://…", "source": "google-custom-search", "width": 1200, "height": 1600 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each result may include optional `width` and `height` (pixels). The **More** modal shows these under each thumbnail; when missing, the client probes dimensions via `GET /api/debug/image-proxy`.
|
||||
|
||||
---
|
||||
|
||||
## `POST /api/artists/:id/fix-portrait`
|
||||
@@ -206,7 +256,7 @@ Upload a local image (base64 JSON body). Validates with `sharp`, resizes to port
|
||||
}
|
||||
```
|
||||
|
||||
Max size 15 MB. **Response** — same as `fix-portrait`.
|
||||
Max decoded size 15 MB (JSON body limit 20 MB on the server). **Response** — same as `fix-portrait`.
|
||||
|
||||
---
|
||||
|
||||
@@ -286,7 +336,22 @@ Painting detail with influence graph neighbours.
|
||||
"period_start_year": 1905, "period_end_year": 1907
|
||||
}
|
||||
],
|
||||
"influenced": [ { "source_type": "painting", "id": 20, "title": "...", ... } ]
|
||||
"influenced": [ { "source_type": "painting", "id": 20, "title": "...", ... } ],
|
||||
"annotations": [
|
||||
{
|
||||
"id": 1,
|
||||
"label": "Central figure",
|
||||
"body": "Short art-history note…",
|
||||
"category": "subject",
|
||||
"pos_x": 42.5,
|
||||
"pos_y": 38.0,
|
||||
"source_author": "E.H. Gombrich",
|
||||
"source": "The Story of Art",
|
||||
"source_url": "https://…",
|
||||
"sort_order": 0,
|
||||
"confidence": "curated"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -427,11 +492,13 @@ Up to 20 ranked painting image candidates for the **More** picker modal.
|
||||
"searchUrl": "https://…",
|
||||
"source": "google-arts",
|
||||
"results": [
|
||||
{ "imageUrl": "https://…", "thumbUrl": "https://…", "source": "google-arts" }
|
||||
{ "imageUrl": "https://…", "thumbUrl": "https://…", "source": "google-arts", "width": 2400, "height": 1800 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Optional `width` / `height` on each result — see portrait **more** endpoint above.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/paintings/:id/clear-image`
|
||||
@@ -464,7 +531,7 @@ Upload a local painting image (base64 JSON body). Validates with `sharp`, writes
|
||||
}
|
||||
```
|
||||
|
||||
Max size 15 MB. **Response** — same shape as `fix-image` (`imagePath`, `thumbnailPath`, `fixed`, `checked`).
|
||||
Max decoded size 15 MB (JSON body limit 20 MB on the server). **Response** — same shape as `fix-image` (`imagePath`, `thumbnailPath`, `fixed`, `checked`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -109,6 +109,26 @@ When `checkup_fixed` is true, `checkup_checked` is set automatically and cannot
|
||||
|
||||
Applied by `npm run migrate:checkup-flags` (`db/migrate-checkup-flags.sql`).
|
||||
|
||||
### `painting_annotations`
|
||||
|
||||
Short art-history notes shown on painting detail (`PaintingAnnotations.tsx`).
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | SERIAL PK | |
|
||||
| `painting_id` | FK → `paintings` | ON DELETE CASCADE |
|
||||
| `label` | VARCHAR(80) | Optional short heading (e.g. figure name) |
|
||||
| `body` | TEXT | Note text |
|
||||
| `category` | VARCHAR(30) | Default `subject`; also `technique`, `context`, `symbolism`, etc. |
|
||||
| `pos_x`, `pos_y` | NUMERIC(5,2) | Optional marker position on image (percent 0–100) |
|
||||
| `source_author` | VARCHAR(200) | e.g. Gombrich, Met catalog |
|
||||
| `source` | VARCHAR(500) | Citation label |
|
||||
| `source_url` | VARCHAR(500) | Reference link |
|
||||
| `sort_order` | INTEGER | Display order within the painting |
|
||||
| `confidence` | VARCHAR(20) | Default `curated`; Wikipedia pass uses `wikipedia` |
|
||||
|
||||
Applied by `npm run migrate:painting-annotations` (`db/migrate-painting-annotations.sql`). Load data with `npm run update-painting-annotations` (curated entries in `scripts/painting-annotations-data.js`; add `--wikipedia` for intro sentences from each work’s `wikipedia_title`).
|
||||
|
||||
### `painting_influences`
|
||||
|
||||
Directed edges: *this painting* was influenced by *that painting*.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Art Gallery — architecture basics
|
||||
|
||||
Interactive virtual museum spanning art history: zoomable timeline, branching movement flow, 3D gallery halls, and painting influence graphs. See [README.md](../README.md) for quick start.
|
||||
Interactive virtual museum spanning art history: zoomable timeline with event guides, branching movement flow, 3D gallery halls, painting influence graphs, and art-history annotations on detail pages. See [README.md](../README.md) for quick start.
|
||||
|
||||
## Concept
|
||||
|
||||
@@ -8,8 +8,8 @@ The app is organised as a **drill-down hierarchy**:
|
||||
|
||||
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries.
|
||||
2. **Movement flow** — art movements as curved SVG streams on the same year axis; documented predecessor→successor branches; portrait thumbnails placed along each stream.
|
||||
3. **3D gallery** — one personal hall per artist; paintings on the walls, open centre, single exit for influence-based navigation.
|
||||
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), prev/next catalog browsing, optional fullscreen, link to artist biography.
|
||||
3. **3D gallery** — one personal hall per artist *or* a **movement gallery** (click a movement name on the flow diagram): period-themed interiors, chronological wings of up to ~55 works, side-wall hang only.
|
||||
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **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**).
|
||||
|
||||
All artwork images are stored locally under `data/images/` — the UI never hot-links to Wikipedia or Commons at runtime (except optional on-demand fetch when a file is missing).
|
||||
@@ -31,11 +31,20 @@ Gallery/
|
||||
├── server/ # Express API, DB pool, image service
|
||||
├── client/ # React/Vite frontend
|
||||
│ ├── src/ # Source (components, pages, 3D scene)
|
||||
│ │ ├── components/VirtualGallery.tsx # 3D hall (parquet, frames, museum exit)
|
||||
│ │ ├── components/VirtualGallery.tsx # 3D hall (artist + movement modes)
|
||||
│ │ ├── components/GalleryWindows.tsx # Side-wall daylight windows (movement)
|
||||
│ │ ├── components/HallPassage.tsx # Open archway between movement wings
|
||||
│ │ ├── components/MovementHallDetails.tsx # Period architectural details
|
||||
│ │ ├── 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/TimelineEventGuides.tsx # Event vertical guides into movement flow
|
||||
│ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches)
|
||||
│ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail
|
||||
│ │ ├── pages/CheckupPage.tsx # Image audit table
|
||||
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
|
||||
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
|
||||
@@ -80,6 +89,14 @@ Use the Vite URL during frontend work for HMR.
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Home — timeline + movement flow] -->|scroll / drag / zoom| A
|
||||
A -->|click movement name| G[Movement gallery — 3D wings]
|
||||
G -->|click painting| D
|
||||
D -->|Back| G
|
||||
G -->|back door / Wings / Exit| H[Wing navigator]
|
||||
H -->|pick wing| G
|
||||
H -->|Exit to Timeline| A
|
||||
G -->|front arch / E| G
|
||||
G -->|Back to Timeline| A
|
||||
A -->|click portrait| B[Artist bio — Wikipedia text]
|
||||
B -->|Enter Gallery| C[Artist hall — 3D]
|
||||
C -->|click painting| D[Painting detail + influences]
|
||||
@@ -105,7 +122,11 @@ The home page shows two linked views over the **same year window** (`viewStart`
|
||||
| Era bar | `Timeline.tsx` | Historical eras, major event markers, click-to-zoom |
|
||||
| Movement flow | `MovementBands.tsx` | Curved streams per movement, lineage branches, artist portraits |
|
||||
|
||||
Both views share zoom/pan behaviour via `client/src/utils/timelineView.ts`. The home page uses a **fixed viewport** (`100vh`): the movement flow compresses vertically so all movements in the visible year range fit without page scrolling.
|
||||
Both views share zoom/pan behaviour via `client/src/utils/timelineView.ts`. The home page uses a **fixed viewport** (`100vh`): timeline + movement flow sit in a shared `home-timeline-stack` so event guide lines can extend from the era bar down through the movement canvas. The movement flow compresses vertically so all movements in the visible year range fit without page scrolling.
|
||||
|
||||
### Timeline year labels
|
||||
|
||||
Year ticks along the bottom of the era bar use **large, high-contrast** labels (bold cream text with shadow). The active range in the control row (e.g. `1400 CE — 1900 CE`) uses the same stronger styling.
|
||||
|
||||
### Timeline controls
|
||||
|
||||
@@ -122,6 +143,8 @@ Both views share zoom/pan behaviour via `client/src/utils/timelineView.ts`. The
|
||||
|
||||
Major world events appear on the era bar as pin markers (single years) or shaded spans (e.g. World War I, World War II). Data lives in `client/src/data/historical-events.ts` — not in PostgreSQL. Labels appear when zoomed in enough; tooltips always show name and dates. Edit `HISTORICAL_EVENTS` and rebuild the client to add or change markers.
|
||||
|
||||
**Vertical guides:** `TimelineEventGuides.tsx` draws faint gold lines (point events) or shaded bands (spans) from the marker row **down through the movement flow**, aligned to the same year scale. Guides are visual only (`pointer-events: none`); click-to-zoom stays on the markers in `Timeline.tsx`.
|
||||
|
||||
### Movement flow
|
||||
|
||||
Each visible movement is drawn as a **portrait-width curved stream** (~54 px stroke, compressed when many rows are visible) from its start year to its end year.
|
||||
@@ -154,13 +177,18 @@ Each artist appears as a **portrait circle** on their movement’s stream row:
|
||||
| Scroll wheel on flow canvas | Zoom (same range as timeline) |
|
||||
| Drag on flow canvas | Pan |
|
||||
| Click portrait | Open artist biography |
|
||||
| Click **movement name** (label on stream) | Open **movement gallery** for that movement |
|
||||
|
||||
Artist portraits stop wheel/drag propagation so zooming over a face does not fight portrait clicks.
|
||||
Artist portraits stop wheel/drag propagation so zooming over a face does not fight portrait clicks. Hovering a portrait highlights the artist’s lifespan on the era bar and brightens their segment on the movement stream.
|
||||
|
||||
**Note:** Movement lineage is **frontend curation** for layout and labels — it is not stored in PostgreSQL. Painting influence links (`painting_influence_sources`, plus legacy `painting_influences` for hall navigation) are separate and drive the 3D exit picker and detail panels.
|
||||
|
||||
## Virtual gallery (3D halls)
|
||||
|
||||
The 3D scene supports two modes in `VirtualGallery.tsx`: **artist halls** (personal catalog) and **movement galleries** (full movement collection, chronological).
|
||||
|
||||
### Artist halls
|
||||
|
||||
Each artist has **exactly one hall**. The hall is a rectangular room sized to fit their catalog:
|
||||
|
||||
| Rule | Implementation |
|
||||
@@ -182,7 +210,7 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi
|
||||
| Detail view return | Opening a painting close-up **keeps the 3D hall mounted** in the background so position and view direction are preserved when you go back |
|
||||
| After image fix | Debug **Fix it** updates the gallery session, busts texture cache (`?v=N`), and returns to the hall with the new image and gold frame |
|
||||
|
||||
**Controls:**
|
||||
**Controls (artist hall):**
|
||||
|
||||
| Input | Action |
|
||||
|-------|--------|
|
||||
@@ -196,15 +224,50 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi
|
||||
|
||||
Predecessors and successors come from the **painting influence graph** (`painting_influences` → other artists). Empty lists mean no influence edges are recorded yet for that artist — run `npm run update-influences` or extend seed data.
|
||||
|
||||
**3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; call `POST /api/artists/:id/preload-images` before entering a hall to link disk files. While a texture is loading, the frame shows the canvas cover instead of a white placeholder.
|
||||
### Movement galleries
|
||||
|
||||
Enter from the home page by clicking a **movement name** on the movement flow (`MovementBands.tsx` → `GET /api/movements/:id/gallery`).
|
||||
|
||||
| Rule | Implementation |
|
||||
|------|----------------|
|
||||
| One gallery per movement | All paintings by artists in that movement, sorted chronologically |
|
||||
| Wings | Catalog split into wings of up to **55 works** (`movementHallLayout.ts`); large movements (e.g. Baroque) use multiple wings |
|
||||
| Paintings on walls | **Left and right walls only** — back wall reserved for exit, front for passage to the next wing |
|
||||
| Wall order | Along each side wall: **later works on the left**, **earlier on the right** (same convention as artist halls) |
|
||||
| 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, NYC loft, white cube, etc.) |
|
||||
| Textures | Hi-res procedural wall/floor/ceiling maps with normal maps (`galleryProceduralTextures.ts`) |
|
||||
| Windows | **Side walls only** — placed in gaps between frames (high on the wall, no overlap with paintings); style matches the movement era |
|
||||
| Lighting | Daylight from windows + ceiling track lights + ambient/sun fill |
|
||||
| Back wall | **Exit double doors** → **Wing navigator** (jump to any wing) or **Exit to Timeline** |
|
||||
| Front wall | Open **“Next wing →”** archway when a later wing exists; walk through or press `E` when near |
|
||||
| Influence lamps | Same golden lamps as artist halls when `has_influence_links` is true |
|
||||
| Missing images | Draped canvas cover in frame |
|
||||
| Detail return | Hall stays mounted; camera preserved on **Back to Timeline** / **Back to Gallery** |
|
||||
|
||||
**Controls (movement gallery):**
|
||||
|
||||
| Input | Action |
|
||||
|-------|--------|
|
||||
| Walk / turn / drag | Same as artist hall |
|
||||
| Click painting | Open detail view (returns to the same wing) |
|
||||
| Back wall / `E` / **Wings / Exit →** | Open wing navigator |
|
||||
| Front archway / `E` (when near) | Advance to the **next chronological wing** |
|
||||
|
||||
Movement galleries do **not** use the predecessor/successor influence picker — that remains artist-hall only.
|
||||
|
||||
### Shared 3D behaviour
|
||||
|
||||
**3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; call `POST /api/artists/:id/preload-images` before entering an **artist** hall to link disk files. Movement galleries load painting lists from the API without a separate preload step. While a texture is loading, the frame shows the canvas cover instead of a white placeholder.
|
||||
|
||||
## Painting detail view
|
||||
|
||||
Opened from the 3D hall (click a frame) or from influence thumbnails on another work’s detail page.
|
||||
Opened from the 3D hall (artist or movement wing — click a frame) or from influence thumbnails on another work’s detail page.
|
||||
|
||||
| Layer | What you see |
|
||||
|-------|----------------|
|
||||
| **Detail** | Centre image, *Influenced By* (left) and *Influenced* (right) — painting thumbnails, artist portraits, or movement swatches — position in catalog (e.g. `3 of 12`) |
|
||||
| **Art history notes** | Numbered markers on the image (when positioned) plus a note list below — short citations from Gombrich, museum catalogs, Wikipedia, etc. (`painting_annotations` table) |
|
||||
| **Fullscreen** | Click the centre image; `Escape` or click anywhere to return to detail only |
|
||||
|
||||
**Controls:**
|
||||
@@ -216,7 +279,7 @@ Opened from the 3D hall (click a frame) or from influence thumbnails on another
|
||||
| Click centre image | Open fullscreen lightbox |
|
||||
| Click influence thumbnail | Open that work’s detail (different artist allowed) |
|
||||
| Click influence artist portrait | Open that artist’s 3D gallery hall |
|
||||
| **← Back to Gallery** | Return to the hall you entered from — **3D camera position is preserved** |
|
||||
| **← Back to Gallery** / **← Back to Timeline** | Return to the hall or movement wing you entered from — **3D camera position is preserved** |
|
||||
| **About {artist}** | Open artist biography |
|
||||
|
||||
**Navigation rules:**
|
||||
@@ -235,6 +298,7 @@ When **Debug mode** is enabled from the home header, painting detail and artist
|
||||
- **Movement filtering** on zoom only shows movements that have at least one artist active in the visible year range.
|
||||
- **Movement lineage** (`movement-lineage.ts`) documents art-historical predecessor→successor links for the flow diagram; extend that file to add or correct branches.
|
||||
- **One hall per artist** keeps navigation predictable: enter from the timeline or bio, leave via the single exit or back button.
|
||||
- **Movement galleries** complement artist halls: full movement corpus in period-themed wings, entered from the flow diagram.
|
||||
- **Influence-based hall links** connect artists through documented painting relationships, grouped by movement at the exit.
|
||||
- **3D gallery images** use locally cached files only; slow remote fetches would break realtime rendering.
|
||||
- **Influence data** is stored as directed links from paintings to sources (another painting, an artist, or a movement), with optional period fields and citation metadata (source author, quote, URL).
|
||||
@@ -257,7 +321,7 @@ When debug mode is on, a panel at the bottom-left shows the image search query,
|
||||
|--------|--------|
|
||||
| **Checked** | Sets `checkup_checked` via `PATCH …/checkup-flags` (disabled once already reviewed) |
|
||||
| **Fix it** | Replaces the local image from the top search result (`POST …/fix-image` or `…/fix-portrait`); sets **Fixed** and **Reviewed** |
|
||||
| **More** | Opens a modal with up to **20** search results; click one to apply the same replace as **Fix it** |
|
||||
| **More** | Opens a modal with up to **20** search results (each shows image resolution when available); click one to apply the same replace as **Fix it** |
|
||||
| **Clear** | Deletes the local file(s), clears DB paths, leaves an **empty frame** (no placeholder); sets **Fixed** and **Reviewed** so on-demand fetch does not refill the slot |
|
||||
| **Upload** | File picker for a local image; saves to disk like **Fix it** (thumbnail generated for paintings; portrait resized for artists) |
|
||||
|
||||
@@ -269,7 +333,7 @@ After **Fix it**, **More**, **Upload**, or **Clear**, the main view, gallery tex
|
||||
|
||||
**Search visible** runs image search only for rows currently shown after text/filter — not automatically on page load. Fixing an image sets **Fixed** and **Reviewed**.
|
||||
|
||||
Run `npm run migrate:checkup-flags` and `npm run migrate:artist-checkup-flags` once on existing databases. After server code changes, restart `npm run dev` so new routes (e.g. clear, upload, portrait debug) are registered.
|
||||
Run `npm run migrate:checkup-flags`, `npm run migrate:artist-checkup-flags`, and `npm run migrate:painting-annotations` once on existing databases. Load notes with `npm run update-painting-annotations` (add `--wikipedia` for overview lines from Wikipedia intro text). After server code changes, restart `npm run dev` so new routes (e.g. clear, upload, portrait debug, annotations) are registered. JSON body limit for uploads is **20 MB** (`express.json` in `server/index.js`); individual files are capped at **15 MB** after decode.
|
||||
|
||||
See [API.md](API.md#developer-image-audit) and [data-and-images.md](data-and-images.md#duplicate-paintings).
|
||||
|
||||
|
||||
@@ -191,6 +191,12 @@ Separate from the painting influence graph, `client/src/data/movement-lineage.ts
|
||||
|
||||
To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the client. No migration or API change is required.
|
||||
|
||||
## 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 (columns, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts`.
|
||||
|
||||
Wing layout (up to 55 works per wing, side-wall-only hang, window gap placement) lives in `client/src/utils/movementHallLayout.ts`. To change a movement’s look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
|
||||
|
||||
## Historical event markers (frontend timeline)
|
||||
|
||||
`client/src/data/historical-events.ts` lists **world-history** markers shown on `Timeline.tsx` (French Revolution, World War I/II, etc.).
|
||||
@@ -200,9 +206,25 @@ To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the cli
|
||||
| Storage | TypeScript module in the client — **not** a database table |
|
||||
| Format | `{ id, name, startYear, endYear?, shortLabel? }` — omit `endYear` for a single-year pin |
|
||||
| Interaction | Click a marker to zoom the shared timeline/movement view to that period |
|
||||
| Vertical guides | `TimelineEventGuides.tsx` draws faint gold lines (or shaded spans) from the marker row down through the movement flow, aligned to the same year scale |
|
||||
|
||||
Edit `HISTORICAL_EVENTS` and rebuild the client to extend the set.
|
||||
|
||||
## Painting annotations (art-history notes)
|
||||
|
||||
Short curator-style notes on the painting detail page — separate from the influence graph.
|
||||
|
||||
| Aspect | Detail |
|
||||
|--------|--------|
|
||||
| Storage | PostgreSQL table `painting_annotations` |
|
||||
| UI | `PaintingAnnotations.tsx` — numbered markers on the image (when `pos_x` / `pos_y` set) plus an “Art history notes” list |
|
||||
| API | Included as `annotations[]` on `GET /api/paintings/:id` |
|
||||
| Curated data | `scripts/painting-annotations-data.js` — artist/title keys matched via `influence-resolver.js` |
|
||||
| Load | `npm run update-painting-annotations` (replaces existing rows per painting by default) |
|
||||
| Wikipedia pass | `npm run update-painting-annotations -- --wikipedia` — one intro sentence per work from `wikipedia_title`; use `--wiki-delay=3000` if rate-limited; `--no-replace` to append without clearing curated rows |
|
||||
|
||||
Categories include `subject`, `technique`, `context`, and `symbolism`. Sources cite Gombrich, museum catalogs, and Wikipedia as appropriate.
|
||||
|
||||
## Batch image fetch
|
||||
|
||||
`npm run fetch-images` (alias: `npm run search-missing-paintings`) runs `scripts/fetch-missing-images.js`. It searches multiple sources for paintings without local files:
|
||||
@@ -265,7 +287,9 @@ Requests are deduplicated (`inflight` map) and timeout after 15 seconds. On-dema
|
||||
|
||||
## Preload before 3D gallery
|
||||
|
||||
`POST /api/artists/:id/preload-images` runs **local-only** linking — no network. Call this when entering an artist’s 3D hall so textures use files already on disk.
|
||||
`POST /api/artists/:id/preload-images` runs **local-only** linking — no network. Call this when entering an **artist’s** 3D hall so textures use files already on disk.
|
||||
|
||||
**Movement galleries** (`GET /api/movements/:id/gallery`) do not use preload — they load the full painting list from the API and resolve local paths the same way as artist halls. Works without files still show the canvas cover in the frame.
|
||||
|
||||
The 3D scene uses `galleryImageUrl()`, which never hits the on-demand API (remote latency breaks WebGL texture loading).
|
||||
|
||||
@@ -337,10 +361,10 @@ As of a recent audit (~1200 paintings): **52 exact duplicate pairs** (52 removab
|
||||
When **Debug mode** is on (home header) or from the **Checkup** page:
|
||||
|
||||
1. **Search** — `GET /api/paintings/:id/debug-image-search` (or `…/debug-portrait-search` for artists) tries Google Custom Search (if `GOOGLE_CSE_API_KEY` + `GOOGLE_CSE_CX` are set in `.env`), Google Arts & Culture, Google Images scrape, then DuckDuckGo (`searchGoogleImagesFirst` / `searchArtistPortraitFirst` in `scripts/image-fetcher.js`).
|
||||
2. **More** — `GET …/debug-image-search/more` or `…/debug-portrait-search/more` returns up to 20 ranked candidates (`searchPaintingImagesMany` / `searchArtistPortraitMany`).
|
||||
2. **More** — `GET …/debug-image-search/more` or `…/debug-portrait-search/more` returns up to 20 ranked candidates (`searchPaintingImagesMany` / `searchArtistPortraitMany`). The modal shows each thumbnail with **resolution** when the search API provides dimensions; otherwise the client probes via `GET /api/debug/image-proxy`.
|
||||
3. **Fix** — `POST …/fix-image` or `…/fix-portrait` downloads the chosen URL via `downloadImageForFix` → `replacePaintingImageFromUrl` / `replaceArtistPortraitFromUrl` in `server/image-service.js`, regenerates thumbnails with `sharp`, and sets `checkup_fixed` + `checkup_checked`.
|
||||
4. **Clear** — `POST …/clear-image` or `…/clear-portrait` deletes local file(s), nulls DB paths, sets both flags. Cleared slots stay empty in the UI (no placeholder; `checkup_fixed` prevents on-demand refetch for paintings).
|
||||
5. **Upload** — `POST …/upload-image` or `…/upload-portrait` accepts a base64-encoded file (max 15 MB), validates with `sharp`, writes to the standard filename under `data/images/`.
|
||||
5. **Upload** — `POST …/upload-image` or `…/upload-portrait` accepts a base64-encoded file in JSON (Express body limit **20 MB**; decoded image max **15 MB**), validates with `sharp`, writes to the standard filename under `data/images/`.
|
||||
|
||||
### Debug panel (painting detail and artist bio)
|
||||
|
||||
@@ -350,7 +374,7 @@ With debug mode on, `PaintingDetail.tsx` and `ArtistBio.tsx` show a bottom-left
|
||||
|--------|----------------------------|--------|
|
||||
| **Checked** | `PATCH …/checkup-flags` `{ "checked": true }` | Marks reviewed; gold frame (paintings) or gold portrait border (artists) |
|
||||
| **Fix it** | `POST …/fix-image` / `…/fix-portrait` | Saves top search result to disk, sets both flags, refreshes detail + gallery / timeline |
|
||||
| **More** | `GET …/debug-*-search/more` then fix endpoint | Modal with 20 clickable results; pick one to replace |
|
||||
| **More** | `GET …/debug-*-search/more` then fix endpoint | Modal with 20 clickable results (resolution label under each thumb); pick one to replace |
|
||||
| **Clear** | `POST …/clear-image` / `…/clear-portrait` | Removes file(s), empty frame in UI |
|
||||
| **Upload** | `POST …/upload-image` / `…/upload-portrait` | Local file picker → save like **Fix it** |
|
||||
|
||||
|
||||
@@ -67,6 +67,8 @@ npm run expand-catalog # famous works for artists below MIN_PAINTING
|
||||
npm run update-influences # painting influence graph for detail view + hall exits
|
||||
npm run migrate:checkup-flags # optional: review/fixed flags for Checkup page (paintings)
|
||||
npm run migrate:artist-checkup-flags # optional: same flags for artist portraits (bio debug)
|
||||
npm run migrate:painting-annotations # optional: art-history notes table
|
||||
npm run update-painting-annotations # optional: load curated notes (+ --wikipedia for Wikipedia intros)
|
||||
npm run fetch-images -- --limit=50 # random sample; 10s max per painting (default)
|
||||
npm run fetch-images -- --limit=50 --max-wait=120 # same batch size, longer lookup per work
|
||||
cd client && npm run build && cd ..
|
||||
@@ -116,6 +118,10 @@ Open http://localhost:3001 (or your configured `PORT`).
|
||||
| `npm run migrate:influence-sources` | `scripts/migrate-influence-sources.js` | Create `painting_influence_sources` + backfill legacy edges |
|
||||
| `npm run migrate:checkup-flags` | `scripts/migrate-checkup-flags.js` | Add `checkup_checked` / `checkup_fixed` on `paintings` |
|
||||
| `npm run migrate:artist-checkup-flags` | `scripts/migrate-artist-checkup-flags.js` | Add `checkup_checked` / `checkup_fixed` on `artists` (bio debug) |
|
||||
| `npm run migrate:painting-annotations` | `scripts/migrate-painting-annotations.js` | Create `painting_annotations` table |
|
||||
| `npm run update-painting-annotations` | `scripts/update-painting-annotations.js` | Load curated notes from `painting-annotations-data.js` |
|
||||
| `npm run update-painting-annotations -- --wikipedia` | ↑ | Add intro sentences from each work’s Wikipedia page |
|
||||
| `npm run update-painting-annotations -- --wikipedia --wiki-delay=3000` | ↑ | Slower Wikipedia pass when rate-limited (429) |
|
||||
| `npm run find-duplicates` | `scripts/find-duplicate-paintings.js` | Report duplicate and near-duplicate painting rows |
|
||||
| `npm run update-influences` | `scripts/update-influences.js` | Insert influence links (painting / artist / movement) from `art-influences-data.js` |
|
||||
| `npm run update-influences -- --fetch-images` | ↑ | Also download images for newly created works |
|
||||
@@ -135,6 +141,7 @@ These are checked in and maintained:
|
||||
- `find-duplicate-paintings.js` — duplicate catalog audit
|
||||
- `migrate-checkup-flags.js` — checkup workflow columns (paintings)
|
||||
- `migrate-artist-checkup-flags.js` — checkup workflow columns (artist portraits)
|
||||
- `migrate-painting-annotations.js`, `update-painting-annotations.js`, `painting-annotations-data.js` — art-history notes on painting detail
|
||||
- `regenerate-thumbnails.js`, `audit-painting-images.js`
|
||||
|
||||
These are referenced in `package.json` but may need to be restored from git history if missing locally: `seed-wikipedia.js`, `fetch-artist-images.js`, `sync-image-paths.js`.
|
||||
@@ -172,7 +179,12 @@ After clone: copy `.env.example` → `.env`, install dependencies, run `npm run
|
||||
| Wikipedia API rate limit during fetch | Too many requests in a row | Wait and re-run; scripts retry with backoff |
|
||||
| Checkup **Reviewed** toggle returns 404 | Stale server process missing new routes | Restart `npm run dev` after pulling API changes |
|
||||
| Debug **More** / **Clear** / **Upload** returns 404 | Same as above | Restart server; routes live in `server/index.js` + `server/image-service.js` |
|
||||
| Debug **Upload** returns 413 Payload Too Large | Base64 JSON exceeds body limit | Server allows 20 MB JSON / 15 MB decoded image; compress file or resize before upload |
|
||||
| No art-history notes on painting detail | Annotations not migrated or loaded | `npm run migrate:painting-annotations` then `npm run update-painting-annotations` |
|
||||
| **Fix it** fails with `read ECONNRESET` | Remote host dropped connection | Restart server; client sends `searchUrl` / `source`; retry or use Commons URL in overrides |
|
||||
| Fixed image not shown in 3D gallery | Stale gallery session or cached texture | Rebuild client; fix updates session + `?v=` revision — use **Back to Gallery** (not browser back) |
|
||||
| Frame still black after **Checked** | Gallery session not synced | Re-enter hall or toggle debug **Checked** from detail with gallery open behind overlay |
|
||||
| Duplicate works in gallery / timeline | Double import or variant Wikipedia titles | `npm run find-duplicates`; merge or delete spare rows manually |
|
||||
| **Failed to load movement gallery** / `Cannot GET /api/movements/:id/gallery` | Stale server process missing route | Restart `npm run dev` or `npm run dev:server` after pulling API changes |
|
||||
| Movement gallery shows generic cream walls | Stale client build | `cd client && npm run build`; hard-refresh browser |
|
||||
| Windows overlap paintings in movement wing | Stale client | Rebuild client — windows are placed only on side walls in gaps between frames |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Art Gallery
|
||||
|
||||
Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom and major event markers, branching art-movement flow (curved streams, hover-to-reveal artist lifespans), 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 prev/next catalog browsing and fullscreen lightbox, debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**), Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies.
|
||||
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** — period-themed 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, debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**), Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies.
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -42,6 +42,8 @@ Interactive virtual art gallery: zoomable historical timeline with era click-to-
|
||||
npm run update-influences # art-history lineage links between paintings
|
||||
npm run migrate:checkup-flags # review/fixed flags for Checkup + debug mode (paintings)
|
||||
npm run migrate:artist-checkup-flags # same flags for artist portraits (bio debug)
|
||||
npm run migrate:painting-annotations # art-history notes on painting detail
|
||||
npm run update-painting-annotations # load curated notes (+ optional --wikipedia)
|
||||
npm run fetch-images -- --limit=50 # random batch of missing images (10s per work)
|
||||
npm run fetch-images -- --artist="Claude Monet" # one artist in catalog order
|
||||
```
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
YearBounds,
|
||||
Artist,
|
||||
ArtistDetail,
|
||||
MovementGalleryDetail,
|
||||
PaintingDetail,
|
||||
ArtistNavigation,
|
||||
} from '../types';
|
||||
@@ -126,6 +127,8 @@ export interface DebugImageSearchResultItem {
|
||||
imageUrl: string;
|
||||
thumbUrl?: string;
|
||||
source: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface DebugImageSearchManyResult {
|
||||
@@ -173,6 +176,8 @@ export const api = {
|
||||
|
||||
getArtist: (id: number) => fetchJson<ArtistDetail>(`${API}/artists/${id}`),
|
||||
|
||||
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(`${API}/movements/${id}/gallery`),
|
||||
|
||||
getArtistNavigation: (id: number) =>
|
||||
fetchJson<ArtistNavigation>(`${API}/artists/${id}/navigation`),
|
||||
|
||||
|
||||
@@ -87,7 +87,8 @@
|
||||
|
||||
.debug-search-modal-item {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
border: 2px solid rgba(201, 169, 110, 0.35);
|
||||
border-radius: 6px;
|
||||
@@ -96,6 +97,13 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.debug-search-modal-item-media {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
width: 100%;
|
||||
background: #2a1f15;
|
||||
}
|
||||
|
||||
.debug-search-modal-item:hover:not(:disabled) {
|
||||
border-color: #e8a040;
|
||||
box-shadow: 0 0 0 1px rgba(232, 160, 64, 0.35);
|
||||
@@ -117,6 +125,18 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.debug-search-modal-item-resolution {
|
||||
display: block;
|
||||
padding: 5px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
color: rgba(232, 213, 181, 0.9);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border-top: 1px solid rgba(201, 169, 110, 0.2);
|
||||
}
|
||||
|
||||
.debug-search-modal-item-index {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
|
||||
@@ -1,7 +1,57 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { debugImageProxyUrl, type DebugImageSearchManyResult, type DebugImageSearchResultItem } from '../api/client';
|
||||
import './DebugSearchResultsModal.css';
|
||||
|
||||
function formatResolution(width?: number, height?: number): string | null {
|
||||
if (!width || !height || width <= 0 || height <= 0) return null;
|
||||
return `${width} × ${height}`;
|
||||
}
|
||||
|
||||
function ResultResolution({
|
||||
item,
|
||||
searchUrl,
|
||||
}: {
|
||||
item: DebugImageSearchResultItem;
|
||||
searchUrl: string;
|
||||
}) {
|
||||
const initial = formatResolution(item.width, item.height);
|
||||
const [label, setLabel] = useState<string | null>(initial);
|
||||
|
||||
useEffect(() => {
|
||||
if (initial) {
|
||||
setLabel(initial);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (cancelled) return;
|
||||
const text = formatResolution(img.naturalWidth, img.naturalHeight);
|
||||
setLabel(text ?? '—');
|
||||
};
|
||||
img.onerror = () => {
|
||||
if (!cancelled) setLabel('—');
|
||||
};
|
||||
img.src = debugImageProxyUrl(item.imageUrl, {
|
||||
searchUrl,
|
||||
source: item.source,
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
};
|
||||
}, [item.imageUrl, item.source, item.width, item.height, searchUrl, initial]);
|
||||
|
||||
return (
|
||||
<span className="debug-search-modal-item-resolution" aria-hidden="true">
|
||||
{label ?? '…'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
@@ -76,19 +126,22 @@ export default function DebugSearchResultsModal({
|
||||
onClick={() => onSelect(item)}
|
||||
title="Use this image"
|
||||
>
|
||||
<img
|
||||
src={debugImageProxyUrl(item.thumbUrl || item.imageUrl, {
|
||||
searchUrl: data.searchUrl,
|
||||
source: item.source,
|
||||
})}
|
||||
alt={`Result ${index + 1}`}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
|
||||
}}
|
||||
/>
|
||||
<span className="debug-search-modal-item-index">{index + 1}</span>
|
||||
{busy && <span className="debug-search-modal-item-busy-label">Saving…</span>}
|
||||
<span className="debug-search-modal-item-media">
|
||||
<img
|
||||
src={debugImageProxyUrl(item.thumbUrl || item.imageUrl, {
|
||||
searchUrl: data.searchUrl,
|
||||
source: item.source,
|
||||
})}
|
||||
alt={`Result ${index + 1}`}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
|
||||
}}
|
||||
/>
|
||||
<span className="debug-search-modal-item-index">{index + 1}</span>
|
||||
{busy && <span className="debug-search-modal-item-busy-label">Saving…</span>}
|
||||
</span>
|
||||
<ResultResolution item={item} searchUrl={data.searchUrl} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import type { GalleryWindowSpec, GalleryWindowStyle } from '../data/movement-interior-styles';
|
||||
|
||||
const WALL_HEIGHT = 4.2;
|
||||
|
||||
interface Props {
|
||||
windows: GalleryWindowSpec[];
|
||||
halfW: number;
|
||||
halfD: number;
|
||||
trimColor: string;
|
||||
}
|
||||
|
||||
function windowWorldPosition(
|
||||
spec: GalleryWindowSpec,
|
||||
halfW: number,
|
||||
halfD: number
|
||||
): { position: [number, number, number]; rotation: [number, number, number] } {
|
||||
const inset = 0.1;
|
||||
switch (spec.wall) {
|
||||
case 'back':
|
||||
return {
|
||||
position: [spec.x, spec.y, -halfD + inset],
|
||||
rotation: [0, 0, 0],
|
||||
};
|
||||
case 'left':
|
||||
return {
|
||||
position: [-halfW + inset, spec.y, spec.x],
|
||||
rotation: [0, Math.PI / 2, 0],
|
||||
};
|
||||
case 'right':
|
||||
return {
|
||||
position: [halfW - inset, spec.y, spec.x],
|
||||
rotation: [0, -Math.PI / 2, 0],
|
||||
};
|
||||
case 'ceiling':
|
||||
return {
|
||||
position: [spec.x, WALL_HEIGHT - 0.06, spec.x === 0 ? 0 : spec.x],
|
||||
rotation: [Math.PI / 2, 0, 0],
|
||||
};
|
||||
default:
|
||||
return { position: [0, spec.y, -halfD + inset], rotation: [0, 0, 0] };
|
||||
}
|
||||
}
|
||||
|
||||
function WindowFrame({
|
||||
style,
|
||||
width,
|
||||
height,
|
||||
trimColor,
|
||||
}: {
|
||||
style: GalleryWindowStyle;
|
||||
width: number;
|
||||
height: number;
|
||||
trimColor: string;
|
||||
}) {
|
||||
const frameW = 0.08;
|
||||
const depth = 0.12;
|
||||
|
||||
const arch =
|
||||
style === 'roman-arch' || style === 'gothic-lancet' || style === 'art-nouveau';
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Outer frame */}
|
||||
<mesh position={[0, 0, -depth / 2]}>
|
||||
<boxGeometry args={[width + frameW * 2, height + frameW * 2, depth]} />
|
||||
<meshStandardMaterial color={trimColor} roughness={0.35} metalness={0.45} />
|
||||
</mesh>
|
||||
|
||||
{arch && style === 'gothic-lancet' && (
|
||||
<mesh position={[0, height / 2 + 0.15, -depth / 2 + 0.02]}>
|
||||
<coneGeometry args={[width / 2 + frameW, 0.5, 4]} />
|
||||
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.35} />
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{style === 'roman-arch' && (
|
||||
<mesh position={[0, height / 2 + 0.05, -depth / 2 + 0.02]} rotation={[0, 0, Math.PI]}>
|
||||
<sphereGeometry args={[width / 2, 16, 8, 0, Math.PI * 2, 0, Math.PI / 2]} />
|
||||
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.35} />
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{style === 'factory' && (
|
||||
<>
|
||||
{[-width / 3, 0, width / 3].map((ox) => (
|
||||
<mesh key={ox} position={[ox, 0, -depth / 2 + 0.02]}>
|
||||
<boxGeometry args={[0.06, height, depth + 0.02]} />
|
||||
<meshStandardMaterial color="#3a3a3a" roughness={0.5} metalness={0.6} />
|
||||
</mesh>
|
||||
))}
|
||||
{[height / 4, -height / 4].map((oy) => (
|
||||
<mesh key={oy} position={[0, oy, -depth / 2 + 0.02]}>
|
||||
<boxGeometry args={[width, 0.06, depth + 0.02]} />
|
||||
<meshStandardMaterial color="#3a3a3a" roughness={0.5} metalness={0.6} />
|
||||
</mesh>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{style === 'glass-block' &&
|
||||
Array.from({ length: 4 }, (_, row) =>
|
||||
Array.from({ length: 3 }, (_, col) => (
|
||||
<mesh
|
||||
key={`${row}-${col}`}
|
||||
position={[
|
||||
-width / 3 + col * (width / 3),
|
||||
-height / 4 + row * (height / 4),
|
||||
-depth / 2 + 0.03,
|
||||
]}
|
||||
>
|
||||
<boxGeometry args={[width / 3 - 0.06, height / 4 - 0.06, 0.04]} />
|
||||
<meshStandardMaterial
|
||||
color="#d0e8f8"
|
||||
roughness={0.1}
|
||||
metalness={0.05}
|
||||
transparent
|
||||
opacity={0.75}
|
||||
/>
|
||||
</mesh>
|
||||
))
|
||||
)}
|
||||
|
||||
{style === 'sash' && (
|
||||
<mesh position={[0, 0, -depth / 2 + 0.03]}>
|
||||
<boxGeometry args={[0.05, height, depth]} />
|
||||
<meshStandardMaterial color={trimColor} roughness={0.45} metalness={0.3} />
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{style === 'baroque-pair' && (
|
||||
<mesh position={[0, 0, -depth / 2 + 0.03]}>
|
||||
<boxGeometry args={[0.06, height, depth]} />
|
||||
<meshStandardMaterial color={trimColor} roughness={0.3} metalness={0.55} />
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function SingleWindow({
|
||||
spec,
|
||||
trimColor,
|
||||
}: {
|
||||
spec: GalleryWindowSpec;
|
||||
trimColor: string;
|
||||
}) {
|
||||
const glassColor = useMemo(() => new THREE.Color(spec.lightColor), [spec.lightColor]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<WindowFrame style={spec.style} width={spec.width} height={spec.height} trimColor={trimColor} />
|
||||
|
||||
{/* Sky / daylight pane */}
|
||||
<mesh position={[0, 0, 0.02]}>
|
||||
<planeGeometry args={[spec.width - 0.12, spec.height - 0.12]} />
|
||||
<meshStandardMaterial
|
||||
color={spec.lightColor}
|
||||
emissive={spec.lightColor}
|
||||
emissiveIntensity={0.85}
|
||||
roughness={0.05}
|
||||
metalness={0.02}
|
||||
toneMapped={false}
|
||||
transparent
|
||||
opacity={0.92}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Soft sky gradient overlay */}
|
||||
<mesh position={[0, spec.height * 0.15, 0.03]}>
|
||||
<planeGeometry args={[spec.width - 0.2, spec.height * 0.5]} />
|
||||
<meshBasicMaterial color="#ffffff" transparent opacity={0.25} toneMapped={false} />
|
||||
</mesh>
|
||||
|
||||
{/* Daylight into room */}
|
||||
<spotLight
|
||||
position={[0, 0, 0.15]}
|
||||
angle={Math.min(1.2, (spec.width / Math.max(spec.height, 0.5)) * 0.55)}
|
||||
penumbra={0.95}
|
||||
intensity={spec.lightIntensity}
|
||||
distance={14}
|
||||
color={spec.lightColor}
|
||||
castShadow={false}
|
||||
/>
|
||||
<pointLight
|
||||
position={[0, 0, 0.25]}
|
||||
intensity={spec.lightIntensity * 0.45}
|
||||
distance={10}
|
||||
color={glassColor}
|
||||
decay={2}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GalleryWindows({ windows, halfW, halfD, trimColor }: Props) {
|
||||
return (
|
||||
<group>
|
||||
{windows.map((spec, i) => {
|
||||
const { position, rotation } = windowWorldPosition(spec, halfW, halfD);
|
||||
return (
|
||||
<group key={`${spec.wall}-${spec.x}-${i}`} position={position} rotation={rotation}>
|
||||
<SingleWindow spec={spec} trimColor={trimColor} />
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ceiling-mounted gallery track lights for even illumination. */
|
||||
export function GalleryTrackLights({
|
||||
width,
|
||||
depth,
|
||||
intensity,
|
||||
color,
|
||||
}: {
|
||||
width: number;
|
||||
depth: number;
|
||||
intensity: number;
|
||||
color: string;
|
||||
}) {
|
||||
const positions = useMemo(() => {
|
||||
const pts: [number, number, number][] = [];
|
||||
const cols = Math.max(2, Math.min(5, Math.floor(width / 4)));
|
||||
const rows = Math.max(1, Math.min(3, Math.floor(depth / 6)));
|
||||
for (let c = 0; c < cols; c++) {
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const x = -width / 2 + (width / (cols + 1)) * (c + 1);
|
||||
const z = -depth / 2 + (depth / (rows + 1)) * (r + 1);
|
||||
pts.push([x, WALL_HEIGHT - 0.15, z]);
|
||||
}
|
||||
}
|
||||
return pts;
|
||||
}, [width, depth]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{positions.map(([x, y, z], i) => (
|
||||
<group key={i} position={[x, y, z]}>
|
||||
<mesh rotation={[Math.PI, 0, 0]}>
|
||||
<cylinderGeometry args={[0.02, 0.025, 0.12, 8]} />
|
||||
<meshStandardMaterial color="#2a2a2a" metalness={0.8} roughness={0.25} />
|
||||
</mesh>
|
||||
<spotLight
|
||||
position={[0, -0.04, 0]}
|
||||
angle={0.55}
|
||||
penumbra={0.85}
|
||||
intensity={intensity}
|
||||
distance={8}
|
||||
color={color}
|
||||
castShadow={false}
|
||||
/>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { Text } from '@react-three/drei';
|
||||
|
||||
const DOOR_WIDTH = 2.4;
|
||||
const DOOR_HEIGHT = 2.5;
|
||||
const WALL_THICKNESS = 0.18;
|
||||
|
||||
/** Open archway connecting to the next movement wing. */
|
||||
export default function HallPassage({
|
||||
position,
|
||||
active,
|
||||
label,
|
||||
onActivate,
|
||||
wallColor = '#f0ebe3',
|
||||
trimColor = '#ddd5c8',
|
||||
}: {
|
||||
position: [number, number, number];
|
||||
active: boolean;
|
||||
label: string;
|
||||
onActivate: () => void;
|
||||
wallColor?: string;
|
||||
trimColor?: string;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const highlight = active || hovered;
|
||||
const openingW = DOOR_WIDTH;
|
||||
const openingH = DOOR_HEIGHT;
|
||||
const jamb = 0.12;
|
||||
const faceZ = 0.04;
|
||||
|
||||
const handleActivate = (e: THREE.Event & { stopPropagation: () => void }) => {
|
||||
e.stopPropagation();
|
||||
onActivate();
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
<pointLight position={[0, openingH * 0.5, 0.3]} intensity={highlight ? 0.9 : 0.5} distance={5} color="#fff4e8" />
|
||||
|
||||
{/* Depth beyond passage */}
|
||||
<mesh position={[0, openingH * 0.5, 0.15]}>
|
||||
<planeGeometry args={[openingW * 0.9, openingH * 0.95]} />
|
||||
<meshStandardMaterial color="#fff8f0" emissive="#ffe8c8" emissiveIntensity={highlight ? 0.28 : 0.14} />
|
||||
</mesh>
|
||||
|
||||
{/* Side jambs */}
|
||||
{([-1, 1] as const).map((sign) => (
|
||||
<mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), openingH / 2, faceZ]} castShadow>
|
||||
<boxGeometry args={[jamb, openingH + 0.1, 0.1]} />
|
||||
<meshStandardMaterial color={trimColor} roughness={0.45} metalness={0.25} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Arch header */}
|
||||
<mesh position={[0, openingH + 0.06, faceZ]} castShadow>
|
||||
<boxGeometry args={[openingW + jamb * 2, 0.14, 0.1]} />
|
||||
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.3} />
|
||||
</mesh>
|
||||
|
||||
<mesh position={[0, openingH + 0.22, faceZ]} rotation={[0, 0, Math.PI]}>
|
||||
<sphereGeometry args={[openingW / 2 + 0.04, 16, 8, 0, Math.PI * 2, 0, Math.PI / 2]} />
|
||||
<meshStandardMaterial color={wallColor} roughness={0.88} />
|
||||
</mesh>
|
||||
|
||||
<group position={[0, openingH + 0.38, faceZ - 0.01]}>
|
||||
<mesh onClick={handleActivate} onPointerOver={() => setHovered(true)} onPointerOut={() => setHovered(false)}>
|
||||
<boxGeometry args={[1.4, 0.18, 0.02]} />
|
||||
<meshStandardMaterial
|
||||
color={highlight ? '#d4af37' : trimColor}
|
||||
roughness={0.3}
|
||||
metalness={0.5}
|
||||
emissive={highlight ? '#5a4010' : '#000000'}
|
||||
emissiveIntensity={highlight ? 0.2 : 0}
|
||||
/>
|
||||
</mesh>
|
||||
<Text
|
||||
position={[0, 0, -0.012]}
|
||||
rotation={[0, Math.PI, 0]}
|
||||
fontSize={0.1}
|
||||
color={highlight ? '#fff8e8' : '#4a3020'}
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
onClick={handleActivate}
|
||||
onPointerOver={() => setHovered(true)}
|
||||
onPointerOut={() => setHovered(false)}
|
||||
>
|
||||
{label.toUpperCase()}
|
||||
</Text>
|
||||
</group>
|
||||
|
||||
<mesh
|
||||
position={[0, openingH / 2, faceZ - 0.12]}
|
||||
rotation={[0, Math.PI, 0]}
|
||||
onClick={handleActivate}
|
||||
onPointerOver={() => setHovered(true)}
|
||||
onPointerOut={() => setHovered(false)}
|
||||
>
|
||||
<planeGeometry args={[openingW * 1.1, openingH * 1.05]} />
|
||||
<meshBasicMaterial transparent opacity={0} depthWrite={false} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export { DOOR_WIDTH, DOOR_HEIGHT, WALL_THICKNESS };
|
||||
@@ -47,12 +47,44 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.movement-lifespan-overlays {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.movement-lifespan-dim {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.movement-lifespan-highlight {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.08) 0%,
|
||||
rgba(255, 255, 255, 0.16) 40%,
|
||||
rgba(255, 255, 255, 0.12) 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 0 0 2px rgba(255, 230, 180, 0.45),
|
||||
inset 0 0 48px rgba(255, 255, 255, 0.08);
|
||||
border-left: 2px solid rgba(255, 220, 160, 0.65);
|
||||
border-right: 2px solid rgba(255, 220, 160, 0.65);
|
||||
}
|
||||
|
||||
.movements-flow-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.movement-branch {
|
||||
@@ -90,13 +122,16 @@
|
||||
.movements-flow-artists {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.movements-flow-artists {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.artist-on-band {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.artist-lifespan {
|
||||
position: absolute;
|
||||
height: 0;
|
||||
@@ -110,31 +145,45 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 3px;
|
||||
height: 4px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 25%, transparent) 0%,
|
||||
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 70%, #e8d5b5) 12%,
|
||||
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 85%, #fff) 50%,
|
||||
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 70%, #e8d5b5) 88%,
|
||||
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 25%, transparent) 100%
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
var(--lifespan-color, #e8d5b5) 15%,
|
||||
#fff 50%,
|
||||
var(--lifespan-color, #e8d5b5) 85%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--lifespan-color, #e8d5b5) 40%, transparent);
|
||||
border-radius: 1px;
|
||||
box-shadow:
|
||||
0 0 8px rgba(255, 255, 255, 0.55),
|
||||
0 0 16px var(--lifespan-color, #e8d5b5);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.artist-lifespan .artist-portrait {
|
||||
.artist-on-band .artist-portrait {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.artist-lifespan .artist-portrait:hover {
|
||||
.artist-on-band .artist-portrait:hover {
|
||||
transform: translate(-50%, -50%) scale(1.14);
|
||||
}
|
||||
|
||||
.artist-on-band-active .artist-portrait {
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(255, 255, 255, 0.85),
|
||||
0 4px 24px rgba(255, 220, 160, 0.65);
|
||||
}
|
||||
|
||||
.movements-flow-labels {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.movement-flow-label {
|
||||
position: absolute;
|
||||
transform: translate(-4px, -100%);
|
||||
@@ -144,6 +193,26 @@
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.movement-flow-label-btn {
|
||||
pointer-events: auto;
|
||||
border: none;
|
||||
background: rgba(12, 10, 18, 0.55);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.movement-flow-label-btn:hover {
|
||||
background: rgba(24, 20, 32, 0.82);
|
||||
box-shadow: 0 0 0 1px rgba(255, 220, 160, 0.35);
|
||||
}
|
||||
|
||||
.movement-flow-label-btn:focus-visible {
|
||||
outline: 2px solid rgba(255, 220, 160, 0.65);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.movement-name {
|
||||
display: block;
|
||||
font-family: 'Georgia', serif;
|
||||
|
||||
@@ -15,6 +15,8 @@ interface Props {
|
||||
absoluteMax: number;
|
||||
onViewChange: (start: number, end: number) => void;
|
||||
onArtistClick: (artistId: number) => void;
|
||||
onMovementClick?: (movementId: number) => void;
|
||||
onArtistHover?: (info: { birthYear: number; deathYear: number; color: string } | null) => void;
|
||||
}
|
||||
|
||||
interface MovementLayout {
|
||||
@@ -61,50 +63,159 @@ function artistTimelineYear(artist: Artist): number {
|
||||
return artist.birth_year ?? artist.death_year ?? 0;
|
||||
}
|
||||
|
||||
interface ArtistLifespanLayout {
|
||||
lineLeft: number;
|
||||
lineWidth: number;
|
||||
lineRight: number;
|
||||
portraitLeft: number;
|
||||
y: number;
|
||||
centerX: number;
|
||||
}
|
||||
|
||||
function artistLifespanLayout(
|
||||
artist: Artist,
|
||||
layout: MovementLayout,
|
||||
viewStart: number,
|
||||
viewEnd: number
|
||||
): ArtistLifespanLayout | null {
|
||||
const birth = artist.birth_year ?? viewStart;
|
||||
const death = artist.death_year ?? viewEnd;
|
||||
const centerYear = artistTimelineYear(artist);
|
||||
|
||||
const lineLeft = yearToPercent(Math.max(birth, viewStart), viewStart, viewEnd);
|
||||
const lineRight = yearToPercent(Math.min(death, viewEnd), viewStart, viewEnd);
|
||||
const centerX = yearToPercent(centerYear, viewStart, viewEnd);
|
||||
const lineWidth = lineRight - lineLeft;
|
||||
|
||||
if (lineWidth <= 0) return null;
|
||||
|
||||
const yAnchorX = Math.min(Math.max(centerX, layout.xStart), layout.xEnd);
|
||||
const y = yOnStream(layout, yAnchorX);
|
||||
const portraitLeft = ((centerX - lineLeft) / lineWidth) * 100;
|
||||
|
||||
return { lineLeft, lineWidth, lineRight, portraitLeft, y, centerX };
|
||||
}
|
||||
|
||||
interface ArtistPlacement {
|
||||
layout: MovementLayout;
|
||||
artist: Artist;
|
||||
lineLeft: number;
|
||||
lineWidth: number;
|
||||
portraitLeft: number;
|
||||
portraitX: number;
|
||||
y: number;
|
||||
lane: number;
|
||||
colorIndex: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface PortraitCandidate {
|
||||
artist: Artist;
|
||||
layout: MovementLayout;
|
||||
lineLeft: number;
|
||||
lineRight: number;
|
||||
lineWidth: number;
|
||||
minX: number;
|
||||
maxX: number;
|
||||
idealX: number;
|
||||
x: number;
|
||||
}
|
||||
|
||||
function portraitMinGapPct(portraitSizePx: number, canvasWidthPx: number): number {
|
||||
const width = Math.max(canvasWidthPx, 320);
|
||||
const diameterPct = (portraitSizePx / width) * 100;
|
||||
return Math.max(diameterPct * 1.08, 3.5);
|
||||
}
|
||||
|
||||
function resolvePortraitCollisions(candidates: PortraitCandidate[], minGap: number): void {
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
for (const c of candidates) {
|
||||
c.x = Math.min(c.maxX, Math.max(c.minX, c.idealX));
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => a.x - b.x || a.idealX - b.idealX);
|
||||
|
||||
for (let i = 1; i < candidates.length; i++) {
|
||||
if (candidates[i].x < candidates[i - 1].x + minGap) {
|
||||
candidates[i].x = candidates[i - 1].x + minGap;
|
||||
}
|
||||
}
|
||||
|
||||
const last = candidates[candidates.length - 1];
|
||||
if (last.x > last.maxX) {
|
||||
last.x = last.maxX;
|
||||
for (let i = candidates.length - 2; i >= 0; i--) {
|
||||
candidates[i].x = Math.min(candidates[i].x, candidates[i + 1].x - minGap);
|
||||
candidates[i].x = Math.max(candidates[i].x, candidates[i].minX);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = candidates.length - 2; i >= 0; i--) {
|
||||
if (candidates[i].x + minGap > candidates[i + 1].x) {
|
||||
candidates[i].x = candidates[i + 1].x - minGap;
|
||||
candidates[i].x = Math.max(candidates[i].x, candidates[i].minX);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 1; i < candidates.length; i++) {
|
||||
if (candidates[i].x < candidates[i - 1].x + minGap) {
|
||||
candidates[i].x = Math.min(candidates[i - 1].x + minGap, candidates[i].maxX);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildArtistPlacements(
|
||||
layouts: MovementLayout[],
|
||||
artistsByMovement: Map<number, Artist[]>,
|
||||
viewStart: number,
|
||||
viewEnd: number,
|
||||
portraitSizePx: number,
|
||||
canvasWidthPx: number
|
||||
): ArtistPlacement[] {
|
||||
const placements: ArtistPlacement[] = [];
|
||||
const minGap = portraitMinGapPct(portraitSizePx, canvasWidthPx);
|
||||
|
||||
for (const layout of layouts) {
|
||||
const movementArtists = artistsByMovement.get(layout.movement.id) || [];
|
||||
const candidates: PortraitCandidate[] = [];
|
||||
const portraitHalfPct = minGap / 2;
|
||||
|
||||
for (const artist of movementArtists) {
|
||||
const birth = artist.birth_year ?? viewStart;
|
||||
const death = artist.death_year ?? viewEnd;
|
||||
const centerYear = artistTimelineYear(artist);
|
||||
|
||||
const lineLeft = yearToPercent(Math.max(birth, viewStart), viewStart, viewEnd);
|
||||
const lineRight = yearToPercent(Math.min(death, viewEnd), viewStart, viewEnd);
|
||||
const lineWidth = lineRight - lineLeft;
|
||||
if (lineWidth <= 0) continue;
|
||||
|
||||
const spanLeft = Math.max(lineLeft, layout.xStart);
|
||||
const spanRight = Math.min(lineRight, layout.xEnd);
|
||||
if (spanRight <= spanLeft) continue;
|
||||
|
||||
const centerX = yearToPercent(centerYear, viewStart, viewEnd);
|
||||
let minX = spanLeft + portraitHalfPct;
|
||||
let maxX = spanRight - portraitHalfPct;
|
||||
if (maxX <= minX) {
|
||||
const mid = (spanLeft + spanRight) / 2;
|
||||
minX = mid;
|
||||
maxX = mid;
|
||||
}
|
||||
|
||||
const idealX = Math.min(maxX, Math.max(minX, centerX));
|
||||
|
||||
candidates.push({
|
||||
artist,
|
||||
layout,
|
||||
lineLeft,
|
||||
lineRight,
|
||||
lineWidth,
|
||||
minX,
|
||||
maxX,
|
||||
idealX,
|
||||
x: idealX,
|
||||
});
|
||||
}
|
||||
|
||||
candidates.sort(
|
||||
(a, b) =>
|
||||
a.idealX - b.idealX ||
|
||||
b.lineWidth - a.lineWidth ||
|
||||
(a.artist.birth_year ?? 0) - (b.artist.birth_year ?? 0)
|
||||
);
|
||||
|
||||
resolvePortraitCollisions(candidates, minGap);
|
||||
|
||||
candidates.sort((a, b) => a.x - b.x);
|
||||
|
||||
const colorCount = Math.max(candidates.length, 1);
|
||||
candidates.forEach((candidate, colorIndex) => {
|
||||
const { artist, layout, lineLeft, lineWidth, x } = candidate;
|
||||
const y = yOnStream(layout, x);
|
||||
|
||||
placements.push({
|
||||
layout,
|
||||
artist,
|
||||
lineLeft,
|
||||
lineWidth,
|
||||
portraitX: x,
|
||||
y,
|
||||
colorIndex,
|
||||
color: artistLifespanColor(layout.movement.color, colorIndex, colorCount),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return placements;
|
||||
}
|
||||
|
||||
function parseHexColor(hex: string): [number, number, number] {
|
||||
const normalized = hex.replace('#', '');
|
||||
const value =
|
||||
@@ -167,76 +278,6 @@ function artistLifespanColor(baseColor: string, laneIndex: number, laneCount: nu
|
||||
}
|
||||
}
|
||||
|
||||
function buildArtistPlacements(
|
||||
layouts: MovementLayout[],
|
||||
artistsByMovement: Map<number, Artist[]>,
|
||||
viewStart: number,
|
||||
viewEnd: number,
|
||||
streamStrokePx: number,
|
||||
portraitSizePx: number
|
||||
): ArtistPlacement[] {
|
||||
const placements: ArtistPlacement[] = [];
|
||||
|
||||
for (const layout of layouts) {
|
||||
const movementArtists = artistsByMovement.get(layout.movement.id) || [];
|
||||
const candidates: Array<{
|
||||
artist: Artist;
|
||||
span: ArtistLifespanLayout;
|
||||
}> = [];
|
||||
|
||||
for (const artist of movementArtists) {
|
||||
const span = artistLifespanLayout(artist, layout, viewStart, viewEnd);
|
||||
if (span) candidates.push({ artist, span });
|
||||
}
|
||||
|
||||
candidates.sort(
|
||||
(a, b) =>
|
||||
a.span.lineLeft - b.span.lineLeft ||
|
||||
b.span.lineWidth - a.span.lineWidth ||
|
||||
a.artist.birth_year! - b.artist.birth_year!
|
||||
);
|
||||
|
||||
const laneEnds: number[] = [];
|
||||
const minGap = 0.6;
|
||||
const assigned: Array<{ candidate: (typeof candidates)[0]; lane: number }> = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
let lane = laneEnds.findIndex((end) => candidate.span.lineLeft >= end + minGap);
|
||||
if (lane === -1) {
|
||||
lane = laneEnds.length;
|
||||
laneEnds.push(candidate.span.lineRight);
|
||||
} else {
|
||||
laneEnds[lane] = Math.max(laneEnds[lane], candidate.span.lineRight);
|
||||
}
|
||||
assigned.push({ candidate, lane });
|
||||
}
|
||||
|
||||
const laneCount = Math.max(laneEnds.length, 1);
|
||||
const laneStep = Math.min(
|
||||
portraitSizePx * 0.52,
|
||||
(streamStrokePx * 0.88) / laneCount
|
||||
);
|
||||
|
||||
for (const { candidate, lane } of assigned) {
|
||||
const { artist, span } = candidate;
|
||||
const yOffset = (lane - (laneCount - 1) / 2) * laneStep;
|
||||
|
||||
placements.push({
|
||||
layout,
|
||||
artist,
|
||||
lineLeft: span.lineLeft,
|
||||
lineWidth: span.lineWidth,
|
||||
portraitLeft: span.portraitLeft,
|
||||
y: span.y + yOffset,
|
||||
lane,
|
||||
color: artistLifespanColor(layout.movement.color, lane, laneCount),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return placements;
|
||||
}
|
||||
|
||||
function organicDrift(id: number): number {
|
||||
return ((id * 17) % 11) - 5;
|
||||
}
|
||||
@@ -333,10 +374,13 @@ export default function MovementBands({
|
||||
absoluteMax,
|
||||
onViewChange,
|
||||
onArtistClick,
|
||||
onMovementClick,
|
||||
onArtistHover,
|
||||
}: Props) {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const [panning, setPanning] = useState(false);
|
||||
const [canvasHeight, setCanvasHeight] = useState(DEFAULT_CANVAS_HEIGHT);
|
||||
const [canvasWidth, setCanvasWidth] = useState(800);
|
||||
const [hoveredArtistKey, setHoveredArtistKey] = useState<string | null>(null);
|
||||
const panStart = useRef({ x: 0, viewStart: 0, viewEnd: 0 });
|
||||
|
||||
@@ -424,14 +468,16 @@ export default function MovementBands({
|
||||
if (!el) return;
|
||||
|
||||
const measure = () => {
|
||||
const h = el.getBoundingClientRect().height;
|
||||
if (h > 0) setCanvasHeight(Math.round(h));
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.height > 0) setCanvasHeight(Math.round(rect.height));
|
||||
if (rect.width > 0) setCanvasWidth(Math.round(rect.width));
|
||||
};
|
||||
|
||||
measure();
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const h = entries[0]?.contentRect.height;
|
||||
if (h > 0) setCanvasHeight(Math.round(h));
|
||||
const rect = entries[0]?.contentRect;
|
||||
if (rect && rect.height > 0) setCanvasHeight(Math.round(rect.height));
|
||||
if (rect && rect.width > 0) setCanvasWidth(Math.round(rect.width));
|
||||
});
|
||||
ro.observe(el);
|
||||
window.addEventListener('resize', measure);
|
||||
@@ -577,12 +623,21 @@ export default function MovementBands({
|
||||
artistsByMovement,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
streamStrokePx,
|
||||
portraitSizePx
|
||||
portraitSizePx,
|
||||
canvasWidth
|
||||
),
|
||||
[layouts, artistsByMovement, viewStart, viewEnd, streamStrokePx, portraitSizePx]
|
||||
[layouts, artistsByMovement, viewStart, viewEnd, portraitSizePx, canvasWidth]
|
||||
);
|
||||
|
||||
const hoveredPlacement = useMemo(() => {
|
||||
if (!hoveredArtistKey) return null;
|
||||
return (
|
||||
artistPlacements.find(
|
||||
(p) => `${p.layout.movement.id}-${p.artist.id}` === hoveredArtistKey
|
||||
) ?? null
|
||||
);
|
||||
}, [hoveredArtistKey, artistPlacements]);
|
||||
|
||||
if (visibleMovements.length === 0) {
|
||||
return (
|
||||
<div className="movements-empty">
|
||||
@@ -719,26 +774,59 @@ export default function MovementBands({
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{hoveredPlacement && (
|
||||
<div className="movement-lifespan-overlays" aria-hidden>
|
||||
{hoveredPlacement.lineLeft > 0 && (
|
||||
<div
|
||||
className="movement-lifespan-dim"
|
||||
style={{ left: 0, width: `${hoveredPlacement.lineLeft}%` }}
|
||||
/>
|
||||
)}
|
||||
{hoveredPlacement.lineLeft + hoveredPlacement.lineWidth < 100 && (
|
||||
<div
|
||||
className="movement-lifespan-dim"
|
||||
style={{
|
||||
left: `${hoveredPlacement.lineLeft + hoveredPlacement.lineWidth}%`,
|
||||
width: `${100 - hoveredPlacement.lineLeft - hoveredPlacement.lineWidth}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="movement-lifespan-highlight"
|
||||
style={{
|
||||
left: `${hoveredPlacement.lineLeft}%`,
|
||||
width: `${hoveredPlacement.lineWidth}%`,
|
||||
['--lifespan-color' as string]: hoveredPlacement.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="movements-flow-labels">
|
||||
{layouts.map((layout) => (
|
||||
<div
|
||||
<button
|
||||
key={`label-${layout.movement.id}`}
|
||||
className="movement-flow-label"
|
||||
type="button"
|
||||
className="movement-flow-label movement-flow-label-btn"
|
||||
style={{
|
||||
left: `${layout.xStart}%`,
|
||||
top: `${((layout.y - 32) / layoutHeight) * 100}%`,
|
||||
}}
|
||||
title={`Open ${layout.movement.name} gallery hall`}
|
||||
onClick={() => onMovementClick?.(layout.movement.id)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="movement-name">{layout.movement.name}</span>
|
||||
{layout.movement.era_name && (
|
||||
<span className="movement-era">{layout.movement.era_name}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="movements-flow-artists">
|
||||
{artistPlacements.map(({ layout, artist, lineLeft, lineWidth, portraitLeft, y, lane, color }) => {
|
||||
{artistPlacements.map(({ layout, artist, lineLeft, lineWidth, portraitX, y, colorIndex, color }) => {
|
||||
const birthLabel = artist.birth_year != null ? artist.birth_year : '?';
|
||||
const deathLabel = artist.death_year != null ? artist.death_year : '?';
|
||||
const artistKey = `${layout.movement.id}-${artist.id}`;
|
||||
@@ -747,26 +835,42 @@ export default function MovementBands({
|
||||
return (
|
||||
<div
|
||||
key={artistKey}
|
||||
className={`artist-lifespan${isHovered ? ' artist-lifespan-active' : ''}`}
|
||||
style={{
|
||||
left: `${lineLeft}%`,
|
||||
width: `${lineWidth}%`,
|
||||
top: `${(y / layoutHeight) * 100}%`,
|
||||
zIndex: isHovered ? 12 : 4 + lane,
|
||||
['--lifespan-color' as string]: color,
|
||||
}}
|
||||
className={`artist-on-band${isHovered ? ' artist-on-band-active' : ''}`}
|
||||
>
|
||||
{isHovered && <span className="artist-lifespan-line" aria-hidden />}
|
||||
<div
|
||||
className={`artist-lifespan${isHovered ? ' artist-lifespan-active' : ''}`}
|
||||
style={{
|
||||
left: `${lineLeft}%`,
|
||||
width: `${lineWidth}%`,
|
||||
top: `${(y / layoutHeight) * 100}%`,
|
||||
zIndex: isHovered ? 12 : 4 + colorIndex,
|
||||
['--lifespan-color' as string]: color,
|
||||
}}
|
||||
>
|
||||
{isHovered && <span className="artist-lifespan-line" aria-hidden />}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="artist-portrait"
|
||||
style={{
|
||||
left: `${portraitLeft}%`,
|
||||
left: `${portraitX}%`,
|
||||
top: `${(y / layoutHeight) * 100}%`,
|
||||
borderColor: color,
|
||||
zIndex: isHovered ? 13 : 5 + colorIndex,
|
||||
}}
|
||||
onClick={() => onArtistClick(artist.id)}
|
||||
onMouseEnter={() => setHoveredArtistKey(artistKey)}
|
||||
onMouseLeave={() => setHoveredArtistKey(null)}
|
||||
onMouseEnter={() => {
|
||||
setHoveredArtistKey(artistKey);
|
||||
onArtistHover?.({
|
||||
birthYear: artist.birth_year ?? viewStart,
|
||||
deathYear: artist.death_year ?? viewEnd,
|
||||
color,
|
||||
});
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredArtistKey(null);
|
||||
onArtistHover?.(null);
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
title={`${artist.name} (${birthLabel}–${deathLabel}) · ${layout.movement.name}`}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import type { MovementInteriorStyle } from '../data/movement-interior-styles';
|
||||
|
||||
interface Props {
|
||||
style: MovementInteriorStyle;
|
||||
width: number;
|
||||
depth: number;
|
||||
halfW: number;
|
||||
halfD: number;
|
||||
}
|
||||
|
||||
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]} castShadow>
|
||||
<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 MedievalDetails({ halfW, halfD }: Pick<Props, 'halfW' | 'halfD'>) {
|
||||
const torchPositions = useMemo(
|
||||
() =>
|
||||
[
|
||||
[-halfW + 0.2, -halfD * 0.5],
|
||||
[halfW - 0.2, -halfD * 0.5],
|
||||
[-halfW + 0.2, halfD * 0.3],
|
||||
[halfW - 0.2, halfD * 0.3],
|
||||
] as [number, number][],
|
||||
[halfW, halfD]
|
||||
);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{torchPositions.map(([x, z], i) => (
|
||||
<group key={i} position={[x, 2.2, z]}>
|
||||
<mesh>
|
||||
<boxGeometry args={[0.08, 0.35, 0.12]} />
|
||||
<meshStandardMaterial color="#3a3028" roughness={0.9} />
|
||||
</mesh>
|
||||
<pointLight position={[0, 0.15, 0.08]} intensity={0.65} distance={5} color="#ff9830" />
|
||||
<mesh position={[0, 0.2, 0.06]}>
|
||||
<sphereGeometry args={[0.06, 8, 8]} />
|
||||
<meshStandardMaterial color="#ffb040" emissive="#ff8010" emissiveIntensity={0.8} toneMapped={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Rough stone courses */}
|
||||
{[-halfD + 0.08, halfD - 0.08].map((z, i) => (
|
||||
<mesh key={i} position={[0, 1.5, z]}>
|
||||
<boxGeometry args={[0.04, 3, 0.04]} />
|
||||
<meshStandardMaterial color="#6a6458" roughness={0.98} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function NeoclassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
|
||||
const columns = [
|
||||
[-halfW + 0.45, -halfD + 0.6],
|
||||
[halfW - 0.45, -halfD + 0.6],
|
||||
[-halfW + 0.45, halfD - 0.6],
|
||||
[halfW - 0.45, halfD - 0.6],
|
||||
] as [number, number][];
|
||||
|
||||
return (
|
||||
<group>
|
||||
{columns.map(([x, z], i) => (
|
||||
<group key={i} position={[x, 0, z]}>
|
||||
<mesh position={[0, 1.85, 0]}>
|
||||
<cylinderGeometry args={[0.18, 0.2, 3.7, 12]} />
|
||||
<meshStandardMaterial color="#f0ece8" roughness={0.72} metalness={0.12} />
|
||||
</mesh>
|
||||
<mesh position={[0, 3.85, 0]}>
|
||||
<boxGeometry args={[0.42, 0.12, 0.42]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.4} metalness={0.35} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
<mesh position={[0, 3.88, -halfD + 0.12]}>
|
||||
<boxGeometry args={[2.8, 0.14, 0.2]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.45} metalness={0.3} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassicalDetails({ halfW, trim }: Pick<Props, 'halfW'> & { trim: string }) {
|
||||
return (
|
||||
<group>
|
||||
{[
|
||||
[-halfW + 0.5, 0],
|
||||
[halfW - 0.5, 0],
|
||||
].map(([x, z], i) => (
|
||||
<group key={i} position={[x, 0, z]}>
|
||||
<mesh position={[0, 2, 0]}>
|
||||
<cylinderGeometry args={[0.16, 0.18, 4, 10]} />
|
||||
<meshStandardMaterial color="#e0d8c8" roughness={0.88} />
|
||||
</mesh>
|
||||
<mesh position={[0, 4.05, 0]}>
|
||||
<boxGeometry args={[0.36, 0.1, 0.36]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.25} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function SalonDetails({ trim }: { trim: string }) {
|
||||
return (
|
||||
<mesh position={[0, 4.05, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.3, 1.2, 32]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.35} metalness={0.5} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MovementHallDetails({ style, width, depth, halfW, halfD }: Props) {
|
||||
const trim = style.tints.trim;
|
||||
|
||||
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 'medieval':
|
||||
return <MedievalDetails halfW={halfW} halfD={halfD} />;
|
||||
case 'neoclassical':
|
||||
return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
case 'classical':
|
||||
return <ClassicalDetails halfW={halfW} trim={trim} />;
|
||||
case 'salon':
|
||||
return <SalonDetails trim={trim} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
.painting-frame-annotated {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.painting-frame-image-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.painting-frame-image-wrap img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.painting-annotation-markers {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.painting-annotation-marker {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 2px solid rgba(255, 255, 255, 0.9);
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #1a1208;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.painting-annotation-marker:hover,
|
||||
.painting-annotation-marker-active {
|
||||
transform: translate(-50%, -50%) scale(1.12);
|
||||
box-shadow: 0 0 0 2px rgba(255, 215, 0, 0.55), 0 2px 10px rgba(0, 0, 0, 0.5);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.painting-annotation-marker-technique { background: #7eb8da; }
|
||||
.painting-annotation-marker-composition { background: #c9a96e; }
|
||||
.painting-annotation-marker-symbolism { background: #b088cc; }
|
||||
.painting-annotation-marker-history { background: #8cbe8c; }
|
||||
.painting-annotation-marker-subject { background: #e8a040; }
|
||||
|
||||
.painting-annotations-panel {
|
||||
max-width: 700px;
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.painting-annotations-title {
|
||||
margin: 0 0 10px;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #c9a96e;
|
||||
}
|
||||
|
||||
.painting-annotations-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.painting-annotation-card {
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.2);
|
||||
background: rgba(201, 169, 110, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.painting-annotation-card-active {
|
||||
border-color: rgba(255, 215, 0, 0.45);
|
||||
background: rgba(201, 169, 110, 0.12);
|
||||
}
|
||||
|
||||
.painting-annotation-card-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.painting-annotation-card-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.painting-annotation-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
.painting-annotation-category {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(201, 169, 110, 0.85);
|
||||
}
|
||||
|
||||
.painting-annotation-label {
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 13px;
|
||||
color: #e8d5b5;
|
||||
}
|
||||
|
||||
.painting-annotation-body {
|
||||
margin: 0;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: rgba(232, 213, 181, 0.88);
|
||||
}
|
||||
|
||||
.painting-annotation-source {
|
||||
margin: 6px 0 0;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
color: rgba(201, 169, 110, 0.75);
|
||||
}
|
||||
|
||||
.painting-annotation-source cite {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.painting-annotation-source-link {
|
||||
display: inline-block;
|
||||
margin: 0 12px 10px;
|
||||
font-size: 11px;
|
||||
color: #7eb8da;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.painting-annotation-source-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.painting-annotation-card-technique { border-left: 3px solid #7eb8da; }
|
||||
.painting-annotation-card-composition { border-left: 3px solid #c9a96e; }
|
||||
.painting-annotation-card-symbolism { border-left: 3px solid #b088cc; }
|
||||
.painting-annotation-card-history { border-left: 3px solid #8cbe8c; }
|
||||
.painting-annotation-card-subject { border-left: 3px solid #e8a040; }
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useRef } from 'react';
|
||||
import type { PaintingAnnotation } from '../types';
|
||||
import './PaintingAnnotations.css';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
technique: 'Technique',
|
||||
composition: 'Composition',
|
||||
symbolism: 'Symbolism',
|
||||
history: 'History',
|
||||
subject: 'Subject',
|
||||
};
|
||||
|
||||
interface PanelProps {
|
||||
annotations: PaintingAnnotation[];
|
||||
activeId: number | null;
|
||||
onSelect: (id: number | null) => void;
|
||||
}
|
||||
|
||||
export function PaintingAnnotationMarkers({
|
||||
annotations,
|
||||
activeId,
|
||||
onSelect,
|
||||
}: PanelProps) {
|
||||
const positioned = annotations.filter(
|
||||
(a) => a.pos_x != null && a.pos_y != null && Number.isFinite(Number(a.pos_x)) && Number.isFinite(Number(a.pos_y))
|
||||
);
|
||||
if (!positioned.length) return null;
|
||||
|
||||
return (
|
||||
<div className="painting-annotation-markers" aria-hidden={false}>
|
||||
{positioned.map((ann) => {
|
||||
const number = annotations.indexOf(ann) + 1;
|
||||
const isActive = activeId === ann.id;
|
||||
return (
|
||||
<button
|
||||
key={ann.id}
|
||||
type="button"
|
||||
className={`painting-annotation-marker painting-annotation-marker-${ann.category}${isActive ? ' painting-annotation-marker-active' : ''}`}
|
||||
style={{ left: `${ann.pos_x}%`, top: `${ann.pos_y}%` }}
|
||||
title={ann.label || ann.body}
|
||||
aria-label={`Annotation ${number}: ${ann.label || ann.body}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(isActive ? null : ann.id);
|
||||
}}
|
||||
>
|
||||
{number}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PaintingAnnotationsPanel({
|
||||
annotations,
|
||||
activeId,
|
||||
onSelect,
|
||||
}: PanelProps) {
|
||||
const cardRefs = useRef<Map<number, HTMLLIElement>>(new Map());
|
||||
|
||||
if (!annotations.length) return null;
|
||||
|
||||
const focusAnnotation = (id: number | null) => {
|
||||
onSelect(id);
|
||||
if (id != null) {
|
||||
cardRefs.current.get(id)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="painting-annotations-panel" aria-label="Art history annotations">
|
||||
<h3 className="painting-annotations-title">Art history notes</h3>
|
||||
<ul className="painting-annotations-list">
|
||||
{annotations.map((ann, index) => {
|
||||
const isActive = activeId === ann.id;
|
||||
const category = CATEGORY_LABELS[ann.category] || ann.category;
|
||||
return (
|
||||
<li
|
||||
key={ann.id}
|
||||
ref={(el) => {
|
||||
if (el) cardRefs.current.set(ann.id, el);
|
||||
else cardRefs.current.delete(ann.id);
|
||||
}}
|
||||
className={`painting-annotation-card painting-annotation-card-${ann.category}${isActive ? ' painting-annotation-card-active' : ''}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="painting-annotation-card-btn"
|
||||
onClick={() => focusAnnotation(isActive ? null : ann.id)}
|
||||
>
|
||||
<span className="painting-annotation-card-head">
|
||||
<span className="painting-annotation-number">{index + 1}</span>
|
||||
<span className="painting-annotation-category">{category}</span>
|
||||
{ann.label && <strong className="painting-annotation-label">{ann.label}</strong>}
|
||||
</span>
|
||||
<p className="painting-annotation-body">{ann.body}</p>
|
||||
{(ann.source_author || ann.source) && (
|
||||
<p className="painting-annotation-source">
|
||||
— {ann.source_author}
|
||||
{ann.source && <cite>, {ann.source}</cite>}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
{ann.source_url && (
|
||||
<a
|
||||
className="painting-annotation-source-link"
|
||||
href={ann.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Read source
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -392,6 +392,11 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.painting-frame-large .painting-frame-image-wrap img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.painting-description {
|
||||
max-width: 700px;
|
||||
margin-top: 20px;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ChangeEvent, type SyntheticEvent } fr
|
||||
import type { InfluenceLink, Painting, PaintingDetail } from '../types';
|
||||
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client';
|
||||
import DebugSearchResultsModal from './DebugSearchResultsModal';
|
||||
import PaintingAnnotationsPanel, { PaintingAnnotationMarkers } from './PaintingAnnotations';
|
||||
import PaintingLightbox from './PaintingLightbox';
|
||||
import './PaintingDetail.css';
|
||||
|
||||
@@ -190,7 +191,7 @@ export default function PaintingDetailView({
|
||||
onPaintingImageFixed,
|
||||
onPaintingCheckupFlagsUpdated,
|
||||
}: Props) {
|
||||
const { painting, influencedBy, influenced } = data;
|
||||
const { painting, influencedBy, influenced, annotations = [] } = data;
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [imageVersion, setImageVersion] = useState(0);
|
||||
const [debugSearch, setDebugSearch] = useState<DebugImageSearchResult | null>(null);
|
||||
@@ -205,6 +206,7 @@ export default function PaintingDetailView({
|
||||
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [activeAnnotationId, setActiveAnnotationId] = useState<number | null>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const baseImageUrl = paintingImageUrl(painting);
|
||||
@@ -227,6 +229,7 @@ export default function PaintingDetailView({
|
||||
setMoreOpen(false);
|
||||
setMoreResults(null);
|
||||
setMoreError(null);
|
||||
setActiveAnnotationId(null);
|
||||
}, [painting.id]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -475,11 +478,28 @@ export default function PaintingDetailView({
|
||||
title={imageSrc ? 'View full screen' : undefined}
|
||||
aria-label={imageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`}
|
||||
>
|
||||
{imageSrc ? (
|
||||
<img src={imageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
|
||||
) : null}
|
||||
<div className="painting-frame-image-wrap">
|
||||
{imageSrc ? (
|
||||
<img src={imageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
|
||||
) : null}
|
||||
{imageSrc && annotations.length > 0 && (
|
||||
<PaintingAnnotationMarkers
|
||||
annotations={annotations}
|
||||
activeId={activeAnnotationId}
|
||||
onSelect={setActiveAnnotationId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{annotations.length > 0 && (
|
||||
<PaintingAnnotationsPanel
|
||||
annotations={annotations}
|
||||
activeId={activeAnnotationId}
|
||||
onSelect={setActiveAnnotationId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCatalogNav && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -33,15 +33,17 @@
|
||||
|
||||
.timeline-range {
|
||||
margin-left: 12px;
|
||||
color: #c9a96e;
|
||||
color: #f5e6c8;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
position: relative;
|
||||
height: 88px;
|
||||
height: 96px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
border: 1px solid rgba(201, 169, 110, 0.3);
|
||||
@@ -53,6 +55,39 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.timeline-lifespan-overlays {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeline-lifespan-dim {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.timeline-lifespan-highlight {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.05) 0%,
|
||||
rgba(255, 255, 255, 0.22) 18%,
|
||||
rgba(255, 255, 255, 0.38) 50%,
|
||||
rgba(255, 255, 255, 0.22) 82%,
|
||||
rgba(255, 255, 255, 0.05) 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 0 0 2px rgba(255, 240, 200, 0.55),
|
||||
inset 0 0 32px rgba(255, 255, 255, 0.2);
|
||||
border-left: 2px solid rgba(255, 230, 180, 0.75);
|
||||
border-right: 2px solid rgba(255, 230, 180, 0.75);
|
||||
}
|
||||
|
||||
.timeline-track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -120,18 +155,16 @@
|
||||
}
|
||||
|
||||
.historical-event-point .event-marker-line {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
bottom: 22px;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
transform: translateX(-50%);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 210, 120, 0.95) 0%,
|
||||
rgba(255, 180, 90, 0.75) 100%
|
||||
);
|
||||
box-shadow: 0 0 6px rgba(255, 190, 100, 0.45);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.historical-event-span {
|
||||
top: 20px;
|
||||
bottom: 24px;
|
||||
background: rgba(180, 70, 55, 0.28);
|
||||
border-left: 2px solid rgba(255, 150, 110, 0.75);
|
||||
border-right: 2px solid rgba(255, 150, 110, 0.75);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.historical-event-point::before {
|
||||
@@ -148,16 +181,13 @@
|
||||
}
|
||||
|
||||
.historical-event-span {
|
||||
top: 20px;
|
||||
bottom: 24px;
|
||||
background: rgba(180, 70, 55, 0.22);
|
||||
border-left: 2px solid rgba(255, 150, 110, 0.65);
|
||||
border-right: 2px solid rgba(255, 150, 110, 0.65);
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
border-left-color: transparent;
|
||||
border-right-color: transparent;
|
||||
}
|
||||
|
||||
.historical-event-span:hover,
|
||||
.historical-event-point:hover .event-marker-line {
|
||||
.historical-event-point:hover::before {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
@@ -197,24 +227,31 @@
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 24px;
|
||||
height: 34px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.tick {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
transform: translateX(-50%);
|
||||
border-left: 1px solid rgba(201, 169, 110, 0.4);
|
||||
height: 8px;
|
||||
border-left: 2px solid rgba(245, 230, 200, 0.75);
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.tick span {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
bottom: 14px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 10px;
|
||||
color: rgba(201, 169, 110, 0.7);
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #f5e6c8;
|
||||
text-shadow:
|
||||
0 0 8px rgba(0, 0, 0, 0.95),
|
||||
0 1px 2px rgba(0, 0, 0, 1),
|
||||
0 0 1px rgba(0, 0, 0, 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@ import {
|
||||
} from '../data/historical-events';
|
||||
import './Timeline.css';
|
||||
|
||||
interface LifespanHighlight {
|
||||
birthYear: number;
|
||||
deathYear: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
eras: HistoricalEra[];
|
||||
viewStart: number;
|
||||
@@ -15,6 +21,7 @@ interface Props {
|
||||
onViewChange: (start: number, end: number) => void;
|
||||
absoluteMin: number;
|
||||
absoluteMax: number;
|
||||
lifespanHighlight?: LifespanHighlight | null;
|
||||
}
|
||||
|
||||
function yearToPercent(year: number, start: number, end: number): number {
|
||||
@@ -26,7 +33,7 @@ function formatYear(year: number): string {
|
||||
return `${year} CE`;
|
||||
}
|
||||
|
||||
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax }: Props) {
|
||||
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState<'left' | 'right' | 'pan' | null>(null);
|
||||
const dragStart = useRef({ x: 0, viewStart: 0, viewEnd: 0 });
|
||||
@@ -169,6 +176,15 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
|
||||
});
|
||||
}, [viewStart, viewEnd, span]);
|
||||
|
||||
const lifespanBand = useMemo(() => {
|
||||
if (!lifespanHighlight) return null;
|
||||
const left = yearToPercent(Math.max(lifespanHighlight.birthYear, viewStart), viewStart, viewEnd);
|
||||
const right = yearToPercent(Math.min(lifespanHighlight.deathYear, viewEnd), viewStart, viewEnd);
|
||||
const width = right - left;
|
||||
if (width <= 0) return null;
|
||||
return { left, width, color: lifespanHighlight.color };
|
||||
}, [lifespanHighlight, viewStart, viewEnd]);
|
||||
|
||||
return (
|
||||
<div className="timeline-wrapper">
|
||||
<div className="timeline-controls">
|
||||
@@ -182,7 +198,7 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="timeline-container"
|
||||
className={`timeline-container${lifespanBand ? ' timeline-container-lifespan-hover' : ''}`}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={(e) => handleMouseDown(e, 'pan')}
|
||||
>
|
||||
@@ -218,6 +234,31 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
|
||||
})}
|
||||
</div>
|
||||
|
||||
{lifespanBand && (
|
||||
<div className="timeline-lifespan-overlays" aria-hidden>
|
||||
{lifespanBand.left > 0 && (
|
||||
<div className="timeline-lifespan-dim" style={{ left: 0, width: `${lifespanBand.left}%` }} />
|
||||
)}
|
||||
{lifespanBand.left + lifespanBand.width < 100 && (
|
||||
<div
|
||||
className="timeline-lifespan-dim"
|
||||
style={{
|
||||
left: `${lifespanBand.left + lifespanBand.width}%`,
|
||||
width: `${100 - lifespanBand.left - lifespanBand.width}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="timeline-lifespan-highlight"
|
||||
style={{
|
||||
left: `${lifespanBand.left}%`,
|
||||
width: `${lifespanBand.width}%`,
|
||||
['--lifespan-color' as string]: lifespanBand.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="timeline-events" aria-hidden={false}>
|
||||
{visibleEvents.map(({ event, showLabel }) => {
|
||||
const end = eventEndYear(event);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
.timeline-event-guides {
|
||||
position: absolute;
|
||||
top: 78px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.timeline-event-guide-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
transform: translateX(-50%);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 210, 120, 0.55) 0%,
|
||||
rgba(255, 180, 90, 0.35) 35%,
|
||||
rgba(255, 160, 80, 0.18) 100%
|
||||
);
|
||||
box-shadow: 0 0 8px rgba(255, 190, 100, 0.25);
|
||||
}
|
||||
|
||||
.timeline-event-guide-span {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(180, 70, 55, 0.1);
|
||||
border-left: 2px solid rgba(255, 150, 110, 0.35);
|
||||
border-right: 2px solid rgba(255, 150, 110, 0.35);
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { HISTORICAL_EVENTS, eventEndYear, eventInView } from '../data/historical-events';
|
||||
import './TimelineEventGuides.css';
|
||||
|
||||
function yearToPercent(year: number, start: number, end: number): number {
|
||||
return ((year - start) / (end - start)) * 100;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
viewStart: number;
|
||||
viewEnd: number;
|
||||
}
|
||||
|
||||
export default function TimelineEventGuides({ viewStart, viewEnd }: Props) {
|
||||
const events = HISTORICAL_EVENTS.filter((event) => eventInView(event, viewStart, viewEnd));
|
||||
|
||||
return (
|
||||
<div className="timeline-event-guides" aria-hidden>
|
||||
{events.map((event) => {
|
||||
const end = eventEndYear(event);
|
||||
const isSpan = event.endYear != null && event.endYear !== event.startYear;
|
||||
|
||||
if (isSpan) {
|
||||
const left = yearToPercent(Math.max(event.startYear, viewStart), viewStart, viewEnd);
|
||||
const right = yearToPercent(Math.min(end, viewEnd), viewStart, viewEnd);
|
||||
if (right <= 0 || left >= 100) return null;
|
||||
const width = Math.min(100, right) - Math.max(0, left);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="timeline-event-guide-span"
|
||||
style={{
|
||||
left: `${Math.max(0, left)}%`,
|
||||
width: `${width}%`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (event.startYear < viewStart || event.startYear > viewEnd) return null;
|
||||
const left = yearToPercent(event.startYear, viewStart, viewEnd);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="timeline-event-guide-line"
|
||||
style={{ left: `${left}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -380,3 +380,42 @@
|
||||
border-bottom: 1px solid rgba(201, 169, 110, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.movement-hall-nav-list {
|
||||
list-style: none;
|
||||
margin: 0 0 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.movement-hall-nav-list li + li {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.movement-hall-nav-list button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
border-radius: 8px;
|
||||
background: rgba(30, 24, 18, 0.6);
|
||||
color: #e8d5b5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.movement-hall-nav-list button:hover,
|
||||
.movement-hall-nav-active {
|
||||
border-color: rgba(212, 175, 55, 0.75) !important;
|
||||
background: rgba(60, 48, 32, 0.85) !important;
|
||||
}
|
||||
|
||||
.movement-hall-nav-list small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.movement-hall-exit-timeline {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@@ -11,10 +11,19 @@ export interface HistoricalEvent {
|
||||
|
||||
export const HISTORICAL_EVENTS: readonly HistoricalEvent[] = [
|
||||
{ id: 'fall-rome', name: 'Fall of Rome', startYear: 476 },
|
||||
{ id: 'charlemagne', name: 'Charlemagne crowned emperor', startYear: 800 },
|
||||
{ id: 'battle-hastings', name: 'Battle of Hastings', startYear: 1066 },
|
||||
{ id: 'first-crusade', name: 'First Crusade', startYear: 1096, endYear: 1099 },
|
||||
{ id: 'magna-carta', name: 'Magna Carta', startYear: 1215 },
|
||||
{ id: 'black-death', name: 'Black Death', startYear: 1347 },
|
||||
{ id: 'printing-press', name: 'Printing press', startYear: 1450 },
|
||||
{ id: 'reformation', name: 'Protestant Reformation', startYear: 1517 },
|
||||
{ id: 'fall-constantinople', name: 'Fall of Constantinople', startYear: 1453 },
|
||||
{ id: 'columbus', name: 'Columbus reaches the Americas', startYear: 1492 },
|
||||
{ id: 'reformation', name: 'Protestant Reformation', startYear: 1517 },
|
||||
{ id: 'thirty-years-war', name: 'Thirty Years\' War', startYear: 1618, endYear: 1648, shortLabel: '30 Years\' War' },
|
||||
{ id: 'english-civil-war', name: 'English Civil War', startYear: 1642, endYear: 1651 },
|
||||
{ id: 'glorious-revolution', name: 'Glorious Revolution', startYear: 1688 },
|
||||
{ id: 'war-spanish-succession', name: 'War of the Spanish Succession', startYear: 1701, endYear: 1714, shortLabel: 'Spanish Succession' },
|
||||
{ id: 'american-revolution', name: 'American Revolution', startYear: 1776 },
|
||||
{ id: 'french-revolution', name: 'French Revolution', startYear: 1789 },
|
||||
{ id: 'waterloo', name: 'Battle of Waterloo', startYear: 1815 },
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
import type { ArtMovement } from '../types';
|
||||
import type { SurfaceTextureKind } from '../utils/galleryProceduralTextures';
|
||||
|
||||
export type GalleryWindowStyle =
|
||||
| 'roman-arch'
|
||||
| 'gothic-lancet'
|
||||
| 'baroque-pair'
|
||||
| 'sash'
|
||||
| 'factory'
|
||||
| 'skylight'
|
||||
| 'art-nouveau'
|
||||
| 'glass-block'
|
||||
| 'clerestory'
|
||||
| 'round-oculus';
|
||||
|
||||
export interface GalleryWindowSpec {
|
||||
wall: 'back' | 'left' | 'right' | 'ceiling';
|
||||
/** Offset along wall axis from center (meters). */
|
||||
x: number;
|
||||
/** Vertical center height (meters). */
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
style: GalleryWindowStyle;
|
||||
lightColor: string;
|
||||
lightIntensity: number;
|
||||
}
|
||||
|
||||
export interface MovementInteriorStyle {
|
||||
id: string;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
surfaces: {
|
||||
wall: SurfaceTextureKind;
|
||||
wallSide?: SurfaceTextureKind;
|
||||
ceiling: SurfaceTextureKind;
|
||||
floor: SurfaceTextureKind;
|
||||
};
|
||||
tints: {
|
||||
wall: string;
|
||||
wallSide?: string;
|
||||
ceiling: string;
|
||||
floor: string;
|
||||
trim: string;
|
||||
};
|
||||
titleColor: string;
|
||||
ambient: number;
|
||||
warmLight: string;
|
||||
sunLight: string;
|
||||
fog: string;
|
||||
background: string;
|
||||
doorWood: [string, string, string];
|
||||
windows: GalleryWindowSpec[];
|
||||
trackLights: number;
|
||||
details: 'palazzo' | 'baroque' | 'medieval' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier';
|
||||
}
|
||||
|
||||
function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] {
|
||||
return specs;
|
||||
}
|
||||
|
||||
function mk(
|
||||
id: string,
|
||||
label: string,
|
||||
subtitle: string,
|
||||
surfaces: MovementInteriorStyle['surfaces'],
|
||||
tints: MovementInteriorStyle['tints'],
|
||||
opts: Partial<Omit<MovementInteriorStyle, 'id' | 'label' | 'subtitle' | 'surfaces' | 'tints'>> & {
|
||||
details: MovementInteriorStyle['details'];
|
||||
windows: GalleryWindowSpec[];
|
||||
}
|
||||
): MovementInteriorStyle {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
subtitle,
|
||||
surfaces,
|
||||
tints,
|
||||
titleColor: opts.titleColor ?? '#4a3020',
|
||||
ambient: opts.ambient ?? 0.55,
|
||||
warmLight: opts.warmLight ?? '#fff4e8',
|
||||
sunLight: opts.sunLight ?? '#fffaf0',
|
||||
fog: opts.fog ?? '#1a1814',
|
||||
background: opts.background ?? '#121010',
|
||||
doorWood: opts.doorWood ?? ['#3d2818', '#4e3624', '#261a10'],
|
||||
windows: opts.windows,
|
||||
trackLights: opts.trackLights ?? 0.8,
|
||||
details: opts.details,
|
||||
};
|
||||
}
|
||||
|
||||
/** Unique photo-real interior per movement (keyed by database id). */
|
||||
const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
27: mk(
|
||||
'ancient-classical',
|
||||
'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' },
|
||||
{
|
||||
details: 'classical',
|
||||
titleColor: '#5a4830',
|
||||
ambient: 0.62,
|
||||
warmLight: '#fff8e8',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: -2.5, y: 3.2, width: 1.4, height: 1.8, style: 'roman-arch', lightColor: '#fff8e0', lightIntensity: 3.2 },
|
||||
{ wall: 'back', x: 2.5, y: 3.2, width: 1.4, height: 1.8, style: 'roman-arch', lightColor: '#fff8e0', lightIntensity: 3.2 },
|
||||
{ wall: 'left', x: 0, y: 3.0, width: 2.0, height: 1.6, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.8 },
|
||||
{ wall: 'right', x: 0, y: 3.0, width: 2.0, height: 1.6, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.8 }
|
||||
),
|
||||
trackLights: 0.4,
|
||||
doorWood: ['#6a5840', '#7a6848', '#5a4830'],
|
||||
}
|
||||
),
|
||||
28: mk(
|
||||
'byzantine-sanctuary',
|
||||
'Byzantine chapel',
|
||||
'Gold mosaic walls · amber light through arched windows',
|
||||
{ wall: 'mosaic-byzantine', ceiling: 'gilded-stucco', floor: 'marble-checker' },
|
||||
{ wall: '#d4af37', ceiling: '#c8a840', floor: '#ddd8cc', trim: '#b8860b' },
|
||||
{
|
||||
details: 'medieval',
|
||||
titleColor: '#f0e8c0',
|
||||
ambient: 0.48,
|
||||
warmLight: '#ffd898',
|
||||
sunLight: '#ffe8b0',
|
||||
fog: '#0a0806',
|
||||
background: '#060504',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.8, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 2.4 },
|
||||
{ wall: 'left', x: -1, y: 2.6, width: 0.7, height: 1.8, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 1.8 },
|
||||
{ wall: 'right', x: 1, y: 2.6, width: 0.7, height: 1.8, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 1.8 }
|
||||
),
|
||||
trackLights: 0.5,
|
||||
doorWood: ['#3a3020', '#4a3830', '#2a2018'],
|
||||
}
|
||||
),
|
||||
29: mk(
|
||||
'gothic-cathedral',
|
||||
'Gothic hall',
|
||||
'Stone vault · tall lancet windows · flagstone floor',
|
||||
{ wall: 'rough-stone', ceiling: 'basalt', floor: 'flagstone' },
|
||||
{ wall: '#8a8478', ceiling: '#3a3630', floor: '#6a6458', trim: '#5c5648' },
|
||||
{
|
||||
details: 'medieval',
|
||||
titleColor: '#e8dcc8',
|
||||
ambient: 0.52,
|
||||
warmLight: '#e8d8c0',
|
||||
sunLight: '#d0e8ff',
|
||||
fog: '#0c0c10',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: -2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.5 },
|
||||
{ wall: 'back', x: 2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.5 },
|
||||
{ wall: 'left', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 },
|
||||
{ wall: 'right', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 }
|
||||
),
|
||||
trackLights: 0.3,
|
||||
}
|
||||
),
|
||||
30: mk(
|
||||
'early-renaissance-palazzo',
|
||||
'Florentine palazzo',
|
||||
'Stucco salone · terracotta accents · arched windows',
|
||||
{ wall: 'stucco-cream', ceiling: 'fresco-worn', floor: 'terracotta-tiles' },
|
||||
{ wall: '#f4ebe0', ceiling: '#faf6ee', floor: '#c89070', trim: '#c9a227' },
|
||||
{
|
||||
details: 'palazzo',
|
||||
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 },
|
||||
{ wall: 'back', x: 2.2, y: 2.6, width: 1.3, height: 1.7, style: 'roman-arch', lightColor: '#fff4e0', lightIntensity: 3.0 },
|
||||
{ wall: 'left', x: 0, y: 2.8, width: 2.2, height: 1.4, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.6 }
|
||||
),
|
||||
}
|
||||
),
|
||||
31: mk(
|
||||
'high-renaissance-palazzo',
|
||||
'Roman palazzo',
|
||||
'Marble and stucco · coffered ceiling · checker floor',
|
||||
{ wall: 'marble-veined-carrara', wallSide: 'stucco-cream', ceiling: 'plaster-warm', floor: 'marble-checker' },
|
||||
{ wall: '#f0ece4', wallSide: '#efe4d4', ceiling: '#faf6ee', floor: '#ddd8cc', trim: '#c9a227' },
|
||||
{
|
||||
details: 'palazzo',
|
||||
titleColor: '#6b4423',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.8, width: 2.8, height: 1.8, style: 'baroque-pair', lightColor: '#fff8f0', lightIntensity: 3.8 },
|
||||
{ wall: 'left', x: 0, y: 3.0, width: 2.0, height: 1.5, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.8 },
|
||||
{ wall: 'right', x: 0, y: 3.0, width: 2.0, height: 1.5, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.8 },
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 3.0, height: 2.0, style: 'round-oculus', lightColor: '#ffffff', lightIntensity: 4.0 }
|
||||
),
|
||||
}
|
||||
),
|
||||
32: mk(
|
||||
'northern-renaissance-hall',
|
||||
'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' },
|
||||
{
|
||||
details: 'salon',
|
||||
titleColor: '#f0e8d8',
|
||||
warmLight: '#ffe8c8',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: -2, y: 2.5, width: 1.2, height: 1.5, style: 'sash', lightColor: '#e8f0ff', lightIntensity: 2.8 },
|
||||
{ wall: 'back', x: 2, y: 2.5, width: 1.2, height: 1.5, style: 'sash', lightColor: '#e8f0ff', lightIntensity: 2.8 },
|
||||
{ wall: 'left', x: 0, y: 2.6, width: 1.8, height: 1.4, style: 'sash', lightColor: '#e8f0ff', lightIntensity: 2.4 }
|
||||
),
|
||||
doorWood: ['#4a3020', '#5c3a28', '#3a2010'],
|
||||
}
|
||||
),
|
||||
33: mk(
|
||||
'mannerist-villa',
|
||||
'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' },
|
||||
{
|
||||
details: 'baroque',
|
||||
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 },
|
||||
{ wall: 'back', x: 1.8, y: 2.7, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#fff0d8', lightIntensity: 2.8 },
|
||||
{ wall: 'right', x: 0, y: 3.0, width: 1.6, height: 1.2, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.2 }
|
||||
),
|
||||
}
|
||||
),
|
||||
34: mk(
|
||||
'baroque-palace',
|
||||
'Baroque state gallery',
|
||||
'Crimson velvet · gilded stucco · grand windows',
|
||||
{ wall: 'velvet-crimson', ceiling: 'gilded-stucco', floor: 'marble-veined-carrara' },
|
||||
{ wall: '#5c1828', ceiling: '#d8c070', floor: '#ece8e0', trim: '#d4af37' },
|
||||
{
|
||||
details: 'baroque',
|
||||
titleColor: '#f0d890',
|
||||
ambient: 0.58,
|
||||
warmLight: '#ffd898',
|
||||
fog: '#100808',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.6, width: 3.2, height: 2.2, style: 'baroque-pair', lightColor: '#fff8e8', lightIntensity: 4.5 },
|
||||
{ wall: 'left', x: 0, y: 2.8, width: 1.8, height: 1.8, style: 'baroque-pair', lightColor: '#fff8e8', lightIntensity: 3.2 },
|
||||
{ wall: 'right', x: 0, y: 2.8, width: 1.8, height: 1.8, style: 'baroque-pair', lightColor: '#fff8e8', lightIntensity: 3.2 },
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 2.5, height: 1.8, style: 'skylight', lightColor: '#ffffff', lightIntensity: 3.5 }
|
||||
),
|
||||
trackLights: 0.6,
|
||||
doorWood: ['#1a1008', '#2a1810', '#120c06'],
|
||||
}
|
||||
),
|
||||
35: mk(
|
||||
'rococo-salon',
|
||||
'Rococo salon',
|
||||
'Pastel stucco · gilt trim · chevron parquet',
|
||||
{ wall: 'pastel-plaster', ceiling: 'silk-pale', floor: 'parquet-chevrons' },
|
||||
{ wall: '#e8dce8', ceiling: '#faf6f0', floor: '#c8a882', trim: '#d4af37' },
|
||||
{
|
||||
details: 'salon',
|
||||
titleColor: '#6a4858',
|
||||
warmLight: '#fff0f0',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: -2, y: 2.5, width: 1.4, height: 1.8, style: 'baroque-pair', lightColor: '#fff8ff', lightIntensity: 3.4 },
|
||||
{ wall: 'back', x: 2, y: 2.5, width: 1.4, height: 1.8, style: 'baroque-pair', lightColor: '#fff8ff', lightIntensity: 3.4 },
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 2.0, height: 1.5, style: 'skylight', lightColor: '#ffffff', lightIntensity: 3.0 }
|
||||
),
|
||||
}
|
||||
),
|
||||
36: mk(
|
||||
'neoclassical-museum',
|
||||
'Neoclassical museum',
|
||||
'White marble · coffered ceiling · skylit salon',
|
||||
{ wall: 'marble-white', ceiling: 'plaster-white', floor: 'marble-veined-carrara' },
|
||||
{ wall: '#f2f0ec', ceiling: '#fafafa', floor: '#eceae6', trim: '#b8b0a4' },
|
||||
{
|
||||
details: 'neoclassical',
|
||||
titleColor: '#4a4844',
|
||||
ambient: 0.65,
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.6, width: 2.8, height: 2.0, style: 'baroque-pair', lightColor: '#ffffff', lightIntensity: 4.0 },
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 4.0, height: 2.5, style: 'skylight', lightColor: '#ffffff', lightIntensity: 5.0 },
|
||||
{ wall: 'left', x: 0, y: 3.0, width: 2.0, height: 1.2, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.5 }
|
||||
),
|
||||
trackLights: 0.7,
|
||||
}
|
||||
),
|
||||
37: mk(
|
||||
'romantic-gothic-revival',
|
||||
'Romantic gallery',
|
||||
'Dark walnut paneling · pointed windows · Persian carpet tones',
|
||||
{ wall: 'walnut-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||
{ wall: '#5a4030', ceiling: '#2a2420', floor: '#6a5040', trim: '#8a7050' },
|
||||
{
|
||||
details: 'salon',
|
||||
titleColor: '#e8dcc8',
|
||||
ambient: 0.5,
|
||||
warmLight: '#ffe0c0',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: -1.5, y: 2.6, width: 0.9, height: 2.2, style: 'gothic-lancet', lightColor: '#c8d8ff', lightIntensity: 2.6 },
|
||||
{ wall: 'back', x: 1.5, y: 2.6, width: 0.9, height: 2.2, style: 'gothic-lancet', lightColor: '#c8d8ff', lightIntensity: 2.6 },
|
||||
{ wall: 'right', x: 0, y: 2.5, width: 1.4, height: 1.6, style: 'sash', lightColor: '#e8f0ff', lightIntensity: 2.2 }
|
||||
),
|
||||
}
|
||||
),
|
||||
38: mk(
|
||||
'realist-bourgeois',
|
||||
'Realist picture gallery',
|
||||
'Warm plaster · bourgeois salon · oak parquet',
|
||||
{ wall: 'plaster-warm', ceiling: 'plaster-warm', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e8e2d8', ceiling: '#f5f0e8', floor: '#a08060', trim: '#8a7050' },
|
||||
{
|
||||
details: 'salon',
|
||||
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 }
|
||||
),
|
||||
}
|
||||
),
|
||||
39: mk(
|
||||
'impressionist-salon',
|
||||
'Impressionist salon',
|
||||
'North-light skylight · pale walls · herringbone floor',
|
||||
{ wall: 'plaster-warm', ceiling: 'plaster-white', floor: 'parquet-herringbone' },
|
||||
{ wall: '#f0ebe3', ceiling: '#fafafa', floor: '#c8a882', trim: '#c9a96e' },
|
||||
{
|
||||
details: 'salon',
|
||||
ambient: 0.68,
|
||||
windows: windows(
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 5.0, height: 3.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 6.0 },
|
||||
{ wall: 'back', x: -2, y: 2.6, width: 1.2, height: 1.4, style: 'sash', lightColor: '#f0f8ff', lightIntensity: 2.5 },
|
||||
{ wall: 'back', x: 2, y: 2.6, width: 1.2, height: 1.4, style: 'sash', lightColor: '#f0f8ff', lightIntensity: 2.5 }
|
||||
),
|
||||
trackLights: 0.5,
|
||||
}
|
||||
),
|
||||
40: mk(
|
||||
'post-impressionist-atelier',
|
||||
'Montmartre atelier',
|
||||
'Studio walls · large north window · worn floorboards',
|
||||
{ wall: 'plaster-warm', ceiling: 'plaster-warm', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e8e0d0', ceiling: '#f0ebe0', floor: '#9a7858', trim: '#8a6848' },
|
||||
{
|
||||
details: 'atelier',
|
||||
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 }
|
||||
),
|
||||
}
|
||||
),
|
||||
41: mk(
|
||||
'symbolist-chamber',
|
||||
'Symbolist chamber',
|
||||
'Deep emerald walls · amber window glow · dark parquet',
|
||||
{ wall: 'velvet-emerald', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||
{ wall: '#1a4030', ceiling: '#1a1814', floor: '#4a3828', trim: '#8a7050' },
|
||||
{
|
||||
details: 'salon',
|
||||
titleColor: '#d8e8c8',
|
||||
ambient: 0.55,
|
||||
warmLight: '#ffd898',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.4, width: 1.0, height: 1.8, style: 'sash', lightColor: '#ffb860', lightIntensity: 2.0 },
|
||||
{ wall: 'right', x: 0, y: 2.6, width: 0.8, height: 1.4, style: 'gothic-lancet', lightColor: '#ffd080', lightIntensity: 1.6 }
|
||||
),
|
||||
trackLights: 0.9,
|
||||
}
|
||||
),
|
||||
42: mk(
|
||||
'art-nouveau-salon',
|
||||
'Art Nouveau salon',
|
||||
'Organic plaster · stained glass · terrazzo floor',
|
||||
{ wall: 'silk-pale', ceiling: 'silk-gold', floor: 'terrazzo' },
|
||||
{ wall: '#f0ece4', ceiling: '#d8c890', floor: '#d8d0c4', trim: '#6a9868' },
|
||||
{
|
||||
details: 'salon',
|
||||
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 },
|
||||
{ wall: 'left', x: 0, y: 2.6, width: 1.4, height: 1.8, style: 'art-nouveau', lightColor: '#ffe8f0', lightIntensity: 2.4 },
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 2.0, height: 1.2, style: 'skylight', lightColor: '#ffffff', lightIntensity: 2.8 }
|
||||
),
|
||||
}
|
||||
),
|
||||
43: mk(
|
||||
'fauvist-studio',
|
||||
'Fauvist studio',
|
||||
'Bold warm plaster · flooded with color and light',
|
||||
{ wall: 'stucco-terracotta', wallSide: 'silk-gold', ceiling: 'plaster-warm', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e89060', wallSide: '#e8c860', ceiling: '#f8f0e0', floor: '#a07048', trim: '#c04020' },
|
||||
{
|
||||
details: 'atelier',
|
||||
titleColor: '#402010',
|
||||
ambient: 0.7,
|
||||
windows: windows(
|
||||
{ wall: 'left', x: 0, y: 2.5, width: 3.0, height: 2.2, style: 'factory', lightColor: '#fff8f0', lightIntensity: 5.0 },
|
||||
{ wall: 'back', x: 0, y: 2.8, width: 1.6, height: 1.2, style: 'sash', lightColor: '#fff0e0', lightIntensity: 2.5 }
|
||||
),
|
||||
}
|
||||
),
|
||||
44: mk(
|
||||
'expressionist-room',
|
||||
'Expressionist room',
|
||||
'Angular wood panels · dramatic raking light',
|
||||
{ wall: 'dark-wood-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||
{ wall: '#3a2818', ceiling: '#2a2018', floor: '#5a4030', trim: '#8a6040' },
|
||||
{
|
||||
details: 'atelier',
|
||||
titleColor: '#f0d8b0',
|
||||
ambient: 0.52,
|
||||
windows: windows(
|
||||
{ wall: 'back', x: -1.5, y: 2.6, width: 1.0, height: 1.6, style: 'sash', lightColor: '#ffe8c0', lightIntensity: 2.8 },
|
||||
{ wall: 'right', x: 0, y: 2.4, width: 2.0, height: 1.8, style: 'factory', lightColor: '#fff0d8', lightIntensity: 3.5 }
|
||||
),
|
||||
}
|
||||
),
|
||||
45: mk(
|
||||
'cubist-studio',
|
||||
'Cubist studio',
|
||||
'Paris atelier · factory windows · raw plaster',
|
||||
{ wall: 'plaster-warm', ceiling: 'plaster-white', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e8e4dc', ceiling: '#f5f5f5', floor: '#9a8870', trim: '#888888' },
|
||||
{
|
||||
details: 'atelier',
|
||||
ambient: 0.65,
|
||||
windows: windows(
|
||||
{ wall: 'left', x: 0, y: 2.5, width: 3.5, height: 2.4, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 5.5 },
|
||||
{ wall: 'back', x: 0, y: 3.0, width: 1.8, height: 1.0, style: 'clerestory', lightColor: '#ffffff', lightIntensity: 2.5 }
|
||||
),
|
||||
}
|
||||
),
|
||||
46: mk(
|
||||
'futurist-loft',
|
||||
'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' },
|
||||
{
|
||||
details: 'industrial',
|
||||
titleColor: '#303030',
|
||||
ambient: 0.62,
|
||||
fog: '#181818',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.6, width: 4.0, height: 2.4, style: 'factory', lightColor: '#ffffff', lightIntensity: 6.0 },
|
||||
{ wall: 'left', x: 0, y: 2.8, width: 2.5, height: 1.6, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 3.5 },
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 3.0, height: 2.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 4.0 }
|
||||
),
|
||||
doorWood: ['#606060', '#707070', '#505050'],
|
||||
}
|
||||
),
|
||||
47: mk(
|
||||
'suprematist-gallery',
|
||||
'Suprematist white cube',
|
||||
'Pure white volume · geometric light · polished floor',
|
||||
{ wall: 'white-plaster', ceiling: 'white-plaster', floor: 'concrete-polished' },
|
||||
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#e0e0e0', trim: '#cccccc' },
|
||||
{
|
||||
details: 'modern',
|
||||
titleColor: '#222222',
|
||||
ambient: 0.72,
|
||||
fog: '#1a1a1a',
|
||||
background: '#111111',
|
||||
windows: windows(
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 4.5, height: 3.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 6.5 },
|
||||
{ wall: 'back', x: 0, y: 2.8, width: 2.0, height: 1.2, style: 'clerestory', lightColor: '#ffffff', lightIntensity: 3.0 }
|
||||
),
|
||||
trackLights: 0.4,
|
||||
doorWood: ['#aaaaaa', '#bbbbbb', '#999999'],
|
||||
}
|
||||
),
|
||||
48: mk(
|
||||
'constructivist-space',
|
||||
'Constructivist space',
|
||||
'Glass block · concrete · angular daylight',
|
||||
{ wall: 'concrete-block', ceiling: 'concrete-raw', floor: 'concrete-polished' },
|
||||
{ wall: '#b0b0b0', ceiling: '#a0a0a0', floor: '#c0c0c0', trim: '#808080' },
|
||||
{
|
||||
details: 'industrial',
|
||||
titleColor: '#303030',
|
||||
ambient: 0.65,
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.5, width: 2.8, height: 2.0, style: 'glass-block', lightColor: '#f0f8ff', lightIntensity: 4.0 },
|
||||
{ wall: 'left', x: 0, y: 2.6, width: 2.0, height: 1.8, style: 'glass-block', lightColor: '#f0f8ff', lightIntensity: 3.2 },
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 2.5, height: 1.5, style: 'skylight', lightColor: '#ffffff', lightIntensity: 3.5 }
|
||||
),
|
||||
doorWood: ['#707070', '#808080', '#606060'],
|
||||
}
|
||||
),
|
||||
49: mk(
|
||||
'dada-salon',
|
||||
'Dada salon',
|
||||
'Eclectic bourgeois room · mismatched light sources',
|
||||
{ wall: 'plaster-warm', wallSide: 'brick', ceiling: 'plaster-warm', floor: 'parquet-herringbone' },
|
||||
{ wall: '#e8dcc8', wallSide: '#8a5040', ceiling: '#f0ebe0', floor: '#8a6848', trim: '#6a4830' },
|
||||
{
|
||||
details: 'salon',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: -1.5, y: 2.5, width: 1.0, height: 1.4, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.5 },
|
||||
{ wall: 'back', x: 1.5, y: 2.6, width: 0.8, height: 1.8, style: 'gothic-lancet', lightColor: '#ffe8c0', lightIntensity: 2.0 },
|
||||
{ wall: 'right', x: 0, y: 2.4, width: 1.6, height: 1.6, style: 'factory', lightColor: '#ffffff', lightIntensity: 3.0 }
|
||||
),
|
||||
trackLights: 1.0,
|
||||
}
|
||||
),
|
||||
50: mk(
|
||||
'surrealist-interior',
|
||||
'Surrealist interior',
|
||||
'Bourgeois wallpaper tones · uncanny warm light',
|
||||
{ wall: 'velvet-navy', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
|
||||
{ wall: '#1a2848', ceiling: '#2a2428', floor: '#5a4838', trim: '#8a7050' },
|
||||
{
|
||||
details: 'salon',
|
||||
titleColor: '#e8dcc8',
|
||||
ambient: 0.58,
|
||||
warmLight: '#ffd898',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.5, width: 1.4, height: 1.6, style: 'sash', lightColor: '#ffe8c8', lightIntensity: 2.8 },
|
||||
{ wall: 'left', x: 0, y: 2.7, width: 1.0, height: 1.2, style: 'sash', lightColor: '#c8e0ff', lightIntensity: 2.0 },
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 1.5, height: 1.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 2.5 }
|
||||
),
|
||||
trackLights: 0.85,
|
||||
}
|
||||
),
|
||||
51: mk(
|
||||
'abstract-expressionist-loft',
|
||||
'NYC loft',
|
||||
'Raw brick and plaster · north-light factory windows',
|
||||
{ wall: 'brick', wallSide: 'plaster-white', ceiling: 'concrete-raw', floor: 'industrial-floor' },
|
||||
{ wall: '#8a5040', wallSide: '#f5f5f5', ceiling: '#989898', floor: '#888880', trim: '#606060' },
|
||||
{
|
||||
details: 'industrial',
|
||||
titleColor: '#303030',
|
||||
ambient: 0.65,
|
||||
windows: windows(
|
||||
{ wall: 'left', x: 0, y: 2.5, width: 4.0, height: 2.6, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 6.5 },
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 3.5, height: 2.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 4.5 }
|
||||
),
|
||||
}
|
||||
),
|
||||
52: mk(
|
||||
'pop-art-gallery',
|
||||
'Pop Art gallery',
|
||||
'White cube · fluorescent daylight · polished concrete',
|
||||
{ wall: 'studio-white', ceiling: 'studio-white', floor: 'concrete-polished' },
|
||||
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#d8d8d8', trim: '#cccccc' },
|
||||
{
|
||||
details: 'modern',
|
||||
titleColor: '#222222',
|
||||
ambient: 0.75,
|
||||
warmLight: '#ffffff',
|
||||
fog: '#1a1a1a',
|
||||
background: '#101010',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.6, width: 3.0, height: 2.0, style: 'factory', lightColor: '#ffffff', lightIntensity: 5.5 },
|
||||
{ wall: 'ceiling', x: 0, y: 0, width: 5.0, height: 3.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 6.0 }
|
||||
),
|
||||
trackLights: 0.6,
|
||||
doorWood: ['#888888', '#999999', '#777777'],
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
function blendAccent(style: MovementInteriorStyle, accentHex?: string): MovementInteriorStyle {
|
||||
if (!accentHex) return style;
|
||||
const w = 0.08;
|
||||
const mix = (a: string, b: string) => {
|
||||
const pa = parseInt(a.replace('#', ''), 16);
|
||||
const pb = parseInt(b.replace('#', ''), 16);
|
||||
const r = Math.round(((pa >> 16) & 255) * w + ((pb >> 16) & 255) * (1 - w));
|
||||
const g = Math.round(((pa >> 8) & 255) * w + ((pb >> 8) & 255) * (1 - w));
|
||||
const bl = Math.round((pa & 255) * w + (pb & 255) * (1 - w));
|
||||
return `#${((r << 16) | (g << 8) | bl).toString(16).padStart(6, '0')}`;
|
||||
};
|
||||
return {
|
||||
...style,
|
||||
tints: {
|
||||
...style.tints,
|
||||
wall: mix(accentHex, style.tints.wall),
|
||||
wallSide: style.tints.wallSide ? mix(accentHex, style.tints.wallSide) : undefined,
|
||||
trim: mix(accentHex, style.tints.trim),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Fallback name-based resolver for movements not in the catalog. */
|
||||
function fallbackByName(movement: ArtMovement & { era_name?: string }): MovementInteriorStyle {
|
||||
const n = movement.name.toLowerCase();
|
||||
const era = (movement.era_name ?? '').toLowerCase();
|
||||
if (n.includes('renaissance')) return BY_MOVEMENT_ID[31];
|
||||
if (n.includes('baroque') || n.includes('rococo')) return BY_MOVEMENT_ID[34];
|
||||
if (era.includes('medieval') || n.includes('gothic')) return BY_MOVEMENT_ID[29];
|
||||
if (n.includes('impression')) return BY_MOVEMENT_ID[39];
|
||||
if (era.includes('modern') || era.includes('contemporary')) return BY_MOVEMENT_ID[52];
|
||||
return BY_MOVEMENT_ID[36];
|
||||
}
|
||||
|
||||
export function resolveMovementInteriorStyle(
|
||||
movement: ArtMovement & { era_name?: string }
|
||||
): MovementInteriorStyle {
|
||||
const base = BY_MOVEMENT_ID[movement.id] ?? fallbackByName(movement);
|
||||
return blendAccent(base, movement.color);
|
||||
}
|
||||
|
||||
/** @deprecated use surfaces.floor — kept for transitional imports */
|
||||
export type GalleryFloorKind = string;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { cloneSurfaceTexture, getSurfaceTexture, type SurfaceTextureKind } from '../utils/galleryProceduralTextures';
|
||||
|
||||
export function useTexturedMaterial(
|
||||
kind: SurfaceTextureKind,
|
||||
tint: string,
|
||||
spanW: number,
|
||||
spanH: number
|
||||
): THREE.MeshStandardMaterial {
|
||||
const material = useMemo(() => {
|
||||
const meters = getSurfaceTexture(kind).metersPerRepeat;
|
||||
const surf = cloneSurfaceTexture(
|
||||
kind,
|
||||
Math.max(1, spanW / meters),
|
||||
Math.max(1, spanH / meters)
|
||||
);
|
||||
return new THREE.MeshStandardMaterial({
|
||||
map: surf.map,
|
||||
normalMap: surf.normalMap,
|
||||
color: tint,
|
||||
roughness: surf.roughness,
|
||||
metalness: surf.metalness,
|
||||
envMapIntensity: 0.65,
|
||||
});
|
||||
}, [kind, tint, spanW, spanH]);
|
||||
|
||||
useEffect(() => () => material.dispose(), [material]);
|
||||
return material;
|
||||
}
|
||||
@@ -11,6 +11,17 @@
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.home-timeline-stack {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gallery-session-suspended {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import Timeline from '../components/Timeline';
|
||||
import TimelineEventGuides from '../components/TimelineEventGuides';
|
||||
import MovementBands from '../components/MovementBands';
|
||||
import VirtualGallery from '../components/VirtualGallery';
|
||||
import PaintingDetailView from '../components/PaintingDetail';
|
||||
import ArtistBio from '../components/ArtistBio';
|
||||
import CheckupPage from '../pages/CheckupPage';
|
||||
import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail } from '../types';
|
||||
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types';
|
||||
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
|
||||
import { readDebugMode, writeDebugMode } from '../utils/debugMode';
|
||||
import './HomePage.css';
|
||||
@@ -15,9 +16,25 @@ type View =
|
||||
| { type: 'timeline' }
|
||||
| { type: 'checkup' }
|
||||
| { type: 'gallery'; artistId: number; data: ArtistDetail }
|
||||
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
|
||||
| { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View }
|
||||
| { type: 'bio'; artistId: number; data: ArtistDetail; returnTo: View };
|
||||
|
||||
type GallerySession =
|
||||
| { kind: 'artist'; artistId: number; data: ArtistDetail }
|
||||
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail };
|
||||
|
||||
function patchPaintingInMovementDetail(
|
||||
detail: MovementGalleryDetail,
|
||||
paintingId: number,
|
||||
patch: Partial<Painting>
|
||||
): MovementGalleryDetail {
|
||||
return {
|
||||
...detail,
|
||||
paintings: detail.paintings.map((p) => (p.id === paintingId ? { ...p, ...patch } : p)),
|
||||
};
|
||||
}
|
||||
|
||||
function patchPaintingInArtistDetail(
|
||||
detail: ArtistDetail,
|
||||
paintingId: number,
|
||||
@@ -38,9 +55,7 @@ function patchArtistInArtistDetail(detail: ArtistDetail, patch: Partial<Artist>)
|
||||
|
||||
export default function HomePage() {
|
||||
const [view, setView] = useState<View>({ type: 'timeline' });
|
||||
const [gallerySession, setGallerySession] = useState<{ artistId: number; data: ArtistDetail } | null>(
|
||||
null
|
||||
);
|
||||
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
|
||||
const [bounds, setBounds] = useState({ min: -800, max: 2025 });
|
||||
const [viewStart, setViewStart] = useState(-800);
|
||||
const [viewEnd, setViewEnd] = useState(2025);
|
||||
@@ -52,6 +67,11 @@ export default function HomePage() {
|
||||
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
|
||||
const [portraitRevisions, setPortraitRevisions] = useState<Record<number, number>>({});
|
||||
const [debugMode, setDebugMode] = useState(readDebugMode);
|
||||
const [hoveredLifespan, setHoveredLifespan] = useState<{
|
||||
birthYear: number;
|
||||
deathYear: number;
|
||||
color: string;
|
||||
} | null>(null);
|
||||
const detailReturnToRef = useRef<View>({ type: 'timeline' });
|
||||
|
||||
useEffect(() => {
|
||||
@@ -68,7 +88,9 @@ export default function HomePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (view.type === 'gallery') {
|
||||
setGallerySession({ artistId: view.artistId, data: view.data });
|
||||
setGallerySession({ kind: 'artist', artistId: view.artistId, data: view.data });
|
||||
} else if (view.type === 'movement-gallery') {
|
||||
setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data });
|
||||
} else if (view.type === 'timeline') {
|
||||
setGallerySession(null);
|
||||
}
|
||||
@@ -132,6 +154,12 @@ export default function HomePage() {
|
||||
data: patchPaintingInArtistDetail(returnTo.data, paintingId, patch),
|
||||
};
|
||||
}
|
||||
if (returnTo.type === 'movement-gallery') {
|
||||
returnTo = {
|
||||
...returnTo,
|
||||
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
|
||||
};
|
||||
}
|
||||
return { ...current, data: updatedData, returnTo };
|
||||
});
|
||||
|
||||
@@ -139,11 +167,15 @@ export default function HomePage() {
|
||||
list.map((p) => (p.id === paintingId ? { ...p, ...patch } : p))
|
||||
);
|
||||
|
||||
setGallerySession((session) =>
|
||||
session && session.artistId === data.painting.artist_id
|
||||
? { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) }
|
||||
: session
|
||||
);
|
||||
setGallerySession((session) => {
|
||||
if (session?.kind === 'artist' && session.artistId === data.painting.artist_id) {
|
||||
return { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) };
|
||||
}
|
||||
if (session?.kind === 'movement') {
|
||||
return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) };
|
||||
}
|
||||
return session;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handlePaintingCheckupFlagsUpdated = useCallback(
|
||||
@@ -167,6 +199,12 @@ export default function HomePage() {
|
||||
data: patchPaintingInArtistDetail(returnTo.data, paintingId, patch),
|
||||
};
|
||||
}
|
||||
if (returnTo.type === 'movement-gallery') {
|
||||
returnTo = {
|
||||
...returnTo,
|
||||
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
|
||||
};
|
||||
}
|
||||
return { ...current, data: updatedData, returnTo };
|
||||
});
|
||||
|
||||
@@ -174,11 +212,15 @@ export default function HomePage() {
|
||||
list.map((p) => (p.id === paintingId ? { ...p, ...patch } : p))
|
||||
);
|
||||
|
||||
setGallerySession((session) =>
|
||||
session && session.artistId === data.painting.artist_id
|
||||
? { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) }
|
||||
: session
|
||||
);
|
||||
setGallerySession((session) => {
|
||||
if (session?.kind === 'artist' && session.artistId === data.painting.artist_id) {
|
||||
return { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) };
|
||||
}
|
||||
if (session?.kind === 'movement') {
|
||||
return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) };
|
||||
}
|
||||
return session;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
@@ -209,7 +251,7 @@ export default function HomePage() {
|
||||
});
|
||||
|
||||
setGallerySession((session) =>
|
||||
session && session.artistId === artistId
|
||||
session?.kind === 'artist' && session.artistId === artistId
|
||||
? { ...session, data: patchArtistInArtistDetail(session.data, patch) }
|
||||
: session
|
||||
);
|
||||
@@ -253,13 +295,23 @@ export default function HomePage() {
|
||||
const handleArtistClick = async (artistId: number) => {
|
||||
try {
|
||||
const data = await api.getArtist(artistId);
|
||||
setGallerySession({ artistId, data });
|
||||
setGallerySession({ kind: 'artist', artistId, data });
|
||||
setView({ type: 'gallery', artistId, data });
|
||||
} catch {
|
||||
setError('Failed to load artist gallery.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMovementClick = async (movementId: number) => {
|
||||
try {
|
||||
const data = await api.getMovementGallery(movementId);
|
||||
setGallerySession({ kind: 'movement', movementId, data });
|
||||
setView({ type: 'movement-gallery', movementId, data });
|
||||
} catch {
|
||||
setError('Failed to load movement gallery.');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaintingClick = async (paintingId: number) => {
|
||||
try {
|
||||
const data = await api.getPainting(paintingId);
|
||||
@@ -303,11 +355,17 @@ export default function HomePage() {
|
||||
}
|
||||
|
||||
const artistId = view.data.painting.artist_id;
|
||||
if (gallerySession?.artistId === artistId) {
|
||||
if (gallerySession?.kind === 'artist' && gallerySession.artistId === artistId) {
|
||||
setDetailArtistPaintings(gallerySession.data.paintings);
|
||||
return;
|
||||
}
|
||||
|
||||
const returnTo = detailReturnToRef.current;
|
||||
if (returnTo.type === 'movement-gallery') {
|
||||
setDetailArtistPaintings(sortArtistPaintingsChronological(returnTo.data.paintings));
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
api.getArtist(artistId)
|
||||
.then((data) => {
|
||||
@@ -327,21 +385,39 @@ export default function HomePage() {
|
||||
[detailArtistPaintings]
|
||||
);
|
||||
|
||||
const galleryActive = view.type === 'gallery';
|
||||
const galleryActive = view.type === 'gallery' || view.type === 'movement-gallery';
|
||||
|
||||
return (
|
||||
<>
|
||||
{gallerySession && (
|
||||
<div className={galleryActive ? undefined : 'gallery-session-suspended'} aria-hidden={!galleryActive}>
|
||||
<VirtualGallery
|
||||
data={gallerySession.data}
|
||||
imageRevisions={imageRevisions}
|
||||
active={galleryActive}
|
||||
onPaintingClick={handlePaintingClick}
|
||||
onNavigateArtist={handleArtistClick}
|
||||
onBack={() => setView({ type: 'timeline' })}
|
||||
onBioClick={() => handleBioClick(gallerySession.data, { type: 'gallery', ...gallerySession })}
|
||||
/>
|
||||
{gallerySession.kind === 'artist' ? (
|
||||
<VirtualGallery
|
||||
mode="artist"
|
||||
data={gallerySession.data}
|
||||
imageRevisions={imageRevisions}
|
||||
active={galleryActive}
|
||||
onPaintingClick={handlePaintingClick}
|
||||
onNavigateArtist={handleArtistClick}
|
||||
onBack={() => setView({ type: 'timeline' })}
|
||||
onBioClick={() =>
|
||||
handleBioClick(gallerySession.data, {
|
||||
type: 'gallery',
|
||||
artistId: gallerySession.artistId,
|
||||
data: gallerySession.data,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<VirtualGallery
|
||||
mode="movement"
|
||||
data={gallerySession.data}
|
||||
imageRevisions={imageRevisions}
|
||||
active={galleryActive}
|
||||
onPaintingClick={handlePaintingClick}
|
||||
onBack={() => setView({ type: 'timeline' })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -352,12 +428,26 @@ export default function HomePage() {
|
||||
artistPaintings={sortedDetailArtistPaintings}
|
||||
onBack={() => {
|
||||
const returnTo = view.returnTo;
|
||||
if (returnTo.type === 'gallery' && gallerySession?.artistId === returnTo.artistId) {
|
||||
if (
|
||||
returnTo.type === 'gallery' &&
|
||||
gallerySession?.kind === 'artist' &&
|
||||
gallerySession.artistId === returnTo.artistId
|
||||
) {
|
||||
setView({
|
||||
type: 'gallery',
|
||||
artistId: gallerySession.artistId,
|
||||
data: gallerySession.data,
|
||||
});
|
||||
} else if (
|
||||
returnTo.type === 'movement-gallery' &&
|
||||
gallerySession?.kind === 'movement' &&
|
||||
gallerySession.movementId === returnTo.movementId
|
||||
) {
|
||||
setView({
|
||||
type: 'movement-gallery',
|
||||
movementId: gallerySession.movementId,
|
||||
data: gallerySession.data,
|
||||
});
|
||||
} else {
|
||||
setView(returnTo);
|
||||
}
|
||||
@@ -424,34 +514,42 @@ export default function HomePage() {
|
||||
<p className="site-subtitle">Watch art movements branch forward through time — each flowing from what came before</p>
|
||||
</header>
|
||||
|
||||
<Timeline
|
||||
eras={timelineData.eras}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
onViewChange={handleViewChange}
|
||||
absoluteMin={bounds.min}
|
||||
absoluteMax={bounds.max}
|
||||
/>
|
||||
<div className="home-timeline-stack">
|
||||
<Timeline
|
||||
eras={timelineData.eras}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
onViewChange={handleViewChange}
|
||||
absoluteMin={bounds.min}
|
||||
absoluteMax={bounds.max}
|
||||
lifespanHighlight={hoveredLifespan}
|
||||
/>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading home-movements-section">Loading art history...</div>
|
||||
) : (
|
||||
<div className="home-movements-section">
|
||||
<MovementBands
|
||||
movements={timelineData.movements}
|
||||
artists={artists}
|
||||
portraitRevisions={portraitRevisions}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
absoluteMin={bounds.min}
|
||||
absoluteMax={bounds.max}
|
||||
onViewChange={handleViewChange}
|
||||
onArtistClick={handleArtistClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="loading home-movements-section">Loading art history...</div>
|
||||
) : (
|
||||
<>
|
||||
<TimelineEventGuides viewStart={viewStart} viewEnd={viewEnd} />
|
||||
<div className="home-movements-section">
|
||||
<MovementBands
|
||||
movements={timelineData.movements}
|
||||
artists={artists}
|
||||
portraitRevisions={portraitRevisions}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
absoluteMin={bounds.min}
|
||||
absoluteMax={bounds.max}
|
||||
onViewChange={handleViewChange}
|
||||
onArtistClick={handleArtistClick}
|
||||
onMovementClick={handleMovementClick}
|
||||
onArtistHover={setHoveredLifespan}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -96,10 +96,25 @@ export interface InfluenceLink {
|
||||
movement_color?: string;
|
||||
}
|
||||
|
||||
export interface PaintingAnnotation {
|
||||
id: number;
|
||||
label: string | null;
|
||||
body: string;
|
||||
category: string;
|
||||
pos_x: number | null;
|
||||
pos_y: number | null;
|
||||
source_author: string | null;
|
||||
source: string | null;
|
||||
source_url: string | null;
|
||||
sort_order: number;
|
||||
confidence?: string;
|
||||
}
|
||||
|
||||
export interface PaintingDetail {
|
||||
painting: Painting & { artist_name: string; artist_portrait: string | null };
|
||||
influencedBy: InfluenceLink[];
|
||||
influenced: InfluenceLink[];
|
||||
annotations?: PaintingAnnotation[];
|
||||
}
|
||||
|
||||
export interface ArtistDetail {
|
||||
@@ -108,6 +123,11 @@ export interface ArtistDetail {
|
||||
paintings: Painting[];
|
||||
}
|
||||
|
||||
export interface MovementGalleryDetail {
|
||||
movement: ArtMovement & { era_name?: string };
|
||||
paintings: Painting[];
|
||||
}
|
||||
|
||||
export interface TimelineData {
|
||||
eras: HistoricalEra[];
|
||||
movements: ArtMovement[];
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as THREE from 'three';
|
||||
import { PARQUET_METERS_PER_TILE } from './parquetFloorTexture';
|
||||
import { getSurfaceTexture, type SurfaceTextureKind } from './galleryProceduralTextures';
|
||||
|
||||
export { PARQUET_METERS_PER_TILE };
|
||||
export type GalleryFloorKind = SurfaceTextureKind | 'parquet' | 'marble-checker' | 'marble-veined' | 'concrete' | 'mosaic';
|
||||
|
||||
const LEGACY_MAP: Record<string, SurfaceTextureKind> = {
|
||||
parquet: 'parquet-herringbone',
|
||||
'marble-checker': 'marble-checker',
|
||||
'marble-veined': 'marble-veined-carrara',
|
||||
flagstone: 'flagstone',
|
||||
terrazzo: 'terrazzo',
|
||||
concrete: 'concrete-polished',
|
||||
mosaic: 'mosaic-roman',
|
||||
};
|
||||
|
||||
function resolveKind(kind: GalleryFloorKind): SurfaceTextureKind {
|
||||
return LEGACY_MAP[kind as string] ?? (kind as SurfaceTextureKind);
|
||||
}
|
||||
|
||||
export function getGalleryFloorTexture(kind: GalleryFloorKind): THREE.CanvasTexture {
|
||||
return getSurfaceTexture(resolveKind(kind)).map;
|
||||
}
|
||||
|
||||
export function floorMetersPerTile(kind: GalleryFloorKind): number {
|
||||
return getSurfaceTexture(resolveKind(kind)).metersPerRepeat;
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
/** Hi-res procedural textures for photo-realistic gallery surfaces. */
|
||||
export const TEXTURE_SIZE = 1024;
|
||||
|
||||
export type SurfaceTextureKind =
|
||||
| 'marble-white'
|
||||
| 'marble-veined-carrara'
|
||||
| 'marble-veined-emerald'
|
||||
| 'marble-checker'
|
||||
| 'limestone'
|
||||
| 'sandstone'
|
||||
| 'rough-stone'
|
||||
| 'basalt'
|
||||
| 'stucco-cream'
|
||||
| 'stucco-terracotta'
|
||||
| 'plaster-white'
|
||||
| 'plaster-warm'
|
||||
| 'velvet-crimson'
|
||||
| 'velvet-navy'
|
||||
| 'velvet-emerald'
|
||||
| 'silk-gold'
|
||||
| 'silk-pale'
|
||||
| 'oak-panel'
|
||||
| 'walnut-panel'
|
||||
| 'dark-wood-panel'
|
||||
| 'parquet-herringbone'
|
||||
| 'parquet-chevrons'
|
||||
| 'terracotta-tiles'
|
||||
| 'flagstone'
|
||||
| 'mosaic-byzantine'
|
||||
| 'mosaic-roman'
|
||||
| 'terrazzo'
|
||||
| 'concrete-polished'
|
||||
| 'concrete-raw'
|
||||
| 'industrial-floor'
|
||||
| 'gilded-stucco'
|
||||
| 'fresco-worn'
|
||||
| 'brick'
|
||||
| 'concrete-block'
|
||||
| 'dark-plaster'
|
||||
| 'pastel-plaster'
|
||||
| 'white-plaster'
|
||||
| 'studio-white';
|
||||
|
||||
export interface SurfaceTextureSet {
|
||||
map: THREE.CanvasTexture;
|
||||
normalMap: THREE.CanvasTexture;
|
||||
roughness: number;
|
||||
metalness: number;
|
||||
metersPerRepeat: number;
|
||||
}
|
||||
|
||||
const cache = new Map<string, SurfaceTextureSet>();
|
||||
|
||||
function seeded(seed: number) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
const n = parseInt(hex.replace('#', ''), 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
|
||||
function rgb(r: number, g: number, b: number) {
|
||||
return `rgb(${r | 0},${g | 0},${b | 0})`;
|
||||
}
|
||||
|
||||
function shadeHex(hex: string, amount: number): string {
|
||||
const [r, g, b] = hexToRgb(hex);
|
||||
const clamp = (v: number) => Math.min(255, Math.max(0, v + amount));
|
||||
return rgb(clamp(r), clamp(g), clamp(b));
|
||||
}
|
||||
|
||||
function fill(ctx: CanvasRenderingContext2D, size: number, color: string) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
}
|
||||
|
||||
function noiseOverlay(ctx: CanvasRenderingContext2D, size: number, alpha: number, seed: number) {
|
||||
const rand = seeded(seed);
|
||||
const img = ctx.getImageData(0, 0, size, size);
|
||||
const d = img.data;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const n = (rand() - 0.5) * alpha * 255;
|
||||
d[i] += n;
|
||||
d[i + 1] += n;
|
||||
d[i + 2] += n;
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
}
|
||||
|
||||
function marbleVeined(ctx: CanvasRenderingContext2D, size: number, base: string, vein: string, seed: number) {
|
||||
fill(ctx, size, base);
|
||||
const rand = seeded(seed);
|
||||
for (let i = 0; i < 28; i++) {
|
||||
ctx.strokeStyle = vein.replace(')', `,${0.06 + rand() * 0.14})`).replace('rgb', 'rgba');
|
||||
if (vein.startsWith('#')) {
|
||||
const [r, g, b] = hexToRgb(vein);
|
||||
ctx.strokeStyle = `rgba(${r},${g},${b},${0.05 + rand() * 0.12})`;
|
||||
}
|
||||
ctx.lineWidth = 1 + rand() * 5;
|
||||
ctx.beginPath();
|
||||
let x = rand() * size;
|
||||
let y = rand() * size;
|
||||
ctx.moveTo(x, y);
|
||||
for (let s = 0; s < 10; s++) {
|
||||
x += (rand() - 0.5) * size * 0.18;
|
||||
y += (rand() - 0.5) * size * 0.12;
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
noiseOverlay(ctx, size, 0.04, seed + 1);
|
||||
}
|
||||
|
||||
function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
switch (kind) {
|
||||
case 'marble-white':
|
||||
case 'marble-veined-carrara':
|
||||
marbleVeined(ctx, size, '#ece8e0', '#a8a098', 101);
|
||||
break;
|
||||
case 'marble-veined-emerald':
|
||||
marbleVeined(ctx, size, '#dce8e0', '#4a7868', 102);
|
||||
break;
|
||||
case 'marble-checker': {
|
||||
const tile = size / 10;
|
||||
for (let row = 0; row < 10; row++) {
|
||||
for (let col = 0; col < 10; col++) {
|
||||
const light = (row + col) % 2 === 0;
|
||||
ctx.fillStyle = light ? '#f2eee6' : '#4a6858';
|
||||
ctx.fillRect(col * tile, row * tile, tile, tile);
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.06)';
|
||||
ctx.strokeRect(col * tile + 0.5, row * tile + 0.5, tile - 1, tile - 1);
|
||||
}
|
||||
}
|
||||
noiseOverlay(ctx, size, 0.03, 103);
|
||||
break;
|
||||
}
|
||||
case 'limestone':
|
||||
fill(ctx, size, '#ddd4c4');
|
||||
noiseOverlay(ctx, size, 0.08, 104);
|
||||
break;
|
||||
case 'sandstone':
|
||||
fill(ctx, size, '#c8b090');
|
||||
noiseOverlay(ctx, size, 0.1, 105);
|
||||
break;
|
||||
case 'rough-stone':
|
||||
fill(ctx, size, '#7a7468');
|
||||
for (let row = 0; row < 6; row++) {
|
||||
for (let col = 0; col < 6; col++) {
|
||||
const tones = ['#6a6458', '#8a8478', '#5a5448'];
|
||||
ctx.fillStyle = tones[(row + col) % 3];
|
||||
const w = size / 6 + (row % 2 ? 6 : -4);
|
||||
ctx.fillRect(col * (size / 6), row * (size / 6), w, size / 6);
|
||||
}
|
||||
}
|
||||
noiseOverlay(ctx, size, 0.12, 106);
|
||||
break;
|
||||
case 'basalt':
|
||||
fill(ctx, size, '#3a3834');
|
||||
noiseOverlay(ctx, size, 0.15, 107);
|
||||
break;
|
||||
case 'stucco-cream':
|
||||
fill(ctx, size, '#f0e8d8');
|
||||
noiseOverlay(ctx, size, 0.06, 108);
|
||||
break;
|
||||
case 'stucco-terracotta':
|
||||
fill(ctx, size, '#c89070');
|
||||
noiseOverlay(ctx, size, 0.08, 109);
|
||||
break;
|
||||
case 'plaster-white':
|
||||
case 'white-plaster':
|
||||
case 'studio-white':
|
||||
fill(ctx, size, '#f8f8f6');
|
||||
noiseOverlay(ctx, size, 0.035, 110);
|
||||
break;
|
||||
case 'plaster-warm':
|
||||
fill(ctx, size, '#f2ebe0');
|
||||
noiseOverlay(ctx, size, 0.045, 111);
|
||||
break;
|
||||
case 'dark-plaster':
|
||||
fill(ctx, size, '#3a3230');
|
||||
noiseOverlay(ctx, size, 0.07, 112);
|
||||
break;
|
||||
case 'pastel-plaster':
|
||||
fill(ctx, size, '#e8dce8');
|
||||
noiseOverlay(ctx, size, 0.05, 113);
|
||||
break;
|
||||
case 'velvet-crimson':
|
||||
fill(ctx, size, '#5c1828');
|
||||
noiseOverlay(ctx, size, 0.18, 114);
|
||||
break;
|
||||
case 'velvet-navy':
|
||||
fill(ctx, size, '#1a2848');
|
||||
noiseOverlay(ctx, size, 0.18, 115);
|
||||
break;
|
||||
case 'velvet-emerald':
|
||||
fill(ctx, size, '#1a4030');
|
||||
noiseOverlay(ctx, size, 0.18, 116);
|
||||
break;
|
||||
case 'silk-gold':
|
||||
fill(ctx, size, '#d8c890');
|
||||
noiseOverlay(ctx, size, 0.06, 117);
|
||||
break;
|
||||
case 'silk-pale':
|
||||
fill(ctx, size, '#f0ece4');
|
||||
noiseOverlay(ctx, size, 0.05, 118);
|
||||
break;
|
||||
case 'oak-panel':
|
||||
case 'walnut-panel':
|
||||
case 'dark-wood-panel': {
|
||||
const base = kind === 'oak-panel' ? '#8a6848' : kind === 'walnut-panel' ? '#5a4030' : '#3a2818';
|
||||
const plankH = size / 14;
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const grad = ctx.createLinearGradient(0, i * plankH, size, i * plankH);
|
||||
grad.addColorStop(0, shadeHex(base, -15));
|
||||
grad.addColorStop(0.5, base);
|
||||
grad.addColorStop(1, shadeHex(base, -10));
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, i * plankH, size, plankH - 2);
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.25)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, i * plankH);
|
||||
ctx.lineTo(size, i * plankH);
|
||||
ctx.stroke();
|
||||
for (let g = 0; g < 12; g++) {
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.08)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(g * (size / 12), i * plankH);
|
||||
ctx.lineTo(g * (size / 12) + size * 0.08, i * plankH + plankH);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'parquet-herringbone':
|
||||
case 'parquet-chevrons': {
|
||||
const tones = ['#6b4a2e', '#735234', '#624428', '#7a5638'];
|
||||
const plankW = size / 24;
|
||||
const plankL = size / 8;
|
||||
for (let row = 0; row < 16; row++) {
|
||||
for (let col = 0; col < 16; col++) {
|
||||
ctx.save();
|
||||
ctx.translate(col * plankW * 1.4, row * plankW * 1.4);
|
||||
ctx.rotate(kind === 'parquet-chevrons' ? Math.PI / 4 : (col + row) % 2 ? Math.PI / 4 : -Math.PI / 4);
|
||||
ctx.fillStyle = tones[(row + col) % tones.length];
|
||||
ctx.fillRect(-plankL / 2, -plankW / 2, plankL, plankW);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
noiseOverlay(ctx, size, 0.04, 119);
|
||||
break;
|
||||
}
|
||||
case 'terracotta-tiles': {
|
||||
const tile = size / 8;
|
||||
for (let row = 0; row < 8; row++) {
|
||||
for (let col = 0; col < 8; col++) {
|
||||
ctx.fillStyle = ['#b87050', '#c88058', '#a86048'][ (row + col) % 3];
|
||||
ctx.fillRect(col * tile + 2, row * tile + 2, tile - 4, tile - 4);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'flagstone':
|
||||
fill(ctx, size, '#5a5448');
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const rand = seeded(120 + i);
|
||||
const w = size * (0.12 + rand() * 0.15);
|
||||
const h = size * (0.1 + rand() * 0.12);
|
||||
ctx.fillStyle = ['#6a6458', '#7a7468', '#625c50'][i % 3];
|
||||
ctx.fillRect(rand() * (size - w), rand() * (size - h), w, h);
|
||||
}
|
||||
break;
|
||||
case 'mosaic-byzantine': {
|
||||
fill(ctx, size, '#1a1814');
|
||||
const cell = size / 32;
|
||||
const colors = ['#d4af37', '#8a3020', '#2060a0', '#f0ece0', '#408040'];
|
||||
for (let y = 0; y < size; y += cell) {
|
||||
for (let x = 0; x < size; x += cell) {
|
||||
ctx.fillStyle = colors[((x / cell) + (y / cell)) % colors.length];
|
||||
ctx.fillRect(x + 1, y + 1, cell - 2, cell - 2);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'mosaic-roman': {
|
||||
fill(ctx, size, '#c8b898');
|
||||
const cell = 20;
|
||||
const colors = ['#8a7060', '#a88870', '#706050', '#d0c0a0'];
|
||||
for (let y = 0; y < size; y += cell) {
|
||||
for (let x = 0; x < size; x += cell) {
|
||||
ctx.fillStyle = colors[((x / cell) + (y / cell)) % colors.length];
|
||||
ctx.fillRect(x + 1, y + 1, cell - 2, cell - 2);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'terrazzo':
|
||||
fill(ctx, size, '#d8d0c4');
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
const rand = seeded(121 + i);
|
||||
ctx.fillStyle = ['#a89888', '#c8b8a8', '#888078'][i % 3];
|
||||
ctx.beginPath();
|
||||
ctx.arc(rand() * size, rand() * size, 2 + rand() * 6, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
break;
|
||||
case 'concrete-polished':
|
||||
fill(ctx, size, '#c8c8c8');
|
||||
noiseOverlay(ctx, size, 0.06, 122);
|
||||
break;
|
||||
case 'concrete-raw':
|
||||
case 'concrete-block':
|
||||
fill(ctx, size, '#a8a8a8');
|
||||
noiseOverlay(ctx, size, 0.1, 123);
|
||||
if (kind === 'concrete-block') {
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.2)';
|
||||
ctx.lineWidth = 3;
|
||||
for (let y = 0; y < size; y += size / 6) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(size, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let x = 0; x < size; x += size / 4) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, size);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'industrial-floor':
|
||||
fill(ctx, size, '#888880');
|
||||
noiseOverlay(ctx, size, 0.12, 124);
|
||||
break;
|
||||
case 'gilded-stucco':
|
||||
fill(ctx, size, '#d8c070');
|
||||
noiseOverlay(ctx, size, 0.05, 125);
|
||||
break;
|
||||
case 'fresco-worn':
|
||||
fill(ctx, size, '#d0c8b8');
|
||||
noiseOverlay(ctx, size, 0.09, 126);
|
||||
break;
|
||||
case 'brick': {
|
||||
const bh = size / 16;
|
||||
const bw = size / 8;
|
||||
fill(ctx, size, '#6a4030');
|
||||
for (let row = 0; row < 16; row++) {
|
||||
const offset = row % 2 ? bw / 2 : 0;
|
||||
for (let col = -1; col < 9; col++) {
|
||||
ctx.fillStyle = ['#8a5040', '#7a4838', '#9a5848'][(row + col) % 3];
|
||||
ctx.fillRect(col * bw + offset, row * bh, bw - 3, bh - 3);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
fill(ctx, size, '#e8e4dc');
|
||||
noiseOverlay(ctx, size, 0.05, 999);
|
||||
}
|
||||
|
||||
return ctx.getImageData(0, 0, size, size);
|
||||
}
|
||||
|
||||
function imageDataToNormalMap(data: ImageData, size: number, strength = 2.5): ImageData {
|
||||
const out = new ImageData(size, size);
|
||||
const src = data.data;
|
||||
const dst = out.data;
|
||||
const idx = (x: number, y: number) => ((y * size + x) * 4);
|
||||
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const xl = src[idx(Math.max(0, x - 1), y)];
|
||||
const xr = src[idx(Math.min(size - 1, x + 1), y)];
|
||||
const yt = src[idx(x, Math.max(0, y - 1))];
|
||||
const yb = src[idx(x, Math.min(size - 1, y + 1))];
|
||||
const dx = (xr - xl) / 255;
|
||||
const dy = (yb - yt) / 255;
|
||||
let nx = -dx * strength;
|
||||
let ny = -dy * strength;
|
||||
let nz = 1;
|
||||
const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
|
||||
nx /= len;
|
||||
ny /= len;
|
||||
nz /= len;
|
||||
const i = idx(x, y);
|
||||
dst[i] = ((nx + 1) * 0.5 * 255) | 0;
|
||||
dst[i + 1] = ((ny + 1) * 0.5 * 255) | 0;
|
||||
dst[i + 2] = ((nz + 1) * 0.5 * 255) | 0;
|
||||
dst[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function imageDataToTexture(data: ImageData): THREE.CanvasTexture {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = data.width;
|
||||
canvas.height = data.height;
|
||||
canvas.getContext('2d')!.putImageData(data, 0, 0);
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
tex.wrapS = THREE.RepeatWrapping;
|
||||
tex.wrapT = THREE.RepeatWrapping;
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
tex.anisotropy = 16;
|
||||
return tex;
|
||||
}
|
||||
|
||||
const ROUGHNESS: Partial<Record<SurfaceTextureKind, number>> = {
|
||||
'marble-white': 0.18,
|
||||
'marble-veined-carrara': 0.16,
|
||||
'marble-veined-emerald': 0.18,
|
||||
'marble-checker': 0.14,
|
||||
'gilded-stucco': 0.22,
|
||||
'velvet-crimson': 0.92,
|
||||
'velvet-navy': 0.92,
|
||||
'velvet-emerald': 0.92,
|
||||
'concrete-polished': 0.28,
|
||||
'concrete-raw': 0.72,
|
||||
'oak-panel': 0.55,
|
||||
'parquet-herringbone': 0.42,
|
||||
};
|
||||
|
||||
const METALNESS: Partial<Record<SurfaceTextureKind, number>> = {
|
||||
'marble-white': 0.08,
|
||||
'marble-veined-carrara': 0.1,
|
||||
'gilded-stucco': 0.65,
|
||||
'concrete-polished': 0.12,
|
||||
'terrazzo': 0.15,
|
||||
};
|
||||
|
||||
const METERS: Partial<Record<SurfaceTextureKind, number>> = {
|
||||
'marble-checker': 2.4,
|
||||
'flagstone': 3.0,
|
||||
'mosaic-byzantine': 1.8,
|
||||
'mosaic-roman': 2.0,
|
||||
'parquet-herringbone': 1.8,
|
||||
'parquet-chevrons': 1.8,
|
||||
'concrete-raw': 4.0,
|
||||
'brick': 2.5,
|
||||
};
|
||||
|
||||
export function getSurfaceTexture(kind: SurfaceTextureKind): SurfaceTextureSet {
|
||||
const cached = cache.get(kind);
|
||||
if (cached) return cached;
|
||||
|
||||
const colorData = paintSurface(kind, TEXTURE_SIZE);
|
||||
const normalData = imageDataToNormalMap(colorData, TEXTURE_SIZE, kind.includes('velvet') ? 1.2 : 2.8);
|
||||
|
||||
const set: SurfaceTextureSet = {
|
||||
map: imageDataToTexture(colorData),
|
||||
normalMap: imageDataToTexture(normalData),
|
||||
roughness: ROUGHNESS[kind] ?? 0.75,
|
||||
metalness: METALNESS[kind] ?? 0.04,
|
||||
metersPerRepeat: METERS[kind] ?? 2.8,
|
||||
};
|
||||
cache.set(kind, set);
|
||||
return set;
|
||||
}
|
||||
|
||||
export function cloneSurfaceTexture(kind: SurfaceTextureKind, repeatW: number, repeatH: number): SurfaceTextureSet {
|
||||
const base = getSurfaceTexture(kind);
|
||||
const map = base.map.clone();
|
||||
const normalMap = base.normalMap.clone();
|
||||
map.repeat.set(repeatW, repeatH);
|
||||
normalMap.repeat.set(repeatW, repeatH);
|
||||
map.needsUpdate = true;
|
||||
normalMap.needsUpdate = true;
|
||||
return { ...base, map, normalMap };
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import type { Painting } from '../types';
|
||||
import type { GalleryWindowSpec, MovementInteriorStyle } from '../data/movement-interior-styles';
|
||||
import { comparePaintingsChronological } from './paintingUtils';
|
||||
|
||||
/** Target capacity per movement wing (50–60 works). */
|
||||
export const MOVEMENT_PAINTINGS_PER_HALL = 55;
|
||||
|
||||
export type WallSide = 'back' | 'left' | 'right';
|
||||
|
||||
export interface FrameSlot {
|
||||
position: [number, number, number];
|
||||
rotationY: number;
|
||||
maxW: number;
|
||||
maxH: number;
|
||||
side: WallSide;
|
||||
}
|
||||
|
||||
export interface WallSegment {
|
||||
side: WallSide;
|
||||
label: string;
|
||||
paintings: Painting[];
|
||||
slots: FrameSlot[];
|
||||
}
|
||||
|
||||
export interface MovementHallLayout {
|
||||
hallIndex: number;
|
||||
hallCount: number;
|
||||
width: number;
|
||||
depth: number;
|
||||
segments: WallSegment[];
|
||||
paintingCount: number;
|
||||
yearLabel: string;
|
||||
}
|
||||
|
||||
const WALL_HEIGHT = 4.2;
|
||||
const WALL_THICKNESS = 0.18;
|
||||
const MOUNT_OFFSET = 0.16;
|
||||
const WALL_STANDOFF = 0.07;
|
||||
const EYE_HEIGHT = 1.65;
|
||||
const FRAME_GAP = 0.32;
|
||||
const MIN_FRAME_W = 0.45;
|
||||
const MAX_FRAME_W = 1.05;
|
||||
const MAX_FRAME_H = 1.35;
|
||||
const MIN_HALL_SIZE = 10;
|
||||
const MIN_HALL_WIDTH = 11;
|
||||
const WALL_PADDING = 1.4;
|
||||
|
||||
const FRAME_MAT_BORDER = 0.1;
|
||||
const FRAME_RAIL = 0.08;
|
||||
const REVIEWED_MAT_BORDER = FRAME_MAT_BORDER * 2;
|
||||
const REVIEWED_RAIL = FRAME_RAIL * 2;
|
||||
|
||||
function paintingIsReviewed(p: Painting) {
|
||||
return !!p.checkup_checked;
|
||||
}
|
||||
|
||||
function frameDims(reviewed: boolean) {
|
||||
return reviewed
|
||||
? { matBorder: REVIEWED_MAT_BORDER, rail: REVIEWED_RAIL }
|
||||
: { matBorder: FRAME_MAT_BORDER, rail: FRAME_RAIL };
|
||||
}
|
||||
|
||||
function frameOuterW(w: number, reviewed: boolean) {
|
||||
const { matBorder, rail } = frameDims(reviewed);
|
||||
return w + matBorder * 2 + rail;
|
||||
}
|
||||
|
||||
function layoutRow(paintings: Painting[], span: number) {
|
||||
const count = paintings.length;
|
||||
if (count === 0) return { slots: [] as { offset: number; maxW: number; maxH: number }[], spanNeeded: span };
|
||||
|
||||
let frameW = MAX_FRAME_W;
|
||||
for (let attempt = 0; attempt < 40; attempt++) {
|
||||
let total = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
total += frameOuterW(frameW, paintingIsReviewed(paintings[i]));
|
||||
if (i < count - 1) total += FRAME_GAP;
|
||||
}
|
||||
if (total <= span - WALL_PADDING) break;
|
||||
frameW -= 0.015;
|
||||
}
|
||||
frameW = Math.max(MIN_FRAME_W, frameW);
|
||||
const frameH = Math.min(MAX_FRAME_H, frameW * 1.22);
|
||||
const outers = paintings.map((p) => frameOuterW(frameW, paintingIsReviewed(p)));
|
||||
const rowWidth = outers.reduce((s, w) => s + w, 0) + (count - 1) * FRAME_GAP;
|
||||
const slots: { offset: number; maxW: number; maxH: number }[] = [];
|
||||
let cursor = -rowWidth / 2;
|
||||
for (let i = 0; i < count; i++) {
|
||||
slots.push({ offset: cursor + outers[i] / 2, maxW: frameW, maxH: frameH });
|
||||
cursor += outers[i] + FRAME_GAP;
|
||||
}
|
||||
return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) };
|
||||
}
|
||||
|
||||
function orderForWall(paintings: Painting[]) {
|
||||
return [...paintings].sort(comparePaintingsChronological).reverse();
|
||||
}
|
||||
|
||||
function formatYearLabel(paintings: Painting[]) {
|
||||
const years = paintings.map((p) => p.year).filter((y): y is number => y != null);
|
||||
if (years.length === 0) return 'Undated works';
|
||||
const min = Math.min(...years);
|
||||
const max = Math.max(...years);
|
||||
return min === max ? `${min}` : `${min} – ${max}`;
|
||||
}
|
||||
|
||||
export function splitPaintingsIntoMovementHalls(paintings: Painting[]): Painting[][] {
|
||||
const sorted = [...paintings].sort(comparePaintingsChronological);
|
||||
if (sorted.length === 0) return [[]];
|
||||
const chunks: Painting[][] = [];
|
||||
for (let i = 0; i < sorted.length; i += MOVEMENT_PAINTINGS_PER_HALL) {
|
||||
chunks.push(sorted.slice(i, i + MOVEMENT_PAINTINGS_PER_HALL));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function distributeToSideWalls(paintings: Painting[]) {
|
||||
const left: Painting[] = [];
|
||||
const right: Painting[] = [];
|
||||
const sorted = [...paintings].sort(comparePaintingsChronological);
|
||||
sorted.forEach((p, i) => (i % 2 === 0 ? left : right).push(p));
|
||||
return { left: orderForWall(left), right: orderForWall(right) };
|
||||
}
|
||||
|
||||
function layoutSideSlots(
|
||||
paintings: Painting[],
|
||||
span: number,
|
||||
side: 'left' | 'right',
|
||||
halfW: number,
|
||||
inset: number
|
||||
): FrameSlot[] {
|
||||
if (paintings.length === 0) return [];
|
||||
const { slots: rowSlots } = layoutRow(paintings, span);
|
||||
const y = EYE_HEIGHT;
|
||||
return rowSlots.map((s) => ({
|
||||
maxW: s.maxW,
|
||||
maxH: s.maxH,
|
||||
rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2,
|
||||
side,
|
||||
position:
|
||||
side === 'left'
|
||||
? ([-halfW + inset + WALL_STANDOFF, y, s.offset] as [number, number, number])
|
||||
: ([halfW - inset - WALL_STANDOFF, y, s.offset] as [number, number, number]),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildMovementHallLayout(
|
||||
paintings: Painting[],
|
||||
hallIndex: number,
|
||||
hallCount: number
|
||||
): MovementHallLayout {
|
||||
const { left, right } = distributeToSideWalls(paintings);
|
||||
const leftSpan = layoutRow(left, MIN_HALL_SIZE);
|
||||
const rightSpan = layoutRow(right, MIN_HALL_SIZE);
|
||||
const depth = Math.max(MIN_HALL_SIZE, leftSpan.spanNeeded, rightSpan.spanNeeded);
|
||||
const width = MIN_HALL_WIDTH;
|
||||
const halfW = width / 2;
|
||||
const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET;
|
||||
|
||||
const segments: WallSegment[] = [
|
||||
{ side: 'back', label: '', paintings: [], slots: [] },
|
||||
{
|
||||
side: 'left',
|
||||
label: left.length > 0 ? `Wing ${hallIndex + 1} · Left wall` : '',
|
||||
paintings: left,
|
||||
slots: layoutSideSlots(left, depth, 'left', halfW, inset),
|
||||
},
|
||||
{
|
||||
side: 'right',
|
||||
label: right.length > 0 ? `Wing ${hallIndex + 1} · Right wall` : '',
|
||||
paintings: right,
|
||||
slots: layoutSideSlots(right, depth, 'right', halfW, inset),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
hallIndex,
|
||||
hallCount,
|
||||
width,
|
||||
depth,
|
||||
segments,
|
||||
paintingCount: paintings.length,
|
||||
yearLabel: formatYearLabel(paintings),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAllMovementHallLayouts(paintings: Painting[]): MovementHallLayout[] {
|
||||
const chunks = splitPaintingsIntoMovementHalls(paintings);
|
||||
return chunks.map((chunk, i) => buildMovementHallLayout(chunk, i, chunks.length));
|
||||
}
|
||||
|
||||
function mergeIntervals(intervals: [number, number][]): [number, number][] {
|
||||
if (intervals.length === 0) return [];
|
||||
const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
|
||||
const out: [number, number][] = [sorted[0]];
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const last = out[out.length - 1];
|
||||
if (sorted[i][0] <= last[1]) last[1] = Math.max(last[1], sorted[i][1]);
|
||||
else out.push(sorted[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function findWallGaps(occupied: [number, number][], halfSpan: number, minGap: number): [number, number][] {
|
||||
const merged = mergeIntervals(occupied);
|
||||
const gaps: [number, number][] = [];
|
||||
let cursor = -halfSpan + 1.2;
|
||||
for (const [a, b] of merged) {
|
||||
if (a - cursor >= minGap) gaps.push([cursor, a]);
|
||||
cursor = Math.max(cursor, b);
|
||||
}
|
||||
if (halfSpan - 1.2 - cursor >= minGap) gaps.push([cursor, halfSpan - 1.2]);
|
||||
return gaps.sort((a, b) => b[1] - b[0] - (a[1] - a[0]));
|
||||
}
|
||||
|
||||
function windowTemplate(style: MovementInteriorStyle): Pick<GalleryWindowSpec, 'style' | 'lightColor' | 'lightIntensity' | 'width' | 'height'> {
|
||||
const side = style.windows.find((w) => w.wall === 'left' || w.wall === 'right');
|
||||
if (side) {
|
||||
return {
|
||||
style: side.style,
|
||||
lightColor: side.lightColor,
|
||||
lightIntensity: side.lightIntensity,
|
||||
width: Math.min(side.width, 1.6),
|
||||
height: Math.min(side.height, 1.5),
|
||||
};
|
||||
}
|
||||
return { style: 'sash', lightColor: style.warmLight, lightIntensity: 2.8, width: 1.4, height: 1.4 };
|
||||
}
|
||||
|
||||
/** Place windows on side walls only, in gaps between painting frames. */
|
||||
export function computeSideWallWindows(
|
||||
layout: MovementHallLayout,
|
||||
interiorStyle: MovementInteriorStyle
|
||||
): GalleryWindowSpec[] {
|
||||
const halfD = layout.depth / 2;
|
||||
const tmpl = windowTemplate(interiorStyle);
|
||||
const specs: GalleryWindowSpec[] = [];
|
||||
const windowY = 3.15;
|
||||
const minGap = tmpl.width + 0.6;
|
||||
|
||||
for (const side of ['left', 'right'] as const) {
|
||||
const seg = layout.segments.find((s) => s.side === side);
|
||||
if (!seg) continue;
|
||||
|
||||
const occupied = seg.slots.map((s): [number, number] => {
|
||||
const outerW = frameOuterW(s.maxW, false);
|
||||
return [s.position[2] - outerW / 2 - 0.45, s.position[2] + outerW / 2 + 0.45];
|
||||
});
|
||||
|
||||
const gaps = findWallGaps(occupied, halfD, minGap);
|
||||
const maxWindows = Math.min(3, gaps.length);
|
||||
for (let i = 0; i < maxWindows; i++) {
|
||||
const [g0, g1] = gaps[i];
|
||||
const center = (g0 + g1) / 2;
|
||||
const w = Math.min(tmpl.width, g1 - g0 - 0.35);
|
||||
if (w < 0.9) continue;
|
||||
specs.push({
|
||||
wall: side,
|
||||
x: center,
|
||||
y: windowY,
|
||||
width: w,
|
||||
height: tmpl.height,
|
||||
style: tmpl.style,
|
||||
lightColor: tmpl.lightColor,
|
||||
lightIntensity: tmpl.lightIntensity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return specs;
|
||||
}
|
||||
|
||||
export { WALL_HEIGHT };
|
||||
@@ -15,6 +15,20 @@ export function sortArtistPaintingsChronological(paintings: Painting[]): Paintin
|
||||
return [...paintings].sort(comparePaintingsChronological);
|
||||
}
|
||||
|
||||
export function formatPaintingYear(painting: Pick<Painting, 'year' | 'year_end'>): string {
|
||||
if (painting.year == null) return 'Undated';
|
||||
if (painting.year_end != null && painting.year_end !== painting.year) {
|
||||
return `${painting.year}–${painting.year_end}`;
|
||||
}
|
||||
return String(painting.year);
|
||||
}
|
||||
|
||||
export function paintingWallCaption(painting: Pick<Painting, 'year' | 'year_end' | 'artist_name'>): string {
|
||||
const year = formatPaintingYear(painting);
|
||||
const artist = painting.artist_name?.trim() || 'Unknown artist';
|
||||
return `${year} · ${artist}`;
|
||||
}
|
||||
|
||||
/** True when painting appears in the influence graph (influenced by or influenced). */
|
||||
export function paintingHasInfluenceLinks(
|
||||
painting: Pick<Painting, 'has_influence_links'>
|
||||
|
||||
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 489 KiB |
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 5.6 MiB |
|
Before Width: | Height: | Size: 185 MiB |
|
After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 683 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 646 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 646 KiB After Width: | Height: | Size: 635 KiB |
|
Before Width: | Height: | Size: 715 KiB After Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 958 KiB After Width: | Height: | Size: 366 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 645 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 701 KiB |
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 401 KiB |
|
Before Width: | Height: | Size: 1003 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 498 KiB |
|
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 443 KiB After Width: | Height: | Size: 363 KiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 438 KiB |
|
Before Width: | Height: | Size: 443 KiB After Width: | Height: | Size: 700 KiB |
|
Before Width: | Height: | Size: 443 KiB After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 443 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 606 KiB |
|
Before Width: | Height: | Size: 760 KiB After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 376 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 854 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 57 KiB |