Improve timeline UX and add catalog restore scripts for paintings and portraits.
Load the home-page catalog once with a lightweight artists API, batch pan/zoom updates per frame, use dynamic year labels, and speed up movement-flow zoom. Add sync-image-paths and fetch-artist-images plus docs for the post-seed pipeline.
@@ -65,9 +65,12 @@ Artists for timeline portraits and the movement flow diagram.
|
||||
| `start` | int | Only artists alive after this year |
|
||||
| `end` | int | Only artists born before this year |
|
||||
| `movement_id` | int | Filter by movement |
|
||||
| `timeline` | bool | When `1` or `true`, return a lightweight row set for the home-page timeline (omits `bio_full` and other heavy fields) |
|
||||
|
||||
**Response** — array of artist objects with joined `movement_name` and `movement_color`.
|
||||
|
||||
The React home page loads the timeline catalog **once** on mount via `GET /api/bounds`, `GET /api/timeline?start=…&end=…` (full range), and `GET /api/artists?timeline=1`. Pan and zoom filter movements and portraits **client-side** — no refetch per view change. Rapid pan/zoom is batched with `createViewChangeScheduler()` (one React update per animation frame).
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/movements/:id/artists`
|
||||
@@ -581,6 +584,7 @@ The React client wraps these endpoints in `client/src/api/client.ts`:
|
||||
| `api.getBounds()` | `GET /api/bounds` |
|
||||
| `api.getTimeline(start, end)` | `GET /api/timeline` |
|
||||
| `api.getArtists(...)` | `GET /api/artists` |
|
||||
| `api.getTimelineArtists()` | `GET /api/artists?timeline=1` |
|
||||
| `api.getArtist(id)` | `GET /api/artists/:id` |
|
||||
| `api.getArtistNavigation(id)` | `GET /api/artists/:id/navigation` |
|
||||
| `api.getPainting(id)` | `GET /api/paintings/:id` |
|
||||
|
||||
@@ -53,6 +53,10 @@ Gallery/
|
||||
│ │ └── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
|
||||
│ └── dist/ # Production build (served by API when present)
|
||||
├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools
|
||||
│ ├── seed-wikipedia.js
|
||||
│ ├── seed-catalog-data.js
|
||||
│ ├── sync-image-paths.js
|
||||
│ ├── fetch-artist-images.js
|
||||
│ ├── fetch-artist-bios.js
|
||||
│ ├── expand-paintings.js
|
||||
│ ├── famous-paintings-data.js
|
||||
@@ -92,7 +96,7 @@ npm run dev:server # API on PORT from .env (3520 production, 3001 typical dev)
|
||||
npm run dev:client # Vite on :5173, proxies /api and /images to PORT
|
||||
```
|
||||
|
||||
Use the Vite URL during frontend work for HMR.
|
||||
Use the Vite URL during frontend work for HMR. When the public domain is proxied to Vite (see [`deploy/nginx-gallery.conf`](../deploy/nginx-gallery.conf)), both `dev:server` and `dev:client` must stay running or visitors see **503**.
|
||||
|
||||
## User navigation flow
|
||||
|
||||
@@ -132,12 +136,26 @@ The home page shows two linked views over the **same year window** (`viewStart`
|
||||
| Era bar | `Timeline.tsx` | Historical eras, major event markers, click-to-zoom |
|
||||
| Movement flow | `MovementBands.tsx` | Curved streams per movement, lineage branches, artist portraits |
|
||||
|
||||
Both views share zoom/pan behaviour via `client/src/utils/timelineView.ts`. The home page uses a **fixed viewport** (`100vh`): timeline + movement flow sit in a shared `home-timeline-stack` so event guide lines can extend from the era bar down through the movement canvas. The movement flow compresses vertically so all movements in the visible year range fit without page scrolling.
|
||||
Both views share zoom/pan behaviour via `client/src/utils/timelineView.ts` (`zoomTimelineView`, `panTimelineView`, `chooseTimelineTickInterval`, `createViewChangeScheduler`). The home page uses a **fixed viewport** (`100vh`): timeline + movement flow sit in a shared `home-timeline-stack` so event guide lines can extend from the era bar down through the movement canvas. The movement flow compresses vertically so all movements in the visible year range fit without page scrolling.
|
||||
|
||||
### Timeline data loading
|
||||
|
||||
On first visit, `HomePage.tsx` fetches the full catalog once:
|
||||
|
||||
1. `GET /api/bounds` — initialise the year range.
|
||||
2. `GET /api/timeline?start=…&end=…` — all eras and movements for that range.
|
||||
3. `GET /api/artists?timeline=1` — lightweight artist rows (portraits, lifespan, movement colour; no full biography text).
|
||||
|
||||
Pan, zoom, and era/event click-to-zoom only update **local** `viewStart` / `viewEnd` state. `MovementBands.tsx` and `Timeline.tsx` filter what is visible for the current window — they do not trigger new API calls. The “Loading art history…” message appears only until the first successful load completes.
|
||||
|
||||
View updates are **batched to one commit per animation frame** via `createViewChangeScheduler()` in `timelineView.ts` (`HomePage.tsx` → `handleViewChange`), so rapid scroll-wheel events do not flood React with separate renders.
|
||||
|
||||
### Timeline year labels
|
||||
|
||||
Year ticks along the bottom of the era bar use **large, high-contrast** labels (bold cream text with shadow). The active range in the control row (e.g. `1400 CE — 1900 CE`) uses the same stronger styling.
|
||||
|
||||
Label density is **dynamic**: `chooseTimelineTickInterval()` in `timelineView.ts` picks a “nice” step (1, 2, 5, 10, … 5000 years) from the visible span and measured bar width so labels stay ~76 px apart. Zoomed-out overviews show fewer dates; zooming in reveals finer steps automatically.
|
||||
|
||||
### Timeline controls
|
||||
|
||||
| Input | Action |
|
||||
@@ -165,7 +183,7 @@ Each visible movement is drawn as a **portrait-width curved stream** (~54 px str
|
||||
| Vertical depth | Successor movements sit on rows below their deepest parent; sibling movements at the same depth are spread into lanes to limit overlap |
|
||||
| Branch connectors | Smooth curves from the **centre** of a parent stream to the **centre** of each child stream (siblings fan out along the parent’s length) |
|
||||
| Visual blending | Path-aligned SVG gradients with transparent fades at stream ends and branch junctions; streams draw on top of branches so overlap brightness stays uniform |
|
||||
| Filtering | Same rule as the API: only movements with at least one artist active in the visible year range |
|
||||
| Filtering | Same rule as the API: only movements with at least one artist active in the visible year range (filtered client-side after initial load) |
|
||||
| Viewport layout | Row height and stream width scale from measured canvas size so every visible movement row fits in the remaining screen space |
|
||||
|
||||
### Artists on movement streams
|
||||
@@ -184,12 +202,14 @@ Each artist appears as a **portrait circle** on their movement’s stream row:
|
||||
|
||||
| Input | Action |
|
||||
|-------|--------|
|
||||
| Scroll wheel on flow canvas | Zoom (same range as timeline) |
|
||||
| Scroll wheel anywhere on flow canvas | Zoom (same year range as timeline; works over portraits and labels too) |
|
||||
| Drag on flow canvas | Pan |
|
||||
| Click portrait | Open artist biography |
|
||||
| Click **movement name** (label on stream) | Open **movement gallery** for that movement |
|
||||
|
||||
Artist portraits stop wheel/drag propagation so zooming over a face does not fight portrait clicks. Hovering a portrait highlights the artist’s lifespan on the era bar and brightens their segment on the movement stream.
|
||||
**Zoom/pan performance:** The movement canvas uses a **capture-phase** wheel listener so scroll zoom works even when the cursor is over a portrait. While scrolling or dragging, a short **interaction mode** (~120 ms after the last input) draws simplified solid SVG strokes and temporarily hides portrait/label DOM so zoom stays responsive; full gradients and portraits return when you stop.
|
||||
|
||||
Only **mousedown** on portraits and movement labels stops propagation (so drag-to-pan does not start when clicking them). Hovering a portrait highlights the artist’s lifespan on the era bar and brightens their segment on the movement stream.
|
||||
|
||||
**Note:** Movement lineage is **frontend curation** for layout and labels — it is not stored in PostgreSQL. Painting influence links live in **`painting_influence_sources`** (paintings, artists, or movements as sources). The API reads that table for detail panels, hall navigation, and `has_influence_links`. The legacy **`painting_influences`** table is still written in parallel when curators add painting-to-painting edges but is not queried for display.
|
||||
|
||||
@@ -309,6 +329,8 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens
|
||||
## 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 catalog** loads once from the API; pan/zoom is client-side only, with per-frame batching via `createViewChangeScheduler()`.
|
||||
- **Movement flow interaction** uses simplified SVG and hides portrait DOM during active scroll/drag so zoom stays responsive over dense portrait fields.
|
||||
- **Movement filtering** on zoom only shows movements that have at least one artist active in the visible year range.
|
||||
- **Movement lineage** (`movement-lineage.ts`) documents art-historical predecessor→successor links for the flow diagram; extend that file to add or correct branches.
|
||||
- **One hall per artist** keeps navigation predictable: enter from the timeline or bio, leave via the single exit or back button.
|
||||
|
||||
@@ -26,11 +26,14 @@ File names are sanitised `{Artist}_{Title}.{ext}`. The image service can redisco
|
||||
|
||||
| Script | npm command | Role |
|
||||
|--------|-------------|------|
|
||||
| `seed-wikipedia.js` | `npm run seed` | Initial eras, movements, artists, paintings, influences |
|
||||
| `seed-wikipedia.js` | `npm run seed` | Initial eras, movements, artists, one flagship painting per artist |
|
||||
| `seed-catalog-data.js` | *(data only)* | Eras, movements, artist metadata consumed by seed |
|
||||
| `sync-image-paths.js` | `npm run sync-image-paths` | Import painting rows from disk; set `image_path` / `thumbnail_path` |
|
||||
| `fetch-artist-images.js` | `npm run fetch-artist-images` | Download or link artist portraits under `data/images/portraits/` |
|
||||
| `fetch-artist-bios.js` | `npm run fetch-artist-bios` | Wikipedia intros → `bio_short` / `bio_full` |
|
||||
| `famous-paintings-data.js` | *(data only)* | Curated list of notable works per artist |
|
||||
| `expand-paintings.js` | `npm run expand-catalog` | Inserts works from data file for thin catalogs |
|
||||
| `art-influences-data.js` | *(data only)* | Curated painting-to-painting influence edges |
|
||||
| `art-influences-data.js` | *(data only)* | Curated influence edges (painting / artist / movement) |
|
||||
| `update-influences.js` | `npm run update-influences` | Applies influence graph; creates missing artists/works |
|
||||
| `fetch-missing-images.js` | `npm run fetch-images` | Downloads files for paintings missing on disk |
|
||||
| `image-fetcher.js` | *(library)* | Wikimedia / museum resolution used by fetch scripts and API |
|
||||
@@ -42,23 +45,26 @@ File names are sanitised `{Artist}_{Title}.{ext}`. The image service can redisco
|
||||
## Typical workflow
|
||||
|
||||
```text
|
||||
migrate → seed → fetch-artist-bios → expand-catalog → update-influences → fetch-images (per artist or batch) → build client
|
||||
migrate → seed → sync-image-paths → fetch-artist-images → fetch-artist-bios → expand-catalog → update-influences → fetch-images (per artist or batch) → build client
|
||||
```
|
||||
|
||||
1. **Seed** creates the base catalog (often one flagship painting per modern artist).
|
||||
2. **fetch-artist-bios** fills biography fields for every artist with a `wikipedia_title`.
|
||||
3. **expand-catalog** brings each artist up to at least **6** notable works (configurable via `MIN_PAINTINGS`).
|
||||
4. **fetch-images** downloads artwork files; the 3D gallery needs local files for reliable textures.
|
||||
1. **Seed** creates the base catalog (one flagship painting per artist; ~100 artists).
|
||||
2. **sync-image-paths** imports additional paintings when `data/images/paintings/` already contains files from a full clone (filename pattern `{Artist}_{Title}.jpg`).
|
||||
3. **fetch-artist-images** sets `portrait_path` from local files or Wikipedia.
|
||||
4. **fetch-artist-bios** fills biography fields for every artist with a `wikipedia_title`.
|
||||
5. **expand-catalog** brings each artist up to at least **6** notable works (configurable via `MIN_PAINTINGS`).
|
||||
6. **update-influences** loads the influence graph (*Influenced By* / *Influenced* panels, 3D hall lamps, exit navigation).
|
||||
7. **fetch-images** downloads artwork files still missing on disk; the 3D gallery needs local files for reliable textures.
|
||||
|
||||
## Seeding pipeline
|
||||
|
||||
`npm run seed` runs `scripts/seed-wikipedia.js`, which:
|
||||
|
||||
1. Inserts **historical eras** and **art movements** (curated date ranges and colours).
|
||||
1. Inserts **historical eras** and **art movements** (curated date ranges and colours from `seed-catalog-data.js`).
|
||||
2. For each curated **artist**:
|
||||
- Creates **artist periods** and **paintings**.
|
||||
- May download portraits and painting images (depending on seed script version).
|
||||
3. Writes **painting_influences** edges from curated scholarship references (mirrored into `painting_influence_sources` when you run `npm run migrate:influence-sources` and `npm run update-influences`).
|
||||
- Creates **artist periods** and one **flagship painting**.
|
||||
- May download portraits and painting images when run with `--fetch-images`.
|
||||
3. Does **not** insert influence edges — run `npm run update-influences` after seed (see [Painting influence graph](#painting-influence-graph)).
|
||||
|
||||
Those influence edges power **3D hall navigation** and painting detail panels via **`painting_influence_sources`** (see `GET /api/artists/:id/navigation` and `GET /api/paintings/:id` in [API.md](API.md)).
|
||||
|
||||
@@ -111,7 +117,44 @@ To add more works, append entries to `famous-paintings-data.js`:
|
||||
|
||||
`wikipedia_title` is optional; it defaults to `title`. Use it when the Wikipedia article name differs from the display title.
|
||||
|
||||
Renaissance and medieval masters with large museum catalog dumps (e.g. Raphael, Dürer) are usually above the minimum already; expansion targets Impressionists, modernists, and other artists who had only a single seed painting.
|
||||
Renaissance and medieval masters with large museum catalog dumps (e.g. Raphael, Dürer) are usually above the minimum already when **`sync-image-paths`** has imported files from disk; expansion targets Impressionists, modernists, and other artists who had only a single seed painting.
|
||||
|
||||
## Importing paintings from disk
|
||||
|
||||
When the repository includes a full `data/images/paintings/` tree but the database was seeded fresh (one row per artist), run:
|
||||
|
||||
```bash
|
||||
npm run sync-image-paths
|
||||
```
|
||||
|
||||
`scripts/sync-image-paths.js`:
|
||||
|
||||
1. Scans `data/images/paintings/` for full-size files (not `thumbs/`).
|
||||
2. Matches filenames to artists using the same `{Artist}_{Title}` sanitisation as `server/image-service.js`.
|
||||
3. **Updates** `image_path` / `thumbnail_path` on existing rows when files are found.
|
||||
4. **Inserts** missing painting rows for files not yet in the catalog.
|
||||
|
||||
Flags:
|
||||
|
||||
- `--dry-run` — report counts only, no DB writes.
|
||||
|
||||
Safe to re-run; already-imported works are skipped by normalized title matching.
|
||||
|
||||
Typical result on a full clone: ~1,000+ paintings linked from ~1,000 on-disk files.
|
||||
|
||||
## Artist portraits
|
||||
|
||||
`npm run fetch-artist-images` runs `scripts/fetch-artist-images.js`:
|
||||
|
||||
1. For each artist, checks `data/images/portraits/{Artist}.jpg` (or other extensions) and sets `portrait_path` when a local file exists.
|
||||
2. Otherwise downloads from Wikipedia / search fallbacks via `image-fetcher.js`.
|
||||
|
||||
Flags:
|
||||
|
||||
- `--force` — re-fetch even when `portrait_path` is already set.
|
||||
- `--limit=N` — process only the first N artists needing portraits.
|
||||
|
||||
Run after seed when portrait files exist on disk but the DB still has null `portrait_path` values.
|
||||
|
||||
## Painting influence graph
|
||||
|
||||
@@ -216,6 +259,8 @@ Wing layout (up to 55 works per wing, side-wall-only hang, window gap placement)
|
||||
| Storage | TypeScript module in the client — **not** a database table |
|
||||
| Format | `{ id, name, startYear, endYear?, shortLabel? }` — omit `endYear` for a single-year pin |
|
||||
| Interaction | Click a marker to zoom the shared timeline/movement view to that period |
|
||||
| Year axis labels | Dynamic density in `Timeline.tsx` via `chooseTimelineTickInterval()` — fewer labels when zoomed out |
|
||||
| Pan/zoom batching | `createViewChangeScheduler()` in `timelineView.ts` — one React update per animation frame |
|
||||
| Vertical guides | `TimelineEventGuides.tsx` draws faint gold lines (or shaded spans) from the marker row down through the movement flow, aligned to the same year scale |
|
||||
|
||||
Edit `HISTORICAL_EVENTS` and rebuild the client to extend the set.
|
||||
|
||||
@@ -55,7 +55,7 @@ Or step by step:
|
||||
|
||||
```bash
|
||||
npm run migrate # db/schema.sql + db/migrate-*.sql via server/migrate.js
|
||||
npm run seed # eras, movements, artists, paintings, influences
|
||||
npm run seed # eras, movements, artists, flagship paintings (one per artist)
|
||||
```
|
||||
|
||||
`npm run migrate` is safe to re-run on existing databases (uses `IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS`).
|
||||
@@ -67,6 +67,8 @@ If migration fails with permission errors, grant schema rights to the app user f
|
||||
After a fresh seed, run these to match a fully populated local install:
|
||||
|
||||
```bash
|
||||
npm run sync-image-paths # import paintings from data/images/paintings/ (clone with image files)
|
||||
npm run fetch-artist-images # link local portraits or download from Wikipedia
|
||||
npm run fetch-artist-bios # bio_short / bio_full from Wikipedia
|
||||
npm run expand-catalog # famous works for artists below MIN_PAINTINGS
|
||||
npm run update-influences # painting influence graph for detail view + hall exits
|
||||
@@ -79,6 +81,8 @@ npm run fetch-images -- --limit=50 --max-wait=120 # same batch size, longer look
|
||||
cd client && npm run build && cd ..
|
||||
```
|
||||
|
||||
**Order matters:** `sync-image-paths` should run when the repo already contains painting files under `data/images/paintings/` but the database only has one flagship work per artist (typical after `npm run setup` on a clone). `update-influences` is required for *Influenced By* / *Influenced* panels and golden lamps in the 3D hall — seed does not insert influence edges.
|
||||
|
||||
Image fetch can take hours if you run it for the entire catalog. The first line of each run reports **`Missing local files: N`**. Use **`npm run fetch-images -- --limit=N`** for random batches (10s per painting by default), **`--artist="…"`** for one artist in catalog order, or on-demand resolution when viewing a painting in the detail view.
|
||||
|
||||
## Run
|
||||
@@ -139,9 +143,9 @@ Or use the systemd unit in [`deploy/gallery.service`](../deploy/gallery.service)
|
||||
|
||||
Point `gallery.mysuperlab.netcraze.pro` at the host running the app. Example nginx config: [`deploy/nginx-gallery.conf`](../deploy/nginx-gallery.conf).
|
||||
|
||||
**Development (default in repo):** nginx forwards to **Vite on `127.0.0.1:5173`**. Run both `npm run dev:server` (API on `3520`) and `npm run dev:client` (`5173`). Vite proxies `/api` and `/images` to the API.
|
||||
**Development (default in repo):** nginx forwards to **Vite on `127.0.0.1:5173`**. Run both `npm run dev:server` (API on `3520`) and `npm run dev:client` (`5173`). Vite proxies `/api` and `/images` to the API. If either process stops, the public hostname may return **503** (reverse proxy cannot reach upstream).
|
||||
|
||||
**Production (built SPA):** change nginx `proxy_pass` to `http://127.0.0.1:3520` after `npm run build` and `npm run start` — Node serves `client/dist` and the API on one port.
|
||||
**Production (built SPA):** change nginx `proxy_pass` to `http://127.0.0.1:3520` after `npm run build` and `npm run start` — Node serves `client/dist` and the API on one port. Prefer the systemd unit in [`deploy/gallery.service`](../deploy/gallery.service) so the process restarts automatically.
|
||||
|
||||
Keep `TRUST_PROXY=true` in `.env` so Express sees the correct client IP and scheme.
|
||||
|
||||
@@ -168,8 +172,10 @@ Allow inbound **TCP 3520** on the gallery host if clients reach it directly on t
|
||||
| `npm run regenerate-thumbnails` | `scripts/regenerate-thumbnails.js` | Rebuild all thumbs from full local files |
|
||||
| `npm run audit-painting-images` | `scripts/audit-painting-images.js` | List thumb/full aspect-ratio mismatches |
|
||||
| `npm run migrate:thumbnails` | `db/migrate-thumbnails.sql` | Add thumbnail columns |
|
||||
| `npm run fetch-artist-images` | `scripts/fetch-artist-images.js` | Backfill portrait files *(if present)* |
|
||||
| `npm run sync-image-paths` | `scripts/sync-image-paths.js` | Align DB paths with disk *(if present)* |
|
||||
| `npm run fetch-artist-images` | `scripts/fetch-artist-images.js` | Download portraits or link existing files under `data/images/portraits/` |
|
||||
| `npm run fetch-artist-images -- --force` | ↑ | Re-fetch even when `portrait_path` is set |
|
||||
| `npm run sync-image-paths` | `scripts/sync-image-paths.js` | Link `image_path` / `thumbnail_path` and import missing painting rows from disk |
|
||||
| `npm run sync-image-paths -- --dry-run` | ↑ | Report only, no DB writes |
|
||||
| `npm run migrate:influence-sources` | `scripts/migrate-influence-sources.js` | Create `painting_influence_sources` + backfill legacy edges |
|
||||
| `npm run migrate:checkup-flags` | `scripts/migrate-checkup-flags.js` | Add `checkup_checked` / `checkup_fixed` on `paintings` |
|
||||
| `npm run migrate:artist-checkup-flags` | `scripts/migrate-artist-checkup-flags.js` | Add `checkup_checked` / `checkup_fixed` on `artists` (bio debug) |
|
||||
@@ -192,7 +198,10 @@ Allow inbound **TCP 3520** on the gallery host if clients reach it directly on t
|
||||
|
||||
These are checked in and maintained:
|
||||
|
||||
- `seed-wikipedia.js`, `seed-catalog-data.js` — initial catalog (`npm run seed`)
|
||||
- `image-fetcher.js` — Wikimedia / museum image resolution
|
||||
- `fetch-artist-images.js` — artist portrait download / disk linking
|
||||
- `sync-image-paths.js` — import paintings and align paths from `data/images/paintings/`
|
||||
- `fetch-artist-bios.js` — artist biographies
|
||||
- `expand-paintings.js` + `famous-paintings-data.js` — catalog expansion
|
||||
- `update-influences.js` + `art-influences-data.js` — influence graph (paintings, artists, movements)
|
||||
@@ -207,8 +216,6 @@ These are checked in and maintained:
|
||||
- `migrate-painting-annotations.js`, `update-painting-annotations.js`, `painting-annotations-data.js` — art-history notes on painting detail
|
||||
- `regenerate-thumbnails.js`, `audit-painting-images.js`
|
||||
|
||||
These are referenced in `package.json` but may need to be restored from git history if missing locally: `fetch-artist-images.js`, `sync-image-paths.js`.
|
||||
|
||||
See [data-and-images.md](data-and-images.md) for pipeline details and override maps.
|
||||
|
||||
## Remote repository
|
||||
@@ -226,9 +233,15 @@ After clone: copy `.env.example` → `.env`, install dependencies, run `npm run
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---------|--------------|-----|
|
||||
| Empty timeline | DB not seeded | `npm run seed` |
|
||||
| 503 on public URL | Vite or API not running behind nginx | Start `npm run dev:server` + `npm run dev:client`, or switch nginx to production `:3520` |
|
||||
| 500 on all `/api/*` | Wrong `.env` or Postgres down | Check connection, logs |
|
||||
| “Biographical information not yet available” | Bios not fetched | `npm run fetch-artist-bios` |
|
||||
| Artist hall has only 1–2 paintings | Catalog not expanded | `npm run expand-catalog`; extend `famous-paintings-data.js` |
|
||||
| Placeholder portraits on timeline | `portrait_path` not set | `npm run fetch-artist-images` |
|
||||
| Artist hall has only 1–2 paintings | DB not expanded / disk not imported | `npm run sync-image-paths` then `npm run expand-catalog`; extend `famous-paintings-data.js` |
|
||||
| Movement flow shows “Loading art history…” repeatedly | Stale client refetching on every pan/zoom | Pull latest client; timeline loads catalog once — hard-refresh |
|
||||
| Movement flow slow on first load | Large artist payload | Client uses `GET /api/artists?timeline=1` (no full bios); rebuild client |
|
||||
| Movement flow zoom sluggish / dead over portraits | Stale client build | Rebuild client — wheel uses capture listener + interaction-mode rendering; hard-refresh |
|
||||
| Timeline year labels overlap when zoomed out | Stale client | Rebuild client — `chooseTimelineTickInterval()` adapts step to span and bar width |
|
||||
| Black frames / canvas covers in 3D gallery | No local image for painting | `npm run fetch-images -- --limit=50` or `--artist="…"`; then `POST …/preload-images` |
|
||||
| Many `⏱ timeout` lines in fetch batch | Default 10s cap too short for hard works | `--max-wait=120` or raise `FETCH_MAX_WAIT_SEC` |
|
||||
| White/grey flicker on frames | Texture loading or z-fighting with wall | Rebuild client (`cd client && npm run build`); ensure latest `VirtualGallery.tsx` |
|
||||
|
||||
@@ -37,9 +37,11 @@ Interactive virtual art gallery: zoomable historical timeline with era click-to-
|
||||
4. Enrich the catalog (recommended after seed):
|
||||
|
||||
```bash
|
||||
npm run fetch-artist-bios # Wikipedia biographies for all artists
|
||||
npm run expand-catalog # add famous works for artists with thin catalogs
|
||||
npm run update-influences # art-history lineage links between paintings
|
||||
npm run sync-image-paths # import paintings from data/images/paintings/ when present
|
||||
npm run fetch-artist-images # link or download artist portraits
|
||||
npm run fetch-artist-bios # Wikipedia biographies for all artists
|
||||
npm run expand-catalog # add famous works for artists with thin catalogs
|
||||
npm run update-influences # art-history lineage links (detail panels + 3D hall)
|
||||
npm run migrate:artist-palette # optional: JSONB column for PainterPalette enrichment
|
||||
npm run import-painter-palette # optional: metadata + influence links from Inputs/PainterPalette.csv
|
||||
npm run migrate:checkup-flags # review/fixed flags for Checkup + debug mode (paintings)
|
||||
@@ -70,3 +72,5 @@ Run API and Vite dev server separately:
|
||||
npm run dev:server # API — PORT from .env (3520 or 3001)
|
||||
npm run dev:client # http://localhost:5173 (proxies /api and /images)
|
||||
```
|
||||
|
||||
Both processes must run when the public domain is proxied to Vite (`deploy/nginx-gallery.conf`).
|
||||
|
||||
@@ -174,6 +174,9 @@ export const api = {
|
||||
return fetchJson<Artist[]>(`${API}/artists?${params}`);
|
||||
},
|
||||
|
||||
/** Lightweight artist rows for the timeline (no biography text). */
|
||||
getTimelineArtists: () => fetchJson<Artist[]>(`${API}/artists?timeline=1`),
|
||||
|
||||
getArtist: (id: number) => fetchJson<ArtistDetail>(`${API}/artists/${id}`),
|
||||
|
||||
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(`${API}/movements/${id}/gallery`),
|
||||
|
||||
@@ -87,6 +87,26 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.movements-flow-canvas.movements-flow-interacting {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.movements-flow-canvas.movements-flow-interacting.movements-flow-panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.movement-branch-fast {
|
||||
stroke-opacity: 0.32;
|
||||
stroke-width: calc(var(--stream-stroke) * 0.45);
|
||||
}
|
||||
|
||||
.movement-stream-fast {
|
||||
stroke-opacity: 0.42;
|
||||
stroke-width: var(--stream-stroke);
|
||||
stroke-linecap: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.movement-branch {
|
||||
stroke-width: var(--stream-stroke);
|
||||
stroke-linecap: round;
|
||||
|
||||
@@ -379,45 +379,74 @@ export default function MovementBands({
|
||||
}: Props) {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const [panning, setPanning] = useState(false);
|
||||
const [interacting, setInteracting] = useState(false);
|
||||
const [canvasHeight, setCanvasHeight] = useState(DEFAULT_CANVAS_HEIGHT);
|
||||
const [canvasWidth, setCanvasWidth] = useState(800);
|
||||
const [hoveredArtistKey, setHoveredArtistKey] = useState<string | null>(null);
|
||||
const panStart = useRef({ x: 0, viewStart: 0, viewEnd: 0 });
|
||||
const interactionTimer = useRef<number | null>(null);
|
||||
const viewRef = useRef({ viewStart, viewEnd });
|
||||
const onViewChangeRef = useRef(onViewChange);
|
||||
viewRef.current = { viewStart, viewEnd };
|
||||
onViewChangeRef.current = onViewChange;
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
const markInteracting = useCallback(() => {
|
||||
setInteracting(true);
|
||||
if (interactionTimer.current != null) {
|
||||
window.clearTimeout(interactionTimer.current);
|
||||
}
|
||||
interactionTimer.current = window.setTimeout(() => {
|
||||
interactionTimer.current = null;
|
||||
setInteracting(false);
|
||||
}, 120);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (interactionTimer.current != null) window.clearTimeout(interactionTimer.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = canvasRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
markInteracting();
|
||||
const rect = el.getBoundingClientRect();
|
||||
const { viewStart: vs, viewEnd: ve } = viewRef.current;
|
||||
const next = zoomTimelineView(
|
||||
e.clientX,
|
||||
rect.left,
|
||||
rect.width,
|
||||
e.deltaY,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
vs,
|
||||
ve,
|
||||
absoluteMin,
|
||||
absoluteMax
|
||||
);
|
||||
onViewChange(next.start, next.end);
|
||||
},
|
||||
[viewStart, viewEnd, absoluteMin, absoluteMax, onViewChange]
|
||||
);
|
||||
onViewChangeRef.current(next.start, next.end);
|
||||
};
|
||||
|
||||
el.addEventListener('wheel', onWheel, { passive: false, capture: true });
|
||||
return () => el.removeEventListener('wheel', onWheel, { capture: true });
|
||||
}, [absoluteMin, absoluteMax, markInteracting]);
|
||||
|
||||
const handlePanStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
setPanning(true);
|
||||
markInteracting();
|
||||
panStart.current = { x: e.clientX, viewStart, viewEnd };
|
||||
},
|
||||
[viewStart, viewEnd]
|
||||
[viewStart, viewEnd, markInteracting]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!panning) return;
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
markInteracting();
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const dx = e.clientX - panStart.current.x;
|
||||
@@ -429,7 +458,7 @@ export default function MovementBands({
|
||||
absoluteMin,
|
||||
absoluteMax
|
||||
);
|
||||
onViewChange(next.start, next.end);
|
||||
onViewChangeRef.current(next.start, next.end);
|
||||
};
|
||||
|
||||
const onUp = () => setPanning(false);
|
||||
@@ -439,7 +468,7 @@ export default function MovementBands({
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [panning, absoluteMin, absoluteMax, onViewChange]);
|
||||
}, [panning, absoluteMin, absoluteMax, markInteracting]);
|
||||
|
||||
const artistsByMovement = useMemo(() => {
|
||||
const map = new Map<number, Artist[]>();
|
||||
@@ -616,18 +645,26 @@ export default function MovementBands({
|
||||
};
|
||||
}, [visibleMovements, movements, viewStart, viewEnd, canvasHeight]);
|
||||
|
||||
const artistPlacements = useMemo(
|
||||
() =>
|
||||
buildArtistPlacements(
|
||||
layouts,
|
||||
artistsByMovement,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
portraitSizePx,
|
||||
canvasWidth
|
||||
),
|
||||
[layouts, artistsByMovement, viewStart, viewEnd, portraitSizePx, canvasWidth]
|
||||
);
|
||||
const artistPlacements = useMemo(() => {
|
||||
if (interacting || panning) return [];
|
||||
return buildArtistPlacements(
|
||||
layouts,
|
||||
artistsByMovement,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
portraitSizePx,
|
||||
canvasWidth
|
||||
);
|
||||
}, [
|
||||
layouts,
|
||||
artistsByMovement,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
portraitSizePx,
|
||||
canvasWidth,
|
||||
interacting,
|
||||
panning,
|
||||
]);
|
||||
|
||||
const hoveredPlacement = useMemo(() => {
|
||||
if (!hoveredArtistKey) return null;
|
||||
@@ -647,6 +684,7 @@ export default function MovementBands({
|
||||
}
|
||||
|
||||
const layoutById = new Map(layouts.map((l) => [l.movement.id, l]));
|
||||
const fastGraphics = interacting || panning;
|
||||
|
||||
return (
|
||||
<div className="movements-flow">
|
||||
@@ -656,12 +694,11 @@ export default function MovementBands({
|
||||
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className={`movements-flow-canvas${panning ? ' movements-flow-panning' : ''}`}
|
||||
className={`movements-flow-canvas${panning ? ' movements-flow-panning' : ''}${fastGraphics ? ' movements-flow-interacting' : ''}`}
|
||||
style={{
|
||||
['--stream-stroke' as string]: `${streamStrokePx}px`,
|
||||
['--portrait-size' as string]: `${portraitSizePx}px`,
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handlePanStart}
|
||||
>
|
||||
<svg
|
||||
@@ -670,111 +707,141 @@ export default function MovementBands({
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
{branches.map((branch) => (
|
||||
<linearGradient
|
||||
key={`branch-grad-${branch.key}`}
|
||||
id={`branch-grad-${branch.key}`}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1={branch.x1}
|
||||
y1={branch.y1}
|
||||
x2={branch.x2}
|
||||
y2={branch.y2}
|
||||
>
|
||||
<stop offset="0%" stopColor={branch.colorFrom} stopOpacity={0} />
|
||||
<stop offset="18%" stopColor={branch.colorFrom} stopOpacity={0.34} />
|
||||
<stop offset="50%" stopColor={branch.colorTo} stopOpacity={0.34} />
|
||||
<stop offset="82%" stopColor={branch.colorTo} stopOpacity={0.34} />
|
||||
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
))}
|
||||
|
||||
{layouts.map((layout) => {
|
||||
const primaryParent =
|
||||
layout.parentIds.length > 0 ? layoutById.get(layout.parentIds[0]) : null;
|
||||
const hasChildren = (childIdsByParent.get(layout.movement.id)?.length ?? 0) > 0;
|
||||
const parentColor = primaryParent?.movement.color ?? layout.movement.color;
|
||||
|
||||
return (
|
||||
{!fastGraphics && (
|
||||
<defs>
|
||||
{branches.map((branch) => (
|
||||
<linearGradient
|
||||
key={`grad-${layout.movement.id}`}
|
||||
id={`stream-grad-${layout.movement.id}`}
|
||||
key={`branch-grad-${branch.key}`}
|
||||
id={`branch-grad-${branch.key}`}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1={layout.xStart}
|
||||
y1={layout.y}
|
||||
x2={layout.xEnd}
|
||||
y2={layout.yEnd}
|
||||
x1={branch.x1}
|
||||
y1={branch.y1}
|
||||
x2={branch.x2}
|
||||
y2={branch.y2}
|
||||
>
|
||||
{primaryParent ? (
|
||||
<>
|
||||
<stop offset="0%" stopColor={parentColor} stopOpacity={0.28} />
|
||||
<stop offset="16%" stopColor={layout.movement.color} stopOpacity={0.34} />
|
||||
<stop offset="32%" stopColor={layout.movement.color} stopOpacity={0.38} />
|
||||
</>
|
||||
) : (
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={layout.movement.color}
|
||||
stopOpacity={layout.movement.start_definite ? 0.28 : 0.08}
|
||||
/>
|
||||
)}
|
||||
<stop offset="50%" stopColor={layout.movement.color} stopOpacity={0.38} />
|
||||
{hasChildren ? (
|
||||
<>
|
||||
<stop offset="68%" stopColor={layout.movement.color} stopOpacity={0.38} />
|
||||
<stop offset="84%" stopColor={layout.movement.color} stopOpacity={0.22} />
|
||||
<stop offset="100%" stopColor={layout.movement.color} stopOpacity={0} />
|
||||
</>
|
||||
) : (
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={layout.movement.color}
|
||||
stopOpacity={layout.movement.end_definite ? 0.28 : 0.08}
|
||||
/>
|
||||
)}
|
||||
<stop offset="0%" stopColor={branch.colorFrom} stopOpacity={0} />
|
||||
<stop offset="18%" stopColor={branch.colorFrom} stopOpacity={0.34} />
|
||||
<stop offset="50%" stopColor={branch.colorTo} stopOpacity={0.34} />
|
||||
<stop offset="82%" stopColor={branch.colorTo} stopOpacity={0.34} />
|
||||
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
);
|
||||
})}
|
||||
</defs>
|
||||
))}
|
||||
|
||||
{branches.map((branch) => (
|
||||
<path
|
||||
key={branch.key}
|
||||
d={branch.d}
|
||||
className="movement-branch"
|
||||
stroke={`url(#branch-grad-${branch.key})`}
|
||||
fill="none"
|
||||
/>
|
||||
))}
|
||||
{layouts.map((layout) => {
|
||||
const primaryParent =
|
||||
layout.parentIds.length > 0 ? layoutById.get(layout.parentIds[0]) : null;
|
||||
const hasChildren = (childIdsByParent.get(layout.movement.id)?.length ?? 0) > 0;
|
||||
const parentColor = primaryParent?.movement.color ?? layout.movement.color;
|
||||
|
||||
{layouts.map((layout) => {
|
||||
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd);
|
||||
return (
|
||||
<path
|
||||
key={`stream-${layout.movement.id}`}
|
||||
d={d}
|
||||
className="movement-stream-fill"
|
||||
stroke={`url(#stream-grad-${layout.movement.id})`}
|
||||
fill="none"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<linearGradient
|
||||
key={`grad-${layout.movement.id}`}
|
||||
id={`stream-grad-${layout.movement.id}`}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1={layout.xStart}
|
||||
y1={layout.y}
|
||||
x2={layout.xEnd}
|
||||
y2={layout.yEnd}
|
||||
>
|
||||
{primaryParent ? (
|
||||
<>
|
||||
<stop offset="0%" stopColor={parentColor} stopOpacity={0.28} />
|
||||
<stop offset="16%" stopColor={layout.movement.color} stopOpacity={0.34} />
|
||||
<stop offset="32%" stopColor={layout.movement.color} stopOpacity={0.38} />
|
||||
</>
|
||||
) : (
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={layout.movement.color}
|
||||
stopOpacity={layout.movement.start_definite ? 0.28 : 0.08}
|
||||
/>
|
||||
)}
|
||||
<stop offset="50%" stopColor={layout.movement.color} stopOpacity={0.38} />
|
||||
{hasChildren ? (
|
||||
<>
|
||||
<stop offset="68%" stopColor={layout.movement.color} stopOpacity={0.38} />
|
||||
<stop offset="84%" stopColor={layout.movement.color} stopOpacity={0.22} />
|
||||
<stop offset="100%" stopColor={layout.movement.color} stopOpacity={0} />
|
||||
</>
|
||||
) : (
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={layout.movement.color}
|
||||
stopOpacity={layout.movement.end_definite ? 0.28 : 0.08}
|
||||
/>
|
||||
)}
|
||||
</linearGradient>
|
||||
);
|
||||
})}
|
||||
</defs>
|
||||
)}
|
||||
|
||||
{layouts.map((layout) => {
|
||||
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd);
|
||||
return (
|
||||
<path
|
||||
key={`core-${layout.movement.id}`}
|
||||
d={d}
|
||||
className="movement-stream-core"
|
||||
stroke={layout.movement.color}
|
||||
fill="none"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{fastGraphics ? (
|
||||
<>
|
||||
{branches.map((branch) => (
|
||||
<path
|
||||
key={branch.key}
|
||||
d={branch.d}
|
||||
className="movement-branch movement-branch-fast"
|
||||
stroke={branch.colorTo}
|
||||
fill="none"
|
||||
/>
|
||||
))}
|
||||
{layouts.map((layout) => {
|
||||
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd);
|
||||
return (
|
||||
<path
|
||||
key={`stream-${layout.movement.id}`}
|
||||
d={d}
|
||||
className="movement-stream-core movement-stream-fast"
|
||||
stroke={layout.movement.color}
|
||||
fill="none"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{branches.map((branch) => (
|
||||
<path
|
||||
key={branch.key}
|
||||
d={branch.d}
|
||||
className="movement-branch"
|
||||
stroke={`url(#branch-grad-${branch.key})`}
|
||||
fill="none"
|
||||
/>
|
||||
))}
|
||||
|
||||
{layouts.map((layout) => {
|
||||
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd);
|
||||
return (
|
||||
<path
|
||||
key={`stream-${layout.movement.id}`}
|
||||
d={d}
|
||||
className="movement-stream-fill"
|
||||
stroke={`url(#stream-grad-${layout.movement.id})`}
|
||||
fill="none"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{layouts.map((layout) => {
|
||||
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd);
|
||||
return (
|
||||
<path
|
||||
key={`core-${layout.movement.id}`}
|
||||
d={d}
|
||||
className="movement-stream-core"
|
||||
stroke={layout.movement.color}
|
||||
fill="none"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{hoveredPlacement && (
|
||||
{hoveredPlacement && !fastGraphics && (
|
||||
<div className="movement-lifespan-overlays" aria-hidden>
|
||||
{hoveredPlacement.lineLeft > 0 && (
|
||||
<div
|
||||
@@ -802,6 +869,8 @@ export default function MovementBands({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fastGraphics && (
|
||||
<>
|
||||
<div className="movements-flow-labels">
|
||||
{layouts.map((layout) => (
|
||||
<button
|
||||
@@ -815,7 +884,6 @@ export default function MovementBands({
|
||||
title={`Open ${layout.movement.name} gallery hall`}
|
||||
onClick={() => onMovementClick?.(layout.movement.id)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="movement-name">{layout.movement.name}</span>
|
||||
{layout.movement.era_name && (
|
||||
@@ -872,7 +940,6 @@ export default function MovementBands({
|
||||
onArtistHover?.(null);
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
title={`${artist.name} (${birthLabel}–${deathLabel}) · ${layout.movement.name}`}
|
||||
>
|
||||
<img
|
||||
@@ -888,6 +955,8 @@ export default function MovementBands({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
||||
import type { HistoricalEra } from '../types';
|
||||
import {
|
||||
HISTORICAL_EVENTS,
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
eventInView,
|
||||
type HistoricalEvent,
|
||||
} from '../data/historical-events';
|
||||
import {
|
||||
buildTimelineTickYears,
|
||||
chooseTimelineTickInterval,
|
||||
} from '../utils/timelineView';
|
||||
import './Timeline.css';
|
||||
|
||||
interface LifespanHighlight {
|
||||
@@ -36,13 +40,39 @@ function formatYear(year: number): string {
|
||||
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState<'left' | 'right' | 'pan' | null>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(800);
|
||||
const dragStart = useRef({ x: 0, viewStart: 0, viewEnd: 0 });
|
||||
|
||||
const span = viewEnd - viewStart;
|
||||
const tickInterval = span > 500 ? 100 : span > 200 ? 50 : span > 50 ? 25 : span > 10 ? 5 : 1;
|
||||
const ticks: number[] = [];
|
||||
const firstTick = Math.ceil(viewStart / tickInterval) * tickInterval;
|
||||
for (let y = firstTick; y <= viewEnd; y += tickInterval) ticks.push(y);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const measure = () => {
|
||||
const w = el.getBoundingClientRect().width;
|
||||
if (w > 0) setContainerWidth(Math.round(w));
|
||||
};
|
||||
|
||||
measure();
|
||||
const ro = new ResizeObserver(() => measure());
|
||||
ro.observe(el);
|
||||
window.addEventListener('resize', measure);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener('resize', measure);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const tickInterval = useMemo(
|
||||
() => chooseTimelineTickInterval(span, containerWidth),
|
||||
[span, containerWidth]
|
||||
);
|
||||
|
||||
const ticks = useMemo(
|
||||
() => buildTimelineTickYears(viewStart, viewEnd, tickInterval),
|
||||
[viewStart, viewEnd, tickInterval]
|
||||
);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import ArtistBio from '../components/ArtistBio';
|
||||
import CheckupPage from '../pages/CheckupPage';
|
||||
import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types';
|
||||
import { createViewChangeScheduler } from '../utils/timelineView';
|
||||
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
|
||||
import { readDebugMode, readDebugShowMore, writeDebugMode, writeDebugShowMore } from '../utils/debugMode';
|
||||
import './HomePage.css';
|
||||
@@ -119,18 +120,6 @@ export default function HomePage() {
|
||||
} | null>(null);
|
||||
const detailReturnToRef = useRef<View>({ type: 'timeline' });
|
||||
|
||||
useEffect(() => {
|
||||
api.getBounds()
|
||||
.then((b) => {
|
||||
const min = b.min_year ?? -800;
|
||||
const max = b.max_year ?? 2025;
|
||||
setBounds({ min, max });
|
||||
setViewStart(min);
|
||||
setViewEnd(max);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (view.type === 'gallery') {
|
||||
setGallerySession({ kind: 'artist', artistId: view.artistId, data: view.data });
|
||||
@@ -141,31 +130,55 @@ export default function HomePage() {
|
||||
}
|
||||
}, [view]);
|
||||
|
||||
const loadTimelineData = useCallback(async (start: number, end: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [timeline, artistList] = await Promise.all([
|
||||
api.getTimeline(start, end),
|
||||
api.getArtists(start, end),
|
||||
]);
|
||||
setTimelineData(timeline);
|
||||
setArtists(artistList);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError('Could not load gallery data. Is the server running?');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// Load full catalog once — pan/zoom filters client-side (MovementBands, Timeline).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const b = await api.getBounds();
|
||||
const min = b.min_year ?? -800;
|
||||
const max = b.max_year ?? 2025;
|
||||
if (cancelled) return;
|
||||
|
||||
setBounds({ min, max });
|
||||
setViewStart(min);
|
||||
setViewEnd(max);
|
||||
|
||||
const [timeline, artistList] = await Promise.all([
|
||||
api.getTimeline(min, max),
|
||||
api.getTimelineArtists(),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
setTimelineData(timeline);
|
||||
setArtists(artistList);
|
||||
setError(null);
|
||||
} catch {
|
||||
if (!cancelled) setError('Could not load gallery data. Is the server running?');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTimelineData(viewStart, viewEnd);
|
||||
}, [viewStart, viewEnd, loadTimelineData]);
|
||||
const viewChangeScheduler = useRef(
|
||||
createViewChangeScheduler((start, end) => {
|
||||
setViewStart(start);
|
||||
setViewEnd(end);
|
||||
})
|
||||
);
|
||||
|
||||
const handleViewChange = (start: number, end: number) => {
|
||||
setViewStart(start);
|
||||
setViewEnd(end);
|
||||
};
|
||||
useEffect(() => () => viewChangeScheduler.current.cancel(), []);
|
||||
|
||||
const handleViewChange = useCallback((start: number, end: number) => {
|
||||
viewChangeScheduler.current.schedule(start, end);
|
||||
}, []);
|
||||
|
||||
const toggleDebugMode = () => {
|
||||
setDebugMode((prev) => {
|
||||
@@ -683,7 +696,7 @@ export default function HomePage() {
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
{loading && timelineData.movements.length === 0 ? (
|
||||
<div className="loading home-movements-section">Loading art history...</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,45 @@
|
||||
/** Shared timeline zoom/pan math for Timeline and MovementBands. */
|
||||
|
||||
/** "Nice" year steps for axis labels (ascending). */
|
||||
const TICK_INTERVALS = [1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000, 5000];
|
||||
|
||||
/** Pick a label interval from span + pixel width so dates do not overlap. */
|
||||
export function chooseTimelineTickInterval(
|
||||
spanYears: number,
|
||||
widthPx: number,
|
||||
minLabelGapPx = 76
|
||||
): number {
|
||||
if (spanYears <= 0) return 1;
|
||||
const width = Math.max(widthPx, 320);
|
||||
const minYears = (spanYears / width) * minLabelGapPx;
|
||||
|
||||
let chosen = TICK_INTERVALS[TICK_INTERVALS.length - 1];
|
||||
for (const interval of TICK_INTERVALS) {
|
||||
if (interval >= minYears) {
|
||||
chosen = interval;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Hard cap — never more than ~16 labelled ticks
|
||||
while (spanYears / chosen > 16) {
|
||||
const idx = TICK_INTERVALS.indexOf(chosen);
|
||||
if (idx < 0 || idx >= TICK_INTERVALS.length - 1) break;
|
||||
chosen = TICK_INTERVALS[idx + 1];
|
||||
}
|
||||
|
||||
return chosen;
|
||||
}
|
||||
|
||||
export function buildTimelineTickYears(viewStart: number, viewEnd: number, interval: number): number[] {
|
||||
const ticks: number[] = [];
|
||||
const firstTick = Math.ceil(viewStart / interval) * interval;
|
||||
for (let y = firstTick; y <= viewEnd; y += interval) {
|
||||
ticks.push(y);
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
export function zoomTimelineView(
|
||||
clientX: number,
|
||||
rectLeft: number,
|
||||
@@ -49,3 +90,38 @@ export function panTimelineView(
|
||||
}
|
||||
return { start: Math.round(newStart), end: Math.round(newEnd) };
|
||||
}
|
||||
|
||||
/** Coalesce rapid view updates to one React commit per animation frame. */
|
||||
export function createViewChangeScheduler(onApply: (start: number, end: number) => void) {
|
||||
let rafId: number | null = null;
|
||||
let pending: { start: number; end: number } | null = null;
|
||||
|
||||
return {
|
||||
schedule(start: number, end: number) {
|
||||
pending = { start, end };
|
||||
if (rafId != null) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
const next = pending;
|
||||
pending = null;
|
||||
if (next) onApply(next.start, next.end);
|
||||
});
|
||||
},
|
||||
flush() {
|
||||
if (rafId != null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
if (pending) {
|
||||
const next = pending;
|
||||
pending = null;
|
||||
onApply(next.start, next.end);
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
if (rafId != null) cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
pending = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 592 KiB |
|
Before Width: | Height: | Size: 379 KiB After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 4.0 MiB |
|
After Width: | Height: | Size: 684 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 614 KiB |
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Download artist portrait images from Wikipedia (and fallbacks) into data/images/portraits/.
|
||||
* Run: npm run fetch-artist-images
|
||||
* Flags: --force (re-fetch even when portrait_path is set), --limit=N
|
||||
*/
|
||||
require('dotenv').config();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const pool = require('../server/db');
|
||||
const { saveArtistPortrait, findLocalPortraitPath } = require('./image-fetcher');
|
||||
|
||||
const FORCE = process.argv.includes('--force');
|
||||
const LIMIT = parseInt(process.argv.find((a) => a.startsWith('--limit='))?.split('=')[1] || '0', 10);
|
||||
const IMAGE_DIR = path.resolve(process.env.IMAGE_DIR || path.join(__dirname, '../data/images'));
|
||||
|
||||
function portraitFileExists(portraitPath) {
|
||||
return !!portraitPath && fs.existsSync(path.join(IMAGE_DIR, portraitPath));
|
||||
}
|
||||
|
||||
function needsPortrait(artist, force) {
|
||||
if (force) return true;
|
||||
const local = findLocalPortraitPath(artist.name, IMAGE_DIR);
|
||||
if (local) return artist.portrait_path !== local;
|
||||
if (artist.portrait_path) return !portraitFileExists(artist.portrait_path);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { rows: artists } = await pool.query(`
|
||||
SELECT id, name, wikipedia_title, portrait_path
|
||||
FROM artists
|
||||
ORDER BY name
|
||||
`);
|
||||
|
||||
let targets = artists.filter((a) => needsPortrait(a, FORCE));
|
||||
if (LIMIT > 0) targets = targets.slice(0, LIMIT);
|
||||
|
||||
console.log(
|
||||
`Artists total: ${artists.length}, to fetch: ${targets.length}${FORCE ? ' (force)' : ''}${LIMIT > 0 ? ` (limit ${LIMIT})` : ''}`
|
||||
);
|
||||
|
||||
let updated = 0;
|
||||
let linked = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const artist of targets) {
|
||||
try {
|
||||
const local = findLocalPortraitPath(artist.name, IMAGE_DIR);
|
||||
if (local) {
|
||||
await pool.query('UPDATE artists SET portrait_path = $1 WHERE id = $2', [local, artist.id]);
|
||||
console.log(`↺ ${artist.name} — ${local}`);
|
||||
linked += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const wikiTitle = artist.wikipedia_title || artist.name;
|
||||
const saved = await saveArtistPortrait(artist.name, wikiTitle, IMAGE_DIR);
|
||||
if (!saved.path) {
|
||||
console.warn(`✗ ${artist.name} — no portrait found`);
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await pool.query('UPDATE artists SET portrait_path = $1 WHERE id = $2', [saved.path, artist.id]);
|
||||
console.log(`✓ ${artist.name} — ${saved.path} (${saved.source || 'Wikipedia'})`);
|
||||
updated += 1;
|
||||
} catch (err) {
|
||||
console.error(`✗ ${artist.name} — ${err.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone: ${updated} downloaded, ${linked} linked from disk, ${failed} failed`);
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1807,11 +1807,93 @@ async function saveImageForItem(wikiTitle, subdir, filename, imageDir, options =
|
||||
}
|
||||
}
|
||||
|
||||
const ARTIST_PORTRAIT_WIKI_OVERRIDES = {
|
||||
Zeuxis: 'Zeuxis (painter)',
|
||||
'Ivan Klyun': 'Ivan Kliun',
|
||||
'Jean-Antoine Watteau': 'Antoine Watteau',
|
||||
};
|
||||
|
||||
function artistPortraitWikiCandidates(artistName, wikiTitle) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const title of [
|
||||
ARTIST_PORTRAIT_WIKI_OVERRIDES[artistName],
|
||||
ARTIST_PORTRAIT_WIKI_OVERRIDES[wikiTitle],
|
||||
wikiTitle,
|
||||
artistName,
|
||||
`${artistName} (painter)`,
|
||||
`${wikiTitle} (painter)`,
|
||||
]) {
|
||||
if (title && !seen.has(title)) {
|
||||
seen.add(title);
|
||||
out.push(title);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function resolveArtistPortrait(artistName, wikiTitle) {
|
||||
for (const title of artistPortraitWikiCandidates(artistName, wikiTitle)) {
|
||||
const images = await getWikipediaImages(title);
|
||||
if (images?.fullUrl) {
|
||||
return { ...images, wikipedia_title: title };
|
||||
}
|
||||
}
|
||||
|
||||
const search = await searchArtistPortraitFirst(artistName);
|
||||
if (search?.imageUrl) {
|
||||
return {
|
||||
fullUrl: search.imageUrl,
|
||||
source: search.sourceLabel || search.source || 'web search',
|
||||
wikipedia_title: wikiTitle,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findLocalPortraitPath(artistName, imageDir) {
|
||||
const safeBase = artistName.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.JPG']) {
|
||||
const rel = `portraits/${safeBase}${ext}`;
|
||||
if (fs.existsSync(path.join(imageDir, rel))) return rel.replace(/\\/g, '/');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function saveArtistPortrait(artistName, wikiTitle, imageDir) {
|
||||
const local = findLocalPortraitPath(artistName, imageDir);
|
||||
if (local) return { path: local, source: 'local disk' };
|
||||
|
||||
const resolved = await resolveArtistPortrait(artistName, wikiTitle);
|
||||
if (!resolved?.fullUrl) return { path: null, source: null };
|
||||
|
||||
const portraitsDir = path.join(imageDir, 'portraits');
|
||||
if (!fs.existsSync(portraitsDir)) fs.mkdirSync(portraitsDir, { recursive: true });
|
||||
|
||||
const safeBase = artistName.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
const ext = pickExt(resolved.fullUrl);
|
||||
const destPath = path.join(portraitsDir, safeBase + ext);
|
||||
|
||||
try {
|
||||
await downloadImageToFile(resolved.fullUrl, destPath);
|
||||
return {
|
||||
path: path.join('portraits', safeBase + ext).replace(/\\/g, '/'),
|
||||
source: resolved.source,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn(` Portrait download failed: ${err.message}`);
|
||||
return { path: null, source: resolved.source };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveImageUrl,
|
||||
resolvePaintingImages,
|
||||
saveImageForItem,
|
||||
savePaintingImages,
|
||||
saveArtistPortrait,
|
||||
findLocalPortraitPath,
|
||||
resolveArtistPortrait,
|
||||
downloadImageToFile,
|
||||
downloadImageForFix,
|
||||
generateThumbnailFromFull,
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Align painting image_path / thumbnail_path with files on disk and import missing rows.
|
||||
* Run: npm run sync-image-paths
|
||||
* Flags: --dry-run (report only, no DB writes)
|
||||
*/
|
||||
require('dotenv').config();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const pool = require('../server/db');
|
||||
|
||||
const DRY_RUN = process.argv.includes('--dry-run');
|
||||
const IMAGE_DIR = path.resolve(process.env.IMAGE_DIR || path.join(__dirname, '../data/images'));
|
||||
const PAINTINGS_DIR = path.join(IMAGE_DIR, 'paintings');
|
||||
const THUMBS_DIR = path.join(IMAGE_DIR, 'paintings', 'thumbs');
|
||||
|
||||
function safeArtistBase(name) {
|
||||
return name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
}
|
||||
|
||||
function safePaintingBase(artistName, title) {
|
||||
return `${artistName}_${title}`.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
}
|
||||
|
||||
function normalizeTitle(s) {
|
||||
return s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function slugToTitle(slug) {
|
||||
return slug.replace(/_/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function findPathsForSafeBase(safeBase) {
|
||||
let imagePath = null;
|
||||
let thumbPath = null;
|
||||
|
||||
for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.JPG']) {
|
||||
const full = path.join(PAINTINGS_DIR, safeBase + ext);
|
||||
if (!imagePath && fs.existsSync(full)) {
|
||||
imagePath = `paintings/${safeBase}${ext}`.replace(/\\/g, '/');
|
||||
}
|
||||
const thumb = path.join(THUMBS_DIR, safeBase + '_thumb' + ext);
|
||||
if (!thumbPath && fs.existsSync(thumb)) {
|
||||
thumbPath = `paintings/thumbs/${safeBase}_thumb${ext}`.replace(/\\/g, '/');
|
||||
}
|
||||
}
|
||||
if (!thumbPath && imagePath) thumbPath = imagePath;
|
||||
return { imagePath, thumbPath };
|
||||
}
|
||||
|
||||
function matchArtistFromFilename(filename, artistsByPrefix) {
|
||||
for (const { prefix, artist } of artistsByPrefix) {
|
||||
if (filename === prefix || filename.startsWith(prefix + '_')) {
|
||||
return { artist, prefix };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(PAINTINGS_DIR)) {
|
||||
console.error(`Paintings directory not found: ${PAINTINGS_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const artistsRes = await pool.query('SELECT id, name FROM artists ORDER BY name');
|
||||
const artistsByPrefix = artistsRes.rows
|
||||
.map((a) => ({ prefix: safeArtistBase(a.name), artist: a }))
|
||||
.sort((a, b) => b.prefix.length - a.prefix.length);
|
||||
|
||||
const paintingsRes = await pool.query(`
|
||||
SELECT p.id, p.title, p.image_path, p.thumbnail_path, p.artist_id, a.name AS artist_name
|
||||
FROM paintings p
|
||||
JOIN artists a ON a.id = p.artist_id
|
||||
`);
|
||||
|
||||
const bySafeBase = new Map();
|
||||
const titlesByArtist = new Map();
|
||||
for (const row of paintingsRes.rows) {
|
||||
const base = safePaintingBase(row.artist_name, row.title);
|
||||
bySafeBase.set(base, row);
|
||||
if (!titlesByArtist.has(row.artist_id)) titlesByArtist.set(row.artist_id, new Set());
|
||||
titlesByArtist.get(row.artist_id).add(normalizeTitle(row.title));
|
||||
}
|
||||
|
||||
const diskFiles = fs
|
||||
.readdirSync(PAINTINGS_DIR)
|
||||
.filter((f) => /\.(jpg|jpeg|png|webp)$/i.test(f) && !f.includes('_thumb'));
|
||||
|
||||
let linked = 0;
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
let unmatched = 0;
|
||||
|
||||
for (const file of diskFiles) {
|
||||
const ext = path.extname(file);
|
||||
const safeBase = file.slice(0, -ext.length);
|
||||
const paths = findPathsForSafeBase(safeBase);
|
||||
if (!paths.imagePath && !paths.thumbPath) continue;
|
||||
|
||||
const existing = bySafeBase.get(safeBase);
|
||||
if (existing) {
|
||||
const needsUpdate =
|
||||
(paths.imagePath && existing.image_path !== paths.imagePath) ||
|
||||
(paths.thumbPath && existing.thumbnail_path !== paths.thumbPath);
|
||||
if (needsUpdate) {
|
||||
if (!DRY_RUN) {
|
||||
await pool.query(
|
||||
`UPDATE paintings SET image_path = COALESCE($1, image_path), thumbnail_path = COALESCE($2, thumbnail_path) WHERE id = $3`,
|
||||
[paths.imagePath, paths.thumbPath, existing.id]
|
||||
);
|
||||
}
|
||||
linked += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const matched = matchArtistFromFilename(safeBase, artistsByPrefix);
|
||||
if (!matched) {
|
||||
unmatched += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const titleSlug = safeBase.slice(matched.prefix.length + 1);
|
||||
if (!titleSlug) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const title = slugToTitle(titleSlug);
|
||||
const norm = normalizeTitle(title);
|
||||
const artistTitles = titlesByArtist.get(matched.artist.id) || new Set();
|
||||
if (artistTitles.has(norm)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const sortRes = await pool.query(
|
||||
'SELECT COALESCE(MAX(sort_order), 0) + 1 AS next FROM paintings WHERE artist_id = $1',
|
||||
[matched.artist.id]
|
||||
);
|
||||
const sortOrder = sortRes.rows[0].next;
|
||||
|
||||
if (!DRY_RUN) {
|
||||
const insert = await pool.query(
|
||||
`INSERT INTO paintings (artist_id, title, wikipedia_title, image_path, thumbnail_path, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id`,
|
||||
[matched.artist.id, title, title, paths.imagePath, paths.thumbPath, sortOrder]
|
||||
);
|
||||
bySafeBase.set(safeBase, {
|
||||
id: insert.rows[0].id,
|
||||
artist_name: matched.artist.name,
|
||||
title,
|
||||
image_path: paths.imagePath,
|
||||
thumbnail_path: paths.thumbPath,
|
||||
});
|
||||
}
|
||||
artistTitles.add(norm);
|
||||
titlesByArtist.set(matched.artist.id, artistTitles);
|
||||
imported += 1;
|
||||
}
|
||||
|
||||
// Link paths for existing rows whose files use the canonical safe base name
|
||||
for (const row of paintingsRes.rows) {
|
||||
if (row.image_path && row.thumbnail_path) continue;
|
||||
const synced = findPathsForSafeBase(safePaintingBase(row.artist_name, row.title));
|
||||
if (!synced.imagePath && !synced.thumbPath) continue;
|
||||
if (!DRY_RUN) {
|
||||
await pool.query(
|
||||
`UPDATE paintings SET image_path = COALESCE($1, image_path), thumbnail_path = COALESCE($2, thumbnail_path) WHERE id = $3`,
|
||||
[synced.imagePath, synced.thumbPath, row.id]
|
||||
);
|
||||
}
|
||||
linked += 1;
|
||||
}
|
||||
|
||||
const summary = await pool.query(`
|
||||
SELECT COUNT(*)::int AS total_paintings,
|
||||
COUNT(DISTINCT artist_id)::int AS artists_with_works,
|
||||
ROUND(AVG(cnt), 1) AS avg_per_artist,
|
||||
MAX(cnt)::int AS max_per_artist
|
||||
FROM (
|
||||
SELECT p.artist_id, COUNT(p.id)::int AS cnt
|
||||
FROM paintings p
|
||||
GROUP BY p.artist_id
|
||||
) t
|
||||
`);
|
||||
|
||||
console.log(
|
||||
`${DRY_RUN ? '[dry-run] ' : ''}Linked ${linked}, imported ${imported}, skipped ${skipped}, unmatched files ${unmatched}`
|
||||
);
|
||||
console.log('Catalog:', summary.rows[0]);
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -175,8 +175,17 @@ app.get('/api/movements/:id/artists', async (req, res) => {
|
||||
// All artists in a year range (for timeline portrait placement)
|
||||
app.get('/api/artists', async (req, res) => {
|
||||
try {
|
||||
const { start, end, movement_id } = req.query;
|
||||
let query = `
|
||||
const { start, end, movement_id, timeline } = req.query;
|
||||
const timelineOnly = timeline === '1' || timeline === 'true';
|
||||
let query = timelineOnly
|
||||
? `
|
||||
SELECT a.id, a.name, a.birth_year, a.death_year, a.movement_id, a.portrait_path,
|
||||
a.bio_short, a.wikipedia_title, a.century,
|
||||
m.name as movement_name, m.color as movement_color
|
||||
FROM artists a
|
||||
LEFT JOIN art_movements m ON a.movement_id = m.id
|
||||
WHERE 1=1`
|
||||
: `
|
||||
SELECT a.*, m.name as movement_name, m.color as movement_color
|
||||
FROM artists a
|
||||
LEFT JOIN art_movements m ON a.movement_id = m.id
|
||||
|
||||