Enhance timeline and movement flow UI; add fetched painting images.
Timeline: era and historical-event click-to-zoom, event markers from historical-events.ts. Movement flow: shared zoom/pan, viewport-fit layout, curved lineage streams, hover-to-reveal artist lifespans on stacked lanes. Docs updated. Includes batch-fetched paintings and regenerated thumbs. Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -54,7 +54,7 @@ Movements are filtered to those with at least one artist active in the requested
|
|||||||
|
|
||||||
## `GET /api/artists`
|
## `GET /api/artists`
|
||||||
|
|
||||||
Artists for timeline portraits and movement bands.
|
Artists for timeline portraits and the movement flow diagram.
|
||||||
|
|
||||||
**Query**
|
**Query**
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ Finer-grained styles (Impressionism, Cubism, Suprematism, …).
|
|||||||
| `start_definite`, `end_definite` | BOOLEAN | Same visual semantics as eras |
|
| `start_definite`, `end_definite` | BOOLEAN | Same visual semantics as eras |
|
||||||
| `era_id` | FK → `historical_eras` | Optional parent era |
|
| `era_id` | FK → `historical_eras` | Optional parent era |
|
||||||
| `description` | TEXT | |
|
| `description` | TEXT | |
|
||||||
| `color` | VARCHAR(20) | Hex colour for movement band |
|
| `color` | VARCHAR(20) | Hex colour for movement stream in the flow diagram |
|
||||||
|
|
||||||
### `artists`
|
### `artists`
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# Art Gallery — architecture basics
|
# Art Gallery — architecture basics
|
||||||
|
|
||||||
Interactive virtual museum spanning art history: zoomable timeline, movement bands, 3D gallery halls, and painting influence graphs. See [README.md](../README.md) for quick start.
|
Interactive virtual museum spanning art history: zoomable timeline, branching movement flow, 3D gallery halls, and painting influence graphs. See [README.md](../README.md) for quick start.
|
||||||
|
|
||||||
## Concept
|
## Concept
|
||||||
|
|
||||||
The app is organised as a **drill-down hierarchy**:
|
The app is organised as a **drill-down hierarchy**:
|
||||||
|
|
||||||
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries.
|
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries.
|
||||||
2. **Movement bands** — art movements aligned to the same time axis, each showing portrait thumbnails of key artists.
|
2. **Movement flow** — art movements as curved SVG streams on the same year axis; documented predecessor→successor branches; portrait thumbnails placed along each stream.
|
||||||
3. **3D gallery** — one personal hall per artist; paintings on the walls, open centre, single exit for influence-based navigation.
|
3. **3D gallery** — one personal hall per artist; paintings on the walls, open centre, single exit for influence-based navigation.
|
||||||
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right, prev/next catalog browsing, optional fullscreen, link to artist biography.
|
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right, prev/next catalog browsing, optional fullscreen, link to artist biography.
|
||||||
5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`).
|
5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`).
|
||||||
@@ -31,6 +31,10 @@ Gallery/
|
|||||||
├── server/ # Express API, DB pool, image service
|
├── server/ # Express API, DB pool, image service
|
||||||
├── client/ # React/Vite frontend
|
├── client/ # React/Vite frontend
|
||||||
│ ├── src/ # Source (components, pages, 3D scene)
|
│ ├── src/ # Source (components, pages, 3D scene)
|
||||||
|
│ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches)
|
||||||
|
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
|
||||||
|
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
|
||||||
|
│ │ └── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
|
||||||
│ └── dist/ # Production build (served by API when present)
|
│ └── dist/ # Production build (served by API when present)
|
||||||
├── scripts/ # Seed, bios, catalog expansion, image fetch
|
├── scripts/ # Seed, bios, catalog expansion, image fetch
|
||||||
│ ├── fetch-artist-bios.js
|
│ ├── fetch-artist-bios.js
|
||||||
@@ -67,7 +71,7 @@ Use the Vite URL during frontend work for HMR.
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
A[Home — timeline + movements] -->|scroll / drag / zoom| A
|
A[Home — timeline + movement flow] -->|scroll / drag / zoom| A
|
||||||
A -->|click portrait| B[Artist bio — Wikipedia text]
|
A -->|click portrait| B[Artist bio — Wikipedia text]
|
||||||
B -->|Enter Gallery| C[Artist hall — 3D]
|
B -->|Enter Gallery| C[Artist hall — 3D]
|
||||||
C -->|click painting| D[Painting detail + influences]
|
C -->|click painting| D[Painting detail + influences]
|
||||||
@@ -84,6 +88,69 @@ flowchart TD
|
|||||||
C -->|Back| A
|
C -->|Back| A
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Timeline and movement flow
|
||||||
|
|
||||||
|
The home page shows two linked views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`):
|
||||||
|
|
||||||
|
| View | Component | Purpose |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| Era bar | `Timeline.tsx` | Historical eras, major event markers, click-to-zoom |
|
||||||
|
| Movement flow | `MovementBands.tsx` | Curved streams per movement, lineage branches, artist portraits |
|
||||||
|
|
||||||
|
Both views share zoom/pan behaviour via `client/src/utils/timelineView.ts`. The home page uses a **fixed viewport** (`100vh`): the movement flow compresses vertically so all movements in the visible year range fit without page scrolling.
|
||||||
|
|
||||||
|
### Timeline controls
|
||||||
|
|
||||||
|
| Input | Action |
|
||||||
|
|-------|--------|
|
||||||
|
| Click an **era band** | Zoom to that historical period |
|
||||||
|
| Click an **event marker** | Zoom to that event (or war span) |
|
||||||
|
| Scroll wheel | Zoom toward cursor |
|
||||||
|
| Drag centre | Pan the year range |
|
||||||
|
| Drag left / right handle | Trim view start / end |
|
||||||
|
| **+** / **−** / **⟲** buttons | Zoom in, zoom out, reset to full range |
|
||||||
|
|
||||||
|
### Historical event markers
|
||||||
|
|
||||||
|
Major world events appear on the era bar as pin markers (single years) or shaded spans (e.g. World War I, World War II). Data lives in `client/src/data/historical-events.ts` — not in PostgreSQL. Labels appear when zoomed in enough; tooltips always show name and dates. Edit `HISTORICAL_EVENTS` and rebuild the client to add or change markers.
|
||||||
|
|
||||||
|
### Movement flow
|
||||||
|
|
||||||
|
Each visible movement is drawn as a **portrait-width curved stream** (~54 px stroke, compressed when many rows are visible) from its start year to its end year.
|
||||||
|
|
||||||
|
| Feature | Implementation |
|
||||||
|
|---------|----------------|
|
||||||
|
| Lineage layout | `client/src/data/movement-lineage.ts` — curated predecessor→successor pairs (Met / ArtStory / museum essays); multiple parents allowed |
|
||||||
|
| Vertical depth | Successor movements sit on rows below their deepest parent; sibling movements at the same depth are spread into lanes to limit overlap |
|
||||||
|
| Branch connectors | Smooth curves from the **centre** of a parent stream to the **centre** of each child stream (siblings fan out along the parent’s length) |
|
||||||
|
| Visual blending | Path-aligned SVG gradients with transparent fades at stream ends and branch junctions; streams draw on top of branches so overlap brightness stays uniform |
|
||||||
|
| Filtering | Same rule as the API: only movements with at least one artist active in the visible year range |
|
||||||
|
| Viewport layout | Row height and stream width scale from measured canvas size so every visible movement row fits in the remaining screen space |
|
||||||
|
|
||||||
|
### Artists on movement streams
|
||||||
|
|
||||||
|
Each artist appears as a **portrait circle** on their movement’s stream row:
|
||||||
|
|
||||||
|
| Feature | Behaviour |
|
||||||
|
|---------|-----------|
|
||||||
|
| Placement | Portrait at the **midpoint** of birth–death years |
|
||||||
|
| Lifespan line | Hidden by default; **hover the portrait** to show a horizontal line from birth year to death year |
|
||||||
|
| Timeline span | The lifespan line follows the **full timeline axis** (clamped to the current zoom), even when it extends beyond the movement band |
|
||||||
|
| Stacking | Artists in the same movement who would overlap are placed on **separate lanes** within the stream band |
|
||||||
|
| Colour | Each lane gets a slightly shifted tint derived from the movement colour; portrait border matches its line |
|
||||||
|
|
||||||
|
### Movement flow controls
|
||||||
|
|
||||||
|
| Input | Action |
|
||||||
|
|-------|--------|
|
||||||
|
| Scroll wheel on flow canvas | Zoom (same range as timeline) |
|
||||||
|
| Drag on flow canvas | Pan |
|
||||||
|
| Click portrait | Open artist biography |
|
||||||
|
|
||||||
|
Artist portraits stop wheel/drag propagation so zooming over a face does not fight portrait clicks.
|
||||||
|
|
||||||
|
**Note:** Movement lineage is **frontend curation** for layout and labels — it is not stored in PostgreSQL. Painting-to-painting influence edges (`painting_influences`) are separate and drive the 3D exit picker and detail panels.
|
||||||
|
|
||||||
## Virtual gallery (3D halls)
|
## Virtual gallery (3D halls)
|
||||||
|
|
||||||
Each artist has **exactly one hall**. The hall is a rectangular room sized to fit their catalog:
|
Each artist has **exactly one hall**. The hall is a rectangular room sized to fit their catalog:
|
||||||
@@ -148,6 +215,7 @@ Opened from the 3D hall (click a frame) or from influence thumbnails on another
|
|||||||
|
|
||||||
- **Timeline bounds** derive from the earliest art movement start year, not ancient-era metadata alone, so the default view opens where catalogued content begins.
|
- **Timeline bounds** derive from the earliest art movement start year, not ancient-era metadata alone, so the default view opens where catalogued content begins.
|
||||||
- **Movement filtering** on zoom only shows movements that have at least one artist active in the visible year range.
|
- **Movement filtering** on zoom only shows movements that have at least one artist active in the visible year range.
|
||||||
|
- **Movement lineage** (`movement-lineage.ts`) documents art-historical predecessor→successor links for the flow diagram; extend that file to add or correct branches.
|
||||||
- **One hall per artist** keeps navigation predictable: enter from the timeline or bio, leave via the single exit or back button.
|
- **One hall per artist** keeps navigation predictable: enter from the timeline or bio, leave via the single exit or back button.
|
||||||
- **Influence-based hall links** connect artists through documented painting-to-painting relationships, grouped by movement at the exit.
|
- **Influence-based hall links** connect artists through documented painting-to-painting relationships, grouped by movement at the exit.
|
||||||
- **3D gallery images** use locally cached files only; slow remote fetches would break realtime rendering.
|
- **3D gallery images** use locally cached files only; slow remote fetches would break realtime rendering.
|
||||||
|
|||||||
@@ -130,6 +130,31 @@ Each entry defines a later `work` influenced by an earlier `influencedBy` painti
|
|||||||
|
|
||||||
Extend the data file to add lineage chains (e.g. Giotto → Masaccio → Michelangelo → Manet → Picasso → Warhol). New bridge artists may include Géricault, Friedrich, Constable, Giorgione, Poussin, de Chirico, and Böcklin.
|
Extend the data file to add lineage chains (e.g. Giotto → Masaccio → Michelangelo → Manet → Picasso → Warhol). New bridge artists may include Géricault, Friedrich, Constable, Giorgione, Poussin, de Chirico, and Böcklin.
|
||||||
|
|
||||||
|
## Movement lineage (frontend flow diagram)
|
||||||
|
|
||||||
|
Separate from the painting influence graph, `client/src/data/movement-lineage.ts` lists **art-movement** predecessor→successor pairs used only by `MovementBands.tsx` to position streams and draw branch connectors (e.g. Impressionism → Post-Impressionism → Fauvism / Cubism / Expressionism).
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| Storage | TypeScript module in the client — **not** a database table |
|
||||||
|
| Format | `[parentMovementName, childMovementName]` tuples; names must match `art_movements.name` from the seed |
|
||||||
|
| Multiple parents | Allowed (e.g. Post-Impressionism feeding several modern paths) |
|
||||||
|
| Sources | Curator notes in the file reference Met essays, TheArtStory, and similar |
|
||||||
|
|
||||||
|
To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the client. No migration or API change is required.
|
||||||
|
|
||||||
|
## Historical event markers (frontend timeline)
|
||||||
|
|
||||||
|
`client/src/data/historical-events.ts` lists **world-history** markers shown on `Timeline.tsx` (French Revolution, World War I/II, etc.).
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| Storage | TypeScript module in the client — **not** a database table |
|
||||||
|
| Format | `{ id, name, startYear, endYear?, shortLabel? }` — omit `endYear` for a single-year pin |
|
||||||
|
| Interaction | Click a marker to zoom the shared timeline/movement view to that period |
|
||||||
|
|
||||||
|
Edit `HISTORICAL_EVENTS` and rebuild the client to extend the set.
|
||||||
|
|
||||||
## Batch image fetch
|
## Batch image fetch
|
||||||
|
|
||||||
`npm run fetch-images` (alias: `npm run search-missing-paintings`) runs `scripts/fetch-missing-images.js`. It searches multiple sources for paintings without local files:
|
`npm run fetch-images` (alias: `npm run search-missing-paintings`) runs `scripts/fetch-missing-images.js`. It searches multiple sources for paintings without local files:
|
||||||
@@ -152,13 +177,15 @@ Extend the data file to add lineage chains (e.g. Giotto → Masaccio → Michela
|
|||||||
```bash
|
```bash
|
||||||
npm run fetch-images # all missing, catalog order (~hours)
|
npm run fetch-images # all missing, catalog order (~hours)
|
||||||
npm run fetch-images -- --limit=50 # random sample of 50; 10s max per painting
|
npm run fetch-images -- --limit=50 # random sample of 50; 10s max per painting
|
||||||
npm run fetch-images -- --limit=920 # random sample up to N missing works
|
npm run fetch-images -- --limit=250 # random sample up to N (caps at current missing count)
|
||||||
npm run fetch-images -- --limit=50 --max-wait=120 # slower, more thorough lookup per painting
|
npm run fetch-images -- --limit=50 --max-wait=120 # slower, more thorough lookup per painting
|
||||||
npm run fetch-images -- --artist="Albrecht Dürer" # one artist, catalog order
|
npm run fetch-images -- --artist="Albrecht Dürer" # one artist, catalog order
|
||||||
npm run fetch-images -- --discover-only --limit=20 # fix wikipedia_title only
|
npm run fetch-images -- --discover-only --limit=20 # fix wikipedia_title only
|
||||||
npm run fetch-images -- --web-search-only --limit=50 # DuckDuckGo + Commons + multilingual Wikipedia
|
npm run fetch-images -- --web-search-only --limit=50 # DuckDuckGo + Commons + multilingual Wikipedia
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Each run prints **`Missing local files: N`** at startup — that is the current count of catalogued paintings with no full-size or thumbnail file under `data/images/`. A painting counts as present if **either** file exists on disk.
|
||||||
|
|
||||||
| Flag | Effect |
|
| Flag | Effect |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `--limit=N` | Process at most **N** paintings. Queue is a **random sample** of all works missing local files (not alphabetical). |
|
| `--limit=N` | Process at most **N** paintings. Queue is a **random sample** of all works missing local files (not alphabetical). |
|
||||||
@@ -198,7 +225,7 @@ The 3D scene uses `galleryImageUrl()`, which never hits the on-demand API (remot
|
|||||||
|
|
||||||
When no image is available:
|
When no image is available:
|
||||||
|
|
||||||
- `/placeholder-portrait.svg` — timeline / movement band portraits
|
- `/placeholder-portrait.svg` — timeline and movement-flow portraits
|
||||||
- `/placeholder-art.svg` — paintings in lists and detail view
|
- `/placeholder-art.svg` — paintings in lists and detail view
|
||||||
- **3D gallery** — draped **canvas cover** inside the frame (`CanvasCover` in `VirtualGallery.tsx`); shown when there is no local file, the fetch failed, or the texture has not loaded yet
|
- **3D gallery** — draped **canvas cover** inside the frame (`CanvasCover` in `VirtualGallery.tsx`); shown when there is no local file, the fetch failed, or the texture has not loaded yet
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ npm run fetch-images -- --limit=50 --max-wait=120 # same batch size, longer look
|
|||||||
cd client && npm run build && cd ..
|
cd client && npm run build && cd ..
|
||||||
```
|
```
|
||||||
|
|
||||||
Image fetch can take hours for the full catalog (~900+ missing works after seed). Use **`npm run fetch-images -- --limit=N`** for random batches (10s per painting by default), **`--artist="…"`** for one artist in catalog order, or on-demand resolution when viewing a painting in the detail view.
|
Image fetch can take hours if you run it for the entire catalog. The first line of each run reports **`Missing local files: N`**. Use **`npm run fetch-images -- --limit=N`** for random batches (10s per painting by default), **`--artist="…"`** for one artist in catalog order, or on-demand resolution when viewing a painting in the detail view.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -103,6 +103,7 @@ Open http://localhost:3001 (or your configured `PORT`).
|
|||||||
| `npm run search-missing-paintings` | ↑ (alias) | Same as `fetch-images` |
|
| `npm run search-missing-paintings` | ↑ (alias) | Same as `fetch-images` |
|
||||||
| `npm run fetch-images -- --artist="Name"` | ↑ | Limit to one artist (catalog order) |
|
| `npm run fetch-images -- --artist="Name"` | ↑ | Limit to one artist (catalog order) |
|
||||||
| `npm run fetch-images -- --limit=50` | ↑ | Random sample of N missing paintings |
|
| `npm run fetch-images -- --limit=50` | ↑ | Random sample of N missing paintings |
|
||||||
|
| `npm run fetch-images -- --limit=250` | ↑ | Larger random batch (processes min(N, missing count)) |
|
||||||
| `npm run fetch-images -- --limit=50 --max-wait=120` | ↑ | Random batch; 120s cap per painting |
|
| `npm run fetch-images -- --limit=50 --max-wait=120` | ↑ | Random batch; 120s cap per painting |
|
||||||
| `npm run fetch-images -- --discover-only --limit=20` | ↑ | Fix `wikipedia_title` via search only |
|
| `npm run fetch-images -- --discover-only --limit=20` | ↑ | Fix `wikipedia_title` via search only |
|
||||||
| `npm run regenerate-thumbnails` | `scripts/regenerate-thumbnails.js` | Rebuild all thumbs from full local files |
|
| `npm run regenerate-thumbnails` | `scripts/regenerate-thumbnails.js` | Rebuild all thumbs from full local files |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Art Gallery
|
# Art Gallery
|
||||||
|
|
||||||
Interactive virtual art gallery: zoomable historical timeline, art movement bands, one 3D hall per artist (dynamic wall layout, chronological wall order, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with prev/next catalog browsing and fullscreen lightbox, preserved gallery camera on return, and Wikipedia-sourced artist biographies.
|
Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom and major event markers, branching art-movement flow (curved streams, hover-to-reveal artist lifespans), one 3D hall per artist (dynamic wall layout, chronological wall order, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with prev/next catalog browsing and fullscreen lightbox, preserved gallery camera on return, and Wikipedia-sourced artist biographies.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|||||||
@@ -1,83 +1,183 @@
|
|||||||
.movements-container {
|
.movements-flow {
|
||||||
padding: 16px;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
padding: 8px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movements-flow-caption {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: italic;
|
||||||
|
color: rgba(201, 169, 110, 0.65);
|
||||||
}
|
}
|
||||||
|
|
||||||
.movements-empty {
|
.movements-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
padding: 48px;
|
padding: 48px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: rgba(201, 169, 110, 0.6);
|
color: rgba(201, 169, 110, 0.6);
|
||||||
font-family: 'Georgia', serif;
|
font-family: 'Georgia', serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.movement-row {
|
.movements-flow-canvas {
|
||||||
display: flex;
|
--stream-stroke: 54px;
|
||||||
align-items: stretch;
|
--portrait-size: 52px;
|
||||||
min-height: 90px;
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 60% at 50% 100%, rgba(201, 169, 110, 0.07), transparent 70%),
|
||||||
|
rgba(0, 0, 0, 0.25);
|
||||||
|
border: 1px solid rgba(201, 169, 110, 0.12);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.movement-label {
|
.movements-flow-canvas.movements-flow-panning {
|
||||||
width: 160px;
|
cursor: grabbing;
|
||||||
flex-shrink: 0;
|
}
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
.movements-flow-svg {
|
||||||
justify-content: center;
|
position: absolute;
|
||||||
padding-right: 12px;
|
inset: 0;
|
||||||
text-align: right;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-branch {
|
||||||
|
stroke-width: var(--stream-stroke);
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-stream-fill {
|
||||||
|
stroke-width: var(--stream-stroke);
|
||||||
|
stroke-linecap: round;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-stream-core {
|
||||||
|
stroke-width: 2px;
|
||||||
|
stroke-linecap: round;
|
||||||
|
opacity: 0.22;
|
||||||
|
stroke-dasharray: 8 6;
|
||||||
|
animation: stream-shimmer 16s linear infinite;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes stream-shimmer {
|
||||||
|
from {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: -42;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.movements-flow-labels,
|
||||||
|
.movements-flow-artists {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movements-flow-artists {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-lifespan {
|
||||||
|
position: absolute;
|
||||||
|
height: 0;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-lifespan-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 25%, transparent) 0%,
|
||||||
|
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 70%, #e8d5b5) 12%,
|
||||||
|
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 85%, #fff) 50%,
|
||||||
|
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 70%, #e8d5b5) 88%,
|
||||||
|
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 25%, transparent) 100%
|
||||||
|
);
|
||||||
|
box-shadow: 0 0 6px color-mix(in srgb, var(--lifespan-color, #e8d5b5) 40%, transparent);
|
||||||
|
border-radius: 1px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-lifespan .artist-portrait {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-lifespan .artist-portrait:hover {
|
||||||
|
transform: translate(-50%, -50%) scale(1.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-flow-label {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-4px, -100%);
|
||||||
|
max-width: 160px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.movement-name {
|
.movement-name {
|
||||||
|
display: block;
|
||||||
font-family: 'Georgia', serif;
|
font-family: 'Georgia', serif;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e8d5b5;
|
color: rgba(232, 213, 181, 0.95);
|
||||||
|
line-height: 1.25;
|
||||||
|
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.movement-era {
|
.movement-era {
|
||||||
font-size: 10px;
|
display: block;
|
||||||
color: rgba(201, 169, 110, 0.5);
|
font-size: 9px;
|
||||||
margin-top: 2px;
|
color: rgba(201, 169, 110, 0.6);
|
||||||
}
|
margin-top: 1px;
|
||||||
|
|
||||||
.movement-band-track {
|
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
|
||||||
min-height: 80px;
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid rgba(201, 169, 110, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.movement-band {
|
|
||||||
position: absolute;
|
|
||||||
top: 4px;
|
|
||||||
bottom: 4px;
|
|
||||||
border-radius: 3px;
|
|
||||||
min-width: 40px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-portrait {
|
.artist-portrait {
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
background: none;
|
background: none;
|
||||||
border: 2px solid rgba(201, 169, 110, 0.6);
|
border: 2px solid rgba(232, 213, 181, 0.75);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: 56px;
|
width: var(--portrait-size, 52px);
|
||||||
height: 56px;
|
height: var(--portrait-size, 52px);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s;
|
transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s;
|
||||||
z-index: 2;
|
z-index: 5;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-portrait:hover {
|
.artist-portrait:hover {
|
||||||
transform: translate(-50%, -50%) scale(1.15);
|
border-color: #fff;
|
||||||
border-color: #c9a96e;
|
box-shadow: 0 4px 20px rgba(201, 169, 110, 0.5);
|
||||||
box-shadow: 0 4px 16px rgba(201, 169, 110, 0.4);
|
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,11 +208,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.movement-label {
|
.movements-flow-canvas {
|
||||||
width: 100px;
|
--stream-stroke: 44px;
|
||||||
|
--portrait-size: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.movements-flow-caption {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.artist-portrait {
|
.artist-portrait {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.movement-flow-label {
|
||||||
|
max-width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-name {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
||||||
import type { ArtMovement, Artist } from '../types';
|
import type { ArtMovement, Artist } from '../types';
|
||||||
import { imageUrl } from '../api/client';
|
import { imageUrl } from '../api/client';
|
||||||
|
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
||||||
|
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||||
import './MovementBands.css';
|
import './MovementBands.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -7,9 +10,39 @@ interface Props {
|
|||||||
artists: Artist[];
|
artists: Artist[];
|
||||||
viewStart: number;
|
viewStart: number;
|
||||||
viewEnd: number;
|
viewEnd: number;
|
||||||
|
absoluteMin: number;
|
||||||
|
absoluteMax: number;
|
||||||
|
onViewChange: (start: number, end: number) => void;
|
||||||
onArtistClick: (artistId: number) => void;
|
onArtistClick: (artistId: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MovementLayout {
|
||||||
|
movement: ArtMovement;
|
||||||
|
xStart: number;
|
||||||
|
xEnd: number;
|
||||||
|
y: number;
|
||||||
|
yEnd: number;
|
||||||
|
depth: number;
|
||||||
|
parentIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BranchSegment {
|
||||||
|
d: string;
|
||||||
|
colorFrom: string;
|
||||||
|
colorTo: string;
|
||||||
|
key: string;
|
||||||
|
x1: number;
|
||||||
|
y1: number;
|
||||||
|
x2: number;
|
||||||
|
y2: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_STREAM_STROKE_PX = 54;
|
||||||
|
const MIN_STREAM_STROKE_PX = 28;
|
||||||
|
const CANVAS_TOP_PAD = 38;
|
||||||
|
const CANVAS_BOTTOM_PAD = 24;
|
||||||
|
const DEFAULT_CANVAS_HEIGHT = 360;
|
||||||
|
|
||||||
function yearToPercent(year: number, start: number, end: number): number {
|
function yearToPercent(year: number, start: number, end: number): number {
|
||||||
return ((year - start) / (end - start)) * 100;
|
return ((year - start) / (end - start)) * 100;
|
||||||
}
|
}
|
||||||
@@ -27,17 +60,525 @@ function artistTimelineYear(artist: Artist): number {
|
|||||||
return artist.birth_year ?? artist.death_year ?? 0;
|
return artist.birth_year ?? artist.death_year ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MovementBands({ movements, artists, viewStart, viewEnd, onArtistClick }: Props) {
|
interface ArtistLifespanLayout {
|
||||||
const artistsByMovement = new Map<number, Artist[]>();
|
lineLeft: number;
|
||||||
for (const artist of artists) {
|
lineWidth: number;
|
||||||
if (!artist.movement_id || !artistInView(artist, viewStart, viewEnd)) continue;
|
lineRight: number;
|
||||||
const list = artistsByMovement.get(artist.movement_id) || [];
|
portraitLeft: number;
|
||||||
list.push(artist);
|
y: number;
|
||||||
artistsByMovement.set(artist.movement_id, list);
|
centerX: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function artistLifespanLayout(
|
||||||
|
artist: Artist,
|
||||||
|
layout: MovementLayout,
|
||||||
|
viewStart: number,
|
||||||
|
viewEnd: number
|
||||||
|
): ArtistLifespanLayout | null {
|
||||||
|
const birth = artist.birth_year ?? viewStart;
|
||||||
|
const death = artist.death_year ?? viewEnd;
|
||||||
|
const centerYear = artistTimelineYear(artist);
|
||||||
|
|
||||||
|
const lineLeft = yearToPercent(Math.max(birth, viewStart), viewStart, viewEnd);
|
||||||
|
const lineRight = yearToPercent(Math.min(death, viewEnd), viewStart, viewEnd);
|
||||||
|
const centerX = yearToPercent(centerYear, viewStart, viewEnd);
|
||||||
|
const lineWidth = lineRight - lineLeft;
|
||||||
|
|
||||||
|
if (lineWidth <= 0) return null;
|
||||||
|
|
||||||
|
const yAnchorX = Math.min(Math.max(centerX, layout.xStart), layout.xEnd);
|
||||||
|
const y = yOnStream(layout, yAnchorX);
|
||||||
|
const portraitLeft = ((centerX - lineLeft) / lineWidth) * 100;
|
||||||
|
|
||||||
|
return { lineLeft, lineWidth, lineRight, portraitLeft, y, centerX };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArtistPlacement {
|
||||||
|
layout: MovementLayout;
|
||||||
|
artist: Artist;
|
||||||
|
lineLeft: number;
|
||||||
|
lineWidth: number;
|
||||||
|
portraitLeft: number;
|
||||||
|
y: number;
|
||||||
|
lane: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHexColor(hex: string): [number, number, number] {
|
||||||
|
const normalized = hex.replace('#', '');
|
||||||
|
const value =
|
||||||
|
normalized.length === 3
|
||||||
|
? normalized
|
||||||
|
.split('')
|
||||||
|
.map((c) => c + c)
|
||||||
|
.join('')
|
||||||
|
: normalized.padStart(6, '0').slice(0, 6);
|
||||||
|
return [
|
||||||
|
parseInt(value.slice(0, 2), 16),
|
||||||
|
parseInt(value.slice(2, 4), 16),
|
||||||
|
parseInt(value.slice(4, 6), 16),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||||||
|
const rn = r / 255;
|
||||||
|
const gn = g / 255;
|
||||||
|
const bn = b / 255;
|
||||||
|
const max = Math.max(rn, gn, bn);
|
||||||
|
const min = Math.min(rn, gn, bn);
|
||||||
|
const l = (max + min) / 2;
|
||||||
|
if (max === min) return [0, 0, l];
|
||||||
|
const d = max - min;
|
||||||
|
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
let h = 0;
|
||||||
|
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
|
||||||
|
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
|
||||||
|
else h = ((rn - gn) / d + 4) / 6;
|
||||||
|
return [h * 360, s, l];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hslToHex(h: number, s: number, l: number): string {
|
||||||
|
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||||
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||||
|
const m = l - c / 2;
|
||||||
|
let r = 0;
|
||||||
|
let g = 0;
|
||||||
|
let b = 0;
|
||||||
|
if (h < 60) [r, g, b] = [c, x, 0];
|
||||||
|
else if (h < 120) [r, g, b] = [x, c, 0];
|
||||||
|
else if (h < 180) [r, g, b] = [0, c, x];
|
||||||
|
else if (h < 240) [r, g, b] = [0, x, c];
|
||||||
|
else if (h < 300) [r, g, b] = [x, 0, c];
|
||||||
|
else [r, g, b] = [c, 0, x];
|
||||||
|
const toByte = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
||||||
|
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function artistLifespanColor(baseColor: string, laneIndex: number, laneCount: number): string {
|
||||||
|
if (laneCount <= 1) return baseColor;
|
||||||
|
try {
|
||||||
|
const [r, g, b] = parseHexColor(baseColor);
|
||||||
|
const [h, s, l] = rgbToHsl(r, g, b);
|
||||||
|
const t = laneIndex / Math.max(laneCount - 1, 1) - 0.5;
|
||||||
|
return hslToHex((h + t * 26 + 360) % 360, Math.min(1, s * 1.05), Math.min(0.82, Math.max(0.32, l + t * 0.14)));
|
||||||
|
} catch {
|
||||||
|
return baseColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArtistPlacements(
|
||||||
|
layouts: MovementLayout[],
|
||||||
|
artistsByMovement: Map<number, Artist[]>,
|
||||||
|
viewStart: number,
|
||||||
|
viewEnd: number,
|
||||||
|
streamStrokePx: number,
|
||||||
|
portraitSizePx: number
|
||||||
|
): ArtistPlacement[] {
|
||||||
|
const placements: ArtistPlacement[] = [];
|
||||||
|
|
||||||
|
for (const layout of layouts) {
|
||||||
|
const movementArtists = artistsByMovement.get(layout.movement.id) || [];
|
||||||
|
const candidates: Array<{
|
||||||
|
artist: Artist;
|
||||||
|
span: ArtistLifespanLayout;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const artist of movementArtists) {
|
||||||
|
const span = artistLifespanLayout(artist, layout, viewStart, viewEnd);
|
||||||
|
if (span) candidates.push({ artist, span });
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.span.lineLeft - b.span.lineLeft ||
|
||||||
|
b.span.lineWidth - a.span.lineWidth ||
|
||||||
|
a.artist.birth_year! - b.artist.birth_year!
|
||||||
|
);
|
||||||
|
|
||||||
|
const laneEnds: number[] = [];
|
||||||
|
const minGap = 0.6;
|
||||||
|
const assigned: Array<{ candidate: (typeof candidates)[0]; lane: number }> = [];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
let lane = laneEnds.findIndex((end) => candidate.span.lineLeft >= end + minGap);
|
||||||
|
if (lane === -1) {
|
||||||
|
lane = laneEnds.length;
|
||||||
|
laneEnds.push(candidate.span.lineRight);
|
||||||
|
} else {
|
||||||
|
laneEnds[lane] = Math.max(laneEnds[lane], candidate.span.lineRight);
|
||||||
|
}
|
||||||
|
assigned.push({ candidate, lane });
|
||||||
|
}
|
||||||
|
|
||||||
|
const laneCount = Math.max(laneEnds.length, 1);
|
||||||
|
const laneStep = Math.min(
|
||||||
|
portraitSizePx * 0.52,
|
||||||
|
(streamStrokePx * 0.88) / laneCount
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const { candidate, lane } of assigned) {
|
||||||
|
const { artist, span } = candidate;
|
||||||
|
const yOffset = (lane - (laneCount - 1) / 2) * laneStep;
|
||||||
|
|
||||||
|
placements.push({
|
||||||
|
layout,
|
||||||
|
artist,
|
||||||
|
lineLeft: span.lineLeft,
|
||||||
|
lineWidth: span.lineWidth,
|
||||||
|
portraitLeft: span.portraitLeft,
|
||||||
|
y: span.y + yOffset,
|
||||||
|
lane,
|
||||||
|
color: artistLifespanColor(layout.movement.color, lane, laneCount),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleMovements = movements.filter(
|
return placements;
|
||||||
(m) => m.end_year >= viewStart && m.start_year <= viewEnd && (artistsByMovement.get(m.id)?.length ?? 0) > 0
|
}
|
||||||
|
|
||||||
|
function organicDrift(id: number): number {
|
||||||
|
return ((id * 17) % 11) - 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLineageParentMap(
|
||||||
|
visible: ArtMovement[],
|
||||||
|
nameToId: Map<string, number>
|
||||||
|
): Map<number, number[]> {
|
||||||
|
const visibleIds = new Set(visible.map((m) => m.id));
|
||||||
|
const childToParents = new Map<number, number[]>();
|
||||||
|
|
||||||
|
for (const [parentName, childName] of MOVEMENT_LINEAGE) {
|
||||||
|
const parentId = nameToId.get(parentName);
|
||||||
|
const childId = nameToId.get(childName);
|
||||||
|
if (!parentId || !childId) continue;
|
||||||
|
if (!visibleIds.has(parentId) || !visibleIds.has(childId)) continue;
|
||||||
|
|
||||||
|
const list = childToParents.get(childId) || [];
|
||||||
|
if (!list.includes(parentId)) list.push(parentId);
|
||||||
|
childToParents.set(childId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return childToParents;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignDepths(movements: ArtMovement[], lineageParents: Map<number, number[]>): Map<number, number> {
|
||||||
|
const visibleIds = new Set(movements.map((m) => m.id));
|
||||||
|
const depth = new Map<number, number>();
|
||||||
|
|
||||||
|
function depthFor(id: number, visiting = new Set<number>()): number {
|
||||||
|
if (depth.has(id)) return depth.get(id)!;
|
||||||
|
if (visiting.has(id)) return 0;
|
||||||
|
visiting.add(id);
|
||||||
|
|
||||||
|
const parents = (lineageParents.get(id) || []).filter((pid) => visibleIds.has(pid));
|
||||||
|
if (parents.length === 0) {
|
||||||
|
depth.set(id, 0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = 1 + Math.max(...parents.map((pid) => depthFor(pid, visiting)));
|
||||||
|
depth.set(id, d);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const m of movements) depthFor(m.id);
|
||||||
|
return depth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamPath(xStart: number, xEnd: number, yStart: number, yEnd: number): string {
|
||||||
|
const width = Math.max(xEnd - xStart, 0.5);
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function yOnStream(layout: MovementLayout, x: number): number {
|
||||||
|
const t = layout.xEnd > layout.xStart ? (x - layout.xStart) / (layout.xEnd - layout.xStart) : 0;
|
||||||
|
const clamped = Math.min(1, Math.max(0, t));
|
||||||
|
return layout.y + (layout.yEnd - layout.y) * clamped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchPath(xFrom: number, yFrom: number, xTo: number, yTo: number): string {
|
||||||
|
const dx = xTo - xFrom;
|
||||||
|
const dy = yTo - yFrom;
|
||||||
|
const c1x = xFrom + dx * 0.38;
|
||||||
|
const c1y = yFrom + dy * 0.08;
|
||||||
|
const c2x = xTo - dx * 0.38;
|
||||||
|
const c2y = yTo - dy * 0.08;
|
||||||
|
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);
|
||||||
|
return { x, y: yOnStream(parent, x) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchTargetOnChild(layout: MovementLayout): { x: number; y: number } {
|
||||||
|
const x = (layout.xStart + layout.xEnd) / 2;
|
||||||
|
return { x, y: yOnStream(layout, x) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MovementBands({
|
||||||
|
movements,
|
||||||
|
artists,
|
||||||
|
viewStart,
|
||||||
|
viewEnd,
|
||||||
|
absoluteMin,
|
||||||
|
absoluteMax,
|
||||||
|
onViewChange,
|
||||||
|
onArtistClick,
|
||||||
|
}: Props) {
|
||||||
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [panning, setPanning] = useState(false);
|
||||||
|
const [canvasHeight, setCanvasHeight] = useState(DEFAULT_CANVAS_HEIGHT);
|
||||||
|
const [hoveredArtistKey, setHoveredArtistKey] = useState<string | null>(null);
|
||||||
|
const panStart = useRef({ x: 0, viewStart: 0, viewEnd: 0 });
|
||||||
|
|
||||||
|
const handleWheel = useCallback(
|
||||||
|
(e: React.WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
const next = zoomTimelineView(
|
||||||
|
e.clientX,
|
||||||
|
rect.left,
|
||||||
|
rect.width,
|
||||||
|
e.deltaY,
|
||||||
|
viewStart,
|
||||||
|
viewEnd,
|
||||||
|
absoluteMin,
|
||||||
|
absoluteMax
|
||||||
|
);
|
||||||
|
onViewChange(next.start, next.end);
|
||||||
|
},
|
||||||
|
[viewStart, viewEnd, absoluteMin, absoluteMax, onViewChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePanStart = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setPanning(true);
|
||||||
|
panStart.current = { x: e.clientX, viewStart, viewEnd };
|
||||||
|
},
|
||||||
|
[viewStart, viewEnd]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!panning) return;
|
||||||
|
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
const dx = e.clientX - panStart.current.x;
|
||||||
|
const next = panTimelineView(
|
||||||
|
dx,
|
||||||
|
rect.width,
|
||||||
|
panStart.current.viewStart,
|
||||||
|
panStart.current.viewEnd,
|
||||||
|
absoluteMin,
|
||||||
|
absoluteMax
|
||||||
|
);
|
||||||
|
onViewChange(next.start, next.end);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp = () => setPanning(false);
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
}, [panning, absoluteMin, absoluteMax, onViewChange]);
|
||||||
|
|
||||||
|
const artistsByMovement = useMemo(() => {
|
||||||
|
const map = new Map<number, Artist[]>();
|
||||||
|
for (const artist of artists) {
|
||||||
|
if (!artist.movement_id || !artistInView(artist, viewStart, viewEnd)) continue;
|
||||||
|
const list = map.get(artist.movement_id) || [];
|
||||||
|
list.push(artist);
|
||||||
|
map.set(artist.movement_id, list);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [artists, viewStart, viewEnd]);
|
||||||
|
|
||||||
|
const visibleMovements = useMemo(
|
||||||
|
() =>
|
||||||
|
movements.filter(
|
||||||
|
(m) =>
|
||||||
|
m.end_year >= viewStart &&
|
||||||
|
m.start_year <= viewEnd &&
|
||||||
|
(artistsByMovement.get(m.id)?.length ?? 0) > 0
|
||||||
|
),
|
||||||
|
[movements, viewStart, viewEnd, artistsByMovement]
|
||||||
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = canvasRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
const h = el.getBoundingClientRect().height;
|
||||||
|
if (h > 0) setCanvasHeight(Math.round(h));
|
||||||
|
};
|
||||||
|
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver((entries) => {
|
||||||
|
const h = entries[0]?.contentRect.height;
|
||||||
|
if (h > 0) setCanvasHeight(Math.round(h));
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
window.addEventListener('resize', measure);
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
window.removeEventListener('resize', measure);
|
||||||
|
};
|
||||||
|
}, [visibleMovements.length]);
|
||||||
|
|
||||||
|
const { layouts, layoutHeight, branches, childIdsByParent, streamStrokePx, portraitSizePx } =
|
||||||
|
useMemo(() => {
|
||||||
|
if (visibleMovements.length === 0) {
|
||||||
|
return {
|
||||||
|
layouts: [] as MovementLayout[],
|
||||||
|
layoutHeight: DEFAULT_CANVAS_HEIGHT,
|
||||||
|
branches: [] as BranchSegment[],
|
||||||
|
childIdsByParent: new Map<number, number[]>(),
|
||||||
|
streamStrokePx: MAX_STREAM_STROKE_PX,
|
||||||
|
portraitSizePx: 52,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameToId = new Map(movements.map((m) => [m.name, m.id]));
|
||||||
|
const lineageParents = buildLineageParentMap(visibleMovements, nameToId);
|
||||||
|
const depths = assignDepths(visibleMovements, lineageParents);
|
||||||
|
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<number, ArtMovement[]>();
|
||||||
|
for (const movement of visibleMovements) {
|
||||||
|
const depth = depths.get(movement.id) ?? 0;
|
||||||
|
const list = byDepth.get(depth) || [];
|
||||||
|
list.push(movement);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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 layoutById = new Map<number, MovementLayout>();
|
||||||
|
const branchList: BranchSegment[] = [];
|
||||||
|
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) {
|
||||||
|
const xStart = yearToPercent(Math.max(movement.start_year, viewStart), viewStart, viewEnd);
|
||||||
|
const xEnd = yearToPercent(Math.min(movement.end_year, viewEnd), viewStart, viewEnd);
|
||||||
|
if (xEnd <= xStart) continue;
|
||||||
|
|
||||||
|
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 parentIds = lineageParents.get(movement.id) || [];
|
||||||
|
|
||||||
|
layoutById.set(movement.id, {
|
||||||
|
movement,
|
||||||
|
xStart,
|
||||||
|
xEnd,
|
||||||
|
y,
|
||||||
|
yEnd,
|
||||||
|
depth,
|
||||||
|
parentIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const layout of layoutById.values()) {
|
||||||
|
for (const parentId of layout.parentIds) {
|
||||||
|
const children = childIdsByParent.get(parentId) || [];
|
||||||
|
children.push(layout.movement.id);
|
||||||
|
childIdsByParent.set(parentId, children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const children of childIdsByParent.values()) {
|
||||||
|
children.sort((a, b) => {
|
||||||
|
const la = layoutById.get(a)!;
|
||||||
|
const lb = layoutById.get(b)!;
|
||||||
|
return la.y - lb.y || la.movement.start_year - lb.movement.start_year;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const layout of layoutById.values()) {
|
||||||
|
for (const parentId of layout.parentIds) {
|
||||||
|
const parent = layoutById.get(parentId);
|
||||||
|
if (!parent) continue;
|
||||||
|
|
||||||
|
const children = childIdsByParent.get(parentId)!;
|
||||||
|
const childIndex = children.indexOf(layout.movement.id);
|
||||||
|
const origin = branchOriginOnParent(parent, childIndex, children.length);
|
||||||
|
const target = branchTargetOnChild(layout);
|
||||||
|
|
||||||
|
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,
|
||||||
|
x1: origin.x,
|
||||||
|
y1: origin.y,
|
||||||
|
x2: target.x,
|
||||||
|
y2: target.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
layouts: [...layoutById.values()].sort((a, b) => a.depth - b.depth),
|
||||||
|
layoutHeight,
|
||||||
|
branches: branchList,
|
||||||
|
childIdsByParent,
|
||||||
|
streamStrokePx,
|
||||||
|
portraitSizePx,
|
||||||
|
};
|
||||||
|
}, [visibleMovements, movements, viewStart, viewEnd, canvasHeight]);
|
||||||
|
|
||||||
|
const artistPlacements = useMemo(
|
||||||
|
() =>
|
||||||
|
buildArtistPlacements(
|
||||||
|
layouts,
|
||||||
|
artistsByMovement,
|
||||||
|
viewStart,
|
||||||
|
viewEnd,
|
||||||
|
streamStrokePx,
|
||||||
|
portraitSizePx
|
||||||
|
),
|
||||||
|
[layouts, artistsByMovement, viewStart, viewEnd, streamStrokePx, portraitSizePx]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (visibleMovements.length === 0) {
|
if (visibleMovements.length === 0) {
|
||||||
@@ -48,72 +589,200 @@ export default function MovementBands({ movements, artists, viewStart, viewEnd,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const layoutById = new Map(layouts.map((l) => [l.movement.id, l]));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="movements-container">
|
<div className="movements-flow">
|
||||||
{visibleMovements.map((movement) => {
|
<p className="movements-flow-caption">
|
||||||
const left = yearToPercent(Math.max(movement.start_year, viewStart), viewStart, viewEnd);
|
Scroll to zoom · drag to pan · streams blend from movement to movement through history
|
||||||
const right = yearToPercent(Math.min(movement.end_year, viewEnd), viewStart, viewEnd);
|
</p>
|
||||||
const width = right - left;
|
|
||||||
if (width <= 0) return null;
|
|
||||||
|
|
||||||
const movementArtists = artistsByMovement.get(movement.id) || [];
|
<div
|
||||||
|
ref={canvasRef}
|
||||||
|
className={`movements-flow-canvas${panning ? ' movements-flow-panning' : ''}`}
|
||||||
|
style={{
|
||||||
|
['--stream-stroke' as string]: `${streamStrokePx}px`,
|
||||||
|
['--portrait-size' as string]: `${portraitSizePx}px`,
|
||||||
|
}}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
onMouseDown={handlePanStart}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="movements-flow-svg"
|
||||||
|
viewBox={`0 0 100 ${layoutHeight}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
{branches.map((branch) => (
|
||||||
|
<linearGradient
|
||||||
|
key={`branch-grad-${branch.key}`}
|
||||||
|
id={`branch-grad-${branch.key}`}
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1={branch.x1}
|
||||||
|
y1={branch.y1}
|
||||||
|
x2={branch.x2}
|
||||||
|
y2={branch.y2}
|
||||||
|
>
|
||||||
|
<stop offset="0%" stopColor={branch.colorFrom} stopOpacity={0} />
|
||||||
|
<stop offset="18%" stopColor={branch.colorFrom} stopOpacity={0.34} />
|
||||||
|
<stop offset="50%" stopColor={branch.colorTo} stopOpacity={0.34} />
|
||||||
|
<stop offset="82%" stopColor={branch.colorTo} stopOpacity={0.34} />
|
||||||
|
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
))}
|
||||||
|
|
||||||
return (
|
{layouts.map((layout) => {
|
||||||
<div key={movement.id} className="movement-row">
|
const primaryParent =
|
||||||
<div className="movement-label">
|
layout.parentIds.length > 0 ? layoutById.get(layout.parentIds[0]) : null;
|
||||||
<span className="movement-name">{movement.name}</span>
|
const hasChildren = (childIdsByParent.get(layout.movement.id)?.length ?? 0) > 0;
|
||||||
{movement.era_name && <span className="movement-era">{movement.era_name}</span>}
|
const parentColor = primaryParent?.movement.color ?? layout.movement.color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<linearGradient
|
||||||
|
key={`grad-${layout.movement.id}`}
|
||||||
|
id={`stream-grad-${layout.movement.id}`}
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1={layout.xStart}
|
||||||
|
y1={layout.y}
|
||||||
|
x2={layout.xEnd}
|
||||||
|
y2={layout.yEnd}
|
||||||
|
>
|
||||||
|
{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={layout.movement.color}
|
||||||
|
stopOpacity={layout.movement.start_definite ? 0.28 : 0.08}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<stop offset="50%" stopColor={layout.movement.color} stopOpacity={0.38} />
|
||||||
|
{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="100%"
|
||||||
|
stopColor={layout.movement.color}
|
||||||
|
stopOpacity={layout.movement.end_definite ? 0.28 : 0.08}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</linearGradient>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{branches.map((branch) => (
|
||||||
|
<path
|
||||||
|
key={branch.key}
|
||||||
|
d={branch.d}
|
||||||
|
className="movement-branch"
|
||||||
|
stroke={`url(#branch-grad-${branch.key})`}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{layouts.map((layout) => {
|
||||||
|
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd);
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={`stream-${layout.movement.id}`}
|
||||||
|
d={d}
|
||||||
|
className="movement-stream-fill"
|
||||||
|
stroke={`url(#stream-grad-${layout.movement.id})`}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{layouts.map((layout) => {
|
||||||
|
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd);
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={`core-${layout.movement.id}`}
|
||||||
|
d={d}
|
||||||
|
className="movement-stream-core"
|
||||||
|
stroke={layout.movement.color}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div className="movements-flow-labels">
|
||||||
|
{layouts.map((layout) => (
|
||||||
|
<div
|
||||||
|
key={`label-${layout.movement.id}`}
|
||||||
|
className="movement-flow-label"
|
||||||
|
style={{
|
||||||
|
left: `${layout.xStart}%`,
|
||||||
|
top: `${((layout.y - 32) / layoutHeight) * 100}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="movement-name">{layout.movement.name}</span>
|
||||||
|
{layout.movement.era_name && (
|
||||||
|
<span className="movement-era">{layout.movement.era_name}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="movement-band-track">
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="movements-flow-artists">
|
||||||
|
{artistPlacements.map(({ layout, artist, lineLeft, lineWidth, portraitLeft, y, lane, color }) => {
|
||||||
|
const birthLabel = artist.birth_year != null ? artist.birth_year : '?';
|
||||||
|
const deathLabel = artist.death_year != null ? artist.death_year : '?';
|
||||||
|
const artistKey = `${layout.movement.id}-${artist.id}`;
|
||||||
|
const isHovered = hoveredArtistKey === artistKey;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
className="movement-band"
|
key={artistKey}
|
||||||
|
className={`artist-lifespan${isHovered ? ' artist-lifespan-active' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
left: `${left}%`,
|
left: `${lineLeft}%`,
|
||||||
width: `${width}%`,
|
width: `${lineWidth}%`,
|
||||||
borderLeft: movement.start_definite
|
top: `${(y / layoutHeight) * 100}%`,
|
||||||
? `3px solid ${movement.color}`
|
zIndex: isHovered ? 12 : 4 + lane,
|
||||||
: 'none',
|
['--lifespan-color' as string]: color,
|
||||||
borderRight: movement.end_definite
|
|
||||||
? `3px solid ${movement.color}`
|
|
||||||
: 'none',
|
|
||||||
background: `linear-gradient(90deg,
|
|
||||||
${movement.start_definite ? movement.color : 'transparent'} 0%,
|
|
||||||
${movement.color}33 10%,
|
|
||||||
${movement.color}33 90%,
|
|
||||||
${movement.end_definite ? movement.color : 'transparent'} 100%)`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{movementArtists.map((artist) => {
|
{isHovered && <span className="artist-lifespan-line" aria-hidden />}
|
||||||
const timelineYear = artistTimelineYear(artist);
|
<button
|
||||||
const artistLeft = yearToPercent(timelineYear, viewStart, viewEnd);
|
type="button"
|
||||||
if (artistLeft < left || artistLeft > right) return null;
|
className="artist-portrait"
|
||||||
|
style={{
|
||||||
const relLeft = ((artistLeft - left) / width) * 100;
|
left: `${portraitLeft}%`,
|
||||||
|
borderColor: color,
|
||||||
return (
|
}}
|
||||||
<button
|
onClick={() => onArtistClick(artist.id)}
|
||||||
key={artist.id}
|
onMouseEnter={() => setHoveredArtistKey(artistKey)}
|
||||||
className="artist-portrait"
|
onMouseLeave={() => setHoveredArtistKey(null)}
|
||||||
style={{ left: `${relLeft}%` }}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onClick={() => onArtistClick(artist.id)}
|
onWheel={(e) => e.stopPropagation()}
|
||||||
title={`${artist.name} (${artist.birth_year}–${artist.death_year})`}
|
title={`${artist.name} (${birthLabel}–${deathLabel}) · ${layout.movement.name}`}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={imageUrl(artist.portrait_path)}
|
src={imageUrl(artist.portrait_path)}
|
||||||
alt={artist.name}
|
alt={artist.name}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
|
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="artist-name">{artist.name}</span>
|
<span className="artist-name">{artist.name}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
})}
|
||||||
);
|
</div>
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
|
|
||||||
.timeline-container {
|
.timeline-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 72px;
|
height: 88px;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
border: 1px solid rgba(201, 169, 110, 0.3);
|
border: 1px solid rgba(201, 169, 110, 0.3);
|
||||||
@@ -67,6 +67,23 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
border-top: none;
|
||||||
|
border-bottom: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.era-block:hover {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.era-block:focus-visible {
|
||||||
|
outline: 2px solid #c9a96e;
|
||||||
|
outline-offset: -2px;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.era-label {
|
.era-label {
|
||||||
@@ -79,6 +96,102 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timeline-events {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 4;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-event {
|
||||||
|
position: absolute;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-event-point {
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 0;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-event-point .event-marker-line {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
bottom: 22px;
|
||||||
|
left: 50%;
|
||||||
|
width: 2px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(255, 210, 120, 0.95) 0%,
|
||||||
|
rgba(255, 180, 90, 0.75) 100%
|
||||||
|
);
|
||||||
|
box-shadow: 0 0 6px rgba(255, 190, 100, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-event-point::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 50%;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
transform: translateX(-50%) rotate(45deg);
|
||||||
|
background: rgba(255, 215, 130, 0.95);
|
||||||
|
border: 1px solid rgba(255, 240, 200, 0.8);
|
||||||
|
box-shadow: 0 0 4px rgba(255, 200, 100, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-event-span {
|
||||||
|
top: 20px;
|
||||||
|
bottom: 24px;
|
||||||
|
background: rgba(180, 70, 55, 0.22);
|
||||||
|
border-left: 2px solid rgba(255, 150, 110, 0.65);
|
||||||
|
border-right: 2px solid rgba(255, 150, 110, 0.65);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-event-span:hover,
|
||||||
|
.historical-event-point:hover .event-marker-line {
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-event:focus-visible {
|
||||||
|
outline: 2px solid #e8c878;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
max-width: 120px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: rgba(255, 235, 200, 0.95);
|
||||||
|
background: rgba(20, 16, 28, 0.72);
|
||||||
|
border: 1px solid rgba(255, 200, 120, 0.35);
|
||||||
|
border-radius: 3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-event-span .event-label {
|
||||||
|
top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.timeline-ticks {
|
.timeline-ticks {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { useRef, useState, useCallback, useEffect } from 'react';
|
import { useRef, useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
import type { HistoricalEra } from '../types';
|
import type { HistoricalEra } from '../types';
|
||||||
|
import {
|
||||||
|
HISTORICAL_EVENTS,
|
||||||
|
eventEndYear,
|
||||||
|
eventInView,
|
||||||
|
type HistoricalEvent,
|
||||||
|
} from '../data/historical-events';
|
||||||
import './Timeline.css';
|
import './Timeline.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -101,6 +107,68 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
|
|||||||
|
|
||||||
const resetView = () => onViewChange(absoluteMin, absoluteMax);
|
const resetView = () => onViewChange(absoluteMin, absoluteMax);
|
||||||
|
|
||||||
|
const zoomToEra = useCallback(
|
||||||
|
(era: HistoricalEra, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const eraSpan = era.end_year - era.start_year;
|
||||||
|
const padding = Math.max(5, Math.round(eraSpan * 0.03));
|
||||||
|
let start = era.start_year - padding;
|
||||||
|
let end = era.end_year + padding;
|
||||||
|
|
||||||
|
if (start < absoluteMin) start = absoluteMin;
|
||||||
|
if (end > absoluteMax) end = absoluteMax;
|
||||||
|
if (end - start < 10) {
|
||||||
|
const center = (era.start_year + era.end_year) / 2;
|
||||||
|
start = Math.max(absoluteMin, Math.round(center - 5));
|
||||||
|
end = Math.min(absoluteMax, Math.round(center + 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
onViewChange(Math.round(start), Math.round(end));
|
||||||
|
},
|
||||||
|
[absoluteMin, absoluteMax, onViewChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const zoomToEvent = useCallback(
|
||||||
|
(event: HistoricalEvent, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const end = eventEndYear(event);
|
||||||
|
const eventSpan = Math.max(end - event.startYear, 1);
|
||||||
|
const padding = Math.max(8, Math.round(eventSpan * 0.2));
|
||||||
|
let start = event.startYear - padding;
|
||||||
|
let endView = end + padding;
|
||||||
|
|
||||||
|
if (start < absoluteMin) start = absoluteMin;
|
||||||
|
if (endView > absoluteMax) endView = absoluteMax;
|
||||||
|
if (endView - start < 10) {
|
||||||
|
const center = (event.startYear + end) / 2;
|
||||||
|
start = Math.max(absoluteMin, Math.round(center - 5));
|
||||||
|
endView = Math.min(absoluteMax, Math.round(center + 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
onViewChange(Math.round(start), Math.round(endView));
|
||||||
|
},
|
||||||
|
[absoluteMin, absoluteMax, onViewChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleEvents = useMemo(() => {
|
||||||
|
const inView = HISTORICAL_EVENTS.filter((event) => eventInView(event, viewStart, viewEnd));
|
||||||
|
const minLabelGapYears =
|
||||||
|
span > 200 ? 40 : span > 80 ? 18 : span > 30 ? 10 : span > 12 ? 5 : 2;
|
||||||
|
|
||||||
|
let lastLabelYear = -Infinity;
|
||||||
|
return inView.map((event) => {
|
||||||
|
const end = eventEndYear(event);
|
||||||
|
const labelAnchor = event.endYear ? (event.startYear + end) / 2 : event.startYear;
|
||||||
|
const showLabel = labelAnchor - lastLabelYear >= minLabelGapYears;
|
||||||
|
if (showLabel) lastLabelYear = labelAnchor;
|
||||||
|
return { event, showLabel };
|
||||||
|
});
|
||||||
|
}, [viewStart, viewEnd, span]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="timeline-wrapper">
|
<div className="timeline-wrapper">
|
||||||
<div className="timeline-controls">
|
<div className="timeline-controls">
|
||||||
@@ -124,8 +192,9 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
|
|||||||
const right = yearToPercent(Math.min(era.end_year, viewEnd), viewStart, viewEnd);
|
const right = yearToPercent(Math.min(era.end_year, viewEnd), viewStart, viewEnd);
|
||||||
if (right <= 0 || left >= 100) return null;
|
if (right <= 0 || left >= 100) return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={era.id}
|
key={era.id}
|
||||||
|
type="button"
|
||||||
className="era-block"
|
className="era-block"
|
||||||
style={{
|
style={{
|
||||||
left: `${Math.max(0, left)}%`,
|
left: `${Math.max(0, left)}%`,
|
||||||
@@ -139,10 +208,63 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
|
|||||||
${era.end_definite ? 'var(--era-color)' : 'transparent'} 100%)`,
|
${era.end_definite ? 'var(--era-color)' : 'transparent'} 100%)`,
|
||||||
['--era-color' as string]: getEraColor(era.name),
|
['--era-color' as string]: getEraColor(era.name),
|
||||||
}}
|
}}
|
||||||
title={era.description}
|
title={`${era.name}: ${formatYear(era.start_year)} – ${formatYear(era.end_year)}${era.description ? ` · ${era.description}` : ''}`}
|
||||||
|
onClick={(e) => zoomToEra(era, e)}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<span className="era-label">{era.name}</span>
|
<span className="era-label">{era.name}</span>
|
||||||
</div>
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="timeline-events" aria-hidden={false}>
|
||||||
|
{visibleEvents.map(({ event, showLabel }) => {
|
||||||
|
const end = eventEndYear(event);
|
||||||
|
const isSpan = event.endYear != null && event.endYear !== event.startYear;
|
||||||
|
|
||||||
|
if (isSpan) {
|
||||||
|
const left = yearToPercent(Math.max(event.startYear, viewStart), viewStart, viewEnd);
|
||||||
|
const right = yearToPercent(Math.min(end, viewEnd), viewStart, viewEnd);
|
||||||
|
if (right <= 0 || left >= 100) return null;
|
||||||
|
const width = Math.min(100, right) - Math.max(0, left);
|
||||||
|
const label = showLabel ? event.shortLabel ?? event.name : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={event.id}
|
||||||
|
type="button"
|
||||||
|
className="historical-event historical-event-span"
|
||||||
|
style={{
|
||||||
|
left: `${Math.max(0, left)}%`,
|
||||||
|
width: `${width}%`,
|
||||||
|
}}
|
||||||
|
title={`${event.name} (${formatYear(event.startYear)} – ${formatYear(end)})`}
|
||||||
|
onClick={(e) => zoomToEvent(event, e)}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{label && <span className="event-label">{label}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.startYear < viewStart || event.startYear > viewEnd) return null;
|
||||||
|
const left = yearToPercent(event.startYear, viewStart, viewEnd);
|
||||||
|
const label = showLabel ? event.shortLabel ?? event.name : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={event.id}
|
||||||
|
type="button"
|
||||||
|
className="historical-event historical-event-point"
|
||||||
|
style={{ left: `${left}%` }}
|
||||||
|
title={`${event.name} (${formatYear(event.startYear)})`}
|
||||||
|
onClick={(e) => zoomToEvent(event, e)}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<span className="event-marker-line" />
|
||||||
|
{label && <span className="event-label">{label}</span>}
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -172,7 +294,7 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="timeline-hint">
|
<p className="timeline-hint">
|
||||||
Scroll to zoom · Drag to pan · Use +/− buttons to zoom into a historical period
|
Click an era or event to zoom · Scroll to zoom · Drag to pan
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/** Major world events shown as markers on the timeline (frontend curation). */
|
||||||
|
export interface HistoricalEvent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
startYear: number;
|
||||||
|
/** Omit for a single-year marker. */
|
||||||
|
endYear?: number;
|
||||||
|
/** Shorter label when the view is crowded. */
|
||||||
|
shortLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HISTORICAL_EVENTS: readonly HistoricalEvent[] = [
|
||||||
|
{ id: 'fall-rome', name: 'Fall of Rome', startYear: 476 },
|
||||||
|
{ id: 'black-death', name: 'Black Death', startYear: 1347 },
|
||||||
|
{ id: 'printing-press', name: 'Printing press', startYear: 1450 },
|
||||||
|
{ id: 'reformation', name: 'Protestant Reformation', startYear: 1517 },
|
||||||
|
{ id: 'columbus', name: 'Columbus reaches the Americas', startYear: 1492 },
|
||||||
|
{ id: 'american-revolution', name: 'American Revolution', startYear: 1776 },
|
||||||
|
{ id: 'french-revolution', name: 'French Revolution', startYear: 1789 },
|
||||||
|
{ id: 'waterloo', name: 'Battle of Waterloo', startYear: 1815 },
|
||||||
|
{ id: 'revolutions-1848', name: 'Revolutions of 1848', startYear: 1848 },
|
||||||
|
{ id: 'german-unification', name: 'Unification of Germany', startYear: 1871 },
|
||||||
|
{ id: 'ww1', name: 'World War I', startYear: 1914, endYear: 1918, shortLabel: 'WWI' },
|
||||||
|
{ id: 'russian-revolution', name: 'Russian Revolution', startYear: 1917 },
|
||||||
|
{ id: 'ww2', name: 'World War II', startYear: 1939, endYear: 1945, shortLabel: 'WWII' },
|
||||||
|
{ id: 'berlin-wall', name: 'Fall of Berlin Wall', startYear: 1989 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function eventEndYear(event: HistoricalEvent): number {
|
||||||
|
return event.endYear ?? event.startYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventInView(event: HistoricalEvent, viewStart: number, viewEnd: number): boolean {
|
||||||
|
return eventEndYear(event) >= viewStart && event.startYear <= viewEnd;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Documented art-historical movement lineage (predecessor → successor).
|
||||||
|
* Sources: Met Museum essays, TheArtStory, Sotheby's movement guides.
|
||||||
|
*
|
||||||
|
* Multiple parents are allowed (e.g. Post-Impressionism feeding several modern paths).
|
||||||
|
*/
|
||||||
|
export const MOVEMENT_LINEAGE: ReadonlyArray<readonly [parent: string, child: string]> = [
|
||||||
|
// Classical & medieval
|
||||||
|
['Ancient Greek & Roman', 'Byzantine'],
|
||||||
|
['Byzantine', 'Gothic'],
|
||||||
|
|
||||||
|
// Renaissance chain
|
||||||
|
['Gothic', 'Early Renaissance'],
|
||||||
|
['Early Renaissance', 'High Renaissance'],
|
||||||
|
['Northern Renaissance', 'Early Renaissance'],
|
||||||
|
['High Renaissance', 'Mannerism'],
|
||||||
|
['Mannerism', 'Baroque'],
|
||||||
|
|
||||||
|
// 17th–19th century
|
||||||
|
['Baroque', 'Rococo'],
|
||||||
|
['Rococo', 'Neoclassicism'],
|
||||||
|
['Neoclassicism', 'Romanticism'],
|
||||||
|
['Romanticism', 'Realism'],
|
||||||
|
['Romanticism', 'Impressionism'], // Turner, Delacroix → light and atmosphere
|
||||||
|
['Realism', 'Impressionism'],
|
||||||
|
|
||||||
|
// Modern origins (Met / ArtStory: Impressionism → Post-Impressionism → …)
|
||||||
|
['Impressionism', 'Post-Impressionism'],
|
||||||
|
['Post-Impressionism', 'Symbolism'],
|
||||||
|
['Post-Impressionism', 'Fauvism'],
|
||||||
|
['Post-Impressionism', 'Expressionism'],
|
||||||
|
['Post-Impressionism', 'Cubism'], // Cézanne's structure → Picasso/Braque
|
||||||
|
['Symbolism', 'Art Nouveau'],
|
||||||
|
['Fauvism', 'Cubism'], // Braque, Matisse → geometric turn
|
||||||
|
['Cubism', 'Futurism'],
|
||||||
|
['Cubism', 'Suprematism'],
|
||||||
|
['Cubism', 'Constructivism'],
|
||||||
|
['Expressionism', 'Abstract Expressionism'],
|
||||||
|
|
||||||
|
// Avant-garde 20th century
|
||||||
|
['Dada', 'Surrealism'],
|
||||||
|
['Surrealism', 'Abstract Expressionism'],
|
||||||
|
['Abstract Expressionism', 'Pop Art'],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function lineageParentsByName(childName: string): string[] {
|
||||||
|
return MOVEMENT_LINEAGE.filter(([, child]) => child === childName).map(([parent]) => parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lineageChildrenByName(parentName: string): string[] {
|
||||||
|
return MOVEMENT_LINEAGE.filter(([parent]) => parent === parentName).map(([, child]) => child);
|
||||||
|
}
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
.home-page {
|
.home-page {
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
background: linear-gradient(180deg, #0f0f1a 0%, #1a1a2e 40%, #16213e 100%);
|
background: linear-gradient(180deg, #0f0f1a 0%, #1a1a2e 40%, #16213e 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-movements-section {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.gallery-session-suspended {
|
.gallery-session-suspended {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -21,6 +31,7 @@
|
|||||||
.site-header {
|
.site-header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 24px 16px 8px;
|
padding: 24px 16px 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-header h1 {
|
.site-header h1 {
|
||||||
@@ -47,6 +58,10 @@
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-page > .timeline-wrapper {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.error-banner {
|
.error-banner {
|
||||||
margin: 16px;
|
margin: 16px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ export default function HomePage() {
|
|||||||
<div className="home-page">
|
<div className="home-page">
|
||||||
<header className="site-header">
|
<header className="site-header">
|
||||||
<h1>Virtual Art Gallery</h1>
|
<h1>Virtual Art Gallery</h1>
|
||||||
<p className="site-subtitle">Explore the flowing connections of art history</p>
|
<p className="site-subtitle">Watch art movements branch forward through time — each flowing from what came before</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Timeline
|
<Timeline
|
||||||
@@ -217,15 +217,20 @@ export default function HomePage() {
|
|||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="loading">Loading art history...</div>
|
<div className="loading home-movements-section">Loading art history...</div>
|
||||||
) : (
|
) : (
|
||||||
<MovementBands
|
<div className="home-movements-section">
|
||||||
movements={timelineData.movements}
|
<MovementBands
|
||||||
artists={artists}
|
movements={timelineData.movements}
|
||||||
viewStart={viewStart}
|
artists={artists}
|
||||||
viewEnd={viewEnd}
|
viewStart={viewStart}
|
||||||
onArtistClick={handleArtistClick}
|
viewEnd={viewEnd}
|
||||||
/>
|
absoluteMin={bounds.min}
|
||||||
|
absoluteMax={bounds.max}
|
||||||
|
onViewChange={handleViewChange}
|
||||||
|
onArtistClick={handleArtistClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/** Shared timeline zoom/pan math for Timeline and MovementBands. */
|
||||||
|
export function zoomTimelineView(
|
||||||
|
clientX: number,
|
||||||
|
rectLeft: number,
|
||||||
|
rectWidth: number,
|
||||||
|
deltaY: number,
|
||||||
|
viewStart: number,
|
||||||
|
viewEnd: number,
|
||||||
|
absoluteMin: number,
|
||||||
|
absoluteMax: number
|
||||||
|
): { start: number; end: number } {
|
||||||
|
const span = viewEnd - viewStart;
|
||||||
|
const ratio = (clientX - rectLeft) / rectWidth;
|
||||||
|
const centerYear = viewStart + ratio * span;
|
||||||
|
const factor = deltaY > 0 ? 1.15 : 0.85;
|
||||||
|
const newSpan = Math.max(10, Math.min(absoluteMax - absoluteMin, span * factor));
|
||||||
|
let newStart = centerYear - ratio * newSpan;
|
||||||
|
let newEnd = centerYear + (1 - ratio) * newSpan;
|
||||||
|
if (newStart < absoluteMin) {
|
||||||
|
newEnd += absoluteMin - newStart;
|
||||||
|
newStart = absoluteMin;
|
||||||
|
}
|
||||||
|
if (newEnd > absoluteMax) {
|
||||||
|
newStart -= newEnd - absoluteMax;
|
||||||
|
newEnd = absoluteMax;
|
||||||
|
}
|
||||||
|
return { start: Math.round(newStart), end: Math.round(newEnd) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function panTimelineView(
|
||||||
|
deltaX: number,
|
||||||
|
rectWidth: number,
|
||||||
|
viewStart: number,
|
||||||
|
viewEnd: number,
|
||||||
|
absoluteMin: number,
|
||||||
|
absoluteMax: number
|
||||||
|
): { start: number; end: number } {
|
||||||
|
const span = viewEnd - viewStart;
|
||||||
|
const yearDelta = (deltaX / rectWidth) * span;
|
||||||
|
let newStart = viewStart - yearDelta;
|
||||||
|
let newEnd = viewEnd - yearDelta;
|
||||||
|
if (newStart < absoluteMin) {
|
||||||
|
newEnd += absoluteMin - newStart;
|
||||||
|
newStart = absoluteMin;
|
||||||
|
}
|
||||||
|
if (newEnd > absoluteMax) {
|
||||||
|
newStart -= newEnd - absoluteMax;
|
||||||
|
newEnd = absoluteMax;
|
||||||
|
}
|
||||||
|
return { start: Math.round(newStart), end: Math.round(newEnd) };
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 4.6 MiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 4.9 MiB |
|
After Width: | Height: | Size: 7.2 MiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 41 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 941 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 486 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 584 KiB |
|
After Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 427 KiB |
|
After Width: | Height: | Size: 633 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 971 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 447 KiB |
|
After Width: | Height: | Size: 447 KiB |
|
After Width: | Height: | Size: 447 KiB |
|
After Width: | Height: | Size: 447 KiB |
|
After Width: | Height: | Size: 2.7 MiB |