Improve 3D gallery and debug checkup workflow for image curation.
Add parquet floor, museum-style exit, movement-tinted walls, and gold/black frames with gallery sync after Fix it. Debug panel gets Checked and Fix it buttons; documentation and image-fetch reliability updates included. Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -96,11 +96,17 @@ Full artist profile for the bio page and 3D gallery entry.
|
|||||||
"wikipedia_title": "Claude Monet"
|
"wikipedia_title": "Claude Monet"
|
||||||
},
|
},
|
||||||
"periods": [ { "id": 1, "name": "Milan Period", "start_year": 1482, "end_year": 1499, ... } ],
|
"periods": [ { "id": 1, "name": "Milan Period", "start_year": 1482, "end_year": 1499, ... } ],
|
||||||
"paintings": [ { "id": 10, "title": "...", "year": 1498, "image_path": "...", "thumbnail_path": "...", "wikipedia_title": "...", "has_influence_links": true, ... } ]
|
"paintings": [ { "id": 10, "title": "...", "year": 1498, "image_path": "...", "thumbnail_path": "...", "wikipedia_title": "...", "has_influence_links": true, "checkup_checked": false, "checkup_fixed": false, ... } ]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Each painting includes `has_influence_links` (boolean) — `true` when the work appears in any `painting_influences` row as source or target. The 3D gallery uses this to show a golden lamp above the frame.
|
Each painting includes:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `has_influence_links` | `true` when the work appears in any influence row — 3D gallery shows a golden lamp above the frame |
|
||||||
|
| `checkup_checked` | Reviewed in checkup / debug workflow (gold frame in 3D when true) |
|
||||||
|
| `checkup_fixed` | Image replaced via **Fix it** |
|
||||||
|
|
||||||
Populate biographies with `npm run fetch-artist-bios` (see [data-and-images.md](data-and-images.md)).
|
Populate biographies with `npm run fetch-artist-bios` (see [data-and-images.md](data-and-images.md)).
|
||||||
|
|
||||||
@@ -163,7 +169,7 @@ Painting detail with influence graph neighbours.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"painting": { "id": 10, "title": "...", "artist_name": "...", "image_path": "...", "has_influence_links": true, ... },
|
"painting": { "id": 10, "title": "...", "artist_name": "...", "image_path": "...", "checkup_checked": false, "checkup_fixed": false, "has_influence_links": true, ... },
|
||||||
"influencedBy": [
|
"influencedBy": [
|
||||||
{
|
{
|
||||||
"source_type": "painting",
|
"source_type": "painting",
|
||||||
@@ -281,12 +287,21 @@ Google-family image search for debug / checkup (Custom Search → Google Arts &
|
|||||||
|
|
||||||
Download a remote URL and replace the painting’s local full image + thumbnail. Sets `checkup_fixed = true` and `checkup_checked = true`.
|
Download a remote URL and replace the painting’s local full image + thumbnail. Sets `checkup_fixed = true` and `checkup_checked = true`.
|
||||||
|
|
||||||
|
Uses `downloadImageForFix` in `scripts/image-fetcher.js` (browser User-Agent, referer fallbacks, Wikimedia URL upgrades) for reliable downloads from Google Arts, Commons, etc.
|
||||||
|
|
||||||
**Body**
|
**Body**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "imageUrl": "https://…" }
|
{
|
||||||
|
"imageUrl": "https://…",
|
||||||
|
"searchUrl": "https://…",
|
||||||
|
"source": "google-arts",
|
||||||
|
"thumbUrl": "https://…"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Only `imageUrl` is required; optional fields improve fetch success for hotlinked URLs.
|
||||||
|
|
||||||
**Response**
|
**Response**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -325,9 +340,10 @@ The React client wraps these endpoints in `client/src/api/client.ts`:
|
|||||||
| `preloadArtistImages(id)` | `POST /api/artists/:id/preload-images` |
|
| `preloadArtistImages(id)` | `POST /api/artists/:id/preload-images` |
|
||||||
| `imageUrl(path)` | `/images/<path>` or placeholder |
|
| `imageUrl(path)` | `/images/<path>` or placeholder |
|
||||||
| `galleryImageUrl(painting)` | Local thumb/full only (3D) |
|
| `galleryImageUrl(painting)` | Local thumb/full only (3D) |
|
||||||
|
| `galleryImageUrlWithRevision(painting, revision)` | Local URL with `?v=` cache buster after fix |
|
||||||
| `paintingImageUrl(painting)` | Local file or on-demand API |
|
| `paintingImageUrl(painting)` | Local file or on-demand API |
|
||||||
| `api.getPaintingCheckup()` | `GET /api/paintings/checkup` |
|
| `api.getPaintingCheckup()` | `GET /api/paintings/checkup` |
|
||||||
| `api.updatePaintingCheckupFlags(id, flags)` | `PATCH /api/paintings/:id/checkup-flags` |
|
| `api.updatePaintingCheckupFlags(id, flags)` | `PATCH /api/paintings/:id/checkup-flags` |
|
||||||
| `api.getPaintingDebugImageSearch(id)` | `GET /api/paintings/:id/debug-image-search` |
|
| `api.getPaintingDebugImageSearch(id)` | `GET /api/paintings/:id/debug-image-search` |
|
||||||
| `api.fixPaintingImage(id, imageUrl)` | `POST /api/paintings/:id/fix-image` |
|
| `api.fixPaintingImage(id, imageUrl, context?)` | `POST /api/paintings/:id/fix-image` |
|
||||||
| `debugImageProxyUrl(imageUrl)` | `GET /api/debug/image-proxy?url=…` |
|
| `debugImageProxyUrl(imageUrl, context?)` | `GET /api/debug/image-proxy?url=…` |
|
||||||
|
|||||||
@@ -31,9 +31,14 @@ Gallery/
|
|||||||
├── server/ # Express API, DB pool, image service
|
├── server/ # Express API, DB pool, image service
|
||||||
├── client/ # React/Vite frontend
|
├── client/ # React/Vite frontend
|
||||||
│ ├── src/ # Source (components, pages, 3D scene)
|
│ ├── src/ # Source (components, pages, 3D scene)
|
||||||
|
│ │ ├── components/VirtualGallery.tsx # 3D hall (parquet, frames, museum exit)
|
||||||
|
│ │ ├── components/PaintingDetail.tsx # Detail view + debug panel
|
||||||
│ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches)
|
│ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches)
|
||||||
|
│ │ ├── pages/CheckupPage.tsx # Image audit table
|
||||||
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
|
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
|
||||||
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
|
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
|
||||||
|
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor
|
||||||
|
│ │ ├── utils/debugMode.ts # Debug mode localStorage toggle
|
||||||
│ │ └── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
|
│ │ └── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
|
||||||
│ └── dist/ # Production build (served by API when present)
|
│ └── dist/ # Production build (served by API when present)
|
||||||
├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools
|
├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools
|
||||||
@@ -160,15 +165,20 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi
|
|||||||
|------|----------------|
|
|------|----------------|
|
||||||
| One hall per artist | `VirtualGallery.tsx` builds a single room from that artist’s paintings |
|
| One hall per artist | `VirtualGallery.tsx` builds a single room from that artist’s paintings |
|
||||||
| Catalog depth | Most artists target **≥ 6** notable works via `npm run expand-catalog` and `famous-paintings-data.js`; some masters have larger museum dumps |
|
| Catalog depth | Most artists target **≥ 6** notable works via `npm run expand-catalog` and `famous-paintings-data.js`; some masters have larger museum dumps |
|
||||||
| Paintings on walls | Works hang on the **back, left, and right** walls; the room **grows and uses multiple rows** when the catalog is large (e.g. 75+ works) |
|
| Paintings on walls | Works hang on the **back, left, and right** walls in **one row per wall**; room **depth grows** when the catalog is large |
|
||||||
|
| Corridor layout | **15+ paintings:** short back wall (up to 8 works), remaining works on extended **left/right** side walls — a long gallery corridor |
|
||||||
| Wall order | On each wall, left → right: **later works on the left**, **earlier works on the right**; undated works sort toward the left |
|
| Wall order | On each wall, left → right: **later works on the left**, **earlier works on the right**; undated works sort toward the left |
|
||||||
|
| Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) |
|
||||||
|
| Wall tint | Gallery walls blend the artist’s **movement colour** into cream plaster tones |
|
||||||
|
| Frame finish | **Unchecked** works: black moulding; **Reviewed** (`checkup_checked`): bright gold moulding at **double width** |
|
||||||
| Influence lamps | A golden picture light appears **above frames** whose work has any influence-graph edge (`has_influence_links` from the API) |
|
| Influence lamps | A golden picture light appears **above frames** whose work has any influence-graph edge (`has_influence_links` from the API) |
|
||||||
| Eye-level viewing | Frame centres sit at **eye height (~1.65 m)**; the camera stays **level with the floor** (no pitch up/down) |
|
| Eye-level viewing | Frame centres sit at **eye height (~1.65 m)**; the camera stays **level with the floor** (no pitch up/down) |
|
||||||
| Open centre | Floor and ceiling only — no columns, pedestals, or other centre objects |
|
| Open centre | Floor and ceiling only — no columns, pedestals, or other centre objects |
|
||||||
| Single exit | One doorway on the **front wall**; walk to it or click it |
|
| Museum exit | Front-wall **double doors** with transom, brass hardware, sconces, marble threshold, and warm vestibule glow |
|
||||||
| Hall-to-hall travel | Exit opens a panel: **Predecessors** (left) and **Successors** (right), each grouped by art movement |
|
| Hall-to-hall travel | Exit opens a panel: **Predecessors** (left) and **Successors** (right), each grouped by art movement |
|
||||||
| Missing images | Works without a local file show a **draped canvas cover** in the frame (not a blank white rectangle) |
|
| Missing images | Works without a local file show a **draped canvas cover** in the frame (not a blank white rectangle) |
|
||||||
| 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 |
|
| 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:**
|
||||||
|
|
||||||
@@ -213,6 +223,10 @@ Opened from the 3D hall (click a frame) or from influence thumbnails on another
|
|||||||
- **Influence links** push a new detail layer; **Back** from an influenced work returns to the painting you came from (and from there back to the gallery if applicable).
|
- **Influence links** push a new detail layer; **Back** from an influenced work returns to the painting you came from (and from there back to the gallery if applicable).
|
||||||
- The 3D hall stays mounted in the background while detail is open so nothing is lost on return.
|
- The 3D hall stays mounted in the background while detail is open so nothing is lost on return.
|
||||||
|
|
||||||
|
### Debug mode (developer)
|
||||||
|
|
||||||
|
When **Debug mode** is enabled from the home header, painting detail shows a bottom-left panel with image search preview and **Checked** / **Fix it** buttons. See [Developer tools (image audit)](#developer-tools-image-audit).
|
||||||
|
|
||||||
## Key design decisions
|
## Key design decisions
|
||||||
|
|
||||||
- **Timeline bounds** derive from the earliest art movement start year, not ancient-era metadata alone, so the default view opens where catalogued content begins.
|
- **Timeline bounds** derive from the earliest art movement start year, not ancient-era metadata alone, so the default view opens where catalogued content begins.
|
||||||
@@ -231,9 +245,22 @@ Optional workflow for curating local image files — not part of the public visi
|
|||||||
|---------|--------|---------|
|
|---------|--------|---------|
|
||||||
| **Debug mode** | Home header toggle (`client/src/utils/debugMode.ts`) | Persists in `localStorage`; enables debug panel on painting detail |
|
| **Debug mode** | Home header toggle (`client/src/utils/debugMode.ts`) | Persists in `localStorage`; enables debug panel on painting detail |
|
||||||
| **Checkup page** | Home header → **Checkup** (`CheckupPage.tsx`) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags |
|
| **Checkup page** | Home header → **Checkup** (`CheckupPage.tsx`) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags |
|
||||||
| **Debug panel** | Painting detail (bottom-left, when debug mode on) | Google-family image search preview + **Fix it** for the current work |
|
| **Debug panel** | Painting detail (bottom-left, when debug mode on) | Search preview + two action buttons |
|
||||||
|
|
||||||
**Checkup columns:** Gallery and Detail thumbnails, Search (reference image), Fix (replace local file), **Reviewed** (`checkup_checked`), **Fixed** (`checkup_fixed`).
|
### Debug panel (painting detail)
|
||||||
|
|
||||||
|
When debug mode is on, a panel at the bottom-left shows the image search query, a preview when a result is found, and **two buttons**:
|
||||||
|
|
||||||
|
| Button | Action |
|
||||||
|
|--------|--------|
|
||||||
|
| **Checked** | Sets `checkup_checked` via `PATCH /api/paintings/:id/checkup-flags` (disabled once already reviewed) |
|
||||||
|
| **Fix it** | Replaces local full + thumbnail from the search result via `POST /api/paintings/:id/fix-image`; sets **Fixed** and **Reviewed** |
|
||||||
|
|
||||||
|
After **Fix it**, the detail image, gallery textures, and frame colour (gold if reviewed) update without a full page reload. **Back to Gallery** returns to the live hall session, not a stale snapshot.
|
||||||
|
|
||||||
|
### Checkup page
|
||||||
|
|
||||||
|
**Columns:** Gallery and Detail thumbnails, Search (reference image), Fix (replace local file), **Reviewed** (`checkup_checked`), **Fixed** (`checkup_fixed`).
|
||||||
|
|
||||||
**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**.
|
**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**.
|
||||||
|
|
||||||
|
|||||||
@@ -337,7 +337,18 @@ 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:
|
When **Debug mode** is on (home header) or from the **Checkup** page:
|
||||||
|
|
||||||
1. **Search** — `GET /api/paintings/:id/debug-image-search` tries Google Custom Search (if `GOOGLE_CSE_API_KEY` + `GOOGLE_CSE_CX` are set in `.env`), Google Arts & Culture, Google Images scrape, then DuckDuckGo (`searchGoogleImagesFirst` in `scripts/image-fetcher.js`).
|
1. **Search** — `GET /api/paintings/:id/debug-image-search` tries Google Custom Search (if `GOOGLE_CSE_API_KEY` + `GOOGLE_CSE_CX` are set in `.env`), Google Arts & Culture, Google Images scrape, then DuckDuckGo (`searchGoogleImagesFirst` in `scripts/image-fetcher.js`).
|
||||||
2. **Fix** — `POST /api/paintings/:id/fix-image` downloads the chosen URL via `replacePaintingImageFromUrl` in `server/image-service.js`, regenerates the thumbnail with `sharp`, and sets checkup flags.
|
2. **Fix** — `POST /api/paintings/:id/fix-image` downloads the chosen URL via `downloadImageForFix` → `replacePaintingImageFromUrl` in `server/image-service.js`, regenerates the thumbnail with `sharp`, and sets `checkup_fixed` + `checkup_checked`.
|
||||||
|
|
||||||
|
### Painting detail debug panel
|
||||||
|
|
||||||
|
With debug mode on, `PaintingDetail.tsx` shows a bottom-left panel with search preview and two buttons:
|
||||||
|
|
||||||
|
| Button | API | Effect |
|
||||||
|
|--------|-----|--------|
|
||||||
|
| **Checked** | `PATCH …/checkup-flags` `{ "checked": true }` | Marks reviewed; 3D frame turns gold |
|
||||||
|
| **Fix it** | `POST …/fix-image` | Saves image to disk, sets both flags, refreshes detail + gallery |
|
||||||
|
|
||||||
|
The client passes `searchUrl`, `source`, and `thumbUrl` from the search result to improve download reliability. After a fix, `HomePage` updates the gallery session and appends a revision query on 3D texture URLs so replaced files reload even when the path is unchanged.
|
||||||
|
|
||||||
Checkup **Search visible** queues search for filtered rows only (3 concurrent); it does not search the full catalog on load.
|
Checkup **Search visible** queues search for filtered rows only (3 concurrent); it does not search the full catalog on load.
|
||||||
|
|
||||||
|
|||||||
@@ -168,4 +168,7 @@ After clone: copy `.env.example` → `.env`, install dependencies, run `npm run
|
|||||||
| Permission denied creating tables | `gallery` user lacks CREATE | Run admin grants, then migrate |
|
| Permission denied creating tables | `gallery` user lacks CREATE | Run admin grants, then migrate |
|
||||||
| Wikipedia API rate limit during fetch | Too many requests in a row | Wait and re-run; scripts retry with backoff |
|
| 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 |
|
| Checkup **Reviewed** toggle returns 404 | Stale server process missing new routes | Restart `npm run dev` after pulling API changes |
|
||||||
|
| **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 |
|
| Duplicate works in gallery / timeline | Double import or variant Wikipedia titles | `npm run find-duplicates`; merge or delete spare rows manually |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Art Gallery
|
# 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 (dynamic wall layout, chronological wall order, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with prev/next catalog browsing and fullscreen lightbox, preserved gallery camera on return, and Wikipedia-sourced artist biographies.
|
Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom and major event markers, branching art-movement flow (curved streams, hover-to-reveal artist lifespans), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with prev/next catalog browsing and fullscreen lightbox, debug-mode image audit (**Checked** / **Fix it**), Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ Interactive virtual art gallery: zoomable historical timeline with era click-to-
|
|||||||
npm run fetch-artist-bios # Wikipedia biographies for all artists
|
npm run fetch-artist-bios # Wikipedia biographies for all artists
|
||||||
npm run expand-catalog # add famous works for artists with thin catalogs
|
npm run expand-catalog # add famous works for artists with thin catalogs
|
||||||
npm run update-influences # art-history lineage links between paintings
|
npm run update-influences # art-history lineage links between paintings
|
||||||
|
npm run migrate:checkup-flags # review/fixed flags for Checkup + debug mode
|
||||||
npm run fetch-images -- --limit=50 # random batch of missing images (10s per work)
|
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
|
npm run fetch-images -- --artist="Claude Monet" # one artist in catalog order
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ export function galleryImageUrl(painting: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function galleryImageUrlWithRevision(
|
||||||
|
painting: {
|
||||||
|
id?: number;
|
||||||
|
thumbnail_path?: string | null;
|
||||||
|
image_path?: string | null;
|
||||||
|
},
|
||||||
|
revision?: number
|
||||||
|
): string | null {
|
||||||
|
const base = galleryImageUrl(painting);
|
||||||
|
if (!base || !revision) return base;
|
||||||
|
return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function paintingImageUrl(painting: {
|
export function paintingImageUrl(painting: {
|
||||||
id: number;
|
id: number;
|
||||||
image_path?: string | null;
|
image_path?: string | null;
|
||||||
@@ -46,6 +59,13 @@ export async function preloadArtistImages(artistId: number): Promise<{ fetched:
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FixPaintingImageResult {
|
||||||
|
imagePath: string;
|
||||||
|
thumbnailPath: string;
|
||||||
|
fixed?: boolean;
|
||||||
|
checked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DebugImageSearchResult {
|
export interface DebugImageSearchResult {
|
||||||
query: string;
|
query: string;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
@@ -100,17 +120,21 @@ export const api = {
|
|||||||
getPaintingDebugImageSearch: (id: number) =>
|
getPaintingDebugImageSearch: (id: number) =>
|
||||||
fetchJson<DebugImageSearchResult>(`${API}/paintings/${id}/debug-image-search`),
|
fetchJson<DebugImageSearchResult>(`${API}/paintings/${id}/debug-image-search`),
|
||||||
|
|
||||||
fixPaintingImage: (id: number, imageUrl: string) =>
|
fixPaintingImage: (
|
||||||
|
id: number,
|
||||||
|
imageUrl: string,
|
||||||
|
context?: { searchUrl?: string; source?: string; pageUrl?: string; thumbUrl?: string }
|
||||||
|
) =>
|
||||||
fetch(`${API}/paintings/${id}/fix-image`, {
|
fetch(`${API}/paintings/${id}/fix-image`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ imageUrl }),
|
body: JSON.stringify({ imageUrl, ...context }),
|
||||||
}).then(async (res) => {
|
}).then(async (res) => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
throw new Error(body.error || `Fix failed: ${res.status}`);
|
throw new Error(body.error || `Fix failed: ${res.status}`);
|
||||||
}
|
}
|
||||||
return res.json() as Promise<{ imagePath: string; thumbnailPath: string; fixed?: boolean; checked?: boolean }>;
|
return res.json() as Promise<FixPaintingImageResult>;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getPaintingCheckup: () => fetchJson<PaintingCheckupData>(`${API}/paintings/checkup`),
|
getPaintingCheckup: () => fetchJson<PaintingCheckupData>(`${API}/paintings/checkup`),
|
||||||
@@ -132,6 +156,12 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function debugImageProxyUrl(imageUrl: string): string {
|
export function debugImageProxyUrl(
|
||||||
return `${API}/debug/image-proxy?url=${encodeURIComponent(imageUrl)}`;
|
imageUrl: string,
|
||||||
|
context?: { searchUrl?: string; source?: string }
|
||||||
|
): string {
|
||||||
|
const params = new URLSearchParams({ url: imageUrl });
|
||||||
|
if (context?.searchUrl) params.set('searchUrl', context.searchUrl);
|
||||||
|
if (context?.source) params.set('source', context.source);
|
||||||
|
return `${API}/debug/image-proxy?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -440,6 +440,34 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.debug-action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-checked-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid rgba(201, 169, 110, 0.45);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(201, 169, 110, 0.1);
|
||||||
|
color: #c9a96e;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-checked-btn:hover:not(:disabled) {
|
||||||
|
background: rgba(201, 169, 110, 0.22);
|
||||||
|
border-color: #c9a96e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-checked-btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.debug-image-preview {
|
.debug-image-preview {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -451,7 +479,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.debug-fix-btn {
|
.debug-fix-btn {
|
||||||
width: 100%;
|
flex: 1;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border: 1px solid #e8a040;
|
border: 1px solid #e8a040;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, type SyntheticEvent } from 'react';
|
import { useEffect, useState, type SyntheticEvent } from 'react';
|
||||||
import type { InfluenceLink, Painting, PaintingDetail } from '../types';
|
import type { InfluenceLink, Painting, PaintingDetail } from '../types';
|
||||||
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult } from '../api/client';
|
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type FixPaintingImageResult } from '../api/client';
|
||||||
import PaintingLightbox from './PaintingLightbox';
|
import PaintingLightbox from './PaintingLightbox';
|
||||||
import './PaintingDetail.css';
|
import './PaintingDetail.css';
|
||||||
|
|
||||||
@@ -13,7 +13,14 @@ interface Props {
|
|||||||
onArtistBio: () => void;
|
onArtistBio: () => void;
|
||||||
onInfluenceArtistClick?: (artistId: number) => void;
|
onInfluenceArtistClick?: (artistId: number) => void;
|
||||||
debugMode?: boolean;
|
debugMode?: boolean;
|
||||||
onPaintingImageFixed?: (paintingId: number) => void | Promise<void>;
|
onPaintingImageFixed?: (
|
||||||
|
paintingId: number,
|
||||||
|
fixResult: FixPaintingImageResult
|
||||||
|
) => void | Promise<void>;
|
||||||
|
onPaintingCheckupFlagsUpdated?: (
|
||||||
|
paintingId: number,
|
||||||
|
flags: { checked: boolean; fixed: boolean }
|
||||||
|
) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function influenceKey(inf: InfluenceLink, index: number): string {
|
function influenceKey(inf: InfluenceLink, index: number): string {
|
||||||
@@ -180,6 +187,7 @@ export default function PaintingDetailView({
|
|||||||
onInfluenceArtistClick,
|
onInfluenceArtistClick,
|
||||||
debugMode = false,
|
debugMode = false,
|
||||||
onPaintingImageFixed,
|
onPaintingImageFixed,
|
||||||
|
onPaintingCheckupFlagsUpdated,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { painting, influencedBy, influenced } = data;
|
const { painting, influencedBy, influenced } = data;
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
@@ -188,6 +196,7 @@ export default function PaintingDetailView({
|
|||||||
const [debugLoading, setDebugLoading] = useState(false);
|
const [debugLoading, setDebugLoading] = useState(false);
|
||||||
const [debugError, setDebugError] = useState<string | null>(null);
|
const [debugError, setDebugError] = useState<string | null>(null);
|
||||||
const [fixing, setFixing] = useState(false);
|
const [fixing, setFixing] = useState(false);
|
||||||
|
const [markingChecked, setMarkingChecked] = useState(false);
|
||||||
|
|
||||||
const imageSrc = `${paintingImageUrl(painting)}${paintingImageUrl(painting).includes('?') ? '&' : '?'}v=${imageVersion}`;
|
const imageSrc = `${paintingImageUrl(painting)}${paintingImageUrl(painting).includes('?') ? '&' : '?'}v=${imageVersion}`;
|
||||||
|
|
||||||
@@ -237,9 +246,15 @@ export default function PaintingDetailView({
|
|||||||
setFixing(true);
|
setFixing(true);
|
||||||
setDebugError(null);
|
setDebugError(null);
|
||||||
try {
|
try {
|
||||||
await api.fixPaintingImage(painting.id, debugSearch.imageUrl);
|
const fixResult = await api.fixPaintingImage(painting.id, debugSearch.imageUrl, {
|
||||||
|
searchUrl: debugSearch.searchUrl,
|
||||||
|
source: debugSearch.source,
|
||||||
|
thumbUrl: debugSearch.thumbUrl,
|
||||||
|
});
|
||||||
setImageVersion((v) => v + 1);
|
setImageVersion((v) => v + 1);
|
||||||
await onPaintingImageFixed?.(painting.id);
|
if (onPaintingImageFixed) {
|
||||||
|
await onPaintingImageFixed(painting.id, fixResult);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDebugError(err instanceof Error ? err.message : 'Could not replace image.');
|
setDebugError(err instanceof Error ? err.message : 'Could not replace image.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -247,6 +262,20 @@ export default function PaintingDetailView({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMarkChecked = async () => {
|
||||||
|
if (painting.checkup_checked || markingChecked) return;
|
||||||
|
setMarkingChecked(true);
|
||||||
|
setDebugError(null);
|
||||||
|
try {
|
||||||
|
const updated = await api.updatePaintingCheckupFlags(painting.id, { checked: true });
|
||||||
|
await onPaintingCheckupFlagsUpdated?.(painting.id, updated);
|
||||||
|
} catch (err) {
|
||||||
|
setDebugError(err instanceof Error ? err.message : 'Could not mark as checked.');
|
||||||
|
} finally {
|
||||||
|
setMarkingChecked(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fullscreen) return;
|
if (fullscreen) return;
|
||||||
|
|
||||||
@@ -391,28 +420,39 @@ export default function PaintingDetailView({
|
|||||||
{debugLoading && <p className="debug-image-status">Searching…</p>}
|
{debugLoading && <p className="debug-image-status">Searching…</p>}
|
||||||
{debugError && <p className="debug-image-error">{debugError}</p>}
|
{debugError && <p className="debug-image-error">{debugError}</p>}
|
||||||
{!debugLoading && debugSearch?.imageUrl && (
|
{!debugLoading && debugSearch?.imageUrl && (
|
||||||
<>
|
<img
|
||||||
<img
|
className="debug-image-preview"
|
||||||
className="debug-image-preview"
|
src={debugImageProxyUrl(debugSearch.imageUrl, {
|
||||||
src={debugImageProxyUrl(debugSearch.imageUrl)}
|
searchUrl: debugSearch.searchUrl,
|
||||||
alt={`Google search result for ${debugSearch.query}`}
|
source: debugSearch.source,
|
||||||
onError={(e) => {
|
})}
|
||||||
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
|
alt={`Google search result for ${debugSearch.query}`}
|
||||||
}}
|
onError={(e) => {
|
||||||
/>
|
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
|
||||||
<button
|
}}
|
||||||
type="button"
|
/>
|
||||||
className="debug-fix-btn"
|
|
||||||
onClick={handleFixImage}
|
|
||||||
disabled={fixing}
|
|
||||||
>
|
|
||||||
{fixing ? 'Replacing…' : 'Fix it'}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
|
{!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
|
||||||
<p className="debug-image-status">No Google image result found.</p>
|
<p className="debug-image-status">No Google image result found.</p>
|
||||||
)}
|
)}
|
||||||
|
<div className="debug-action-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="debug-checked-btn"
|
||||||
|
onClick={handleMarkChecked}
|
||||||
|
disabled={!!painting.checkup_checked || markingChecked}
|
||||||
|
>
|
||||||
|
{markingChecked ? '…' : 'Checked'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="debug-fix-btn"
|
||||||
|
onClick={handleFixImage}
|
||||||
|
disabled={fixing || debugLoading || !debugSearch?.imageUrl}
|
||||||
|
>
|
||||||
|
{fixing ? '…' : 'Fix it'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import type {
|
|||||||
ArtistNavigation,
|
ArtistNavigation,
|
||||||
MovementArtistGroup,
|
MovementArtistGroup,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { galleryImageUrl, imageUrl, api, preloadArtistImages } from '../api/client';
|
import { galleryImageUrlWithRevision, imageUrl, api, preloadArtistImages } from '../api/client';
|
||||||
import { comparePaintingsChronological, paintingHasInfluenceLinks } from '../utils/paintingUtils';
|
import { comparePaintingsChronological, paintingHasInfluenceLinks } from '../utils/paintingUtils';
|
||||||
|
import { createParquetFloorTexture, PARQUET_METERS_PER_TILE } from '../utils/parquetFloorTexture';
|
||||||
import './VirtualGallery.css';
|
import './VirtualGallery.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: ArtistDetail;
|
data: ArtistDetail;
|
||||||
|
imageRevisions?: Record<number, number>;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
onPaintingClick: (paintingId: number) => void;
|
onPaintingClick: (paintingId: number) => void;
|
||||||
onNavigateArtist: (artistId: number) => void;
|
onNavigateArtist: (artistId: number) => void;
|
||||||
@@ -28,20 +30,66 @@ const MOUNT_OFFSET = 0.16;
|
|||||||
const WALL_STANDOFF = 0.07;
|
const WALL_STANDOFF = 0.07;
|
||||||
const BACK_WALL_EXTRA = 0.05;
|
const BACK_WALL_EXTRA = 0.05;
|
||||||
const FRAME_FACE_Z = 0.018;
|
const FRAME_FACE_Z = 0.018;
|
||||||
|
const FRAME_DEPTH = 0.07;
|
||||||
|
const FRAME_MAT_BORDER = 0.1;
|
||||||
|
const FRAME_RAIL = 0.08;
|
||||||
|
/** Checked paintings — double-width moulding. */
|
||||||
|
const REVIEWED_MAT_BORDER = FRAME_MAT_BORDER * 2;
|
||||||
|
const REVIEWED_RAIL = FRAME_RAIL * 2;
|
||||||
const EYE_HEIGHT = 1.65;
|
const EYE_HEIGHT = 1.65;
|
||||||
const FRAME_GAP = 0.18;
|
const FRAME_GAP = 0.32;
|
||||||
const MIN_FRAME_W = 0.45;
|
const MIN_FRAME_W = 0.45;
|
||||||
const MAX_FRAME_W = 1.05;
|
const MAX_FRAME_W = 1.05;
|
||||||
const MAX_FRAME_H = 1.35;
|
const MAX_FRAME_H = 1.35;
|
||||||
const MIN_HALL_SIZE = 9;
|
const MIN_HALL_SIZE = 9;
|
||||||
const ROW_GAP = 0.2;
|
const ROW_GAP = 0.2;
|
||||||
const WALL_PADDING = 1.4;
|
const WALL_PADDING = 1.4;
|
||||||
|
/** Above this count, use a long corridor (short back wall, extended side walls). */
|
||||||
|
const CORRIDOR_CATALOG_THRESHOLD = 15;
|
||||||
|
const BACK_WALL_MAX_PAINTINGS = 8;
|
||||||
|
/** Every wall shows at most one row; side-wall depth grows to fit the catalog. */
|
||||||
|
const MAX_WALL_ROWS = 1;
|
||||||
const DOOR_WIDTH = 2.4;
|
const DOOR_WIDTH = 2.4;
|
||||||
const DOOR_HEIGHT = 2.5;
|
const DOOR_HEIGHT = 2.5;
|
||||||
|
/** Museum exit — dimensions derived from door opening. */
|
||||||
|
const EXIT_JAMB = 0.13;
|
||||||
|
const EXIT_HEADER = 0.15;
|
||||||
|
const EXIT_TRANSOM = 0.52;
|
||||||
|
const EXIT_SURROUND = 0.1;
|
||||||
const TURN_SPEED = 0.032;
|
const TURN_SPEED = 0.032;
|
||||||
const MOUSE_TURN_SENSITIVITY = 0.004;
|
const MOUSE_TURN_SENSITIVITY = 0.004;
|
||||||
const DRAG_START_THRESHOLD_PX = 5;
|
const DRAG_START_THRESHOLD_PX = 5;
|
||||||
|
|
||||||
|
const DEFAULT_MOVEMENT_COLOR = '#8B7355';
|
||||||
|
const WALL_BASE_MAIN = '#f0ebe3';
|
||||||
|
const WALL_BASE_SIDE = '#e8e2d8';
|
||||||
|
|
||||||
|
function parseHex(hex: string): { r: number; g: number; b: number } {
|
||||||
|
const n = parseInt(hex.replace('#', ''), 16);
|
||||||
|
if (Number.isNaN(n)) return { r: 240, g: 235, b: 227 };
|
||||||
|
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function blendHex(base: string, accent: string, accentWeight: number): string {
|
||||||
|
const a = parseHex(accent);
|
||||||
|
const b = parseHex(base);
|
||||||
|
const w = Math.min(1, Math.max(0, accentWeight));
|
||||||
|
const mix = (ca: number, cb: number) => Math.round(ca * w + cb * (1 - w));
|
||||||
|
const r = mix(a.r, b.r);
|
||||||
|
const g = mix(a.g, b.g);
|
||||||
|
const bl = mix(a.b, b.b);
|
||||||
|
return `#${((r << 16) | (g << 8) | bl).toString(16).padStart(6, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function galleryWallColors(movementColor?: string) {
|
||||||
|
const accent = movementColor || DEFAULT_MOVEMENT_COLOR;
|
||||||
|
return {
|
||||||
|
main: blendHex(WALL_BASE_MAIN, accent, 0.34),
|
||||||
|
side: blendHex(WALL_BASE_SIDE, accent, 0.26),
|
||||||
|
trim: blendHex('#b8956a', accent, 0.45),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Height above frame top edge — keeps fixture out of the viewer's line of sight. */
|
/** Height above frame top edge — keeps fixture out of the viewer's line of sight. */
|
||||||
const INFLUENCE_LAMP_ABOVE_FRAME = 0.46;
|
const INFLUENCE_LAMP_ABOVE_FRAME = 0.46;
|
||||||
|
|
||||||
@@ -68,34 +116,73 @@ interface HallLayout {
|
|||||||
segments: WallSegment[];
|
segments: WallSegment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function layoutRow(count: number, span: number) {
|
function paintingIsReviewed(painting: Painting): boolean {
|
||||||
|
return !!painting.checkup_checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameDimsForReviewed(reviewed: boolean) {
|
||||||
|
return reviewed
|
||||||
|
? { matBorder: REVIEWED_MAT_BORDER, rail: REVIEWED_RAIL, depth: FRAME_DEPTH * 1.08 }
|
||||||
|
: { matBorder: FRAME_MAT_BORDER, rail: FRAME_RAIL, depth: FRAME_DEPTH };
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameOuterW(canvasW: number, reviewed: boolean): number {
|
||||||
|
const { matBorder, rail } = frameDimsForReviewed(reviewed);
|
||||||
|
return canvasW + matBorder * 2 + rail;
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameOuterH(canvasH: number, reviewed: boolean): number {
|
||||||
|
const { matBorder, rail } = frameDimsForReviewed(reviewed);
|
||||||
|
return canvasH + matBorder * 2 + rail;
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutRow(paintings: Painting[], span: number) {
|
||||||
|
const count = paintings.length;
|
||||||
|
if (count === 0) return { slots: [], spanNeeded: span };
|
||||||
|
|
||||||
const gap = FRAME_GAP;
|
const gap = FRAME_GAP;
|
||||||
const padding = 1.4;
|
const available = span - WALL_PADDING;
|
||||||
const available = span - padding;
|
|
||||||
let frameW = Math.min(MAX_FRAME_W, (available - (count - 1) * gap) / Math.max(count, 1));
|
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 += gap;
|
||||||
|
}
|
||||||
|
if (total <= available) break;
|
||||||
|
frameW -= 0.015;
|
||||||
|
}
|
||||||
frameW = Math.max(MIN_FRAME_W, frameW);
|
frameW = Math.max(MIN_FRAME_W, frameW);
|
||||||
|
|
||||||
const frameH = Math.min(MAX_FRAME_H, frameW * 1.22);
|
const frameH = Math.min(MAX_FRAME_H, frameW * 1.22);
|
||||||
const rowWidth = count * frameW + (count - 1) * gap;
|
const outers = paintings.map((p) => frameOuterW(frameW, paintingIsReviewed(p)));
|
||||||
|
const rowWidth = outers.reduce((sum, w) => sum + w, 0) + (count - 1) * gap;
|
||||||
const slots: { offset: number; maxW: number; maxH: number }[] = [];
|
const slots: { offset: number; maxW: number; maxH: number }[] = [];
|
||||||
|
|
||||||
|
let cursor = -rowWidth / 2;
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
|
const outerW = outers[i];
|
||||||
slots.push({
|
slots.push({
|
||||||
offset: -rowWidth / 2 + frameW / 2 + i * (frameW + gap),
|
offset: cursor + outerW / 2,
|
||||||
maxW: frameW,
|
maxW: frameW,
|
||||||
maxH: frameH,
|
maxH: frameH,
|
||||||
});
|
});
|
||||||
|
cursor += outerW + gap;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) };
|
return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function wallRowHeights(count: number, span: number, rows: number) {
|
function wallRowHeights(paintings: Painting[], span: number, rows: number) {
|
||||||
const perRow = Math.ceil(count / rows);
|
const perRow = Math.ceil(paintings.length / rows);
|
||||||
const heights: number[] = [];
|
const heights: number[] = [];
|
||||||
for (let r = 0; r < rows; r++) {
|
for (let r = 0; r < rows; r++) {
|
||||||
const inRow = Math.min(perRow, count - r * perRow);
|
const rowStart = r * perRow;
|
||||||
const { slots } = layoutRow(inRow, span);
|
const row = paintings.slice(rowStart, rowStart + perRow);
|
||||||
heights.push(slots[0]?.maxH ?? MAX_FRAME_H);
|
const { slots } = layoutRow(row, span);
|
||||||
|
const reviewed = row.some(paintingIsReviewed);
|
||||||
|
heights.push(frameOuterH(slots[0]?.maxH ?? MAX_FRAME_H, reviewed));
|
||||||
}
|
}
|
||||||
return heights;
|
return heights;
|
||||||
}
|
}
|
||||||
@@ -104,16 +191,14 @@ function wallStackHeight(heights: number[]) {
|
|||||||
return heights.reduce((sum, h, i) => sum + h + (i > 0 ? ROW_GAP : 0), 0);
|
return heights.reduce((sum, h, i) => sum + h + (i > 0 ? ROW_GAP : 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fitsOnWall(count: number, span: number, rows: number) {
|
function fitsOnWall(paintings: Painting[], span: number, rows: number) {
|
||||||
|
const count = paintings.length;
|
||||||
const perRow = Math.ceil(count / rows);
|
const perRow = Math.ceil(count / rows);
|
||||||
const available = span - WALL_PADDING;
|
const row = paintings.slice(0, perRow);
|
||||||
const frameW = Math.min(
|
const { slots } = layoutRow(row, span);
|
||||||
MAX_FRAME_W,
|
if ((slots[0]?.maxW ?? 0) < MIN_FRAME_W) return false;
|
||||||
(available - (perRow - 1) * FRAME_GAP) / Math.max(perRow, 1)
|
|
||||||
);
|
|
||||||
if (frameW < MIN_FRAME_W) return false;
|
|
||||||
|
|
||||||
const totalHeight = wallStackHeight(wallRowHeights(count, span, rows));
|
const totalHeight = wallStackHeight(wallRowHeights(paintings, span, rows));
|
||||||
const bottom = EYE_HEIGHT - totalHeight / 2;
|
const bottom = EYE_HEIGHT - totalHeight / 2;
|
||||||
const top = EYE_HEIGHT + totalHeight / 2;
|
const top = EYE_HEIGHT + totalHeight / 2;
|
||||||
if (bottom < 0.35) return false;
|
if (bottom < 0.35) return false;
|
||||||
@@ -121,26 +206,35 @@ function fitsOnWall(count: number, span: number, rows: number) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowCountForWall(count: number, span: number) {
|
function rowCountForWall(paintings: Painting[], span: number, maxRows: number = paintings.length) {
|
||||||
for (let rows = 1; rows <= count; rows++) {
|
const count = paintings.length;
|
||||||
if (fitsOnWall(count, span, rows)) return rows;
|
const limit = Math.max(1, Math.min(count, maxRows));
|
||||||
|
for (let rows = 1; rows <= limit; rows++) {
|
||||||
|
if (fitsOnWall(paintings, span, rows)) return rows;
|
||||||
}
|
}
|
||||||
return count;
|
return limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
function minSpanForWall(count: number) {
|
function minSpanForWall(paintings: Painting[], maxRows: number = paintings.length) {
|
||||||
|
const count = paintings.length;
|
||||||
if (count === 0) return MIN_HALL_SIZE;
|
if (count === 0) return MIN_HALL_SIZE;
|
||||||
|
|
||||||
let best = Infinity;
|
let best = Infinity;
|
||||||
for (let rows = 1; rows <= count; rows++) {
|
const rowLimit = Math.max(1, Math.min(count, maxRows));
|
||||||
|
for (let rows = 1; rows <= rowLimit; rows++) {
|
||||||
const perRow = Math.ceil(count / rows);
|
const perRow = Math.ceil(count / rows);
|
||||||
const span = perRow * MIN_FRAME_W + (perRow - 1) * FRAME_GAP + WALL_PADDING;
|
const { spanNeeded } = layoutRow(paintings.slice(0, perRow), MIN_HALL_SIZE);
|
||||||
if (fitsOnWall(count, span, rows)) {
|
if (fitsOnWall(paintings, spanNeeded, rows)) {
|
||||||
best = Math.min(best, span);
|
best = Math.min(best, spanNeeded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.max(MIN_HALL_SIZE, best === Infinity ? MIN_HALL_SIZE : best);
|
if (best === Infinity) {
|
||||||
|
const perRow = Math.ceil(count / rowLimit);
|
||||||
|
best = layoutRow(paintings.slice(0, perRow), MIN_HALL_SIZE).spanNeeded;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(MIN_HALL_SIZE, best);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Left → right on each wall: later works on the left, earlier works on the right. */
|
/** Left → right on each wall: later works on the left, earlier works on the right. */
|
||||||
@@ -150,9 +244,30 @@ function orderPaintingsForWallDisplay(paintings: Painting[]) {
|
|||||||
|
|
||||||
function distributePaintingsAcrossWalls(paintings: Painting[]) {
|
function distributePaintingsAcrossWalls(paintings: Painting[]) {
|
||||||
const sorted = [...paintings].sort(comparePaintingsChronological);
|
const sorted = [...paintings].sort(comparePaintingsChronological);
|
||||||
const walls: Painting[][] = [[], [], []];
|
const back: Painting[] = [];
|
||||||
sorted.forEach((p, i) => walls[i % 3].push(p));
|
const left: Painting[] = [];
|
||||||
return walls.map(orderPaintingsForWallDisplay);
|
const right: Painting[] = [];
|
||||||
|
|
||||||
|
if (sorted.length <= CORRIDOR_CATALOG_THRESHOLD) {
|
||||||
|
sorted.forEach((p, i) => {
|
||||||
|
if (i % 3 === 0) back.push(p);
|
||||||
|
else if (i % 3 === 1) left.push(p);
|
||||||
|
else right.push(p);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const backCount = Math.min(BACK_WALL_MAX_PAINTINGS, Math.max(4, Math.ceil(sorted.length * 0.12)));
|
||||||
|
back.push(...sorted.slice(0, backCount));
|
||||||
|
sorted.slice(backCount).forEach((p, i) => {
|
||||||
|
if (i % 2 === 0) left.push(p);
|
||||||
|
else right.push(p);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
orderPaintingsForWallDisplay(back),
|
||||||
|
orderPaintingsForWallDisplay(left),
|
||||||
|
orderPaintingsForWallDisplay(right),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function wallLabelForPaintings(wallPaintings: Painting[], periods: ArtistPeriod[]) {
|
function wallLabelForPaintings(wallPaintings: Painting[], periods: ArtistPeriod[]) {
|
||||||
@@ -173,12 +288,13 @@ function layoutWallSlots(
|
|||||||
side: WallSide,
|
side: WallSide,
|
||||||
halfW: number,
|
halfW: number,
|
||||||
halfD: number,
|
halfD: number,
|
||||||
inset: number
|
inset: number,
|
||||||
|
maxRows: number = paintings.length
|
||||||
): FrameSlot[] {
|
): FrameSlot[] {
|
||||||
const count = paintings.length;
|
const count = paintings.length;
|
||||||
if (count === 0) return [];
|
if (count === 0) return [];
|
||||||
|
|
||||||
const rows = rowCountForWall(count, span);
|
const rows = rowCountForWall(paintings, span, maxRows);
|
||||||
const perRow = Math.ceil(count / rows);
|
const perRow = Math.ceil(count / rows);
|
||||||
const slots: FrameSlot[] = [];
|
const slots: FrameSlot[] = [];
|
||||||
|
|
||||||
@@ -186,7 +302,7 @@ function layoutWallSlots(
|
|||||||
for (let r = 0; r < rows; r++) {
|
for (let r = 0; r < rows; r++) {
|
||||||
const rowStart = r * perRow;
|
const rowStart = r * perRow;
|
||||||
const inRow = Math.min(perRow, count - rowStart);
|
const inRow = Math.min(perRow, count - rowStart);
|
||||||
rowLayouts.push(layoutRow(inRow, span));
|
rowLayouts.push(layoutRow(paintings.slice(rowStart, rowStart + inRow), span));
|
||||||
}
|
}
|
||||||
|
|
||||||
const rowHeights = rowLayouts.map((row) => row.slots[0]?.maxH ?? MAX_FRAME_H);
|
const rowHeights = rowLayouts.map((row) => row.slots[0]?.maxH ?? MAX_FRAME_H);
|
||||||
@@ -236,14 +352,17 @@ function buildHallLayout(paintings: Painting[], periods: ArtistPeriod[]): HallLa
|
|||||||
const walls: WallSide[] = ['back', 'left', 'right'];
|
const walls: WallSide[] = ['back', 'left', 'right'];
|
||||||
const wallPaintings = distributePaintingsAcrossWalls(paintings);
|
const wallPaintings = distributePaintingsAcrossWalls(paintings);
|
||||||
|
|
||||||
let width = minSpanForWall(wallPaintings[0].length);
|
let width = minSpanForWall(wallPaintings[0], MAX_WALL_ROWS);
|
||||||
let depth = Math.max(minSpanForWall(wallPaintings[1].length), minSpanForWall(wallPaintings[2].length));
|
let depth = Math.max(
|
||||||
|
minSpanForWall(wallPaintings[1], MAX_WALL_ROWS),
|
||||||
|
minSpanForWall(wallPaintings[2], MAX_WALL_ROWS)
|
||||||
|
);
|
||||||
|
|
||||||
for (let i = 0; i < 24; i++) {
|
for (let i = 0; i < 24; i++) {
|
||||||
const nextWidth = minSpanForWall(wallPaintings[0].length);
|
const nextWidth = minSpanForWall(wallPaintings[0], MAX_WALL_ROWS);
|
||||||
const nextDepth = Math.max(
|
const nextDepth = Math.max(
|
||||||
minSpanForWall(wallPaintings[1].length),
|
minSpanForWall(wallPaintings[1], MAX_WALL_ROWS),
|
||||||
minSpanForWall(wallPaintings[2].length)
|
minSpanForWall(wallPaintings[2], MAX_WALL_ROWS)
|
||||||
);
|
);
|
||||||
if (nextWidth === width && nextDepth === depth) break;
|
if (nextWidth === width && nextDepth === depth) break;
|
||||||
width = nextWidth;
|
width = nextWidth;
|
||||||
@@ -267,7 +386,8 @@ function buildHallLayout(paintings: Painting[], periods: ArtistPeriod[]): HallLa
|
|||||||
side,
|
side,
|
||||||
halfW,
|
halfW,
|
||||||
halfD,
|
halfD,
|
||||||
inset
|
inset,
|
||||||
|
MAX_WALL_ROWS
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -328,6 +448,25 @@ function getCanvasWeaveTexture() {
|
|||||||
return canvasWeaveTexture;
|
return canvasWeaveTexture;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function frameFinish(reviewed: boolean, hovered: boolean) {
|
||||||
|
if (reviewed) {
|
||||||
|
return {
|
||||||
|
color: hovered ? '#ffe566' : '#ffd700',
|
||||||
|
roughness: 0.22,
|
||||||
|
metalness: 0.78,
|
||||||
|
emissive: hovered ? '#ffcc00' : '#daa520',
|
||||||
|
emissiveIntensity: hovered ? 0.35 : 0.18,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
color: hovered ? '#2a2a2a' : '#0a0a0a',
|
||||||
|
roughness: 0.28,
|
||||||
|
metalness: 0.72,
|
||||||
|
emissive: '#000000',
|
||||||
|
emissiveIntensity: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function CanvasCover({
|
function CanvasCover({
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
@@ -510,6 +649,7 @@ function PaintingFrame({
|
|||||||
maxWidth,
|
maxWidth,
|
||||||
maxHeight,
|
maxHeight,
|
||||||
wallSide,
|
wallSide,
|
||||||
|
imageRevision,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
painting: Painting;
|
painting: Painting;
|
||||||
@@ -518,19 +658,21 @@ function PaintingFrame({
|
|||||||
maxWidth: number;
|
maxWidth: number;
|
||||||
maxHeight: number;
|
maxHeight: number;
|
||||||
wallSide: WallSide;
|
wallSide: WallSide;
|
||||||
|
imageRevision?: number;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const [aspect, setAspect] = useState(1.33);
|
const [aspect, setAspect] = useState(1.33);
|
||||||
const { width, height } = computeFrameSize(aspect, maxWidth, maxHeight);
|
const { width, height } = computeFrameSize(aspect, maxWidth, maxHeight);
|
||||||
const frameDepth = 0.06;
|
const reviewed = paintingIsReviewed(painting);
|
||||||
const matBorder = 0.05;
|
const { matBorder, rail, depth: frameDepth } = frameDimsForReviewed(reviewed);
|
||||||
const hasImage = paintingHasGalleryImage(painting);
|
const hasImage = paintingHasGalleryImage(painting);
|
||||||
const url = hasImage ? galleryImageUrl(painting) : null;
|
const url = hasImage ? galleryImageUrlWithRevision(painting, imageRevision) : null;
|
||||||
const { texture, failed } = usePaintingTexture(url);
|
const { texture, failed } = usePaintingTexture(url);
|
||||||
const showImage = hasImage && !failed && !!texture;
|
const showImage = hasImage && !failed && !!texture;
|
||||||
const showCanvas = !showImage;
|
const showCanvas = !showImage;
|
||||||
const hasInfluenceLinks = paintingHasInfluenceLinks(painting);
|
const hasInfluenceLinks = paintingHasInfluenceLinks(painting);
|
||||||
|
const finish = frameFinish(reviewed, hovered);
|
||||||
const faceZ = FRAME_FACE_Z + (wallSide === 'back' ? 0.012 : 0);
|
const faceZ = FRAME_FACE_Z + (wallSide === 'back' ? 0.012 : 0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -563,18 +705,24 @@ function PaintingFrame({
|
|||||||
onPointerOver={() => setHovered(true)}
|
onPointerOver={() => setHovered(true)}
|
||||||
onPointerOut={() => setHovered(false)}
|
onPointerOut={() => setHovered(false)}
|
||||||
>
|
>
|
||||||
<boxGeometry args={[width + matBorder * 2 + 0.04, height + matBorder * 2 + 0.04, frameDepth]} />
|
<boxGeometry args={[width + matBorder * 2 + rail, height + matBorder * 2 + rail, frameDepth]} />
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
color={showCanvas ? (hovered ? '#8a7355' : '#5a4520') : hovered ? '#c9a227' : '#6b4f1d'}
|
color={finish.color}
|
||||||
roughness={0.45}
|
roughness={finish.roughness}
|
||||||
metalness={showCanvas ? 0.15 : 0.5}
|
metalness={finish.metalness}
|
||||||
|
emissive={finish.emissive}
|
||||||
|
emissiveIntensity={finish.emissiveIntensity}
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{!showCanvas && (
|
{!showCanvas && (
|
||||||
<mesh position={[0, 0, frameDepth + 0.002]} renderOrder={2}>
|
<mesh position={[0, 0, frameDepth + 0.002]} renderOrder={2}>
|
||||||
<boxGeometry args={[width + matBorder * 2, height + matBorder * 2, 0.008]} />
|
<boxGeometry args={[width + matBorder * 2, height + matBorder * 2, 0.008]} />
|
||||||
<meshStandardMaterial color="#f5f0e6" roughness={0.95} />
|
<meshStandardMaterial
|
||||||
|
color={reviewed ? '#fff6cc' : '#f5f0e6'}
|
||||||
|
roughness={reviewed ? 0.75 : 0.95}
|
||||||
|
metalness={reviewed ? 0.15 : 0}
|
||||||
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -637,69 +785,311 @@ function ExitPortal({
|
|||||||
position,
|
position,
|
||||||
active,
|
active,
|
||||||
onActivate,
|
onActivate,
|
||||||
|
wallColor = '#f0ebe3',
|
||||||
|
trimColor = '#ddd5c8',
|
||||||
}: {
|
}: {
|
||||||
position: [number, number, number];
|
position: [number, number, number];
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onActivate: () => void;
|
onActivate: () => void;
|
||||||
|
wallColor?: string;
|
||||||
|
trimColor?: string;
|
||||||
}) {
|
}) {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const glow = active || hovered;
|
const highlight = active || hovered;
|
||||||
|
|
||||||
|
const openingW = DOOR_WIDTH;
|
||||||
|
const openingH = DOOR_HEIGHT;
|
||||||
|
const jamb = EXIT_JAMB;
|
||||||
|
const header = EXIT_HEADER;
|
||||||
|
const transomH = EXIT_TRANSOM;
|
||||||
|
const surround = EXIT_SURROUND;
|
||||||
|
const frameW = openingW + jamb * 2;
|
||||||
|
const frameFullH = openingH + header + transomH;
|
||||||
|
const leafW = (openingW - 0.025) / 2;
|
||||||
|
const leafH = openingH - 0.08;
|
||||||
|
const leafY = 0.04 + leafH / 2;
|
||||||
|
const faceZ = -0.04;
|
||||||
|
|
||||||
|
const woodDark = '#261a10';
|
||||||
|
const woodMid = '#3d2818';
|
||||||
|
const woodGrain = '#4e3624';
|
||||||
|
const stone = wallColor;
|
||||||
|
const stoneDark = trimColor;
|
||||||
|
const brass = highlight ? '#d4af37' : '#a08050';
|
||||||
|
const brassEmissive = highlight ? '#5a4010' : '#000000';
|
||||||
|
const corridorGlow = highlight ? '#fff0d0' : '#ffe8c0';
|
||||||
|
|
||||||
const handleActivate = (e: THREE.Event & { stopPropagation: () => void }) => {
|
const handleActivate = (e: THREE.Event & { stopPropagation: () => void }) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onActivate();
|
onActivate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setHover = (on: boolean) => () => setHovered(on);
|
||||||
|
|
||||||
|
const doorLeaf = (side: 'left' | 'right') => {
|
||||||
|
const x = side === 'left' ? -leafW / 2 - 0.006 : leafW / 2 + 0.006;
|
||||||
|
const panelInset = 0.06;
|
||||||
|
return (
|
||||||
|
<group key={side} position={[x, leafY, faceZ]}>
|
||||||
|
<mesh castShadow>
|
||||||
|
<boxGeometry args={[leafW, leafH, 0.055]} />
|
||||||
|
<meshStandardMaterial color={woodMid} roughness={0.62} metalness={0.04} />
|
||||||
|
</mesh>
|
||||||
|
{/* Raised panel */}
|
||||||
|
<mesh position={[0, 0.08, 0.03]}>
|
||||||
|
<boxGeometry args={[leafW - panelInset * 2, leafH * 0.38, 0.012]} />
|
||||||
|
<meshStandardMaterial color={woodGrain} roughness={0.58} metalness={0.03} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, -leafH * 0.22, 0.03]}>
|
||||||
|
<boxGeometry args={[leafW - panelInset * 2, leafH * 0.32, 0.012]} />
|
||||||
|
<meshStandardMaterial color={woodDark} roughness={0.65} metalness={0.02} />
|
||||||
|
</mesh>
|
||||||
|
{/* Stile edges */}
|
||||||
|
<mesh position={[side === 'left' ? leafW / 2 - 0.025 : -leafW / 2 + 0.025, 0, 0.028]}>
|
||||||
|
<boxGeometry args={[0.05, leafH, 0.008]} />
|
||||||
|
<meshStandardMaterial color={woodDark} roughness={0.7} />
|
||||||
|
</mesh>
|
||||||
|
{/* Brass handle */}
|
||||||
|
<mesh position={[side === 'left' ? leafW / 2 - 0.1 : -leafW / 2 + 0.1, 0, 0.038]} rotation={[0, 0, side === 'left' ? 0.08 : -0.08]}>
|
||||||
|
<boxGeometry args={[0.04, 0.22, 0.025]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={brass}
|
||||||
|
roughness={0.22}
|
||||||
|
metalness={0.88}
|
||||||
|
emissive={brassEmissive}
|
||||||
|
emissiveIntensity={highlight ? 0.15 : 0}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={position}>
|
<group position={position}>
|
||||||
<mesh position={[0, DOOR_HEIGHT / 2, 0]} castShadow>
|
{/* Wall reveal — depth into opening */}
|
||||||
<boxGeometry args={[DOOR_WIDTH, DOOR_HEIGHT, 0.12]} />
|
<mesh position={[0, frameFullH / 2, 0.02]}>
|
||||||
|
<boxGeometry args={[openingW - 0.04, frameFullH - 0.04, WALL_THICKNESS - 0.04]} />
|
||||||
|
<meshStandardMaterial color="#1a1410" roughness={0.95} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Warm vestibule glow beyond doors */}
|
||||||
|
<pointLight position={[0, openingH * 0.55, 0.22]} intensity={highlight ? 1.1 : 0.65} distance={4.5} color={corridorGlow} />
|
||||||
|
<mesh position={[0, openingH * 0.55, 0.1]}>
|
||||||
|
<planeGeometry args={[openingW * 0.85, openingH * 0.9]} />
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
color={glow ? '#d4af37' : '#8b6914'}
|
color="#fff4e8"
|
||||||
emissive={glow ? '#5a4010' : '#000000'}
|
emissive="#ffdcb0"
|
||||||
emissiveIntensity={glow ? 0.45 : 0}
|
emissiveIntensity={highlight ? 0.35 : 0.18}
|
||||||
roughness={0.55}
|
roughness={0.9}
|
||||||
metalness={0.35}
|
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{/* Large invisible target — easier to click from anywhere in the hall */}
|
{/* Marble threshold */}
|
||||||
<mesh
|
<mesh position={[0, 0.025, faceZ + 0.04]} receiveShadow>
|
||||||
position={[0, DOOR_HEIGHT / 2, -0.12]}
|
<boxGeometry args={[frameW + 0.12, 0.05, 0.14]} />
|
||||||
rotation={[0, Math.PI, 0]}
|
<meshStandardMaterial color="#e8e4dc" roughness={0.28} metalness={0.08} />
|
||||||
onClick={handleActivate}
|
</mesh>
|
||||||
onPointerOver={() => setHovered(true)}
|
<mesh position={[0, 0.052, faceZ + 0.05]}>
|
||||||
onPointerOut={() => setHovered(false)}
|
<boxGeometry args={[frameW + 0.08, 0.006, 0.1]} />
|
||||||
>
|
<meshStandardMaterial color="#b8860b" roughness={0.3} metalness={0.75} />
|
||||||
<planeGeometry args={[DOOR_WIDTH * 3, DOOR_HEIGHT * 1.8]} />
|
|
||||||
<meshBasicMaterial transparent opacity={0} depthWrite={false} side={THREE.DoubleSide} />
|
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
<Text
|
{/* Door leaves */}
|
||||||
position={[0, DOOR_HEIGHT + 0.25, -0.06]}
|
{doorLeaf('left')}
|
||||||
|
{doorLeaf('right')}
|
||||||
|
|
||||||
|
{/* Center meeting stile / push bar */}
|
||||||
|
<mesh position={[0, leafY, faceZ + 0.042]}>
|
||||||
|
<boxGeometry args={[0.035, leafH * 0.72, 0.018]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={brass}
|
||||||
|
roughness={0.2}
|
||||||
|
metalness={0.9}
|
||||||
|
emissive={brassEmissive}
|
||||||
|
emissiveIntensity={highlight ? 0.2 : 0}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Side jambs */}
|
||||||
|
{([-1, 1] as const).map((sign) => (
|
||||||
|
<mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), frameFullH / 2, faceZ + 0.01]} castShadow>
|
||||||
|
<boxGeometry args={[jamb, frameFullH, 0.1]} />
|
||||||
|
<meshStandardMaterial color={woodDark} roughness={0.55} metalness={0.06} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Header lintel */}
|
||||||
|
<mesh position={[0, openingH + header / 2, faceZ + 0.01]} castShadow>
|
||||||
|
<boxGeometry args={[frameW, header, 0.1]} />
|
||||||
|
<meshStandardMaterial color={woodMid} roughness={0.58} metalness={0.05} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Transom — frosted museum glass */}
|
||||||
|
<mesh position={[0, openingH + header + transomH / 2, faceZ + 0.02]}>
|
||||||
|
<boxGeometry args={[openingW - 0.06, transomH - 0.06, 0.025]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#f5f0e8"
|
||||||
|
emissive={highlight ? '#fff0d8' : '#ffeacc'}
|
||||||
|
emissiveIntensity={highlight ? 0.25 : 0.12}
|
||||||
|
roughness={0.15}
|
||||||
|
metalness={0.05}
|
||||||
|
transparent
|
||||||
|
opacity={0.88}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
{/* Transom mullions */}
|
||||||
|
{[-0.35, 0, 0.35].map((ox) => (
|
||||||
|
<mesh key={ox} position={[ox, openingH + header + transomH / 2, faceZ + 0.035]}>
|
||||||
|
<boxGeometry args={[0.04, transomH - 0.1, 0.012]} />
|
||||||
|
<meshStandardMaterial color={woodDark} roughness={0.6} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Limestone surround */}
|
||||||
|
{([-1, 1] as const).map((sign) => (
|
||||||
|
<mesh
|
||||||
|
key={`sur-${sign}`}
|
||||||
|
position={[sign * (openingW / 2 + jamb + surround / 2), frameFullH / 2, faceZ - 0.01]}
|
||||||
|
castShadow
|
||||||
|
>
|
||||||
|
<boxGeometry args={[surround, frameFullH + 0.08, 0.08]} />
|
||||||
|
<meshStandardMaterial color={stone} roughness={0.88} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
<mesh position={[0, frameFullH + 0.04, faceZ - 0.01]} castShadow>
|
||||||
|
<boxGeometry args={[frameW + surround * 2, 0.12, 0.08]} />
|
||||||
|
<meshStandardMaterial color={stoneDark} roughness={0.82} metalness={0.08} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Crown on surround */}
|
||||||
|
<mesh position={[0, frameFullH + 0.12, faceZ - 0.005]}>
|
||||||
|
<boxGeometry args={[frameW + surround * 2 + 0.06, 0.08, 0.1]} />
|
||||||
|
<meshStandardMaterial color={stoneDark} roughness={0.5} metalness={0.2} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Brass EXIT plaque */}
|
||||||
|
<group position={[0, openingH + header + transomH + 0.18, faceZ - 0.02]}>
|
||||||
|
<mesh
|
||||||
|
onClick={handleActivate}
|
||||||
|
onPointerOver={setHover(true)}
|
||||||
|
onPointerOut={setHover(false)}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[0.55, 0.14, 0.018]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={brass}
|
||||||
|
roughness={0.25}
|
||||||
|
metalness={0.85}
|
||||||
|
emissive={brassEmissive}
|
||||||
|
emissiveIntensity={highlight ? 0.25 : 0.05}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
<Text
|
||||||
|
position={[0, 0, -0.012]}
|
||||||
|
rotation={[0, Math.PI, 0]}
|
||||||
|
fontSize={0.09}
|
||||||
|
color={highlight ? '#fff8e8' : '#3d2818'}
|
||||||
|
anchorX="center"
|
||||||
|
anchorY="middle"
|
||||||
|
onClick={handleActivate}
|
||||||
|
onPointerOver={setHover(true)}
|
||||||
|
onPointerOut={setHover(false)}
|
||||||
|
>
|
||||||
|
EXIT
|
||||||
|
</Text>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{/* Wall sconces */}
|
||||||
|
{([-1, 1] as const).map((sign) => (
|
||||||
|
<group key={`sconce-${sign}`} position={[sign * (frameW / 2 + surround + 0.18), openingH + 0.35, faceZ - 0.02]}>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[0.08, 0.22, 0.1]} />
|
||||||
|
<meshStandardMaterial color={brass} roughness={0.3} metalness={0.8} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, -0.08, -0.04]} rotation={[Math.PI / 2, 0, 0]}>
|
||||||
|
<coneGeometry args={[0.07, 0.12, 8, 1, true]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#fff4e0"
|
||||||
|
emissive="#ffdcb0"
|
||||||
|
emissiveIntensity={highlight ? 0.5 : 0.28}
|
||||||
|
roughness={0.4}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
<pointLight
|
||||||
|
position={[0, -0.12, -0.06]}
|
||||||
|
intensity={highlight ? 0.45 : 0.22}
|
||||||
|
distance={2.2}
|
||||||
|
color="#fff0d8"
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Hinges */}
|
||||||
|
{([-1, 1] as const).flatMap((side) =>
|
||||||
|
[0.35, 0.85, 1.35].map((hy) => (
|
||||||
|
<mesh
|
||||||
|
key={`hinge-${side}-${hy}`}
|
||||||
|
position={[side * (openingW / 2 + 0.02), hy, faceZ + 0.03]}
|
||||||
|
rotation={[0, 0, side === -1 ? 0 : Math.PI]}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[0.025, 0.1, 0.04]} />
|
||||||
|
<meshStandardMaterial color={brass} roughness={0.28} metalness={0.85} />
|
||||||
|
</mesh>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Large invisible click target */}
|
||||||
|
<mesh
|
||||||
|
position={[0, frameFullH / 2, faceZ - 0.15]}
|
||||||
rotation={[0, Math.PI, 0]}
|
rotation={[0, Math.PI, 0]}
|
||||||
fontSize={0.22}
|
|
||||||
color={glow ? '#f5e6c8' : '#c9a96e'}
|
|
||||||
anchorX="center"
|
|
||||||
onClick={handleActivate}
|
onClick={handleActivate}
|
||||||
onPointerOver={() => setHovered(true)}
|
onPointerOver={setHover(true)}
|
||||||
onPointerOut={() => setHovered(false)}
|
onPointerOut={setHover(false)}
|
||||||
>
|
>
|
||||||
EXIT
|
<planeGeometry args={[DOOR_WIDTH * 2.2, frameFullH * 1.15]} />
|
||||||
</Text>
|
<meshBasicMaterial transparent opacity={0} depthWrite={false} side={THREE.DoubleSide} />
|
||||||
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ParquetFloor({ width, depth }: { width: number; depth: number }) {
|
||||||
|
const floorW = width + 0.4;
|
||||||
|
const floorD = depth + 0.4;
|
||||||
|
const texture = useMemo(() => {
|
||||||
|
const map = createParquetFloorTexture();
|
||||||
|
map.repeat.set(floorW / PARQUET_METERS_PER_TILE, floorD / PARQUET_METERS_PER_TILE);
|
||||||
|
map.needsUpdate = true;
|
||||||
|
return map;
|
||||||
|
}, [floorW, floorD]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.002, 0]} receiveShadow>
|
||||||
|
<planeGeometry args={[floorW, floorD]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
map={texture}
|
||||||
|
roughness={0.38}
|
||||||
|
metalness={0.12}
|
||||||
|
envMapIntensity={0.55}
|
||||||
|
color="#c8a882"
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ArtistHall({
|
function ArtistHall({
|
||||||
layout,
|
layout,
|
||||||
artistName,
|
artistName,
|
||||||
|
movementColor,
|
||||||
|
imageRevisions,
|
||||||
onPaintingClick,
|
onPaintingClick,
|
||||||
onExitActivate,
|
onExitActivate,
|
||||||
nearExit,
|
nearExit,
|
||||||
}: {
|
}: {
|
||||||
layout: HallLayout;
|
layout: HallLayout;
|
||||||
artistName: string;
|
artistName: string;
|
||||||
|
movementColor?: string;
|
||||||
|
imageRevisions?: Record<number, number>;
|
||||||
onPaintingClick: (id: number) => void;
|
onPaintingClick: (id: number) => void;
|
||||||
onExitActivate: () => void;
|
onExitActivate: () => void;
|
||||||
nearExit: boolean;
|
nearExit: boolean;
|
||||||
@@ -707,52 +1097,56 @@ function ArtistHall({
|
|||||||
const { width, depth, segments } = layout;
|
const { width, depth, segments } = layout;
|
||||||
const halfW = width / 2;
|
const halfW = width / 2;
|
||||||
const halfD = depth / 2;
|
const halfD = depth / 2;
|
||||||
|
const walls = useMemo(() => galleryWallColors(movementColor), [movementColor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{/* Floor & ceiling — open centre, no furniture */}
|
{/* Floor & ceiling — open centre, no furniture */}
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]} receiveShadow>
|
<ParquetFloor width={width} depth={depth} />
|
||||||
<planeGeometry args={[width + 0.4, depth + 0.4]} />
|
|
||||||
<meshStandardMaterial color="#4a3020" roughness={0.78} />
|
|
||||||
</mesh>
|
|
||||||
<mesh rotation={[Math.PI / 2, 0, 0]} position={[0, WALL_HEIGHT, 0]}>
|
<mesh rotation={[Math.PI / 2, 0, 0]} position={[0, WALL_HEIGHT, 0]}>
|
||||||
<planeGeometry args={[width + 0.4, depth + 0.4]} />
|
<planeGeometry args={[width + 0.4, depth + 0.4]} />
|
||||||
<meshStandardMaterial color="#2a2018" roughness={0.9} />
|
<meshStandardMaterial color="#2a2018" roughness={0.9} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{/* Back wall */}
|
{/* Back wall */}
|
||||||
<GalleryWall position={[0, WALL_HEIGHT / 2, -halfD]} size={[width, WALL_HEIGHT]} color="#f0ebe3" />
|
<GalleryWall position={[0, WALL_HEIGHT / 2, -halfD]} size={[width, WALL_HEIGHT]} color={walls.main} />
|
||||||
{/* Left wall */}
|
{/* Left wall */}
|
||||||
<GalleryWall
|
<GalleryWall
|
||||||
position={[-halfW, WALL_HEIGHT / 2, 0]}
|
position={[-halfW, WALL_HEIGHT / 2, 0]}
|
||||||
size={[depth, WALL_HEIGHT]}
|
size={[depth, WALL_HEIGHT]}
|
||||||
rotation={[0, Math.PI / 2, 0]}
|
rotation={[0, Math.PI / 2, 0]}
|
||||||
color="#e8e2d8"
|
color={walls.side}
|
||||||
/>
|
/>
|
||||||
{/* Right wall */}
|
{/* Right wall */}
|
||||||
<GalleryWall
|
<GalleryWall
|
||||||
position={[halfW, WALL_HEIGHT / 2, 0]}
|
position={[halfW, WALL_HEIGHT / 2, 0]}
|
||||||
size={[depth, WALL_HEIGHT]}
|
size={[depth, WALL_HEIGHT]}
|
||||||
rotation={[0, Math.PI / 2, 0]}
|
rotation={[0, Math.PI / 2, 0]}
|
||||||
color="#e8e2d8"
|
color={walls.side}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Front wall — two segments with door gap */}
|
{/* Front wall — two segments with door gap */}
|
||||||
<GalleryWall
|
<GalleryWall
|
||||||
position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]}
|
position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]}
|
||||||
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
|
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
|
||||||
color="#f0ebe3"
|
color={walls.main}
|
||||||
/>
|
/>
|
||||||
<GalleryWall
|
<GalleryWall
|
||||||
position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]}
|
position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]}
|
||||||
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
|
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
|
||||||
color="#f0ebe3"
|
color={walls.main}
|
||||||
|
/>
|
||||||
|
{/* Wall section above door opening */}
|
||||||
|
<GalleryWall
|
||||||
|
position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, halfD]}
|
||||||
|
size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]}
|
||||||
|
color={walls.main}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Crown molding on back wall */}
|
{/* Crown molding on back wall */}
|
||||||
<mesh position={[0, WALL_HEIGHT - 0.08, -halfD + WALL_THICKNESS / 2]}>
|
<mesh position={[0, WALL_HEIGHT - 0.08, -halfD + WALL_THICKNESS / 2]}>
|
||||||
<boxGeometry args={[width, 0.14, 0.1]} />
|
<boxGeometry args={[width, 0.14, 0.1]} />
|
||||||
<meshStandardMaterial color="#b8956a" roughness={0.5} metalness={0.25} />
|
<meshStandardMaterial color={walls.trim} roughness={0.5} metalness={0.25} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
@@ -768,13 +1162,14 @@ function ArtistHall({
|
|||||||
<group key={`${seg.side}-${seg.label}`}>
|
<group key={`${seg.side}-${seg.label}`}>
|
||||||
{seg.paintings.map((painting, i) => (
|
{seg.paintings.map((painting, i) => (
|
||||||
<PaintingFrame
|
<PaintingFrame
|
||||||
key={painting.id}
|
key={`${painting.id}-${imageRevisions?.[painting.id] ?? 0}`}
|
||||||
painting={painting}
|
painting={painting}
|
||||||
position={seg.slots[i].position}
|
position={seg.slots[i].position}
|
||||||
rotationY={seg.slots[i].rotationY}
|
rotationY={seg.slots[i].rotationY}
|
||||||
maxWidth={seg.slots[i].maxW}
|
maxWidth={seg.slots[i].maxW}
|
||||||
maxHeight={seg.slots[i].maxH}
|
maxHeight={seg.slots[i].maxH}
|
||||||
wallSide={seg.slots[i].side}
|
wallSide={seg.slots[i].side}
|
||||||
|
imageRevision={imageRevisions?.[painting.id]}
|
||||||
onClick={() => onPaintingClick(painting.id)}
|
onClick={() => onPaintingClick(painting.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -800,10 +1195,15 @@ function ArtistHall({
|
|||||||
position={[0, 0, halfD - WALL_THICKNESS / 2 - 0.02]}
|
position={[0, 0, halfD - WALL_THICKNESS / 2 - 0.02]}
|
||||||
active={nearExit}
|
active={nearExit}
|
||||||
onActivate={onExitActivate}
|
onActivate={onExitActivate}
|
||||||
|
wallColor={walls.main}
|
||||||
|
trimColor={walls.trim}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<pointLight position={[0, WALL_HEIGHT - 0.4, 0]} intensity={0.5} color="#fff5e8" distance={width + depth} />
|
<pointLight position={[0, WALL_HEIGHT - 0.4, 0]} intensity={0.5} color="#fff5e8" distance={width + depth} />
|
||||||
<pointLight position={[0, WALL_HEIGHT - 0.4, -halfD / 2]} intensity={0.35} color="#fff0dd" distance={12} />
|
<pointLight position={[0, WALL_HEIGHT - 0.4, -halfD / 2]} intensity={0.35} color="#fff0dd" distance={Math.max(12, depth * 0.75)} />
|
||||||
|
{depth > 14 && (
|
||||||
|
<pointLight position={[0, WALL_HEIGHT - 0.4, -halfD + 1.2]} intensity={0.3} color="#fff0dd" distance={14} />
|
||||||
|
)}
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -900,6 +1300,7 @@ function NavigationPanel({
|
|||||||
|
|
||||||
export default function VirtualGallery({
|
export default function VirtualGallery({
|
||||||
data,
|
data,
|
||||||
|
imageRevisions,
|
||||||
active = true,
|
active = true,
|
||||||
onPaintingClick,
|
onPaintingClick,
|
||||||
onNavigateArtist,
|
onNavigateArtist,
|
||||||
@@ -916,6 +1317,12 @@ export default function VirtualGallery({
|
|||||||
const [nearExit, setNearExit] = useState(false);
|
const [nearExit, setNearExit] = useState(false);
|
||||||
const [isLooking, setIsLooking] = useState(false);
|
const [isLooking, setIsLooking] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setArtist(data.artist);
|
||||||
|
setPeriods(data.periods);
|
||||||
|
setPaintings(data.paintings);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -946,16 +1353,17 @@ export default function VirtualGallery({
|
|||||||
|
|
||||||
const layout = useMemo(() => buildHallLayout(paintings, periods), [paintings, periods]);
|
const layout = useMemo(() => buildHallLayout(paintings, periods), [paintings, periods]);
|
||||||
|
|
||||||
const halfW = layout.width / 2 - 0.8;
|
const halfW = layout.width / 2 - 0.55;
|
||||||
const halfD = layout.depth / 2 - 0.8;
|
const halfD = layout.depth / 2 - 0.35;
|
||||||
const exitZ = layout.depth / 2 - 0.6;
|
const exitZ = layout.depth / 2 - 0.55;
|
||||||
|
const fogFar = Math.max(55, layout.depth + 42);
|
||||||
|
|
||||||
const initialPos = useMemo(
|
const initialPos = useMemo(
|
||||||
() => new THREE.Vector3(0, EYE_HEIGHT, layout.depth / 2 - 2.2),
|
() => new THREE.Vector3(0, EYE_HEIGHT, layout.depth / 2 - 2.2),
|
||||||
[layout.depth]
|
[layout.depth]
|
||||||
);
|
);
|
||||||
const initialTarget = useMemo(
|
const initialTarget = useMemo(
|
||||||
() => new THREE.Vector3(0, EYE_HEIGHT, -layout.depth / 4),
|
() => new THREE.Vector3(0, EYE_HEIGHT, -layout.depth * (layout.depth > 14 ? 0.38 : 0.25)),
|
||||||
[layout.depth]
|
[layout.depth]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1141,7 +1549,7 @@ export default function VirtualGallery({
|
|||||||
camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}
|
camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}
|
||||||
>
|
>
|
||||||
<color attach="background" args={['#0d0906']} />
|
<color attach="background" args={['#0d0906']} />
|
||||||
<fog attach="fog" args={['#0d0906', 18, 55]} />
|
<fog attach="fog" args={['#0d0906', 18, fogFar]} />
|
||||||
<ambientLight intensity={0.42} />
|
<ambientLight intensity={0.42} />
|
||||||
<directionalLight position={[3, 9, 4]} intensity={0.65} castShadow shadow-mapSize={[1024, 1024]} />
|
<directionalLight position={[3, 9, 4]} intensity={0.65} castShadow shadow-mapSize={[1024, 1024]} />
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
@@ -1150,6 +1558,8 @@ export default function VirtualGallery({
|
|||||||
<ArtistHall
|
<ArtistHall
|
||||||
layout={layout}
|
layout={layout}
|
||||||
artistName={artist.name}
|
artistName={artist.name}
|
||||||
|
movementColor={artist.movement_color}
|
||||||
|
imageRevisions={imageRevisions}
|
||||||
onPaintingClick={onPaintingClick}
|
onPaintingClick={onPaintingClick}
|
||||||
onExitActivate={openExitNav}
|
onExitActivate={openExitNav}
|
||||||
nearExit={nearExit}
|
nearExit={nearExit}
|
||||||
|
|||||||
@@ -130,7 +130,10 @@ function SearchPreviewCell({
|
|||||||
return (
|
return (
|
||||||
<div className="checkup-thumb checkup-thumb-search" title={result.query}>
|
<div className="checkup-thumb checkup-thumb-search" title={result.query}>
|
||||||
<img
|
<img
|
||||||
src={debugImageProxyUrl(result.imageUrl)}
|
src={debugImageProxyUrl(result.imageUrl, {
|
||||||
|
searchUrl: result.searchUrl,
|
||||||
|
source: result.source,
|
||||||
|
})}
|
||||||
alt={`Search: ${result.query}`}
|
alt={`Search: ${result.query}`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
@@ -337,7 +340,11 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await api.fixPaintingImage(row.id, searchState.result.imageUrl);
|
const updated = await api.fixPaintingImage(row.id, searchState.result.imageUrl, {
|
||||||
|
searchUrl: searchState.result.searchUrl,
|
||||||
|
source: searchState.result.source,
|
||||||
|
thumbUrl: searchState.result.thumbUrl,
|
||||||
|
});
|
||||||
setImageVersionById((prev) => ({ ...prev, [row.id]: (prev[row.id] ?? 0) + 1 }));
|
setImageVersionById((prev) => ({ ...prev, [row.id]: (prev[row.id] ?? 0) + 1 }));
|
||||||
setRows((list) =>
|
setRows((list) =>
|
||||||
list.map((r) =>
|
list.map((r) =>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import VirtualGallery from '../components/VirtualGallery';
|
|||||||
import PaintingDetailView from '../components/PaintingDetail';
|
import PaintingDetailView from '../components/PaintingDetail';
|
||||||
import ArtistBio from '../components/ArtistBio';
|
import ArtistBio from '../components/ArtistBio';
|
||||||
import CheckupPage from '../pages/CheckupPage';
|
import CheckupPage from '../pages/CheckupPage';
|
||||||
import { api } from '../api/client';
|
import { api, type FixPaintingImageResult } from '../api/client';
|
||||||
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail } from '../types';
|
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail } from '../types';
|
||||||
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
|
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
|
||||||
import { readDebugMode, writeDebugMode } from '../utils/debugMode';
|
import { readDebugMode, writeDebugMode } from '../utils/debugMode';
|
||||||
@@ -18,6 +18,17 @@ type View =
|
|||||||
| { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View }
|
| { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View }
|
||||||
| { type: 'bio'; artistId: number; data: ArtistDetail; returnTo: View };
|
| { type: 'bio'; artistId: number; data: ArtistDetail; returnTo: View };
|
||||||
|
|
||||||
|
function patchPaintingInArtistDetail(
|
||||||
|
detail: ArtistDetail,
|
||||||
|
paintingId: number,
|
||||||
|
patch: Partial<Painting>
|
||||||
|
): ArtistDetail {
|
||||||
|
return {
|
||||||
|
...detail,
|
||||||
|
paintings: detail.paintings.map((p) => (p.id === paintingId ? { ...p, ...patch } : p)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [view, setView] = useState<View>({ type: 'timeline' });
|
const [view, setView] = useState<View>({ type: 'timeline' });
|
||||||
const [gallerySession, setGallerySession] = useState<{ artistId: number; data: ArtistDetail } | null>(
|
const [gallerySession, setGallerySession] = useState<{ artistId: number; data: ArtistDetail } | null>(
|
||||||
@@ -31,6 +42,7 @@ export default function HomePage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]);
|
const [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]);
|
||||||
|
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
|
||||||
const [debugMode, setDebugMode] = useState(readDebugMode);
|
const [debugMode, setDebugMode] = useState(readDebugMode);
|
||||||
const detailReturnToRef = useRef<View>({ type: 'timeline' });
|
const detailReturnToRef = useRef<View>({ type: 'timeline' });
|
||||||
|
|
||||||
@@ -88,42 +100,80 @@ export default function HomePage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePaintingImageFixed = useCallback(async (paintingId: number) => {
|
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
|
||||||
const data = await api.getPainting(paintingId);
|
const data = await api.getPainting(paintingId);
|
||||||
setView((current) =>
|
const patch: Partial<Painting> = {
|
||||||
current.type === 'painting' && current.paintingId === paintingId
|
image_path: fixResult.imagePath ?? data.painting.image_path,
|
||||||
? { ...current, data }
|
thumbnail_path: fixResult.thumbnailPath ?? data.painting.thumbnail_path,
|
||||||
: current
|
checkup_checked: fixResult.checked ?? true,
|
||||||
);
|
checkup_fixed: fixResult.fixed ?? true,
|
||||||
|
};
|
||||||
|
const updatedData: PaintingDetail = {
|
||||||
|
...data,
|
||||||
|
painting: { ...data.painting, ...patch },
|
||||||
|
};
|
||||||
|
|
||||||
|
setImageRevisions((prev) => ({ ...prev, [paintingId]: (prev[paintingId] ?? 0) + 1 }));
|
||||||
|
|
||||||
|
setView((current) => {
|
||||||
|
if (current.type !== 'painting' || current.paintingId !== paintingId) return current;
|
||||||
|
let returnTo = current.returnTo;
|
||||||
|
if (returnTo.type === 'gallery' && returnTo.artistId === data.painting.artist_id) {
|
||||||
|
returnTo = {
|
||||||
|
...returnTo,
|
||||||
|
data: patchPaintingInArtistDetail(returnTo.data, paintingId, patch),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...current, data: updatedData, returnTo };
|
||||||
|
});
|
||||||
|
|
||||||
setDetailArtistPaintings((list) =>
|
setDetailArtistPaintings((list) =>
|
||||||
list.map((p) =>
|
list.map((p) => (p.id === paintingId ? { ...p, ...patch } : p))
|
||||||
p.id === paintingId
|
|
||||||
? { ...p, image_path: data.painting.image_path, thumbnail_path: data.painting.thumbnail_path }
|
|
||||||
: p
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
if (gallerySession?.artistId === data.painting.artist_id) {
|
|
||||||
|
setGallerySession((session) =>
|
||||||
|
session && session.artistId === data.painting.artist_id
|
||||||
|
? { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) }
|
||||||
|
: session
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePaintingCheckupFlagsUpdated = useCallback(
|
||||||
|
async (paintingId: number, flags: { checked: boolean; fixed: boolean }) => {
|
||||||
|
const data = await api.getPainting(paintingId);
|
||||||
|
const patch: Partial<Painting> = {
|
||||||
|
checkup_checked: flags.checked,
|
||||||
|
checkup_fixed: flags.fixed,
|
||||||
|
};
|
||||||
|
const updatedData: PaintingDetail = {
|
||||||
|
...data,
|
||||||
|
painting: { ...data.painting, ...patch },
|
||||||
|
};
|
||||||
|
|
||||||
|
setView((current) => {
|
||||||
|
if (current.type !== 'painting' || current.paintingId !== paintingId) return current;
|
||||||
|
let returnTo = current.returnTo;
|
||||||
|
if (returnTo.type === 'gallery' && returnTo.artistId === data.painting.artist_id) {
|
||||||
|
returnTo = {
|
||||||
|
...returnTo,
|
||||||
|
data: patchPaintingInArtistDetail(returnTo.data, paintingId, patch),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...current, data: updatedData, returnTo };
|
||||||
|
});
|
||||||
|
|
||||||
|
setDetailArtistPaintings((list) =>
|
||||||
|
list.map((p) => (p.id === paintingId ? { ...p, ...patch } : p))
|
||||||
|
);
|
||||||
|
|
||||||
setGallerySession((session) =>
|
setGallerySession((session) =>
|
||||||
session
|
session && session.artistId === data.painting.artist_id
|
||||||
? {
|
? { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) }
|
||||||
...session,
|
|
||||||
data: {
|
|
||||||
...session.data,
|
|
||||||
paintings: session.data.paintings.map((p) =>
|
|
||||||
p.id === paintingId
|
|
||||||
? {
|
|
||||||
...p,
|
|
||||||
image_path: data.painting.image_path,
|
|
||||||
thumbnail_path: data.painting.thumbnail_path,
|
|
||||||
}
|
|
||||||
: p
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: session
|
: session
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
}, [gallerySession]);
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleArtistClick = async (artistId: number) => {
|
const handleArtistClick = async (artistId: number) => {
|
||||||
try {
|
try {
|
||||||
@@ -210,6 +260,7 @@ export default function HomePage() {
|
|||||||
<div className={galleryActive ? undefined : 'gallery-session-suspended'} aria-hidden={!galleryActive}>
|
<div className={galleryActive ? undefined : 'gallery-session-suspended'} aria-hidden={!galleryActive}>
|
||||||
<VirtualGallery
|
<VirtualGallery
|
||||||
data={gallerySession.data}
|
data={gallerySession.data}
|
||||||
|
imageRevisions={imageRevisions}
|
||||||
active={galleryActive}
|
active={galleryActive}
|
||||||
onPaintingClick={handlePaintingClick}
|
onPaintingClick={handlePaintingClick}
|
||||||
onNavigateArtist={handleArtistClick}
|
onNavigateArtist={handleArtistClick}
|
||||||
@@ -224,7 +275,18 @@ export default function HomePage() {
|
|||||||
<PaintingDetailView
|
<PaintingDetailView
|
||||||
data={view.data}
|
data={view.data}
|
||||||
artistPaintings={sortedDetailArtistPaintings}
|
artistPaintings={sortedDetailArtistPaintings}
|
||||||
onBack={() => setView(view.returnTo)}
|
onBack={() => {
|
||||||
|
const returnTo = view.returnTo;
|
||||||
|
if (returnTo.type === 'gallery' && gallerySession?.artistId === returnTo.artistId) {
|
||||||
|
setView({
|
||||||
|
type: 'gallery',
|
||||||
|
artistId: gallerySession.artistId,
|
||||||
|
data: gallerySession.data,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setView(returnTo);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onPaintingClick={handlePaintingClick}
|
onPaintingClick={handlePaintingClick}
|
||||||
onCatalogNavigate={handleCatalogNavigate}
|
onCatalogNavigate={handleCatalogNavigate}
|
||||||
onArtistBio={async () => {
|
onArtistBio={async () => {
|
||||||
@@ -234,6 +296,7 @@ export default function HomePage() {
|
|||||||
onInfluenceArtistClick={handleArtistClick}
|
onInfluenceArtistClick={handleArtistClick}
|
||||||
debugMode={debugMode}
|
debugMode={debugMode}
|
||||||
onPaintingImageFixed={handlePaintingImageFixed}
|
onPaintingImageFixed={handlePaintingImageFixed}
|
||||||
|
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export interface Painting {
|
|||||||
sort_order: number;
|
sort_order: number;
|
||||||
artist_name?: string;
|
artist_name?: string;
|
||||||
has_influence_links?: boolean | string | number;
|
has_influence_links?: boolean | string | number;
|
||||||
|
checkup_checked?: boolean;
|
||||||
|
checkup_fixed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InfluenceSourceType = 'painting' | 'artist' | 'movement';
|
export type InfluenceSourceType = 'painting' | 'artist' | 'movement';
|
||||||
@@ -99,7 +101,7 @@ export interface PaintingDetail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ArtistDetail {
|
export interface ArtistDetail {
|
||||||
artist: Artist & { movement_name: string };
|
artist: Artist & { movement_name: string; movement_color?: string };
|
||||||
periods: ArtistPeriod[];
|
periods: ArtistPeriod[];
|
||||||
paintings: Painting[];
|
paintings: Painting[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
/** Warm oak tones with subtle variation — museum-style herringbone parquet. */
|
||||||
|
const OAK_TONES = ['#6b4a2e', '#735234', '#624428', '#7a5638', '#5c3f26', '#6e4d30'];
|
||||||
|
|
||||||
|
function shade(hex: string, amount: number): string {
|
||||||
|
const n = parseInt(hex.slice(1), 16);
|
||||||
|
const r = Math.min(255, Math.max(0, ((n >> 16) & 255) + amount));
|
||||||
|
const g = Math.min(255, Math.max(0, ((n >> 8) & 255) + amount));
|
||||||
|
const b = Math.min(255, Math.max(0, (n & 255) + amount));
|
||||||
|
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPlank(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
length: number,
|
||||||
|
width: number,
|
||||||
|
angle: number,
|
||||||
|
color: string
|
||||||
|
) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(cx, cy);
|
||||||
|
ctx.rotate(angle);
|
||||||
|
|
||||||
|
const grad = ctx.createLinearGradient(-length / 2, 0, length / 2, 0);
|
||||||
|
grad.addColorStop(0, shade(color, -12));
|
||||||
|
grad.addColorStop(0.35, color);
|
||||||
|
grad.addColorStop(0.65, shade(color, 6));
|
||||||
|
grad.addColorStop(1, shade(color, -8));
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.fillRect(-length / 2, -width / 2, length, width);
|
||||||
|
|
||||||
|
// Fine grain lines
|
||||||
|
ctx.strokeStyle = 'rgba(0,0,0,0.12)';
|
||||||
|
ctx.lineWidth = 0.6;
|
||||||
|
for (let g = -length / 2 + 4; g < length / 2; g += 7) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(g, -width / 2 + 1);
|
||||||
|
ctx.lineTo(g + 3, width / 2 - 1);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plank edge / grout
|
||||||
|
ctx.strokeStyle = 'rgba(18,10,4,0.55)';
|
||||||
|
ctx.lineWidth = 1.1;
|
||||||
|
ctx.strokeRect(-length / 2, -width / 2, length, width);
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintHerringbone(ctx: CanvasRenderingContext2D, size: number) {
|
||||||
|
ctx.fillStyle = '#2a1810';
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
|
||||||
|
const plankLen = 56;
|
||||||
|
const plankW = 14;
|
||||||
|
const step = plankLen * 0.707; // cos(45°) spacing for herringbone unit
|
||||||
|
|
||||||
|
const cols = Math.ceil(size / step) + 3;
|
||||||
|
const rows = Math.ceil(size / step) + 3;
|
||||||
|
|
||||||
|
for (let row = -1; row < rows; row++) {
|
||||||
|
for (let col = -1; col < cols; col++) {
|
||||||
|
const px = col * step;
|
||||||
|
const py = row * step;
|
||||||
|
const tone = OAK_TONES[(row * 5 + col * 7 + size) % OAK_TONES.length];
|
||||||
|
|
||||||
|
if ((row + col) % 2 === 0) {
|
||||||
|
drawPlank(ctx, px, py, plankLen, plankW, Math.PI / 4, tone);
|
||||||
|
drawPlank(ctx, px + step * 0.5, py + step * 0.5, plankLen, plankW, Math.PI / 4, shade(tone, -4));
|
||||||
|
} else {
|
||||||
|
drawPlank(ctx, px + step * 0.25, py + step * 0.25, plankLen, plankW, -Math.PI / 4, tone);
|
||||||
|
drawPlank(ctx, px + step * 0.75, py + step * 0.75, plankLen, plankW, -Math.PI / 4, shade(tone, 5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft polish sheen
|
||||||
|
const sheen = ctx.createLinearGradient(0, 0, size, size);
|
||||||
|
sheen.addColorStop(0, 'rgba(255,240,210,0.06)');
|
||||||
|
sheen.addColorStop(0.5, 'rgba(255,255,255,0)');
|
||||||
|
sheen.addColorStop(1, 'rgba(255,230,200,0.04)');
|
||||||
|
ctx.fillStyle = sheen;
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedTexture: THREE.CanvasTexture | null = null;
|
||||||
|
|
||||||
|
/** Seamless herringbone parquet — shared across gallery halls. */
|
||||||
|
export function createParquetFloorTexture(): THREE.CanvasTexture {
|
||||||
|
if (cachedTexture) return cachedTexture;
|
||||||
|
|
||||||
|
const size = 512;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('Could not create parquet canvas');
|
||||||
|
|
||||||
|
paintHerringbone(ctx, size);
|
||||||
|
|
||||||
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
|
texture.wrapS = THREE.RepeatWrapping;
|
||||||
|
texture.wrapT = THREE.RepeatWrapping;
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
texture.anisotropy = 8;
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
|
||||||
|
cachedTexture = texture;
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Meters covered by one texture repeat — tune for plank scale in the hall. */
|
||||||
|
export const PARQUET_METERS_PER_TILE = 2.8;
|
||||||
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 14 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 576 KiB |
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 936 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 936 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 936 KiB After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 549 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 3.1 MiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 607 KiB |
|
Before Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 708 KiB |
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 3.1 MiB After Width: | Height: | Size: 398 KiB |
|
Before Width: | Height: | Size: 3.1 MiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 568 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 568 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 568 KiB After Width: | Height: | Size: 599 KiB |
|
Before Width: | Height: | Size: 568 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 3.6 MiB After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 559 KiB |
|
Before Width: | Height: | Size: 427 KiB After Width: | Height: | Size: 643 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 21 KiB |
@@ -4,6 +4,8 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const USER_AGENT = 'VirtualArtGallery/1.0 (educational art history project; local museum gallery)';
|
const USER_AGENT = 'VirtualArtGallery/1.0 (educational art history project; local museum gallery)';
|
||||||
|
const BROWSER_USER_AGENT =
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||||
|
|
||||||
const WIKI_LANGS = ['de', 'fr', 'it', 'ru'];
|
const WIKI_LANGS = ['de', 'fr', 'it', 'ru'];
|
||||||
|
|
||||||
@@ -200,17 +202,20 @@ async function throttle() {
|
|||||||
lastRequestTime = Date.now();
|
lastRequestTime = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchBuffer(url, redirectCount = 0, referer = null) {
|
function fetchBuffer(url, redirectCount = 0, referer = null, options = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (redirectCount > 8) return reject(new Error('Too many redirects'));
|
if (redirectCount > 8) return reject(new Error('Too many redirects'));
|
||||||
const client = url.startsWith('https') ? https : http;
|
const client = url.startsWith('https') ? https : http;
|
||||||
|
const useBrowser = options.browser !== false && (options.browser === true || !!referer);
|
||||||
const headers = {
|
const headers = {
|
||||||
'User-Agent': USER_AGENT,
|
'User-Agent': useBrowser ? BROWSER_USER_AGENT : USER_AGENT,
|
||||||
Accept: 'image/*,*/*',
|
Accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
};
|
};
|
||||||
if (referer) headers.Referer = referer;
|
if (referer) headers.Referer = referer;
|
||||||
if (url.includes('wikimedia.org') || url.includes('wikipedia.org')) {
|
if (url.includes('wikimedia.org') || url.includes('wikipedia.org')) {
|
||||||
headers.Referer = 'https://commons.wikimedia.org/';
|
headers.Referer = 'https://commons.wikimedia.org/';
|
||||||
|
headers['User-Agent'] = USER_AGENT;
|
||||||
}
|
}
|
||||||
if (url.includes('artic.edu')) {
|
if (url.includes('artic.edu')) {
|
||||||
headers.Referer = 'https://www.artic.edu/';
|
headers.Referer = 'https://www.artic.edu/';
|
||||||
@@ -221,15 +226,15 @@ function fetchBuffer(url, redirectCount = 0, referer = null) {
|
|||||||
if (url.includes('hermitagemuseum.org') || url.includes('pushkinmuseum.art')) {
|
if (url.includes('hermitagemuseum.org') || url.includes('pushkinmuseum.art')) {
|
||||||
headers.Referer = url.split('/').slice(0, 3).join('/') + '/';
|
headers.Referer = url.split('/').slice(0, 3).join('/') + '/';
|
||||||
}
|
}
|
||||||
if (url.includes('googleusercontent.com') || url.includes('artsandculture.google.com')) {
|
if (url.includes('googleusercontent.com') || url.includes('gstatic.com') || url.includes('artsandculture.google.com')) {
|
||||||
headers.Referer = 'https://artsandculture.google.com/';
|
headers.Referer = headers.Referer || 'https://artsandculture.google.com/';
|
||||||
}
|
}
|
||||||
const req = client.get(url, { headers }, (res) => {
|
const req = client.get(url, { headers }, (res) => {
|
||||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
const next = res.headers.location.startsWith('http')
|
const next = res.headers.location.startsWith('http')
|
||||||
? res.headers.location
|
? res.headers.location
|
||||||
: new URL(res.headers.location, url).href;
|
: new URL(res.headers.location, url).href;
|
||||||
return resolve(fetchBuffer(next, redirectCount + 1, referer));
|
return resolve(fetchBuffer(next, redirectCount + 1, referer, options));
|
||||||
}
|
}
|
||||||
if (res.statusCode === 429) {
|
if (res.statusCode === 429) {
|
||||||
res.resume();
|
res.resume();
|
||||||
@@ -1182,6 +1187,109 @@ async function downloadImageToFile(url, destPath, { force = false } = {}) {
|
|||||||
return destPath;
|
return destPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function upgradeWikimediaToFull(url) {
|
||||||
|
const thumbMatch = url.match(
|
||||||
|
/^(https?:\/\/upload\.wikimedia\.org\/wikipedia\/(?:commons|en)\/)thumb\/(.+)\/\d+px-[^/]+$/
|
||||||
|
);
|
||||||
|
if (thumbMatch) return `${thumbMatch[1]}${thumbMatch[2]}`;
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareDownloadCandidates(url, context = {}) {
|
||||||
|
const candidates = [url];
|
||||||
|
if (context.thumbUrl && context.thumbUrl !== url) candidates.push(context.thumbUrl);
|
||||||
|
const google = upgradeGoogleUserContentUrl(url, FULL_WIDTH);
|
||||||
|
if (google !== url) candidates.push(google);
|
||||||
|
const wiki = upgradeWikimediaToFull(url);
|
||||||
|
if (wiki !== url) candidates.push(wiki);
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.search) candidates.push(`${parsed.origin}${parsed.pathname}`);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return [...new Set(candidates)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function referersForDownload(url, context = {}) {
|
||||||
|
const refs = [];
|
||||||
|
if (context.pageUrl) refs.push(context.pageUrl);
|
||||||
|
if (context.searchUrl) refs.push(context.searchUrl);
|
||||||
|
if (context.source === 'google-arts-culture') refs.push('https://artsandculture.google.com/');
|
||||||
|
if (context.source === 'google-images' || context.source === 'google-custom-search') {
|
||||||
|
refs.push('https://www.google.com/');
|
||||||
|
}
|
||||||
|
if (context.source === 'duckduckgo-images') refs.push('https://duckduckgo.com/');
|
||||||
|
if (/googleusercontent\.com|gstatic\.com/i.test(url)) refs.push('https://artsandculture.google.com/');
|
||||||
|
if (/wikimedia\.org|wikipedia\.org/i.test(url)) refs.push('https://commons.wikimedia.org/');
|
||||||
|
try {
|
||||||
|
refs.push(new URL(url).origin + '/');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return [...new Set(refs)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download with browser headers, referer fallbacks, and URL variants (fix / debug). */
|
||||||
|
async function downloadImageForFix(url, destPath, context = {}) {
|
||||||
|
const candidates = prepareDownloadCandidates(url, context);
|
||||||
|
const referers = referersForDownload(url, context);
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
await throttle();
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
for (const referer of referers) {
|
||||||
|
try {
|
||||||
|
const buffer = await fetchBuffer(candidate, 0, referer, { browser: true });
|
||||||
|
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
||||||
|
fs.writeFileSync(destPath, buffer);
|
||||||
|
return destPath;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
await sleep(300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error('Download failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchImageBuffer(url, context = {}) {
|
||||||
|
const candidates = prepareDownloadCandidates(url, context);
|
||||||
|
const referers = referersForDownload(url, context);
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
await throttle();
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
for (const referer of referers) {
|
||||||
|
try {
|
||||||
|
return await fetchBuffer(candidate, 0, referer, { browser: true });
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
await sleep(300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error('Download failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendlyImageFetchError(err) {
|
||||||
|
const msg = err?.message || '';
|
||||||
|
if (/ECONNRESET|ETIMEDOUT|EPIPE|socket hang up/i.test(msg)) {
|
||||||
|
return 'Could not download this image — the host closed the connection. Try Search again; Wikimedia and Google Arts results work best.';
|
||||||
|
}
|
||||||
|
if (/HTTP 403|HTTP 401|HTTP 429/i.test(msg)) {
|
||||||
|
return 'Could not download this image — the host blocked the request. Try Search again or pick a different preview.';
|
||||||
|
}
|
||||||
|
if (/Too many redirects/i.test(msg)) {
|
||||||
|
return 'Could not download this image — too many redirects from the image URL.';
|
||||||
|
}
|
||||||
|
return msg || 'Could not download image';
|
||||||
|
}
|
||||||
|
|
||||||
function decodeGoogleEmbeddedUrl(raw) {
|
function decodeGoogleEmbeddedUrl(raw) {
|
||||||
return raw
|
return raw
|
||||||
.replace(/\\u003d/g, '=')
|
.replace(/\\u003d/g, '=')
|
||||||
@@ -1361,11 +1469,6 @@ async function searchGoogleImagesFirst(artistName, paintingTitle) {
|
|||||||
return { query, imageUrl: null, searchUrl, source: 'google-images', sourceLabel: 'Google Images' };
|
return { query, imageUrl: null, searchUrl, source: 'google-images', sourceLabel: 'Google Images' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchImageBuffer(url) {
|
|
||||||
await throttle();
|
|
||||||
return withRetry(() => fetchBuffer(url));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateThumbnailFromFull(fullDest, thumbDest, width = THUMB_WIDTH) {
|
async function generateThumbnailFromFull(fullDest, thumbDest, width = THUMB_WIDTH) {
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
if (!fs.existsSync(fullDest)) return false;
|
if (!fs.existsSync(fullDest)) return false;
|
||||||
@@ -1493,11 +1596,13 @@ module.exports = {
|
|||||||
saveImageForItem,
|
saveImageForItem,
|
||||||
savePaintingImages,
|
savePaintingImages,
|
||||||
downloadImageToFile,
|
downloadImageToFile,
|
||||||
|
downloadImageForFix,
|
||||||
generateThumbnailFromFull,
|
generateThumbnailFromFull,
|
||||||
searchWikipediaTitle,
|
searchWikipediaTitle,
|
||||||
searchWebForPaintingImages,
|
searchWebForPaintingImages,
|
||||||
searchGoogleImagesFirst,
|
searchGoogleImagesFirst,
|
||||||
fetchImageBuffer,
|
fetchImageBuffer,
|
||||||
|
friendlyImageFetchError,
|
||||||
pickExt,
|
pickExt,
|
||||||
getGoogleArtsCultureImages,
|
getGoogleArtsCultureImages,
|
||||||
simplifyPaintingTitle,
|
simplifyPaintingTitle,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const path = require('path');
|
|||||||
const pool = require('./db');
|
const pool = require('./db');
|
||||||
const {
|
const {
|
||||||
savePaintingImages,
|
savePaintingImages,
|
||||||
downloadImageToFile,
|
downloadImageForFix,
|
||||||
generateThumbnailFromFull,
|
generateThumbnailFromFull,
|
||||||
pickExt,
|
pickExt,
|
||||||
} = require('../scripts/image-fetcher');
|
} = require('../scripts/image-fetcher');
|
||||||
@@ -154,7 +154,7 @@ async function ensurePaintingImages(paintingId, size = 'thumb') {
|
|||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replacePaintingImageFromUrl(paintingId, imageUrl) {
|
async function replacePaintingImageFromUrl(paintingId, imageUrl, context = {}) {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name
|
`SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name
|
||||||
FROM paintings p
|
FROM paintings p
|
||||||
@@ -182,7 +182,7 @@ async function replacePaintingImageFromUrl(paintingId, imageUrl) {
|
|||||||
unlinkIfExists(fullDest);
|
unlinkIfExists(fullDest);
|
||||||
unlinkIfExists(thumbDest);
|
unlinkIfExists(thumbDest);
|
||||||
|
|
||||||
await downloadImageToFile(imageUrl, fullDest, { force: true });
|
await downloadImageForFix(imageUrl, fullDest, context);
|
||||||
|
|
||||||
let thumbnailPath = null;
|
let thumbnailPath = null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ require('dotenv').config();
|
|||||||
|
|
||||||
const pool = require('./db');
|
const pool = require('./db');
|
||||||
const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, IMAGE_DIR } = require('./image-service');
|
const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, IMAGE_DIR } = require('./image-service');
|
||||||
const { searchGoogleImagesFirst, fetchImageBuffer, pickExt } = require('../scripts/image-fetcher');
|
const { searchGoogleImagesFirst, fetchImageBuffer, friendlyImageFetchError, pickExt } = require('../scripts/image-fetcher');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
@@ -240,7 +240,7 @@ app.get('/api/artists/:id', async (req, res) => {
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const [artist, periods, paintings] = await Promise.all([
|
const [artist, periods, paintings] = await Promise.all([
|
||||||
pool.query(
|
pool.query(
|
||||||
`SELECT a.*, m.name as movement_name
|
`SELECT a.*, m.name as movement_name, m.color as movement_color
|
||||||
FROM artists a
|
FROM artists a
|
||||||
LEFT JOIN art_movements m ON a.movement_id = m.id
|
LEFT JOIN art_movements m ON a.movement_id = m.id
|
||||||
WHERE a.id = $1`,
|
WHERE a.id = $1`,
|
||||||
@@ -252,6 +252,8 @@ app.get('/api/artists/:id', async (req, res) => {
|
|||||||
),
|
),
|
||||||
pool.query(
|
pool.query(
|
||||||
`SELECT p.*,
|
`SELECT p.*,
|
||||||
|
p.checkup_checked,
|
||||||
|
p.checkup_fixed,
|
||||||
(${INFLUENCE_LINKS_EXISTS}) AS has_influence_links
|
(${INFLUENCE_LINKS_EXISTS}) AS has_influence_links
|
||||||
FROM paintings p
|
FROM paintings p
|
||||||
WHERE p.artist_id = $1
|
WHERE p.artist_id = $1
|
||||||
@@ -477,12 +479,17 @@ app.get('/api/paintings/:id/debug-image-search', async (req, res) => {
|
|||||||
app.post('/api/paintings/:id/fix-image', async (req, res) => {
|
app.post('/api/paintings/:id/fix-image', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const paintingId = parseInt(req.params.id, 10);
|
const paintingId = parseInt(req.params.id, 10);
|
||||||
const imageUrl = req.body?.imageUrl;
|
const { imageUrl, searchUrl, source, pageUrl, thumbUrl } = req.body ?? {};
|
||||||
if (!imageUrl || typeof imageUrl !== 'string' || !/^https?:\/\//i.test(imageUrl)) {
|
if (!imageUrl || typeof imageUrl !== 'string' || !/^https?:\/\//i.test(imageUrl)) {
|
||||||
return res.status(400).json({ error: 'Valid imageUrl required' });
|
return res.status(400).json({ error: 'Valid imageUrl required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await replacePaintingImageFromUrl(paintingId, imageUrl);
|
const updated = await replacePaintingImageFromUrl(paintingId, imageUrl, {
|
||||||
|
searchUrl: typeof searchUrl === 'string' ? searchUrl : undefined,
|
||||||
|
source: typeof source === 'string' ? source : undefined,
|
||||||
|
pageUrl: typeof pageUrl === 'string' ? pageUrl : undefined,
|
||||||
|
thumbUrl: typeof thumbUrl === 'string' ? thumbUrl : undefined,
|
||||||
|
});
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE paintings SET checkup_fixed = true, checkup_checked = true WHERE id = $1`,
|
`UPDATE paintings SET checkup_fixed = true, checkup_checked = true WHERE id = $1`,
|
||||||
[paintingId]
|
[paintingId]
|
||||||
@@ -490,7 +497,7 @@ app.post('/api/paintings/:id/fix-image', async (req, res) => {
|
|||||||
res.json({ ...updated, fixed: true, checked: true });
|
res.json({ ...updated, fixed: true, checked: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fix image error:', err.message);
|
console.error('Fix image error:', err.message);
|
||||||
res.status(500).json({ error: err.message || 'Fix image failed' });
|
res.status(500).json({ error: friendlyImageFetchError(err) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -498,11 +505,16 @@ app.post('/api/paintings/:id/fix-image', async (req, res) => {
|
|||||||
app.get('/api/debug/image-proxy', async (req, res) => {
|
app.get('/api/debug/image-proxy', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const imageUrl = req.query.url;
|
const imageUrl = req.query.url;
|
||||||
|
const searchUrl = req.query.searchUrl;
|
||||||
|
const source = req.query.source;
|
||||||
if (!imageUrl || typeof imageUrl !== 'string' || !/^https?:\/\//i.test(imageUrl)) {
|
if (!imageUrl || typeof imageUrl !== 'string' || !/^https?:\/\//i.test(imageUrl)) {
|
||||||
return res.status(400).json({ error: 'Valid url query required' });
|
return res.status(400).json({ error: 'Valid url query required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = await fetchImageBuffer(imageUrl);
|
const buffer = await fetchImageBuffer(imageUrl, {
|
||||||
|
searchUrl: typeof searchUrl === 'string' ? searchUrl : undefined,
|
||||||
|
source: typeof source === 'string' ? source : undefined,
|
||||||
|
});
|
||||||
const ext = pickExt(imageUrl).toLowerCase();
|
const ext = pickExt(imageUrl).toLowerCase();
|
||||||
const type =
|
const type =
|
||||||
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
|
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
|
||||||
|
|||||||