Improve movement ribbon layout with collision-aware labels and hover highlight.
Add FAC operator cheat sheet and link it from README and setup docs.
This commit is contained in:
@@ -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 <PID> -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 <path>` | Restore backup into **dev** (truncates tables first; prompts `yes`) |
|
||||||
|
| `npm run db:restore:prod -- --file <path>` | 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 |
|
||||||
@@ -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:client` | Vite dev server on :5173 |
|
||||||
| `npm run dev` | Alias for `start` |
|
| `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:
|
**Production frontend:** build the client, then start the server:
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Interactive virtual art gallery: zoomable historical timeline with era click-to-
|
|||||||
| Document | Purpose |
|
| Document | Purpose |
|
||||||
|----------|---------|
|
|----------|---------|
|
||||||
| [Documentation/basics.md](Documentation/basics.md) | Architecture, layout, user flow |
|
| [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/environments.md](Documentation/environments.md) | Dev/prod URLs, DB split, deploy, sync |
|
||||||
| [Documentation/setup.md](Documentation/setup.md) | Install, env, npm scripts |
|
| [Documentation/setup.md](Documentation/setup.md) | Install, env, npm scripts |
|
||||||
| [Documentation/DB_structure.md](Documentation/DB_structure.md) | PostgreSQL tables |
|
| [Documentation/DB_structure.md](Documentation/DB_structure.md) | PostgreSQL tables |
|
||||||
|
|||||||
@@ -112,12 +112,14 @@
|
|||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
vector-effect: non-scaling-stroke;
|
vector-effect: non-scaling-stroke;
|
||||||
|
transition: filter 0.2s ease, opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.movement-stream-fill {
|
.movement-stream-fill {
|
||||||
stroke-width: var(--stream-stroke);
|
stroke-width: var(--stream-stroke);
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
vector-effect: non-scaling-stroke;
|
vector-effect: non-scaling-stroke;
|
||||||
|
transition: filter 0.2s ease, opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.movement-stream-core {
|
.movement-stream-core {
|
||||||
@@ -127,6 +129,33 @@
|
|||||||
stroke-dasharray: 8 6;
|
stroke-dasharray: 8 6;
|
||||||
animation: stream-shimmer 16s linear infinite;
|
animation: stream-shimmer 16s linear infinite;
|
||||||
vector-effect: non-scaling-stroke;
|
vector-effect: non-scaling-stroke;
|
||||||
|
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 {
|
@keyframes stream-shimmer {
|
||||||
@@ -201,16 +230,23 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 3;
|
z-index: 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.movement-flow-label {
|
.movement-flow-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
transform: translate(-4px, -100%);
|
|
||||||
max-width: 160px;
|
max-width: 160px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
pointer-events: none;
|
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 {
|
.movement-flow-label-btn {
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ interface BranchSegment {
|
|||||||
|
|
||||||
const MAX_STREAM_STROKE_PX = 54;
|
const MAX_STREAM_STROKE_PX = 54;
|
||||||
const MIN_STREAM_STROKE_PX = 28;
|
const MIN_STREAM_STROKE_PX = 28;
|
||||||
|
const LANE_GAP_PX = 10;
|
||||||
|
const DEPTH_GAP_PX = 14;
|
||||||
const CANVAS_TOP_PAD = 38;
|
const CANVAS_TOP_PAD = 38;
|
||||||
const CANVAS_BOTTOM_PAD = 24;
|
const CANVAS_BOTTOM_PAD = 24;
|
||||||
const DEFAULT_CANVAS_HEIGHT = 360;
|
const DEFAULT_CANVAS_HEIGHT = 360;
|
||||||
@@ -327,11 +329,50 @@ function assignDepths(movements: ArtMovement[], lineageParents: Map<number, numb
|
|||||||
return depth;
|
return depth;
|
||||||
}
|
}
|
||||||
|
|
||||||
function streamPath(xStart: number, xEnd: number, yStart: number, yEnd: number): string {
|
/** Pack movements into lanes — only concurrent (overlapping) spans need separate lanes. */
|
||||||
|
function assignTemporalLanes(
|
||||||
|
group: ArtMovement[],
|
||||||
|
viewStart: number,
|
||||||
|
viewEnd: number
|
||||||
|
): Map<number, number> {
|
||||||
|
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<number, number>();
|
||||||
|
|
||||||
|
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 width = Math.max(xEnd - xStart, 0.5);
|
||||||
|
const bulge = Math.min(curveOffset, width * 0.08);
|
||||||
const c1x = xStart + width * 0.32;
|
const c1x = xStart + width * 0.32;
|
||||||
const c2x = xEnd - 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 {
|
function yOnStream(layout: MovementLayout, x: number): number {
|
||||||
@@ -359,6 +400,240 @@ function branchOriginOnParent(parent: MovementLayout, childIndex: number, childC
|
|||||||
return { x, y: yOnStream(parent, x) };
|
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<number>();
|
||||||
|
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 } {
|
function branchTargetOnChild(layout: MovementLayout): { x: number; y: number } {
|
||||||
const x = (layout.xStart + layout.xEnd) / 2;
|
const x = (layout.xStart + layout.xEnd) / 2;
|
||||||
return { x, y: yOnStream(layout, x) };
|
return { x, y: yOnStream(layout, x) };
|
||||||
@@ -383,6 +658,7 @@ export default function MovementBands({
|
|||||||
const [canvasHeight, setCanvasHeight] = useState(DEFAULT_CANVAS_HEIGHT);
|
const [canvasHeight, setCanvasHeight] = useState(DEFAULT_CANVAS_HEIGHT);
|
||||||
const [canvasWidth, setCanvasWidth] = useState(800);
|
const [canvasWidth, setCanvasWidth] = useState(800);
|
||||||
const [hoveredArtistKey, setHoveredArtistKey] = useState<string | null>(null);
|
const [hoveredArtistKey, setHoveredArtistKey] = useState<string | null>(null);
|
||||||
|
const [hoveredMovementId, setHoveredMovementId] = useState<number | null>(null);
|
||||||
const panStart = useRef({ x: 0, viewStart: 0, viewEnd: 0 });
|
const panStart = useRef({ x: 0, viewStart: 0, viewEnd: 0 });
|
||||||
const interactionTimer = useRef<number | null>(null);
|
const interactionTimer = useRef<number | null>(null);
|
||||||
const viewRef = useRef({ viewStart, viewEnd });
|
const viewRef = useRef({ viewStart, viewEnd });
|
||||||
@@ -516,7 +792,7 @@ export default function MovementBands({
|
|||||||
};
|
};
|
||||||
}, [visibleMovements.length]);
|
}, [visibleMovements.length]);
|
||||||
|
|
||||||
const { layouts, layoutHeight, branches, childIdsByParent, streamStrokePx, portraitSizePx } =
|
const { layouts, layoutHeight, branches, childIdsByParent, streamStrokePx, portraitSizePx, streamCurveOffset } =
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
if (visibleMovements.length === 0) {
|
if (visibleMovements.length === 0) {
|
||||||
return {
|
return {
|
||||||
@@ -526,6 +802,7 @@ export default function MovementBands({
|
|||||||
childIdsByParent: new Map<number, number[]>(),
|
childIdsByParent: new Map<number, number[]>(),
|
||||||
streamStrokePx: MAX_STREAM_STROKE_PX,
|
streamStrokePx: MAX_STREAM_STROKE_PX,
|
||||||
portraitSizePx: 52,
|
portraitSizePx: 52,
|
||||||
|
streamCurveOffset: 14,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,8 +812,6 @@ export default function MovementBands({
|
|||||||
const maxDepth = Math.max(...depths.values());
|
const maxDepth = Math.max(...depths.values());
|
||||||
const layoutHeight = Math.max(200, canvasHeight);
|
const layoutHeight = Math.max(200, canvasHeight);
|
||||||
const usableHeight = layoutHeight - CANVAS_TOP_PAD - CANVAS_BOTTOM_PAD;
|
const usableHeight = layoutHeight - CANVAS_TOP_PAD - CANVAS_BOTTOM_PAD;
|
||||||
const depthCount = maxDepth + 1;
|
|
||||||
const rowStep = usableHeight / depthCount;
|
|
||||||
|
|
||||||
const byDepth = new Map<number, ArtMovement[]>();
|
const byDepth = new Map<number, ArtMovement[]>();
|
||||||
for (const movement of visibleMovements) {
|
for (const movement of visibleMovements) {
|
||||||
@@ -546,25 +821,43 @@ export default function MovementBands({
|
|||||||
byDepth.set(depth, list);
|
byDepth.set(depth, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxLaneCount = Math.max(1, ...[...byDepth.values()].map((g) => g.length));
|
const laneIndex = new Map<number, number>();
|
||||||
let streamStrokePx = Math.min(MAX_STREAM_STROKE_PX, rowStep * 0.68);
|
const maxLanesByDepth = new Map<number, number>();
|
||||||
if (maxLaneCount > 1) {
|
for (const [depth, group] of byDepth.entries()) {
|
||||||
streamStrokePx = Math.min(streamStrokePx, (rowStep * 0.92) / maxLaneCount);
|
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);
|
streamStrokePx = Math.max(MIN_STREAM_STROKE_PX, streamStrokePx);
|
||||||
const portraitSizePx = Math.max(28, Math.min(52, streamStrokePx * 0.96));
|
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<number, number>();
|
||||||
|
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<number, MovementLayout>();
|
const layoutById = new Map<number, MovementLayout>();
|
||||||
const branchList: BranchSegment[] = [];
|
const branchList: BranchSegment[] = [];
|
||||||
const childIdsByParent = new Map<number, number[]>();
|
const childIdsByParent = new Map<number, number[]>();
|
||||||
|
|
||||||
const laneIndex = new Map<number, number>();
|
|
||||||
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) {
|
for (const movement of visibleMovements) {
|
||||||
const xStart = yearToPercent(Math.max(movement.start_year, viewStart), viewStart, viewEnd);
|
const xStart = yearToPercent(Math.max(movement.start_year, viewStart), viewStart, viewEnd);
|
||||||
const xEnd = yearToPercent(Math.min(movement.end_year, viewEnd), 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 depth = depths.get(movement.id) ?? 0;
|
||||||
const lane = laneIndex.get(movement.id) ?? 0;
|
const lane = laneIndex.get(movement.id) ?? 0;
|
||||||
const laneCount = byDepth.get(depth)?.length ?? 1;
|
const lanesAtDepth = maxLanesByDepth.get(depth) ?? 1;
|
||||||
const laneSpread =
|
const bandTop = depthBaseY.get(depth) ?? CANVAS_TOP_PAD;
|
||||||
laneCount > 1
|
const y = bandTop + lane * laneStep + laneStep / 2;
|
||||||
? Math.max(0, (rowStep - streamStrokePx * 0.12) / (laneCount - 1))
|
const allowDrift = lanesAtDepth === 1 && laneStep >= 80;
|
||||||
: 0;
|
const yEnd = y + (allowDrift ? organicDrift(movement.id + 1000) * 0.22 : 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 parentIds = lineageParents.get(movement.id) || [];
|
const parentIds = lineageParents.get(movement.id) || [];
|
||||||
|
|
||||||
layoutById.set(movement.id, {
|
layoutById.set(movement.id, {
|
||||||
@@ -642,29 +929,49 @@ export default function MovementBands({
|
|||||||
childIdsByParent,
|
childIdsByParent,
|
||||||
streamStrokePx,
|
streamStrokePx,
|
||||||
portraitSizePx,
|
portraitSizePx,
|
||||||
|
streamCurveOffset,
|
||||||
};
|
};
|
||||||
}, [visibleMovements, movements, viewStart, viewEnd, canvasHeight]);
|
}, [visibleMovements, movements, viewStart, viewEnd, canvasHeight]);
|
||||||
|
|
||||||
|
const artistPlacementsForLabels = useMemo(
|
||||||
|
() =>
|
||||||
|
buildArtistPlacements(
|
||||||
|
layouts,
|
||||||
|
artistsByMovement,
|
||||||
|
viewStart,
|
||||||
|
viewEnd,
|
||||||
|
portraitSizePx,
|
||||||
|
canvasWidth
|
||||||
|
),
|
||||||
|
[layouts, artistsByMovement, viewStart, viewEnd, portraitSizePx, canvasWidth]
|
||||||
|
);
|
||||||
|
|
||||||
const artistPlacements = useMemo(() => {
|
const artistPlacements = useMemo(() => {
|
||||||
if (interacting || panning) return [];
|
if (interacting || panning) return [];
|
||||||
return buildArtistPlacements(
|
return artistPlacementsForLabels;
|
||||||
|
}, [artistPlacementsForLabels, interacting, panning]);
|
||||||
|
|
||||||
|
const labelPlacements = useMemo(
|
||||||
|
() =>
|
||||||
|
buildLabelPlacements(
|
||||||
|
layouts,
|
||||||
|
artistPlacementsForLabels,
|
||||||
|
layoutHeight,
|
||||||
|
canvasWidth,
|
||||||
|
canvasHeight,
|
||||||
|
streamStrokePx,
|
||||||
|
portraitSizePx
|
||||||
|
),
|
||||||
|
[
|
||||||
layouts,
|
layouts,
|
||||||
artistsByMovement,
|
artistPlacementsForLabels,
|
||||||
viewStart,
|
layoutHeight,
|
||||||
viewEnd,
|
canvasWidth,
|
||||||
|
canvasHeight,
|
||||||
|
streamStrokePx,
|
||||||
portraitSizePx,
|
portraitSizePx,
|
||||||
canvasWidth
|
]
|
||||||
);
|
);
|
||||||
}, [
|
|
||||||
layouts,
|
|
||||||
artistsByMovement,
|
|
||||||
viewStart,
|
|
||||||
viewEnd,
|
|
||||||
portraitSizePx,
|
|
||||||
canvasWidth,
|
|
||||||
interacting,
|
|
||||||
panning,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const hoveredPlacement = useMemo(() => {
|
const hoveredPlacement = useMemo(() => {
|
||||||
if (!hoveredArtistKey) return null;
|
if (!hoveredArtistKey) return null;
|
||||||
@@ -694,11 +1001,12 @@ export default function MovementBands({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className={`movements-flow-canvas${panning ? ' movements-flow-panning' : ''}${fastGraphics ? ' movements-flow-interacting' : ''}`}
|
className={`movements-flow-canvas${panning ? ' movements-flow-panning' : ''}${fastGraphics ? ' movements-flow-interacting' : ''}${hoveredMovementId != null ? ' movements-flow-movement-hover' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
['--stream-stroke' as string]: `${streamStrokePx}px`,
|
['--stream-stroke' as string]: `${streamStrokePx}px`,
|
||||||
['--portrait-size' as string]: `${portraitSizePx}px`,
|
['--portrait-size' as string]: `${portraitSizePx}px`,
|
||||||
}}
|
}}
|
||||||
|
data-movement-hover={hoveredMovementId ?? undefined}
|
||||||
onMouseDown={handlePanStart}
|
onMouseDown={handlePanStart}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -788,7 +1096,7 @@ export default function MovementBands({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{layouts.map((layout) => {
|
{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 (
|
return (
|
||||||
<path
|
<path
|
||||||
key={`stream-${layout.movement.id}`}
|
key={`stream-${layout.movement.id}`}
|
||||||
@@ -802,23 +1110,30 @@ export default function MovementBands({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{branches.map((branch) => (
|
{branches.map((branch) => {
|
||||||
<path
|
const [fromId, toId] = branch.key.split('-').map(Number);
|
||||||
key={branch.key}
|
const branchHighlighted =
|
||||||
d={branch.d}
|
hoveredMovementId != null &&
|
||||||
className="movement-branch"
|
(fromId === hoveredMovementId || toId === hoveredMovementId);
|
||||||
stroke={`url(#branch-grad-${branch.key})`}
|
return (
|
||||||
fill="none"
|
<path
|
||||||
/>
|
key={branch.key}
|
||||||
))}
|
d={branch.d}
|
||||||
|
className={`movement-branch${branchHighlighted ? ' movement-branch-highlighted' : ''}`}
|
||||||
|
stroke={`url(#branch-grad-${branch.key})`}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{layouts.map((layout) => {
|
{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 (
|
return (
|
||||||
<path
|
<path
|
||||||
key={`stream-${layout.movement.id}`}
|
key={`stream-${layout.movement.id}`}
|
||||||
d={d}
|
d={d}
|
||||||
className="movement-stream-fill"
|
className={`movement-stream-fill${streamHighlighted ? ' movement-stream-highlighted' : ''}`}
|
||||||
stroke={`url(#stream-grad-${layout.movement.id})`}
|
stroke={`url(#stream-grad-${layout.movement.id})`}
|
||||||
fill="none"
|
fill="none"
|
||||||
/>
|
/>
|
||||||
@@ -826,12 +1141,13 @@ export default function MovementBands({
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{layouts.map((layout) => {
|
{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 (
|
return (
|
||||||
<path
|
<path
|
||||||
key={`core-${layout.movement.id}`}
|
key={`core-${layout.movement.id}`}
|
||||||
d={d}
|
d={d}
|
||||||
className="movement-stream-core"
|
className={`movement-stream-core${streamHighlighted ? ' movement-stream-highlighted' : ''}`}
|
||||||
stroke={layout.movement.color}
|
stroke={layout.movement.color}
|
||||||
fill="none"
|
fill="none"
|
||||||
/>
|
/>
|
||||||
@@ -872,17 +1188,19 @@ export default function MovementBands({
|
|||||||
{!fastGraphics && (
|
{!fastGraphics && (
|
||||||
<>
|
<>
|
||||||
<div className="movements-flow-labels">
|
<div className="movements-flow-labels">
|
||||||
{layouts.map((layout) => (
|
{labelPlacements.map(({ layout, leftPct, topPct, above }) => (
|
||||||
<button
|
<button
|
||||||
key={`label-${layout.movement.id}`}
|
key={`label-${layout.movement.id}`}
|
||||||
type="button"
|
type="button"
|
||||||
className="movement-flow-label movement-flow-label-btn"
|
className={`movement-flow-label movement-flow-label-btn${above ? ' movement-flow-label-above' : ' movement-flow-label-below'}`}
|
||||||
style={{
|
style={{
|
||||||
left: `${layout.xStart}%`,
|
left: `${leftPct}%`,
|
||||||
top: `${((layout.y - 32) / layoutHeight) * 100}%`,
|
top: `${topPct}%`,
|
||||||
}}
|
}}
|
||||||
title={`Open ${layout.movement.name} gallery hall`}
|
title={`Open ${layout.movement.name} gallery hall`}
|
||||||
onClick={() => onMovementClick?.(layout.movement.id)}
|
onClick={() => onMovementClick?.(layout.movement.id)}
|
||||||
|
onMouseEnter={() => setHoveredMovementId(layout.movement.id)}
|
||||||
|
onMouseLeave={() => setHoveredMovementId(null)}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<span className="movement-name">{layout.movement.name}</span>
|
<span className="movement-name">{layout.movement.name}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user