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:
Danila Khodjaef
2026-07-28 20:36:55 +03:00
co-authored by Cursor
parent 23e1210e86
commit 8111cd0163
13 changed files with 632 additions and 166 deletions
+17 -2
View File
@@ -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
View File
@@ -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 **126** → authored keys **2752**, 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.
+3 -3
View File
@@ -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 **artists** 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).
+10
View File
@@ -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 {
+12 -10
View File
@@ -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 {
+368 -80
View File
@@ -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,16 +1782,16 @@ 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',
}} }}
/> />
@@ -1517,7 +1799,7 @@ export default function MovementBands({
</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}
+53 -17
View File
@@ -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 =
isWingedHall && 'endWallHasDoor' in layout ? layout.endWallHasDoor : false;
// Front door/passage jambs when the entrance wall has an opening.
if (!isWingedHall || hasNextHall || !endWallHasDoor) {
boxes.push(...doorJambKeepOuts(wallInnerHalfD, true)); boxes.push(...doorJambKeepOuts(wallInnerHalfD, true));
if (isWingedHall) boxes.push(...doorJambKeepOuts(wallInnerHalfD, false)); }
// 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 &amp; 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 &amp; 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>
)} )}
+38 -2
View File
@@ -578,13 +578,48 @@ function blendAccent(style: MovementInteriorStyle, accentHex?: string): Movement
}; };
} }
/**
* Styles were authored with legacy ids 2752; gallery_dev uses 126.
* 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);
} }
+1
View File
@@ -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 {
+2
View File
@@ -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 {
+55 -15
View File
@@ -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
View File
@@ -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
View File
@@ -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 {