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>
This commit is contained in:
Danila Khodjaef
2026-06-20 00:21:31 +03:00
co-authored by Cursor
parent 35c337253d
commit 0ece1195fa
1301 changed files with 1412 additions and 140 deletions
+1 -1
View File
@@ -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**
+1 -1
View File
@@ -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`
+71 -3
View File
@@ -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 parents 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 movements stream row:
| Feature | Behaviour |
|---------|-----------|
| Placement | Portrait at the **midpoint** of birthdeath 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.
+29 -2
View File
@@ -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
+2 -1
View File
@@ -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 -1
View File
@@ -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
+163 -49
View File
@@ -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;
}
} }
+719 -50
View File
@@ -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;
} }
const visibleMovements = movements.filter( function artistLifespanLayout(
(m) => m.end_year >= viewStart && m.start_year <= viewEnd && (artistsByMovement.get(m.id)?.length ?? 0) > 0 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),
});
}
}
return placements;
}
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,55 +589,185 @@ export default function MovementBands({ movements, artists, viewStart, viewEnd,
); );
} }
return ( const layoutById = new Map(layouts.map((l) => [l.movement.id, l]));
<div className="movements-container">
{visibleMovements.map((movement) => {
const left = yearToPercent(Math.max(movement.start_year, viewStart), viewStart, viewEnd);
const right = yearToPercent(Math.min(movement.end_year, viewEnd), viewStart, viewEnd);
const width = right - left;
if (width <= 0) return null;
const movementArtists = artistsByMovement.get(movement.id) || [];
return ( return (
<div key={movement.id} className="movement-row"> <div className="movements-flow">
<div className="movement-label"> <p className="movements-flow-caption">
<span className="movement-name">{movement.name}</span> Scroll to zoom · drag to pan · streams blend from movement to movement through history
{movement.era_name && <span className="movement-era">{movement.era_name}</span>} </p>
</div>
<div className="movement-band-track">
<div <div
className="movement-band" ref={canvasRef}
className={`movements-flow-canvas${panning ? ' movements-flow-panning' : ''}`}
style={{ style={{
left: `${left}%`, ['--stream-stroke' as string]: `${streamStrokePx}px`,
width: `${width}%`, ['--portrait-size' as string]: `${portraitSizePx}px`,
borderLeft: movement.start_definite }}
? `3px solid ${movement.color}` onWheel={handleWheel}
: 'none', onMouseDown={handlePanStart}
borderRight: movement.end_definite >
? `3px solid ${movement.color}` <svg
: 'none', className="movements-flow-svg"
background: `linear-gradient(90deg, viewBox={`0 0 100 ${layoutHeight}`}
${movement.start_definite ? movement.color : 'transparent'} 0%, preserveAspectRatio="none"
${movement.color}33 10%, aria-hidden
${movement.color}33 90%, >
${movement.end_definite ? movement.color : 'transparent'} 100%)`, <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>
))}
{layouts.map((layout) => {
const primaryParent =
layout.parentIds.length > 0 ? layoutById.get(layout.parentIds[0]) : null;
const hasChildren = (childIdsByParent.get(layout.movement.id)?.length ?? 0) > 0;
const parentColor = primaryParent?.movement.color ?? layout.movement.color;
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}%`,
}} }}
> >
{movementArtists.map((artist) => { <span className="movement-name">{layout.movement.name}</span>
const timelineYear = artistTimelineYear(artist); {layout.movement.era_name && (
const artistLeft = yearToPercent(timelineYear, viewStart, viewEnd); <span className="movement-era">{layout.movement.era_name}</span>
if (artistLeft < left || artistLeft > right) return null; )}
</div>
))}
</div>
const relLeft = ((artistLeft - left) / width) * 100; <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 ( return (
<div
key={artistKey}
className={`artist-lifespan${isHovered ? ' artist-lifespan-active' : ''}`}
style={{
left: `${lineLeft}%`,
width: `${lineWidth}%`,
top: `${(y / layoutHeight) * 100}%`,
zIndex: isHovered ? 12 : 4 + lane,
['--lifespan-color' as string]: color,
}}
>
{isHovered && <span className="artist-lifespan-line" aria-hidden />}
<button <button
key={artist.id} type="button"
className="artist-portrait" className="artist-portrait"
style={{ left: `${relLeft}%` }} style={{
left: `${portraitLeft}%`,
borderColor: color,
}}
onClick={() => onArtistClick(artist.id)} onClick={() => onArtistClick(artist.id)}
title={`${artist.name} (${artist.birth_year}${artist.death_year})`} onMouseEnter={() => setHoveredArtistKey(artistKey)}
onMouseLeave={() => setHoveredArtistKey(null)}
onMouseDown={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
title={`${artist.name} (${birthLabel}${deathLabel}) · ${layout.movement.name}`}
> >
<img <img
src={imageUrl(artist.portrait_path)} src={imageUrl(artist.portrait_path)}
@@ -107,13 +778,11 @@ export default function MovementBands({ movements, artists, viewStart, viewEnd,
/> />
<span className="artist-name">{artist.name}</span> <span className="artist-name">{artist.name}</span>
</button> </button>
);
})}
</div>
</div>
</div> </div>
); );
})} })}
</div> </div>
</div>
</div>
); );
} }
+114 -1
View File
@@ -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;
+126 -4
View File
@@ -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>
</button>
);
})}
</div> </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>
); );
+35
View File
@@ -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;
}
+52
View File
@@ -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'],
// 17th19th 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);
}
+16 -1
View File
@@ -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;
+7 -2
View File
@@ -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>
) : ( ) : (
<div className="home-movements-section">
<MovementBands <MovementBands
movements={timelineData.movements} movements={timelineData.movements}
artists={artists} artists={artists}
viewStart={viewStart} viewStart={viewStart}
viewEnd={viewEnd} viewEnd={viewEnd}
absoluteMin={bounds.min}
absoluteMax={bounds.max}
onViewChange={handleViewChange}
onArtistClick={handleArtistClick} onArtistClick={handleArtistClick}
/> />
</div>
)} )}
</div> </div>
)} )}
+51
View File
@@ -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) };
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 941 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 971 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Some files were not shown because too many files have changed in this diff Show More