diff --git a/Documentation/API.md b/Documentation/API.md index e7accd5..9d0107c 100644 --- a/Documentation/API.md +++ b/Documentation/API.md @@ -76,6 +76,54 @@ Artists belonging to a single movement. --- +## `GET /api/movements/:id/gallery` + +Full **movement gallery** payload for the 3D movement wings view (opened by clicking a movement name on the home page). + +**Response** + +```json +{ + "movement": { + "id": 34, + "name": "Baroque", + "start_year": 1600, + "end_year": 1750, + "era_id": 6, + "era_name": "Baroque", + "color": "#8B4513", + ... + }, + "paintings": [ + { + "id": 120, + "title": "...", + "year": 1640, + "artist_id": 5, + "artist_name": "Rembrandt", + "image_path": "paintings/...", + "thumbnail_path": "paintings/thumbs/...", + "checkup_checked": false, + "checkup_fixed": false, + "has_influence_links": true, + ... + } + ] +} +``` + +| Field | Meaning | +|-------|---------| +| `movement.era_name` | Joined from `historical_eras` — used to pick period interior styling | +| `paintings[].artist_name` | Artist display name for frame captions (`year · artist`) | +| `paintings` order | Chronological: `year`, then `sort_order`, artist birth year, title | + +Includes all paintings whose `artist.movement_id` matches `:id`. Returns **404** if the movement does not exist. + +**Client:** `api.getMovementGallery(id)` in `client/src/api/client.ts`; rendered by `VirtualGallery` in `mode: 'movement'`. + +--- + ## `GET /api/artists/:id` Full artist profile for the bio page and 3D gallery entry. @@ -152,11 +200,13 @@ Up to 20 ranked portrait candidates for the **More** picker modal. "searchUrl": "https://…", "source": "google-custom-search", "results": [ - { "imageUrl": "https://…", "thumbUrl": "https://…", "source": "google-custom-search" } + { "imageUrl": "https://…", "thumbUrl": "https://…", "source": "google-custom-search", "width": 1200, "height": 1600 } ] } ``` +Each result may include optional `width` and `height` (pixels). The **More** modal shows these under each thumbnail; when missing, the client probes dimensions via `GET /api/debug/image-proxy`. + --- ## `POST /api/artists/:id/fix-portrait` @@ -206,7 +256,7 @@ Upload a local image (base64 JSON body). Validates with `sharp`, resizes to port } ``` -Max size 15 MB. **Response** — same as `fix-portrait`. +Max decoded size 15 MB (JSON body limit 20 MB on the server). **Response** — same as `fix-portrait`. --- @@ -286,7 +336,22 @@ Painting detail with influence graph neighbours. "period_start_year": 1905, "period_end_year": 1907 } ], - "influenced": [ { "source_type": "painting", "id": 20, "title": "...", ... } ] + "influenced": [ { "source_type": "painting", "id": 20, "title": "...", ... } ], + "annotations": [ + { + "id": 1, + "label": "Central figure", + "body": "Short art-history note…", + "category": "subject", + "pos_x": 42.5, + "pos_y": 38.0, + "source_author": "E.H. Gombrich", + "source": "The Story of Art", + "source_url": "https://…", + "sort_order": 0, + "confidence": "curated" + } + ] } ``` @@ -427,11 +492,13 @@ Up to 20 ranked painting image candidates for the **More** picker modal. "searchUrl": "https://…", "source": "google-arts", "results": [ - { "imageUrl": "https://…", "thumbUrl": "https://…", "source": "google-arts" } + { "imageUrl": "https://…", "thumbUrl": "https://…", "source": "google-arts", "width": 2400, "height": 1800 } ] } ``` +Optional `width` / `height` on each result — see portrait **more** endpoint above. + --- ### `POST /api/paintings/:id/clear-image` @@ -464,7 +531,7 @@ Upload a local painting image (base64 JSON body). Validates with `sharp`, writes } ``` -Max size 15 MB. **Response** — same shape as `fix-image` (`imagePath`, `thumbnailPath`, `fixed`, `checked`). +Max decoded size 15 MB (JSON body limit 20 MB on the server). **Response** — same shape as `fix-image` (`imagePath`, `thumbnailPath`, `fixed`, `checked`). --- diff --git a/Documentation/DB_structure.md b/Documentation/DB_structure.md index 15befb4..74db399 100644 --- a/Documentation/DB_structure.md +++ b/Documentation/DB_structure.md @@ -109,6 +109,26 @@ When `checkup_fixed` is true, `checkup_checked` is set automatically and cannot Applied by `npm run migrate:checkup-flags` (`db/migrate-checkup-flags.sql`). +### `painting_annotations` + +Short art-history notes shown on painting detail (`PaintingAnnotations.tsx`). + +| Column | Type | Notes | +|--------|------|-------| +| `id` | SERIAL PK | | +| `painting_id` | FK → `paintings` | ON DELETE CASCADE | +| `label` | VARCHAR(80) | Optional short heading (e.g. figure name) | +| `body` | TEXT | Note text | +| `category` | VARCHAR(30) | Default `subject`; also `technique`, `context`, `symbolism`, etc. | +| `pos_x`, `pos_y` | NUMERIC(5,2) | Optional marker position on image (percent 0–100) | +| `source_author` | VARCHAR(200) | e.g. Gombrich, Met catalog | +| `source` | VARCHAR(500) | Citation label | +| `source_url` | VARCHAR(500) | Reference link | +| `sort_order` | INTEGER | Display order within the painting | +| `confidence` | VARCHAR(20) | Default `curated`; Wikipedia pass uses `wikipedia` | + +Applied by `npm run migrate:painting-annotations` (`db/migrate-painting-annotations.sql`). Load data with `npm run update-painting-annotations` (curated entries in `scripts/painting-annotations-data.js`; add `--wikipedia` for intro sentences from each work’s `wikipedia_title`). + ### `painting_influences` Directed edges: *this painting* was influenced by *that painting*. diff --git a/Documentation/basics.md b/Documentation/basics.md index 6f87275..0f5f018 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -1,6 +1,6 @@ # Art Gallery — architecture basics -Interactive virtual museum spanning art history: zoomable timeline, branching movement flow, 3D gallery halls, and painting influence graphs. See [README.md](../README.md) for quick start. +Interactive virtual museum spanning art history: zoomable timeline with event guides, branching movement flow, 3D gallery halls, painting influence graphs, and art-history annotations on detail pages. See [README.md](../README.md) for quick start. ## Concept @@ -8,8 +8,8 @@ The app is organised as a **drill-down hierarchy**: 1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries. 2. **Movement flow** — art movements as curved SVG streams on the same year axis; documented predecessor→successor branches; portrait thumbnails placed along each stream. -3. **3D gallery** — one personal hall per artist; paintings on the walls, open centre, single exit for influence-based navigation. -4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), prev/next catalog browsing, optional fullscreen, link to artist biography. +3. **3D gallery** — one personal hall per artist *or* a **movement gallery** (click a movement name on the flow diagram): period-themed interiors, chronological wings of up to ~55 works, side-wall hang only. +4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography. 5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**). All artwork images are stored locally under `data/images/` — the UI never hot-links to Wikipedia or Commons at runtime (except optional on-demand fetch when a file is missing). @@ -31,11 +31,20 @@ Gallery/ ├── server/ # Express API, DB pool, image service ├── client/ # React/Vite frontend │ ├── src/ # Source (components, pages, 3D scene) -│ │ ├── components/VirtualGallery.tsx # 3D hall (parquet, frames, museum exit) +│ │ ├── components/VirtualGallery.tsx # 3D hall (artist + movement modes) +│ │ ├── components/GalleryWindows.tsx # Side-wall daylight windows (movement) +│ │ ├── components/HallPassage.tsx # Open archway between movement wings +│ │ ├── components/MovementHallDetails.tsx # Period architectural details +│ │ ├── data/movement-interior-styles.ts # Per-movement interior themes +│ │ ├── utils/movementHallLayout.ts # Wing split + window gap placement +│ │ ├── utils/galleryProceduralTextures.ts # Hi-res wall/floor textures │ │ ├── components/PaintingDetail.tsx # Detail view + debug panel │ │ ├── components/ArtistBio.tsx # Biography + portrait debug panel │ │ ├── components/DebugSearchResultsModal.tsx # “More” search picker (20 results) +│ │ ├── components/Timeline.tsx # Era bar, year ticks, event markers +│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow │ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches) +│ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail │ │ ├── pages/CheckupPage.tsx # Image audit table │ │ ├── data/historical-events.ts # Timeline event markers (UI) │ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI) @@ -80,6 +89,14 @@ Use the Vite URL during frontend work for HMR. ```mermaid flowchart TD A[Home — timeline + movement flow] -->|scroll / drag / zoom| A + A -->|click movement name| G[Movement gallery — 3D wings] + G -->|click painting| D + D -->|Back| G + G -->|back door / Wings / Exit| H[Wing navigator] + H -->|pick wing| G + H -->|Exit to Timeline| A + G -->|front arch / E| G + G -->|Back to Timeline| A A -->|click portrait| B[Artist bio — Wikipedia text] B -->|Enter Gallery| C[Artist hall — 3D] C -->|click painting| D[Painting detail + influences] @@ -105,7 +122,11 @@ The home page shows two linked views over the **same year window** (`viewStart` | Era bar | `Timeline.tsx` | Historical eras, major event markers, click-to-zoom | | Movement flow | `MovementBands.tsx` | Curved streams per movement, lineage branches, artist portraits | -Both views share zoom/pan behaviour via `client/src/utils/timelineView.ts`. The home page uses a **fixed viewport** (`100vh`): the movement flow compresses vertically so all movements in the visible year range fit without page scrolling. +Both views share zoom/pan behaviour via `client/src/utils/timelineView.ts`. The home page uses a **fixed viewport** (`100vh`): timeline + movement flow sit in a shared `home-timeline-stack` so event guide lines can extend from the era bar down through the movement canvas. The movement flow compresses vertically so all movements in the visible year range fit without page scrolling. + +### Timeline year labels + +Year ticks along the bottom of the era bar use **large, high-contrast** labels (bold cream text with shadow). The active range in the control row (e.g. `1400 CE — 1900 CE`) uses the same stronger styling. ### Timeline controls @@ -122,6 +143,8 @@ Both views share zoom/pan behaviour via `client/src/utils/timelineView.ts`. The Major world events appear on the era bar as pin markers (single years) or shaded spans (e.g. World War I, World War II). Data lives in `client/src/data/historical-events.ts` — not in PostgreSQL. Labels appear when zoomed in enough; tooltips always show name and dates. Edit `HISTORICAL_EVENTS` and rebuild the client to add or change markers. +**Vertical guides:** `TimelineEventGuides.tsx` draws faint gold lines (point events) or shaded bands (spans) from the marker row **down through the movement flow**, aligned to the same year scale. Guides are visual only (`pointer-events: none`); click-to-zoom stays on the markers in `Timeline.tsx`. + ### Movement flow Each visible movement is drawn as a **portrait-width curved stream** (~54 px stroke, compressed when many rows are visible) from its start year to its end year. @@ -154,13 +177,18 @@ Each artist appears as a **portrait circle** on their movement’s stream row: | Scroll wheel on flow canvas | Zoom (same range as timeline) | | Drag on flow canvas | Pan | | Click portrait | Open artist biography | +| Click **movement name** (label on stream) | Open **movement gallery** for that movement | -Artist portraits stop wheel/drag propagation so zooming over a face does not fight portrait clicks. +Artist portraits stop wheel/drag propagation so zooming over a face does not fight portrait clicks. Hovering a portrait highlights the artist’s lifespan on the era bar and brightens their segment on the movement stream. **Note:** Movement lineage is **frontend curation** for layout and labels — it is not stored in PostgreSQL. Painting influence links (`painting_influence_sources`, plus legacy `painting_influences` for hall navigation) are separate and drive the 3D exit picker and detail panels. ## Virtual gallery (3D halls) +The 3D scene supports two modes in `VirtualGallery.tsx`: **artist halls** (personal catalog) and **movement galleries** (full movement collection, chronological). + +### Artist halls + Each artist has **exactly one hall**. The hall is a rectangular room sized to fit their catalog: | Rule | Implementation | @@ -182,7 +210,7 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi | Detail view return | Opening a painting close-up **keeps the 3D hall mounted** in the background so position and view direction are preserved when you go back | | After image fix | Debug **Fix it** updates the gallery session, busts texture cache (`?v=N`), and returns to the hall with the new image and gold frame | -**Controls:** +**Controls (artist hall):** | Input | Action | |-------|--------| @@ -196,15 +224,50 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi Predecessors and successors come from the **painting influence graph** (`painting_influences` → other artists). Empty lists mean no influence edges are recorded yet for that artist — run `npm run update-influences` or extend seed data. -**3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; call `POST /api/artists/:id/preload-images` before entering a hall to link disk files. While a texture is loading, the frame shows the canvas cover instead of a white placeholder. +### Movement galleries + +Enter from the home page by clicking a **movement name** on the movement flow (`MovementBands.tsx` → `GET /api/movements/:id/gallery`). + +| Rule | Implementation | +|------|----------------| +| One gallery per movement | All paintings by artists in that movement, sorted chronologically | +| Wings | Catalog split into wings of up to **55 works** (`movementHallLayout.ts`); large movements (e.g. Baroque) use multiple wings | +| Paintings on walls | **Left and right walls only** — back wall reserved for exit, front for passage to the next wing | +| Wall order | Along each side wall: **later works on the left**, **earlier on the right** (same convention as artist halls) | +| Frame captions | **Year · artist** label below each frame | +| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, NYC loft, white cube, etc.) | +| Textures | Hi-res procedural wall/floor/ceiling maps with normal maps (`galleryProceduralTextures.ts`) | +| Windows | **Side walls only** — placed in gaps between frames (high on the wall, no overlap with paintings); style matches the movement era | +| Lighting | Daylight from windows + ceiling track lights + ambient/sun fill | +| Back wall | **Exit double doors** → **Wing navigator** (jump to any wing) or **Exit to Timeline** | +| Front wall | Open **“Next wing →”** archway when a later wing exists; walk through or press `E` when near | +| Influence lamps | Same golden lamps as artist halls when `has_influence_links` is true | +| Missing images | Draped canvas cover in frame | +| Detail return | Hall stays mounted; camera preserved on **Back to Timeline** / **Back to Gallery** | + +**Controls (movement gallery):** + +| Input | Action | +|-------|--------| +| Walk / turn / drag | Same as artist hall | +| Click painting | Open detail view (returns to the same wing) | +| Back wall / `E` / **Wings / Exit →** | Open wing navigator | +| Front archway / `E` (when near) | Advance to the **next chronological wing** | + +Movement galleries do **not** use the predecessor/successor influence picker — that remains artist-hall only. + +### Shared 3D behaviour + +**3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; call `POST /api/artists/:id/preload-images` before entering an **artist** hall to link disk files. Movement galleries load painting lists from the API without a separate preload step. While a texture is loading, the frame shows the canvas cover instead of a white placeholder. ## Painting detail view -Opened from the 3D hall (click a frame) or from influence thumbnails on another work’s detail page. +Opened from the 3D hall (artist or movement wing — click a frame) or from influence thumbnails on another work’s detail page. | Layer | What you see | |-------|----------------| | **Detail** | Centre image, *Influenced By* (left) and *Influenced* (right) — painting thumbnails, artist portraits, or movement swatches — position in catalog (e.g. `3 of 12`) | +| **Art history notes** | Numbered markers on the image (when positioned) plus a note list below — short citations from Gombrich, museum catalogs, Wikipedia, etc. (`painting_annotations` table) | | **Fullscreen** | Click the centre image; `Escape` or click anywhere to return to detail only | **Controls:** @@ -216,7 +279,7 @@ Opened from the 3D hall (click a frame) or from influence thumbnails on another | Click centre image | Open fullscreen lightbox | | Click influence thumbnail | Open that work’s detail (different artist allowed) | | Click influence artist portrait | Open that artist’s 3D gallery hall | -| **← Back to Gallery** | Return to the hall you entered from — **3D camera position is preserved** | +| **← Back to Gallery** / **← Back to Timeline** | Return to the hall or movement wing you entered from — **3D camera position is preserved** | | **About {artist}** | Open artist biography | **Navigation rules:** @@ -235,6 +298,7 @@ When **Debug mode** is enabled from the home header, painting detail and artist - **Movement filtering** on zoom only shows movements that have at least one artist active in the visible year range. - **Movement lineage** (`movement-lineage.ts`) documents art-historical predecessor→successor links for the flow diagram; extend that file to add or correct branches. - **One hall per artist** keeps navigation predictable: enter from the timeline or bio, leave via the single exit or back button. +- **Movement galleries** complement artist halls: full movement corpus in period-themed wings, entered from the flow diagram. - **Influence-based hall links** connect artists through documented painting relationships, grouped by movement at the exit. - **3D gallery images** use locally cached files only; slow remote fetches would break realtime rendering. - **Influence data** is stored as directed links from paintings to sources (another painting, an artist, or a movement), with optional period fields and citation metadata (source author, quote, URL). @@ -257,7 +321,7 @@ When debug mode is on, a panel at the bottom-left shows the image search query, |--------|--------| | **Checked** | Sets `checkup_checked` via `PATCH …/checkup-flags` (disabled once already reviewed) | | **Fix it** | Replaces the local image from the top search result (`POST …/fix-image` or `…/fix-portrait`); sets **Fixed** and **Reviewed** | -| **More** | Opens a modal with up to **20** search results; click one to apply the same replace as **Fix it** | +| **More** | Opens a modal with up to **20** search results (each shows image resolution when available); click one to apply the same replace as **Fix it** | | **Clear** | Deletes the local file(s), clears DB paths, leaves an **empty frame** (no placeholder); sets **Fixed** and **Reviewed** so on-demand fetch does not refill the slot | | **Upload** | File picker for a local image; saves to disk like **Fix it** (thumbnail generated for paintings; portrait resized for artists) | @@ -269,7 +333,7 @@ After **Fix it**, **More**, **Upload**, or **Clear**, the main view, gallery tex **Search visible** runs image search only for rows currently shown after text/filter — not automatically on page load. Fixing an image sets **Fixed** and **Reviewed**. -Run `npm run migrate:checkup-flags` and `npm run migrate:artist-checkup-flags` once on existing databases. After server code changes, restart `npm run dev` so new routes (e.g. clear, upload, portrait debug) are registered. +Run `npm run migrate:checkup-flags`, `npm run migrate:artist-checkup-flags`, and `npm run migrate:painting-annotations` once on existing databases. Load notes with `npm run update-painting-annotations` (add `--wikipedia` for overview lines from Wikipedia intro text). After server code changes, restart `npm run dev` so new routes (e.g. clear, upload, portrait debug, annotations) are registered. JSON body limit for uploads is **20 MB** (`express.json` in `server/index.js`); individual files are capped at **15 MB** after decode. See [API.md](API.md#developer-image-audit) and [data-and-images.md](data-and-images.md#duplicate-paintings). diff --git a/Documentation/data-and-images.md b/Documentation/data-and-images.md index 043c36a..8366870 100644 --- a/Documentation/data-and-images.md +++ b/Documentation/data-and-images.md @@ -191,6 +191,12 @@ Separate from the painting influence graph, `client/src/data/movement-lineage.ts To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the client. No migration or API change is required. +## Movement gallery interiors (frontend) + +Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (columns, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts`. + +Wing layout (up to 55 works per wing, side-wall-only hang, window gap placement) lives in `client/src/utils/movementHallLayout.ts`. To change a movement’s look, edit its entry in `movement-interior-styles.ts` and rebuild the client. + ## Historical event markers (frontend timeline) `client/src/data/historical-events.ts` lists **world-history** markers shown on `Timeline.tsx` (French Revolution, World War I/II, etc.). @@ -200,9 +206,25 @@ To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the cli | Storage | TypeScript module in the client — **not** a database table | | Format | `{ id, name, startYear, endYear?, shortLabel? }` — omit `endYear` for a single-year pin | | Interaction | Click a marker to zoom the shared timeline/movement view to that period | +| Vertical guides | `TimelineEventGuides.tsx` draws faint gold lines (or shaded spans) from the marker row down through the movement flow, aligned to the same year scale | Edit `HISTORICAL_EVENTS` and rebuild the client to extend the set. +## Painting annotations (art-history notes) + +Short curator-style notes on the painting detail page — separate from the influence graph. + +| Aspect | Detail | +|--------|--------| +| Storage | PostgreSQL table `painting_annotations` | +| UI | `PaintingAnnotations.tsx` — numbered markers on the image (when `pos_x` / `pos_y` set) plus an “Art history notes” list | +| API | Included as `annotations[]` on `GET /api/paintings/:id` | +| Curated data | `scripts/painting-annotations-data.js` — artist/title keys matched via `influence-resolver.js` | +| Load | `npm run update-painting-annotations` (replaces existing rows per painting by default) | +| Wikipedia pass | `npm run update-painting-annotations -- --wikipedia` — one intro sentence per work from `wikipedia_title`; use `--wiki-delay=3000` if rate-limited; `--no-replace` to append without clearing curated rows | + +Categories include `subject`, `technique`, `context`, and `symbolism`. Sources cite Gombrich, museum catalogs, and Wikipedia as appropriate. + ## Batch image fetch `npm run fetch-images` (alias: `npm run search-missing-paintings`) runs `scripts/fetch-missing-images.js`. It searches multiple sources for paintings without local files: @@ -265,7 +287,9 @@ Requests are deduplicated (`inflight` map) and timeout after 15 seconds. On-dema ## Preload before 3D gallery -`POST /api/artists/:id/preload-images` runs **local-only** linking — no network. Call this when entering an artist’s 3D hall so textures use files already on disk. +`POST /api/artists/:id/preload-images` runs **local-only** linking — no network. Call this when entering an **artist’s** 3D hall so textures use files already on disk. + +**Movement galleries** (`GET /api/movements/:id/gallery`) do not use preload — they load the full painting list from the API and resolve local paths the same way as artist halls. Works without files still show the canvas cover in the frame. The 3D scene uses `galleryImageUrl()`, which never hits the on-demand API (remote latency breaks WebGL texture loading). @@ -337,10 +361,10 @@ As of a recent audit (~1200 paintings): **52 exact duplicate pairs** (52 removab When **Debug mode** is on (home header) or from the **Checkup** page: 1. **Search** — `GET /api/paintings/:id/debug-image-search` (or `…/debug-portrait-search` for artists) tries Google Custom Search (if `GOOGLE_CSE_API_KEY` + `GOOGLE_CSE_CX` are set in `.env`), Google Arts & Culture, Google Images scrape, then DuckDuckGo (`searchGoogleImagesFirst` / `searchArtistPortraitFirst` in `scripts/image-fetcher.js`). -2. **More** — `GET …/debug-image-search/more` or `…/debug-portrait-search/more` returns up to 20 ranked candidates (`searchPaintingImagesMany` / `searchArtistPortraitMany`). +2. **More** — `GET …/debug-image-search/more` or `…/debug-portrait-search/more` returns up to 20 ranked candidates (`searchPaintingImagesMany` / `searchArtistPortraitMany`). The modal shows each thumbnail with **resolution** when the search API provides dimensions; otherwise the client probes via `GET /api/debug/image-proxy`. 3. **Fix** — `POST …/fix-image` or `…/fix-portrait` downloads the chosen URL via `downloadImageForFix` → `replacePaintingImageFromUrl` / `replaceArtistPortraitFromUrl` in `server/image-service.js`, regenerates thumbnails with `sharp`, and sets `checkup_fixed` + `checkup_checked`. 4. **Clear** — `POST …/clear-image` or `…/clear-portrait` deletes local file(s), nulls DB paths, sets both flags. Cleared slots stay empty in the UI (no placeholder; `checkup_fixed` prevents on-demand refetch for paintings). -5. **Upload** — `POST …/upload-image` or `…/upload-portrait` accepts a base64-encoded file (max 15 MB), validates with `sharp`, writes to the standard filename under `data/images/`. +5. **Upload** — `POST …/upload-image` or `…/upload-portrait` accepts a base64-encoded file in JSON (Express body limit **20 MB**; decoded image max **15 MB**), validates with `sharp`, writes to the standard filename under `data/images/`. ### Debug panel (painting detail and artist bio) @@ -350,7 +374,7 @@ With debug mode on, `PaintingDetail.tsx` and `ArtistBio.tsx` show a bottom-left |--------|----------------------------|--------| | **Checked** | `PATCH …/checkup-flags` `{ "checked": true }` | Marks reviewed; gold frame (paintings) or gold portrait border (artists) | | **Fix it** | `POST …/fix-image` / `…/fix-portrait` | Saves top search result to disk, sets both flags, refreshes detail + gallery / timeline | -| **More** | `GET …/debug-*-search/more` then fix endpoint | Modal with 20 clickable results; pick one to replace | +| **More** | `GET …/debug-*-search/more` then fix endpoint | Modal with 20 clickable results (resolution label under each thumb); pick one to replace | | **Clear** | `POST …/clear-image` / `…/clear-portrait` | Removes file(s), empty frame in UI | | **Upload** | `POST …/upload-image` / `…/upload-portrait` | Local file picker → save like **Fix it** | diff --git a/Documentation/setup.md b/Documentation/setup.md index 39a9f9c..74528c9 100644 --- a/Documentation/setup.md +++ b/Documentation/setup.md @@ -67,6 +67,8 @@ npm run expand-catalog # famous works for artists below MIN_PAINTING npm run update-influences # painting influence graph for detail view + hall exits npm run migrate:checkup-flags # optional: review/fixed flags for Checkup page (paintings) npm run migrate:artist-checkup-flags # optional: same flags for artist portraits (bio debug) +npm run migrate:painting-annotations # optional: art-history notes table +npm run update-painting-annotations # optional: load curated notes (+ --wikipedia for Wikipedia intros) npm run fetch-images -- --limit=50 # random sample; 10s max per painting (default) npm run fetch-images -- --limit=50 --max-wait=120 # same batch size, longer lookup per work cd client && npm run build && cd .. @@ -116,6 +118,10 @@ Open http://localhost:3001 (or your configured `PORT`). | `npm run migrate:influence-sources` | `scripts/migrate-influence-sources.js` | Create `painting_influence_sources` + backfill legacy edges | | `npm run migrate:checkup-flags` | `scripts/migrate-checkup-flags.js` | Add `checkup_checked` / `checkup_fixed` on `paintings` | | `npm run migrate:artist-checkup-flags` | `scripts/migrate-artist-checkup-flags.js` | Add `checkup_checked` / `checkup_fixed` on `artists` (bio debug) | +| `npm run migrate:painting-annotations` | `scripts/migrate-painting-annotations.js` | Create `painting_annotations` table | +| `npm run update-painting-annotations` | `scripts/update-painting-annotations.js` | Load curated notes from `painting-annotations-data.js` | +| `npm run update-painting-annotations -- --wikipedia` | ↑ | Add intro sentences from each work’s Wikipedia page | +| `npm run update-painting-annotations -- --wikipedia --wiki-delay=3000` | ↑ | Slower Wikipedia pass when rate-limited (429) | | `npm run find-duplicates` | `scripts/find-duplicate-paintings.js` | Report duplicate and near-duplicate painting rows | | `npm run update-influences` | `scripts/update-influences.js` | Insert influence links (painting / artist / movement) from `art-influences-data.js` | | `npm run update-influences -- --fetch-images` | ↑ | Also download images for newly created works | @@ -135,6 +141,7 @@ These are checked in and maintained: - `find-duplicate-paintings.js` — duplicate catalog audit - `migrate-checkup-flags.js` — checkup workflow columns (paintings) - `migrate-artist-checkup-flags.js` — checkup workflow columns (artist portraits) +- `migrate-painting-annotations.js`, `update-painting-annotations.js`, `painting-annotations-data.js` — art-history notes on painting detail - `regenerate-thumbnails.js`, `audit-painting-images.js` These are referenced in `package.json` but may need to be restored from git history if missing locally: `seed-wikipedia.js`, `fetch-artist-images.js`, `sync-image-paths.js`. @@ -172,7 +179,12 @@ After clone: copy `.env.example` → `.env`, install dependencies, run `npm run | Wikipedia API rate limit during fetch | Too many requests in a row | Wait and re-run; scripts retry with backoff | | Checkup **Reviewed** toggle returns 404 | Stale server process missing new routes | Restart `npm run dev` after pulling API changes | | Debug **More** / **Clear** / **Upload** returns 404 | Same as above | Restart server; routes live in `server/index.js` + `server/image-service.js` | +| Debug **Upload** returns 413 Payload Too Large | Base64 JSON exceeds body limit | Server allows 20 MB JSON / 15 MB decoded image; compress file or resize before upload | +| No art-history notes on painting detail | Annotations not migrated or loaded | `npm run migrate:painting-annotations` then `npm run update-painting-annotations` | | **Fix it** fails with `read ECONNRESET` | Remote host dropped connection | Restart server; client sends `searchUrl` / `source`; retry or use Commons URL in overrides | | Fixed image not shown in 3D gallery | Stale gallery session or cached texture | Rebuild client; fix updates session + `?v=` revision — use **Back to Gallery** (not browser back) | | Frame still black after **Checked** | Gallery session not synced | Re-enter hall or toggle debug **Checked** from detail with gallery open behind overlay | | Duplicate works in gallery / timeline | Double import or variant Wikipedia titles | `npm run find-duplicates`; merge or delete spare rows manually | +| **Failed to load movement gallery** / `Cannot GET /api/movements/:id/gallery` | Stale server process missing route | Restart `npm run dev` or `npm run dev:server` after pulling API changes | +| Movement gallery shows generic cream walls | Stale client build | `cd client && npm run build`; hard-refresh browser | +| Windows overlap paintings in movement wing | Stale client | Rebuild client — windows are placed only on side walls in gaps between frames | diff --git a/README.md b/README.md index 86602e8..b96feee 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Art Gallery -Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom and major event markers, branching art-movement flow (curved streams, hover-to-reveal artist lifespans), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with prev/next catalog browsing and fullscreen lightbox, debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**), Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. +Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — period-themed wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**), Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. ## Documentation @@ -42,6 +42,8 @@ Interactive virtual art gallery: zoomable historical timeline with era click-to- npm run update-influences # art-history lineage links between paintings npm run migrate:checkup-flags # review/fixed flags for Checkup + debug mode (paintings) npm run migrate:artist-checkup-flags # same flags for artist portraits (bio debug) + npm run migrate:painting-annotations # art-history notes on painting detail + npm run update-painting-annotations # load curated notes (+ optional --wikipedia) npm run fetch-images -- --limit=50 # random batch of missing images (10s per work) npm run fetch-images -- --artist="Claude Monet" # one artist in catalog order ``` diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 1f10c50..3bbae18 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -3,6 +3,7 @@ import type { YearBounds, Artist, ArtistDetail, + MovementGalleryDetail, PaintingDetail, ArtistNavigation, } from '../types'; @@ -126,6 +127,8 @@ export interface DebugImageSearchResultItem { imageUrl: string; thumbUrl?: string; source: string; + width?: number; + height?: number; } export interface DebugImageSearchManyResult { @@ -173,6 +176,8 @@ export const api = { getArtist: (id: number) => fetchJson(`${API}/artists/${id}`), + getMovementGallery: (id: number) => fetchJson(`${API}/movements/${id}/gallery`), + getArtistNavigation: (id: number) => fetchJson(`${API}/artists/${id}/navigation`), diff --git a/client/src/components/DebugSearchResultsModal.css b/client/src/components/DebugSearchResultsModal.css index de2c60b..337ddb7 100644 --- a/client/src/components/DebugSearchResultsModal.css +++ b/client/src/components/DebugSearchResultsModal.css @@ -87,7 +87,8 @@ .debug-search-modal-item { position: relative; - aspect-ratio: 1; + display: flex; + flex-direction: column; padding: 0; border: 2px solid rgba(201, 169, 110, 0.35); border-radius: 6px; @@ -96,6 +97,13 @@ overflow: hidden; } +.debug-search-modal-item-media { + position: relative; + aspect-ratio: 1; + width: 100%; + background: #2a1f15; +} + .debug-search-modal-item:hover:not(:disabled) { border-color: #e8a040; box-shadow: 0 0 0 1px rgba(232, 160, 64, 0.35); @@ -117,6 +125,18 @@ display: block; } +.debug-search-modal-item-resolution { + display: block; + padding: 5px 6px; + font-size: 10px; + font-weight: 600; + line-height: 1.2; + text-align: center; + color: rgba(232, 213, 181, 0.9); + background: rgba(0, 0, 0, 0.45); + border-top: 1px solid rgba(201, 169, 110, 0.2); +} + .debug-search-modal-item-index { position: absolute; top: 4px; diff --git a/client/src/components/DebugSearchResultsModal.tsx b/client/src/components/DebugSearchResultsModal.tsx index e1606e4..b170f09 100644 --- a/client/src/components/DebugSearchResultsModal.tsx +++ b/client/src/components/DebugSearchResultsModal.tsx @@ -1,7 +1,57 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { debugImageProxyUrl, type DebugImageSearchManyResult, type DebugImageSearchResultItem } from '../api/client'; import './DebugSearchResultsModal.css'; +function formatResolution(width?: number, height?: number): string | null { + if (!width || !height || width <= 0 || height <= 0) return null; + return `${width} × ${height}`; +} + +function ResultResolution({ + item, + searchUrl, +}: { + item: DebugImageSearchResultItem; + searchUrl: string; +}) { + const initial = formatResolution(item.width, item.height); + const [label, setLabel] = useState(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 ( + + ); +} + interface Props { open: boolean; title: string; @@ -76,19 +126,22 @@ export default function DebugSearchResultsModal({ onClick={() => onSelect(item)} title="Use this image" > - {`Result { - (e.target as HTMLImageElement).src = '/placeholder-art.svg'; - }} - /> - {index + 1} - {busy && Saving…} + + {`Result { + (e.target as HTMLImageElement).src = '/placeholder-art.svg'; + }} + /> + {index + 1} + {busy && Saving…} + + ); })} diff --git a/client/src/components/GalleryWindows.tsx b/client/src/components/GalleryWindows.tsx new file mode 100644 index 0000000..283dc65 --- /dev/null +++ b/client/src/components/GalleryWindows.tsx @@ -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 ( + + {/* Outer frame */} + + + + + + {arch && style === 'gothic-lancet' && ( + + + + + )} + + {style === 'roman-arch' && ( + + + + + )} + + {style === 'factory' && ( + <> + {[-width / 3, 0, width / 3].map((ox) => ( + + + + + ))} + {[height / 4, -height / 4].map((oy) => ( + + + + + ))} + + )} + + {style === 'glass-block' && + Array.from({ length: 4 }, (_, row) => + Array.from({ length: 3 }, (_, col) => ( + + + + + )) + )} + + {style === 'sash' && ( + + + + + )} + + {style === 'baroque-pair' && ( + + + + + )} + + ); +} + +function SingleWindow({ + spec, + trimColor, +}: { + spec: GalleryWindowSpec; + trimColor: string; +}) { + const glassColor = useMemo(() => new THREE.Color(spec.lightColor), [spec.lightColor]); + + return ( + + + + {/* Sky / daylight pane */} + + + + + + {/* Soft sky gradient overlay */} + + + + + + {/* Daylight into room */} + + + + ); +} + +export default function GalleryWindows({ windows, halfW, halfD, trimColor }: Props) { + return ( + + {windows.map((spec, i) => { + const { position, rotation } = windowWorldPosition(spec, halfW, halfD); + return ( + + + + ); + })} + + ); +} + +/** 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 ( + + {positions.map(([x, y, z], i) => ( + + + + + + + + ))} + + ); +} diff --git a/client/src/components/HallPassage.tsx b/client/src/components/HallPassage.tsx new file mode 100644 index 0000000..d55076b --- /dev/null +++ b/client/src/components/HallPassage.tsx @@ -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 ( + + + + {/* Depth beyond passage */} + + + + + + {/* Side jambs */} + {([-1, 1] as const).map((sign) => ( + + + + + ))} + + {/* Arch header */} + + + + + + + + + + + + setHovered(true)} onPointerOut={() => setHovered(false)}> + + + + setHovered(true)} + onPointerOut={() => setHovered(false)} + > + {label.toUpperCase()} + + + + setHovered(true)} + onPointerOut={() => setHovered(false)} + > + + + + + ); +} + +export { DOOR_WIDTH, DOOR_HEIGHT, WALL_THICKNESS }; diff --git a/client/src/components/MovementBands.css b/client/src/components/MovementBands.css index 63538fe..87dee2d 100644 --- a/client/src/components/MovementBands.css +++ b/client/src/components/MovementBands.css @@ -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; diff --git a/client/src/components/MovementBands.tsx b/client/src/components/MovementBands.tsx index 6255f25..dfcea55 100644 --- a/client/src/components/MovementBands.tsx +++ b/client/src/components/MovementBands.tsx @@ -15,6 +15,8 @@ interface Props { absoluteMax: number; onViewChange: (start: number, end: number) => void; onArtistClick: (artistId: number) => void; + onMovementClick?: (movementId: number) => void; + onArtistHover?: (info: { birthYear: number; deathYear: number; color: string } | null) => void; } interface MovementLayout { @@ -61,50 +63,159 @@ function artistTimelineYear(artist: Artist): number { return artist.birth_year ?? artist.death_year ?? 0; } -interface ArtistLifespanLayout { - lineLeft: number; - lineWidth: number; - lineRight: number; - portraitLeft: number; - y: number; - centerX: number; -} - -function artistLifespanLayout( - artist: Artist, - layout: MovementLayout, - viewStart: number, - viewEnd: number -): ArtistLifespanLayout | null { - const birth = artist.birth_year ?? viewStart; - const death = artist.death_year ?? viewEnd; - const centerYear = artistTimelineYear(artist); - - const lineLeft = yearToPercent(Math.max(birth, viewStart), viewStart, viewEnd); - const lineRight = yearToPercent(Math.min(death, viewEnd), viewStart, viewEnd); - const centerX = yearToPercent(centerYear, viewStart, viewEnd); - const lineWidth = lineRight - lineLeft; - - if (lineWidth <= 0) return null; - - const yAnchorX = Math.min(Math.max(centerX, layout.xStart), layout.xEnd); - const y = yOnStream(layout, yAnchorX); - const portraitLeft = ((centerX - lineLeft) / lineWidth) * 100; - - return { lineLeft, lineWidth, lineRight, portraitLeft, y, centerX }; -} - interface ArtistPlacement { layout: MovementLayout; artist: Artist; lineLeft: number; lineWidth: number; - portraitLeft: number; + portraitX: number; y: number; - lane: number; + colorIndex: number; color: string; } +interface PortraitCandidate { + artist: Artist; + layout: MovementLayout; + lineLeft: number; + lineRight: number; + lineWidth: number; + minX: number; + maxX: number; + idealX: number; + x: number; +} + +function portraitMinGapPct(portraitSizePx: number, canvasWidthPx: number): number { + const width = Math.max(canvasWidthPx, 320); + const diameterPct = (portraitSizePx / width) * 100; + return Math.max(diameterPct * 1.08, 3.5); +} + +function resolvePortraitCollisions(candidates: PortraitCandidate[], minGap: number): void { + if (candidates.length === 0) return; + + for (const c of candidates) { + c.x = Math.min(c.maxX, Math.max(c.minX, c.idealX)); + } + + candidates.sort((a, b) => a.x - b.x || a.idealX - b.idealX); + + for (let i = 1; i < candidates.length; i++) { + if (candidates[i].x < candidates[i - 1].x + minGap) { + candidates[i].x = candidates[i - 1].x + minGap; + } + } + + const last = candidates[candidates.length - 1]; + if (last.x > last.maxX) { + last.x = last.maxX; + for (let i = candidates.length - 2; i >= 0; i--) { + candidates[i].x = Math.min(candidates[i].x, candidates[i + 1].x - minGap); + candidates[i].x = Math.max(candidates[i].x, candidates[i].minX); + } + } + + for (let i = candidates.length - 2; i >= 0; i--) { + if (candidates[i].x + minGap > candidates[i + 1].x) { + candidates[i].x = candidates[i + 1].x - minGap; + candidates[i].x = Math.max(candidates[i].x, candidates[i].minX); + } + } + + for (let i = 1; i < candidates.length; i++) { + if (candidates[i].x < candidates[i - 1].x + minGap) { + candidates[i].x = Math.min(candidates[i - 1].x + minGap, candidates[i].maxX); + } + } +} + +function buildArtistPlacements( + layouts: MovementLayout[], + artistsByMovement: Map, + viewStart: number, + viewEnd: number, + portraitSizePx: number, + canvasWidthPx: number +): ArtistPlacement[] { + const placements: ArtistPlacement[] = []; + const minGap = portraitMinGapPct(portraitSizePx, canvasWidthPx); + + for (const layout of layouts) { + const movementArtists = artistsByMovement.get(layout.movement.id) || []; + const candidates: PortraitCandidate[] = []; + const portraitHalfPct = minGap / 2; + + for (const artist of movementArtists) { + const birth = artist.birth_year ?? viewStart; + const death = artist.death_year ?? viewEnd; + const centerYear = artistTimelineYear(artist); + + const lineLeft = yearToPercent(Math.max(birth, viewStart), viewStart, viewEnd); + const lineRight = yearToPercent(Math.min(death, viewEnd), viewStart, viewEnd); + const lineWidth = lineRight - lineLeft; + if (lineWidth <= 0) continue; + + const spanLeft = Math.max(lineLeft, layout.xStart); + const spanRight = Math.min(lineRight, layout.xEnd); + if (spanRight <= spanLeft) continue; + + const centerX = yearToPercent(centerYear, viewStart, viewEnd); + let minX = spanLeft + portraitHalfPct; + let maxX = spanRight - portraitHalfPct; + if (maxX <= minX) { + const mid = (spanLeft + spanRight) / 2; + minX = mid; + maxX = mid; + } + + const idealX = Math.min(maxX, Math.max(minX, centerX)); + + candidates.push({ + artist, + layout, + lineLeft, + lineRight, + lineWidth, + minX, + maxX, + idealX, + x: idealX, + }); + } + + candidates.sort( + (a, b) => + a.idealX - b.idealX || + b.lineWidth - a.lineWidth || + (a.artist.birth_year ?? 0) - (b.artist.birth_year ?? 0) + ); + + resolvePortraitCollisions(candidates, minGap); + + candidates.sort((a, b) => a.x - b.x); + + const colorCount = Math.max(candidates.length, 1); + candidates.forEach((candidate, colorIndex) => { + const { artist, layout, lineLeft, lineWidth, x } = candidate; + const y = yOnStream(layout, x); + + placements.push({ + layout, + artist, + lineLeft, + lineWidth, + portraitX: x, + y, + colorIndex, + color: artistLifespanColor(layout.movement.color, colorIndex, colorCount), + }); + }); + } + + return placements; +} + function parseHexColor(hex: string): [number, number, number] { const normalized = hex.replace('#', ''); const value = @@ -167,76 +278,6 @@ function artistLifespanColor(baseColor: string, laneIndex: number, laneCount: nu } } -function buildArtistPlacements( - layouts: MovementLayout[], - artistsByMovement: Map, - viewStart: number, - viewEnd: number, - streamStrokePx: number, - portraitSizePx: number -): ArtistPlacement[] { - const placements: ArtistPlacement[] = []; - - for (const layout of layouts) { - const movementArtists = artistsByMovement.get(layout.movement.id) || []; - const candidates: Array<{ - artist: Artist; - span: ArtistLifespanLayout; - }> = []; - - for (const artist of movementArtists) { - const span = artistLifespanLayout(artist, layout, viewStart, viewEnd); - if (span) candidates.push({ artist, span }); - } - - candidates.sort( - (a, b) => - a.span.lineLeft - b.span.lineLeft || - b.span.lineWidth - a.span.lineWidth || - a.artist.birth_year! - b.artist.birth_year! - ); - - const laneEnds: number[] = []; - const minGap = 0.6; - const assigned: Array<{ candidate: (typeof candidates)[0]; lane: number }> = []; - - for (const candidate of candidates) { - let lane = laneEnds.findIndex((end) => candidate.span.lineLeft >= end + minGap); - if (lane === -1) { - lane = laneEnds.length; - laneEnds.push(candidate.span.lineRight); - } else { - laneEnds[lane] = Math.max(laneEnds[lane], candidate.span.lineRight); - } - assigned.push({ candidate, lane }); - } - - const laneCount = Math.max(laneEnds.length, 1); - const laneStep = Math.min( - portraitSizePx * 0.52, - (streamStrokePx * 0.88) / laneCount - ); - - for (const { candidate, lane } of assigned) { - const { artist, span } = candidate; - const yOffset = (lane - (laneCount - 1) / 2) * laneStep; - - placements.push({ - layout, - artist, - lineLeft: span.lineLeft, - lineWidth: span.lineWidth, - portraitLeft: span.portraitLeft, - y: span.y + yOffset, - lane, - color: artistLifespanColor(layout.movement.color, lane, laneCount), - }); - } - } - - return placements; -} - function organicDrift(id: number): number { return ((id * 17) % 11) - 5; } @@ -333,10 +374,13 @@ export default function MovementBands({ absoluteMax, onViewChange, onArtistClick, + onMovementClick, + onArtistHover, }: Props) { const canvasRef = useRef(null); const [panning, setPanning] = useState(false); const [canvasHeight, setCanvasHeight] = useState(DEFAULT_CANVAS_HEIGHT); + const [canvasWidth, setCanvasWidth] = useState(800); const [hoveredArtistKey, setHoveredArtistKey] = useState(null); const panStart = useRef({ x: 0, viewStart: 0, viewEnd: 0 }); @@ -424,14 +468,16 @@ export default function MovementBands({ if (!el) return; const measure = () => { - const h = el.getBoundingClientRect().height; - if (h > 0) setCanvasHeight(Math.round(h)); + const rect = el.getBoundingClientRect(); + if (rect.height > 0) setCanvasHeight(Math.round(rect.height)); + if (rect.width > 0) setCanvasWidth(Math.round(rect.width)); }; measure(); const ro = new ResizeObserver((entries) => { - const h = entries[0]?.contentRect.height; - if (h > 0) setCanvasHeight(Math.round(h)); + const rect = entries[0]?.contentRect; + if (rect && rect.height > 0) setCanvasHeight(Math.round(rect.height)); + if (rect && rect.width > 0) setCanvasWidth(Math.round(rect.width)); }); ro.observe(el); window.addEventListener('resize', measure); @@ -577,12 +623,21 @@ export default function MovementBands({ artistsByMovement, viewStart, viewEnd, - streamStrokePx, - portraitSizePx + portraitSizePx, + canvasWidth ), - [layouts, artistsByMovement, viewStart, viewEnd, streamStrokePx, portraitSizePx] + [layouts, artistsByMovement, viewStart, viewEnd, portraitSizePx, canvasWidth] ); + const hoveredPlacement = useMemo(() => { + if (!hoveredArtistKey) return null; + return ( + artistPlacements.find( + (p) => `${p.layout.movement.id}-${p.artist.id}` === hoveredArtistKey + ) ?? null + ); + }, [hoveredArtistKey, artistPlacements]); + if (visibleMovements.length === 0) { return (
@@ -719,26 +774,59 @@ export default function MovementBands({ })} + {hoveredPlacement && ( +
+ {hoveredPlacement.lineLeft > 0 && ( +
+ )} + {hoveredPlacement.lineLeft + hoveredPlacement.lineWidth < 100 && ( +
+ )} +
+
+ )} +
{layouts.map((layout) => ( -
onMovementClick?.(layout.movement.id)} + onMouseDown={(e) => e.stopPropagation()} + onWheel={(e) => e.stopPropagation()} > {layout.movement.name} {layout.movement.era_name && ( {layout.movement.era_name} )} -
+ ))}
- {artistPlacements.map(({ layout, artist, lineLeft, lineWidth, portraitLeft, y, lane, color }) => { + {artistPlacements.map(({ layout, artist, lineLeft, lineWidth, portraitX, y, colorIndex, color }) => { const birthLabel = artist.birth_year != null ? artist.birth_year : '?'; const deathLabel = artist.death_year != null ? artist.death_year : '?'; const artistKey = `${layout.movement.id}-${artist.id}`; @@ -747,26 +835,42 @@ export default function MovementBands({ return (
- {isHovered && } +
+ {isHovered && } +
+ ); + })} +
+ ); +} + +export default function PaintingAnnotationsPanel({ + annotations, + activeId, + onSelect, +}: PanelProps) { + const cardRefs = useRef>(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 ( + + ); +} diff --git a/client/src/components/PaintingDetail.css b/client/src/components/PaintingDetail.css index fa326c6..fda30be 100644 --- a/client/src/components/PaintingDetail.css +++ b/client/src/components/PaintingDetail.css @@ -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; diff --git a/client/src/components/PaintingDetail.tsx b/client/src/components/PaintingDetail.tsx index a5f0894..37f69a0 100644 --- a/client/src/components/PaintingDetail.tsx +++ b/client/src/components/PaintingDetail.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ChangeEvent, type SyntheticEvent } fr import type { InfluenceLink, Painting, PaintingDetail } from '../types'; import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client'; import DebugSearchResultsModal from './DebugSearchResultsModal'; +import PaintingAnnotationsPanel, { PaintingAnnotationMarkers } from './PaintingAnnotations'; import PaintingLightbox from './PaintingLightbox'; import './PaintingDetail.css'; @@ -190,7 +191,7 @@ export default function PaintingDetailView({ onPaintingImageFixed, onPaintingCheckupFlagsUpdated, }: Props) { - const { painting, influencedBy, influenced } = data; + const { painting, influencedBy, influenced, annotations = [] } = data; const [fullscreen, setFullscreen] = useState(false); const [imageVersion, setImageVersion] = useState(0); const [debugSearch, setDebugSearch] = useState(null); @@ -205,6 +206,7 @@ export default function PaintingDetailView({ const [applyingUrl, setApplyingUrl] = useState(null); const [clearing, setClearing] = useState(false); const [uploading, setUploading] = useState(false); + const [activeAnnotationId, setActiveAnnotationId] = useState(null); const uploadInputRef = useRef(null); const baseImageUrl = paintingImageUrl(painting); @@ -227,6 +229,7 @@ export default function PaintingDetailView({ setMoreOpen(false); setMoreResults(null); setMoreError(null); + setActiveAnnotationId(null); }, [painting.id]); useEffect(() => { @@ -475,11 +478,28 @@ export default function PaintingDetailView({ title={imageSrc ? 'View full screen' : undefined} aria-label={imageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`} > - {imageSrc ? ( - {painting.title} - ) : null} +
+ {imageSrc ? ( + {painting.title} + ) : null} + {imageSrc && annotations.length > 0 && ( + + )} +
+ {annotations.length > 0 && ( + + )} + {showCatalogNav && ( + +
+
+

Choose a wing

+
    + {halls.map((hall, i) => ( +
  • + +
  • + ))} +
+ +
+
+
+
+ ); +} + function NavigationPanel({ navigation, loading, @@ -1298,43 +1628,63 @@ function NavigationPanel({ ); } -export default function VirtualGallery({ - data, - imageRevisions, - active = true, - onPaintingClick, - onNavigateArtist, - onBack, - onBioClick, -}: Props) { - const [artist, setArtist] = useState(data.artist); - const [periods, setPeriods] = useState(data.periods); - const [paintings, setPaintings] = useState(data.paintings); +export default function VirtualGallery(props: Props) { + const { + imageRevisions, + active = true, + onPaintingClick, + onBack, + } = props; + + const isMovement = props.mode === 'movement'; + const hallKey = isMovement ? props.data.movement.id : props.data.artist.id; + const hallTitle = isMovement ? props.data.movement.name : props.data.artist.name; + const movementColor = isMovement ? props.data.movement.color : props.data.artist.movement_color; + const initialPaintings = useMemo(() => { + if (props.mode === 'movement') { + return [...props.data.paintings].sort(comparePaintingsChronological); + } + return props.data.paintings; + }, [props]); + const initialPeriods = isMovement ? [] : props.data.periods; + + const [paintings, setPaintings] = useState(initialPaintings); + const [periods, setPeriods] = useState(initialPeriods); const [syncStatus, setSyncStatus] = useState(''); const [showExitNav, setShowExitNav] = useState(false); const [navigation, setNavigation] = useState(null); const [navLoading, setNavLoading] = useState(false); const [nearExit, setNearExit] = useState(false); + const [nearPassage, setNearPassage] = useState(false); const [isLooking, setIsLooking] = useState(false); useEffect(() => { - setArtist(data.artist); - setPeriods(data.periods); - setPaintings(data.paintings); - }, [data]); + setPaintings(initialPaintings); + setPeriods(initialPeriods); + }, [initialPaintings, initialPeriods, hallKey]); useEffect(() => { + if (props.mode === 'movement') { + const withImg = initialPaintings.filter((p) => p.image_path || p.thumbnail_path).length; + setSyncStatus( + withImg < initialPaintings.length + ? `${withImg} of ${initialPaintings.length} works have images` + : '' + ); + return; + } + let cancelled = false; + const artistId = props.data.artist.id; (async () => { try { setSyncStatus('Syncing images…'); const timeout = new Promise((resolve) => setTimeout(resolve, 2000)); - await Promise.race([preloadArtistImages(data.artist.id), timeout]); - const fresh = await api.getArtist(data.artist.id); + await Promise.race([preloadArtistImages(artistId), timeout]); + const fresh = await api.getArtist(artistId); if (!cancelled) { - setArtist(fresh.artist); - setPeriods(fresh.periods); setPaintings(fresh.paintings); + setPeriods(fresh.periods); const withImg = fresh.paintings.filter((p) => p.image_path || p.thumbnail_path).length; setSyncStatus( withImg < fresh.paintings.length @@ -1349,9 +1699,37 @@ export default function VirtualGallery({ return () => { cancelled = true; }; - }, [data.artist.id]); + }, [hallKey, props.mode, initialPaintings.length, props.mode === 'artist' ? props.data.artist.id : null]); - const layout = useMemo(() => buildHallLayout(paintings, periods), [paintings, periods]); + const interiorStyle = useMemo( + () => (isMovement ? resolveMovementInteriorStyle(props.data.movement) : undefined), + [isMovement, isMovement ? props.data.movement : null] + ); + + const movementHalls = useMemo( + () => (isMovement ? buildAllMovementHallLayouts(paintings) : []), + [isMovement, paintings] + ); + + const [hallIndex, setHallIndex] = useState(0); + + useEffect(() => { + setHallIndex(0); + }, [hallKey]); + + const layout = useMemo(() => { + if (isMovement && movementHalls.length > 0) { + return movementHalls[Math.min(hallIndex, movementHalls.length - 1)]; + } + return buildHallLayout(paintings, periods); + }, [isMovement, movementHalls, hallIndex, paintings, periods]); + + const computedWindows = useMemo(() => { + if (!isMovement || !interiorStyle || !('hallIndex' in layout)) return undefined; + return computeSideWallWindows(layout as MovementHallLayout, interiorStyle); + }, [isMovement, interiorStyle, layout]); + + const hasNextHall = isMovement && movementHalls.length > 1 && hallIndex < movementHalls.length - 1; const halfW = layout.width / 2 - 0.55; const halfD = layout.depth / 2 - 0.35; @@ -1377,25 +1755,58 @@ export default function VirtualGallery({ camPosRef.current = camPos; camTargetRef.current = camTarget; + const goToHall = useCallback( + (index: number, enterFrom: 'front' | 'back' | 'default' = 'default') => { + const clamped = Math.max(0, Math.min(index, movementHalls.length - 1)); + setHallIndex(clamped); + setShowExitNav(false); + setNearExit(false); + setNearPassage(false); + const nextLayout = movementHalls[clamped]; + if (!nextLayout) return; + const nextHalfD = nextLayout.depth / 2; + if (enterFrom === 'back') { + setCamPos(new THREE.Vector3(0, EYE_HEIGHT, -nextHalfD + 2.2)); + setCamTarget(new THREE.Vector3(0, EYE_HEIGHT, nextHalfD * 0.25)); + } else { + setCamPos(new THREE.Vector3(0, EYE_HEIGHT, nextHalfD - 2.2)); + setCamTarget(new THREE.Vector3(0, EYE_HEIGHT, -nextHalfD * 0.25)); + } + }, + [movementHalls] + ); + + const goToNextHall = useCallback(() => { + if (hallIndex < movementHalls.length - 1) goToHall(hallIndex + 1, 'back'); + }, [hallIndex, movementHalls.length, goToHall]); + useEffect(() => { setCamPos(initialPos.clone()); setCamTarget(initialTarget.clone()); setShowExitNav(false); setNearExit(false); - }, [data.artist.id, initialPos, initialTarget]); + setNearPassage(false); + }, [hallKey, initialPos, initialTarget]); const openExitNav = useCallback(async () => { + if (isMovement) { + setShowExitNav(true); + return; + } setShowExitNav(true); setNavLoading(true); try { - const nav = await api.getArtistNavigation(artist.id); + const nav = await api.getArtistNavigation(props.data.artist.id); setNavigation(nav); } catch { setNavigation({ predecessors: [], successors: [] }); } finally { setNavLoading(false); } - }, [artist.id]); + }, [isMovement, onBack, isMovement ? undefined : props.data.artist.id]); + + const backExitZ = isMovement ? -layout.depth / 2 + 0.55 : layout.depth / 2 - 0.55; + const frontPassageZ = layout.depth / 2 - 0.55; const moveCamera = useCallback( (forward: number, strafe: number, rotY: number) => { @@ -1418,18 +1829,28 @@ export default function VirtualGallery({ pos.x = Math.max(-halfW, Math.min(halfW, pos.x)); target.x = Math.max(-halfW, Math.min(halfW, target.x)); - pos.z = Math.max(-halfD, Math.min(exitZ, pos.z)); - target.z = Math.max(-halfD, Math.min(exitZ, target.z)); + + if (isMovement) { + pos.z = Math.max(-halfD, Math.min(halfD, pos.z)); + target.z = Math.max(-halfD, Math.min(halfD, target.z)); + const atBackExit = pos.z < backExitZ + 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6; + const atFrontPassage = + hasNextHall && pos.z > frontPassageZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6; + setNearExit(atBackExit); + setNearPassage(!!atFrontPassage); + } else { + pos.z = Math.max(-halfD, Math.min(exitZ, pos.z)); + target.z = Math.max(-halfD, Math.min(exitZ, target.z)); + const atExit = pos.z > exitZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6; + setNearExit(atExit); + } levelHorizontalView(pos, target); setCamPos(pos); setCamTarget(target); - - const atExit = pos.z > exitZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6; - setNearExit(atExit); }, - [halfW, halfD, exitZ] + [halfW, halfD, exitZ, isMovement, backExitZ, frontPassageZ, hasNextHall] ); useEffect(() => { @@ -1446,7 +1867,11 @@ export default function VirtualGallery({ const onKeyDown = (e: KeyboardEvent) => { keysPressed.current.add(e.key); if ((e.key === 'e' || e.key === 'E') && !showExitNav) { - openExitNav(); + if (isMovement && nearPassage && hasNextHall) { + goToNextHall(); + } else { + openExitNav(); + } } }; const onKeyUp = (e: KeyboardEvent) => keysPressed.current.delete(e.key); @@ -1470,13 +1895,28 @@ export default function VirtualGallery({ window.removeEventListener('keyup', onKeyUp); clearInterval(interval); }; - }, [active, moveCamera, showExitNav, openExitNav]); + }, [active, moveCamera, showExitNav, openExitNav, isMovement, nearPassage, hasNextHall, goToNextHall]); const handleNavigate = (artistId: number) => { + if (isMovement) return; setShowExitNav(false); - onNavigateArtist(artistId); + props.onNavigateArtist(artistId); }; + const subtitle = isMovement + ? interiorStyle + ? `${interiorStyle.subtitle} · ${paintings.length} works${ + movementHalls.length > 1 + ? ` · Wing ${hallIndex + 1}/${movementHalls.length} (${(layout as MovementHallLayout).yearLabel})` + : '' + }` + : `Movement gallery · ${paintings.length} works · chronological` + : `Personal hall · ${paintings.length} works on the walls`; + + const sceneBackground = interiorStyle?.background ?? '#0d0906'; + const sceneFog = interiorStyle?.fog ?? '#0d0906'; + const ambientIntensity = interiorStyle?.ambient ?? 0.42; + const handleCanvasPointerDown = (e: React.PointerEvent) => { if (!active || showExitNav || e.button !== 0) return; dragStartRef.current = { x: e.clientX, y: e.clientY }; @@ -1509,19 +1949,41 @@ export default function VirtualGallery({ } }; + const exitHint = isMovement ? ( + <> + Back wall: E for wing navigator · Front arch: next wing + {movementHalls.length > 1 ? ` (${hallIndex + 1}/${movementHalls.length})` : ''} + + ) : ( + <>Click the exit door, E, or Exit → above + ); + + const hallSubtitle = + isMovement && 'yearLabel' in layout + ? `Wing ${hallIndex + 1} of ${movementHalls.length} · ${(layout as MovementHallLayout).yearLabel}` + : undefined; + + const instructionsTitle = isMovement + ? interiorStyle + ? `${hallTitle} · ${interiorStyle.label}` + : `${hallTitle} Gallery` + : `${hallTitle}'s Hall`; + return (
-

{artist.name}

-

Personal hall · {paintings.length} works on the walls

+

{hallTitle}

+

{subtitle}

- + {!isMovement && ( + + )}
@@ -1539,36 +2001,51 @@ export default function VirtualGallery({
)} {!showExitNav && ( -
- Click the exit door, E, or Exit → above -
+
{exitHint}
)} - - - - + + + + - +
- {showExitNav && ( + {!isMovement && showExitNav && ( )} + {isMovement && showExitNav && ( + goToHall(i)} + onExitTimeline={onBack} + onClose={() => setShowExitNav(false)} + /> + )} +
@@ -1587,7 +2075,7 @@ export default function VirtualGallery({
-

{artist.name}'s Hall

+

{instructionsTitle}

  • W / Walk forward
  • S / Walk back
  • @@ -1595,8 +2083,21 @@ export default function VirtualGallery({
  • D / Turn right
  • Drag on the view to look around
  • Click a painting to view details and influences
  • -
  • Golden lamps mark works linked in the influence graph
  • -
  • Click the exit door or E to visit related artists
  • + {isMovement ? ( + <> +
  • Date and artist labels appear below each frame
  • +
  • Works hang on left & right walls — up to ~55 per wing
  • +
  • Back door: wing navigator & exit to timeline
  • + {movementHalls.length > 1 && ( +
  • Front archway: walk to the next chronological wing
  • + )} + + ) : ( + <> +
  • Golden lamps mark works linked in the influence graph
  • +
  • Click the exit door or E to visit related artists
  • + + )}
diff --git a/client/src/data/historical-events.ts b/client/src/data/historical-events.ts index 0c9fbb8..7e06a52 100644 --- a/client/src/data/historical-events.ts +++ b/client/src/data/historical-events.ts @@ -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 }, diff --git a/client/src/data/movement-interior-styles.ts b/client/src/data/movement-interior-styles.ts new file mode 100644 index 0000000..105c79d --- /dev/null +++ b/client/src/data/movement-interior-styles.ts @@ -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> & { + 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 = { + 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; diff --git a/client/src/hooks/useTexturedMaterial.ts b/client/src/hooks/useTexturedMaterial.ts new file mode 100644 index 0000000..581d382 --- /dev/null +++ b/client/src/hooks/useTexturedMaterial.ts @@ -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; +} diff --git a/client/src/pages/HomePage.css b/client/src/pages/HomePage.css index 7658c34..a0ab980 100644 --- a/client/src/pages/HomePage.css +++ b/client/src/pages/HomePage.css @@ -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 { diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index e2dd7be..002fe29 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -1,12 +1,13 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import Timeline from '../components/Timeline'; +import TimelineEventGuides from '../components/TimelineEventGuides'; import MovementBands from '../components/MovementBands'; import VirtualGallery from '../components/VirtualGallery'; import PaintingDetailView from '../components/PaintingDetail'; import ArtistBio from '../components/ArtistBio'; import CheckupPage from '../pages/CheckupPage'; import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client'; -import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail } from '../types'; +import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types'; import { sortArtistPaintingsChronological } from '../utils/paintingUtils'; import { readDebugMode, writeDebugMode } from '../utils/debugMode'; import './HomePage.css'; @@ -15,9 +16,25 @@ type View = | { type: 'timeline' } | { type: 'checkup' } | { type: 'gallery'; artistId: number; data: ArtistDetail } + | { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail } | { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View } | { type: 'bio'; artistId: number; data: ArtistDetail; returnTo: View }; +type GallerySession = + | { kind: 'artist'; artistId: number; data: ArtistDetail } + | { kind: 'movement'; movementId: number; data: MovementGalleryDetail }; + +function patchPaintingInMovementDetail( + detail: MovementGalleryDetail, + paintingId: number, + patch: Partial +): MovementGalleryDetail { + return { + ...detail, + paintings: detail.paintings.map((p) => (p.id === paintingId ? { ...p, ...patch } : p)), + }; +} + function patchPaintingInArtistDetail( detail: ArtistDetail, paintingId: number, @@ -38,9 +55,7 @@ function patchArtistInArtistDetail(detail: ArtistDetail, patch: Partial) export default function HomePage() { const [view, setView] = useState({ type: 'timeline' }); - const [gallerySession, setGallerySession] = useState<{ artistId: number; data: ArtistDetail } | null>( - null - ); + const [gallerySession, setGallerySession] = useState(null); const [bounds, setBounds] = useState({ min: -800, max: 2025 }); const [viewStart, setViewStart] = useState(-800); const [viewEnd, setViewEnd] = useState(2025); @@ -52,6 +67,11 @@ export default function HomePage() { const [imageRevisions, setImageRevisions] = useState>({}); const [portraitRevisions, setPortraitRevisions] = useState>({}); const [debugMode, setDebugMode] = useState(readDebugMode); + const [hoveredLifespan, setHoveredLifespan] = useState<{ + birthYear: number; + deathYear: number; + color: string; + } | null>(null); const detailReturnToRef = useRef({ type: 'timeline' }); useEffect(() => { @@ -68,7 +88,9 @@ export default function HomePage() { useEffect(() => { if (view.type === 'gallery') { - setGallerySession({ artistId: view.artistId, data: view.data }); + setGallerySession({ kind: 'artist', artistId: view.artistId, data: view.data }); + } else if (view.type === 'movement-gallery') { + setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data }); } else if (view.type === 'timeline') { setGallerySession(null); } @@ -132,6 +154,12 @@ export default function HomePage() { data: patchPaintingInArtistDetail(returnTo.data, paintingId, patch), }; } + if (returnTo.type === 'movement-gallery') { + returnTo = { + ...returnTo, + data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch), + }; + } return { ...current, data: updatedData, returnTo }; }); @@ -139,11 +167,15 @@ export default function HomePage() { list.map((p) => (p.id === paintingId ? { ...p, ...patch } : p)) ); - setGallerySession((session) => - session && session.artistId === data.painting.artist_id - ? { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) } - : session - ); + setGallerySession((session) => { + if (session?.kind === 'artist' && session.artistId === data.painting.artist_id) { + return { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) }; + } + if (session?.kind === 'movement') { + return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) }; + } + return session; + }); }, []); const handlePaintingCheckupFlagsUpdated = useCallback( @@ -167,6 +199,12 @@ export default function HomePage() { data: patchPaintingInArtistDetail(returnTo.data, paintingId, patch), }; } + if (returnTo.type === 'movement-gallery') { + returnTo = { + ...returnTo, + data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch), + }; + } return { ...current, data: updatedData, returnTo }; }); @@ -174,11 +212,15 @@ export default function HomePage() { list.map((p) => (p.id === paintingId ? { ...p, ...patch } : p)) ); - setGallerySession((session) => - session && session.artistId === data.painting.artist_id - ? { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) } - : session - ); + setGallerySession((session) => { + if (session?.kind === 'artist' && session.artistId === data.painting.artist_id) { + return { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) }; + } + if (session?.kind === 'movement') { + return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) }; + } + return session; + }); }, [] ); @@ -209,7 +251,7 @@ export default function HomePage() { }); setGallerySession((session) => - session && session.artistId === artistId + session?.kind === 'artist' && session.artistId === artistId ? { ...session, data: patchArtistInArtistDetail(session.data, patch) } : session ); @@ -253,13 +295,23 @@ export default function HomePage() { const handleArtistClick = async (artistId: number) => { try { const data = await api.getArtist(artistId); - setGallerySession({ artistId, data }); + setGallerySession({ kind: 'artist', artistId, data }); setView({ type: 'gallery', artistId, data }); } catch { setError('Failed to load artist gallery.'); } }; + const handleMovementClick = async (movementId: number) => { + try { + const data = await api.getMovementGallery(movementId); + setGallerySession({ kind: 'movement', movementId, data }); + setView({ type: 'movement-gallery', movementId, data }); + } catch { + setError('Failed to load movement gallery.'); + } + }; + const handlePaintingClick = async (paintingId: number) => { try { const data = await api.getPainting(paintingId); @@ -303,11 +355,17 @@ export default function HomePage() { } const artistId = view.data.painting.artist_id; - if (gallerySession?.artistId === artistId) { + if (gallerySession?.kind === 'artist' && gallerySession.artistId === artistId) { setDetailArtistPaintings(gallerySession.data.paintings); return; } + const returnTo = detailReturnToRef.current; + if (returnTo.type === 'movement-gallery') { + setDetailArtistPaintings(sortArtistPaintingsChronological(returnTo.data.paintings)); + return; + } + let cancelled = false; api.getArtist(artistId) .then((data) => { @@ -327,21 +385,39 @@ export default function HomePage() { [detailArtistPaintings] ); - const galleryActive = view.type === 'gallery'; + const galleryActive = view.type === 'gallery' || view.type === 'movement-gallery'; return ( <> {gallerySession && (
- setView({ type: 'timeline' })} - onBioClick={() => handleBioClick(gallerySession.data, { type: 'gallery', ...gallerySession })} - /> + {gallerySession.kind === 'artist' ? ( + setView({ type: 'timeline' })} + onBioClick={() => + handleBioClick(gallerySession.data, { + type: 'gallery', + artistId: gallerySession.artistId, + data: gallerySession.data, + }) + } + /> + ) : ( + setView({ type: 'timeline' })} + /> + )}
)} @@ -352,12 +428,26 @@ export default function HomePage() { artistPaintings={sortedDetailArtistPaintings} onBack={() => { const returnTo = view.returnTo; - if (returnTo.type === 'gallery' && gallerySession?.artistId === returnTo.artistId) { + if ( + returnTo.type === 'gallery' && + gallerySession?.kind === 'artist' && + gallerySession.artistId === returnTo.artistId + ) { setView({ type: 'gallery', artistId: gallerySession.artistId, data: gallerySession.data, }); + } else if ( + returnTo.type === 'movement-gallery' && + gallerySession?.kind === 'movement' && + gallerySession.movementId === returnTo.movementId + ) { + setView({ + type: 'movement-gallery', + movementId: gallerySession.movementId, + data: gallerySession.data, + }); } else { setView(returnTo); } @@ -424,34 +514,42 @@ export default function HomePage() {

Watch art movements branch forward through time — each flowing from what came before

- +
+ - {error &&
{error}
} + {error &&
{error}
} - {loading ? ( -
Loading art history...
- ) : ( -
- -
- )} + {loading ? ( +
Loading art history...
+ ) : ( + <> + +
+ +
+ + )} +
)} diff --git a/client/src/types/index.ts b/client/src/types/index.ts index a694454..7f69b88 100644 --- a/client/src/types/index.ts +++ b/client/src/types/index.ts @@ -96,10 +96,25 @@ export interface InfluenceLink { movement_color?: string; } +export interface PaintingAnnotation { + id: number; + label: string | null; + body: string; + category: string; + pos_x: number | null; + pos_y: number | null; + source_author: string | null; + source: string | null; + source_url: string | null; + sort_order: number; + confidence?: string; +} + export interface PaintingDetail { painting: Painting & { artist_name: string; artist_portrait: string | null }; influencedBy: InfluenceLink[]; influenced: InfluenceLink[]; + annotations?: PaintingAnnotation[]; } export interface ArtistDetail { @@ -108,6 +123,11 @@ export interface ArtistDetail { paintings: Painting[]; } +export interface MovementGalleryDetail { + movement: ArtMovement & { era_name?: string }; + paintings: Painting[]; +} + export interface TimelineData { eras: HistoricalEra[]; movements: ArtMovement[]; diff --git a/client/src/utils/galleryFloorTextures.ts b/client/src/utils/galleryFloorTextures.ts new file mode 100644 index 0000000..5e25dfd --- /dev/null +++ b/client/src/utils/galleryFloorTextures.ts @@ -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 = { + 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; +} diff --git a/client/src/utils/galleryProceduralTextures.ts b/client/src/utils/galleryProceduralTextures.ts new file mode 100644 index 0000000..6a40418 --- /dev/null +++ b/client/src/utils/galleryProceduralTextures.ts @@ -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(); + +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> = { + '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> = { + 'marble-white': 0.08, + 'marble-veined-carrara': 0.1, + 'gilded-stucco': 0.65, + 'concrete-polished': 0.12, + 'terrazzo': 0.15, +}; + +const METERS: Partial> = { + '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 }; +} diff --git a/client/src/utils/movementHallLayout.ts b/client/src/utils/movementHallLayout.ts new file mode 100644 index 0000000..65745a1 --- /dev/null +++ b/client/src/utils/movementHallLayout.ts @@ -0,0 +1,273 @@ +import type { Painting } from '../types'; +import type { GalleryWindowSpec, MovementInteriorStyle } from '../data/movement-interior-styles'; +import { comparePaintingsChronological } from './paintingUtils'; + +/** Target capacity per movement wing (50–60 works). */ +export const MOVEMENT_PAINTINGS_PER_HALL = 55; + +export type WallSide = 'back' | 'left' | 'right'; + +export interface FrameSlot { + position: [number, number, number]; + rotationY: number; + maxW: number; + maxH: number; + side: WallSide; +} + +export interface WallSegment { + side: WallSide; + label: string; + paintings: Painting[]; + slots: FrameSlot[]; +} + +export interface MovementHallLayout { + hallIndex: number; + hallCount: number; + width: number; + depth: number; + segments: WallSegment[]; + paintingCount: number; + yearLabel: string; +} + +const WALL_HEIGHT = 4.2; +const WALL_THICKNESS = 0.18; +const MOUNT_OFFSET = 0.16; +const WALL_STANDOFF = 0.07; +const EYE_HEIGHT = 1.65; +const FRAME_GAP = 0.32; +const MIN_FRAME_W = 0.45; +const MAX_FRAME_W = 1.05; +const MAX_FRAME_H = 1.35; +const MIN_HALL_SIZE = 10; +const MIN_HALL_WIDTH = 11; +const WALL_PADDING = 1.4; + +const FRAME_MAT_BORDER = 0.1; +const FRAME_RAIL = 0.08; +const REVIEWED_MAT_BORDER = FRAME_MAT_BORDER * 2; +const REVIEWED_RAIL = FRAME_RAIL * 2; + +function paintingIsReviewed(p: Painting) { + return !!p.checkup_checked; +} + +function frameDims(reviewed: boolean) { + return reviewed + ? { matBorder: REVIEWED_MAT_BORDER, rail: REVIEWED_RAIL } + : { matBorder: FRAME_MAT_BORDER, rail: FRAME_RAIL }; +} + +function frameOuterW(w: number, reviewed: boolean) { + const { matBorder, rail } = frameDims(reviewed); + return w + matBorder * 2 + rail; +} + +function layoutRow(paintings: Painting[], span: number) { + const count = paintings.length; + if (count === 0) return { slots: [] as { offset: number; maxW: number; maxH: number }[], spanNeeded: span }; + + let frameW = MAX_FRAME_W; + for (let attempt = 0; attempt < 40; attempt++) { + let total = 0; + for (let i = 0; i < count; i++) { + total += frameOuterW(frameW, paintingIsReviewed(paintings[i])); + if (i < count - 1) total += FRAME_GAP; + } + if (total <= span - WALL_PADDING) break; + frameW -= 0.015; + } + frameW = Math.max(MIN_FRAME_W, frameW); + const frameH = Math.min(MAX_FRAME_H, frameW * 1.22); + const outers = paintings.map((p) => frameOuterW(frameW, paintingIsReviewed(p))); + const rowWidth = outers.reduce((s, w) => s + w, 0) + (count - 1) * FRAME_GAP; + const slots: { offset: number; maxW: number; maxH: number }[] = []; + let cursor = -rowWidth / 2; + for (let i = 0; i < count; i++) { + slots.push({ offset: cursor + outers[i] / 2, maxW: frameW, maxH: frameH }); + cursor += outers[i] + FRAME_GAP; + } + return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) }; +} + +function orderForWall(paintings: Painting[]) { + return [...paintings].sort(comparePaintingsChronological).reverse(); +} + +function formatYearLabel(paintings: Painting[]) { + const years = paintings.map((p) => p.year).filter((y): y is number => y != null); + if (years.length === 0) return 'Undated works'; + const min = Math.min(...years); + const max = Math.max(...years); + return min === max ? `${min}` : `${min} – ${max}`; +} + +export function splitPaintingsIntoMovementHalls(paintings: Painting[]): Painting[][] { + const sorted = [...paintings].sort(comparePaintingsChronological); + if (sorted.length === 0) return [[]]; + const chunks: Painting[][] = []; + for (let i = 0; i < sorted.length; i += MOVEMENT_PAINTINGS_PER_HALL) { + chunks.push(sorted.slice(i, i + MOVEMENT_PAINTINGS_PER_HALL)); + } + return chunks; +} + +function distributeToSideWalls(paintings: Painting[]) { + const left: Painting[] = []; + const right: Painting[] = []; + const sorted = [...paintings].sort(comparePaintingsChronological); + sorted.forEach((p, i) => (i % 2 === 0 ? left : right).push(p)); + return { left: orderForWall(left), right: orderForWall(right) }; +} + +function layoutSideSlots( + paintings: Painting[], + span: number, + side: 'left' | 'right', + halfW: number, + inset: number +): FrameSlot[] { + if (paintings.length === 0) return []; + const { slots: rowSlots } = layoutRow(paintings, span); + const y = EYE_HEIGHT; + return rowSlots.map((s) => ({ + maxW: s.maxW, + maxH: s.maxH, + rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2, + side, + position: + side === 'left' + ? ([-halfW + inset + WALL_STANDOFF, y, s.offset] as [number, number, number]) + : ([halfW - inset - WALL_STANDOFF, y, s.offset] as [number, number, number]), + })); +} + +export function buildMovementHallLayout( + paintings: Painting[], + hallIndex: number, + hallCount: number +): MovementHallLayout { + const { left, right } = distributeToSideWalls(paintings); + const leftSpan = layoutRow(left, MIN_HALL_SIZE); + const rightSpan = layoutRow(right, MIN_HALL_SIZE); + const depth = Math.max(MIN_HALL_SIZE, leftSpan.spanNeeded, rightSpan.spanNeeded); + const width = MIN_HALL_WIDTH; + const halfW = width / 2; + const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET; + + const segments: WallSegment[] = [ + { side: 'back', label: '', paintings: [], slots: [] }, + { + side: 'left', + label: left.length > 0 ? `Wing ${hallIndex + 1} · Left wall` : '', + paintings: left, + slots: layoutSideSlots(left, depth, 'left', halfW, inset), + }, + { + side: 'right', + label: right.length > 0 ? `Wing ${hallIndex + 1} · Right wall` : '', + paintings: right, + slots: layoutSideSlots(right, depth, 'right', halfW, inset), + }, + ]; + + return { + hallIndex, + hallCount, + width, + depth, + segments, + paintingCount: paintings.length, + yearLabel: formatYearLabel(paintings), + }; +} + +export function buildAllMovementHallLayouts(paintings: Painting[]): MovementHallLayout[] { + const chunks = splitPaintingsIntoMovementHalls(paintings); + return chunks.map((chunk, i) => buildMovementHallLayout(chunk, i, chunks.length)); +} + +function mergeIntervals(intervals: [number, number][]): [number, number][] { + if (intervals.length === 0) return []; + const sorted = [...intervals].sort((a, b) => a[0] - b[0]); + const out: [number, number][] = [sorted[0]]; + for (let i = 1; i < sorted.length; i++) { + const last = out[out.length - 1]; + if (sorted[i][0] <= last[1]) last[1] = Math.max(last[1], sorted[i][1]); + else out.push(sorted[i]); + } + return out; +} + +function findWallGaps(occupied: [number, number][], halfSpan: number, minGap: number): [number, number][] { + const merged = mergeIntervals(occupied); + const gaps: [number, number][] = []; + let cursor = -halfSpan + 1.2; + for (const [a, b] of merged) { + if (a - cursor >= minGap) gaps.push([cursor, a]); + cursor = Math.max(cursor, b); + } + if (halfSpan - 1.2 - cursor >= minGap) gaps.push([cursor, halfSpan - 1.2]); + return gaps.sort((a, b) => b[1] - b[0] - (a[1] - a[0])); +} + +function windowTemplate(style: MovementInteriorStyle): Pick { + 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 }; diff --git a/client/src/utils/paintingUtils.ts b/client/src/utils/paintingUtils.ts index 538ac62..8a6c594 100644 --- a/client/src/utils/paintingUtils.ts +++ b/client/src/utils/paintingUtils.ts @@ -15,6 +15,20 @@ export function sortArtistPaintingsChronological(paintings: Painting[]): Paintin return [...paintings].sort(comparePaintingsChronological); } +export function formatPaintingYear(painting: Pick): 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): 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 diff --git a/data/images/paintings/Domenico_Ghirlandaio_The_Last_Supper__Ghirlandaio_.jpg b/data/images/paintings/Domenico_Ghirlandaio_The_Last_Supper__Ghirlandaio_.jpg index 8104b29..20a31b3 100644 Binary files a/data/images/paintings/Domenico_Ghirlandaio_The_Last_Supper__Ghirlandaio_.jpg and b/data/images/paintings/Domenico_Ghirlandaio_The_Last_Supper__Ghirlandaio_.jpg differ diff --git a/data/images/paintings/Francisco_Goya_Saturn_Devouring_His_Son.jpg b/data/images/paintings/Francisco_Goya_Saturn_Devouring_His_Son.jpg index 1b17033..b342f9d 100644 Binary files a/data/images/paintings/Francisco_Goya_Saturn_Devouring_His_Son.jpg and b/data/images/paintings/Francisco_Goya_Saturn_Devouring_His_Son.jpg differ diff --git a/data/images/paintings/Francisco_Goya_The_Naked_Maja.jpg b/data/images/paintings/Francisco_Goya_The_Naked_Maja.jpg index 12e634d..470651d 100644 Binary files a/data/images/paintings/Francisco_Goya_The_Naked_Maja.jpg and b/data/images/paintings/Francisco_Goya_The_Naked_Maja.jpg differ diff --git a/data/images/paintings/Francisco_Goya_The_Third_of_May_1808.jpg b/data/images/paintings/Francisco_Goya_The_Third_of_May_1808.jpg deleted file mode 100644 index b9fb44c..0000000 Binary files a/data/images/paintings/Francisco_Goya_The_Third_of_May_1808.jpg and /dev/null differ diff --git a/data/images/paintings/Francisco_Goya_The_Third_of_May_1808.webp b/data/images/paintings/Francisco_Goya_The_Third_of_May_1808.webp new file mode 100644 index 0000000..589a3bb Binary files /dev/null and b/data/images/paintings/Francisco_Goya_The_Third_of_May_1808.webp differ diff --git a/data/images/paintings/Giorgione_The_Pastoral_Concert.jpg b/data/images/paintings/Giorgione_The_Pastoral_Concert.jpg index 7723997..cbdadd3 100644 Binary files a/data/images/paintings/Giorgione_The_Pastoral_Concert.jpg and b/data/images/paintings/Giorgione_The_Pastoral_Concert.jpg differ diff --git a/data/images/paintings/Jacques-Louis_David_The_Death_of_Marat.jpg b/data/images/paintings/Jacques-Louis_David_The_Death_of_Marat.jpg index 5b1d630..8ce06c2 100644 Binary files a/data/images/paintings/Jacques-Louis_David_The_Death_of_Marat.jpg and b/data/images/paintings/Jacques-Louis_David_The_Death_of_Marat.jpg differ diff --git a/data/images/paintings/Jean-Antoine_Watteau_F_tes_Venitiennes.jpg b/data/images/paintings/Jean-Antoine_Watteau_F_tes_Venitiennes.jpg index 39c592c..9050eae 100644 Binary files a/data/images/paintings/Jean-Antoine_Watteau_F_tes_Venitiennes.jpg and b/data/images/paintings/Jean-Antoine_Watteau_F_tes_Venitiennes.jpg differ diff --git a/data/images/paintings/Jean-Antoine_Watteau_The_Shop_Sign_of_Gersaint.jpg b/data/images/paintings/Jean-Antoine_Watteau_The_Shop_Sign_of_Gersaint.jpg index 39c592c..6f7fc18 100644 Binary files a/data/images/paintings/Jean-Antoine_Watteau_The_Shop_Sign_of_Gersaint.jpg and b/data/images/paintings/Jean-Antoine_Watteau_The_Shop_Sign_of_Gersaint.jpg differ diff --git a/data/images/paintings/Jean-Auguste-Dominique_Ingres_Grande_Odalisque.jpg b/data/images/paintings/Jean-Auguste-Dominique_Ingres_Grande_Odalisque.jpg index be4ec76..1704230 100644 Binary files a/data/images/paintings/Jean-Auguste-Dominique_Ingres_Grande_Odalisque.jpg and b/data/images/paintings/Jean-Auguste-Dominique_Ingres_Grande_Odalisque.jpg differ diff --git a/data/images/paintings/Jean-Honor__Fragonard_The_Swing.jpg b/data/images/paintings/Jean-Honor__Fragonard_The_Swing.jpg index f5e6442..635cdf2 100644 Binary files a/data/images/paintings/Jean-Honor__Fragonard_The_Swing.jpg and b/data/images/paintings/Jean-Honor__Fragonard_The_Swing.jpg differ diff --git a/data/images/paintings/Lyubov_Popova_Non-Objective_Composition.jpg b/data/images/paintings/Lyubov_Popova_Non-Objective_Composition.jpg index 1420114..573b8a0 100644 Binary files a/data/images/paintings/Lyubov_Popova_Non-Objective_Composition.jpg and b/data/images/paintings/Lyubov_Popova_Non-Objective_Composition.jpg differ diff --git a/data/images/paintings/Lyubov_Popova_Painterly_Architectonic.jpg b/data/images/paintings/Lyubov_Popova_Painterly_Architectonic.jpg index 68960b8..eb3fad2 100644 Binary files a/data/images/paintings/Lyubov_Popova_Painterly_Architectonic.jpg and b/data/images/paintings/Lyubov_Popova_Painterly_Architectonic.jpg differ diff --git a/data/images/paintings/Lyubov_Popova_Spatial_Force_Construction.jpg b/data/images/paintings/Lyubov_Popova_Spatial_Force_Construction.jpg index 1420114..1b60929 100644 Binary files a/data/images/paintings/Lyubov_Popova_Spatial_Force_Construction.jpg and b/data/images/paintings/Lyubov_Popova_Spatial_Force_Construction.jpg differ diff --git a/data/images/paintings/Lyubov_Popova_Still_Life.jpg b/data/images/paintings/Lyubov_Popova_Still_Life.jpg index 7f1df92..be8b838 100644 Binary files a/data/images/paintings/Lyubov_Popova_Still_Life.jpg and b/data/images/paintings/Lyubov_Popova_Still_Life.jpg differ diff --git a/data/images/paintings/Lyubov_Popova_The_Violin.jpg b/data/images/paintings/Lyubov_Popova_The_Violin.jpg index fc26e71..f01e6b6 100644 Binary files a/data/images/paintings/Lyubov_Popova_The_Violin.jpg and b/data/images/paintings/Lyubov_Popova_The_Violin.jpg differ diff --git a/data/images/paintings/Lyubov_Popova_Traveler.jpg b/data/images/paintings/Lyubov_Popova_Traveler.jpg index 1420114..ba7ddcf 100644 Binary files a/data/images/paintings/Lyubov_Popova_Traveler.jpg and b/data/images/paintings/Lyubov_Popova_Traveler.jpg differ diff --git a/data/images/paintings/Pablo_Picasso_Guernica.jpg b/data/images/paintings/Pablo_Picasso_Guernica.jpg index c6507b3..85c0967 100644 Binary files a/data/images/paintings/Pablo_Picasso_Guernica.jpg and b/data/images/paintings/Pablo_Picasso_Guernica.jpg differ diff --git a/data/images/paintings/Parrhasius_Athlete.jpg b/data/images/paintings/Parrhasius_Athlete.jpg deleted file mode 100644 index 1665797..0000000 Binary files a/data/images/paintings/Parrhasius_Athlete.jpg and /dev/null differ diff --git a/data/images/paintings/Parrhasius_Demos.jpg b/data/images/paintings/Parrhasius_Demos.jpg deleted file mode 100644 index daebf62..0000000 Binary files a/data/images/paintings/Parrhasius_Demos.jpg and /dev/null differ diff --git a/data/images/paintings/Parrhasius_The_Fool.jpg b/data/images/paintings/Parrhasius_The_Fool.jpg deleted file mode 100644 index 48707ef..0000000 Binary files a/data/images/paintings/Parrhasius_The_Fool.jpg and /dev/null differ diff --git a/data/images/paintings/Parrhasius_Theseus.jpg b/data/images/paintings/Parrhasius_Theseus.jpg deleted file mode 100644 index fba1d8b..0000000 Binary files a/data/images/paintings/Parrhasius_Theseus.jpg and /dev/null differ diff --git a/data/images/paintings/Peter_Paul_Rubens_The_Descent_from_the_Cross.jpg b/data/images/paintings/Peter_Paul_Rubens_The_Descent_from_the_Cross.jpg index b02b386..d39d880 100644 Binary files a/data/images/paintings/Peter_Paul_Rubens_The_Descent_from_the_Cross.jpg and b/data/images/paintings/Peter_Paul_Rubens_The_Descent_from_the_Cross.jpg differ diff --git a/data/images/paintings/Rembrandt_The_Night_Watch.jpg b/data/images/paintings/Rembrandt_The_Night_Watch.jpg index 6f23c74..88cac66 100644 Binary files a/data/images/paintings/Rembrandt_The_Night_Watch.jpg and b/data/images/paintings/Rembrandt_The_Night_Watch.jpg differ diff --git a/data/images/paintings/Richard_Hamilton_Hers_is_a_Luscious_Situation.jpg b/data/images/paintings/Richard_Hamilton_Hers_is_a_Luscious_Situation.jpg index 8653c0e..17f27db 100644 Binary files a/data/images/paintings/Richard_Hamilton_Hers_is_a_Luscious_Situation.jpg and b/data/images/paintings/Richard_Hamilton_Hers_is_a_Luscious_Situation.jpg differ diff --git a/data/images/paintings/Richard_Hamilton_Just_What_Is_It.jpg b/data/images/paintings/Richard_Hamilton_Just_What_Is_It.jpg index 8653c0e..0a66c8b 100644 Binary files a/data/images/paintings/Richard_Hamilton_Just_What_Is_It.jpg and b/data/images/paintings/Richard_Hamilton_Just_What_Is_It.jpg differ diff --git a/data/images/paintings/Richard_Hamilton_Just_What_Is_It.png b/data/images/paintings/Richard_Hamilton_Just_What_Is_It.png new file mode 100644 index 0000000..84ffffb Binary files /dev/null and b/data/images/paintings/Richard_Hamilton_Just_What_Is_It.png differ diff --git a/data/images/paintings/Roy_Lichtenstein_Crying_Girl.jpg b/data/images/paintings/Roy_Lichtenstein_Crying_Girl.jpg index f8f8b48..d8e5332 100644 Binary files a/data/images/paintings/Roy_Lichtenstein_Crying_Girl.jpg and b/data/images/paintings/Roy_Lichtenstein_Crying_Girl.jpg differ diff --git a/data/images/paintings/Roy_Lichtenstein_Drowning_Girl.jpg b/data/images/paintings/Roy_Lichtenstein_Drowning_Girl.jpg index c499837..410e9c0 100644 Binary files a/data/images/paintings/Roy_Lichtenstein_Drowning_Girl.jpg and b/data/images/paintings/Roy_Lichtenstein_Drowning_Girl.jpg differ diff --git a/data/images/paintings/Roy_Lichtenstein_Hopeless.jpg b/data/images/paintings/Roy_Lichtenstein_Hopeless.jpg index f8f8b48..521a750 100644 Binary files a/data/images/paintings/Roy_Lichtenstein_Hopeless.jpg and b/data/images/paintings/Roy_Lichtenstein_Hopeless.jpg differ diff --git a/data/images/paintings/Roy_Lichtenstein_Look_Mickey.jpg b/data/images/paintings/Roy_Lichtenstein_Look_Mickey.jpg index f8f8b48..d6e6d87 100644 Binary files a/data/images/paintings/Roy_Lichtenstein_Look_Mickey.jpg and b/data/images/paintings/Roy_Lichtenstein_Look_Mickey.jpg differ diff --git a/data/images/paintings/Roy_Lichtenstein_Masterpiece.jpeg b/data/images/paintings/Roy_Lichtenstein_Masterpiece.jpeg new file mode 100644 index 0000000..f5523e8 Binary files /dev/null and b/data/images/paintings/Roy_Lichtenstein_Masterpiece.jpeg differ diff --git a/data/images/paintings/Roy_Lichtenstein_Masterpiece.jpg b/data/images/paintings/Roy_Lichtenstein_Masterpiece.jpg deleted file mode 100644 index f8f8b48..0000000 Binary files a/data/images/paintings/Roy_Lichtenstein_Masterpiece.jpg and /dev/null differ diff --git a/data/images/paintings/Roy_Lichtenstein_Whaam_.jpg b/data/images/paintings/Roy_Lichtenstein_Whaam_.jpg index 568c8ba..968d730 100644 Binary files a/data/images/paintings/Roy_Lichtenstein_Whaam_.jpg and b/data/images/paintings/Roy_Lichtenstein_Whaam_.jpg differ diff --git a/data/images/paintings/Theophanes_the_Greek_Notre_Dame_au_don.jpg b/data/images/paintings/Theophanes_the_Greek_Notre_Dame_au_don.jpg index 5e640f0..46c689a 100644 Binary files a/data/images/paintings/Theophanes_the_Greek_Notre_Dame_au_don.jpg and b/data/images/paintings/Theophanes_the_Greek_Notre_Dame_au_don.jpg differ diff --git a/data/images/paintings/Theophanes_the_Greek_Our_Lady_of_the_Don.jpg b/data/images/paintings/Theophanes_the_Greek_Our_Lady_of_the_Don.jpg new file mode 100644 index 0000000..613bfee Binary files /dev/null and b/data/images/paintings/Theophanes_the_Greek_Our_Lady_of_the_Don.jpg differ diff --git a/data/images/paintings/Theophanes_the_Greek_Transfiguration_of_Christ.jpg b/data/images/paintings/Theophanes_the_Greek_Transfiguration_of_Christ.jpg new file mode 100644 index 0000000..81fde89 Binary files /dev/null and b/data/images/paintings/Theophanes_the_Greek_Transfiguration_of_Christ.jpg differ diff --git a/data/images/paintings/Theophanes_the_Greek_Transfiguration_of_Jesus.jpg b/data/images/paintings/Theophanes_the_Greek_Transfiguration_of_Jesus.jpg new file mode 100644 index 0000000..f15058c Binary files /dev/null and b/data/images/paintings/Theophanes_the_Greek_Transfiguration_of_Jesus.jpg differ diff --git a/data/images/paintings/Theophanes_the_Greek_the_repose_of_the_Virgin_Mary.jpg b/data/images/paintings/Theophanes_the_Greek_the_repose_of_the_Virgin_Mary.jpg new file mode 100644 index 0000000..854b7cc Binary files /dev/null and b/data/images/paintings/Theophanes_the_Greek_the_repose_of_the_Virgin_Mary.jpg differ diff --git a/data/images/paintings/thumbs/Domenico_Ghirlandaio_The_Last_Supper__Ghirlandaio__thumb.jpg b/data/images/paintings/thumbs/Domenico_Ghirlandaio_The_Last_Supper__Ghirlandaio__thumb.jpg deleted file mode 100644 index f85f327..0000000 Binary files a/data/images/paintings/thumbs/Domenico_Ghirlandaio_The_Last_Supper__Ghirlandaio__thumb.jpg and /dev/null differ diff --git a/data/images/paintings/thumbs/Francisco_Goya_Saturn_Devouring_His_Son_thumb.jpg b/data/images/paintings/thumbs/Francisco_Goya_Saturn_Devouring_His_Son_thumb.jpg index 36e7eb7..b41dfae 100644 Binary files a/data/images/paintings/thumbs/Francisco_Goya_Saturn_Devouring_His_Son_thumb.jpg and b/data/images/paintings/thumbs/Francisco_Goya_Saturn_Devouring_His_Son_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Francisco_Goya_The_Naked_Maja_thumb.jpg b/data/images/paintings/thumbs/Francisco_Goya_The_Naked_Maja_thumb.jpg index 84e195d..cf5ee49 100644 Binary files a/data/images/paintings/thumbs/Francisco_Goya_The_Naked_Maja_thumb.jpg and b/data/images/paintings/thumbs/Francisco_Goya_The_Naked_Maja_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Francisco_Goya_The_Third_of_May_1808_thumb.jpg b/data/images/paintings/thumbs/Francisco_Goya_The_Third_of_May_1808_thumb.jpg new file mode 100644 index 0000000..89ca20d Binary files /dev/null and b/data/images/paintings/thumbs/Francisco_Goya_The_Third_of_May_1808_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Giorgione_The_Pastoral_Concert_thumb.jpg b/data/images/paintings/thumbs/Giorgione_The_Pastoral_Concert_thumb.jpg index 33978ac..f524794 100644 Binary files a/data/images/paintings/thumbs/Giorgione_The_Pastoral_Concert_thumb.jpg and b/data/images/paintings/thumbs/Giorgione_The_Pastoral_Concert_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Jacques-Louis_David_The_Death_of_Marat_thumb.jpg b/data/images/paintings/thumbs/Jacques-Louis_David_The_Death_of_Marat_thumb.jpg index 8a07514..de2617e 100644 Binary files a/data/images/paintings/thumbs/Jacques-Louis_David_The_Death_of_Marat_thumb.jpg and b/data/images/paintings/thumbs/Jacques-Louis_David_The_Death_of_Marat_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Jean-Antoine_Watteau_F_tes_Venitiennes_thumb.jpg b/data/images/paintings/thumbs/Jean-Antoine_Watteau_F_tes_Venitiennes_thumb.jpg index edb8cf5..b45b396 100644 Binary files a/data/images/paintings/thumbs/Jean-Antoine_Watteau_F_tes_Venitiennes_thumb.jpg and b/data/images/paintings/thumbs/Jean-Antoine_Watteau_F_tes_Venitiennes_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Jean-Antoine_Watteau_The_Shop_Sign_of_Gersaint_thumb.jpg b/data/images/paintings/thumbs/Jean-Antoine_Watteau_The_Shop_Sign_of_Gersaint_thumb.jpg index edb8cf5..d953bf4 100644 Binary files a/data/images/paintings/thumbs/Jean-Antoine_Watteau_The_Shop_Sign_of_Gersaint_thumb.jpg and b/data/images/paintings/thumbs/Jean-Antoine_Watteau_The_Shop_Sign_of_Gersaint_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Jean-Auguste-Dominique_Ingres_Grande_Odalisque_thumb.jpg b/data/images/paintings/thumbs/Jean-Auguste-Dominique_Ingres_Grande_Odalisque_thumb.jpg index 13dee26..a0470bc 100644 Binary files a/data/images/paintings/thumbs/Jean-Auguste-Dominique_Ingres_Grande_Odalisque_thumb.jpg and b/data/images/paintings/thumbs/Jean-Auguste-Dominique_Ingres_Grande_Odalisque_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Jean-Honor__Fragonard_The_Swing_thumb.jpg b/data/images/paintings/thumbs/Jean-Honor__Fragonard_The_Swing_thumb.jpg index dfae69d..4747a29 100644 Binary files a/data/images/paintings/thumbs/Jean-Honor__Fragonard_The_Swing_thumb.jpg and b/data/images/paintings/thumbs/Jean-Honor__Fragonard_The_Swing_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Lyubov_Popova_Non-Objective_Composition_thumb.jpg b/data/images/paintings/thumbs/Lyubov_Popova_Non-Objective_Composition_thumb.jpg index 67f4f4e..d2c56bf 100644 Binary files a/data/images/paintings/thumbs/Lyubov_Popova_Non-Objective_Composition_thumb.jpg and b/data/images/paintings/thumbs/Lyubov_Popova_Non-Objective_Composition_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Lyubov_Popova_Painterly_Architectonic_thumb.jpg b/data/images/paintings/thumbs/Lyubov_Popova_Painterly_Architectonic_thumb.jpg index 3d4370c..6d82352 100644 Binary files a/data/images/paintings/thumbs/Lyubov_Popova_Painterly_Architectonic_thumb.jpg and b/data/images/paintings/thumbs/Lyubov_Popova_Painterly_Architectonic_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Lyubov_Popova_Spatial_Force_Construction_thumb.jpg b/data/images/paintings/thumbs/Lyubov_Popova_Spatial_Force_Construction_thumb.jpg index 67f4f4e..845ba6b 100644 Binary files a/data/images/paintings/thumbs/Lyubov_Popova_Spatial_Force_Construction_thumb.jpg and b/data/images/paintings/thumbs/Lyubov_Popova_Spatial_Force_Construction_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Lyubov_Popova_Still_Life_thumb.jpg b/data/images/paintings/thumbs/Lyubov_Popova_Still_Life_thumb.jpg index f9ee906..25298ab 100644 Binary files a/data/images/paintings/thumbs/Lyubov_Popova_Still_Life_thumb.jpg and b/data/images/paintings/thumbs/Lyubov_Popova_Still_Life_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Lyubov_Popova_The_Violin_thumb.jpg b/data/images/paintings/thumbs/Lyubov_Popova_The_Violin_thumb.jpg index 7a5d58e..3b491d7 100644 Binary files a/data/images/paintings/thumbs/Lyubov_Popova_The_Violin_thumb.jpg and b/data/images/paintings/thumbs/Lyubov_Popova_The_Violin_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Lyubov_Popova_Traveler_thumb.jpg b/data/images/paintings/thumbs/Lyubov_Popova_Traveler_thumb.jpg index 67f4f4e..3e1ea66 100644 Binary files a/data/images/paintings/thumbs/Lyubov_Popova_Traveler_thumb.jpg and b/data/images/paintings/thumbs/Lyubov_Popova_Traveler_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Pablo_Picasso_Guernica_thumb.jpg b/data/images/paintings/thumbs/Pablo_Picasso_Guernica_thumb.jpg index 939d041..0954274 100644 Binary files a/data/images/paintings/thumbs/Pablo_Picasso_Guernica_thumb.jpg and b/data/images/paintings/thumbs/Pablo_Picasso_Guernica_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Parrhasius_Athlete_thumb.jpg b/data/images/paintings/thumbs/Parrhasius_Athlete_thumb.jpg deleted file mode 100644 index 08a33b7..0000000 Binary files a/data/images/paintings/thumbs/Parrhasius_Athlete_thumb.jpg and /dev/null differ diff --git a/data/images/paintings/thumbs/Parrhasius_Demos_thumb.jpg b/data/images/paintings/thumbs/Parrhasius_Demos_thumb.jpg deleted file mode 100644 index 1372d2c..0000000 Binary files a/data/images/paintings/thumbs/Parrhasius_Demos_thumb.jpg and /dev/null differ diff --git a/data/images/paintings/thumbs/Parrhasius_The_Fool_thumb.jpg b/data/images/paintings/thumbs/Parrhasius_The_Fool_thumb.jpg deleted file mode 100644 index cf27864..0000000 Binary files a/data/images/paintings/thumbs/Parrhasius_The_Fool_thumb.jpg and /dev/null differ diff --git a/data/images/paintings/thumbs/Parrhasius_Theseus_thumb.jpg b/data/images/paintings/thumbs/Parrhasius_Theseus_thumb.jpg deleted file mode 100644 index 4bd84b8..0000000 Binary files a/data/images/paintings/thumbs/Parrhasius_Theseus_thumb.jpg and /dev/null differ diff --git a/data/images/paintings/thumbs/Peter_Paul_Rubens_The_Descent_from_the_Cross_thumb.jpg b/data/images/paintings/thumbs/Peter_Paul_Rubens_The_Descent_from_the_Cross_thumb.jpg index 4ae2743..aa5b676 100644 Binary files a/data/images/paintings/thumbs/Peter_Paul_Rubens_The_Descent_from_the_Cross_thumb.jpg and b/data/images/paintings/thumbs/Peter_Paul_Rubens_The_Descent_from_the_Cross_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Rembrandt_The_Night_Watch_thumb.jpg b/data/images/paintings/thumbs/Rembrandt_The_Night_Watch_thumb.jpg index b34d9f1..29b4ebc 100644 Binary files a/data/images/paintings/thumbs/Rembrandt_The_Night_Watch_thumb.jpg and b/data/images/paintings/thumbs/Rembrandt_The_Night_Watch_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Richard_Hamilton_Hers_is_a_Luscious_Situation_thumb.jpg b/data/images/paintings/thumbs/Richard_Hamilton_Hers_is_a_Luscious_Situation_thumb.jpg index 1c5b94d..5566cde 100644 Binary files a/data/images/paintings/thumbs/Richard_Hamilton_Hers_is_a_Luscious_Situation_thumb.jpg and b/data/images/paintings/thumbs/Richard_Hamilton_Hers_is_a_Luscious_Situation_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Richard_Hamilton_Just_What_Is_It_thumb.jpg b/data/images/paintings/thumbs/Richard_Hamilton_Just_What_Is_It_thumb.jpg index 1c5b94d..73f54d3 100644 Binary files a/data/images/paintings/thumbs/Richard_Hamilton_Just_What_Is_It_thumb.jpg and b/data/images/paintings/thumbs/Richard_Hamilton_Just_What_Is_It_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Roy_Lichtenstein_Crying_Girl_thumb.jpg b/data/images/paintings/thumbs/Roy_Lichtenstein_Crying_Girl_thumb.jpg index e8cf84e..b70a1c1 100644 Binary files a/data/images/paintings/thumbs/Roy_Lichtenstein_Crying_Girl_thumb.jpg and b/data/images/paintings/thumbs/Roy_Lichtenstein_Crying_Girl_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Roy_Lichtenstein_Drowning_Girl_thumb.jpg b/data/images/paintings/thumbs/Roy_Lichtenstein_Drowning_Girl_thumb.jpg index 2c8b000..133cfb8 100644 Binary files a/data/images/paintings/thumbs/Roy_Lichtenstein_Drowning_Girl_thumb.jpg and b/data/images/paintings/thumbs/Roy_Lichtenstein_Drowning_Girl_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Roy_Lichtenstein_Hopeless_thumb.jpg b/data/images/paintings/thumbs/Roy_Lichtenstein_Hopeless_thumb.jpg index e8cf84e..abe6c2d 100644 Binary files a/data/images/paintings/thumbs/Roy_Lichtenstein_Hopeless_thumb.jpg and b/data/images/paintings/thumbs/Roy_Lichtenstein_Hopeless_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Roy_Lichtenstein_Look_Mickey_thumb.jpg b/data/images/paintings/thumbs/Roy_Lichtenstein_Look_Mickey_thumb.jpg index e8cf84e..58d7eec 100644 Binary files a/data/images/paintings/thumbs/Roy_Lichtenstein_Look_Mickey_thumb.jpg and b/data/images/paintings/thumbs/Roy_Lichtenstein_Look_Mickey_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Roy_Lichtenstein_Masterpiece_thumb.jpg b/data/images/paintings/thumbs/Roy_Lichtenstein_Masterpiece_thumb.jpg index e8cf84e..6e8ba58 100644 Binary files a/data/images/paintings/thumbs/Roy_Lichtenstein_Masterpiece_thumb.jpg and b/data/images/paintings/thumbs/Roy_Lichtenstein_Masterpiece_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Roy_Lichtenstein_Whaam__thumb.jpg b/data/images/paintings/thumbs/Roy_Lichtenstein_Whaam__thumb.jpg index 92df0fe..cca2e2d 100644 Binary files a/data/images/paintings/thumbs/Roy_Lichtenstein_Whaam__thumb.jpg and b/data/images/paintings/thumbs/Roy_Lichtenstein_Whaam__thumb.jpg differ diff --git a/data/images/paintings/thumbs/Theophanes_the_Greek_Notre_Dame_au_don_thumb.jpg b/data/images/paintings/thumbs/Theophanes_the_Greek_Notre_Dame_au_don_thumb.jpg index a434fc1..510823d 100644 Binary files a/data/images/paintings/thumbs/Theophanes_the_Greek_Notre_Dame_au_don_thumb.jpg and b/data/images/paintings/thumbs/Theophanes_the_Greek_Notre_Dame_au_don_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Theophanes_the_Greek_Our_Lady_of_the_Don_thumb.jpg b/data/images/paintings/thumbs/Theophanes_the_Greek_Our_Lady_of_the_Don_thumb.jpg index 0680bda..77b2882 100644 Binary files a/data/images/paintings/thumbs/Theophanes_the_Greek_Our_Lady_of_the_Don_thumb.jpg and b/data/images/paintings/thumbs/Theophanes_the_Greek_Our_Lady_of_the_Don_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Theophanes_the_Greek_Transfiguration_of_Christ_thumb.jpg b/data/images/paintings/thumbs/Theophanes_the_Greek_Transfiguration_of_Christ_thumb.jpg index 0680bda..a4cf879 100644 Binary files a/data/images/paintings/thumbs/Theophanes_the_Greek_Transfiguration_of_Christ_thumb.jpg and b/data/images/paintings/thumbs/Theophanes_the_Greek_Transfiguration_of_Christ_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Theophanes_the_Greek_Transfiguration_of_Jesus_thumb.jpg b/data/images/paintings/thumbs/Theophanes_the_Greek_Transfiguration_of_Jesus_thumb.jpg index 0680bda..7afe803 100644 Binary files a/data/images/paintings/thumbs/Theophanes_the_Greek_Transfiguration_of_Jesus_thumb.jpg and b/data/images/paintings/thumbs/Theophanes_the_Greek_Transfiguration_of_Jesus_thumb.jpg differ diff --git a/data/images/paintings/thumbs/Theophanes_the_Greek_the_repose_of_the_Virgin_Mary_thumb.jpg b/data/images/paintings/thumbs/Theophanes_the_Greek_the_repose_of_the_Virgin_Mary_thumb.jpg new file mode 100644 index 0000000..c12c6eb Binary files /dev/null and b/data/images/paintings/thumbs/Theophanes_the_Greek_the_repose_of_the_Virgin_Mary_thumb.jpg differ diff --git a/data/images/portraits/El_Greco.jpg b/data/images/portraits/El_Greco.jpg new file mode 100644 index 0000000..5f950e5 Binary files /dev/null and b/data/images/portraits/El_Greco.jpg differ diff --git a/data/images/portraits/John_Constable.jpg b/data/images/portraits/John_Constable.jpg new file mode 100644 index 0000000..7e7da17 Binary files /dev/null and b/data/images/portraits/John_Constable.jpg differ diff --git a/data/images/portraits/Parrhasius.jpg b/data/images/portraits/Parrhasius.jpg index 942a1e1..a7060ac 100644 Binary files a/data/images/portraits/Parrhasius.jpg and b/data/images/portraits/Parrhasius.jpg differ diff --git a/data/images/portraits/Roy_Lichtenstein.jpg b/data/images/portraits/Roy_Lichtenstein.jpg index 004b06a..807c946 100644 Binary files a/data/images/portraits/Roy_Lichtenstein.jpg and b/data/images/portraits/Roy_Lichtenstein.jpg differ diff --git a/data/images/portraits/Theophanes_the_Greek.gif b/data/images/portraits/Theophanes_the_Greek.gif deleted file mode 100644 index b8caee5..0000000 Binary files a/data/images/portraits/Theophanes_the_Greek.gif and /dev/null differ diff --git a/db/migrate-painting-annotations.sql b/db/migrate-painting-annotations.sql new file mode 100644 index 0000000..2153003 --- /dev/null +++ b/db/migrate-painting-annotations.sql @@ -0,0 +1,18 @@ +-- Short art-history annotations on painting detail (with optional image markers) +CREATE TABLE IF NOT EXISTS painting_annotations ( + id SERIAL PRIMARY KEY, + painting_id INTEGER NOT NULL REFERENCES paintings(id) ON DELETE CASCADE, + label VARCHAR(80), + body TEXT NOT NULL, + category VARCHAR(30) NOT NULL DEFAULT 'subject', + pos_x NUMERIC(5, 2), + pos_y NUMERIC(5, 2), + source_author VARCHAR(200), + source VARCHAR(500), + source_url VARCHAR(500), + sort_order INTEGER NOT NULL DEFAULT 0, + confidence VARCHAR(20) NOT NULL DEFAULT 'curated' +); + +CREATE INDEX IF NOT EXISTS painting_annotations_painting_idx + ON painting_annotations (painting_id); diff --git a/package.json b/package.json index 2cf7fdb..ee865db 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "migrate:influence-sources": "node scripts/migrate-influence-sources.js", "migrate:checkup-flags": "node scripts/migrate-checkup-flags.js", "migrate:artist-checkup-flags": "node scripts/migrate-artist-checkup-flags.js", + "migrate:painting-annotations": "node scripts/migrate-painting-annotations.js", + "update-painting-annotations": "node scripts/update-painting-annotations.js", "find-duplicates": "node scripts/find-duplicate-paintings.js", "discover-influences": "node scripts/update-influences.js --discover-only", "server": "node server/index.js", diff --git a/scripts/image-fetcher.js b/scripts/image-fetcher.js index 8ffabc8..b149e69 100644 --- a/scripts/image-fetcher.js +++ b/scripts/image-fetcher.js @@ -1373,6 +1373,8 @@ async function searchGoogleCustomSearchImagesMany(query, limit = 20) { imageUrl: item.link, thumbUrl: item.image?.thumbnailLink || item.link, source: 'google-custom-search', + width: item.image?.width, + height: item.image?.height, }); if (out.length >= limit) break; } @@ -1448,6 +1450,8 @@ async function searchDuckDuckGoImagesMany(query, limit = 20) { imageUrl, thumbUrl: item?.thumbnail || item?.image, source: 'duckduckgo-images', + width: item?.width, + height: item?.height, }); if (out.length >= limit) break; } @@ -1636,6 +1640,8 @@ async function searchDebugImagesMany(query, limit = 20) { imageUrl: url, thumbUrl: item.thumbUrl || url, source: item.source || 'image-search', + width: item.width, + height: item.height, }); }; diff --git a/scripts/migrate-painting-annotations.js b/scripts/migrate-painting-annotations.js new file mode 100644 index 0000000..458f028 --- /dev/null +++ b/scripts/migrate-painting-annotations.js @@ -0,0 +1,18 @@ +require('dotenv').config(); +const fs = require('fs'); +const path = require('path'); +const pool = require('../server/db'); + +async function main() { + const sqlPath = path.join(__dirname, '../db/migrate-painting-annotations.sql'); + const sql = fs.readFileSync(sqlPath, 'utf8'); + await pool.query(sql); + const { rows } = await pool.query(`SELECT COUNT(*)::int AS total FROM painting_annotations`); + console.log(`painting_annotations ready (${rows[0].total} rows)`); + await pool.end(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/painting-annotations-data.js b/scripts/painting-annotations-data.js new file mode 100644 index 0000000..3dee77e --- /dev/null +++ b/scripts/painting-annotations-data.js @@ -0,0 +1,433 @@ +/** + * Curated art-history annotations for painting detail overlays. + * Each entry: { artist, title, annotations: [{ label, body, category, pos_x?, pos_y?, source_* }] } + */ +module.exports = [ + { + artist: 'Leonardo da Vinci', + title: 'Mona Lisa', + annotations: [ + { + label: 'Sfumato', + body: 'Leonardo dissolves hard edges into smoky transitions, letting light model the face without outline.', + category: 'technique', + pos_x: 48, + pos_y: 38, + source_author: 'E. H. Gombrich', + source: 'The Story of Art', + }, + { + label: 'Landscape', + body: 'The winding paths and hazy mountains behind the sitter use aerial perspective to push space into depth.', + category: 'composition', + pos_x: 72, + pos_y: 68, + source_author: 'National Gallery', + source: 'Leonardo da Vinci: The Mona Lisa', + source_url: 'https://www.nationalgallery.org.uk/paintings/leonardo-da-vinci-the-mona-lisa', + }, + { + label: 'Enigmatic smile', + body: 'The mouth’s subtle turn became a touchstone for Renaissance discussions of inner life and portraiture.', + category: 'subject', + pos_x: 52, + pos_y: 52, + source_author: 'E. H. Gombrich', + source: 'The Story of Art', + }, + ], + }, + { + artist: 'Leonardo da Vinci', + title: 'The Last Supper', + annotations: [ + { + label: 'Perspective', + body: 'Orthogonal lines converge on Christ’s head, anchoring the drama in a mathematically ordered refectory.', + category: 'composition', + pos_x: 50, + pos_y: 42, + source_author: 'E. H. Gombrich', + source: 'The Story of Art', + }, + { + label: 'Gesture', + body: 'Each apostle reacts with a distinct bodily pose, turning the moment of betrayal into readable human psychology.', + category: 'subject', + pos_x: 28, + pos_y: 55, + source_author: 'H. W. Janson', + source: 'History of Art', + }, + ], + }, + { + artist: 'Michelangelo', + title: 'The Creation of Adam', + annotations: [ + { + label: 'The spark', + body: 'God and Adam reach across a gap barely bridged by touching fingers—the instant before life is given.', + category: 'symbolism', + pos_x: 62, + pos_y: 48, + source_author: 'E. H. Gombrich', + source: 'The Story of Art', + }, + { + label: 'Anatomy', + body: 'Adam’s body echoes antique sculpture while God’s billowing mantle suggests the brain’s form in Renaissance thought.', + category: 'technique', + pos_x: 38, + pos_y: 58, + source_author: 'Michelangelo: Divine Draftsman and Designer', + source: 'The Metropolitan Museum of Art', + source_url: 'https://www.metmuseum.org/exhibitions/listings/2017/michelangelo', + }, + ], + }, + { + artist: 'Raphael', + title: 'The School of Athens', + annotations: [ + { + label: 'Architecture', + body: 'A grand Bramante-like vault frames philosophers as actors on a classical stage of reason.', + category: 'composition', + pos_x: 50, + pos_y: 22, + source_author: 'E. H. Gombrich', + source: 'The Story of Art', + }, + { + label: 'Plato & Aristotle', + body: 'Plato points upward to ideals; Aristotle gestures earthward—two paths of knowledge in dialogue.', + category: 'symbolism', + pos_x: 54, + pos_y: 52, + source_author: 'Stanford Encyclopedia of Philosophy', + source: 'Plato and Aristotle in Raphael\'s School of Athens', + source_url: 'https://plato.stanford.edu/entries/plato/', + }, + ], + }, + { + artist: 'Vincent van Gogh', + title: 'The Starry Night', + annotations: [ + { + label: 'Swirling sky', + body: 'Thick, rhythmic strokes turn the night into an animated force rather than a static backdrop.', + category: 'technique', + pos_x: 50, + pos_y: 28, + source_author: 'Museum of Modern Art', + source: 'Vincent van Gogh: The Starry Night', + source_url: 'https://www.moma.org/collection/works/79802', + }, + { + label: 'Cypress', + body: 'The dark flame-like tree links earth and heaven, a traditional symbol of mourning and eternity.', + category: 'symbolism', + pos_x: 22, + pos_y: 62, + source_author: 'E. H. Gombrich', + source: 'The Story of Art', + }, + { + label: 'Village', + body: 'The quiet hamlet below contrasts with the turbulent heavens, suggesting inner turmoil against outward calm.', + category: 'subject', + pos_x: 48, + pos_y: 78, + source_author: 'Van Gogh Museum', + source: 'Collection catalogue', + source_url: 'https://www.vangoghmuseum.nl/en/collection/s0176V1962', + }, + ], + }, + { + artist: 'Rembrandt', + title: 'The Night Watch', + annotations: [ + { + label: 'Chiaroscuro', + body: 'A sharp light picks out faces and weapons from deep shadow, heightening the militia’s forward motion.', + category: 'technique', + pos_x: 42, + pos_y: 45, + source_author: 'Rijksmuseum', + source: 'The Night Watch', + source_url: 'https://www.rijksmuseum.nl/en/collection/SK-C-5', + }, + { + label: 'Group portrait', + body: 'Rembrandt breaks the static row format—figures overlap and move, turning a commission into narrative action.', + category: 'composition', + pos_x: 58, + pos_y: 55, + source_author: 'E. H. Gombrich', + source: 'The Story of Art', + }, + ], + }, + { + artist: 'Vermeer', + title: 'Girl with a Pearl Earring', + annotations: [ + { + label: 'Pearl', + body: 'A single highlight on the earring demonstrates Vermeer’s control of light on a tiny reflective surface.', + category: 'technique', + pos_x: 58, + pos_y: 52, + source_author: 'Mauritshuis', + source: 'Girl with a Pearl Earring', + source_url: 'https://www.mauritshuis.nl/en/our-collection/artworks/670-girl-with-a-pearl-earring/', + }, + { + label: 'Turned glance', + body: 'The sitter looks over her shoulder as if interrupted—a intimate moment rare in formal Dutch portraiture.', + category: 'subject', + pos_x: 46, + pos_y: 38, + source_author: 'E. H. Gombrich', + source: 'The Story of Art', + }, + ], + }, + { + artist: 'Diego Velázquez', + title: 'Las Meninas', + annotations: [ + { + label: 'Mirror', + body: 'The reflected king and queen place the viewer where royalty would stand, collapsing court and spectator.', + category: 'symbolism', + pos_x: 72, + pos_y: 42, + source_author: 'Museo del Prado', + source: 'Las Meninas', + source_url: 'https://www.museodelprado.es/en/the-collection/art-work/las-meninas', + }, + { + label: 'Painter', + body: 'Velázquez inserts himself at the easel, making painting itself the subject of a royal chamber scene.', + category: 'history', + pos_x: 18, + pos_y: 48, + source_author: 'E. H. Gombrich', + source: 'The Story of Art', + }, + ], + }, + { + artist: 'Pablo Picasso', + title: 'Guernica', + annotations: [ + { + label: 'Monochrome', + body: 'Grey, black, and white evoke newspaper reportage, tying the mural to the bombing’s documentary shock.', + category: 'technique', + pos_x: 50, + pos_y: 35, + source_author: 'Museo Reina Sofía', + source: 'Guernica', + source_url: 'https://www.museoreinasofia.es/en/collection/artwork/guernica', + }, + { + label: 'Broken forms', + body: 'Cubist fragmentation shatters bodies and tools into signs of suffering rather than readable illusion.', + category: 'composition', + pos_x: 35, + pos_y: 58, + source_author: 'E. H. Gombrich', + source: 'The Story of Art', + }, + { + label: 'Bull & horse', + body: 'Spanish symbols of brutality and the people collide in a tableau Picasso designed as political testimony.', + category: 'symbolism', + pos_x: 68, + pos_y: 62, + source_author: 'H. W. Janson', + source: 'History of Art', + }, + ], + }, + { + artist: 'Claude Monet', + title: 'Impression, Sunrise', + annotations: [ + { + label: 'Loose brushwork', + body: 'Quick strokes suggest the harbor rather than describing it—giving a critic the name “Impressionism.”', + category: 'technique', + pos_x: 55, + pos_y: 40, + source_author: 'Musée Marmottan Monet', + source: 'Impression, Sunrise', + source_url: 'https://www.marmottan.fr/en/collections/impression-sunrise', + }, + { + label: 'Orange sun', + body: 'The sun’s reflection on water becomes the focal note against a cool, industrial Le Havre skyline.', + category: 'composition', + pos_x: 38, + pos_y: 32, + source_author: 'E. H. Gombrich', + source: 'The Story of Art', + }, + ], + }, + { + artist: 'Jan van Eyck', + title: 'The Arnolfini Portrait', + annotations: [ + { + label: 'Mirror', + body: 'The convex mirror reflects two additional figures—often read as witnesses to a marital contract.', + category: 'symbolism', + pos_x: 78, + pos_y: 38, + source_author: 'National Gallery', + source: 'The Arnolfini Portrait', + source_url: 'https://www.nationalgallery.org.uk/paintings/jan-van-eyck-the-arnolfini-portrait', + }, + { + label: 'Oil detail', + body: 'Van Eyck layers transparent glazes to render textures from fur to brass with microscopic clarity.', + category: 'technique', + pos_x: 45, + pos_y: 72, + source_author: 'E. H. Gombrich', + source: 'The Story of Art', + }, + ], + }, + { + artist: 'Sandro Botticelli', + title: 'The Birth of Venus', + annotations: [ + { + label: 'Classical myth', + body: 'Venus arrives on a shell, reviving antique pagan beauty for Medici Florence.', + category: 'subject', + pos_x: 52, + pos_y: 48, + source_author: 'Uffizi Galleries', + source: 'Birth of Venus', + source_url: 'https://www.uffizi.it/en/artworks/birth-of-venus', + }, + { + label: 'Linear grace', + body: 'Flowing outlines and floating figures prioritize decorative rhythm over solid Renaissance volume.', + category: 'composition', + pos_x: 30, + pos_y: 55, + source_author: 'E. H. Gombrich', + source: 'The Story of Art', + }, + ], + }, + { + artist: 'Andrei Rublev', + title: 'Trinity', + annotations: [ + { + label: 'Icon symmetry', + body: 'Three angels share a single table in near-perfect balance, expressing theological unity through form.', + category: 'composition', + pos_x: 50, + pos_y: 50, + source_author: 'Tretyakov Gallery', + source: 'The Trinity by Andrei Rublev', + source_url: 'https://www.tretyakovgallery.ru/en/collection/_show/image/_id/208', + }, + { + label: 'Reverse perspective', + body: 'Lines subtly open toward the viewer, inviting contemplation into the sacred space rather than away from it.', + category: 'technique', + pos_x: 50, + pos_y: 72, + source_author: 'E. H. Gombrich', + source: 'The Story of Art', + }, + ], + }, + { + artist: 'Edvard Munch', + title: 'The Scream', + annotations: [ + { + label: 'Wavy horizon', + body: 'Undulating lines transmit inner anxiety into the landscape itself—a key Symbolist device.', + category: 'technique', + pos_x: 50, + pos_y: 30, + source_author: 'Munch Museum', + source: 'The Scream', + source_url: 'https://www.munchmuseet.no/en/the-scream/', + }, + { + label: 'Open mouth', + body: 'The figure’s silent shriek became an emblem of modern alienation in fin-de-siècle Europe.', + category: 'symbolism', + pos_x: 48, + pos_y: 42, + source_author: 'E. H. Gombrich', + source: 'The Story of Art', + }, + ], + }, + { + artist: 'Salvador Dalí', + title: 'The Persistence of Memory', + annotations: [ + { + label: 'Soft watches', + body: 'Liquid clocks parody rigid time, linking Surrealist dream logic to Einstein’s relativity in popular imagination.', + category: 'symbolism', + pos_x: 42, + pos_y: 62, + source_author: 'Museum of Modern Art', + source: 'The Persistence of Memory', + source_url: 'https://www.moma.org/collection/works/79018', + }, + { + label: 'Dead landscape', + body: 'The barren Catalonian coast stretches behind the table, grounding impossible objects in precise illusionism.', + category: 'composition', + pos_x: 55, + pos_y: 28, + source_author: 'E. H. Gombrich', + source: 'The Story of Art', + }, + ], + }, + { + artist: 'Caravaggio', + title: 'The Calling of Saint Matthew', + annotations: [ + { + label: 'Tenebrism', + body: 'A beam of light cuts the tavern darkness to isolate Christ’s gesture and Matthew’s hesitation.', + category: 'technique', + pos_x: 62, + pos_y: 38, + source_author: 'E. H. Gombrich', + source: 'The Story of Art', + }, + { + label: 'Everyday setting', + body: 'Sacred drama unfolds among ordinary tax collectors, a Counter-Reformation strategy to make faith immediate.', + category: 'history', + pos_x: 40, + pos_y: 58, + source_author: 'San Luigi dei Francesi', + source: 'Contarelli Chapel cycle', + source_url: 'https://www.sanluigideifrancesi.it/en/contarelli-chapel/', + }, + ], + }, +]; diff --git a/scripts/update-painting-annotations.js b/scripts/update-painting-annotations.js new file mode 100644 index 0000000..87c1abc --- /dev/null +++ b/scripts/update-painting-annotations.js @@ -0,0 +1,228 @@ +require('dotenv').config(); +const https = require('https'); +const pool = require('../server/db'); +const ANNOTATIONS = require('./painting-annotations-data'); +const { findArtist, findPainting } = require('./influence-resolver'); + +const FROM_WIKIPEDIA = process.argv.includes('--wikipedia'); +const REPLACE = !process.argv.includes('--no-replace'); +const WIKI_DELAY_MS = parseInt(process.argv.find((a) => a.startsWith('--wiki-delay='))?.split('=')[1] || '1200', 10); +const USER_AGENT = 'VirtualArtGallery/1.0 (educational art history project; local museum gallery)'; + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +function fetchJson(url, attempt = 0) { + return new Promise((resolve, reject) => { + https + .get(url, { headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' } }, (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', async () => { + if (res.statusCode === 429 && attempt < 4) { + const wait = 1500 * (attempt + 1); + console.warn(`Wikipedia rate limit; retrying in ${wait}ms…`); + await sleep(wait); + fetchJson(url, attempt + 1).then(resolve).catch(reject); + return; + } + if (res.statusCode && res.statusCode >= 400) { + reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 80)}`)); + return; + } + try { + resolve(JSON.parse(data)); + } catch { + reject(new Error(`Invalid JSON from Wikipedia: ${data.slice(0, 80)}`)); + } + }); + }) + .on('error', reject); + }); +} + +function wikiTitleKey(title) { + return String(title || '').toLowerCase().replace(/ /g, '_'); +} + +function extractIntroFromPage(page) { + const extract = page?.extract?.trim(); + if (!extract || page?.missing) return null; + const sentence = extract.split(/(?<=[.!?])\s+/).find((s) => s.length > 40); + return sentence || extract.slice(0, 280); +} + +async function fetchWikipediaIntro(wikipediaTitle) { + if (!wikipediaTitle) return null; + const url = + `https://en.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(wikipediaTitle)}` + + '&prop=extracts&explaintext=1&exintro=1&format=json'; + try { + const data = await fetchJson(url); + const page = Object.values(data.query?.pages || {})[0]; + return extractIntroFromPage(page); + } catch (err) { + console.warn(`✗ Wikipedia fetch failed for "${wikipediaTitle}": ${err.message}`); + return null; + } +} + +async function fetchWikipediaIntroBatch(titles) { + if (!titles.length) return new Map(); + const url = + `https://en.wikipedia.org/w/api.php?action=query&titles=${titles.map(encodeURIComponent).join('|')}` + + '&prop=extracts&explaintext=1&exintro=1&format=json'; + const data = await fetchJson(url); + const out = new Map(); + for (const page of Object.values(data.query?.pages || {})) { + if (!page?.title) continue; + const body = extractIntroFromPage(page); + if (body) out.set(wikiTitleKey(page.title), body); + } + return out; +} + +async function insertAnnotation(paintingId, row, sortOrder) { + await pool.query( + `INSERT INTO painting_annotations + (painting_id, label, body, category, pos_x, pos_y, source_author, source, source_url, sort_order, confidence) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [ + paintingId, + row.label || null, + row.body, + row.category || 'subject', + row.pos_x ?? null, + row.pos_y ?? null, + row.source_author || null, + row.source || null, + row.source_url || null, + sortOrder, + row.confidence || 'curated', + ] + ); +} + +async function clearCurated(paintingId) { + await pool.query( + `DELETE FROM painting_annotations WHERE painting_id = $1 AND confidence = 'curated'`, + [paintingId] + ); +} + +async function applyCuratedEntry(entry, stats) { + const artist = await findArtist(pool, entry.artist); + if (!artist) { + stats.missing += 1; + console.warn(`✗ artist not found: ${entry.artist}`); + return; + } + + const painting = await findPainting(pool, artist.id, entry.title); + if (!painting) { + stats.missing += 1; + console.warn(`✗ painting not found: ${entry.artist} / ${entry.title}`); + return; + } + + if (REPLACE) { + await clearCurated(painting.id); + } + + let order = 0; + for (const ann of entry.annotations || []) { + await insertAnnotation(painting.id, ann, order++); + stats.added += 1; + } + console.log(`→ ${entry.artist} / ${painting.title}: ${entry.annotations.length} annotation(s)`); +} + +async function applyWikipediaFallback(stats) { + const { rows } = await pool.query(` + SELECT p.id, p.title, p.wikipedia_title, a.name AS artist_name + FROM paintings p + JOIN artists a ON a.id = p.artist_id + WHERE p.wikipedia_title IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM painting_annotations pa WHERE pa.painting_id = p.id) + ORDER BY p.id + `); + + const BATCH_SIZE = 20; + for (let i = 0; i < rows.length; i += BATCH_SIZE) { + const batch = rows.slice(i, i + BATCH_SIZE); + let extracts = new Map(); + try { + extracts = await fetchWikipediaIntroBatch(batch.map((r) => r.wikipedia_title)); + } catch (err) { + console.warn(`✗ Wikipedia batch failed (${i + 1}-${i + batch.length}): ${err.message}`); + stats.wikiFailed += batch.length; + await sleep(WIKI_DELAY_MS * 4); + continue; + } + + for (const row of batch) { + try { + const body = extracts.get(wikiTitleKey(row.wikipedia_title)) || null; + if (!body) { + stats.wikiSkipped += 1; + continue; + } + await insertAnnotation( + row.id, + { + label: 'Overview', + body, + category: 'subject', + source_author: 'Wikipedia', + source: row.wikipedia_title, + source_url: `https://en.wikipedia.org/wiki/${encodeURIComponent(row.wikipedia_title.replace(/ /g, '_'))}`, + confidence: 'discovered', + }, + 0 + ); + stats.wiki += 1; + console.log(`+ wiki: ${row.artist_name} / ${row.title}`); + } catch (err) { + stats.wikiFailed += 1; + console.warn(`✗ wiki insert failed for ${row.artist_name} / ${row.title}: ${err.message}`); + } + } + + if (i + BATCH_SIZE < rows.length) { + await sleep(WIKI_DELAY_MS); + } + } +} + +async function main() { + const stats = { added: 0, missing: 0, wiki: 0, wikiSkipped: 0, wikiFailed: 0 }; + + for (const entry of ANNOTATIONS) { + await applyCuratedEntry(entry, stats); + } + + if (FROM_WIKIPEDIA) { + await applyWikipediaFallback(stats); + } + + const { rows } = await pool.query(` + SELECT COUNT(*)::int AS total, + COUNT(DISTINCT painting_id)::int AS paintings + FROM painting_annotations + `); + console.log( + `Done: ${stats.added} curated inserted, ${stats.missing} unmatched, ${stats.wiki} from Wikipedia` + + (stats.wikiSkipped ? ` (${stats.wikiSkipped} skipped)` : '') + + (stats.wikiFailed ? ` (${stats.wikiFailed} failed)` : '') + + `; ${rows[0].total} total on ${rows[0].paintings} painting(s)` + ); + await pool.end(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/server/index.js b/server/index.js index ae9850b..c9a3653 100644 --- a/server/index.js +++ b/server/index.js @@ -12,7 +12,7 @@ const app = express(); const PORT = process.env.PORT || 3001; app.use(cors()); -app.use(express.json()); +app.use(express.json({ limit: '20mb' })); app.use('/images', express.static(IMAGE_DIR)); const INFLUENCE_LINKS_EXISTS = ` @@ -92,6 +92,44 @@ app.get('/api/timeline', async (req, res) => { } }); +// Movement gallery — all paintings by artists in the movement, chronological +app.get('/api/movements/:id/gallery', async (req, res) => { + try { + const { id } = req.params; + const movement = await pool.query( + `SELECT m.*, e.name AS era_name + FROM art_movements m + LEFT JOIN historical_eras e ON m.era_id = e.id + WHERE m.id = $1`, + [id] + ); + if (movement.rows.length === 0) { + return res.status(404).json({ error: 'Movement not found' }); + } + + const paintings = await pool.query( + `SELECT p.*, + a.name AS artist_name, + p.checkup_checked, + p.checkup_fixed, + (${INFLUENCE_LINKS_EXISTS}) AS has_influence_links + FROM paintings p + INNER JOIN artists a ON p.artist_id = a.id + WHERE a.movement_id = $1 + ORDER BY p.year NULLS LAST, p.sort_order, a.birth_year NULLS LAST, p.title`, + [id] + ); + + res.json({ + movement: movement.rows[0], + paintings: paintings.rows, + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Failed to fetch movement gallery' }); + } +}); + // Artists for a movement in a time range app.get('/api/movements/:id/artists', async (req, res) => { try { @@ -570,7 +608,7 @@ app.get('/api/paintings/:id', async (req, res) => { return res.status(404).json({ error: 'Painting not found' }); } - const [influencedBy, influenced] = await Promise.all([ + const [influencedBy, influenced, annotations] = await Promise.all([ pool.query(INFLUENCED_BY_SQL, [id]), pool.query( `SELECT * FROM ( @@ -593,12 +631,20 @@ app.get('/api/paintings/:id', async (req, res) => { ORDER BY year NULLS LAST`, [id] ), + pool.query( + `SELECT id, label, body, category, pos_x, pos_y, source_author, source, source_url, sort_order, confidence + FROM painting_annotations + WHERE painting_id = $1 + ORDER BY sort_order, id`, + [id] + ), ]); res.json({ painting: painting.rows[0], influencedBy: influencedBy.rows, influenced: influenced.rows, + annotations: annotations.rows, }); } catch (err) { console.error(err);