From aa31a2aa6ec9e638ec385ee43cf5f9af3dc7113d Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Sun, 5 Jul 2026 23:07:44 +0300 Subject: [PATCH] Improve movement ribbon layout with collision-aware labels and hover highlight. Add FAC operator cheat sheet and link it from README and setup docs. --- Documentation/FAC.md | 291 ++++++++++++++++ Documentation/setup.md | 2 +- README.md | 1 + client/src/components/MovementBands.css | 42 ++- client/src/components/MovementBands.tsx | 442 ++++++++++++++++++++---- 5 files changed, 712 insertions(+), 66 deletions(-) create mode 100644 Documentation/FAC.md diff --git a/Documentation/FAC.md b/Documentation/FAC.md new file mode 100644 index 0000000..238002f --- /dev/null +++ b/Documentation/FAC.md @@ -0,0 +1,291 @@ +# Gallery — command reference (FAC) + +Quick cheat sheet for daily operations. All `npm` commands run from the **repository root** unless noted. + +**Environments:** + +| | Dev | Prod | +|---|-----|------| +| URL | https://devgallery.mysuperlab.netcraze.pro | https://gallery.mysuperlab.netcraze.pro | +| Host | Dev PC `192.168.10.70:5173` | TrueNAS `192.168.10.122:5173` | +| Database | `gallery_dev` | `gallery_prod` | + +Details: [environments.md](environments.md) · Deploy: [../infra/docker/DEPLOY-truenas.md](../infra/docker/DEPLOY-truenas.md) + +--- + +## Start and stop servers + +### Start — public dev (Keenetic URL) + +```powershell +cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery +npm run dev:web +``` + +Vite on **:5173**, API on **:3451**. Open https://devgallery.mysuperlab.netcraze.pro or http://localhost:5173. + +### Start — local HMR (no Keenetic) + +Two terminals: + +```powershell +npm run dev:server # API — PORT from .env (default 3451) +npm run dev:client # Vite on :5173 +``` + +### Start — production-style (single Node process, built SPA) + +```powershell +npm run start:prod # build client + serve on PORT from .env +# or: +npm run build +npm run start +``` + +### Stop dev servers + +| Method | When | +|--------|------| +| **Ctrl+C** in the terminal running `dev:web` / `dev:server` / `dev:client` | Normal stop | +| Close the terminal tab | Same effect | + +Free ports **5173**, **3451**, **3520** before DB maintenance or if “port in use” errors appear. + +**Find what holds a port (PowerShell):** + +```powershell +netstat -ano | findstr ":5173 :3451" +# Stop by PID: +Stop-Process -Id -Force +``` + +### Stop / restart production (TrueNAS) + +| Action | Where | +|--------|-------| +| **Stop** | TrueNAS Web UI → Apps → **gallery-web** → Stop | +| **Restart** | Same → Restart (after `docker:publish` or config change) | +| **Update image** | Dev PC: `npm run docker:publish` → restart app on TrueNAS | + +--- + +## First-time install + +```powershell +cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery +copy .env.example .env # edit DB credentials, PUBLIC_URL +npm install +cd client; npm install; cd .. + +npm run migrate # schema + incremental SQL +npm run setup # migrate + seed (fresh empty DB only) +``` + +After clone with existing data/images, skip `setup` if DB already split — use post-seed steps below. + +--- + +## Database + +| Command | Description | +|---------|-------------| +| `npm run migrate` | Apply `db/schema.sql` + incremental migrations (safe to re-run) | +| `npm run setup` | `migrate` + `seed` — fresh catalog from Wikipedia data | +| `npm run db:split-databases` | One-time: legacy `Gallery` → `gallery_prod` + `gallery_dev` (needs `PGUSER=postgres`) | +| `npm run db:sync-from-prod` | Clone `gallery_prod` → `gallery_dev` (TEMPLATE); also runs image sync from prod | +| `npm run db:backup` | Dev data-only backup → `db/DataBackup/*.txt` + `.zip` | +| `npm run db:backup:prod` | Prod backup (reads `infra/docker/.env.prod`) | +| `npm run db:restore -- --file ` | Restore backup into **dev** (truncates tables first; prompts `yes`) | +| `npm run db:restore:prod -- --file ` | Restore into **prod** (requires confirmation) | + +**One-time split (recommended):** pgAdmin on dev PC → open [`db/split-dev-prod-pgadmin.sql`](../db/split-dev-prod-pgadmin.sql) → run each STEP on database `postgres`, then verify on `gallery_dev`. + +**Dev `.env` essentials:** + +```env +DB_NAME=gallery_dev +PORT=3451 +PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro +``` + +--- + +## Import and enrich catalog data + +Run in this order on a **new** or **re-seeded** database: + +| # | Command | What it does | +|---|---------|----------------| +| 1 | `npm run seed` | Eras, movements, artists, one flagship painting per artist | +| 2 | `npm run sync-image-paths` | Import paintings from `data/images/paintings/`; link paths in DB | +| 3 | `npm run fetch-artist-images` | Portraits → `data/images/portraits/`, set `portrait_path` | +| 4 | `npm run fetch-artist-bios` | Wikipedia bios → `bio_short` / `bio_full` | +| 5 | `npm run expand-catalog` | Add famous works per artist (below `MIN_PAINTINGS`) | +| 6 | `npm run update-influences` | Influence graph (detail panels, 3D hall lamps) | +| 7 | `npm run fetch-images -- --limit=50` | Download missing painting files (batch) | + +**One-shot bootstrap:** `npm run setup` = steps 1 + migrate only; still run 2–7 for a full catalog. + +### Useful flags + +```powershell +npm run sync-image-paths -- --dry-run +npm run fetch-artist-images -- --force +npm run fetch-artist-bios -- --force +npm run expand-catalog -- --fetch-images +npm run fetch-images -- --artist="Claude Monet" +npm run fetch-images -- --limit=50 --max-wait=120 +npm run update-influences -- --discover +npm run discover-influences # discovery only, no curated insert +``` + +### Optional migrations / imports + +| Command | Description | +|---------|-------------| +| `npm run migrate:thumbnails` | Add thumbnail columns | +| `npm run migrate:influence-sources` | `painting_influence_sources` table + backfill | +| `npm run migrate:checkup-flags` | Review flags on `paintings` | +| `npm run migrate:artist-checkup-flags` | Review flags on `artists` | +| `npm run migrate:painting-annotations` | Art-history notes table | +| `npm run migrate:artist-palette` | `palette_metadata` JSONB on artists | +| `npm run import-painter-palette` | Enrich from `Inputs/PainterPalette.csv` | +| `npm run update-painting-annotations` | Load curated notes | +| `npm run update-painting-annotations -- --wikipedia` | Add Wikipedia intro sentences | + +### Audit / export + +| Command | Description | +|---------|-------------| +| `npm run audit-painting-images` | Thumb vs full aspect-ratio mismatches | +| `npm run find-duplicates` | Duplicate / near-duplicate painting rows | +| `npm run audit-influence-duplicates` | Duplicate influence edges | +| `npm run analyze-painter-palette` | CSV ↔ artist name match report | +| `npm run export-paintings` | Write `Output/paintings.csv` | + +--- + +## Images (local files) + +| Command | Description | +|---------|-------------| +| `npm run fetch-images` | Search/download missing painting files (alias: `search-missing-paintings`) | +| `npm run fetch-artist-images` | Download or link artist portraits | +| `npm run sync-image-paths` | Align DB paths with files on disk; import new rows | +| `npm run regenerate-thumbnails` | Rebuild all thumbs from full images | + +**Local paths:** `data/images/portraits/`, `data/images/paintings/`, `data/images/paintings/thumbs/` + +--- + +## Dev ↔ prod image sync (SMB) + +SMB share **`Gallery`** → `/mnt/BasePool/Applications/Gallery` on TrueNAS. + +```powershell +net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER + +npm run images:sync-to-prod # dev repo → TrueNAS (promote / first deploy) +npm run images:sync-from-prod # TrueNAS → dev repo +``` + +Type `yes` when prompted. Robocopy exit codes **0–7** = success. + +--- + +## Docker and production deploy + +| Command | Where | Description | +|---------|-------|-------------| +| `npm run docker:publish` | Dev PC, **Admin** PowerShell, Docker Desktop running | Build + push `gallery-web:latest` to Gitea | +| `npm run docker:publish:push-only` | Same | Push only (skip rebuild) | +| `.\infra\docker\save-for-truenas.ps1` | Dev PC | Save image to `gallery-web.tar` (offline deploy) | + +**Before first deploy:** `npm run docker:publish` → TrueNAS Custom App from `infra/docker/compose.truenas.yaml`. + +**After code changes:** `npm run docker:publish` → restart **gallery-web** on TrueNAS. + +```powershell +docker login gitea.mysuperlab.netcraze.pro +``` + +--- + +## Build frontend + +```powershell +npm run build # client → client/dist/ +cd client && npm run build && cd .. +``` + +Prod container serves `client/dist/` from the Docker image (rebuild image after UI changes). + +--- + +## Verify (health checks) + +```powershell +# Dev (servers running) +curl.exe -sk https://devgallery.mysuperlab.netcraze.pro/api/bounds +curl.exe -s http://192.168.10.70:5173/api/bounds + +# Prod (bypass Keenetic) +curl.exe -s http://192.168.10.122:5173/api/bounds + +# Prod (public) +curl.exe -sk https://gallery.mysuperlab.netcraze.pro/api/bounds +``` + +Expect JSON with `min_year` / `max_year`. HTML shell only from `curl` on `/` is normal (Vite dev). + +**PowerShell note:** use `curl.exe`, not `curl` — PowerShell aliases `curl` to `Invoke-WebRequest` (no `-k` flag). + +--- + +## Promote dev → prod (checklist) + +1. `npm run db:backup` +2. Test on https://devgallery.mysuperlab.netcraze.pro +3. `npm run db:restore:prod -- --file db/DataBackup/gallery_dev_data_....txt` +4. `npm run images:sync-to-prod` +5. `npm run docker:publish` (if code changed) +6. Restart **gallery-web** on TrueNAS + +--- + +## Keenetic (router) + +Both rules: **protocol to device = `http`**, **Preserve Host = ON**. + +| Domain | Upstream | +|--------|----------| +| `devgallery.mysuperlab.netcraze.pro` | `192.168.10.70:5173` | +| `gallery.mysuperlab.netcraze.pro` | `192.168.10.122:5173` | + +Wrong IP or `https` to device → **502 / 504** (`Server: Web server`). + +--- + +## Git (Gitea) + +```powershell +git status +git add . +git commit -m "Your message" +git push origin main +``` + +Remote: https://gitea.mysuperlab.netcraze.pro/Danilka/Art-gallery + +--- + +## Related docs + +| Document | Contents | +|----------|----------| +| [environments.md](environments.md) | Full dev/prod walkthrough | +| [setup.md](setup.md) | Install, env vars, troubleshooting | +| [data-and-images.md](data-and-images.md) | Catalog and image pipeline | +| [API.md](API.md) | REST endpoints | +| [DB_structure.md](DB_structure.md) | PostgreSQL schema | diff --git a/Documentation/setup.md b/Documentation/setup.md index 4e44a9e..c3c1752 100644 --- a/Documentation/setup.md +++ b/Documentation/setup.md @@ -103,7 +103,7 @@ Image fetch can take hours if you run it for the entire catalog. The first line | `npm run dev:client` | Vite dev server on :5173 | | `npm run dev` | Alias for `start` | -See [environments.md](environments.md) for dev/prod URLs, database split, Docker deploy, and sync commands. +See [environments.md](environments.md) for dev/prod URLs, database split, Docker deploy, and sync commands. Quick reference: [FAC.md](FAC.md). **Production frontend:** build the client, then start the server: diff --git a/README.md b/README.md index 3052f21..c92fff4 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Interactive virtual art gallery: zoomable historical timeline with era click-to- | Document | Purpose | |----------|---------| | [Documentation/basics.md](Documentation/basics.md) | Architecture, layout, user flow | +| [Documentation/FAC.md](Documentation/FAC.md) | **Command cheat sheet** — start/stop, import, deploy | | [Documentation/environments.md](Documentation/environments.md) | Dev/prod URLs, DB split, deploy, sync | | [Documentation/setup.md](Documentation/setup.md) | Install, env, npm scripts | | [Documentation/DB_structure.md](Documentation/DB_structure.md) | PostgreSQL tables | diff --git a/client/src/components/MovementBands.css b/client/src/components/MovementBands.css index 37904ea..d19878b 100644 --- a/client/src/components/MovementBands.css +++ b/client/src/components/MovementBands.css @@ -112,12 +112,14 @@ stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; + transition: filter 0.2s ease, opacity 0.2s ease; } .movement-stream-fill { stroke-width: var(--stream-stroke); stroke-linecap: round; vector-effect: non-scaling-stroke; + transition: filter 0.2s ease, opacity 0.2s ease; } .movement-stream-core { @@ -127,6 +129,33 @@ stroke-dasharray: 8 6; animation: stream-shimmer 16s linear infinite; vector-effect: non-scaling-stroke; + transition: filter 0.2s ease, opacity 0.2s ease; +} + +.movements-flow-canvas.movements-flow-movement-hover .movement-stream-fill:not(.movement-stream-highlighted) { + opacity: 0.55; +} + +.movements-flow-canvas.movements-flow-movement-hover .movement-stream-core:not(.movement-stream-highlighted) { + opacity: 0.1; +} + +.movements-flow-canvas.movements-flow-movement-hover .movement-branch:not(.movement-branch-highlighted) { + opacity: 0.45; +} + +.movement-stream-fill.movement-stream-highlighted { + filter: brightness(1.65) saturate(1.3); +} + +.movement-stream-core.movement-stream-highlighted { + opacity: 0.72; + filter: brightness(1.5); + stroke-width: 2.5px; +} + +.movement-branch.movement-branch-highlighted { + filter: brightness(1.55) saturate(1.2); } @keyframes stream-shimmer { @@ -201,16 +230,23 @@ position: absolute; inset: 0; pointer-events: none; - z-index: 3; + z-index: 6; } .movement-flow-label { position: absolute; - transform: translate(-4px, -100%); max-width: 160px; padding: 2px 6px; pointer-events: none; - z-index: 3; + z-index: 6; +} + +.movement-flow-label-above { + transform: translate(-4px, -100%); +} + +.movement-flow-label-below { + transform: translate(-4px, 0); } .movement-flow-label-btn { diff --git a/client/src/components/MovementBands.tsx b/client/src/components/MovementBands.tsx index 45296ef..37d57e8 100644 --- a/client/src/components/MovementBands.tsx +++ b/client/src/components/MovementBands.tsx @@ -42,6 +42,8 @@ interface BranchSegment { const MAX_STREAM_STROKE_PX = 54; const MIN_STREAM_STROKE_PX = 28; +const LANE_GAP_PX = 10; +const DEPTH_GAP_PX = 14; const CANVAS_TOP_PAD = 38; const CANVAS_BOTTOM_PAD = 24; const DEFAULT_CANVAS_HEIGHT = 360; @@ -327,11 +329,50 @@ function assignDepths(movements: ArtMovement[], lineageParents: Map { + const spans = group + .map((m) => ({ + id: m.id, + start: Math.max(m.start_year, viewStart), + end: Math.min(m.end_year, viewEnd), + })) + .filter((s) => s.end > s.start) + .sort((a, b) => a.start - b.start || a.end - b.end); + + const laneEnds: number[] = []; + const laneById = new Map(); + + for (const span of spans) { + let lane = laneEnds.findIndex((endYear) => endYear <= span.start); + if (lane === -1) { + lane = laneEnds.length; + laneEnds.push(span.end); + } else { + laneEnds[lane] = span.end; + } + laneById.set(span.id, lane); + } + + return laneById; +} + +function streamPath( + xStart: number, + xEnd: number, + yStart: number, + yEnd: number, + curveOffset = 14 +): string { const width = Math.max(xEnd - xStart, 0.5); + const bulge = Math.min(curveOffset, width * 0.08); const c1x = xStart + width * 0.32; const c2x = xEnd - width * 0.32; - return `M ${xStart} ${yStart} C ${c1x} ${yStart - 14}, ${c2x} ${yEnd + 14}, ${xEnd} ${yEnd}`; + return `M ${xStart} ${yStart} C ${c1x} ${yStart - bulge}, ${c2x} ${yEnd + bulge}, ${xEnd} ${yEnd}`; } function yOnStream(layout: MovementLayout, x: number): number { @@ -359,6 +400,240 @@ function branchOriginOnParent(parent: MovementLayout, childIndex: number, childC return { x, y: yOnStream(parent, x) }; } +interface LabelPlacement { + layout: MovementLayout; + leftPct: number; + topPct: number; + above: boolean; +} + +interface PixelRect { + left: number; + top: number; + right: number; + bottom: number; +} + +const LABEL_MAX_WIDTH_PX = 160; +const LABEL_GAP_PX = 8; +const LABEL_PORTRAIT_PAD_PX = 6; +const LABEL_MIN_SEPARATION_PX = 4; + +function estimateLabelWidthPx(name: string): number { + const textWidth = name.length * 7.2 + 14; + return Math.min(LABEL_MAX_WIDTH_PX, Math.max(52, textWidth)); +} + +function labelHeightPx(hasEra: boolean): number { + return hasEra ? 36 : 22; +} + +function rectsOverlap(a: PixelRect, b: PixelRect, pad = LABEL_MIN_SEPARATION_PX): boolean { + return !( + a.right + pad <= b.left || + a.left - pad >= b.right || + a.bottom + pad <= b.top || + a.top - pad >= b.bottom + ); +} + +function portraitObstacleRect( + portraitX: number, + y: number, + portraitSizePx: number, + layoutHeight: number, + canvasWidth: number, + canvasHeight: number +): PixelRect { + const cx = (portraitX / 100) * canvasWidth; + const cy = (y / layoutHeight) * canvasHeight; + const half = portraitSizePx / 2 + LABEL_PORTRAIT_PAD_PX; + return { left: cx - half, top: cy - half, right: cx + half, bottom: cy + half }; +} + +function streamObstacleRect( + streamY: number, + streamStrokePx: number, + layoutHeight: number, + canvasHeight: number, + leftPx: number, + widthPx: number +): PixelRect { + const cy = (streamY / layoutHeight) * canvasHeight; + const half = streamStrokePx / 2 + 2; + return { + left: leftPx, + top: cy - half, + right: leftPx + widthPx, + bottom: cy + half, + }; +} + +function labelObstacleRect( + leftPct: number, + streamY: number, + above: boolean, + widthPx: number, + heightPx: number, + streamStrokePx: number, + layoutHeight: number, + canvasWidth: number, + canvasHeight: number +): PixelRect { + const anchorX = (leftPct / 100) * canvasWidth - 4; + const streamYPx = (streamY / layoutHeight) * canvasHeight; + if (above) { + const bottom = streamYPx - LABEL_GAP_PX; + return { left: anchorX, top: bottom - heightPx, right: anchorX + widthPx, bottom }; + } + const top = streamYPx + streamStrokePx / 2 + LABEL_GAP_PX; + return { left: anchorX, top, right: anchorX + widthPx, bottom: top + heightPx }; +} + +function clampLabelLeftPct(leftPct: number, widthPx: number, canvasWidth: number): number { + const halfWidthPct = ((widthPx / canvasWidth) * 100) / 2; + return Math.min(Math.max(leftPct, halfWidthPct + 0.5), 100 - halfWidthPct - 0.5); +} + +function labelAnchorTopPct( + streamY: number, + above: boolean, + streamStrokePx: number, + layoutHeight: number +): number { + const gap = above ? LABEL_GAP_PX : streamStrokePx / 2 + LABEL_GAP_PX; + const y = above ? streamY - gap : streamY + gap; + return (y / layoutHeight) * 100; +} + +function buildLabelPlacements( + layouts: MovementLayout[], + artistPlacements: ArtistPlacement[], + layoutHeight: number, + canvasWidth: number, + canvasHeight: number, + streamStrokePx: number, + portraitSizePx: number +): LabelPlacement[] { + if (layouts.length === 0 || canvasWidth <= 0 || canvasHeight <= 0) return []; + + const portraitObstacles = artistPlacements.map((p) => + portraitObstacleRect(p.portraitX, p.y, portraitSizePx, layoutHeight, canvasWidth, canvasHeight) + ); + + const placed: LabelPlacement[] = []; + const placedLabelRects: PixelRect[] = []; + + const sortedLayouts = [...layouts].sort((a, b) => a.y - b.y || a.xStart - b.xStart); + + for (const layout of sortedLayouts) { + const hasEra = !!layout.movement.era_name; + const widthPx = estimateLabelWidthPx(layout.movement.name); + const heightPx = labelHeightPx(hasEra); + const span = Math.max(layout.xEnd - layout.xStart, 0.5); + + const xCandidates = new Set(); + for (const t of [0, 0.2, 0.4, 0.5, 0.6, 0.8, 1]) { + xCandidates.add(layout.xStart + span * t); + } + + const movementPortraits = artistPlacements + .filter((p) => p.layout.movement.id === layout.movement.id) + .sort((a, b) => a.portraitX - b.portraitX); + + if (movementPortraits.length === 0) { + xCandidates.add(layout.xStart + span * 0.5); + } else { + const first = movementPortraits[0].portraitX; + const last = movementPortraits[movementPortraits.length - 1].portraitX; + if (first - layout.xStart > 4) xCandidates.add(layout.xStart + (first - layout.xStart) * 0.45); + if (layout.xEnd - last > 4) xCandidates.add(last + (layout.xEnd - last) * 0.55); + for (let i = 0; i < movementPortraits.length - 1; i++) { + xCandidates.add((movementPortraits[i].portraitX + movementPortraits[i + 1].portraitX) / 2); + } + } + + const orderedX = [...xCandidates].sort( + (a, b) => Math.abs(a - (layout.xStart + span * 0.5)) - Math.abs(b - (layout.xStart + span * 0.5)) + ); + + let chosen: LabelPlacement | null = null; + + for (const above of [true, false] as const) { + for (const rawX of orderedX) { + const leftPct = clampLabelLeftPct(rawX, widthPx, canvasWidth); + const streamY = yOnStream(layout, leftPct); + const rect = labelObstacleRect( + leftPct, + streamY, + above, + widthPx, + heightPx, + streamStrokePx, + layoutHeight, + canvasWidth, + canvasHeight + ); + const streamRect = streamObstacleRect( + streamY, + streamStrokePx, + layoutHeight, + canvasHeight, + rect.left, + widthPx + ); + + const blocked = + portraitObstacles.some((o) => rectsOverlap(rect, o)) || + placedLabelRects.some((o) => rectsOverlap(rect, o)) || + (above && rectsOverlap(rect, streamRect)); + + if (!blocked) { + chosen = { + layout, + leftPct, + topPct: labelAnchorTopPct(streamY, above, streamStrokePx, layoutHeight), + above, + }; + placedLabelRects.push(rect); + break; + } + } + if (chosen) break; + } + + if (!chosen) { + const stackIndex = placed.filter((p) => p.layout.depth === layout.depth).length; + const streamY = layout.y; + const liftPx = LABEL_GAP_PX + stackIndex * (heightPx + 6); + const leftPct = clampLabelLeftPct(layout.xStart + span * 0.08, widthPx, canvasWidth); + chosen = { + layout, + leftPct, + topPct: ((streamY - liftPx) / layoutHeight) * 100, + above: true, + }; + placedLabelRects.push( + labelObstacleRect( + leftPct, + streamY, + true, + widthPx, + heightPx, + streamStrokePx, + layoutHeight, + canvasWidth, + canvasHeight + ) + ); + } + + placed.push(chosen); + } + + return placed; +} + function branchTargetOnChild(layout: MovementLayout): { x: number; y: number } { const x = (layout.xStart + layout.xEnd) / 2; return { x, y: yOnStream(layout, x) }; @@ -383,6 +658,7 @@ export default function MovementBands({ const [canvasHeight, setCanvasHeight] = useState(DEFAULT_CANVAS_HEIGHT); const [canvasWidth, setCanvasWidth] = useState(800); const [hoveredArtistKey, setHoveredArtistKey] = useState(null); + const [hoveredMovementId, setHoveredMovementId] = useState(null); const panStart = useRef({ x: 0, viewStart: 0, viewEnd: 0 }); const interactionTimer = useRef(null); const viewRef = useRef({ viewStart, viewEnd }); @@ -516,7 +792,7 @@ export default function MovementBands({ }; }, [visibleMovements.length]); - const { layouts, layoutHeight, branches, childIdsByParent, streamStrokePx, portraitSizePx } = + const { layouts, layoutHeight, branches, childIdsByParent, streamStrokePx, portraitSizePx, streamCurveOffset } = useMemo(() => { if (visibleMovements.length === 0) { return { @@ -526,6 +802,7 @@ export default function MovementBands({ childIdsByParent: new Map(), streamStrokePx: MAX_STREAM_STROKE_PX, portraitSizePx: 52, + streamCurveOffset: 14, }; } @@ -535,8 +812,6 @@ export default function MovementBands({ const maxDepth = Math.max(...depths.values()); const layoutHeight = Math.max(200, canvasHeight); const usableHeight = layoutHeight - CANVAS_TOP_PAD - CANVAS_BOTTOM_PAD; - const depthCount = maxDepth + 1; - const rowStep = usableHeight / depthCount; const byDepth = new Map(); for (const movement of visibleMovements) { @@ -546,25 +821,43 @@ export default function MovementBands({ byDepth.set(depth, list); } - const maxLaneCount = Math.max(1, ...[...byDepth.values()].map((g) => g.length)); - let streamStrokePx = Math.min(MAX_STREAM_STROKE_PX, rowStep * 0.68); - if (maxLaneCount > 1) { - streamStrokePx = Math.min(streamStrokePx, (rowStep * 0.92) / maxLaneCount); + const laneIndex = new Map(); + const maxLanesByDepth = new Map(); + for (const [depth, group] of byDepth.entries()) { + const temporalLanes = assignTemporalLanes(group, viewStart, viewEnd); + let maxLane = 0; + for (const movement of group) { + const lane = temporalLanes.get(movement.id) ?? 0; + laneIndex.set(movement.id, lane); + maxLane = Math.max(maxLane, lane + 1); + } + maxLanesByDepth.set(depth, Math.max(1, maxLane)); } + + const depthCount = maxDepth + 1; + const depthGaps = Math.max(0, depthCount - 1) * DEPTH_GAP_PX; + const totalLaneSlots = [...maxLanesByDepth.values()].reduce((sum, n) => sum + n, 0); + const minLaneStep = MIN_STREAM_STROKE_PX + LANE_GAP_PX; + const laneStep = Math.max(minLaneStep, (usableHeight - depthGaps) / Math.max(1, totalLaneSlots)); + + let streamStrokePx = Math.min(MAX_STREAM_STROKE_PX, laneStep - LANE_GAP_PX); streamStrokePx = Math.max(MIN_STREAM_STROKE_PX, streamStrokePx); const portraitSizePx = Math.max(28, Math.min(52, streamStrokePx * 0.96)); - const driftScale = rowStep < 72 ? 0 : rowStep < 88 ? 0.12 : 0.3; + const streamCurveOffset = Math.min(12, Math.max(2, (laneStep - streamStrokePx) * 0.35)); + + const depthBaseY = new Map(); + let yCursor = CANVAS_TOP_PAD; + for (let depth = 0; depth <= maxDepth; depth++) { + depthBaseY.set(depth, yCursor); + const bandLanes = maxLanesByDepth.get(depth) ?? 1; + yCursor += bandLanes * laneStep; + if (depth < maxDepth) yCursor += DEPTH_GAP_PX; + } const layoutById = new Map(); const branchList: BranchSegment[] = []; const childIdsByParent = new Map(); - const laneIndex = new Map(); - for (const group of byDepth.values()) { - group.sort((a, b) => a.start_year - b.start_year || a.id - b.id); - group.forEach((m, i) => laneIndex.set(m.id, i)); - } - for (const movement of visibleMovements) { const xStart = yearToPercent(Math.max(movement.start_year, viewStart), viewStart, viewEnd); const xEnd = yearToPercent(Math.min(movement.end_year, viewEnd), viewStart, viewEnd); @@ -572,17 +865,11 @@ export default function MovementBands({ const depth = depths.get(movement.id) ?? 0; const lane = laneIndex.get(movement.id) ?? 0; - const laneCount = byDepth.get(depth)?.length ?? 1; - const laneSpread = - laneCount > 1 - ? Math.max(0, (rowStep - streamStrokePx * 0.12) / (laneCount - 1)) - : 0; - const y = - CANVAS_TOP_PAD + - depth * rowStep + - lane * laneSpread + - organicDrift(movement.id) * driftScale; - const yEnd = y + organicDrift(movement.id + 1000) * driftScale * 0.7; + const lanesAtDepth = maxLanesByDepth.get(depth) ?? 1; + const bandTop = depthBaseY.get(depth) ?? CANVAS_TOP_PAD; + const y = bandTop + lane * laneStep + laneStep / 2; + const allowDrift = lanesAtDepth === 1 && laneStep >= 80; + const yEnd = y + (allowDrift ? organicDrift(movement.id + 1000) * 0.22 : 0); const parentIds = lineageParents.get(movement.id) || []; layoutById.set(movement.id, { @@ -642,29 +929,49 @@ export default function MovementBands({ childIdsByParent, streamStrokePx, portraitSizePx, + streamCurveOffset, }; }, [visibleMovements, movements, viewStart, viewEnd, canvasHeight]); + const artistPlacementsForLabels = useMemo( + () => + buildArtistPlacements( + layouts, + artistsByMovement, + viewStart, + viewEnd, + portraitSizePx, + canvasWidth + ), + [layouts, artistsByMovement, viewStart, viewEnd, portraitSizePx, canvasWidth] + ); + const artistPlacements = useMemo(() => { if (interacting || panning) return []; - return buildArtistPlacements( + return artistPlacementsForLabels; + }, [artistPlacementsForLabels, interacting, panning]); + + const labelPlacements = useMemo( + () => + buildLabelPlacements( + layouts, + artistPlacementsForLabels, + layoutHeight, + canvasWidth, + canvasHeight, + streamStrokePx, + portraitSizePx + ), + [ layouts, - artistsByMovement, - viewStart, - viewEnd, + artistPlacementsForLabels, + layoutHeight, + canvasWidth, + canvasHeight, + streamStrokePx, portraitSizePx, - canvasWidth - ); - }, [ - layouts, - artistsByMovement, - viewStart, - viewEnd, - portraitSizePx, - canvasWidth, - interacting, - panning, - ]); + ] + ); const hoveredPlacement = useMemo(() => { if (!hoveredArtistKey) return null; @@ -694,11 +1001,12 @@ export default function MovementBands({
))} {layouts.map((layout) => { - const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd); + const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset); return ( ) : ( <> - {branches.map((branch) => ( - - ))} + {branches.map((branch) => { + const [fromId, toId] = branch.key.split('-').map(Number); + const branchHighlighted = + hoveredMovementId != null && + (fromId === hoveredMovementId || toId === hoveredMovementId); + return ( + + ); + })} {layouts.map((layout) => { - const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd); + const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset); + const streamHighlighted = hoveredMovementId === layout.movement.id; return ( @@ -826,12 +1141,13 @@ export default function MovementBands({ })} {layouts.map((layout) => { - const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd); + const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset); + const streamHighlighted = hoveredMovementId === layout.movement.id; return ( @@ -872,17 +1188,19 @@ export default function MovementBands({ {!fastGraphics && ( <>
- {layouts.map((layout) => ( + {labelPlacements.map(({ layout, leftPct, topPct, above }) => (