From 8111cd01631b3f4c2ef41a6b6184fbbaab0ad407 Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Tue, 28 Jul 2026 20:36:55 +0300 Subject: [PATCH] Improve movement flow layout/animation and fix Byzantine hall doors, styles, and preload. Co-authored-by: Cursor --- Documentation/API.md | 19 +- Documentation/basics.md | 21 +- Documentation/data-and-images.md | 6 +- client/src/api/client.ts | 10 + client/src/components/MovementBands.css | 22 +- client/src/components/MovementBands.tsx | 470 ++++++++++++++++---- client/src/components/VirtualGallery.tsx | 74 ++- client/src/data/movement-interior-styles.ts | 40 +- client/src/pages/HomePage.tsx | 1 + client/src/types/index.ts | 2 + client/src/utils/movementHallLayout.ts | 70 ++- server/image-service.js | 37 +- server/index.js | 26 +- 13 files changed, 632 insertions(+), 166 deletions(-) diff --git a/Documentation/API.md b/Documentation/API.md index eb60e9d..93086b7 100644 --- a/Documentation/API.md +++ b/Documentation/API.md @@ -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.*` | | `/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)). @@ -597,7 +597,7 @@ Both lists are grouped by art movement and exclude the current artist. Each arti **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** @@ -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` 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.getPainting(id)` | `GET /api/paintings/:id` | | `preloadArtistImages(id)` | `POST /api/artists/:id/preload-images` | +| `preloadMovementImages(id)` | `POST /api/movements/:id/preload-images` | | `imageUrl(path, revision?)` | `/images/` or placeholder; optional `?v=` cache buster | | `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 | diff --git a/Documentation/basics.md b/Documentation/basics.md index 3f61958..ee05a44 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -246,8 +246,9 @@ Each visible movement is drawn as a **portrait-width curved stream** (~54 px str | Feature | Implementation | |---------|----------------| | 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 | -| 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 | +| 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; 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 | | 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 | | 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) | | 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 | | 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 | | Lighting | Daylight from windows + ceiling track lights + ambient/sun fill | -| Back wall | **Exit double doors** → **Wing navigator** (jump to any wing) or **Exit to Timeline** | -| Front wall | Open **“Next wing →”** archway when a later wing exists; walk through or press `E` when near | +| Back wall | Single wing: solid display wall. Multi-wing: **Exit double doors** → **Wing navigator** or **Exit to Timeline** | +| 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 | | 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 | @@ -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 | | Click painting | Open detail view (returns to the same wing) | -| Back wall / `E` / **Wings / Exit →** | Open wing navigator | -| Front archway / `E` (when near) | Advance to the **next chronological wing** | +| Entrance / back door / `E` / **Wings / Exit →** | Open wing navigator (or exit on single-wing halls) | +| 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. @@ -371,7 +372,7 @@ Full guide: [tours.md](tours.md). ### 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. @@ -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). - **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. -- **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 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. diff --git a/Documentation/data-and-images.md b/Documentation/data-and-images.md index 9b368ea..876d623 100644 --- a/Documentation/data-and-images.md +++ b/Documentation/data-and-images.md @@ -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` | | 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` | | 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 -`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). diff --git a/client/src/api/client.ts b/client/src/api/client.ts index ef16a70..5cda8ca 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -302,6 +302,15 @@ export async function preloadArtistImages(artistId: number): Promise<{ fetched: 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 { imagePath: string | null; thumbnailPath: string | null; @@ -864,6 +873,7 @@ export const api = { }), preloadArtistImages, + preloadMovementImages, }; export interface TranslationWorklistItem { diff --git a/client/src/components/MovementBands.css b/client/src/components/MovementBands.css index 38bc845..56b9b0e 100644 --- a/client/src/components/MovementBands.css +++ b/client/src/components/MovementBands.css @@ -96,13 +96,13 @@ } .movement-branch-fast { - stroke-opacity: 0.36; + stroke-opacity: 0.55; stroke-width: calc(var(--stream-stroke) * 0.45); stroke-linecap: round; } .movement-stream-fast { - stroke-opacity: 0.42; + stroke-opacity: 0.62; stroke-width: var(--stream-stroke); stroke-linecap: round; vector-effect: non-scaling-stroke; @@ -120,13 +120,14 @@ stroke-width: var(--stream-stroke); stroke-linecap: round; vector-effect: non-scaling-stroke; + filter: saturate(1.25) brightness(1.12) contrast(1.08); transition: filter 0.2s ease, opacity 0.2s ease; } .movement-stream-core { stroke-width: 2px; stroke-linecap: round; - opacity: 0.22; + opacity: 0.38; stroke-dasharray: 8 6; animation: stream-shimmer 16s linear infinite; vector-effect: non-scaling-stroke; @@ -134,29 +135,30 @@ } .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) { - opacity: 0.1; + opacity: 0.14; } .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 { - filter: brightness(1.65) saturate(1.3); + filter: brightness(1.75) saturate(1.45) contrast(1.12); } .movement-stream-core.movement-stream-highlighted { - opacity: 0.72; - filter: brightness(1.5); + opacity: 0.82; + filter: brightness(1.55) saturate(1.2); stroke-width: 2.5px; } .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 { diff --git a/client/src/components/MovementBands.tsx b/client/src/components/MovementBands.tsx index 04b8a71..a8178f5 100644 --- a/client/src/components/MovementBands.tsx +++ b/client/src/components/MovementBands.tsx @@ -29,6 +29,11 @@ interface MovementLayout { yEnd: number; depth: 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 { @@ -40,6 +45,7 @@ interface BranchSegment { y1: number; x2: number; y2: number; + strokePx: number; } const MAX_STREAM_STROKE_PX = 54; @@ -51,6 +57,22 @@ const CANVAS_TOP_PAD = 38; const CANVAS_BOTTOM_PAD = 24; 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 { return ((year - start) / (end - start)) * 100; } @@ -140,15 +162,14 @@ function buildArtistPlacements( artistsByMovement: Map, viewStart: number, viewEnd: number, - portraitSizePx: number, canvasWidthPx: number ): ArtistPlacement[] { const placements: ArtistPlacement[] = []; - const minGap = portraitMinGapPct(portraitSizePx, canvasWidthPx); for (const layout of layouts) { const movementArtists = artistsByMovement.get(layout.movement.id) || []; const candidates: PortraitCandidate[] = []; + const minGap = portraitMinGapPct(layout.portraitSizePx, canvasWidthPx); const portraitHalfPct = minGap / 2; for (const artist of movementArtists) { @@ -213,7 +234,7 @@ function buildArtistPlacements( portraitX: x, y, 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)}`; } +/** 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 { if (laneCount <= 1) return baseColor; try { @@ -332,7 +371,10 @@ function assignDepths(movements: ArtMovement[], lineageParents: Map(); 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) { lane = laneEnds.length; 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; } +/** 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 { - 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. */ @@ -379,17 +422,42 @@ function branchCorridorXRange( childXStart: number, childIndex: number, childCount: number -): { x0: number; x1: number } { - const t = childCount === 1 ? 0.5 : (childIndex + 1) / (childCount + 1); - const originX = parentXStart + t * (parentXEnd - parentXStart); +): { x0: number; x1: number } | null { + const originX = forwardBranchOriginX(parentXStart, parentXEnd, childXStart, childIndex, childCount); + if (originX == null) return null; const targetX = childXStart; - const x0 = Math.min(originX, targetX); - const x1 = Math.max(originX, targetX); + const x0 = originX; + const x1 = targetX; // Near-vertical transitions still have a wide SVG stroke — pad so overlapping streams register. const pad = Math.max(4, (x1 - x0) * 0.35); 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( movement: ArtMovement, viewStart: number, @@ -569,13 +637,15 @@ function refineLanesForLineageCorridors( const children = childIdsByParent.get(parentId) || [childId]; const childIndex = Math.max(0, children.indexOf(childId)); - const { x0, x1 } = branchCorridorXRange( + const corridor = branchCorridorXRange( parentSpan.xStart, parentSpan.xEnd, childSpan.xStart, childIndex, children.length ); + if (!corridor) continue; + const { x0, x1 } = corridor; const lo = Math.min(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}`; } -function branchOriginOnParent(parent: MovementLayout, childIndex: number, childCount: number): { - x: number; - y: number; -} { - const t = childCount === 1 ? 0.5 : (childIndex + 1) / (childCount + 1); - const x = parent.xStart + t * (parent.xEnd - parent.xStart); +/** Exponential chase rate (1/s) so pan/zoom layout shifts read as motion, not snaps. */ +const LAYOUT_ANIM_RATE = 14; +const LAYOUT_ANIM_EPS = 0.06; + +function lerp(a: number, b: number, t: number): number { + 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) }; } @@ -791,19 +967,24 @@ function buildLabelPlacements( layoutHeight: number, canvasWidth: number, canvasHeight: number, - streamStrokePx: number, - portraitSizePx: number, branches: BranchSegment[] = [] ): LabelPlacement[] { if (layouts.length === 0 || canvasWidth <= 0 || canvasHeight <= 0) return []; 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) => ({ id: layout.movement.id, - rect: streamBandObstacleRect(layout, streamStrokePx, layoutHeight, canvasWidth, canvasHeight), + rect: streamBandObstacleRect(layout, layout.strokePx, layoutHeight, canvasWidth, canvasHeight), })); const branchObstacles = branches.map((branch) => { @@ -813,7 +994,7 @@ function buildLabelPlacements( toId, rect: branchCorridorObstacleRect( branch, - streamStrokePx, + branch.strokePx, layoutHeight, canvasWidth, canvasHeight @@ -938,6 +1119,7 @@ const MovementArtistPortrait = memo(function MovementArtistPortrait({ portraitX, y, layoutHeight, + portraitSizePx, color, colorIndex, isHovered, @@ -959,6 +1141,7 @@ const MovementArtistPortrait = memo(function MovementArtistPortrait({ portraitX: number; y: number; layoutHeight: number; + portraitSizePx: number; color: string; colorIndex: number; isHovered: boolean; @@ -1017,6 +1200,8 @@ const MovementArtistPortrait = memo(function MovementArtistPortrait({ top: `${(y / layoutHeight) * 100}%`, borderColor: color, zIndex: isHovered ? 13 : 5 + colorIndex, + width: `${portraitSizePx}px`, + height: `${portraitSizePx}px`, }} onClick={() => onArtistClick(artist.id)} onMouseEnter={() => { @@ -1079,6 +1264,7 @@ export default function MovementBands({ const [portraitsLoading, setPortraitsLoading] = useState(false); const [panning, setPanning] = useState(false); const [interacting, setInteracting] = useState(false); + const [layoutAnimating, setLayoutAnimating] = useState(false); const [canvasHeight, setCanvasHeight] = useState(DEFAULT_CANVAS_HEIGHT); const [canvasWidth, setCanvasWidth] = useState(800); const [hoveredArtistKey, setHoveredArtistKey] = useState(null); @@ -1114,6 +1300,11 @@ export default function MovementBands({ const interactionTimer = useRef(null); const viewRef = useRef({ viewStart, viewEnd }); const onViewChangeRef = useRef(onViewChange); + const flowTargetRef = useRef(null); + const flowVisualRef = useRef(null); + const flowRafRef = useRef(null); + const flowLastTsRef = useRef(0); + const [flowVisual, setFlowVisual] = useState(null); viewRef.current = { viewStart, viewEnd }; onViewChangeRef.current = onViewChange; @@ -1139,6 +1330,7 @@ export default function MovementBands({ useEffect(() => () => { if (interactionTimer.current != null) window.clearTimeout(interactionTimer.current); + if (flowRafRef.current != null) cancelAnimationFrame(flowRafRef.current); }, []); useEffect(() => { @@ -1280,6 +1472,18 @@ export default function MovementBands({ const layoutHeight = Math.max(200, canvasHeight); const usableHeight = layoutHeight - CANVAS_TOP_PAD - CANVAS_BOTTOM_PAD; + const maxInfluence = Math.max( + 1, + ...visibleMovements.map((m) => movementInfluenceCount(m)) + ); + const desiredStrokeById = new Map(); + for (const movement of visibleMovements) { + desiredStrokeById.set( + movement.id, + desiredStrokeForCount(movementInfluenceCount(movement), maxInfluence) + ); + } + // Pack all visible movements into shared horizontal lanes whenever their // clipped time spans do not overlap, then clear unrelated streams out of // lineage branch corridors (parent/child adjacency preferred). @@ -1295,26 +1499,57 @@ export default function MovementBands({ let maxLanes = 0; const laneOccupancy = new Map(); + const laneMaxDesiredStroke = new Map(); for (const movement of visibleMovements) { const lane = laneIndex.get(movement.id) ?? 0; maxLanes = Math.max(maxLanes, lane + 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); - // Always fit every lane inside the canvas — never floor laneStep to min stroke - // (that pushed lower streams/branches past the bottom edge). - const laneStep = usableHeight / maxLanes; - let streamStrokePx = Math.min(MAX_STREAM_STROKE_PX, laneStep - Math.min(LANE_GAP_PX, laneStep * 0.25)); - streamStrokePx = Math.max(ABS_MIN_STREAM_STROKE_PX, streamStrokePx); - if (laneStep >= MIN_STREAM_STROKE_PX + LANE_GAP_PX) { - streamStrokePx = Math.max(MIN_STREAM_STROKE_PX, streamStrokePx); + // Lane heights proportional to the thickest band in each lane (influence-weighted). + const laneHeightsDesired: number[] = []; + for (let lane = 0; lane < maxLanes; lane++) { + const stroke = laneMaxDesiredStroke.get(lane) ?? ABS_MIN_STREAM_STROKE_PX; + laneHeightsDesired[lane] = stroke + LANE_GAP_PX; + } + 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; - const yMin = CANVAS_TOP_PAD + streamStrokePx / 2; + let maxStrokePx = ABS_MIN_STREAM_STROKE_PX; + const strokeById = new Map(); + 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(); const branchList: BranchSegment[] = []; @@ -1327,9 +1562,11 @@ export default function MovementBands({ const depth = depths.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 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 yEnd = Math.min(yMax, Math.max(yMin, y + drift)); const parentIds = lineageParents.get(movement.id) || []; @@ -1342,6 +1579,9 @@ export default function MovementBands({ yEnd, depth, parentIds, + strokePx, + portraitSizePx: portraitSizeForStroke(strokePx), + displayColor: vividMovementColor(movement.color), }); } @@ -1368,18 +1608,20 @@ export default function MovementBands({ const children = childIdsByParent.get(parentId)!; const childIndex = children.indexOf(layout.movement.id); - const origin = branchOriginOnParent(parent, childIndex, children.length); const target = branchTargetOnChild(layout); + const origin = branchOriginOnParent(parent, childIndex, children.length, target.x); + if (!origin) continue; branchList.push({ key: `${parentId}-${layout.movement.id}`, d: branchPath(origin.x, origin.y, target.x, target.y), - colorFrom: parent.movement.color, - colorTo: layout.movement.color, + colorFrom: parent.displayColor, + colorTo: layout.displayColor, x1: origin.x, y1: origin.y, x2: target.x, y2: target.y, + strokePx: layout.strokePx, }); } } @@ -1395,6 +1637,47 @@ export default function MovementBands({ }; }, [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( () => buildArtistPlacements( @@ -1402,16 +1685,15 @@ export default function MovementBands({ artistsByMovement, viewStart, viewEnd, - portraitSizePx, canvasWidth ), - [layouts, artistsByMovement, viewStart, viewEnd, portraitSizePx, canvasWidth] + [layouts, artistsByMovement, viewStart, viewEnd, canvasWidth] ); const artistPlacements = useMemo(() => { - if (interacting || panning) return []; + if (interacting || panning || layoutAnimating) return []; return artistPlacementsForLabels; - }, [artistPlacementsForLabels, interacting, panning]); + }, [artistPlacementsForLabels, interacting, panning, layoutAnimating]); const labelPlacements = useMemo( () => @@ -1421,8 +1703,6 @@ export default function MovementBands({ layoutHeight, canvasWidth, canvasHeight, - streamStrokePx, - portraitSizePx, branches ), [ @@ -1431,8 +1711,6 @@ export default function MovementBands({ layoutHeight, canvasWidth, canvasHeight, - streamStrokePx, - portraitSizePx, 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 overlaysReady = !fastGraphics && !layoutAnimating; const showPortraits = hoveredMovementId != null || hoveredArtistKey != null; return ( @@ -1500,24 +1782,24 @@ export default function MovementBands({ height={layoutHeight} > - {layouts.map((layout) => ( - - ))} + {drawLayouts.map((layout) => ( + + ))} {!fastGraphics && - branches.map((branch) => ( + drawBranches.map((branch) => ( - - + + ))} {!fastGraphics && - layouts.map((layout) => { + drawLayouts.map((layout) => { const primaryParent = layout.parentIds.length > 0 ? layoutById.get(layout.parentIds[0]) : null; const hasChildren = (childIdsByParent.get(layout.movement.id)?.length ?? 0) > 0; - const parentColor = primaryParent?.movement.color ?? layout.movement.color; + const parentColor = primaryParent?.displayColor ?? layout.displayColor; return ( {primaryParent ? ( <> - - - + + + ) : ( )} - + {hasChildren ? ( <> - - - + + + ) : ( )} @@ -1584,25 +1866,27 @@ export default function MovementBands({ {fastGraphics ? ( <> - {branches.map((branch) => ( + {drawBranches.map((branch) => ( ))} - {layouts.map((layout) => { - const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset); + {drawLayouts.map((layout) => { + const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, drawCurveOffset); return ( ); })} @@ -1610,7 +1894,7 @@ export default function MovementBands({ ) : ( <> - {branches.map((branch) => { + {drawBranches.map((branch) => { const [fromId, toId] = branch.key.split('-').map(Number); const branchHighlighted = hoveredMovementId != null && @@ -1622,13 +1906,14 @@ export default function MovementBands({ className={`movement-branch${branchHighlighted ? ' movement-branch-highlighted' : ''}`} stroke={`url(#branch-grad-${branch.key})`} fill="none" + style={{ strokeWidth: branch.strokePx }} /> ); })} - {layouts.map((layout) => { - const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset); + {drawLayouts.map((layout) => { + const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, drawCurveOffset); const streamHighlighted = hoveredMovementId === layout.movement.id; return ( ); })} - {layouts.map((layout) => { - const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset); + {drawLayouts.map((layout) => { + const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, drawCurveOffset); const streamHighlighted = hoveredMovementId === layout.movement.id; return ( ); @@ -1658,7 +1944,7 @@ export default function MovementBands({ )} - {hoveredPlacement && !fastGraphics && ( + {hoveredPlacement && overlaysReady && (
{hoveredPlacement.lineLeft > 0 && (
)} - {!fastGraphics && ( + {overlaysReady && ( <>
- {layouts.map((layout) => ( + {drawLayouts.map((layout) => (
setMovementHover(layout.movement.id)} onMouseLeave={clearMovementHoverSoon} @@ -1744,6 +2031,7 @@ export default function MovementBands({ portraitX={portraitX} y={y} layoutHeight={layoutHeight} + portraitSizePx={layout.portraitSizePx} color={color} colorIndex={colorIndex} isHovered={hoveredArtistKey === artistKey} diff --git a/client/src/components/VirtualGallery.tsx b/client/src/components/VirtualGallery.tsx index 0ea4bba..19a182e 100644 --- a/client/src/components/VirtualGallery.tsx +++ b/client/src/components/VirtualGallery.tsx @@ -1418,6 +1418,7 @@ function ArtistHall({ nearPassage?: boolean; }) { const { width, depth, segments } = layout; + const endWallHasDoor = movementMode && 'endWallHasDoor' in layout ? layout.endWallHasDoor : false; const halfW = width / 2; const halfD = depth / 2; 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 ? ( - <> - - + ) : movementMode && endWallHasDoor ? ( + ) : ( <> @@ -1547,8 +1546,8 @@ function ArtistHall({ )} - {/* Back wall — movement exit / navigation, or solid with title */} - {movementMode ? ( + {/* Back wall — multi-wing exit / navigator, or solid end wall for paintings */} + {movementMode && endWallHasDoor ? ( <> + ) : movementMode ? ( + interiorStyle ? ( + + ) : ( + + ) ) : null} {interiorStyle && computedWindows && computedWindows.length > 0 && ( @@ -2108,11 +2118,22 @@ export default function VirtualGallery(props: Props) { for (const seg of layout.segments) { for (const slot of seg.slots) boxes.push(paintingKeepOutBounds(slot)); } - // Door jambs — keep 0.5 m from the opening posts; opening corridor stays walkable. - boxes.push(...doorJambKeepOuts(wallInnerHalfD, true)); - if (isWingedHall) boxes.push(...doorJambKeepOuts(wallInnerHalfD, false)); + 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)); + } + // Back exit jambs for multi-wing halls. + if (isWingedHall && endWallHasDoor) { + boxes.push(...doorJambKeepOuts(wallInnerHalfD, false)); + } 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( (pos: { x: number; z: number }, allowFrontDoorApproach: boolean) => { @@ -2123,8 +2144,10 @@ export default function VirtualGallery(props: Props) { let minZ = -playHalfD; let maxZ = playHalfD; if (inDoorBand) { - minZ = -wallInnerHalfD + 0.08; - if (hasNextHall || allowFrontDoorApproach) maxZ = wallInnerHalfD - 0.08; + if (endWallHasDoor) minZ = -wallInnerHalfD + 0.08; + if (hasNextHall || allowFrontDoorApproach || exitOnFront) { + maxZ = wallInnerHalfD - 0.08; + } } pos.z = Math.max(minZ, Math.min(maxZ, pos.z)); } else { @@ -2144,6 +2167,8 @@ export default function VirtualGallery(props: Props) { exitZ, isWingedHall, hasNextHall, + endWallHasDoor, + exitOnFront, wallInnerHalfD, collisionObstacles, ] @@ -2218,23 +2243,30 @@ export default function VirtualGallery(props: Props) { } }, [isWingedHall, onBack, !isWingedHall ? props.data.artist.id : undefined]); - const backExitZ = isWingedHall ? -playHalfD : exitZ; + const backExitZ = isWingedHall && endWallHasDoor ? -playHalfD : exitZ; const frontPassageZ = playHalfD; const updateProximityFlags = useCallback( (pos: { x: number; z: number }) => { 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 = hasNextHall && pos.z > frontPassageZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6; - setNearExit(atBackExit); + setNearExit(!!(atBackExit || atFrontExit)); setNearPassage(!!atFrontPassage); } else { const atExit = pos.z > exitZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6; setNearExit(atExit); } }, - [isWingedHall, backExitZ, frontPassageZ, hasNextHall, exitZ] + [isWingedHall, endWallHasDoor, backExitZ, frontPassageZ, hasNextHall, exitZ] ); const moveCamera = useCallback( @@ -2548,8 +2580,12 @@ export default function VirtualGallery(props: Props) { {isWingedHall ? ( <>
  • Date and artist labels appear below each frame
  • -
  • Works hang on left & right walls — up to ~55 per wing
  • -
  • Back door: wing navigator & exit to timeline
  • +
  • Works hang on left, end, and right walls — up to ~55 per wing
  • +
  • + {movementHalls.length > 1 + ? 'Back door: wing navigator & exit to timeline · Front: next wing' + : 'Entrance door: exit to timeline'} +
  • {movementHalls.length > 1 && (
  • Front archway: walk to the next chronological wing
  • )} diff --git a/client/src/data/movement-interior-styles.ts b/client/src/data/movement-interior-styles.ts index aa864d6..e57b3bc 100644 --- a/client/src/data/movement-interior-styles.ts +++ b/client/src/data/movement-interior-styles.ts @@ -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 = { + 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. */ function fallbackByName(movement: ArtMovement & { era_name?: string }): MovementInteriorStyle { const n = movement.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('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 (era.includes('modern') || era.includes('contemporary')) return BY_MOVEMENT_ID[52]; return BY_MOVEMENT_ID[36]; @@ -593,7 +628,8 @@ function fallbackByName(movement: ArtMovement & { era_name?: string }): Movement export function resolveMovementInteriorStyle( movement: ArtMovement & { era_name?: string } ): 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); } diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index 7e78024..cec434b 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -641,6 +641,7 @@ export default function HomePage() { const handleMovementClick = async (movementId: number) => { setGalleryEntryLoading('Opening movement gallery…'); try { + await api.preloadMovementImages(movementId).catch(() => undefined); const data = await api.getMovementGallery(movementId); openMovementGallery(movementId, data); } catch { diff --git a/client/src/types/index.ts b/client/src/types/index.ts index 1902a22..7774f9f 100644 --- a/client/src/types/index.ts +++ b/client/src/types/index.ts @@ -20,6 +20,8 @@ export interface ArtMovement { era_name?: string; description: string; color: string; + /** Influence edges on paintings by artists in this movement (timeline band weight). */ + influence_link_count?: number; } export interface Artist { diff --git a/client/src/utils/movementHallLayout.ts b/client/src/utils/movementHallLayout.ts index 9f8badb..ca90ea0 100644 --- a/client/src/utils/movementHallLayout.ts +++ b/client/src/utils/movementHallLayout.ts @@ -29,6 +29,8 @@ export interface MovementHallLayout { segments: WallSegment[]; paintingCount: number; yearLabel: string; + /** When true, far (−Z) wall has a center exit; paintings hang on flanks only. */ + endWallHasDoor: boolean; } 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( paintings: Painting[], width: number, halfD: number, - inset: number + inset: number, + hasDoor: boolean ): FrameSlot[] { 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 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 (group.length === 0) return []; - const { slots: rowSlots } = layoutRow(group, flankSpan); + if (!hasDoor) { + const { slots: rowSlots } = layoutRow(paintings, width); return rowSlots.map((s) => ({ maxW: s.maxW, maxH: s.maxH, rotationY: 0, 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( @@ -206,7 +231,21 @@ export function buildMovementHallLayout( const leftSpan = layoutRow(left, MIN_HALL_SIZE); const rightSpan = layoutRow(right, MIN_HALL_SIZE); const depth = Math.max(MIN_HALL_SIZE, leftSpan.spanNeeded, rightSpan.spanNeeded); - const width = MIN_HALL_WIDTH; + + // 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 halfD = depth / 2; const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET; @@ -216,7 +255,7 @@ export function buildMovementHallLayout( side: 'back', label: back.length > 0 ? `Wing ${hallIndex + 1} · End wall` : '', paintings: back, - slots: layoutBackSlots(back, width, halfD, inset), + slots: layoutBackSlots(back, width, halfD, inset, endWallHasDoor), }, { side: 'left', @@ -240,6 +279,7 @@ export function buildMovementHallLayout( segments, paintingCount: paintings.length, yearLabel: formatYearLabel(paintings), + endWallHasDoor, }; } diff --git a/server/image-service.js b/server/image-service.js index 14216d9..b41a668 100644 --- a/server/image-service.js +++ b/server/image-service.js @@ -160,17 +160,9 @@ async function syncPaintingFromDisk(row) { } /** Fast preload: link local files only, no external API calls; regenerate missing thumbs from full files. */ -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] - ); - +async function preloadPaintingImageRows(rows) { let linked = 0; - for (const row of rows.rows) { + for (const row of rows) { const hasFull = localFileExists(row.image_path); const hasThumb = localFileExists(row.thumbnail_path) && isPaintingThumbRel(row.thumbnail_path); 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') { @@ -613,6 +627,7 @@ async function replaceArtistPortraitFromUrl(artistId, imageUrl, context = {}) { module.exports = { ensurePaintingImages, preloadArtistImagesLocal, + preloadMovementImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, diff --git a/server/index.js b/server/index.js index f381798..03f6f5c 100644 --- a/server/index.js +++ b/server/index.js @@ -12,7 +12,7 @@ const { requirePermission } = require('./middleware/auth'); const { logCuratorAction } = require('./audit-log'); const authRoutes = require('./routes/auth'); 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 { searchCatalog } = require('./search-service'); const { @@ -90,10 +90,18 @@ async function fetchTimelineErasAndMovements(startYear, endYear) { [startYear, endYear] ), 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 LEFT JOIN historical_eras e ON m.era_id = e.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 AND (a.death_year IS NULL OR a.death_year >= $1) 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 art_movements) AS movement_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]; } @@ -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 app.get('/api/paintings/:id/debug-image-search/more', requirePermission('images'), async (req, res) => { try {