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.*` |
| `/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/<path>` 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 |
+11 -10
View File
@@ -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 **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 |
| 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.
+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` |
| 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 **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).
+10
View File
@@ -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 {
+12 -10
View File
@@ -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 {
+368 -80
View File
@@ -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<number, Artist[]>,
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<number, numb
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(
group: ArtMovement[],
viewStart: number,
@@ -351,7 +393,7 @@ function assignTemporalLanes(
const laneById = new Map<number, number>();
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<string | null>(null);
@@ -1114,6 +1300,11 @@ export default function MovementBands({
const interactionTimer = useRef<number | null>(null);
const viewRef = useRef({ viewStart, viewEnd });
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 };
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<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
// 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<number, number>();
const laneMaxDesiredStroke = new Map<number, number>();
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<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 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,16 +1782,16 @@ export default function MovementBands({
height={layoutHeight}
>
<rect x={0} y={0} width={100} height={layoutHeight} fill="white" />
{layouts.map((layout) => (
{drawLayouts.map((layout) => (
<path
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"
stroke="black"
strokeLinecap="round"
strokeLinejoin="round"
style={{
strokeWidth: streamStrokePx * 1.12,
strokeWidth: layout.strokePx * 1.12,
vectorEffect: 'non-scaling-stroke',
}}
/>
@@ -1517,7 +1799,7 @@ export default function MovementBands({
</mask>
{!fastGraphics &&
branches.map((branch) => (
drawBranches.map((branch) => (
<linearGradient
key={`branch-grad-${branch.key}`}
id={`branch-grad-${branch.key}`}
@@ -1527,17 +1809,17 @@ export default function MovementBands({
x2={branch.x2}
y2={branch.y2}
>
<stop offset="0%" stopColor={branch.colorFrom} stopOpacity={0.36} />
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0.36} />
<stop offset="0%" stopColor={branch.colorFrom} stopOpacity={0.55} />
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0.55} />
</linearGradient>
))}
{!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 (
<linearGradient
@@ -1551,29 +1833,29 @@ export default function MovementBands({
>
{primaryParent ? (
<>
<stop offset="0%" stopColor={parentColor} stopOpacity={0.28} />
<stop offset="16%" stopColor={layout.movement.color} stopOpacity={0.34} />
<stop offset="32%" stopColor={layout.movement.color} stopOpacity={0.38} />
<stop offset="0%" stopColor={parentColor} stopOpacity={0.48} />
<stop offset="16%" stopColor={layout.displayColor} stopOpacity={0.58} />
<stop offset="32%" stopColor={layout.displayColor} stopOpacity={0.64} />
</>
) : (
<stop
offset="0%"
stopColor={layout.movement.color}
stopOpacity={layout.movement.start_definite ? 0.28 : 0.08}
stopColor={layout.displayColor}
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 ? (
<>
<stop offset="68%" stopColor={layout.movement.color} stopOpacity={0.38} />
<stop offset="84%" stopColor={layout.movement.color} stopOpacity={0.22} />
<stop offset="100%" stopColor={layout.movement.color} stopOpacity={0} />
<stop offset="68%" stopColor={layout.displayColor} stopOpacity={0.64} />
<stop offset="84%" stopColor={layout.displayColor} stopOpacity={0.4} />
<stop offset="100%" stopColor={layout.displayColor} stopOpacity={0} />
</>
) : (
<stop
offset="100%"
stopColor={layout.movement.color}
stopOpacity={layout.movement.end_definite ? 0.28 : 0.08}
stopColor={layout.displayColor}
stopOpacity={layout.movement.end_definite ? 0.5 : 0.18}
/>
)}
</linearGradient>
@@ -1584,25 +1866,27 @@ export default function MovementBands({
{fastGraphics ? (
<>
<g mask="url(#movement-branch-cutout)">
{branches.map((branch) => (
{drawBranches.map((branch) => (
<path
key={branch.key}
d={branch.d}
className="movement-branch movement-branch-fast"
stroke={branch.colorTo}
fill="none"
style={{ strokeWidth: branch.strokePx * 0.45 }}
/>
))}
</g>
{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 (
<path
key={`stream-${layout.movement.id}`}
d={d}
className="movement-stream-core movement-stream-fast"
stroke={layout.movement.color}
stroke={layout.displayColor}
fill="none"
style={{ strokeWidth: layout.strokePx }}
/>
);
})}
@@ -1610,7 +1894,7 @@ export default function MovementBands({
) : (
<>
<g mask="url(#movement-branch-cutout)">
{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 }}
/>
);
})}
</g>
{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 (
<path
@@ -1637,19 +1922,20 @@ export default function MovementBands({
className={`movement-stream-fill${streamHighlighted ? ' movement-stream-highlighted' : ''}`}
stroke={`url(#stream-grad-${layout.movement.id})`}
fill="none"
style={{ strokeWidth: layout.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 (
<path
key={`core-${layout.movement.id}`}
d={d}
className={`movement-stream-core${streamHighlighted ? ' movement-stream-highlighted' : ''}`}
stroke={layout.movement.color}
stroke={layout.displayColor}
fill="none"
/>
);
@@ -1658,7 +1944,7 @@ export default function MovementBands({
)}
</svg>
{hoveredPlacement && !fastGraphics && (
{hoveredPlacement && overlaysReady && (
<div className="movement-lifespan-overlays" aria-hidden>
{hoveredPlacement.lineLeft > 0 && (
<div
@@ -1686,10 +1972,10 @@ export default function MovementBands({
</div>
)}
{!fastGraphics && (
{overlaysReady && (
<>
<div className="movements-flow-hits" aria-hidden>
{layouts.map((layout) => (
{drawLayouts.map((layout) => (
<div
key={`hit-${layout.movement.id}`}
className="movement-stream-hit"
@@ -1697,6 +1983,7 @@ export default function MovementBands({
left: `${layout.xStart}%`,
width: `${Math.max(layout.xEnd - layout.xStart, 0.5)}%`,
top: `${(((layout.y + layout.yEnd) / 2) / layoutHeight) * 100}%`,
height: `${layout.strokePx}px`,
}}
onMouseEnter={() => 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}
+53 -17
View File
@@ -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 ? (
<>
<GalleryWall
@@ -1527,10 +1528,8 @@ function ArtistHall({
trimColor={walls.trim}
/>
</>
) : movementMode ? (
<>
) : movementMode && endWallHasDoor ? (
<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} />
@@ -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 ? (
<>
<GalleryWall
position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, -halfD]}
@@ -1576,6 +1575,17 @@ function ArtistHall({
/>
</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}
{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.
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));
if (isWingedHall) boxes.push(...doorJambKeepOuts(wallInnerHalfD, false));
}
// 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 ? (
<>
<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>Back door: wing navigator &amp; exit to timeline</li>
<li>Works hang on left, end, and right walls up to ~55 per wing</li>
<li>
{movementHalls.length > 1
? 'Back door: wing navigator & exit to timeline · Front: next wing'
: 'Entrance door: exit to timeline'}
</li>
{movementHalls.length > 1 && (
<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. */
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);
}
+1
View File
@@ -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 {
+2
View File
@@ -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 {
+55 -15
View File
@@ -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,
};
}
+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. */
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,
+23 -3
View File
@@ -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 {