Improve movement flow layout/animation and fix Byzantine hall doors, styles, and preload.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
23e1210e86
commit
8111cd0163
+17
-2
@@ -116,7 +116,7 @@ Only **admins** can create or promote **admin** accounts. Cannot deactivate/demo
|
||||
| Tour admin (`/api/tours/admin`, POST/PATCH/DELETE, stops) | `tours` | `tour.*` |
|
||||
| `/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
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,24 +1782,24 @@ export default function MovementBands({
|
||||
height={layoutHeight}
|
||||
>
|
||||
<rect x={0} y={0} width={100} height={layoutHeight} fill="white" />
|
||||
{layouts.map((layout) => (
|
||||
<path
|
||||
key={`branch-cutout-${layout.movement.id}`}
|
||||
d={streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset)}
|
||||
fill="none"
|
||||
stroke="black"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
strokeWidth: streamStrokePx * 1.12,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{drawLayouts.map((layout) => (
|
||||
<path
|
||||
key={`branch-cutout-${layout.movement.id}`}
|
||||
d={streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, drawCurveOffset)}
|
||||
fill="none"
|
||||
stroke="black"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
strokeWidth: layout.strokePx * 1.12,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</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}
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
<GalleryWall position={[0, WALL_HEIGHT / 2, halfD]} size={[width, WALL_HEIGHT]} color={walls.main} />
|
||||
</>
|
||||
) : 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.
|
||||
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 ? (
|
||||
<>
|
||||
<li>Date and artist labels appear below each frame</li>
|
||||
<li>Works hang on left & right walls — up to ~55 per wing</li>
|
||||
<li>Back door: wing navigator & 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>
|
||||
)}
|
||||
|
||||
@@ -578,13 +578,48 @@ function blendAccent(style: MovementInteriorStyle, accentHex?: string): Movement
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Styles were authored with legacy ids 27–52; gallery_dev uses 1–26.
|
||||
* Northern (5) / High Renaissance (6) are swapped vs the legacy sequence.
|
||||
*/
|
||||
const DB_ID_TO_STYLE_KEY: Record<number, number> = {
|
||||
1: 27,
|
||||
2: 28,
|
||||
3: 29,
|
||||
4: 30,
|
||||
5: 32,
|
||||
6: 31,
|
||||
7: 33,
|
||||
8: 34,
|
||||
9: 35,
|
||||
10: 36,
|
||||
11: 37,
|
||||
12: 38,
|
||||
13: 39,
|
||||
14: 40,
|
||||
15: 41,
|
||||
16: 42,
|
||||
17: 43,
|
||||
18: 44,
|
||||
19: 45,
|
||||
20: 46,
|
||||
21: 47,
|
||||
22: 48,
|
||||
23: 49,
|
||||
24: 50,
|
||||
25: 51,
|
||||
26: 52,
|
||||
};
|
||||
|
||||
/** Fallback name-based resolver for movements not in the catalog. */
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user