2 Commits
Author SHA1 Message Date
Danila KhodjaefandCursor df29848d89 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>
2026-06-21 16:54:19 +03:00
Danila KhodjaefandCursor 0972b5df99 Add debug More/Clear/Upload tools for paintings and artist portraits.
Extends the debug panel on painting detail and artist bio with a 20-result search picker, local image upload, and clear-to-empty-frame workflow, plus API routes, artist checkup migration, and documentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 13:14:06 +03:00
158 changed files with 6715 additions and 458 deletions
+234 -4
View File
@@ -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.
@@ -93,7 +141,9 @@ Full artist profile for the bio page and 3D gallery entry.
"portrait_path": "portraits/Claude_Monet.jpg",
"bio_short": "First two sentences from Wikipedia…",
"bio_full": "Full Wikipedia lead section…",
"wikipedia_title": "Claude Monet"
"wikipedia_title": "Claude Monet",
"checkup_checked": false,
"checkup_fixed": false
},
"periods": [ { "id": 1, "name": "Milan Period", "start_year": 1482, "end_year": 1499, ... } ],
"paintings": [ { "id": 10, "title": "...", "year": 1498, "image_path": "...", "thumbnail_path": "...", "wikipedia_title": "...", "has_influence_links": true, "checkup_checked": false, "checkup_fixed": false, ... } ]
@@ -110,6 +160,104 @@ Each painting includes:
Populate biographies with `npm run fetch-artist-bios` (see [data-and-images.md](data-and-images.md)).
Artist objects also include `checkup_checked` and `checkup_fixed` (same semantics as paintings; gold portrait border when reviewed). Run `npm run migrate:artist-checkup-flags` on existing databases.
---
## `PATCH /api/artists/:id/checkup-flags`
Update artist portrait review flags. Body: `{ "checked"?: boolean, "fixed"?: boolean }` — at least one field required.
Same rules as painting checkup flags: setting `fixed: true` also sets `checked: true`.
**Response**
```json
{ "checked": true, "fixed": false }
```
---
## `GET /api/artists/:id/debug-portrait-search`
Portrait image search for debug mode on the artist bio page (Custom Search → Google Arts & Culture → scrape → DuckDuckGo).
**Response** — same shape as painting debug search (`query`, `imageUrl`, `searchUrl`, `source`, optional `thumbUrl`, `sourceLabel`).
---
## `GET /api/artists/:id/debug-portrait-search/more`
Up to 20 ranked portrait candidates for the **More** picker modal.
**Query:** `limit` (int, default 20, max 20)
**Response**
```json
{
"query": "Leonardo da Vinci portrait",
"searchUrl": "https://…",
"source": "google-custom-search",
"results": [
{ "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`
Download a remote URL and replace the artists local portrait. Sets `checkup_fixed = true` and `checkup_checked = true`.
**Body** — same as `POST /api/paintings/:id/fix-image` (`imageUrl` required; optional `searchUrl`, `source`, `thumbUrl`).
**Response**
```json
{
"portraitPath": "portraits/Leonardo_da_Vinci.jpg",
"fixed": true,
"checked": true
}
```
---
## `POST /api/artists/:id/clear-portrait`
Delete the portrait file from disk, set `portrait_path = NULL`, and set both checkup flags. Used by debug **Clear**; the bio page shows an empty portrait slot (no placeholder).
**Response**
```json
{
"portraitPath": null,
"fixed": true,
"checked": true
}
```
---
## `POST /api/artists/:id/upload-portrait`
Upload a local image (base64 JSON body). Validates with `sharp`, resizes to portrait dimensions, sets checkup flags.
**Body**
```json
{
"imageData": "<base64>",
"mimeType": "image/jpeg"
}
```
Max decoded size 15 MB (JSON body limit 20 MB on the server). **Response** — same as `fix-portrait`.
---
## `GET /api/artists/:id/navigation`
@@ -188,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"
}
]
}
```
@@ -212,7 +375,7 @@ Returns the image bytes with `Cache-Control: public, max-age=86400`, or `404` if
## Developer image audit
Routes for the **Checkup** page and **Debug mode** on painting detail. Register `GET /api/paintings/checkup` **before** `GET /api/paintings/:id` so `"checkup"` is not parsed as a painting id.
Routes for the **Checkup** page and **Debug mode** on painting detail and artist bio. Register `GET /api/paintings/checkup` **before** `GET /api/paintings/:id` so `"checkup"` is not parsed as a painting id.
### `GET /api/paintings/checkup`
@@ -315,6 +478,63 @@ Only `imageUrl` is required; optional fields improve fetch success for hotlinked
---
### `GET /api/paintings/:id/debug-image-search/more`
Up to 20 ranked painting image candidates for the **More** picker modal.
**Query:** `limit` (int, default 20, max 20)
**Response**
```json
{
"query": "Andrei Rublev Trinity painting",
"searchUrl": "https://…",
"source": "google-arts",
"results": [
{ "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`
Delete full + thumbnail files from disk, set `image_path` and `thumbnail_path` to `NULL`, and set both checkup flags. Used by debug **Clear**; detail view shows an empty frame (no placeholder, no on-demand refetch).
**Response**
```json
{
"imagePath": null,
"thumbnailPath": null,
"fixed": true,
"checked": true
}
```
---
### `POST /api/paintings/:id/upload-image`
Upload a local painting image (base64 JSON body). Validates with `sharp`, writes full file, regenerates thumbnail, sets checkup flags.
**Body**
```json
{
"imageData": "<base64>",
"mimeType": "image/jpeg"
}
```
Max decoded size 15 MB (JSON body limit 20 MB on the server). **Response** — same shape as `fix-image` (`imagePath`, `thumbnailPath`, `fixed`, `checked`).
---
### `GET /api/debug/image-proxy`
Proxy a remote image URL for debug preview (avoids hotlink / CORS blocks in the browser).
@@ -341,9 +561,19 @@ The React client wraps these endpoints in `client/src/api/client.ts`:
| `imageUrl(path)` | `/images/<path>` or placeholder |
| `galleryImageUrl(painting)` | Local thumb/full only (3D) |
| `galleryImageUrlWithRevision(painting, revision)` | Local URL with `?v=` cache buster after fix |
| `paintingImageUrl(painting)` | Local file or on-demand API |
| `paintingImageUrl(painting)` | Local file, on-demand API, or `null` when cleared (`checkup_fixed` + no paths) |
| `portraitUrl(path, revision?)` | `/images/<path>` with optional `?v=` cache buster |
| `api.getPaintingCheckup()` | `GET /api/paintings/checkup` |
| `api.updatePaintingCheckupFlags(id, flags)` | `PATCH /api/paintings/:id/checkup-flags` |
| `api.getPaintingDebugImageSearch(id)` | `GET /api/paintings/:id/debug-image-search` |
| `api.getPaintingDebugImageSearchMore(id, limit?)` | `GET /api/paintings/:id/debug-image-search/more` |
| `api.fixPaintingImage(id, imageUrl, context?)` | `POST /api/paintings/:id/fix-image` |
| `api.clearPaintingImage(id)` | `POST /api/paintings/:id/clear-image` |
| `api.uploadPaintingImage(id, file)` | `POST /api/paintings/:id/upload-image` |
| `api.updateArtistCheckupFlags(id, flags)` | `PATCH /api/artists/:id/checkup-flags` |
| `api.getArtistDebugPortraitSearch(id)` | `GET /api/artists/:id/debug-portrait-search` |
| `api.getArtistDebugPortraitSearchMore(id, limit?)` | `GET /api/artists/:id/debug-portrait-search/more` |
| `api.fixArtistPortrait(id, imageUrl, context?)` | `POST /api/artists/:id/fix-portrait` |
| `api.clearArtistPortrait(id)` | `POST /api/artists/:id/clear-portrait` |
| `api.uploadArtistPortrait(id, file)` | `POST /api/artists/:id/upload-portrait` |
| `debugImageProxyUrl(imageUrl, context?)` | `GET /api/debug/image-proxy?url=…` |
+30 -5
View File
@@ -66,10 +66,14 @@ Finer-grained styles (Impressionism, Cubism, Suprematism, …).
| `name` | VARCHAR(200) | |
| `birth_year`, `death_year` | INTEGER | Nullable; used for timeline portrait placement |
| `movement_id` | FK → `art_movements` | Primary movement |
| `portrait_path` | VARCHAR(500) | Relative to `data/images/` |
| `portrait_path` | VARCHAR(500) | Relative to `data/images/`; nullable after debug **Clear** |
| `bio_short`, `bio_full` | TEXT | Wikipedia lead section (`npm run fetch-artist-bios`) |
| `wikipedia_title` | VARCHAR(300) | Source page title |
| `century` | INTEGER | Rounded century bucket for seeding limits |
| `checkup_checked` | BOOLEAN NOT NULL DEFAULT false | Portrait reviewed in debug workflow (gold border on bio when true) |
| `checkup_fixed` | BOOLEAN NOT NULL DEFAULT false | Portrait replaced, cleared, or uploaded via debug |
Applied by `npm run migrate:artist-checkup-flags` (`db/migrate-artist-checkup-flags.sql`).
### `artist_periods`
@@ -94,17 +98,37 @@ Phases within an artists career (e.g. “Blue Period”, “Roman Period”).
| `title` | VARCHAR(300) | |
| `year`, `year_end` | INTEGER | Creation date(s) |
| `description` | TEXT | |
| `image_path` | VARCHAR(500) | Full-size local file |
| `thumbnail_path` | VARCHAR(500) | Smaller variant for lists / 3D |
| `image_path` | VARCHAR(500) | Full-size local file; nullable after debug **Clear** |
| `thumbnail_path` | VARCHAR(500) | Smaller variant for lists / 3D; nullable after **Clear** |
| `wikipedia_title` | VARCHAR(300) | Used by image fetcher |
| `sort_order` | INTEGER | |
| `checkup_checked` | BOOLEAN NOT NULL DEFAULT false | Reviewed in image checkup workflow (UI label: **Reviewed**) |
| `checkup_fixed` | BOOLEAN NOT NULL DEFAULT false | Image corrected via checkup / debug **Fix** |
| `checkup_fixed` | BOOLEAN NOT NULL DEFAULT false | Image corrected, cleared, or uploaded via checkup / debug |
When `checkup_fixed` is true, `checkup_checked` is set automatically and cannot be cleared until **Fixed** is off.
When `checkup_fixed` is true, `checkup_checked` is set automatically and cannot be cleared until **Fixed** is off. A cleared painting (`image_path` and `thumbnail_path` both null, `checkup_fixed` true) is shown as an empty frame in detail view and is not refetched on demand.
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 0100) |
| `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 works `wikipedia_title`).
### `painting_influences`
Directed edges: *this painting* was influenced by *that painting*.
@@ -157,6 +181,7 @@ Used by painting detail API (`influencedBy`). Painting-type rows also feed `has_
- `artists(movement_id)`, `artists(century)`
- `paintings(artist_id)`, `paintings(period_id)`, `paintings(checkup_checked)`, `paintings(checkup_fixed)`
- `artists(checkup_checked)`, `artists(checkup_fixed)`
- `art_movements(era_id)`, `art_movements(start_year, end_year)`
- `painting_influences(painting_id)`, `painting_influences(influenced_by_painting_id)`
- `painting_influence_sources(painting_id)`, `painting_influence_sources(source_artist_id)`, `painting_influence_sources(source_movement_id)`
+89 -20
View File
@@ -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,9 +8,9 @@ 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.
5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`).
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,9 +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)
@@ -78,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]
@@ -103,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
@@ -120,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.
@@ -152,13 +177,18 @@ Each artist appears as a **portrait circle** on their movements 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 artists 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 |
@@ -180,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 |
|-------|--------|
@@ -194,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 works detail page.
Opened from the 3D hall (artist or movement wing — click a frame) or from influence thumbnails on another works 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:**
@@ -214,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 works detail (different artist allowed) |
| Click influence artist portrait | Open that artists 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:**
@@ -225,7 +290,7 @@ Opened from the 3D hall (click a frame) or from influence thumbnails on another
### Debug mode (developer)
When **Debug mode** is enabled from the home header, painting detail shows a bottom-left panel with image search preview and **Checked** / **Fix it** buttons. See [Developer tools (image audit)](#developer-tools-image-audit).
When **Debug mode** is enabled from the home header, painting detail and artist biography show a bottom-left panel with image search preview and five action buttons. See [Developer tools (image audit)](#developer-tools-image-audit).
## Key design decisions
@@ -233,6 +298,7 @@ When **Debug mode** is enabled from the home header, painting detail shows a bot
- **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).
@@ -243,20 +309,23 @@ Optional workflow for curating local image files — not part of the public visi
| Feature | Where | Purpose |
|---------|--------|---------|
| **Debug mode** | Home header toggle (`client/src/utils/debugMode.ts`) | Persists in `localStorage`; enables debug panel on painting detail |
| **Debug mode** | Home header toggle (`client/src/utils/debugMode.ts`) | Persists in `localStorage`; enables debug panel on painting detail and artist bio |
| **Checkup page** | Home header → **Checkup** (`CheckupPage.tsx`) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags |
| **Debug panel** | Painting detail (bottom-left, when debug mode on) | Search preview + two action buttons |
| **Debug panel** | Painting detail or artist bio (bottom-left, when debug mode on) | Search preview + five action buttons |
### Debug panel (painting detail)
### Debug panel (painting detail and artist bio)
When debug mode is on, a panel at the bottom-left shows the image search query, a preview when a result is found, and **two buttons**:
When debug mode is on, a panel at the bottom-left shows the image search query, a preview when a result is found, and **five buttons** in two rows:
| Button | Action |
|--------|--------|
| **Checked** | Sets `checkup_checked` via `PATCH /api/paintings/:id/checkup-flags` (disabled once already reviewed) |
| **Fix it** | Replaces local full + thumbnail from the search result via `POST /api/paintings/:id/fix-image`; sets **Fixed** and **Reviewed** |
| **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 (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) |
After **Fix it**, the detail image, gallery textures, and frame colour (gold if reviewed) update without a full page reload. **Back to Gallery** returns to the live hall session, not a stale snapshot.
After **Fix it**, **More**, **Upload**, or **Clear**, the main view, gallery textures (paintings), and timeline portrait (artists) update without a full page reload. Reviewed portraits show a gold border on the bio page; reviewed paintings use gold frames in the 3D hall. **Back to Gallery** returns to the live hall session, not a stale snapshot.
### Checkup page
@@ -264,7 +333,7 @@ After **Fix it**, the detail image, gallery textures, and frame colour (gold if
**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` once on existing databases. After server code changes, restart `npm run dev` so new routes (e.g. `PATCH …/checkup-flags`) 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).
+40 -10
View File
@@ -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 movements 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 artists 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 **artists** 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).
@@ -336,19 +360,25 @@ 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` 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` in `scripts/image-fetcher.js`).
2. **Fix**`POST /api/paintings/:id/fix-image` downloads the chosen URL via `downloadImageForFix``replacePaintingImageFromUrl` in `server/image-service.js`, regenerates the thumbnail with `sharp`, and sets `checkup_fixed` + `checkup_checked`.
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`). 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 in JSON (Express body limit **20 MB**; decoded image max **15 MB**), validates with `sharp`, writes to the standard filename under `data/images/`.
### Painting detail debug panel
### Debug panel (painting detail and artist bio)
With debug mode on, `PaintingDetail.tsx` shows a bottom-left panel with search preview and two buttons:
With debug mode on, `PaintingDetail.tsx` and `ArtistBio.tsx` show a bottom-left panel with search preview and five buttons:
| Button | API | Effect |
|--------|-----|--------|
| **Checked** | `PATCH …/checkup-flags` `{ "checked": true }` | Marks reviewed; 3D frame turns gold |
| **Fix it** | `POST …/fix-image` | Saves image to disk, sets both flags, refreshes detail + gallery |
| Button | API (paintings / portraits) | Effect |
|--------|----------------------------|--------|
| **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 (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** |
The client passes `searchUrl`, `source`, and `thumbUrl` from the search result to improve download reliability. After a fix, `HomePage` updates the gallery session and appends a revision query on 3D texture URLs so replaced files reload even when the path is unchanged.
The client passes `searchUrl`, `source`, and `thumbUrl` from search results to improve download reliability. After a fix, clear, or upload, `HomePage` updates the gallery session and appends a revision query on texture URLs so replaced files reload even when the path is unchanged.
Checkup **Search visible** queues search for filtered rows only (3 concurrent); it does not search the full catalog on load.
+18 -2
View File
@@ -65,7 +65,10 @@ After a fresh seed, run these to match a fully populated local install:
npm run fetch-artist-bios # bio_short / bio_full from Wikipedia
npm run expand-catalog # famous works for artists below MIN_PAINTINGS
npm run update-influences # painting influence graph for detail view + hall exits
npm run migrate:checkup-flags # optional: review/fixed flags for Checkup page
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 ..
@@ -114,6 +117,11 @@ Open http://localhost:3001 (or your configured `PORT`).
| `npm run sync-image-paths` | `scripts/sync-image-paths.js` | Align DB paths with disk *(if present)* |
| `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 works 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 |
@@ -131,7 +139,9 @@ These are checked in and maintained:
- `influence-discovery.js` + `influence-resolver.js` — web discovery and polymorphic source resolution
- `fetch-missing-images.js` — batch image backfill
- `find-duplicate-paintings.js` — duplicate catalog audit
- `migrate-checkup-flags.js` — checkup workflow columns
- `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`.
@@ -168,7 +178,13 @@ After clone: copy `.env.example` → `.env`, install dependencies, run `npm run
| Permission denied creating tables | `gallery` user lacks CREATE | Run admin grants, then migrate |
| 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 |
+5 -2
View File
@@ -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 (**Checked** / **Fix it**), 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
@@ -40,7 +40,10 @@ Interactive virtual art gallery: zoomable historical timeline with era click-to-
npm run fetch-artist-bios # Wikipedia biographies for all artists
npm run expand-catalog # add famous works for artists with thin catalogs
npm run update-influences # art-history lineage links between paintings
npm run migrate:checkup-flags # review/fixed flags for Checkup + debug mode
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
```
+139 -3
View File
@@ -3,6 +3,7 @@ import type {
YearBounds,
Artist,
ArtistDetail,
MovementGalleryDetail,
PaintingDetail,
ArtistNavigation,
} from '../types';
@@ -20,6 +21,12 @@ export function imageUrl(path: string | null | undefined): string {
return `/images/${path}`;
}
export function portraitUrl(path: string | null | undefined, revision?: number): string {
const base = imageUrl(path);
if (!revision || base.startsWith('/placeholder')) return base;
return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`;
}
/** Image for 3D gallery — local cached files only (API fetch is too slow for realtime 3D) */
export function galleryImageUrl(painting: {
thumbnail_path?: string | null;
@@ -47,12 +54,47 @@ export function paintingImageUrl(painting: {
id: number;
image_path?: string | null;
thumbnail_path?: string | null;
}): string {
checkup_fixed?: boolean;
}): string | null {
if (painting.image_path) return `/images/${painting.image_path}`;
if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`;
if (painting.checkup_fixed) return null;
return `/api/paintings/${painting.id}/image?size=full`;
}
async function fileToBase64Payload(file: File): Promise<{ imageData: string; mimeType: string }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (typeof result !== 'string') {
reject(new Error('Could not read file'));
return;
}
const comma = result.indexOf(',');
resolve({
imageData: comma >= 0 ? result.slice(comma + 1) : result,
mimeType: file.type || 'image/jpeg',
});
};
reader.onerror = () => reject(new Error('Could not read file'));
reader.readAsDataURL(file);
});
}
async function postJsonImageAction<T>(url: string, payload: { imageData: string; mimeType: string }): Promise<T> {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json() as Promise<T>;
}
export async function preloadArtistImages(artistId: number): Promise<{ fetched: number; total: number }> {
const res = await fetch(`${API}/artists/${artistId}/preload-images`, { method: 'POST' });
if (!res.ok) throw new Error('Preload failed');
@@ -60,8 +102,14 @@ export async function preloadArtistImages(artistId: number): Promise<{ fetched:
}
export interface FixPaintingImageResult {
imagePath: string;
thumbnailPath: string;
imagePath: string | null;
thumbnailPath: string | null;
fixed?: boolean;
checked?: boolean;
}
export interface FixArtistPortraitResult {
portraitPath: string | null;
fixed?: boolean;
checked?: boolean;
}
@@ -75,6 +123,22 @@ export interface DebugImageSearchResult {
thumbUrl?: string;
}
export interface DebugImageSearchResultItem {
imageUrl: string;
thumbUrl?: string;
source: string;
width?: number;
height?: number;
}
export interface DebugImageSearchManyResult {
query: string;
searchUrl: string;
source: string;
sourceLabel?: string;
results: DebugImageSearchResultItem[];
}
export interface PaintingCheckupRow {
id: number;
title: string;
@@ -112,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`),
@@ -120,6 +186,9 @@ export const api = {
getPaintingDebugImageSearch: (id: number) =>
fetchJson<DebugImageSearchResult>(`${API}/paintings/${id}/debug-image-search`),
getPaintingDebugImageSearchMore: (id: number, limit = 20) =>
fetchJson<DebugImageSearchManyResult>(`${API}/paintings/${id}/debug-image-search/more?limit=${limit}`),
fixPaintingImage: (
id: number,
imageUrl: string,
@@ -137,6 +206,20 @@ export const api = {
return res.json() as Promise<FixPaintingImageResult>;
}),
clearPaintingImage: (id: number) =>
fetch(`${API}/paintings/${id}/clear-image`, { method: 'POST' }).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Clear failed: ${res.status}`);
}
return res.json() as Promise<FixPaintingImageResult>;
}),
uploadPaintingImage: async (id: number, file: File) => {
const payload = await fileToBase64Payload(file);
return postJsonImageAction<FixPaintingImageResult>(`${API}/paintings/${id}/upload-image`, payload);
},
getPaintingCheckup: () => fetchJson<PaintingCheckupData>(`${API}/paintings/checkup`),
updatePaintingCheckupFlags: (
@@ -154,6 +237,59 @@ export const api = {
}
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
}),
getArtistDebugPortraitSearch: (id: number) =>
fetchJson<DebugImageSearchResult>(`${API}/artists/${id}/debug-portrait-search`),
getArtistDebugPortraitSearchMore: (id: number, limit = 20) =>
fetchJson<DebugImageSearchManyResult>(`${API}/artists/${id}/debug-portrait-search/more?limit=${limit}`),
fixArtistPortrait: (
id: number,
imageUrl: string,
context?: { searchUrl?: string; source?: string; pageUrl?: string; thumbUrl?: string }
) =>
fetch(`${API}/artists/${id}/fix-portrait`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl, ...context }),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Fix failed: ${res.status}`);
}
return res.json() as Promise<FixArtistPortraitResult>;
}),
clearArtistPortrait: (id: number) =>
fetch(`${API}/artists/${id}/clear-portrait`, { method: 'POST' }).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Clear failed: ${res.status}`);
}
return res.json() as Promise<FixArtistPortraitResult>;
}),
uploadArtistPortrait: async (id: number, file: File) => {
const payload = await fileToBase64Payload(file);
return postJsonImageAction<FixArtistPortraitResult>(`${API}/artists/${id}/upload-portrait`, payload);
},
updateArtistCheckupFlags: (
id: number,
flags: { checked?: boolean; fixed?: boolean }
) =>
fetch(`${API}/artists/${id}/checkup-flags`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(flags),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Update failed: ${res.status}`);
}
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
}),
};
export function debugImageProxyUrl(
+13
View File
@@ -58,6 +58,19 @@
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.bio-portrait-checked img {
border-color: #ffd700;
box-shadow: 0 8px 28px rgba(255, 215, 0, 0.25);
}
.bio-portrait-empty {
width: 240px;
height: 300px;
border: 4px solid rgba(201, 169, 110, 0.35);
border-radius: 4px;
background: transparent;
}
.bio-text {
flex: 1;
}
+297 -10
View File
@@ -1,14 +1,60 @@
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
import type { Artist } from '../types';
import { imageUrl } from '../api/client';
import {
api,
debugImageProxyUrl,
portraitUrl,
type DebugImageSearchResult,
type DebugImageSearchResultItem,
type FixArtistPortraitResult,
} from '../api/client';
import DebugSearchResultsModal from './DebugSearchResultsModal';
import '../components/PaintingDetail.css';
import './ArtistBio.css';
interface Props {
artist: Artist & { movement_name?: string };
debugMode?: boolean;
portraitRevision?: number;
onBack: () => void;
onEnterGallery: () => void;
onArtistPortraitFixed?: (
artistId: number,
fixResult: FixArtistPortraitResult
) => void | Promise<void>;
onArtistCheckupFlagsUpdated?: (
artistId: number,
flags: { checked: boolean; fixed: boolean }
) => void | Promise<void>;
}
export default function ArtistBio({ artist, onBack, onEnterGallery }: Props) {
export default function ArtistBio({
artist,
debugMode = false,
portraitRevision = 0,
onBack,
onEnterGallery,
onArtistPortraitFixed,
onArtistCheckupFlagsUpdated,
}: Props) {
const [debugSearch, setDebugSearch] = useState<DebugImageSearchResult | null>(null);
const [debugLoading, setDebugLoading] = useState(false);
const [debugError, setDebugError] = useState<string | null>(null);
const [fixing, setFixing] = useState(false);
const [markingChecked, setMarkingChecked] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
const [moreLoading, setMoreLoading] = useState(false);
const [moreError, setMoreError] = useState<string | null>(null);
const [moreResults, setMoreResults] = useState<Awaited<ReturnType<typeof api.getArtistDebugPortraitSearchMore>> | null>(null);
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
const [clearing, setClearing] = useState(false);
const [uploading, setUploading] = useState(false);
const uploadInputRef = useRef<HTMLInputElement>(null);
const portraitCleared = !artist.portrait_path && !!artist.checkup_fixed;
const showPortrait = !!artist.portrait_path || !artist.checkup_fixed;
const portraitSrc = portraitUrl(artist.portrait_path, portraitRevision || undefined);
const lifespan =
artist.birth_year && artist.death_year
? `${artist.birth_year} ${artist.death_year}`
@@ -16,6 +62,154 @@ export default function ArtistBio({ artist, onBack, onEnterGallery }: Props) {
? `b. ${artist.birth_year}`
: '';
useEffect(() => {
if (!debugMode) {
setDebugSearch(null);
setDebugError(null);
setMoreOpen(false);
return;
}
let cancelled = false;
setDebugLoading(true);
setDebugError(null);
setDebugSearch(null);
api.getArtistDebugPortraitSearch(artist.id)
.then((result) => {
if (!cancelled) setDebugSearch(result);
})
.catch(() => {
if (!cancelled) setDebugError('Portrait image search failed.');
})
.finally(() => {
if (!cancelled) setDebugLoading(false);
});
return () => {
cancelled = true;
};
}, [debugMode, artist.id, artist.name]);
const applyPortraitUpdate = async (fixResult: FixArtistPortraitResult) => {
if (onArtistPortraitFixed) {
await onArtistPortraitFixed(artist.id, fixResult);
}
};
const applyFixFromSearch = async (
imageUrl: string,
context: { searchUrl: string; source: string; thumbUrl?: string }
) => {
const fixResult = await api.fixArtistPortrait(artist.id, imageUrl, context);
await applyPortraitUpdate(fixResult);
setDebugSearch((prev) =>
prev
? { ...prev, imageUrl, thumbUrl: context.thumbUrl ?? prev.thumbUrl, source: context.source }
: prev
);
};
const handleFixPortrait = async () => {
if (!debugSearch?.imageUrl || fixing) return;
setFixing(true);
setDebugError(null);
try {
await applyFixFromSearch(debugSearch.imageUrl, {
searchUrl: debugSearch.searchUrl,
source: debugSearch.source,
thumbUrl: debugSearch.thumbUrl,
});
} catch (err) {
setDebugError(err instanceof Error ? err.message : 'Could not replace portrait.');
} finally {
setFixing(false);
}
};
const handleOpenMore = async () => {
setMoreOpen(true);
setMoreLoading(true);
setMoreError(null);
setMoreResults(null);
try {
const results = await api.getArtistDebugPortraitSearchMore(artist.id);
setMoreResults(results);
} catch {
setMoreError('Could not load search results.');
} finally {
setMoreLoading(false);
}
};
const handleSelectMoreResult = async (item: DebugImageSearchResultItem) => {
if (fixing || applyingUrl) return;
setApplyingUrl(item.imageUrl);
setMoreError(null);
setDebugError(null);
try {
const searchUrl = moreResults?.searchUrl ?? debugSearch?.searchUrl ?? '';
await applyFixFromSearch(item.imageUrl, {
searchUrl,
source: item.source,
thumbUrl: item.thumbUrl,
});
setMoreOpen(false);
} catch (err) {
setMoreError(err instanceof Error ? err.message : 'Could not replace portrait.');
} finally {
setApplyingUrl(null);
}
};
const handleMarkChecked = async () => {
if (artist.checkup_checked || markingChecked) return;
setMarkingChecked(true);
setDebugError(null);
try {
const updated = await api.updateArtistCheckupFlags(artist.id, { checked: true });
await onArtistCheckupFlagsUpdated?.(artist.id, updated);
} catch (err) {
setDebugError(err instanceof Error ? err.message : 'Could not mark as checked.');
} finally {
setMarkingChecked(false);
}
};
const handleClearPortrait = async () => {
if (clearing || fixing || uploading) return;
setClearing(true);
setDebugError(null);
try {
const result = await api.clearArtistPortrait(artist.id);
await applyPortraitUpdate(result);
} catch (err) {
setDebugError(err instanceof Error ? err.message : 'Could not clear portrait.');
} finally {
setClearing(false);
}
};
const handleUploadClick = () => {
uploadInputRef.current?.click();
};
const handleUploadFile = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || uploading || fixing || clearing) return;
setUploading(true);
setDebugError(null);
try {
const result = await api.uploadArtistPortrait(artist.id, file);
await applyPortraitUpdate(result);
} catch (err) {
setDebugError(err instanceof Error ? err.message : 'Could not upload portrait.');
} finally {
setUploading(false);
}
};
return (
<div className="artist-bio">
<header className="bio-header">
@@ -25,14 +219,18 @@ export default function ArtistBio({ artist, onBack, onEnterGallery }: Props) {
</header>
<div className="bio-content">
<div className="bio-portrait">
<img
src={imageUrl(artist.portrait_path)}
alt={artist.name}
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
}}
/>
<div
className={`bio-portrait${artist.checkup_checked ? ' bio-portrait-checked' : ''}${portraitCleared ? ' bio-portrait-empty' : ''}`}
>
{showPortrait ? (
<img
src={portraitSrc}
alt={artist.name}
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
}}
/>
) : null}
</div>
<div className="bio-text">
@@ -66,6 +264,95 @@ export default function ArtistBio({ artist, onBack, onEnterGallery }: Props) {
)}
</div>
</div>
{debugMode && (
<aside className="debug-image-panel" aria-label="Debug portrait search">
<h4>{debugSearch?.sourceLabel ?? 'Portrait image search'}</h4>
<p className="debug-image-query">
{debugSearch?.query ?? `${artist.name} portrait`}
</p>
{debugLoading && <p className="debug-image-status">Searching</p>}
{debugError && <p className="debug-image-error">{debugError}</p>}
{!debugLoading && debugSearch?.imageUrl && (
<img
className="debug-image-preview"
src={debugImageProxyUrl(debugSearch.imageUrl, {
searchUrl: debugSearch.searchUrl,
source: debugSearch.source,
})}
alt={`Search result for ${debugSearch.query}`}
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
}}
/>
)}
{!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
<p className="debug-image-status">No portrait image result found.</p>
)}
<div className="debug-action-buttons">
<button
type="button"
className="debug-checked-btn"
onClick={handleMarkChecked}
disabled={!!artist.checkup_checked || markingChecked}
>
{markingChecked ? '…' : 'Checked'}
</button>
<button
type="button"
className="debug-fix-btn"
onClick={handleFixPortrait}
disabled={fixing || debugLoading || !debugSearch?.imageUrl}
>
{fixing ? '…' : 'Fix it'}
</button>
<button
type="button"
className="debug-more-btn"
onClick={handleOpenMore}
disabled={debugLoading || moreLoading}
>
{moreLoading ? '…' : 'More'}
</button>
</div>
<div className="debug-action-buttons debug-action-buttons-secondary">
<button
type="button"
className="debug-clear-btn"
onClick={handleClearPortrait}
disabled={clearing || fixing || uploading || portraitCleared}
>
{clearing ? '…' : 'Clear'}
</button>
<button
type="button"
className="debug-upload-btn"
onClick={handleUploadClick}
disabled={uploading || fixing || clearing}
>
{uploading ? '…' : 'Upload'}
</button>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
hidden
onChange={handleUploadFile}
/>
</div>
</aside>
)}
<DebugSearchResultsModal
open={moreOpen}
title="Choose portrait"
data={moreResults}
loading={moreLoading}
error={moreError}
applyingUrl={applyingUrl}
onClose={() => setMoreOpen(false)}
onSelect={handleSelectMoreResult}
/>
</div>
);
}
@@ -0,0 +1,162 @@
.debug-search-modal-overlay {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(0, 0, 0, 0.72);
}
.debug-search-modal {
width: min(920px, 100%);
max-height: min(88vh, 900px);
display: flex;
flex-direction: column;
padding: 16px 18px 18px;
border-radius: 10px;
border: 1px solid rgba(232, 160, 64, 0.45);
background: rgba(15, 15, 26, 0.98);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
font-family: ui-monospace, 'Cascadia Code', monospace;
color: #e8d5b5;
}
.debug-search-modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.debug-search-modal-header h3 {
margin: 0 0 4px;
font-size: 14px;
font-weight: 600;
color: #e8a040;
}
.debug-search-modal-query {
margin: 0;
font-size: 11px;
line-height: 1.4;
color: rgba(232, 213, 181, 0.75);
word-break: break-word;
}
.debug-search-modal-close {
flex-shrink: 0;
width: 32px;
height: 32px;
border: 1px solid rgba(201, 169, 110, 0.4);
border-radius: 6px;
background: transparent;
color: #e8d5b5;
font-size: 22px;
line-height: 1;
cursor: pointer;
}
.debug-search-modal-close:hover {
background: rgba(201, 169, 110, 0.15);
}
.debug-search-modal-hint,
.debug-search-modal-status {
margin: 0 0 12px;
font-size: 11px;
color: rgba(201, 169, 110, 0.75);
}
.debug-search-modal-error {
margin: 0 0 12px;
font-size: 11px;
color: #ff8a80;
}
.debug-search-modal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
overflow-y: auto;
padding-right: 4px;
max-height: min(68vh, 720px);
}
.debug-search-modal-item {
position: relative;
display: flex;
flex-direction: column;
padding: 0;
border: 2px solid rgba(201, 169, 110, 0.35);
border-radius: 6px;
background: #2a1f15;
cursor: pointer;
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);
}
.debug-search-modal-item:disabled {
cursor: wait;
opacity: 0.7;
}
.debug-search-modal-item-busy {
border-color: #ffd700;
}
.debug-search-modal-item img {
width: 100%;
height: 100%;
object-fit: cover;
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;
left: 4px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.65);
font-size: 10px;
font-weight: 600;
color: #ffd700;
}
.debug-search-modal-item-busy-label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.55);
font-size: 11px;
font-weight: 600;
color: #ffd700;
}
@@ -0,0 +1,154 @@
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;
data: DebugImageSearchManyResult | null;
loading: boolean;
error: string | null;
applyingUrl: string | null;
onClose: () => void;
onSelect: (item: DebugImageSearchResultItem) => void;
}
export default function DebugSearchResultsModal({
open,
title,
data,
loading,
error,
applyingUrl,
onClose,
onSelect,
}: Props) {
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [open, onClose]);
if (!open) return null;
return (
<div
className="debug-search-modal-overlay"
role="dialog"
aria-modal="true"
aria-label={title}
onClick={onClose}
>
<div className="debug-search-modal" onClick={(e) => e.stopPropagation()}>
<header className="debug-search-modal-header">
<div>
<h3>{title}</h3>
{data?.query && <p className="debug-search-modal-query">{data.query}</p>}
</div>
<button type="button" className="debug-search-modal-close" onClick={onClose} aria-label="Close">
×
</button>
</header>
{loading && <p className="debug-search-modal-status">Loading results</p>}
{error && <p className="debug-search-modal-error">{error}</p>}
{!loading && !error && data && data.results.length === 0 && (
<p className="debug-search-modal-status">No images found.</p>
)}
{!loading && data && data.results.length > 0 && (
<>
<p className="debug-search-modal-hint">Click an image to replace the current one.</p>
<div className="debug-search-modal-grid">
{data.results.map((item, index) => {
const busy = applyingUrl === item.imageUrl;
return (
<button
key={`${item.imageUrl}-${index}`}
type="button"
className={`debug-search-modal-item${busy ? ' debug-search-modal-item-busy' : ''}`}
disabled={!!applyingUrl}
onClick={() => onSelect(item)}
title="Use this image"
>
<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>
);
})}
</div>
</>
)}
</div>
</div>
);
}
+259
View File
@@ -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>
);
}
+106
View File
@@ -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 };
+81 -12
View File
@@ -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;
+236 -130
View File
@@ -1,6 +1,6 @@
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
import type { ArtMovement, Artist } from '../types';
import { imageUrl } from '../api/client';
import { portraitUrl } from '../api/client';
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
import './MovementBands.css';
@@ -8,12 +8,15 @@ import './MovementBands.css';
interface Props {
movements: ArtMovement[];
artists: Artist[];
portraitRevisions?: Record<number, number>;
viewStart: number;
viewEnd: number;
absoluteMin: number;
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 {
@@ -60,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 =
@@ -166,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;
}
@@ -325,16 +367,20 @@ function branchTargetOnChild(layout: MovementLayout): { x: number; y: number } {
export default function MovementBands({
movements,
artists,
portraitRevisions,
viewStart,
viewEnd,
absoluteMin,
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 });
@@ -422,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);
@@ -575,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">
@@ -717,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}`;
@@ -745,32 +835,48 @@ 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}`}
>
<img
src={imageUrl(artist.portrait_path)}
src={portraitUrl(artist.portrait_path, portraitRevisions?.[artist.id])}
alt={artist.name}
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
@@ -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>
);
}
+83
View File
@@ -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;
@@ -499,6 +504,84 @@
cursor: wait;
}
.debug-more-btn {
flex: 1;
padding: 8px 10px;
border: 1px solid rgba(201, 169, 110, 0.55);
border-radius: 6px;
background: rgba(201, 169, 110, 0.08);
color: #e8d5b5;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.debug-more-btn:hover:not(:disabled) {
background: rgba(201, 169, 110, 0.2);
border-color: #c9a96e;
}
.debug-more-btn:disabled {
opacity: 0.6;
cursor: wait;
}
.debug-action-buttons-secondary {
margin-top: 0;
}
.debug-clear-btn,
.debug-upload-btn {
flex: 1;
padding: 8px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.debug-clear-btn {
border: 1px solid rgba(232, 120, 100, 0.55);
background: rgba(232, 120, 100, 0.1);
color: #e87864;
}
.debug-clear-btn:hover:not(:disabled) {
background: rgba(232, 120, 100, 0.2);
border-color: #e87864;
}
.debug-upload-btn {
border: 1px solid rgba(140, 190, 140, 0.55);
background: rgba(140, 190, 140, 0.1);
color: #8cbe8c;
}
.debug-upload-btn:hover:not(:disabled) {
background: rgba(140, 190, 140, 0.2);
border-color: #8cbe8c;
}
.debug-clear-btn:disabled,
.debug-upload-btn:disabled {
opacity: 0.6;
cursor: wait;
}
.painting-frame-empty {
min-height: 280px;
cursor: default;
}
.painting-frame-empty:hover {
transform: none;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), inset 0 0 0 2px #c9a96e;
}
.painting-frame-cleared {
background: transparent;
}
.debug-image-status {
margin: 0;
font-size: 10px;
+196 -24
View File
@@ -1,6 +1,8 @@
import { useEffect, useState, type SyntheticEvent } from 'react';
import { useEffect, useRef, useState, type ChangeEvent, type SyntheticEvent } from 'react';
import type { InfluenceLink, Painting, PaintingDetail } from '../types';
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type FixPaintingImageResult } from '../api/client';
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';
@@ -158,7 +160,7 @@ function InfluenceCard({
title={`View ${inf.title}`}
>
<img
src={paintingImageUrl({ id: inf.id, image_path: inf.image_path })}
src={paintingImageUrl({ id: inf.id, image_path: inf.image_path }) ?? '/placeholder-art.svg'}
alt={inf.title || 'Painting'}
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
@@ -189,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);
@@ -197,8 +199,21 @@ export default function PaintingDetailView({
const [debugError, setDebugError] = useState<string | null>(null);
const [fixing, setFixing] = useState(false);
const [markingChecked, setMarkingChecked] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
const [moreLoading, setMoreLoading] = useState(false);
const [moreError, setMoreError] = useState<string | null>(null);
const [moreResults, setMoreResults] = useState<Awaited<ReturnType<typeof api.getPaintingDebugImageSearchMore>> | null>(null);
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 imageSrc = `${paintingImageUrl(painting)}${paintingImageUrl(painting).includes('?') ? '&' : '?'}v=${imageVersion}`;
const baseImageUrl = paintingImageUrl(painting);
const imageSrc = baseImageUrl
? `${baseImageUrl}${baseImageUrl.includes('?') ? '&' : '?'}v=${imageVersion}`
: null;
const imageCleared = !baseImageUrl && !!painting.checkup_fixed;
const catalogIndex = artistPaintings.findIndex((p) => p.id === painting.id);
const previousPainting = catalogIndex > 0 ? artistPaintings[catalogIndex - 1] : null;
@@ -211,12 +226,17 @@ export default function PaintingDetailView({
useEffect(() => {
setFullscreen(false);
setImageVersion(0);
setMoreOpen(false);
setMoreResults(null);
setMoreError(null);
setActiveAnnotationId(null);
}, [painting.id]);
useEffect(() => {
if (!debugMode) {
setDebugSearch(null);
setDebugError(null);
setMoreOpen(false);
return;
}
@@ -241,20 +261,36 @@ export default function PaintingDetailView({
};
}, [debugMode, painting.id, painting.title, painting.artist_name]);
const applyImageUpdate = async (fixResult: FixPaintingImageResult) => {
setImageVersion((v) => v + 1);
if (onPaintingImageFixed) {
await onPaintingImageFixed(painting.id, fixResult);
}
};
const applyFixFromSearch = async (
imageUrl: string,
context: { searchUrl: string; source: string; thumbUrl?: string }
) => {
const fixResult = await api.fixPaintingImage(painting.id, imageUrl, context);
await applyImageUpdate(fixResult);
setDebugSearch((prev) =>
prev
? { ...prev, imageUrl, thumbUrl: context.thumbUrl ?? prev.thumbUrl, source: context.source }
: prev
);
};
const handleFixImage = async () => {
if (!debugSearch?.imageUrl || fixing) return;
setFixing(true);
setDebugError(null);
try {
const fixResult = await api.fixPaintingImage(painting.id, debugSearch.imageUrl, {
await applyFixFromSearch(debugSearch.imageUrl, {
searchUrl: debugSearch.searchUrl,
source: debugSearch.source,
thumbUrl: debugSearch.thumbUrl,
});
setImageVersion((v) => v + 1);
if (onPaintingImageFixed) {
await onPaintingImageFixed(painting.id, fixResult);
}
} catch (err) {
setDebugError(err instanceof Error ? err.message : 'Could not replace image.');
} finally {
@@ -262,6 +298,41 @@ export default function PaintingDetailView({
}
};
const handleOpenMore = async () => {
setMoreOpen(true);
setMoreLoading(true);
setMoreError(null);
setMoreResults(null);
try {
const results = await api.getPaintingDebugImageSearchMore(painting.id);
setMoreResults(results);
} catch {
setMoreError('Could not load search results.');
} finally {
setMoreLoading(false);
}
};
const handleSelectMoreResult = async (item: DebugImageSearchResultItem) => {
if (fixing || applyingUrl) return;
setApplyingUrl(item.imageUrl);
setMoreError(null);
setDebugError(null);
try {
const searchUrl = moreResults?.searchUrl ?? debugSearch?.searchUrl ?? '';
await applyFixFromSearch(item.imageUrl, {
searchUrl,
source: item.source,
thumbUrl: item.thumbUrl,
});
setMoreOpen(false);
} catch (err) {
setMoreError(err instanceof Error ? err.message : 'Could not replace image.');
} finally {
setApplyingUrl(null);
}
};
const handleMarkChecked = async () => {
if (painting.checkup_checked || markingChecked) return;
setMarkingChecked(true);
@@ -276,6 +347,40 @@ export default function PaintingDetailView({
}
};
const handleClearImage = async () => {
if (clearing || fixing || uploading) return;
setClearing(true);
setDebugError(null);
try {
const result = await api.clearPaintingImage(painting.id);
await applyImageUpdate(result);
} catch (err) {
setDebugError(err instanceof Error ? err.message : 'Could not clear image.');
} finally {
setClearing(false);
}
};
const handleUploadClick = () => {
uploadInputRef.current?.click();
};
const handleUploadFile = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || uploading || fixing || clearing) return;
setUploading(true);
setDebugError(null);
try {
const result = await api.uploadPaintingImage(painting.id, file);
await applyImageUpdate(result);
} catch (err) {
setDebugError(err instanceof Error ? err.message : 'Could not upload image.');
} finally {
setUploading(false);
}
};
useEffect(() => {
if (fullscreen) return;
@@ -356,22 +461,45 @@ export default function PaintingDetailView({
)}
<div
className="painting-frame-large painting-frame-clickable"
role="button"
tabIndex={0}
onClick={() => setFullscreen(true)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setFullscreen(true);
}
}}
title="View full screen"
aria-label={`View ${painting.title} full screen`}
className={`painting-frame-large${imageSrc ? ' painting-frame-clickable' : ' painting-frame-empty'}${imageCleared ? ' painting-frame-cleared' : ''}`}
role={imageSrc ? 'button' : undefined}
tabIndex={imageSrc ? 0 : undefined}
onClick={imageSrc ? () => setFullscreen(true) : undefined}
onKeyDown={
imageSrc
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setFullscreen(true);
}
}
: undefined
}
title={imageSrc ? 'View full screen' : undefined}
aria-label={imageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`}
>
<img src={imageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
<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"
@@ -452,11 +580,55 @@ export default function PaintingDetailView({
>
{fixing ? '…' : 'Fix it'}
</button>
<button
type="button"
className="debug-more-btn"
onClick={handleOpenMore}
disabled={debugLoading || moreLoading}
>
{moreLoading ? '…' : 'More'}
</button>
</div>
<div className="debug-action-buttons debug-action-buttons-secondary">
<button
type="button"
className="debug-clear-btn"
onClick={handleClearImage}
disabled={clearing || fixing || uploading || imageCleared}
>
{clearing ? '…' : 'Clear'}
</button>
<button
type="button"
className="debug-upload-btn"
onClick={handleUploadClick}
disabled={uploading || fixing || clearing}
>
{uploading ? '…' : 'Upload'}
</button>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
hidden
onChange={handleUploadFile}
/>
</div>
</aside>
)}
{fullscreen && (
<DebugSearchResultsModal
open={moreOpen}
title="Choose painting image"
data={moreResults}
loading={moreLoading}
error={moreError}
applyingUrl={applyingUrl}
onClose={() => setMoreOpen(false)}
onSelect={handleSelectMoreResult}
/>
{fullscreen && imageSrc && (
<PaintingLightbox
src={imageSrc}
alt={painting.title}
+65 -28
View File
@@ -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;
}
+43 -2
View File
@@ -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>
);
}
+39
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -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 },
+601
View File
@@ -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;
+30
View File
@@ -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;
}
+1 -1
View File
@@ -348,7 +348,7 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
setImageVersionById((prev) => ({ ...prev, [row.id]: (prev[row.id] ?? 0) + 1 }));
setRows((list) =>
list.map((r) =>
r.id === row.id
r.id === row.id && updated.imagePath && updated.thumbnailPath
? {
...r,
gallery_file: updated.imagePath,
+11
View File
@@ -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 {
+235 -57
View File
@@ -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 } from '../api/client';
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail } from '../types';
import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
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,
@@ -29,11 +46,16 @@ function patchPaintingInArtistDetail(
};
}
function patchArtistInArtistDetail(detail: ArtistDetail, patch: Partial<Artist>): ArtistDetail {
return {
...detail,
artist: { ...detail.artist, ...patch },
};
}
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);
@@ -43,7 +65,13 @@ export default function HomePage() {
const [error, setError] = useState<string | null>(null);
const [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]);
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(() => {
@@ -60,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);
}
@@ -103,8 +133,8 @@ export default function HomePage() {
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
const data = await api.getPainting(paintingId);
const patch: Partial<Painting> = {
image_path: fixResult.imagePath ?? data.painting.image_path,
thumbnail_path: fixResult.thumbnailPath ?? data.painting.thumbnail_path,
image_path: fixResult.imagePath,
thumbnail_path: fixResult.thumbnailPath,
checkup_checked: fixResult.checked ?? true,
checkup_fixed: fixResult.fixed ?? true,
};
@@ -124,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 };
});
@@ -131,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(
@@ -159,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 };
});
@@ -166,25 +212,106 @@ 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 applyArtistPatch = useCallback((artistId: number, patch: Partial<Artist>) => {
setArtists((list) => list.map((a) => (a.id === artistId ? { ...a, ...patch } : a)));
setView((current) => {
if (current.type === 'bio' && current.artistId === artistId) {
return { ...current, data: patchArtistInArtistDetail(current.data, patch) };
}
if (current.type === 'gallery' && current.artistId === artistId) {
return { ...current, data: patchArtistInArtistDetail(current.data, patch) };
}
if (current.type === 'painting' && current.data.painting.artist_id === artistId) {
return {
...current,
data: {
...current.data,
painting: {
...current.data.painting,
artist_portrait: patch.portrait_path ?? current.data.painting.artist_portrait,
},
},
};
}
return current;
});
setGallerySession((session) =>
session?.kind === 'artist' && session.artistId === artistId
? { ...session, data: patchArtistInArtistDetail(session.data, patch) }
: session
);
}, []);
const handleArtistPortraitFixed = useCallback(
async (artistId: number, fixResult: FixArtistPortraitResult) => {
const data = await api.getArtist(artistId);
const patch: Partial<Artist> = {
portrait_path: fixResult.portraitPath,
checkup_checked: fixResult.checked ?? true,
checkup_fixed: fixResult.fixed ?? true,
};
setPortraitRevisions((prev) => ({ ...prev, [artistId]: (prev[artistId] ?? 0) + 1 }));
applyArtistPatch(artistId, patch);
setView((current) =>
current.type === 'bio' && current.artistId === artistId
? { ...current, data: { ...data, artist: { ...data.artist, ...patch } } }
: current
);
},
[applyArtistPatch]
);
const handleArtistCheckupFlagsUpdated = useCallback(
async (artistId: number, flags: { checked: boolean; fixed: boolean }) => {
const patch: Partial<Artist> = {
checkup_checked: flags.checked,
checkup_fixed: flags.fixed,
};
applyArtistPatch(artistId, patch);
setView((current) =>
current.type === 'bio' && current.artistId === artistId
? { ...current, data: patchArtistInArtistDetail(current.data, patch) }
: current
);
},
[applyArtistPatch]
);
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);
@@ -228,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) => {
@@ -252,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>
)}
@@ -277,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);
}
@@ -305,10 +470,14 @@ export default function HomePage() {
<div className="home-overlay">
<ArtistBio
artist={view.data.artist}
debugMode={debugMode}
portraitRevision={portraitRevisions[view.data.artist.id]}
onBack={() => setView(view.returnTo)}
onEnterGallery={() =>
setView({ type: 'gallery', artistId: view.artistId, data: view.data })
}
onArtistPortraitFixed={handleArtistPortraitFixed}
onArtistCheckupFlagsUpdated={handleArtistCheckupFlagsUpdated}
/>
</div>
)}
@@ -328,7 +497,7 @@ export default function HomePage() {
type="button"
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
onClick={toggleDebugMode}
title="Toggle developer image audit mode on painting details"
title="Toggle developer image audit mode on painting details and artist bios"
>
Debug mode{debugMode ? ': ON' : ''}
</button>
@@ -345,33 +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}
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>
)}
</>
+26 -4
View File
@@ -30,11 +30,13 @@ export interface Artist {
movement_id: number;
movement_name?: string;
movement_color?: string;
portrait_path: string;
portrait_path: string | null;
bio_short: string;
bio_full: string;
wikipedia_title: string;
century: number;
checkup_checked?: boolean;
checkup_fixed?: boolean;
}
export interface ArtistPeriod {
@@ -55,8 +57,8 @@ export interface Painting {
year: number;
year_end?: number;
description: string;
image_path: string;
thumbnail_path?: string;
image_path: string | null;
thumbnail_path?: string | null;
wikipedia_title: string;
sort_order: number;
artist_name?: string;
@@ -94,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 };
painting: Painting & { artist_name: string; artist_portrait: string | null };
influencedBy: InfluenceLink[];
influenced: InfluenceLink[];
annotations?: PaintingAnnotation[];
}
export interface ArtistDetail {
@@ -106,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[];
+28
View File
@@ -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 };
}
+273
View File
@@ -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 (5060 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 };
+14
View File
@@ -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'>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 683 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 646 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 646 KiB

After

Width:  |  Height:  |  Size: 613 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 646 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 646 KiB

After

Width:  |  Height:  |  Size: 635 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 715 KiB

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 958 KiB

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 645 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 MiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1003 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

After

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 760 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Some files were not shown because too many files have changed in this diff Show More