Improve movement flow layout/animation and fix Byzantine hall doors, styles, and preload.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
23e1210e86
commit
8111cd0163
+17
-2
@@ -116,7 +116,7 @@ Only **admins** can create or promote **admin** accounts. Cannot deactivate/demo
|
|||||||
| Tour admin (`/api/tours/admin`, POST/PATCH/DELETE, stops) | `tours` | `tour.*` |
|
| Tour admin (`/api/tours/admin`, POST/PATCH/DELETE, stops) | `tours` | `tour.*` |
|
||||||
| `/api/users/*` | `users` | `user.*` |
|
| `/api/users/*` | `users` | `user.*` |
|
||||||
|
|
||||||
**Public** (no login): all catalog `GET` routes, `POST /api/artists/:id/preload-images` (local file linking for 3D halls), `/images`, SPA static.
|
**Public** (no login): all catalog `GET` routes, `POST /api/artists/:id/preload-images`, `POST /api/movements/:id/preload-images` (local file linking for 3D halls), `/images`, SPA static.
|
||||||
|
|
||||||
Staff mutations are recorded in `curator_audit_log` with `user_id` (see [DB_structure.md](DB_structure.md)).
|
Staff mutations are recorded in `curator_audit_log` with `user_id` (see [DB_structure.md](DB_structure.md)).
|
||||||
|
|
||||||
@@ -597,7 +597,7 @@ Both lists are grouped by art movement and exclude the current artist. Each arti
|
|||||||
|
|
||||||
**Public** — no curator login required.
|
**Public** — no curator login required.
|
||||||
|
|
||||||
Fast local scan: links paintings to files already on disk. Does **not** download from the internet. The 3D client calls this automatically when entering an **artist** hall.
|
Fast local scan: links paintings to files already on disk and regenerates missing thumbs. Does **not** download from the internet. The 3D client calls this automatically when entering an **artist** hall.
|
||||||
|
|
||||||
**Response**
|
**Response**
|
||||||
|
|
||||||
@@ -609,6 +609,20 @@ Fast local scan: links paintings to files already on disk. Does **not** download
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## `POST /api/movements/:id/preload-images`
|
||||||
|
|
||||||
|
**Public** — no curator login required.
|
||||||
|
|
||||||
|
Same local-only scan as the artist preload, for every painting by artists in the movement. The 3D client calls this automatically when entering a **movement** hall.
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "fetched": 40, "total": 45 }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## `GET /api/paintings/:id`
|
## `GET /api/paintings/:id`
|
||||||
|
|
||||||
Painting detail with influence graph neighbours.
|
Painting detail with influence graph neighbours.
|
||||||
@@ -921,6 +935,7 @@ The React client wraps these endpoints in `client/src/api/client.ts`. All reques
|
|||||||
| `api.getArtistNavigation(id)` | `GET /api/artists/:id/navigation` |
|
| `api.getArtistNavigation(id)` | `GET /api/artists/:id/navigation` |
|
||||||
| `api.getPainting(id)` | `GET /api/paintings/:id` |
|
| `api.getPainting(id)` | `GET /api/paintings/:id` |
|
||||||
| `preloadArtistImages(id)` | `POST /api/artists/:id/preload-images` |
|
| `preloadArtistImages(id)` | `POST /api/artists/:id/preload-images` |
|
||||||
|
| `preloadMovementImages(id)` | `POST /api/movements/:id/preload-images` |
|
||||||
| `imageUrl(path, revision?)` | `/images/<path>` or placeholder; optional `?v=` cache buster |
|
| `imageUrl(path, revision?)` | `/images/<path>` or placeholder; optional `?v=` cache buster |
|
||||||
| `paintingImageRevision(painting, sessionRevision?)` | Prefer API `image_cache_key` / `thumbnail_cache_key`, else in-session counter |
|
| `paintingImageRevision(painting, sessionRevision?)` | Prefer API `image_cache_key` / `thumbnail_cache_key`, else in-session counter |
|
||||||
| `galleryImageUrl(painting, sessionRevision?)` | Local thumb/full only (3D); auto `?v=` from cache keys |
|
| `galleryImageUrl(painting, sessionRevision?)` | Local thumb/full only (3D); auto `?v=` from cache keys |
|
||||||
|
|||||||
+11
-10
@@ -246,8 +246,9 @@ Each visible movement is drawn as a **portrait-width curved stream** (~54 px str
|
|||||||
| Feature | Implementation |
|
| Feature | Implementation |
|
||||||
|---------|----------------|
|
|---------|----------------|
|
||||||
| Lineage layout | `client/src/data/movement-lineage.ts` — curated predecessor→successor pairs (Met / ArtStory / museum essays); multiple parents allowed |
|
| Lineage layout | `client/src/data/movement-lineage.ts` — curated predecessor→successor pairs (Met / ArtStory / museum essays); multiple parents allowed |
|
||||||
| Vertical lanes | Movements whose time spans do **not** overlap (in the current zoom) share a horizontal lane; only concurrent spans stack into extra rows (`assignTemporalLanes`). A follow-up pass (`refineLanesForLineageCorridors`) pulls linked parent/child movements onto nearby or shared lanes when years allow, then displaces unrelated streams out of thick lineage branch corridors |
|
| Vertical lanes | Movements share a horizontal lane only when one ends at least **10 years** before the next starts (in the current zoom); closer or overlapping spans stack into extra rows (`assignTemporalLanes`). A follow-up pass (`refineLanesForLineageCorridors`) pulls linked parent/child movements onto nearby or shared lanes when years allow, then displaces unrelated streams out of thick lineage branch corridors |
|
||||||
| Branch connectors | Smooth curves from fan-out points along a parent stream to the **left edge (start)** of each child stream; color gradients from parent → child at constant opacity; a stream-shaped mask hides branch ink under movements so translucent overlaps do not brighten the bands |
|
| Branch connectors | Smooth curves from fan-out points along a parent stream to the **left edge (start)** of each child stream; origin X is always strictly left of the target (time-forward only — never right→left); stroke thickness matches the **target** (child) band height; color gradients from parent → child at constant opacity; a stream-shaped mask hides branch ink under movements so translucent overlaps do not brighten the bands; pan/zoom layout changes **animate** (streams + branches chase new geometry) so shifts stay trackable |
|
||||||
|
| Band thickness | Stream height is **proportional to `influence_link_count`** (edges on paintings by artists in that movement): thicker bands for denser influence graphs; lane rows share vertical space weighted by the thickest band in each lane |
|
||||||
| Filtering | A movement is drawn when its **span overlaps** the visible year range **and** it has at least one catalogued artist — artists whose lifespan falls outside the window still keep their movement visible (their portraits simply do not render). Filtered client-side after initial load |
|
| Filtering | A movement is drawn when its **span overlaps** the visible year range **and** it has at least one catalogued artist — artists whose lifespan falls outside the window still keep their movement visible (their portraits simply do not render). 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 |
|
| Viewport layout | Row height and stream width scale from measured canvas size so every visible movement row fits in the remaining screen space |
|
||||||
|
|
||||||
@@ -330,16 +331,16 @@ Enter from the home page by clicking a **movement name** on the movement flow (`
|
|||||||
|------|----------------|
|
|------|----------------|
|
||||||
| One gallery per movement | All paintings by artists in that movement, sorted chronologically |
|
| One gallery per movement | All paintings by artists in that movement, sorted chronologically |
|
||||||
| Wings | Catalog split into wings of up to **55 works** (`movementHallLayout.ts`); large movements (e.g. Baroque) use multiple wings |
|
| Wings | Catalog split into wings of up to **55 works** (`movementHallLayout.ts`); large movements (e.g. Baroque) use multiple wings |
|
||||||
| Paintings on walls | **Left, end, and right** — end-wall works sit on the panels beside the exit doors; front wall remains passage / solid |
|
| Paintings on walls | **Left, end, and right** — single-wing halls keep the far wall solid (full span); multi-wing halls hang end-wall works on panels beside the back exit |
|
||||||
| Wall order | Same U-shaped hang as artist halls (left → end → right) |
|
| Wall order | Same U-shaped hang as artist halls (left → end → right) |
|
||||||
| Frame captions | **Year · artist** label below each frame |
|
| Frame captions | **Year · artist** label below each frame |
|
||||||
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, NYC loft, white cube, etc.) |
|
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, NYC loft, white cube, etc.). Style keys use a legacy id map (`DB_ID_TO_STYLE_KEY`: gallery ids **1–26** → authored keys **27–52**, with Northern/High Renaissance swapped) so Byzantine gets the chapel, not Gothic stone |
|
||||||
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes |
|
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes |
|
||||||
| Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue |
|
| Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue |
|
||||||
| Windows | **Side walls only** — placed in gaps between frames (high on the wall, no overlap with paintings); style matches the movement era |
|
| Windows | **Side walls only** — placed in gaps between frames (high on the wall, no overlap with paintings); style matches the movement era |
|
||||||
| Lighting | Daylight from windows + ceiling track lights + ambient/sun fill |
|
| Lighting | Daylight from windows + ceiling track lights + ambient/sun fill |
|
||||||
| Back wall | **Exit double doors** → **Wing navigator** (jump to any wing) or **Exit to Timeline** |
|
| Back wall | Single wing: solid display wall. Multi-wing: **Exit double doors** → **Wing navigator** or **Exit to Timeline** |
|
||||||
| Front wall | Open **“Next wing →”** archway when a later wing exists; walk through or press `E` when near |
|
| Front / entrance wall | Single wing: **Exit double doors** (leave the way you entered). Multi-wing: **“Next wing →”** archway when a later wing exists |
|
||||||
| Influence lamps | Same golden upside-down lamps as artist halls when `has_influence_links` is true |
|
| Influence lamps | Same golden upside-down lamps as artist halls when `has_influence_links` is true |
|
||||||
| Missing images | Draped canvas cover in frame |
|
| Missing images | Draped canvas cover in frame |
|
||||||
| Detail return | **Back to Gallery** from painting detail returns to the same wing with camera preserved; **Back to Timeline** exits the hall entirely |
|
| Detail return | **Back to Gallery** from painting detail returns to the same wing with camera preserved; **Back to Timeline** exits the hall entirely |
|
||||||
@@ -350,8 +351,8 @@ Enter from the home page by clicking a **movement name** on the movement flow (`
|
|||||||
|-------|--------|
|
|-------|--------|
|
||||||
| Walk / turn / drag | Same as artist hall |
|
| Walk / turn / drag | Same as artist hall |
|
||||||
| Click painting | Open detail view (returns to the same wing) |
|
| Click painting | Open detail view (returns to the same wing) |
|
||||||
| Back wall / `E` / **Wings / Exit →** | Open wing navigator |
|
| Entrance / back door / `E` / **Wings / Exit →** | Open wing navigator (or exit on single-wing halls) |
|
||||||
| Front archway / `E` (when near) | Advance to the **next chronological wing** |
|
| Front archway / `E` (when near, multi-wing) | Advance to the **next chronological wing** |
|
||||||
|
|
||||||
Movement galleries do **not** use the predecessor/successor influence picker — that remains artist-hall only.
|
Movement galleries do **not** use the predecessor/successor influence picker — that remains artist-hall only.
|
||||||
|
|
||||||
@@ -371,7 +372,7 @@ Full guide: [tours.md](tours.md).
|
|||||||
|
|
||||||
### Shared 3D behaviour
|
### Shared 3D behaviour
|
||||||
|
|
||||||
**3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; the client calls `POST /api/artists/:id/preload-images` automatically when entering an **artist** hall (public route — links disk files only). Movement galleries load painting lists from the API without a separate preload step. While a texture is loading, the frame shows the canvas cover instead of a white placeholder.
|
**3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; the client calls `POST /api/artists/:id/preload-images` or `POST /api/movements/:id/preload-images` automatically when entering an **artist** or **movement** hall (public routes — link disk files and regenerate missing thumbs). While a texture is loading, the frame shows the canvas cover instead of a white placeholder.
|
||||||
|
|
||||||
**Boot overlay** (`VirtualGallery.tsx`): the center shows **“Loading gallery…”** until the WebGL canvas is ready, HDR `Environment` has settled (or timed out / failed), and a one-shot `gl.compileAsync` warm-up finishes so entrance doors / passages (often frustum-culled at spawn) do not hitch on the first turn. Painting textures keep loading in the background (prefer thumbnails; per-image timeouts; GPU upload after decode) so large halls (50+ works) are not stuck on the overlay. Env and shader warm-up also have short timeouts. The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again.
|
**Boot overlay** (`VirtualGallery.tsx`): the center shows **“Loading gallery…”** until the WebGL canvas is ready, HDR `Environment` has settled (or timed out / failed), and a one-shot `gl.compileAsync` warm-up finishes so entrance doors / passages (often frustum-culled at spawn) do not hitch on the first turn. Painting textures keep loading in the background (prefer thumbnails; per-image timeouts; GPU upload after decode) so large halls (50+ works) are not stuck on the overlay. Env and shader warm-up also have short timeouts. The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again.
|
||||||
|
|
||||||
@@ -429,7 +430,7 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens
|
|||||||
- **Guided tours** add curator-ordered winged halls with stop text on painting detail — [tours.md](tours.md).
|
- **Guided tours** add curator-ordered winged halls with stop text on painting detail — [tours.md](tours.md).
|
||||||
- **Wall hang** is shared: first work on the left at the entrance, last on the right.
|
- **Wall hang** is shared: first work on the left at the entrance, last on the right.
|
||||||
- **Influence-based hall links** connect artists through documented painting relationships, grouped by movement at the exit.
|
- **Influence-based hall links** connect artists through documented painting relationships, grouped by movement at the exit.
|
||||||
- **3D gallery images** use locally cached files only; slow remote fetches would break realtime rendering. The client calls `POST /api/artists/:id/preload-images` automatically when entering an **artist** hall (public route — links disk files only).
|
- **3D gallery images** use locally cached files only; slow remote fetches would break realtime rendering. The client calls `POST /api/artists/:id/preload-images` or `POST /api/movements/:id/preload-images` automatically when entering an artist or movement hall (public routes — link disk files and regenerate missing thumbs).
|
||||||
- **3D gallery session** stays mounted while painting detail or bio overlays are open; returning to the hall remounts the WebGL canvas when it becomes active again.
|
- **3D gallery session** stays mounted while painting detail or bio overlays are open; returning to the hall remounts the WebGL canvas when it becomes active again.
|
||||||
- **3D gallery resilience:** a lost WebGL context is recovered by remounting the canvas with a fresh context (rather than showing a dark window), and the HDR environment map is isolated behind an error boundary so its failure never blanks the scene.
|
- **3D gallery resilience:** a lost WebGL context is recovered by remounting the canvas with a fresh context (rather than showing a dark window), and the HDR environment map is isolated behind an error boundary so its failure never blanks the scene.
|
||||||
- **Loading feedback:** `GalleryLoadingMarker` surfaces catalog load, portrait download, gallery entry, painting-texture / GPU warm-up, Environment settle, and context-restore states so the user always knows work is still in progress.
|
- **Loading feedback:** `GalleryLoadingMarker` surfaces catalog load, portrait download, gallery entry, painting-texture / GPU warm-up, Environment settle, and context-restore states so the user always knows work is still in progress.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ How catalog content, biographies, and artwork files enter the system.
|
|||||||
|-------------|-------------------------|--------------|
|
|-------------|-------------------------|--------------|
|
||||||
| Fix / upload / buffer or URL replace | Write full file, then `writePaintingThumb` / `generateThumbnailFromFull` | `server/image-service.js` |
|
| Fix / upload / buffer or URL replace | Write full file, then `writePaintingThumb` / `generateThumbnailFromFull` | `server/image-service.js` |
|
||||||
| Fetch / seed / expand / influence ingest | Same: thumb from saved full via `savePaintingImages` | `scripts/image-fetcher.js` |
|
| Fetch / seed / expand / influence ingest | Same: thumb from saved full via `savePaintingImages` | `scripts/image-fetcher.js` |
|
||||||
| Disk sync / preload / ensure when full exists but thumb missing | Generate real `*_thumb.jpg` and update `thumbnail_path` | `ensurePaintingThumbFromFull`, `syncPaintingFromDisk`, `ensurePaintingImages`, `preloadArtistImagesLocal` |
|
| Disk sync / preload / ensure when full exists but thumb missing | Generate real `*_thumb.jpg` and update `thumbnail_path` | `ensurePaintingThumbFromFull`, `syncPaintingFromDisk`, `ensurePaintingImages`, `preloadArtistImagesLocal`, `preloadMovementImagesLocal` |
|
||||||
| Clear image / delete painting | Delete thumb file(s) and null paths (or drop row) | `clearPaintingImage`, `deletePainting` |
|
| Clear image / delete painting | Delete thumb file(s) and null paths (or drop row) | `clearPaintingImage`, `deletePainting` |
|
||||||
| Bulk rebuild after sync/harmonize | `regenerate-thumbnails.js` / `harmonize:images` | scripts + npm scripts |
|
| Bulk rebuild after sync/harmonize | `regenerate-thumbnails.js` / `harmonize:images` | scripts + npm scripts |
|
||||||
|
|
||||||
@@ -387,9 +387,9 @@ Requests are deduplicated (`inflight` map) and timeout after 15 seconds. On-dema
|
|||||||
|
|
||||||
## Preload before 3D gallery
|
## Preload before 3D gallery
|
||||||
|
|
||||||
`POST /api/artists/:id/preload-images` is a **public** route (no curator login). It runs **local-only** linking — no network. The React client calls it automatically when entering an **artist’s** 3D hall so textures use files already on disk.
|
`POST /api/artists/:id/preload-images` and `POST /api/movements/:id/preload-images` are **public** routes (no curator login). They run **local-only** linking — no network — and regenerate missing `*_thumb.jpg` files from full images on disk. The React client calls them automatically when entering an artist or movement 3D hall so textures use thumbs already on disk.
|
||||||
|
|
||||||
**Movement galleries** (`GET /api/movements/:id/gallery`) do not use preload — they load the full painting list from the API and resolve local paths the same way as artist halls. Works without files still show the canvas cover in the frame.
|
Works without files still show the canvas cover in the frame.
|
||||||
|
|
||||||
The 3D scene uses `galleryImageUrl()`, which never hits the on-demand API (remote latency breaks WebGL texture loading).
|
The 3D scene uses `galleryImageUrl()`, which never hits the on-demand API (remote latency breaks WebGL texture loading).
|
||||||
|
|
||||||
|
|||||||
@@ -302,6 +302,15 @@ export async function preloadArtistImages(artistId: number): Promise<{ fetched:
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function preloadMovementImages(movementId: number): Promise<{ fetched: number; total: number }> {
|
||||||
|
const res = await fetch(`${API}/movements/${movementId}/preload-images`, {
|
||||||
|
...fetchCredentials,
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Preload failed');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
export interface FixPaintingImageResult {
|
export interface FixPaintingImageResult {
|
||||||
imagePath: string | null;
|
imagePath: string | null;
|
||||||
thumbnailPath: string | null;
|
thumbnailPath: string | null;
|
||||||
@@ -864,6 +873,7 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
preloadArtistImages,
|
preloadArtistImages,
|
||||||
|
preloadMovementImages,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface TranslationWorklistItem {
|
export interface TranslationWorklistItem {
|
||||||
|
|||||||
@@ -96,13 +96,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.movement-branch-fast {
|
.movement-branch-fast {
|
||||||
stroke-opacity: 0.36;
|
stroke-opacity: 0.55;
|
||||||
stroke-width: calc(var(--stream-stroke) * 0.45);
|
stroke-width: calc(var(--stream-stroke) * 0.45);
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
}
|
}
|
||||||
|
|
||||||
.movement-stream-fast {
|
.movement-stream-fast {
|
||||||
stroke-opacity: 0.42;
|
stroke-opacity: 0.62;
|
||||||
stroke-width: var(--stream-stroke);
|
stroke-width: var(--stream-stroke);
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
vector-effect: non-scaling-stroke;
|
vector-effect: non-scaling-stroke;
|
||||||
@@ -120,13 +120,14 @@
|
|||||||
stroke-width: var(--stream-stroke);
|
stroke-width: var(--stream-stroke);
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
vector-effect: non-scaling-stroke;
|
vector-effect: non-scaling-stroke;
|
||||||
|
filter: saturate(1.25) brightness(1.12) contrast(1.08);
|
||||||
transition: filter 0.2s ease, opacity 0.2s ease;
|
transition: filter 0.2s ease, opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.movement-stream-core {
|
.movement-stream-core {
|
||||||
stroke-width: 2px;
|
stroke-width: 2px;
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
opacity: 0.22;
|
opacity: 0.38;
|
||||||
stroke-dasharray: 8 6;
|
stroke-dasharray: 8 6;
|
||||||
animation: stream-shimmer 16s linear infinite;
|
animation: stream-shimmer 16s linear infinite;
|
||||||
vector-effect: non-scaling-stroke;
|
vector-effect: non-scaling-stroke;
|
||||||
@@ -134,29 +135,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.movements-flow-canvas.movements-flow-movement-hover .movement-stream-fill:not(.movement-stream-highlighted) {
|
.movements-flow-canvas.movements-flow-movement-hover .movement-stream-fill:not(.movement-stream-highlighted) {
|
||||||
opacity: 0.55;
|
opacity: 0.5;
|
||||||
|
filter: saturate(1.05) brightness(0.95) contrast(1.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.movements-flow-canvas.movements-flow-movement-hover .movement-stream-core:not(.movement-stream-highlighted) {
|
.movements-flow-canvas.movements-flow-movement-hover .movement-stream-core:not(.movement-stream-highlighted) {
|
||||||
opacity: 0.1;
|
opacity: 0.14;
|
||||||
}
|
}
|
||||||
|
|
||||||
.movements-flow-canvas.movements-flow-movement-hover .movement-branch:not(.movement-branch-highlighted) {
|
.movements-flow-canvas.movements-flow-movement-hover .movement-branch:not(.movement-branch-highlighted) {
|
||||||
opacity: 0.45;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.movement-stream-fill.movement-stream-highlighted {
|
.movement-stream-fill.movement-stream-highlighted {
|
||||||
filter: brightness(1.65) saturate(1.3);
|
filter: brightness(1.75) saturate(1.45) contrast(1.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.movement-stream-core.movement-stream-highlighted {
|
.movement-stream-core.movement-stream-highlighted {
|
||||||
opacity: 0.72;
|
opacity: 0.82;
|
||||||
filter: brightness(1.5);
|
filter: brightness(1.55) saturate(1.2);
|
||||||
stroke-width: 2.5px;
|
stroke-width: 2.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.movement-branch.movement-branch-highlighted {
|
.movement-branch.movement-branch-highlighted {
|
||||||
filter: brightness(1.55) saturate(1.2);
|
filter: brightness(1.65) saturate(1.35) contrast(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes stream-shimmer {
|
@keyframes stream-shimmer {
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ interface MovementLayout {
|
|||||||
yEnd: number;
|
yEnd: number;
|
||||||
depth: number;
|
depth: number;
|
||||||
parentIds: number[];
|
parentIds: number[];
|
||||||
|
/** Band thickness in CSS pixels (proportional to influence_link_count). */
|
||||||
|
strokePx: number;
|
||||||
|
portraitSizePx: number;
|
||||||
|
/** Saturated display color for the dark flow canvas. */
|
||||||
|
displayColor: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BranchSegment {
|
interface BranchSegment {
|
||||||
@@ -40,6 +45,7 @@ interface BranchSegment {
|
|||||||
y1: number;
|
y1: number;
|
||||||
x2: number;
|
x2: number;
|
||||||
y2: number;
|
y2: number;
|
||||||
|
strokePx: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_STREAM_STROKE_PX = 54;
|
const MAX_STREAM_STROKE_PX = 54;
|
||||||
@@ -51,6 +57,22 @@ const CANVAS_TOP_PAD = 38;
|
|||||||
const CANVAS_BOTTOM_PAD = 24;
|
const CANVAS_BOTTOM_PAD = 24;
|
||||||
const DEFAULT_CANVAS_HEIGHT = 360;
|
const DEFAULT_CANVAS_HEIGHT = 360;
|
||||||
|
|
||||||
|
function movementInfluenceCount(movement: ArtMovement): number {
|
||||||
|
const n = movement.influence_link_count;
|
||||||
|
return typeof n === 'number' && Number.isFinite(n) ? Math.max(0, n) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map influence-link count → desired band thickness (linear vs catalog max). */
|
||||||
|
function desiredStrokeForCount(count: number, maxCount: number): number {
|
||||||
|
if (maxCount <= 0) return MIN_STREAM_STROKE_PX;
|
||||||
|
const t = Math.max(0, Math.min(1, count / maxCount));
|
||||||
|
return ABS_MIN_STREAM_STROKE_PX + t * (MAX_STREAM_STROKE_PX - ABS_MIN_STREAM_STROKE_PX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function portraitSizeForStroke(strokePx: number): number {
|
||||||
|
return Math.max(14, Math.min(39, strokePx * 0.72));
|
||||||
|
}
|
||||||
|
|
||||||
function yearToPercent(year: number, start: number, end: number): number {
|
function yearToPercent(year: number, start: number, end: number): number {
|
||||||
return ((year - start) / (end - start)) * 100;
|
return ((year - start) / (end - start)) * 100;
|
||||||
}
|
}
|
||||||
@@ -140,15 +162,14 @@ function buildArtistPlacements(
|
|||||||
artistsByMovement: Map<number, Artist[]>,
|
artistsByMovement: Map<number, Artist[]>,
|
||||||
viewStart: number,
|
viewStart: number,
|
||||||
viewEnd: number,
|
viewEnd: number,
|
||||||
portraitSizePx: number,
|
|
||||||
canvasWidthPx: number
|
canvasWidthPx: number
|
||||||
): ArtistPlacement[] {
|
): ArtistPlacement[] {
|
||||||
const placements: ArtistPlacement[] = [];
|
const placements: ArtistPlacement[] = [];
|
||||||
const minGap = portraitMinGapPct(portraitSizePx, canvasWidthPx);
|
|
||||||
|
|
||||||
for (const layout of layouts) {
|
for (const layout of layouts) {
|
||||||
const movementArtists = artistsByMovement.get(layout.movement.id) || [];
|
const movementArtists = artistsByMovement.get(layout.movement.id) || [];
|
||||||
const candidates: PortraitCandidate[] = [];
|
const candidates: PortraitCandidate[] = [];
|
||||||
|
const minGap = portraitMinGapPct(layout.portraitSizePx, canvasWidthPx);
|
||||||
const portraitHalfPct = minGap / 2;
|
const portraitHalfPct = minGap / 2;
|
||||||
|
|
||||||
for (const artist of movementArtists) {
|
for (const artist of movementArtists) {
|
||||||
@@ -213,7 +234,7 @@ function buildArtistPlacements(
|
|||||||
portraitX: x,
|
portraitX: x,
|
||||||
y,
|
y,
|
||||||
colorIndex,
|
colorIndex,
|
||||||
color: artistLifespanColor(layout.movement.color, colorIndex, colorCount),
|
color: artistLifespanColor(layout.displayColor, colorIndex, colorCount),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -271,6 +292,24 @@ function hslToHex(h: number, s: number, l: number): string {
|
|||||||
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
|
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Boost saturation / mid lightness so streams read vividly on the dark flow canvas. */
|
||||||
|
function vividMovementColor(hex: string): string {
|
||||||
|
try {
|
||||||
|
const [r, g, b] = parseHexColor(hex);
|
||||||
|
const [h, s, l] = rgbToHsl(r, g, b);
|
||||||
|
const s2 = s < 0.1 ? Math.min(0.55, s + 0.42) : Math.min(1, s * 1.65 + 0.08);
|
||||||
|
const l2 =
|
||||||
|
l < 0.22
|
||||||
|
? 0.5
|
||||||
|
: l > 0.78
|
||||||
|
? 0.62
|
||||||
|
: Math.min(0.68, Math.max(0.4, l * 0.75 + 0.28));
|
||||||
|
return hslToHex(h, s2, l2);
|
||||||
|
} catch {
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function artistLifespanColor(baseColor: string, laneIndex: number, laneCount: number): string {
|
function artistLifespanColor(baseColor: string, laneIndex: number, laneCount: number): string {
|
||||||
if (laneCount <= 1) return baseColor;
|
if (laneCount <= 1) return baseColor;
|
||||||
try {
|
try {
|
||||||
@@ -332,7 +371,10 @@ function assignDepths(movements: ArtMovement[], lineageParents: Map<number, numb
|
|||||||
return depth;
|
return depth;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pack movements into lanes — only concurrent (overlapping) spans need separate lanes. */
|
/** Same-lane movements must leave at least this many years between one end and the next start. */
|
||||||
|
const LANE_MIN_GAP_YEARS = 10;
|
||||||
|
|
||||||
|
/** Pack movements into lanes — overlapping or closer than LANE_MIN_GAP_YEARS need separate lanes. */
|
||||||
function assignTemporalLanes(
|
function assignTemporalLanes(
|
||||||
group: ArtMovement[],
|
group: ArtMovement[],
|
||||||
viewStart: number,
|
viewStart: number,
|
||||||
@@ -351,7 +393,7 @@ function assignTemporalLanes(
|
|||||||
const laneById = new Map<number, number>();
|
const laneById = new Map<number, number>();
|
||||||
|
|
||||||
for (const span of spans) {
|
for (const span of spans) {
|
||||||
let lane = laneEnds.findIndex((endYear) => endYear <= span.start);
|
let lane = laneEnds.findIndex((endYear) => endYear + LANE_MIN_GAP_YEARS <= span.start);
|
||||||
if (lane === -1) {
|
if (lane === -1) {
|
||||||
lane = laneEnds.length;
|
lane = laneEnds.length;
|
||||||
laneEnds.push(span.end);
|
laneEnds.push(span.end);
|
||||||
@@ -368,8 +410,9 @@ function spansOverlap(a0: number, a1: number, b0: number, b1: number, tol = 0.15
|
|||||||
return a0 < b1 - tol && b0 < a1 - tol;
|
return a0 < b1 - tol && b0 < a1 - tol;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when spans overlap or the gap between them is shorter than LANE_MIN_GAP_YEARS. */
|
||||||
function yearSpansConflict(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
|
function yearSpansConflict(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
|
||||||
return aStart < bEnd && bStart < aEnd;
|
return !(aEnd + LANE_MIN_GAP_YEARS <= bStart || bEnd + LANE_MIN_GAP_YEARS <= aStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Approximate branch X span (same origin/target X as branch assembly), padded for thick strokes. */
|
/** Approximate branch X span (same origin/target X as branch assembly), padded for thick strokes. */
|
||||||
@@ -379,17 +422,42 @@ function branchCorridorXRange(
|
|||||||
childXStart: number,
|
childXStart: number,
|
||||||
childIndex: number,
|
childIndex: number,
|
||||||
childCount: number
|
childCount: number
|
||||||
): { x0: number; x1: number } {
|
): { x0: number; x1: number } | null {
|
||||||
const t = childCount === 1 ? 0.5 : (childIndex + 1) / (childCount + 1);
|
const originX = forwardBranchOriginX(parentXStart, parentXEnd, childXStart, childIndex, childCount);
|
||||||
const originX = parentXStart + t * (parentXEnd - parentXStart);
|
if (originX == null) return null;
|
||||||
const targetX = childXStart;
|
const targetX = childXStart;
|
||||||
const x0 = Math.min(originX, targetX);
|
const x0 = originX;
|
||||||
const x1 = Math.max(originX, targetX);
|
const x1 = targetX;
|
||||||
// Near-vertical transitions still have a wide SVG stroke — pad so overlapping streams register.
|
// Near-vertical transitions still have a wide SVG stroke — pad so overlapping streams register.
|
||||||
const pad = Math.max(4, (x1 - x0) * 0.35);
|
const pad = Math.max(4, (x1 - x0) * 0.35);
|
||||||
return { x0: x0 - pad, x1: x1 + pad };
|
return { x0: x0 - pad, x1: x1 + pad };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Minimum horizontal gap so transitions always read left→right (earlier→later). */
|
||||||
|
const BRANCH_MIN_FORWARD_DX = 0.4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a parent-stream X that is always strictly left of the child target (time-forward).
|
||||||
|
* Returns null when the parent band cannot leave before the child start.
|
||||||
|
*/
|
||||||
|
function forwardBranchOriginX(
|
||||||
|
parentXStart: number,
|
||||||
|
parentXEnd: number,
|
||||||
|
targetX: number,
|
||||||
|
childIndex: number,
|
||||||
|
childCount: number
|
||||||
|
): number | null {
|
||||||
|
const maxOrigin = targetX - BRANCH_MIN_FORWARD_DX;
|
||||||
|
if (maxOrigin < parentXStart) return null;
|
||||||
|
|
||||||
|
const t = childCount === 1 ? 0.5 : (childIndex + 1) / (childCount + 1);
|
||||||
|
let x = parentXStart + t * (parentXEnd - parentXStart);
|
||||||
|
x = Math.min(x, maxOrigin);
|
||||||
|
x = Math.max(parentXStart, Math.min(parentXEnd, x));
|
||||||
|
if (x >= targetX) return null;
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
function clippedMovementSpan(
|
function clippedMovementSpan(
|
||||||
movement: ArtMovement,
|
movement: ArtMovement,
|
||||||
viewStart: number,
|
viewStart: number,
|
||||||
@@ -569,13 +637,15 @@ function refineLanesForLineageCorridors(
|
|||||||
|
|
||||||
const children = childIdsByParent.get(parentId) || [childId];
|
const children = childIdsByParent.get(parentId) || [childId];
|
||||||
const childIndex = Math.max(0, children.indexOf(childId));
|
const childIndex = Math.max(0, children.indexOf(childId));
|
||||||
const { x0, x1 } = branchCorridorXRange(
|
const corridor = branchCorridorXRange(
|
||||||
parentSpan.xStart,
|
parentSpan.xStart,
|
||||||
parentSpan.xEnd,
|
parentSpan.xEnd,
|
||||||
childSpan.xStart,
|
childSpan.xStart,
|
||||||
childIndex,
|
childIndex,
|
||||||
children.length
|
children.length
|
||||||
);
|
);
|
||||||
|
if (!corridor) continue;
|
||||||
|
const { x0, x1 } = corridor;
|
||||||
|
|
||||||
const lo = Math.min(parentLane, childLane);
|
const lo = Math.min(parentLane, childLane);
|
||||||
const hi = Math.max(parentLane, childLane);
|
const hi = Math.max(parentLane, childLane);
|
||||||
@@ -650,12 +720,118 @@ function branchPath(xFrom: number, yFrom: number, xTo: number, yTo: number): str
|
|||||||
return `M ${xFrom} ${yFrom} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${xTo} ${yTo}`;
|
return `M ${xFrom} ${yFrom} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${xTo} ${yTo}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function branchOriginOnParent(parent: MovementLayout, childIndex: number, childCount: number): {
|
/** Exponential chase rate (1/s) so pan/zoom layout shifts read as motion, not snaps. */
|
||||||
x: number;
|
const LAYOUT_ANIM_RATE = 14;
|
||||||
y: number;
|
const LAYOUT_ANIM_EPS = 0.06;
|
||||||
} {
|
|
||||||
const t = childCount === 1 ? 0.5 : (childIndex + 1) / (childCount + 1);
|
function lerp(a: number, b: number, t: number): number {
|
||||||
const x = parent.xStart + t * (parent.xEnd - parent.xStart);
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function geomSettled(a: number, b: number, eps = LAYOUT_ANIM_EPS): boolean {
|
||||||
|
return Math.abs(a - b) <= eps;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerpMovementLayout(from: MovementLayout, to: MovementLayout, t: number): MovementLayout {
|
||||||
|
return {
|
||||||
|
...to,
|
||||||
|
xStart: lerp(from.xStart, to.xStart, t),
|
||||||
|
xEnd: lerp(from.xEnd, to.xEnd, t),
|
||||||
|
y: lerp(from.y, to.y, t),
|
||||||
|
yEnd: lerp(from.yEnd, to.yEnd, t),
|
||||||
|
strokePx: lerp(from.strokePx, to.strokePx, t),
|
||||||
|
portraitSizePx: lerp(from.portraitSizePx, to.portraitSizePx, t),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function movementLayoutSettled(a: MovementLayout, b: MovementLayout): boolean {
|
||||||
|
return (
|
||||||
|
geomSettled(a.xStart, b.xStart) &&
|
||||||
|
geomSettled(a.xEnd, b.xEnd) &&
|
||||||
|
geomSettled(a.y, b.y) &&
|
||||||
|
geomSettled(a.yEnd, b.yEnd) &&
|
||||||
|
geomSettled(a.strokePx, b.strokePx, 0.35)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerpBranchSegment(from: BranchSegment, to: BranchSegment, t: number): BranchSegment {
|
||||||
|
const x1 = lerp(from.x1, to.x1, t);
|
||||||
|
const y1 = lerp(from.y1, to.y1, t);
|
||||||
|
const x2 = lerp(from.x2, to.x2, t);
|
||||||
|
const y2 = lerp(from.y2, to.y2, t);
|
||||||
|
const strokePx = lerp(from.strokePx, to.strokePx, t);
|
||||||
|
return {
|
||||||
|
...to,
|
||||||
|
x1,
|
||||||
|
y1,
|
||||||
|
x2,
|
||||||
|
y2,
|
||||||
|
strokePx,
|
||||||
|
d: branchPath(x1, y1, x2, y2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchSegmentSettled(a: BranchSegment, b: BranchSegment): boolean {
|
||||||
|
return (
|
||||||
|
geomSettled(a.x1, b.x1) &&
|
||||||
|
geomSettled(a.y1, b.y1) &&
|
||||||
|
geomSettled(a.x2, b.x2) &&
|
||||||
|
geomSettled(a.y2, b.y2) &&
|
||||||
|
geomSettled(a.strokePx, b.strokePx, 0.35)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnimatedFlowGeometry {
|
||||||
|
layouts: MovementLayout[];
|
||||||
|
branches: BranchSegment[];
|
||||||
|
streamCurveOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blendFlowGeometry(
|
||||||
|
from: AnimatedFlowGeometry,
|
||||||
|
to: AnimatedFlowGeometry,
|
||||||
|
t: number
|
||||||
|
): { next: AnimatedFlowGeometry; settled: boolean } {
|
||||||
|
const fromLayouts = new Map(from.layouts.map((l) => [l.movement.id, l]));
|
||||||
|
const fromBranches = new Map(from.branches.map((b) => [b.key, b]));
|
||||||
|
let settled = geomSettled(from.streamCurveOffset, to.streamCurveOffset, 0.15);
|
||||||
|
|
||||||
|
const layouts = to.layouts.map((target) => {
|
||||||
|
const prev = fromLayouts.get(target.movement.id);
|
||||||
|
if (!prev) return target;
|
||||||
|
if (movementLayoutSettled(prev, target)) return target;
|
||||||
|
settled = false;
|
||||||
|
return lerpMovementLayout(prev, target, t);
|
||||||
|
});
|
||||||
|
|
||||||
|
const branches = to.branches.map((target) => {
|
||||||
|
const prev = fromBranches.get(target.key);
|
||||||
|
if (!prev) return target;
|
||||||
|
if (branchSegmentSettled(prev, target)) return target;
|
||||||
|
settled = false;
|
||||||
|
return lerpBranchSegment(prev, target, t);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
next: {
|
||||||
|
layouts,
|
||||||
|
branches,
|
||||||
|
streamCurveOffset: settled
|
||||||
|
? to.streamCurveOffset
|
||||||
|
: lerp(from.streamCurveOffset, to.streamCurveOffset, t),
|
||||||
|
},
|
||||||
|
settled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchOriginOnParent(
|
||||||
|
parent: MovementLayout,
|
||||||
|
childIndex: number,
|
||||||
|
childCount: number,
|
||||||
|
targetX: number
|
||||||
|
): { x: number; y: number } | null {
|
||||||
|
const x = forwardBranchOriginX(parent.xStart, parent.xEnd, targetX, childIndex, childCount);
|
||||||
|
if (x == null) return null;
|
||||||
return { x, y: yOnStream(parent, x) };
|
return { x, y: yOnStream(parent, x) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -791,19 +967,24 @@ function buildLabelPlacements(
|
|||||||
layoutHeight: number,
|
layoutHeight: number,
|
||||||
canvasWidth: number,
|
canvasWidth: number,
|
||||||
canvasHeight: number,
|
canvasHeight: number,
|
||||||
streamStrokePx: number,
|
|
||||||
portraitSizePx: number,
|
|
||||||
branches: BranchSegment[] = []
|
branches: BranchSegment[] = []
|
||||||
): LabelPlacement[] {
|
): LabelPlacement[] {
|
||||||
if (layouts.length === 0 || canvasWidth <= 0 || canvasHeight <= 0) return [];
|
if (layouts.length === 0 || canvasWidth <= 0 || canvasHeight <= 0) return [];
|
||||||
|
|
||||||
const portraitObstacles = artistPlacements.map((p) =>
|
const portraitObstacles = artistPlacements.map((p) =>
|
||||||
portraitObstacleRect(p.portraitX, p.y, portraitSizePx, layoutHeight, canvasWidth, canvasHeight)
|
portraitObstacleRect(
|
||||||
|
p.portraitX,
|
||||||
|
p.y,
|
||||||
|
p.layout.portraitSizePx,
|
||||||
|
layoutHeight,
|
||||||
|
canvasWidth,
|
||||||
|
canvasHeight
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const bandObstacles = layouts.map((layout) => ({
|
const bandObstacles = layouts.map((layout) => ({
|
||||||
id: layout.movement.id,
|
id: layout.movement.id,
|
||||||
rect: streamBandObstacleRect(layout, streamStrokePx, layoutHeight, canvasWidth, canvasHeight),
|
rect: streamBandObstacleRect(layout, layout.strokePx, layoutHeight, canvasWidth, canvasHeight),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const branchObstacles = branches.map((branch) => {
|
const branchObstacles = branches.map((branch) => {
|
||||||
@@ -813,7 +994,7 @@ function buildLabelPlacements(
|
|||||||
toId,
|
toId,
|
||||||
rect: branchCorridorObstacleRect(
|
rect: branchCorridorObstacleRect(
|
||||||
branch,
|
branch,
|
||||||
streamStrokePx,
|
branch.strokePx,
|
||||||
layoutHeight,
|
layoutHeight,
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
canvasHeight
|
canvasHeight
|
||||||
@@ -938,6 +1119,7 @@ const MovementArtistPortrait = memo(function MovementArtistPortrait({
|
|||||||
portraitX,
|
portraitX,
|
||||||
y,
|
y,
|
||||||
layoutHeight,
|
layoutHeight,
|
||||||
|
portraitSizePx,
|
||||||
color,
|
color,
|
||||||
colorIndex,
|
colorIndex,
|
||||||
isHovered,
|
isHovered,
|
||||||
@@ -959,6 +1141,7 @@ const MovementArtistPortrait = memo(function MovementArtistPortrait({
|
|||||||
portraitX: number;
|
portraitX: number;
|
||||||
y: number;
|
y: number;
|
||||||
layoutHeight: number;
|
layoutHeight: number;
|
||||||
|
portraitSizePx: number;
|
||||||
color: string;
|
color: string;
|
||||||
colorIndex: number;
|
colorIndex: number;
|
||||||
isHovered: boolean;
|
isHovered: boolean;
|
||||||
@@ -1017,6 +1200,8 @@ const MovementArtistPortrait = memo(function MovementArtistPortrait({
|
|||||||
top: `${(y / layoutHeight) * 100}%`,
|
top: `${(y / layoutHeight) * 100}%`,
|
||||||
borderColor: color,
|
borderColor: color,
|
||||||
zIndex: isHovered ? 13 : 5 + colorIndex,
|
zIndex: isHovered ? 13 : 5 + colorIndex,
|
||||||
|
width: `${portraitSizePx}px`,
|
||||||
|
height: `${portraitSizePx}px`,
|
||||||
}}
|
}}
|
||||||
onClick={() => onArtistClick(artist.id)}
|
onClick={() => onArtistClick(artist.id)}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
@@ -1079,6 +1264,7 @@ export default function MovementBands({
|
|||||||
const [portraitsLoading, setPortraitsLoading] = useState(false);
|
const [portraitsLoading, setPortraitsLoading] = useState(false);
|
||||||
const [panning, setPanning] = useState(false);
|
const [panning, setPanning] = useState(false);
|
||||||
const [interacting, setInteracting] = useState(false);
|
const [interacting, setInteracting] = useState(false);
|
||||||
|
const [layoutAnimating, setLayoutAnimating] = useState(false);
|
||||||
const [canvasHeight, setCanvasHeight] = useState(DEFAULT_CANVAS_HEIGHT);
|
const [canvasHeight, setCanvasHeight] = useState(DEFAULT_CANVAS_HEIGHT);
|
||||||
const [canvasWidth, setCanvasWidth] = useState(800);
|
const [canvasWidth, setCanvasWidth] = useState(800);
|
||||||
const [hoveredArtistKey, setHoveredArtistKey] = useState<string | null>(null);
|
const [hoveredArtistKey, setHoveredArtistKey] = useState<string | null>(null);
|
||||||
@@ -1114,6 +1300,11 @@ export default function MovementBands({
|
|||||||
const interactionTimer = useRef<number | null>(null);
|
const interactionTimer = useRef<number | null>(null);
|
||||||
const viewRef = useRef({ viewStart, viewEnd });
|
const viewRef = useRef({ viewStart, viewEnd });
|
||||||
const onViewChangeRef = useRef(onViewChange);
|
const onViewChangeRef = useRef(onViewChange);
|
||||||
|
const flowTargetRef = useRef<AnimatedFlowGeometry | null>(null);
|
||||||
|
const flowVisualRef = useRef<AnimatedFlowGeometry | null>(null);
|
||||||
|
const flowRafRef = useRef<number | null>(null);
|
||||||
|
const flowLastTsRef = useRef(0);
|
||||||
|
const [flowVisual, setFlowVisual] = useState<AnimatedFlowGeometry | null>(null);
|
||||||
viewRef.current = { viewStart, viewEnd };
|
viewRef.current = { viewStart, viewEnd };
|
||||||
onViewChangeRef.current = onViewChange;
|
onViewChangeRef.current = onViewChange;
|
||||||
|
|
||||||
@@ -1139,6 +1330,7 @@ export default function MovementBands({
|
|||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
if (interactionTimer.current != null) window.clearTimeout(interactionTimer.current);
|
if (interactionTimer.current != null) window.clearTimeout(interactionTimer.current);
|
||||||
|
if (flowRafRef.current != null) cancelAnimationFrame(flowRafRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1280,6 +1472,18 @@ export default function MovementBands({
|
|||||||
const layoutHeight = Math.max(200, canvasHeight);
|
const layoutHeight = Math.max(200, canvasHeight);
|
||||||
const usableHeight = layoutHeight - CANVAS_TOP_PAD - CANVAS_BOTTOM_PAD;
|
const usableHeight = layoutHeight - CANVAS_TOP_PAD - CANVAS_BOTTOM_PAD;
|
||||||
|
|
||||||
|
const maxInfluence = Math.max(
|
||||||
|
1,
|
||||||
|
...visibleMovements.map((m) => movementInfluenceCount(m))
|
||||||
|
);
|
||||||
|
const desiredStrokeById = new Map<number, number>();
|
||||||
|
for (const movement of visibleMovements) {
|
||||||
|
desiredStrokeById.set(
|
||||||
|
movement.id,
|
||||||
|
desiredStrokeForCount(movementInfluenceCount(movement), maxInfluence)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Pack all visible movements into shared horizontal lanes whenever their
|
// Pack all visible movements into shared horizontal lanes whenever their
|
||||||
// clipped time spans do not overlap, then clear unrelated streams out of
|
// clipped time spans do not overlap, then clear unrelated streams out of
|
||||||
// lineage branch corridors (parent/child adjacency preferred).
|
// lineage branch corridors (parent/child adjacency preferred).
|
||||||
@@ -1295,26 +1499,57 @@ export default function MovementBands({
|
|||||||
|
|
||||||
let maxLanes = 0;
|
let maxLanes = 0;
|
||||||
const laneOccupancy = new Map<number, number>();
|
const laneOccupancy = new Map<number, number>();
|
||||||
|
const laneMaxDesiredStroke = new Map<number, number>();
|
||||||
for (const movement of visibleMovements) {
|
for (const movement of visibleMovements) {
|
||||||
const lane = laneIndex.get(movement.id) ?? 0;
|
const lane = laneIndex.get(movement.id) ?? 0;
|
||||||
maxLanes = Math.max(maxLanes, lane + 1);
|
maxLanes = Math.max(maxLanes, lane + 1);
|
||||||
laneOccupancy.set(lane, (laneOccupancy.get(lane) ?? 0) + 1);
|
laneOccupancy.set(lane, (laneOccupancy.get(lane) ?? 0) + 1);
|
||||||
|
const desired = desiredStrokeById.get(movement.id) ?? ABS_MIN_STREAM_STROKE_PX;
|
||||||
|
laneMaxDesiredStroke.set(lane, Math.max(laneMaxDesiredStroke.get(lane) ?? 0, desired));
|
||||||
}
|
}
|
||||||
maxLanes = Math.max(1, maxLanes);
|
maxLanes = Math.max(1, maxLanes);
|
||||||
|
|
||||||
// Always fit every lane inside the canvas — never floor laneStep to min stroke
|
// Lane heights proportional to the thickest band in each lane (influence-weighted).
|
||||||
// (that pushed lower streams/branches past the bottom edge).
|
const laneHeightsDesired: number[] = [];
|
||||||
const laneStep = usableHeight / maxLanes;
|
for (let lane = 0; lane < maxLanes; lane++) {
|
||||||
let streamStrokePx = Math.min(MAX_STREAM_STROKE_PX, laneStep - Math.min(LANE_GAP_PX, laneStep * 0.25));
|
const stroke = laneMaxDesiredStroke.get(lane) ?? ABS_MIN_STREAM_STROKE_PX;
|
||||||
streamStrokePx = Math.max(ABS_MIN_STREAM_STROKE_PX, streamStrokePx);
|
laneHeightsDesired[lane] = stroke + LANE_GAP_PX;
|
||||||
if (laneStep >= MIN_STREAM_STROKE_PX + LANE_GAP_PX) {
|
}
|
||||||
streamStrokePx = Math.max(MIN_STREAM_STROKE_PX, streamStrokePx);
|
const desiredTotal = laneHeightsDesired.reduce((sum, h) => sum + h, 0) || 1;
|
||||||
|
const fitScale = usableHeight / desiredTotal;
|
||||||
|
const laneHeights = laneHeightsDesired.map((h) => h * fitScale);
|
||||||
|
const laneCenters: number[] = [];
|
||||||
|
let yCursor = CANVAS_TOP_PAD;
|
||||||
|
for (let lane = 0; lane < maxLanes; lane++) {
|
||||||
|
laneCenters[lane] = yCursor + laneHeights[lane] / 2;
|
||||||
|
yCursor += laneHeights[lane];
|
||||||
}
|
}
|
||||||
const portraitSizePx = Math.max(21, Math.min(39, streamStrokePx * 0.72));
|
|
||||||
const streamCurveOffset = Math.min(12, Math.max(2, (laneStep - streamStrokePx) * 0.35));
|
|
||||||
|
|
||||||
const yMax = layoutHeight - CANVAS_BOTTOM_PAD - streamStrokePx / 2;
|
let maxStrokePx = ABS_MIN_STREAM_STROKE_PX;
|
||||||
const yMin = CANVAS_TOP_PAD + streamStrokePx / 2;
|
const strokeById = new Map<number, number>();
|
||||||
|
for (const movement of visibleMovements) {
|
||||||
|
const lane = laneIndex.get(movement.id) ?? 0;
|
||||||
|
const raw = (desiredStrokeById.get(movement.id) ?? ABS_MIN_STREAM_STROKE_PX) * fitScale;
|
||||||
|
const laneCap = Math.max(
|
||||||
|
ABS_MIN_STREAM_STROKE_PX,
|
||||||
|
laneHeights[lane] - LANE_GAP_PX * Math.min(1, fitScale)
|
||||||
|
);
|
||||||
|
const strokePx = Math.min(
|
||||||
|
MAX_STREAM_STROKE_PX,
|
||||||
|
Math.max(ABS_MIN_STREAM_STROKE_PX, Math.min(raw, laneCap))
|
||||||
|
);
|
||||||
|
strokeById.set(movement.id, strokePx);
|
||||||
|
maxStrokePx = Math.max(maxStrokePx, strokePx);
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamStrokePx = maxStrokePx;
|
||||||
|
const portraitSizePx = portraitSizeForStroke(streamStrokePx);
|
||||||
|
const medianLaneHeight =
|
||||||
|
laneHeights.reduce((sum, h) => sum + h, 0) / Math.max(1, laneHeights.length);
|
||||||
|
const streamCurveOffset = Math.min(12, Math.max(2, medianLaneHeight * 0.12));
|
||||||
|
|
||||||
|
const yMax = layoutHeight - CANVAS_BOTTOM_PAD - ABS_MIN_STREAM_STROKE_PX / 2;
|
||||||
|
const yMin = CANVAS_TOP_PAD + ABS_MIN_STREAM_STROKE_PX / 2;
|
||||||
|
|
||||||
const layoutById = new Map<number, MovementLayout>();
|
const layoutById = new Map<number, MovementLayout>();
|
||||||
const branchList: BranchSegment[] = [];
|
const branchList: BranchSegment[] = [];
|
||||||
@@ -1327,9 +1562,11 @@ export default function MovementBands({
|
|||||||
|
|
||||||
const depth = depths.get(movement.id) ?? 0;
|
const depth = depths.get(movement.id) ?? 0;
|
||||||
const lane = laneIndex.get(movement.id) ?? 0;
|
const lane = laneIndex.get(movement.id) ?? 0;
|
||||||
const yRaw = CANVAS_TOP_PAD + lane * laneStep + laneStep / 2;
|
const strokePx = strokeById.get(movement.id) ?? ABS_MIN_STREAM_STROKE_PX;
|
||||||
|
const yRaw = laneCenters[lane] ?? CANVAS_TOP_PAD + usableHeight / 2;
|
||||||
const y = Math.min(yMax, Math.max(yMin, yRaw));
|
const y = Math.min(yMax, Math.max(yMin, yRaw));
|
||||||
const allowDrift = (laneOccupancy.get(lane) ?? 0) === 1 && laneStep >= 80;
|
const allowDrift =
|
||||||
|
(laneOccupancy.get(lane) ?? 0) === 1 && (laneHeights[lane] ?? 0) >= 80;
|
||||||
const drift = allowDrift ? organicDrift(movement.id + 1000) * 0.22 : 0;
|
const drift = allowDrift ? organicDrift(movement.id + 1000) * 0.22 : 0;
|
||||||
const yEnd = Math.min(yMax, Math.max(yMin, y + drift));
|
const yEnd = Math.min(yMax, Math.max(yMin, y + drift));
|
||||||
const parentIds = lineageParents.get(movement.id) || [];
|
const parentIds = lineageParents.get(movement.id) || [];
|
||||||
@@ -1342,6 +1579,9 @@ export default function MovementBands({
|
|||||||
yEnd,
|
yEnd,
|
||||||
depth,
|
depth,
|
||||||
parentIds,
|
parentIds,
|
||||||
|
strokePx,
|
||||||
|
portraitSizePx: portraitSizeForStroke(strokePx),
|
||||||
|
displayColor: vividMovementColor(movement.color),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1368,18 +1608,20 @@ export default function MovementBands({
|
|||||||
|
|
||||||
const children = childIdsByParent.get(parentId)!;
|
const children = childIdsByParent.get(parentId)!;
|
||||||
const childIndex = children.indexOf(layout.movement.id);
|
const childIndex = children.indexOf(layout.movement.id);
|
||||||
const origin = branchOriginOnParent(parent, childIndex, children.length);
|
|
||||||
const target = branchTargetOnChild(layout);
|
const target = branchTargetOnChild(layout);
|
||||||
|
const origin = branchOriginOnParent(parent, childIndex, children.length, target.x);
|
||||||
|
if (!origin) continue;
|
||||||
|
|
||||||
branchList.push({
|
branchList.push({
|
||||||
key: `${parentId}-${layout.movement.id}`,
|
key: `${parentId}-${layout.movement.id}`,
|
||||||
d: branchPath(origin.x, origin.y, target.x, target.y),
|
d: branchPath(origin.x, origin.y, target.x, target.y),
|
||||||
colorFrom: parent.movement.color,
|
colorFrom: parent.displayColor,
|
||||||
colorTo: layout.movement.color,
|
colorTo: layout.displayColor,
|
||||||
x1: origin.x,
|
x1: origin.x,
|
||||||
y1: origin.y,
|
y1: origin.y,
|
||||||
x2: target.x,
|
x2: target.x,
|
||||||
y2: target.y,
|
y2: target.y,
|
||||||
|
strokePx: layout.strokePx,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1395,6 +1637,47 @@ export default function MovementBands({
|
|||||||
};
|
};
|
||||||
}, [visibleMovements, movements, viewStart, viewEnd, canvasHeight]);
|
}, [visibleMovements, movements, viewStart, viewEnd, canvasHeight]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const target: AnimatedFlowGeometry = { layouts, branches, streamCurveOffset };
|
||||||
|
flowTargetRef.current = target;
|
||||||
|
|
||||||
|
if (!flowVisualRef.current) {
|
||||||
|
flowVisualRef.current = target;
|
||||||
|
setFlowVisual(target);
|
||||||
|
setLayoutAnimating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLayoutAnimating(true);
|
||||||
|
if (flowRafRef.current != null) return;
|
||||||
|
|
||||||
|
flowLastTsRef.current = performance.now();
|
||||||
|
const step = (now: number) => {
|
||||||
|
const prev = flowVisualRef.current;
|
||||||
|
const goal = flowTargetRef.current;
|
||||||
|
if (!prev || !goal) {
|
||||||
|
flowRafRef.current = null;
|
||||||
|
setLayoutAnimating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dt = Math.min(0.048, Math.max(0, (now - flowLastTsRef.current) / 1000));
|
||||||
|
flowLastTsRef.current = now;
|
||||||
|
const t = 1 - Math.exp(-LAYOUT_ANIM_RATE * dt);
|
||||||
|
const { next, settled } = blendFlowGeometry(prev, goal, t);
|
||||||
|
flowVisualRef.current = settled ? goal : next;
|
||||||
|
setFlowVisual(flowVisualRef.current);
|
||||||
|
|
||||||
|
if (settled) {
|
||||||
|
flowRafRef.current = null;
|
||||||
|
setLayoutAnimating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
flowRafRef.current = requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
flowRafRef.current = requestAnimationFrame(step);
|
||||||
|
}, [layouts, branches, streamCurveOffset]);
|
||||||
|
|
||||||
const artistPlacementsForLabels = useMemo(
|
const artistPlacementsForLabels = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildArtistPlacements(
|
buildArtistPlacements(
|
||||||
@@ -1402,16 +1685,15 @@ export default function MovementBands({
|
|||||||
artistsByMovement,
|
artistsByMovement,
|
||||||
viewStart,
|
viewStart,
|
||||||
viewEnd,
|
viewEnd,
|
||||||
portraitSizePx,
|
|
||||||
canvasWidth
|
canvasWidth
|
||||||
),
|
),
|
||||||
[layouts, artistsByMovement, viewStart, viewEnd, portraitSizePx, canvasWidth]
|
[layouts, artistsByMovement, viewStart, viewEnd, canvasWidth]
|
||||||
);
|
);
|
||||||
|
|
||||||
const artistPlacements = useMemo(() => {
|
const artistPlacements = useMemo(() => {
|
||||||
if (interacting || panning) return [];
|
if (interacting || panning || layoutAnimating) return [];
|
||||||
return artistPlacementsForLabels;
|
return artistPlacementsForLabels;
|
||||||
}, [artistPlacementsForLabels, interacting, panning]);
|
}, [artistPlacementsForLabels, interacting, panning, layoutAnimating]);
|
||||||
|
|
||||||
const labelPlacements = useMemo(
|
const labelPlacements = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -1421,8 +1703,6 @@ export default function MovementBands({
|
|||||||
layoutHeight,
|
layoutHeight,
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
streamStrokePx,
|
|
||||||
portraitSizePx,
|
|
||||||
branches
|
branches
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
@@ -1431,8 +1711,6 @@ export default function MovementBands({
|
|||||||
layoutHeight,
|
layoutHeight,
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
streamStrokePx,
|
|
||||||
portraitSizePx,
|
|
||||||
branches,
|
branches,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -1454,8 +1732,12 @@ export default function MovementBands({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const layoutById = new Map(layouts.map((l) => [l.movement.id, l]));
|
const layoutById = new Map((flowVisual?.layouts ?? layouts).map((l) => [l.movement.id, l]));
|
||||||
|
const drawLayouts = flowVisual?.layouts ?? layouts;
|
||||||
|
const drawBranches = flowVisual?.branches ?? branches;
|
||||||
|
const drawCurveOffset = flowVisual?.streamCurveOffset ?? streamCurveOffset;
|
||||||
const fastGraphics = interacting || panning;
|
const fastGraphics = interacting || panning;
|
||||||
|
const overlaysReady = !fastGraphics && !layoutAnimating;
|
||||||
const showPortraits = hoveredMovementId != null || hoveredArtistKey != null;
|
const showPortraits = hoveredMovementId != null || hoveredArtistKey != null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1500,24 +1782,24 @@ export default function MovementBands({
|
|||||||
height={layoutHeight}
|
height={layoutHeight}
|
||||||
>
|
>
|
||||||
<rect x={0} y={0} width={100} height={layoutHeight} fill="white" />
|
<rect x={0} y={0} width={100} height={layoutHeight} fill="white" />
|
||||||
{layouts.map((layout) => (
|
{drawLayouts.map((layout) => (
|
||||||
<path
|
<path
|
||||||
key={`branch-cutout-${layout.movement.id}`}
|
key={`branch-cutout-${layout.movement.id}`}
|
||||||
d={streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset)}
|
d={streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, drawCurveOffset)}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="black"
|
stroke="black"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
style={{
|
style={{
|
||||||
strokeWidth: streamStrokePx * 1.12,
|
strokeWidth: layout.strokePx * 1.12,
|
||||||
vectorEffect: 'non-scaling-stroke',
|
vectorEffect: 'non-scaling-stroke',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</mask>
|
</mask>
|
||||||
|
|
||||||
{!fastGraphics &&
|
{!fastGraphics &&
|
||||||
branches.map((branch) => (
|
drawBranches.map((branch) => (
|
||||||
<linearGradient
|
<linearGradient
|
||||||
key={`branch-grad-${branch.key}`}
|
key={`branch-grad-${branch.key}`}
|
||||||
id={`branch-grad-${branch.key}`}
|
id={`branch-grad-${branch.key}`}
|
||||||
@@ -1527,17 +1809,17 @@ export default function MovementBands({
|
|||||||
x2={branch.x2}
|
x2={branch.x2}
|
||||||
y2={branch.y2}
|
y2={branch.y2}
|
||||||
>
|
>
|
||||||
<stop offset="0%" stopColor={branch.colorFrom} stopOpacity={0.36} />
|
<stop offset="0%" stopColor={branch.colorFrom} stopOpacity={0.55} />
|
||||||
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0.36} />
|
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0.55} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{!fastGraphics &&
|
{!fastGraphics &&
|
||||||
layouts.map((layout) => {
|
drawLayouts.map((layout) => {
|
||||||
const primaryParent =
|
const primaryParent =
|
||||||
layout.parentIds.length > 0 ? layoutById.get(layout.parentIds[0]) : null;
|
layout.parentIds.length > 0 ? layoutById.get(layout.parentIds[0]) : null;
|
||||||
const hasChildren = (childIdsByParent.get(layout.movement.id)?.length ?? 0) > 0;
|
const hasChildren = (childIdsByParent.get(layout.movement.id)?.length ?? 0) > 0;
|
||||||
const parentColor = primaryParent?.movement.color ?? layout.movement.color;
|
const parentColor = primaryParent?.displayColor ?? layout.displayColor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<linearGradient
|
<linearGradient
|
||||||
@@ -1551,29 +1833,29 @@ export default function MovementBands({
|
|||||||
>
|
>
|
||||||
{primaryParent ? (
|
{primaryParent ? (
|
||||||
<>
|
<>
|
||||||
<stop offset="0%" stopColor={parentColor} stopOpacity={0.28} />
|
<stop offset="0%" stopColor={parentColor} stopOpacity={0.48} />
|
||||||
<stop offset="16%" stopColor={layout.movement.color} stopOpacity={0.34} />
|
<stop offset="16%" stopColor={layout.displayColor} stopOpacity={0.58} />
|
||||||
<stop offset="32%" stopColor={layout.movement.color} stopOpacity={0.38} />
|
<stop offset="32%" stopColor={layout.displayColor} stopOpacity={0.64} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<stop
|
<stop
|
||||||
offset="0%"
|
offset="0%"
|
||||||
stopColor={layout.movement.color}
|
stopColor={layout.displayColor}
|
||||||
stopOpacity={layout.movement.start_definite ? 0.28 : 0.08}
|
stopOpacity={layout.movement.start_definite ? 0.5 : 0.18}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<stop offset="50%" stopColor={layout.movement.color} stopOpacity={0.38} />
|
<stop offset="50%" stopColor={layout.displayColor} stopOpacity={0.64} />
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
<>
|
<>
|
||||||
<stop offset="68%" stopColor={layout.movement.color} stopOpacity={0.38} />
|
<stop offset="68%" stopColor={layout.displayColor} stopOpacity={0.64} />
|
||||||
<stop offset="84%" stopColor={layout.movement.color} stopOpacity={0.22} />
|
<stop offset="84%" stopColor={layout.displayColor} stopOpacity={0.4} />
|
||||||
<stop offset="100%" stopColor={layout.movement.color} stopOpacity={0} />
|
<stop offset="100%" stopColor={layout.displayColor} stopOpacity={0} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<stop
|
<stop
|
||||||
offset="100%"
|
offset="100%"
|
||||||
stopColor={layout.movement.color}
|
stopColor={layout.displayColor}
|
||||||
stopOpacity={layout.movement.end_definite ? 0.28 : 0.08}
|
stopOpacity={layout.movement.end_definite ? 0.5 : 0.18}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
@@ -1584,25 +1866,27 @@ export default function MovementBands({
|
|||||||
{fastGraphics ? (
|
{fastGraphics ? (
|
||||||
<>
|
<>
|
||||||
<g mask="url(#movement-branch-cutout)">
|
<g mask="url(#movement-branch-cutout)">
|
||||||
{branches.map((branch) => (
|
{drawBranches.map((branch) => (
|
||||||
<path
|
<path
|
||||||
key={branch.key}
|
key={branch.key}
|
||||||
d={branch.d}
|
d={branch.d}
|
||||||
className="movement-branch movement-branch-fast"
|
className="movement-branch movement-branch-fast"
|
||||||
stroke={branch.colorTo}
|
stroke={branch.colorTo}
|
||||||
fill="none"
|
fill="none"
|
||||||
|
style={{ strokeWidth: branch.strokePx * 0.45 }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</g>
|
</g>
|
||||||
{layouts.map((layout) => {
|
{drawLayouts.map((layout) => {
|
||||||
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset);
|
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, drawCurveOffset);
|
||||||
return (
|
return (
|
||||||
<path
|
<path
|
||||||
key={`stream-${layout.movement.id}`}
|
key={`stream-${layout.movement.id}`}
|
||||||
d={d}
|
d={d}
|
||||||
className="movement-stream-core movement-stream-fast"
|
className="movement-stream-core movement-stream-fast"
|
||||||
stroke={layout.movement.color}
|
stroke={layout.displayColor}
|
||||||
fill="none"
|
fill="none"
|
||||||
|
style={{ strokeWidth: layout.strokePx }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1610,7 +1894,7 @@ export default function MovementBands({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<g mask="url(#movement-branch-cutout)">
|
<g mask="url(#movement-branch-cutout)">
|
||||||
{branches.map((branch) => {
|
{drawBranches.map((branch) => {
|
||||||
const [fromId, toId] = branch.key.split('-').map(Number);
|
const [fromId, toId] = branch.key.split('-').map(Number);
|
||||||
const branchHighlighted =
|
const branchHighlighted =
|
||||||
hoveredMovementId != null &&
|
hoveredMovementId != null &&
|
||||||
@@ -1622,13 +1906,14 @@ export default function MovementBands({
|
|||||||
className={`movement-branch${branchHighlighted ? ' movement-branch-highlighted' : ''}`}
|
className={`movement-branch${branchHighlighted ? ' movement-branch-highlighted' : ''}`}
|
||||||
stroke={`url(#branch-grad-${branch.key})`}
|
stroke={`url(#branch-grad-${branch.key})`}
|
||||||
fill="none"
|
fill="none"
|
||||||
|
style={{ strokeWidth: branch.strokePx }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
{layouts.map((layout) => {
|
{drawLayouts.map((layout) => {
|
||||||
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset);
|
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, drawCurveOffset);
|
||||||
const streamHighlighted = hoveredMovementId === layout.movement.id;
|
const streamHighlighted = hoveredMovementId === layout.movement.id;
|
||||||
return (
|
return (
|
||||||
<path
|
<path
|
||||||
@@ -1637,19 +1922,20 @@ export default function MovementBands({
|
|||||||
className={`movement-stream-fill${streamHighlighted ? ' movement-stream-highlighted' : ''}`}
|
className={`movement-stream-fill${streamHighlighted ? ' movement-stream-highlighted' : ''}`}
|
||||||
stroke={`url(#stream-grad-${layout.movement.id})`}
|
stroke={`url(#stream-grad-${layout.movement.id})`}
|
||||||
fill="none"
|
fill="none"
|
||||||
|
style={{ strokeWidth: layout.strokePx }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{layouts.map((layout) => {
|
{drawLayouts.map((layout) => {
|
||||||
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset);
|
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, drawCurveOffset);
|
||||||
const streamHighlighted = hoveredMovementId === layout.movement.id;
|
const streamHighlighted = hoveredMovementId === layout.movement.id;
|
||||||
return (
|
return (
|
||||||
<path
|
<path
|
||||||
key={`core-${layout.movement.id}`}
|
key={`core-${layout.movement.id}`}
|
||||||
d={d}
|
d={d}
|
||||||
className={`movement-stream-core${streamHighlighted ? ' movement-stream-highlighted' : ''}`}
|
className={`movement-stream-core${streamHighlighted ? ' movement-stream-highlighted' : ''}`}
|
||||||
stroke={layout.movement.color}
|
stroke={layout.displayColor}
|
||||||
fill="none"
|
fill="none"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -1658,7 +1944,7 @@ export default function MovementBands({
|
|||||||
)}
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{hoveredPlacement && !fastGraphics && (
|
{hoveredPlacement && overlaysReady && (
|
||||||
<div className="movement-lifespan-overlays" aria-hidden>
|
<div className="movement-lifespan-overlays" aria-hidden>
|
||||||
{hoveredPlacement.lineLeft > 0 && (
|
{hoveredPlacement.lineLeft > 0 && (
|
||||||
<div
|
<div
|
||||||
@@ -1686,10 +1972,10 @@ export default function MovementBands({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!fastGraphics && (
|
{overlaysReady && (
|
||||||
<>
|
<>
|
||||||
<div className="movements-flow-hits" aria-hidden>
|
<div className="movements-flow-hits" aria-hidden>
|
||||||
{layouts.map((layout) => (
|
{drawLayouts.map((layout) => (
|
||||||
<div
|
<div
|
||||||
key={`hit-${layout.movement.id}`}
|
key={`hit-${layout.movement.id}`}
|
||||||
className="movement-stream-hit"
|
className="movement-stream-hit"
|
||||||
@@ -1697,6 +1983,7 @@ export default function MovementBands({
|
|||||||
left: `${layout.xStart}%`,
|
left: `${layout.xStart}%`,
|
||||||
width: `${Math.max(layout.xEnd - layout.xStart, 0.5)}%`,
|
width: `${Math.max(layout.xEnd - layout.xStart, 0.5)}%`,
|
||||||
top: `${(((layout.y + layout.yEnd) / 2) / layoutHeight) * 100}%`,
|
top: `${(((layout.y + layout.yEnd) / 2) / layoutHeight) * 100}%`,
|
||||||
|
height: `${layout.strokePx}px`,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setMovementHover(layout.movement.id)}
|
onMouseEnter={() => setMovementHover(layout.movement.id)}
|
||||||
onMouseLeave={clearMovementHoverSoon}
|
onMouseLeave={clearMovementHoverSoon}
|
||||||
@@ -1744,6 +2031,7 @@ export default function MovementBands({
|
|||||||
portraitX={portraitX}
|
portraitX={portraitX}
|
||||||
y={y}
|
y={y}
|
||||||
layoutHeight={layoutHeight}
|
layoutHeight={layoutHeight}
|
||||||
|
portraitSizePx={layout.portraitSizePx}
|
||||||
color={color}
|
color={color}
|
||||||
colorIndex={colorIndex}
|
colorIndex={colorIndex}
|
||||||
isHovered={hoveredArtistKey === artistKey}
|
isHovered={hoveredArtistKey === artistKey}
|
||||||
|
|||||||
@@ -1418,6 +1418,7 @@ function ArtistHall({
|
|||||||
nearPassage?: boolean;
|
nearPassage?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { width, depth, segments } = layout;
|
const { width, depth, segments } = layout;
|
||||||
|
const endWallHasDoor = movementMode && 'endWallHasDoor' in layout ? layout.endWallHasDoor : false;
|
||||||
const halfW = width / 2;
|
const halfW = width / 2;
|
||||||
const halfD = depth / 2;
|
const halfD = depth / 2;
|
||||||
const walls = useMemo(
|
const walls = useMemo(
|
||||||
@@ -1500,7 +1501,7 @@ function ArtistHall({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Front wall — passage to next wing, or solid (artist exit / movement entrance) */}
|
{/* Front wall — next-wing passage, entrance exit (single-wing), or artist exit */}
|
||||||
{movementMode && hasNextHall ? (
|
{movementMode && hasNextHall ? (
|
||||||
<>
|
<>
|
||||||
<GalleryWall
|
<GalleryWall
|
||||||
@@ -1527,10 +1528,8 @@ function ArtistHall({
|
|||||||
trimColor={walls.trim}
|
trimColor={walls.trim}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : movementMode ? (
|
) : movementMode && endWallHasDoor ? (
|
||||||
<>
|
<GalleryWall position={[0, WALL_HEIGHT / 2, halfD]} size={[width, WALL_HEIGHT]} color={walls.main} />
|
||||||
<GalleryWall position={[0, WALL_HEIGHT / 2, halfD]} size={[width, WALL_HEIGHT]} color={walls.main} />
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<GalleryWall position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} color={walls.main} />
|
<GalleryWall position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} color={walls.main} />
|
||||||
@@ -1547,8 +1546,8 @@ function ArtistHall({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Back wall — movement exit / navigation, or solid with title */}
|
{/* Back wall — multi-wing exit / navigator, or solid end wall for paintings */}
|
||||||
{movementMode ? (
|
{movementMode && endWallHasDoor ? (
|
||||||
<>
|
<>
|
||||||
<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]}
|
||||||
@@ -1576,6 +1575,17 @@ function ArtistHall({
|
|||||||
/>
|
/>
|
||||||
</group>
|
</group>
|
||||||
</>
|
</>
|
||||||
|
) : movementMode ? (
|
||||||
|
interiorStyle ? (
|
||||||
|
<TexturedWall
|
||||||
|
kind={interiorStyle.surfaces.wall}
|
||||||
|
tint={interiorStyle.tints.wall}
|
||||||
|
position={[0, WALL_HEIGHT / 2, -halfD]}
|
||||||
|
size={[width, WALL_HEIGHT, WALL_THICKNESS]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GalleryWall position={[0, WALL_HEIGHT / 2, -halfD]} size={[width, WALL_HEIGHT]} color={walls.main} />
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{interiorStyle && computedWindows && computedWindows.length > 0 && (
|
{interiorStyle && computedWindows && computedWindows.length > 0 && (
|
||||||
@@ -2108,11 +2118,22 @@ export default function VirtualGallery(props: Props) {
|
|||||||
for (const seg of layout.segments) {
|
for (const seg of layout.segments) {
|
||||||
for (const slot of seg.slots) boxes.push(paintingKeepOutBounds(slot));
|
for (const slot of seg.slots) boxes.push(paintingKeepOutBounds(slot));
|
||||||
}
|
}
|
||||||
// Door jambs — keep 0.5 m from the opening posts; opening corridor stays walkable.
|
const endWallHasDoor =
|
||||||
boxes.push(...doorJambKeepOuts(wallInnerHalfD, true));
|
isWingedHall && 'endWallHasDoor' in layout ? layout.endWallHasDoor : false;
|
||||||
if (isWingedHall) boxes.push(...doorJambKeepOuts(wallInnerHalfD, false));
|
// Front door/passage jambs when the entrance wall has an opening.
|
||||||
|
if (!isWingedHall || hasNextHall || !endWallHasDoor) {
|
||||||
|
boxes.push(...doorJambKeepOuts(wallInnerHalfD, true));
|
||||||
|
}
|
||||||
|
// Back exit jambs for multi-wing halls.
|
||||||
|
if (isWingedHall && endWallHasDoor) {
|
||||||
|
boxes.push(...doorJambKeepOuts(wallInnerHalfD, false));
|
||||||
|
}
|
||||||
return boxes;
|
return boxes;
|
||||||
}, [layout.segments, wallInnerHalfD, isWingedHall]);
|
}, [layout, wallInnerHalfD, isWingedHall, hasNextHall]);
|
||||||
|
|
||||||
|
const endWallHasDoor =
|
||||||
|
isWingedHall && 'endWallHasDoor' in layout ? layout.endWallHasDoor : false;
|
||||||
|
const exitOnFront = !isWingedHall || !endWallHasDoor;
|
||||||
|
|
||||||
const resolvePlayerPosition = useCallback(
|
const resolvePlayerPosition = useCallback(
|
||||||
(pos: { x: number; z: number }, allowFrontDoorApproach: boolean) => {
|
(pos: { x: number; z: number }, allowFrontDoorApproach: boolean) => {
|
||||||
@@ -2123,8 +2144,10 @@ export default function VirtualGallery(props: Props) {
|
|||||||
let minZ = -playHalfD;
|
let minZ = -playHalfD;
|
||||||
let maxZ = playHalfD;
|
let maxZ = playHalfD;
|
||||||
if (inDoorBand) {
|
if (inDoorBand) {
|
||||||
minZ = -wallInnerHalfD + 0.08;
|
if (endWallHasDoor) minZ = -wallInnerHalfD + 0.08;
|
||||||
if (hasNextHall || allowFrontDoorApproach) maxZ = wallInnerHalfD - 0.08;
|
if (hasNextHall || allowFrontDoorApproach || exitOnFront) {
|
||||||
|
maxZ = wallInnerHalfD - 0.08;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pos.z = Math.max(minZ, Math.min(maxZ, pos.z));
|
pos.z = Math.max(minZ, Math.min(maxZ, pos.z));
|
||||||
} else {
|
} else {
|
||||||
@@ -2144,6 +2167,8 @@ export default function VirtualGallery(props: Props) {
|
|||||||
exitZ,
|
exitZ,
|
||||||
isWingedHall,
|
isWingedHall,
|
||||||
hasNextHall,
|
hasNextHall,
|
||||||
|
endWallHasDoor,
|
||||||
|
exitOnFront,
|
||||||
wallInnerHalfD,
|
wallInnerHalfD,
|
||||||
collisionObstacles,
|
collisionObstacles,
|
||||||
]
|
]
|
||||||
@@ -2218,23 +2243,30 @@ export default function VirtualGallery(props: Props) {
|
|||||||
}
|
}
|
||||||
}, [isWingedHall, onBack, !isWingedHall ? props.data.artist.id : undefined]);
|
}, [isWingedHall, onBack, !isWingedHall ? props.data.artist.id : undefined]);
|
||||||
|
|
||||||
const backExitZ = isWingedHall ? -playHalfD : exitZ;
|
const backExitZ = isWingedHall && endWallHasDoor ? -playHalfD : exitZ;
|
||||||
const frontPassageZ = playHalfD;
|
const frontPassageZ = playHalfD;
|
||||||
|
|
||||||
const updateProximityFlags = useCallback(
|
const updateProximityFlags = useCallback(
|
||||||
(pos: { x: number; z: number }) => {
|
(pos: { x: number; z: number }) => {
|
||||||
if (isWingedHall) {
|
if (isWingedHall) {
|
||||||
const atBackExit = pos.z < backExitZ + 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
const atBackExit =
|
||||||
|
endWallHasDoor &&
|
||||||
|
pos.z < backExitZ + 0.8 &&
|
||||||
|
Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
||||||
|
const atFrontExit =
|
||||||
|
!endWallHasDoor &&
|
||||||
|
pos.z > frontPassageZ - 0.8 &&
|
||||||
|
Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
||||||
const atFrontPassage =
|
const atFrontPassage =
|
||||||
hasNextHall && pos.z > frontPassageZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
hasNextHall && pos.z > frontPassageZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
||||||
setNearExit(atBackExit);
|
setNearExit(!!(atBackExit || atFrontExit));
|
||||||
setNearPassage(!!atFrontPassage);
|
setNearPassage(!!atFrontPassage);
|
||||||
} else {
|
} else {
|
||||||
const atExit = pos.z > exitZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
const atExit = pos.z > exitZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
||||||
setNearExit(atExit);
|
setNearExit(atExit);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isWingedHall, backExitZ, frontPassageZ, hasNextHall, exitZ]
|
[isWingedHall, endWallHasDoor, backExitZ, frontPassageZ, hasNextHall, exitZ]
|
||||||
);
|
);
|
||||||
|
|
||||||
const moveCamera = useCallback(
|
const moveCamera = useCallback(
|
||||||
@@ -2548,8 +2580,12 @@ export default function VirtualGallery(props: Props) {
|
|||||||
{isWingedHall ? (
|
{isWingedHall ? (
|
||||||
<>
|
<>
|
||||||
<li>Date and artist labels appear below each frame</li>
|
<li>Date and artist labels appear below each frame</li>
|
||||||
<li>Works hang on left & right walls — up to ~55 per wing</li>
|
<li>Works hang on left, end, and right walls — up to ~55 per wing</li>
|
||||||
<li>Back door: wing navigator & exit to timeline</li>
|
<li>
|
||||||
|
{movementHalls.length > 1
|
||||||
|
? 'Back door: wing navigator & exit to timeline · Front: next wing'
|
||||||
|
: 'Entrance door: exit to timeline'}
|
||||||
|
</li>
|
||||||
{movementHalls.length > 1 && (
|
{movementHalls.length > 1 && (
|
||||||
<li>Front archway: walk to the next chronological wing</li>
|
<li>Front archway: walk to the next chronological wing</li>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -578,13 +578,48 @@ function blendAccent(style: MovementInteriorStyle, accentHex?: string): Movement
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Styles were authored with legacy ids 27–52; gallery_dev uses 1–26.
|
||||||
|
* Northern (5) / High Renaissance (6) are swapped vs the legacy sequence.
|
||||||
|
*/
|
||||||
|
const DB_ID_TO_STYLE_KEY: Record<number, number> = {
|
||||||
|
1: 27,
|
||||||
|
2: 28,
|
||||||
|
3: 29,
|
||||||
|
4: 30,
|
||||||
|
5: 32,
|
||||||
|
6: 31,
|
||||||
|
7: 33,
|
||||||
|
8: 34,
|
||||||
|
9: 35,
|
||||||
|
10: 36,
|
||||||
|
11: 37,
|
||||||
|
12: 38,
|
||||||
|
13: 39,
|
||||||
|
14: 40,
|
||||||
|
15: 41,
|
||||||
|
16: 42,
|
||||||
|
17: 43,
|
||||||
|
18: 44,
|
||||||
|
19: 45,
|
||||||
|
20: 46,
|
||||||
|
21: 47,
|
||||||
|
22: 48,
|
||||||
|
23: 49,
|
||||||
|
24: 50,
|
||||||
|
25: 51,
|
||||||
|
26: 52,
|
||||||
|
};
|
||||||
|
|
||||||
/** Fallback name-based resolver for movements not in the catalog. */
|
/** Fallback name-based resolver for movements not in the catalog. */
|
||||||
function fallbackByName(movement: ArtMovement & { era_name?: string }): MovementInteriorStyle {
|
function fallbackByName(movement: ArtMovement & { era_name?: string }): MovementInteriorStyle {
|
||||||
const n = movement.name.toLowerCase();
|
const n = movement.name.toLowerCase();
|
||||||
const era = (movement.era_name ?? '').toLowerCase();
|
const era = (movement.era_name ?? '').toLowerCase();
|
||||||
|
if (n.includes('byzantine')) return BY_MOVEMENT_ID[28];
|
||||||
|
if (n.includes('gothic')) return BY_MOVEMENT_ID[29];
|
||||||
if (n.includes('renaissance')) return BY_MOVEMENT_ID[31];
|
if (n.includes('renaissance')) return BY_MOVEMENT_ID[31];
|
||||||
if (n.includes('baroque') || n.includes('rococo')) return BY_MOVEMENT_ID[34];
|
if (n.includes('baroque') || n.includes('rococo')) return BY_MOVEMENT_ID[34];
|
||||||
if (era.includes('medieval') || n.includes('gothic')) return BY_MOVEMENT_ID[29];
|
if (era.includes('medieval')) return BY_MOVEMENT_ID[29];
|
||||||
if (n.includes('impression')) return BY_MOVEMENT_ID[39];
|
if (n.includes('impression')) return BY_MOVEMENT_ID[39];
|
||||||
if (era.includes('modern') || era.includes('contemporary')) return BY_MOVEMENT_ID[52];
|
if (era.includes('modern') || era.includes('contemporary')) return BY_MOVEMENT_ID[52];
|
||||||
return BY_MOVEMENT_ID[36];
|
return BY_MOVEMENT_ID[36];
|
||||||
@@ -593,7 +628,8 @@ function fallbackByName(movement: ArtMovement & { era_name?: string }): Movement
|
|||||||
export function resolveMovementInteriorStyle(
|
export function resolveMovementInteriorStyle(
|
||||||
movement: ArtMovement & { era_name?: string }
|
movement: ArtMovement & { era_name?: string }
|
||||||
): MovementInteriorStyle {
|
): MovementInteriorStyle {
|
||||||
const base = BY_MOVEMENT_ID[movement.id] ?? fallbackByName(movement);
|
const styleKey = DB_ID_TO_STYLE_KEY[movement.id] ?? movement.id;
|
||||||
|
const base = BY_MOVEMENT_ID[styleKey] ?? fallbackByName(movement);
|
||||||
return blendAccent(base, movement.color);
|
return blendAccent(base, movement.color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -641,6 +641,7 @@ export default function HomePage() {
|
|||||||
const handleMovementClick = async (movementId: number) => {
|
const handleMovementClick = async (movementId: number) => {
|
||||||
setGalleryEntryLoading('Opening movement gallery…');
|
setGalleryEntryLoading('Opening movement gallery…');
|
||||||
try {
|
try {
|
||||||
|
await api.preloadMovementImages(movementId).catch(() => undefined);
|
||||||
const data = await api.getMovementGallery(movementId);
|
const data = await api.getMovementGallery(movementId);
|
||||||
openMovementGallery(movementId, data);
|
openMovementGallery(movementId, data);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface ArtMovement {
|
|||||||
era_name?: string;
|
era_name?: string;
|
||||||
description: string;
|
description: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
/** Influence edges on paintings by artists in this movement (timeline band weight). */
|
||||||
|
influence_link_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Artist {
|
export interface Artist {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export interface MovementHallLayout {
|
|||||||
segments: WallSegment[];
|
segments: WallSegment[];
|
||||||
paintingCount: number;
|
paintingCount: number;
|
||||||
yearLabel: string;
|
yearLabel: string;
|
||||||
|
/** When true, far (−Z) wall has a center exit; paintings hang on flanks only. */
|
||||||
|
endWallHasDoor: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WALL_HEIGHT = 4.2;
|
const WALL_HEIGHT = 4.2;
|
||||||
@@ -165,36 +167,59 @@ function layoutSideSlots(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Far wall ahead of the entrance — split across door flanks (exit sits in the center). */
|
/** Far wall ahead of the entrance — full span, or split across door flanks. */
|
||||||
function layoutBackSlots(
|
function layoutBackSlots(
|
||||||
paintings: Painting[],
|
paintings: Painting[],
|
||||||
width: number,
|
width: number,
|
||||||
halfD: number,
|
halfD: number,
|
||||||
inset: number
|
inset: number,
|
||||||
|
hasDoor: boolean
|
||||||
): FrameSlot[] {
|
): FrameSlot[] {
|
||||||
if (paintings.length === 0) return [];
|
if (paintings.length === 0) return [];
|
||||||
const flankSpan = Math.max(MIN_FRAME_W + WALL_PADDING, (width - DOOR_CLEARANCE) / 2);
|
|
||||||
const mid = Math.ceil(paintings.length / 2);
|
|
||||||
const leftFlank = paintings.slice(0, mid);
|
|
||||||
const rightFlank = paintings.slice(mid);
|
|
||||||
const y = EYE_HEIGHT;
|
const y = EYE_HEIGHT;
|
||||||
const z = -halfD + inset + WALL_STANDOFF;
|
const z = -halfD + inset + WALL_STANDOFF;
|
||||||
const leftCenterX = -DOOR_CLEARANCE / 2 - flankSpan / 2;
|
|
||||||
const rightCenterX = DOOR_CLEARANCE / 2 + flankSpan / 2;
|
|
||||||
|
|
||||||
const mapFlank = (group: Painting[], centerX: number): FrameSlot[] => {
|
if (!hasDoor) {
|
||||||
if (group.length === 0) return [];
|
const { slots: rowSlots } = layoutRow(paintings, width);
|
||||||
const { slots: rowSlots } = layoutRow(group, flankSpan);
|
|
||||||
return rowSlots.map((s) => ({
|
return rowSlots.map((s) => ({
|
||||||
maxW: s.maxW,
|
maxW: s.maxW,
|
||||||
maxH: s.maxH,
|
maxH: s.maxH,
|
||||||
rotationY: 0,
|
rotationY: 0,
|
||||||
side: 'back' as const,
|
side: 'back' as const,
|
||||||
position: [centerX + s.offset, y, z] as [number, number, number],
|
position: [s.offset, y, z] as [number, number, number],
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const flankSpan = Math.max(MIN_FRAME_W + WALL_PADDING, (width - DOOR_CLEARANCE) / 2);
|
||||||
|
const mid = Math.ceil(paintings.length / 2);
|
||||||
|
const leftFlank = paintings.slice(0, mid);
|
||||||
|
const rightFlank = paintings.slice(mid);
|
||||||
|
const leftCenterX = -DOOR_CLEARANCE / 2 - flankSpan / 2;
|
||||||
|
const rightCenterX = DOOR_CLEARANCE / 2 + flankSpan / 2;
|
||||||
|
const doorEdge = DOOR_CLEARANCE / 2 + 0.08;
|
||||||
|
|
||||||
|
const mapFlank = (group: Painting[], centerX: number, side: 'left' | 'right'): FrameSlot[] => {
|
||||||
|
if (group.length === 0) return [];
|
||||||
|
const { slots: rowSlots } = layoutRow(group, flankSpan);
|
||||||
|
return rowSlots.map((s) => {
|
||||||
|
let x = centerX + s.offset;
|
||||||
|
const halfOuter = frameOuterW(s.maxW, false) / 2;
|
||||||
|
if (side === 'left' && x + halfOuter > -doorEdge) {
|
||||||
|
x = -doorEdge - halfOuter;
|
||||||
|
} else if (side === 'right' && x - halfOuter < doorEdge) {
|
||||||
|
x = doorEdge + halfOuter;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
maxW: s.maxW,
|
||||||
|
maxH: s.maxH,
|
||||||
|
rotationY: 0,
|
||||||
|
side: 'back' as const,
|
||||||
|
position: [x, y, z] as [number, number, number],
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return [...mapFlank(leftFlank, leftCenterX), ...mapFlank(rightFlank, rightCenterX)];
|
return [...mapFlank(leftFlank, leftCenterX, 'left'), ...mapFlank(rightFlank, rightCenterX, 'right')];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildMovementHallLayout(
|
export function buildMovementHallLayout(
|
||||||
@@ -206,7 +231,21 @@ export function buildMovementHallLayout(
|
|||||||
const leftSpan = layoutRow(left, MIN_HALL_SIZE);
|
const leftSpan = layoutRow(left, MIN_HALL_SIZE);
|
||||||
const rightSpan = layoutRow(right, MIN_HALL_SIZE);
|
const rightSpan = layoutRow(right, MIN_HALL_SIZE);
|
||||||
const depth = Math.max(MIN_HALL_SIZE, leftSpan.spanNeeded, rightSpan.spanNeeded);
|
const depth = Math.max(MIN_HALL_SIZE, leftSpan.spanNeeded, rightSpan.spanNeeded);
|
||||||
const width = MIN_HALL_WIDTH;
|
|
||||||
|
// Single-wing halls put the exit on the entrance wall, so the far wall is solid
|
||||||
|
// and can use its full width for paintings. Multi-wing halls keep a back exit.
|
||||||
|
const endWallHasDoor = hallCount > 1;
|
||||||
|
|
||||||
|
let width: number;
|
||||||
|
if (endWallHasDoor) {
|
||||||
|
const mid = Math.ceil(back.length / 2);
|
||||||
|
const leftFlankNeed = layoutRow(back.slice(0, mid), MIN_FRAME_W + WALL_PADDING).spanNeeded;
|
||||||
|
const rightFlankNeed = layoutRow(back.slice(mid), MIN_FRAME_W + WALL_PADDING).spanNeeded;
|
||||||
|
width = Math.max(MIN_HALL_WIDTH, leftFlankNeed + DOOR_CLEARANCE + rightFlankNeed);
|
||||||
|
} else {
|
||||||
|
width = Math.max(MIN_HALL_WIDTH, layoutRow(back, MIN_HALL_SIZE).spanNeeded);
|
||||||
|
}
|
||||||
|
|
||||||
const halfW = width / 2;
|
const halfW = width / 2;
|
||||||
const halfD = depth / 2;
|
const halfD = depth / 2;
|
||||||
const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET;
|
const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET;
|
||||||
@@ -216,7 +255,7 @@ export function buildMovementHallLayout(
|
|||||||
side: 'back',
|
side: 'back',
|
||||||
label: back.length > 0 ? `Wing ${hallIndex + 1} · End wall` : '',
|
label: back.length > 0 ? `Wing ${hallIndex + 1} · End wall` : '',
|
||||||
paintings: back,
|
paintings: back,
|
||||||
slots: layoutBackSlots(back, width, halfD, inset),
|
slots: layoutBackSlots(back, width, halfD, inset, endWallHasDoor),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
side: 'left',
|
side: 'left',
|
||||||
@@ -240,6 +279,7 @@ export function buildMovementHallLayout(
|
|||||||
segments,
|
segments,
|
||||||
paintingCount: paintings.length,
|
paintingCount: paintings.length,
|
||||||
yearLabel: formatYearLabel(paintings),
|
yearLabel: formatYearLabel(paintings),
|
||||||
|
endWallHasDoor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-11
@@ -160,17 +160,9 @@ async function syncPaintingFromDisk(row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Fast preload: link local files only, no external API calls; regenerate missing thumbs from full files. */
|
/** Fast preload: link local files only, no external API calls; regenerate missing thumbs from full files. */
|
||||||
async function preloadArtistImagesLocal(artistId) {
|
async function preloadPaintingImageRows(rows) {
|
||||||
const rows = await pool.query(
|
|
||||||
`SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name
|
|
||||||
FROM paintings p
|
|
||||||
JOIN artists a ON a.id = p.artist_id
|
|
||||||
WHERE p.artist_id = $1`,
|
|
||||||
[artistId]
|
|
||||||
);
|
|
||||||
|
|
||||||
let linked = 0;
|
let linked = 0;
|
||||||
for (const row of rows.rows) {
|
for (const row of rows) {
|
||||||
const hasFull = localFileExists(row.image_path);
|
const hasFull = localFileExists(row.image_path);
|
||||||
const hasThumb = localFileExists(row.thumbnail_path) && isPaintingThumbRel(row.thumbnail_path);
|
const hasThumb = localFileExists(row.thumbnail_path) && isPaintingThumbRel(row.thumbnail_path);
|
||||||
if (hasFull && hasThumb) {
|
if (hasFull && hasThumb) {
|
||||||
@@ -188,7 +180,29 @@ async function preloadArtistImagesLocal(artistId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { fetched: linked, total: rows.rows.length };
|
return { fetched: linked, total: rows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preloadArtistImagesLocal(artistId) {
|
||||||
|
const rows = await pool.query(
|
||||||
|
`SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name
|
||||||
|
FROM paintings p
|
||||||
|
JOIN artists a ON a.id = p.artist_id
|
||||||
|
WHERE p.artist_id = $1`,
|
||||||
|
[artistId]
|
||||||
|
);
|
||||||
|
return preloadPaintingImageRows(rows.rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preloadMovementImagesLocal(movementId) {
|
||||||
|
const rows = await pool.query(
|
||||||
|
`SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name
|
||||||
|
FROM paintings p
|
||||||
|
JOIN artists a ON a.id = p.artist_id
|
||||||
|
WHERE a.movement_id = $1`,
|
||||||
|
[movementId]
|
||||||
|
);
|
||||||
|
return preloadPaintingImageRows(rows.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensurePaintingImages(paintingId, size = 'thumb') {
|
async function ensurePaintingImages(paintingId, size = 'thumb') {
|
||||||
@@ -613,6 +627,7 @@ async function replaceArtistPortraitFromUrl(artistId, imageUrl, context = {}) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
ensurePaintingImages,
|
ensurePaintingImages,
|
||||||
preloadArtistImagesLocal,
|
preloadArtistImagesLocal,
|
||||||
|
preloadMovementImagesLocal,
|
||||||
replacePaintingImageFromUrl,
|
replacePaintingImageFromUrl,
|
||||||
replaceArtistPortraitFromUrl,
|
replaceArtistPortraitFromUrl,
|
||||||
clearPaintingImage,
|
clearPaintingImage,
|
||||||
|
|||||||
+23
-3
@@ -12,7 +12,7 @@ const { requirePermission } = require('./middleware/auth');
|
|||||||
const { logCuratorAction } = require('./audit-log');
|
const { logCuratorAction } = require('./audit-log');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const usersRoutes = require('./routes/users');
|
const usersRoutes = require('./routes/users');
|
||||||
const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, enrichPaintingRow, enrichArtistRow, IMAGE_DIR } = require('./image-service');
|
const { ensurePaintingImages, preloadArtistImagesLocal, preloadMovementImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, enrichPaintingRow, enrichArtistRow, IMAGE_DIR } = require('./image-service');
|
||||||
const { getVersionInfo } = require('./version-info');
|
const { getVersionInfo } = require('./version-info');
|
||||||
const { searchCatalog } = require('./search-service');
|
const { searchCatalog } = require('./search-service');
|
||||||
const {
|
const {
|
||||||
@@ -90,10 +90,18 @@ async function fetchTimelineErasAndMovements(startYear, endYear) {
|
|||||||
[startYear, endYear]
|
[startYear, endYear]
|
||||||
),
|
),
|
||||||
pool.query(
|
pool.query(
|
||||||
`SELECT DISTINCT m.*, e.name as era_name
|
`SELECT DISTINCT m.*, e.name as era_name,
|
||||||
|
COALESCE(ic.link_count, 0)::int AS influence_link_count
|
||||||
FROM art_movements m
|
FROM art_movements m
|
||||||
LEFT JOIN historical_eras e ON m.era_id = e.id
|
LEFT JOIN historical_eras e ON m.era_id = e.id
|
||||||
INNER JOIN artists a ON a.movement_id = m.id
|
INNER JOIN artists a ON a.movement_id = m.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT a2.movement_id, COUNT(pis.id)::int AS link_count
|
||||||
|
FROM painting_influence_sources pis
|
||||||
|
JOIN paintings p ON p.id = pis.painting_id
|
||||||
|
JOIN artists a2 ON a2.id = p.artist_id
|
||||||
|
GROUP BY a2.movement_id
|
||||||
|
) ic ON ic.movement_id = m.id
|
||||||
WHERE m.end_year >= $1 AND m.start_year <= $2
|
WHERE m.end_year >= $1 AND m.start_year <= $2
|
||||||
AND (a.death_year IS NULL OR a.death_year >= $1)
|
AND (a.death_year IS NULL OR a.death_year >= $1)
|
||||||
AND (a.birth_year IS NULL OR a.birth_year <= $2)
|
AND (a.birth_year IS NULL OR a.birth_year <= $2)
|
||||||
@@ -126,7 +134,8 @@ async function catalogBootstrapEtag() {
|
|||||||
(SELECT COUNT(*)::int FROM artists) AS artist_count,
|
(SELECT COUNT(*)::int FROM artists) AS artist_count,
|
||||||
(SELECT COUNT(*)::int FROM art_movements) AS movement_count,
|
(SELECT COUNT(*)::int FROM art_movements) AS movement_count,
|
||||||
(SELECT COUNT(*)::int FROM historical_eras) AS era_count,
|
(SELECT COUNT(*)::int FROM historical_eras) AS era_count,
|
||||||
(SELECT COUNT(*)::int FROM paintings) AS painting_count
|
(SELECT COUNT(*)::int FROM paintings) AS painting_count,
|
||||||
|
(SELECT COUNT(*)::int FROM painting_influence_sources) AS influence_count
|
||||||
`);
|
`);
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
}
|
}
|
||||||
@@ -942,6 +951,17 @@ app.post('/api/artists/:id/preload-images', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/movements/:id/preload-images', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const movementId = parseInt(req.params.id, 10);
|
||||||
|
const result = await preloadMovementImagesLocal(movementId);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Movement preload error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Preload failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Developer debug: Google Images first result for image audit
|
// Developer debug: Google Images first result for image audit
|
||||||
app.get('/api/paintings/:id/debug-image-search/more', requirePermission('images'), async (req, res) => {
|
app.get('/api/paintings/:id/debug-image-search/more', requirePermission('images'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user