549 lines
48 KiB
Markdown
549 lines
48 KiB
Markdown
# Art Gallery — architecture basics
|
||
|
||
Interactive virtual museum spanning art history: zoomable timeline with event guides, branching movement flow, 3D gallery halls, painting influence graphs, and art-history annotations on detail pages. See [README.md](../README.md) for quick start.
|
||
|
||
## Concept
|
||
|
||
The app is organised as a **drill-down hierarchy**:
|
||
|
||
1. **Timeline** — historical eras (Ancient → Contemporary) with definite or fuzzy date boundaries; classic left→right, vertical bottom→up, or **tree** bottom→up layout (header links).
|
||
2. **Movement flow** — art movements as SVG streams on the same year axis; documented predecessor→successor branches (classic); portrait thumbnails along each stream. The tree layout redraws the same lineage as a growing tree — see [movement-tree.md](movement-tree.md).
|
||
3. **3D gallery** — one personal hall per artist, a **movement gallery** (click a movement name → artist filter → hall), or a **guided tour** hall (timeline → **Tours**): period-themed or tour wings of up to ~55 works, U-shaped hang (left → end wall → right).
|
||
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **curator notes** and **art-history notes** with image markers, 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`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**).
|
||
|
||
**Catalog search** — on the timeline home page, the header search bar (`CatalogSearchBar.tsx`) finds artists, paintings, and movements by name and metadata (year, movement, Wikipedia title). Type at least **2 characters** (300 ms debounce); results group into **Artists**, **Movements**, and **Paintings** with thumbnails. Keyboard: `↑`/`↓` to move, `Enter` to open, `Escape` to close. Choosing a result opens the artist gallery, movement gallery, or painting detail. Paintings opened from search show **← Back to Timeline** and return to the home timeline (full year range), not the previous view.
|
||
|
||
All artwork images are stored locally under `data/images/` — the UI never hot-links to Wikipedia or Commons at runtime (except optional on-demand fetch when a file is missing).
|
||
|
||
## Stack
|
||
|
||
| Layer | Choice | Role |
|
||
|--------|--------|------|
|
||
| API | **Node.js + Express 5** | REST API, static image serving, optional SPA hosting from `client/dist` |
|
||
| Database | **PostgreSQL** | Eras, movements, artists, paintings, influence edges |
|
||
| Frontend | **React 19 + Vite 8** | SPA routing and UI |
|
||
| 3D | **Three.js** via `@react-three/fiber`, `@react-three/drei` | Virtual gallery navigation |
|
||
| Data ingestion | Node scripts | Wikipedia summaries, Commons images, curated influence data |
|
||
|
||
## Repository layout
|
||
|
||
```text
|
||
Gallery/
|
||
├── server/ # Express API, DB pool, image service
|
||
├── client/ # React/Vite frontend
|
||
│ ├── src/ # Source (components, pages, 3D scene)
|
||
│ │ ├── components/VirtualGallery.tsx # 3D hall (artist + movement modes; WebGL context-loss recovery)
|
||
│ │ ├── components/GalleryLoadingMarker.tsx # Loading spinner overlay/banner (catalog, portraits, halls)
|
||
│ │ ├── components/GalleryWindows.tsx # Side-wall daylight windows (movement)
|
||
│ │ ├── components/HallPassage.tsx # Open archway between movement wings
|
||
│ │ ├── components/MovementHallDetails.tsx # Period architectural details
|
||
│ │ ├── data/movement-interior-styles.ts # Per-movement interior themes
|
||
│ │ ├── utils/movementHallLayout.ts # Wing split + window gap placement
|
||
│ │ ├── utils/galleryProceduralTextures.ts # Hi-res wall/floor textures
|
||
│ │ ├── components/PaintingDetail.tsx # Detail view + debug panel
|
||
│ │ ├── components/ArtistBio.tsx # Biography + portrait debug panel
|
||
│ │ ├── components/DebugSearchResultsModal.tsx # “More” search picker (20 results)
|
||
│ │ ├── components/Timeline.tsx # Classic horizontal era bar
|
||
│ │ ├── components/VerticalTimeline.tsx # Bottom-up vertical era rail
|
||
│ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches)
|
||
│ │ ├── components/VerticalMovementBands.tsx # Bottom-up movement streams
|
||
│ │ ├── components/MovementTree.tsx # Bottom-up movement tree (alternative start page)
|
||
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow
|
||
│ │ ├── components/CatalogSearchBar.tsx # Timeline header catalog search
|
||
│ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail
|
||
│ │ ├── pages/CheckupPage.tsx # Image audit table
|
||
│ │ ├── pages/TranslationsPage.tsx # Russian translation review
|
||
│ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard
|
||
│ │ ├── pages/ToursPage.tsx # Guided tour editor
|
||
│ │ ├── pages/UsersPage.tsx # Staff accounts
|
||
│ │ ├── pages/AuditPage.tsx # Admin curator activity reports
|
||
│ │ ├── components/ToursPopup.tsx # Public published-tours modal
|
||
│ │ ├── i18n/ # react-i18next bootstrap
|
||
│ │ ├── locales/{en,ru}/ # UI chrome strings (incl. timeline layout + captions)
|
||
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
|
||
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
|
||
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor
|
||
│ │ ├── utils/debugMode.ts # Debug mode + “Show more” localStorage prefs
|
||
│ │ ├── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
|
||
│ │ ├── utils/movementColor.ts # Shared vivid/shade hex helpers for all movement charts
|
||
│ │ └── utils/movementTree.ts # View-independent Tree of Art layout engine│ └── dist/ # Production build (served by API when present)
|
||
├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools
|
||
│ ├── seed-wikipedia.js
|
||
│ ├── seed-catalog-data.js
|
||
│ ├── sync-image-paths.js
|
||
│ ├── fetch-artist-images.js
|
||
│ ├── fetch-artist-bios.js
|
||
│ ├── expand-paintings.js
|
||
│ ├── famous-paintings-data.js
|
||
│ ├── fetch-missing-images.js
|
||
│ ├── find-duplicate-paintings.js
|
||
│ ├── audit-influence-duplicates.js
|
||
│ ├── painter-palette-lib.js
|
||
│ ├── import-painter-palette.js
|
||
│ └── image-fetcher.js
|
||
├── Inputs/ # External datasets & influence workbooks (PainterPalette, book extracts, …)
|
||
├── Output/ # Generated exports (CSV dumps, scratch extracts) — gitignored
|
||
├── data/images/ # Local portraits and paintings (+ thumbs/)
|
||
├── db/ # schema.sql, setup-admin.sql, migrate-*.sql
|
||
├── server/migrate.js # npm run dev:migrate — schema + incremental migrations
|
||
├── deploy/ # Legacy nginx + systemd examples (optional)
|
||
├── infra/docker/ # Production Dockerfile, TrueNAS compose, deploy scripts
|
||
├── Documentation/ # This folder
|
||
│ └── environments.md # Dev/prod URLs, DB split, sync, deploy
|
||
└── .env # DB and port config (not committed)
|
||
```
|
||
|
||
## Runtime modes
|
||
|
||
**Default:** develop and test on **dev** (`gallery_dev`, devgallery URL). **Production** is updated on a scheduled release (~weekly), not on every edit. See [environments.md — Development-first workflow](environments.md#development-first-workflow-default).
|
||
|
||
### Public development (`dev:web`) — primary
|
||
|
||
```bash
|
||
npm run dev:web # Vite :5173 + API :3451 — https://devgallery.mysuperlab.netcraze.pro
|
||
```
|
||
|
||
Uses database **`gallery_dev`** on the same PostgreSQL host.
|
||
|
||
### Production (TrueNAS Docker) — scheduled releases
|
||
|
||
Production runs in **`gallery-web`** on TrueNAS port **5173**, database **`gallery_prod`**, public URL **https://gallery.mysuperlab.netcraze.pro**. Images: `/mnt/BasePool/Applications/Gallery/data/images` (SMB share **`Gallery`**). Deploy via `npm run prod:docker:publish` and the promote checklist in [environments.md](environments.md).
|
||
|
||
### Production-style single process (local)
|
||
|
||
```bash
|
||
npm run prod:start # build client + serve on PORT from .env
|
||
```
|
||
|
||
Serves `/api/*`, `/images/*`, and the built SPA from `client/dist`.
|
||
|
||
### Local HMR (two processes)
|
||
|
||
```bash
|
||
npm run dev:server # API on PORT from .env
|
||
npm run dev:client # Vite on :5173, proxies /api and /images
|
||
```
|
||
|
||
Use for fast frontend iteration without Keenetic. Legacy nginx config in [`deploy/nginx-gallery.conf`](../deploy/nginx-gallery.conf) proxied the public domain to Vite `:5173`.
|
||
|
||
## User navigation flow
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
A[Home — timeline + movement flow] -->|scroll / drag / zoom| A
|
||
A -->|catalog search| S[Search results dropdown]
|
||
S -->|artist| C
|
||
S -->|movement| G
|
||
S -->|painting| D
|
||
A -->|click movement name| G[Movement gallery — 3D wings]
|
||
G -->|click painting| D
|
||
D -->|Back to Gallery| G
|
||
G -->|back door / Wings / Exit| H[Wing navigator]
|
||
H -->|pick wing| G
|
||
H -->|Exit to Timeline| A
|
||
G -->|front arch / E| G
|
||
G -->|Back to Timeline| A
|
||
A -->|click portrait| B[Artist bio — Wikipedia text]
|
||
B -->|Enter Gallery| C[Artist hall — 3D]
|
||
C -->|click painting| D[Painting detail + influences]
|
||
D -->|prev / next| D
|
||
D -->|click centre image| F[Fullscreen lightbox]
|
||
F -->|close| D
|
||
D -->|Back to Gallery| C
|
||
C -->|exit doorway / E key| E[Path picker]
|
||
E -->|predecessors| C
|
||
E -->|successors| C
|
||
D -->|influence thumbnail| D
|
||
D -->|artist link| B
|
||
B -->|Back| C
|
||
C -->|Back to Timeline| A
|
||
D -->|Back to Timeline when opened from search| A
|
||
```
|
||
|
||
### Back navigation
|
||
|
||
| Control | Behaviour |
|
||
|---------|-----------|
|
||
| **← Back to Timeline** (3D gallery header, movement **Exit to Timeline**) | Always returns to the **home timeline**: unmounts the hall, clears the gallery session, resets timeline zoom to the full catalog year range |
|
||
| **← Back to Gallery** (painting detail from a hall) | Returns to the **same hall session** — camera position and wing are preserved |
|
||
| **← Back to Timeline** (painting detail opened from catalog search) | Returns to the home timeline (same as gallery **Back to Timeline**) |
|
||
| **← Back** (artist bio) | Returns to wherever you opened bio from (usually the artist hall) |
|
||
|
||
Implementation: `goToTimelineHome()` in `HomePage.tsx` — do not use the browser **Back** button; it is not wired to app navigation.
|
||
|
||
## Timeline and movement flow
|
||
|
||
The home page shows linked era + movement views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`). Header links switch among three layouts; the active layout is also deep-linked:
|
||
|
||
| Layout | Era rail | Movement flow | Time direction | URL |
|
||
|--------|----------|---------------|----------------|-----|
|
||
| **Classic** (default) | `Timeline.tsx` (top bar) | `MovementBands.tsx` | Left → right | omit or `?layout=classic` |
|
||
| **Vertical** | `VerticalTimeline.tsx` (left rail) | `VerticalMovementBands.tsx` (streams + lineage; no portraits) | Bottom → top | `?layout=vertical` |
|
||
| **Tree of art** | `VerticalTimeline.tsx` (left rail) | `MovementTree.tsx` (lineage as a growing tree; no portraits) | Bottom → top | `?layout=tree` |
|
||
|
||
`HomePage` reads `?layout=` once on load and calls `history.replaceState` when the user switches. Classic clears the param so the default URL stays clean. Alias `horizontal` maps to classic.
|
||
|
||
| View | Component | Purpose |
|
||
|------|-----------|---------|
|
||
| Era bar / rail | `Timeline.tsx` / `VerticalTimeline.tsx` | Historical eras, major event markers, click-to-zoom |
|
||
| Movement flow | `MovementBands.tsx` / `VerticalMovementBands.tsx` / `MovementTree.tsx` | Streams or tree limbs per movement; classic also places artist portraits |
|
||
| Stream colours | `utils/movementColor.ts` | Shared `vividMovementColor` / `shadeMovementColor` for all three charts |
|
||
|
||
Hint captions under each rail/chart (`captionClassicTimeline`, `captionClassicFlow`, `captionVerticalTimeline`, `captionVerticalFlow`, `captionTreeFlow`) live in `locales/{en,ru}/home.json` and follow the EN|RU toggle.
|
||
|
||
All three layouts share zoom/pan behaviour via `client/src/utils/timelineView.ts` (`zoomTimelineView`, `panTimelineView`, `chooseTimelineTickInterval`, `createViewChangeScheduler`). The home page uses a **fixed viewport** (`100vh`). Classic stacks timeline above movements; vertical and tree place the year rail beside the flow (`home-timeline-stack-vertical`). The tree layout keeps its horizontal geometry fixed across zoom — rules in [movement-tree.md](movement-tree.md).
|
||
### Catalog search (timeline header)
|
||
|
||
`CatalogSearchBar.tsx` calls `GET /api/search?q=…` (public, no login). The dropdown is stacked above the timeline (`z-index` on `.site-header`) so results are not hidden by movement bands.
|
||
|
||
| UX | Detail |
|
||
|----|--------|
|
||
| Minimum query | 2 characters after trim |
|
||
| Debounce | 300 ms |
|
||
| Result groups | Artists, Movements, Paintings (with thumb or movement colour swatch) |
|
||
| Open artist | Preload images → artist 3D hall |
|
||
| Open movement | Movement gallery (wing 1) |
|
||
| Open painting | Painting detail with `returnTo: timeline` → **Back to Timeline** |
|
||
| Keyboard | `↑`/`↓` highlight, `Enter` open, `Escape` close |
|
||
|
||
Run `npm run dev:migrate:search` once on existing databases before first use, or rely on `npm run dev:migrate` (includes `migrate-search.sql`). See [API.md — GET /api/search](API.md#get-apsearch).
|
||
|
||
### Timeline data loading
|
||
|
||
On first visit, `HomePage.tsx` fetches the full catalog once:
|
||
|
||
1. `GET /api/bounds` — initialise the year range.
|
||
2. `GET /api/timeline?start=…&end=…` — all eras and movements for that range.
|
||
3. `GET /api/artists?timeline=1` — lightweight artist rows (portraits, lifespan, movement colour; no full biography text).
|
||
|
||
Pan, zoom, and era/event click-to-zoom only update **local** `viewStart` / `viewEnd` state. `MovementBands.tsx` and `Timeline.tsx` filter what is visible for the current window — they do not trigger new API calls.
|
||
|
||
**Loading indicators** (`GalleryLoadingMarker.tsx`) keep the user informed while data is still arriving:
|
||
|
||
| Marker | When |
|
||
|--------|------|
|
||
| Overlay **“Loading art history…”** | Until the first catalog fetch (`bounds` + `timeline` + `artists`) completes |
|
||
| Bottom banner **“Loading portraits…”** | While artist portrait thumbnails are still downloading on the movement flow (timeline stays interactive) |
|
||
| Overlay **“Opening artist/movement gallery…”** / **“Loading artists…”** | Between clicking a portrait/movement and the 3D hall (or artist-filter modal) data being ready |
|
||
| Overlay **“Loading gallery…”** | While the 3D canvas initializes and door/hall shaders warm up after the hall opens (HDR Environment and painting textures continue in the background) |
|
||
| Overlay **“Loading paintings…”** | Brief counter while wall painting textures start downloading; large halls (e.g. Byzantine) keep loading after the overlay dismisses — a slow image no longer permanently blanks the frame |
|
||
| Overlay **“Restoring gallery…”** | Briefly after WebGL context loss while the canvas remounts |
|
||
|
||
View updates are **batched to one commit per animation frame** via `createViewChangeScheduler()` in `timelineView.ts` (`HomePage.tsx` → `handleViewChange`), so rapid scroll-wheel events do not flood React with separate renders.
|
||
|
||
### Timeline year labels
|
||
|
||
Year ticks along the bottom of the era bar use **large, high-contrast** labels (bold cream text with shadow). The active range in the control row (e.g. `1400 CE — 1900 CE`) uses the same stronger styling.
|
||
|
||
Label density is **dynamic**: `chooseTimelineTickInterval()` in `timelineView.ts` picks a “nice” step (1, 2, 5, 10, … 5000 years) from the visible span and measured bar width so labels stay ~76 px apart. Zoomed-out overviews show fewer dates; zooming in reveals finer steps automatically.
|
||
|
||
### 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.
|
||
|
||
**Vertical guides:** `TimelineEventGuides.tsx` draws faint gold lines (point events) or shaded bands (spans) from the marker row **down through the movement flow**, aligned to the same year scale. Guides are visual only (`pointer-events: none`); click-to-zoom stays on the markers in `Timeline.tsx`.
|
||
|
||
### Movement flow
|
||
|
||
Each visible movement is drawn as a **portrait-width curved stream** (~54 px stroke, compressed when many rows are visible) in a **solid vivid colour** 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 lanes | Movements share a horizontal lane only when one ends at least **10 years** before the next starts (in the current zoom); closer or overlapping spans stack into extra rows (`assignTemporalLanes`). Lane choice **minimizes vertical branch length**: children prefer parent lanes (same row when years allow), corridor clearing (up to 20 passes) evicts unrelated streams preferring **rows above** the parent/child strip, then re-attracts the child toward the parent |
|
||
| Branch connectors | Smooth curves from fan-out points along a parent stream to the **left edge (start)** of each child stream; origin X is always strictly left of the target (time-forward only — never right→left); stroke thickness matches the **target** (child) band height; color gradients from parent → child at constant opacity; a stream-shaped mask hides branch ink under movements so translucent overlaps do not brighten the bands; pan/zoom layout changes **animate** (streams + branches chase new geometry) so shifts stay trackable |
|
||
| Band thickness | Stream height is **proportional to `influence_link_count`** (edges on paintings by artists in that movement): thicker bands for denser influence graphs; lane rows share vertical space weighted by the thickest band in each lane |
|
||
| Filtering | A movement is drawn when its **span overlaps** the visible year range **and** it has at least one catalogued artist — artists whose lifespan falls outside the window still keep their movement visible (their portraits simply do not render). Filtered client-side after initial load |
|
||
| Viewport layout | Row height and stream width scale from measured canvas size so every visible movement row fits in the remaining screen space |
|
||
| Name labels | On-band **movement name** only. Label placement priority: **(1)** center of movement if label fits and no portrait collision; **(2)** right end inside band borders; **(3)** overflow right or left (up to 100% outside the band border), choosing the side with less transition-line overlap; **(4)** adjacent free gap before/after the band; **(5)** hidden until hover. Collision checks exclude the movement's own portraits (labels may overlap their own band's artists). Movements with overflow labels are packed onto **exclusive horizontal lanes** before vertical stacking so they never share a row with other movements. Minimum movement height = 125% of label height |
|
||
|
||
### 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 anywhere on flow canvas | Zoom (same year range as timeline; works over portraits and labels too) |
|
||
| Drag on flow canvas | Pan |
|
||
| Click portrait | Open artist biography |
|
||
| Click **movement name** (label on stream) | Open the **artist filter** modal for that movement, then the movement gallery |
|
||
|
||
**Zoom/pan performance:** The movement canvas uses a **capture-phase** wheel listener so scroll zoom works even when the cursor is over a portrait. While scrolling or dragging, a short **interaction mode** (~120 ms after the last input) draws simplified solid SVG strokes and temporarily hides portrait/label DOM so zoom stays responsive; full stream styling and portraits return when you stop.
|
||
|
||
Only **mousedown** on portraits and movement labels stops propagation (so drag-to-pan does not start when clicking them). Hovering a portrait highlights the artist’s lifespan on the era bar and brightens their segment on the movement stream.
|
||
|
||
**Note:** Movement lineage is **frontend curation** for layout and labels — it is not stored in PostgreSQL. Painting influence links live in **`painting_influence_sources`** (paintings, artists, or movements as sources). The API reads that table for detail panels, hall navigation, and `has_influence_links`. The legacy **`painting_influences`** table is still written in parallel when curators add painting-to-painting edges but is not queried for display.
|
||
|
||
## Virtual gallery (3D halls)
|
||
|
||
The 3D scene supports three modes in `VirtualGallery.tsx`: **artist halls** (personal catalog), **movement galleries** (full movement collection, chronological), and **guided tours** (curator-ordered stops — [tours.md](tours.md)).
|
||
|
||
**Shared wall hang (all modes):** visit order is a **U-shape** — **left wall**, then the **far/end wall** ahead when entering, then the **right wall**. The **first** work hangs near the entrance on the left (immediately left of the opening view); the **last** hangs near the entrance on the right. With fewer than three works, only left/right are used. Artist halls use chronological order; movement wings use chronological order within each wing; tours use stop order.
|
||
|
||
### Artist halls
|
||
|
||
Each artist has **exactly one hall**. The hall is a rectangular room sized to fit their catalog:
|
||
|
||
| Rule | Implementation |
|
||
|------|----------------|
|
||
| One hall per artist | `VirtualGallery.tsx` builds a single room from that artist’s paintings |
|
||
| Catalog depth | Most artists target **≥ 6** notable works via `npm run dev:expand-catalog` and `famous-paintings-data.js`; some masters have larger museum dumps |
|
||
| Paintings on walls | Works hang on **left**, **far/end**, and **right** walls in **one row per wall**; room **depth** and **width** grow with the catalog |
|
||
| Wall order | Chronological U-path: first third **left** (entrance → end), middle third **end wall**, last third **right** (end → entrance) |
|
||
| Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) |
|
||
| Wall tint | Gallery walls blend the artist’s **movement colour** into cream plaster tones |
|
||
| Frame finish | **Unchecked** works: black moulding; **Reviewed** (`checkup_checked`): bright gold moulding at **double width** |
|
||
| Influence lamps | A golden picture light marks works with any influence-graph edge (`has_influence_links` from the API). All lamps in a hall share one rail height: **40 cm above the tallest allocated frame** in that hall (so they line up regardless of canvas size). Fixture is mounted **upside down** and is **emissive-only** (no per-frame lights — see light budget below) |
|
||
| Curator-note plates | A small brass plate hangs **beneath frames** that have non-empty `curator_notes` |
|
||
| Eye-level viewing | Frame centres sit at **eye height (~1.65 m)**; the camera stays **level with the floor** (no pitch up/down) |
|
||
| Open centre | Floor and ceiling only — no freestanding columns or pedestals in the walkway |
|
||
| Museum exit | Front-wall **double doors** with transom, brass hardware, sconces, marble threshold, and warm vestibule glow |
|
||
| Shadows | **Disabled** — no Canvas shadow maps / `castShadow` (performance; flat lighting only) |
|
||
| Hall-to-hall travel | Exit opens a panel: **Predecessors** (left) and **Successors** (right), each grouped by art movement |
|
||
| Missing images | Works without a local file show a **draped canvas cover** in the frame (not a blank white rectangle) |
|
||
| Detail view return | Opening a painting close-up **keeps the 3D hall mounted** in the background so position and view direction are preserved when you go back |
|
||
| After image fix | Debug **Fix it** updates the gallery session, busts texture cache (`?v=N`), and returns to the hall with the new image and gold frame |
|
||
|
||
**Controls (artist hall):**
|
||
|
||
| Input | Action |
|
||
|-------|--------|
|
||
| `W` / `↑` | Walk forward |
|
||
| `S` / `↓` | Walk back |
|
||
| `A` / `←` / `Q` | Turn left |
|
||
| `D` / `→` | Turn right |
|
||
| Mouse drag | Look left / right (same direction as keyboard turns) |
|
||
| Click painting | Open detail view |
|
||
| Exit doorway / `E` / **Exit →** header button | Open path picker |
|
||
|
||
Predecessors and successors come from **`painting_influence_sources`** (painting and artist sources). Empty lists mean no influence edges are recorded yet for that artist — run `npm run dev:update-influences` or extend `art-influences-data.js`.
|
||
|
||
### Movement galleries
|
||
|
||
Enter from the home page by clicking a **movement name** on the movement flow. First a centered **artist filter** modal (`ArtistFilterModal.tsx`) loads `GET /api/movements/:id/artists-summary` (portrait, lifespan, painting count). All artists are selected by default; deselect any to exclude their works, then **Enter gallery**. The client loads `GET /api/movements/:id/gallery` and filters paintings to the selected artist IDs before opening `VirtualGallery`.
|
||
|
||
| Rule | Implementation |
|
||
|------|----------------|
|
||
| One gallery per movement | Paintings by **selected** artists in that movement, sorted chronologically |
|
||
| Wings | Catalog split into wings of up to **55 works** (`movementHallLayout.ts`); large movements (e.g. Baroque) use multiple wings |
|
||
| Paintings on walls | **Left, end, and right** — single-wing halls keep the far wall solid (full span); multi-wing halls hang end-wall works on panels beside the back exit |
|
||
| Wall order | Same U-shaped hang as artist halls (left → end → right) |
|
||
| Frame captions | **Year · artist** label below each frame |
|
||
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, Byzantine basilica, NYC loft, white cube, etc.). Style keys use a legacy id map (`DB_ID_TO_STYLE_KEY`: gallery ids **1–26** → authored keys **27–52**, with Northern/High Renaissance swapped) so Byzantine gets the basilica, not Gothic stone |
|
||
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco, **marble revetment** (framed book-matched panels), **Cosmatesque paving** (`marble-opus-sectile`), **coffered timber** ceilings, **blind-arcaded ashlar** (`gothic-ashlar`) and **ribbed vaulting** (`gothic-vault`) — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes |
|
||
| Architecture in texture | Period halls that need arcades, vaults, or panelling draw that **relief into the texture** rather than adding geometry: `gothic-ashlar` paints a full storey (plinth → blind arcade → string course → triforium → cornice) into one tile whose `metersPerRepeat` equals `WALL_HEIGHT`, so it maps **once vertically** onto the wall; `gothic-vault` paints a quadripartite bay with tiercerons and a boss. Light/dark banding plus a high `normalStrengthFor()` makes it read as carved stone, keeping the mesh and light budget flat |
|
||
| Door-flanking panels | Wall segments beside exit doors and passages take the hall's own wall texture (`GalleryWall` accepts `kind`/`tint`) instead of rendering as flat single-colour blocks next to textured walls |
|
||
| Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue |
|
||
| Windows | **Side walls only** — placed in gaps between frames when possible; if a wall is packed, high **clerestory** windows are still added so the hall keeps daylight (`computeSideWallWindows`). Styles are drawn in `GalleryWindows.tsx`; `gothic-lancet` builds a two-centred arch head from chords with a glazed spandrel, mullions, and transoms |
|
||
| Lighting | Shared hall lights only (ambient / hemisphere / directional + capped ceiling track spots + one fill per window). See **light budget** below |
|
||
| Period details | `MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** (never freestanding mid-hall or proud corner shafts that cover frames); `byzantine` adds engaged porphyry colonnettes with basket capitals, a marble revetment dado, and hanging brass polycandela; `gothic` adds bay-spaced compound piers with vault springers, transverse ribs arching across the nave, and a moulded string course — all flush to the side walls |
|
||
| Back wall | Single wing: solid display wall. Multi-wing: **Exit double doors** → **Wing navigator** or **Exit to Timeline** |
|
||
| Front / entrance wall | Single wing: **Exit double doors** (leave the way you entered). Multi-wing: **“Next wing →”** archway when a later wing exists |
|
||
| Influence lamps | Same golden upside-down emissive fixtures as artist halls when `has_influence_links` is true (shared rail: 40 cm above the tallest frame in the wing) |
|
||
| Missing images | Draped canvas cover in frame |
|
||
| Detail return | **Back to Gallery** from painting detail returns to the same wing with camera preserved; **Back to Timeline** exits the hall entirely |
|
||
|
||
**Controls (movement gallery):**
|
||
|
||
| Input | Action |
|
||
|-------|--------|
|
||
| Walk / turn / drag | Same as artist hall |
|
||
| Click painting | Open detail view (returns to the same wing) |
|
||
| Entrance / back door / `E` / **Wings / Exit →** | Open wing navigator (or exit on single-wing halls) |
|
||
| Front archway / `E` (when near, multi-wing) | Advance to the **next chronological wing** |
|
||
|
||
Movement galleries do **not** use the predecessor/successor influence picker — that remains artist-hall only.
|
||
|
||
### Guided tour halls
|
||
|
||
Enter from the home page **Tours** popup (`GET /api/tours/:id`). Layout reuses the movement winged hall (`mode: 'tour'` in `VirtualGallery.tsx`).
|
||
|
||
| Rule | Implementation |
|
||
|------|----------------|
|
||
| Visit order | Curator `sort_order` on `tour_stops` (not chronological) |
|
||
| Wings | Same ~55-per-wing split as movements; order preserved across wings |
|
||
| Wall hang | Same U-shaped hang as other halls (left → end → right) |
|
||
| Detail | Tour stop text panel; ‹ › walks tour stops |
|
||
| Exit | Wing navigator / **Exit to Timeline** (no influence picker) |
|
||
|
||
Full guide: [tours.md](tours.md).
|
||
|
||
### Shared 3D behaviour
|
||
|
||
**Light budget (critical):** Three.js forward shading cannot compile `MeshStandardMaterial` with dozens of dynamic lights. Large wings used to add **one `spotLight` per painting** (plus influence lamps), which pushed the scene to ~100–200 lights — walls, floor, and ceiling then rendered as a black void while painting canvases (`MeshBasicMaterial`) still showed. Halls therefore use **shared lighting only**: ambient / hemisphere / directional fill, a small set of ceiling track spots (`GalleryTrackLights`, capped), and **one** window spot each. Painting canvases stay unlit (`MeshBasicMaterial`); frames and influence fixtures use **emissive** accents instead of per-frame lights. Intensities are scaled for three.js physical lights (post-r155).
|
||
|
||
Shared lights carry a **brightness floor** so dark period styles never render as a black void. A style that is *meant* to be dim (a basilica lit by window shafts, not flooded) sets **`lightScale`** in `movement-interior-styles.ts` — it multiplies the scene ambient/hemisphere/directional/fill lights, the hall point lights, the ceiling track spots, and the HDR `environmentIntensity`. It defaults to **1**, so only styles that opt in are affected. Window fill lights are deliberately **not** scaled, so the daylight shafts still read against the darker room.
|
||
|
||
**Period architecture:** Freestanding columns must not sit in the walkway or in the corner pocket in front of frames (hang margin is only ~0.7 m from each wall end). Classical / neoclassical details use **engaged corner pilasters** flush to the walls (`MovementHallDetails.tsx`).
|
||
|
||
**Surface textures:** Procedural colour maps use sRGB; **normal maps use `NoColorSpace`** (`galleryProceduralTextures.ts`). Wrong colour space on normals breaks wall shading.
|
||
|
||
**3D images** prefer local **thumbnail** files (`galleryImageUrlCandidates` in `client/src/api/client.ts`: thumb → `GET /api/paintings/:id/image?size=thumb`; full originals are **not** used for hall frames — they can be tens of MB and made large halls take ~1 minute). Remote Wikipedia fetches are too slow for realtime WebGL textures. Entering an **artist** or **movement** hall fires `POST /api/artists/:id/preload-images` or `POST /api/movements/:id/preload-images` in the **background** (does not block hall open). Texture downloads are **queued** (max 8 parallel). While a texture is loading, the frame shows the canvas cover instead of a white placeholder. A per-image deadline only releases the boot overlay counter — it does **not** permanently blank the frame if the download finishes later.
|
||
|
||
**Boot overlay** (`VirtualGallery.tsx`): the center shows **“Loading gallery…”** until the WebGL canvas is ready and a short `gl.compileAsync` warm-up finishes (so entrance doors / passages do not hitch on the first turn). HDR `Environment` loads in a Suspense boundary **without** blocking the overlay. Painting textures keep loading in the background. The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again.
|
||
|
||
**WebGL context-loss recovery:** on some GPUs/drivers (notably certain Chrome setups) the browser can drop the WebGL context right after entering a hall, which would otherwise leave a permanent dark window. `VirtualGallery.tsx` listens for `webglcontextlost` / `webglcontextrestored`, calls `preventDefault()` so the browser can restore the context, and remounts the `<Canvas>` with a fresh context (a **“Restoring gallery…”** overlay shows briefly). The network-loaded HDR `Environment` map is wrapped in an error boundary so, if it fails to load, the hall still renders without reflections instead of unmounting the whole scene.
|
||
|
||
## Painting detail view
|
||
|
||
Opened from the 3D hall (artist, movement, or tour wing — click a frame) or from influence thumbnails on another work’s detail page.
|
||
|
||
| Layer | What you see |
|
||
|-------|----------------|
|
||
| **Detail** | Centre image, *Influenced By* (left) and *Influenced* (right) — painting thumbnails (full work visible, letterboxed), artist portraits, or movement swatches — position in catalog (e.g. `3 of 12`) |
|
||
| **Tour notes** | When opened from a guided tour: stop text panel under the image (English body from `tour_stops`) |
|
||
| **Curator notes** | Public editorial text on the painting (`paintings.curator_notes`); visitors see it when non-empty; curators edit inline on the detail page |
|
||
| **Art history notes** | Numbered markers on the image (when positioned) plus a note list below — short citations from Gombrich, museum catalogs, Wikipedia, etc. (`painting_annotations` table) |
|
||
| **Fullscreen** | Click the centre image; `Escape` or click anywhere to return to detail only |
|
||
|
||
**Controls:**
|
||
|
||
| Input | Action |
|
||
|-------|--------|
|
||
| `‹` / `›` beside image | Previous / next in the **current catalog** — artist chronology, movement chronology, or **tour stop order** |
|
||
| `←` / `→` | Same as prev / next (disabled while fullscreen is open) |
|
||
| Click centre image | Open fullscreen lightbox |
|
||
| Click influence thumbnail | Open that work’s detail (different artist allowed) |
|
||
| Click influence artist portrait | Open that artist’s 3D gallery hall |
|
||
| **← Back to Gallery** | Return to the hall or movement wing you entered from — **3D camera position is preserved** |
|
||
| **← Back to Timeline** | Return to the home timeline (from search result, or from the 3D gallery header / movement **Exit to Timeline**) — hall unmounts, timeline zoom resets |
|
||
| **About {artist}** | Open artist biography |
|
||
|
||
**Navigation rules:**
|
||
|
||
- **Catalog browsing** (‹ › / arrow keys) walks the active catalog (artist / movement chronology, or tour stops). It does **not** change the back target: after browsing several works, **Back to Gallery** still returns directly to the hall.
|
||
- **Influence links** push a new detail layer; **Back** from an influenced work returns to the painting you came from (and from there back to the gallery if applicable).
|
||
- Side-panel influence images use **`object-fit: contain`** so tall or wide works are not cropped (dark letterbox background).
|
||
- The 3D hall stays mounted in the background while detail is open so nothing is lost on return.
|
||
|
||
### Debug mode (developer)
|
||
|
||
**Curator login required.** Debug tools are hidden until you sign in from the home header (**Curator login**). After login, enable **Debug mode** from the same header area.
|
||
|
||
When debug mode is on, painting detail and artist biography show a bottom-left panel with image search preview and action buttons. See [Developer tools (image audit)](#developer-tools-image-audit).
|
||
|
||
Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens the **More** search-results modal automatically whenever you open a painting or artist bio while debug mode is on.
|
||
|
||
## Key design decisions
|
||
|
||
- **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 catalog** loads once from the API; pan/zoom is client-side only, with per-frame batching via `createViewChangeScheduler()`.
|
||
- **Movement flow interaction** uses simplified SVG and hides portrait DOM during active scroll/drag so zoom stays responsive over dense portrait fields.
|
||
- **Movement filtering** on zoom shows movements whose span overlaps the visible year range and that have at least one catalogued artist; a movement (e.g. Byzantine or Gothic viewed at 311–1231 CE) still appears even when all its artists lived outside the current window.
|
||
- **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.
|
||
- **Movement galleries** complement artist halls: full movement corpus in period-themed wings, entered from the flow diagram.
|
||
- **Guided tours** add curator-ordered winged halls with stop text on painting detail — [tours.md](tours.md).
|
||
- **Wall hang** is shared: first work on the left at the entrance, last on the right.
|
||
- **Influence-based hall links** connect artists through documented painting relationships, grouped by movement at the exit.
|
||
- **3D gallery images** use local **thumbnails** for hall frames (not multi-MB originals). `POST /api/artists/:id/preload-images` / `POST /api/movements/:id/preload-images` run in the background when entering a hall (public routes — link disk files and regenerate missing thumbs).
|
||
- **3D gallery session** stays mounted while painting detail or bio overlays are open; returning to the hall remounts the WebGL canvas when it becomes active again.
|
||
- **3D gallery resilience:** a lost WebGL context is recovered by remounting the canvas with a fresh context (rather than showing a dark window), and the HDR environment map is isolated behind an error boundary so its failure never blanks the scene.
|
||
- **Loading feedback:** `GalleryLoadingMarker` surfaces catalog load, portrait download, gallery entry, painting-texture / GPU warm-up, Environment settle, and context-restore states so the user always knows work is still in progress.
|
||
- **Influence data** is stored in **`painting_influence_sources`** (directed links from paintings to source paintings, artists, or movements), with optional period fields and citation metadata. Sources include curated scholarship (`art-influences-data.js`) and **PainterPalette** (`discovered_via = painter-palette`). Legacy `painting_influences` mirrors painting-to-painting edges for scripts only.
|
||
|
||
## User roles and access
|
||
|
||
| Role | Who | Can do |
|
||
|------|-----|--------|
|
||
| **`user`** | Anonymous visitor (default) | Browse timeline, movement flow, 3D artist/movement halls, painting detail, artist bios, images |
|
||
| **`curator`** | Named staff account | Public browse + tools allowed by their **permission flags** |
|
||
| **`admin`** | Named staff account | All curator tools + **Users** management + **Activity** audit reports |
|
||
|
||
**Permission flags:** `images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`. Admins always have every flag.
|
||
|
||
Staff sign in via **Curator login** in the site header (individual username/password). Sessions use an HTTP-only cookie (`gallery.sid`). The UI shows only tools the account may use; the server enforces the same rules (`401` without a session, `403` without permission).
|
||
|
||
Admins create and manage accounts on the **Users** page (`UsersPage.tsx` / `/api/users`). Bootstrap the first admin with `CURATOR_*` env vars + `npm run dev:migrate` (or `npm run dev:reset-curator`).
|
||
|
||
Mutating actions are appended to **`curator_audit_log`** with `user_id`, action, target id, optional JSON details, and client IP. Admins browse reports on the **Activity** page (`AuditPage.tsx` / `/api/audit`); the API reads whatever DB the server is connected to (`DB_NAME`: `gallery_dev` on dev, `gallery_prod` on prod). See [DB_structure.md](DB_structure.md#curator_audit_log).
|
||
|
||
## Developer tools (image audit)
|
||
|
||
Staff workflow for reviewing and fixing local image files (requires **`images`** permission) — not part of the public visitor experience.
|
||
|
||
| Feature | Where | Purpose |
|
||
|---------|--------|---------|
|
||
| **Catalog search** | Timeline header (all visitors) | Find artists, paintings, movements; `GET /api/search`; navigate to gallery or detail |
|
||
| **Curator login** | Home header (guests) | Username + password modal; unlocks permitted tools |
|
||
| **Debug mode** | Home header toggle (`images`) | Persists in `localStorage`; enables debug panel on painting detail and artist bio |
|
||
| **Show more** | Home header checkbox (`images`, when debug on) | Auto-opens the **More** modal on each painting / bio page load |
|
||
| **Checkup page** | Home header → **Checkup** (`checkup`) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags |
|
||
| **Translations** | Home header → **Translations** (`translations`) | Review/publish Russian `entity_translations` |
|
||
| **Influences** | Home header → **Influences** (`influences`) | List/CRUD influence edges, CSV/JSON/XLSX import wizard, neighborhood graph — [influence-import.md](influence-import.md) |
|
||
| **Tour editor** | Home header → **Tour editor** (`tours`) | Create/publish guided tours and stop text — [tours.md](tours.md) |
|
||
| **Users** | Home header → **Users** (`users` / admin) | Create staff accounts, roles, permissions, reset passwords, disable accounts |
|
||
| **Activity** | Home header → **Activity** (admin only) | Curator audit reports: filters, summary, dated action log from `curator_audit_log` (env DB) |
|
||
| **Tours** | Home header → **Tours** (everyone) | Open published tours in a 3D hall — [tours.md](tours.md) |
|
||
| **Logout** | Home header (staff) | Ends session; hides staff tools |
|
||
| **Debug panel** | Painting detail or artist bio (bottom-left, when debug mode on) | Search preview + action buttons (six on painting detail, five on artist bio) |
|
||
|
||
### Debug panel (painting detail and artist bio)
|
||
|
||
When debug mode is on, a panel at the bottom-left shows the image search query, a preview when a result is found, and action buttons in two or three rows:
|
||
|
||
| Button | Painting detail | Artist bio |
|
||
|--------|-----------------|------------|
|
||
| **Checked** | Sets `checkup_checked` via `PATCH …/checkup-flags` | Same for artist portrait flags |
|
||
| **Fix it** | Replaces full image from search result; **regenerates painting thumb** (~400px JPEG) from that file | Replaces portrait; **regenerates timeline thumb** (256px) |
|
||
| **More** | Modal with up to **20** results (resolution shown when known); thumb regenerated from chosen full image | Same |
|
||
| **Clear** | Deletes files, clears DB paths, empty frame | Clears portrait slot |
|
||
| **Upload** | Local file picker (`DebugUploadButton`) → full image + **auto-generated painting thumb**; full-page **Loading…** overlay; hides current image and pauses search/fix until upload finishes | Local file → portrait + **auto-generated portrait thumb**; same upload overlay behaviour |
|
||
| **Remove entry** | **Painting detail only** — deletes row from DB, removes image files, refreshes 3D gallery, navigates to next/previous work in catalog (or back to gallery if last work). No confirmation dialog. | — |
|
||
|
||
After **Fix it**, **More**, **Upload**, or **Clear**, the main view, gallery textures (paintings), and timeline portrait (artists) update without a full page reload. Painting and portrait thumbs under `data/images/*/thumbs/` are rebuilt on the server whenever a curator replaces the full image. **Remove entry** refetches artist (and movement gallery when relevant) from the API and remounts the 3D hall so the deleted frame disappears immediately.
|
||
|
||
Pressing **Upload** clears the debug search preview and closes **More** before the file picker opens. While uploading, **Fix it**, **More**, and **Checked** are disabled and the main painting/portrait is hidden behind a centered loading overlay.
|
||
|
||
Reviewed portraits show a gold border on the bio page; reviewed paintings use gold frames in the 3D hall. **Back to Gallery** returns to the live hall session, not a stale snapshot.
|
||
|
||
Influence side-panel thumbnails use **letterboxing** (`object-fit: contain`) so full compositions are visible.
|
||
|
||
### Checkup page
|
||
|
||
**Columns:** Gallery and Detail thumbnails, Search (reference image), Fix (replace local file), **Reviewed** (`checkup_checked`), **Fixed** (`checkup_fixed`).
|
||
|
||
**Search visible** runs image search only for rows currently shown after text/filter — not automatically on page load. Fixing an image sets **Fixed** and **Reviewed**.
|
||
|
||
Run `npm run dev:migrate:checkup-flags`, `npm run dev:migrate:artist-checkup-flags`, and `npm run dev:migrate:painting-annotations` once on existing databases. Load notes with `npm run dev:update-painting-annotations` (add `--wikipedia` for overview lines from Wikipedia intro text). After server code changes, restart `npm run dev:start` (or `npm run dev:server`) so new routes are registered. JSON body limit for uploads is **20 MB** (`express.json` in `server/index.js`); individual files are capped at **15 MB** after decode.
|
||
|
||
Server-side auth lives in `server/middleware/session.js`, `server/middleware/auth.js`, `server/routes/auth.js`, and `server/audit-log.js`. Client auth context: `client/src/context/AuthContext.tsx`.
|
||
|
||
See [API.md](API.md#authentication) and [data-and-images.md](data-and-images.md#duplicate-paintings).
|
||
|
||
## Related docs
|
||
|
||
| Document | Contents |
|
||
|----------|----------|
|
||
| [setup.md](setup.md) | Install, database, npm scripts |
|
||
| [deploy-dev-to-prod.md](deploy-dev-to-prod.md) | Release runbook + one-command `devtoprod:release` |
|
||
| [harmonize-dev-prod.md](harmonize-dev-prod.md) | Incremental dev ↔ prod merge (catalog DB + images) |
|
||
| [DB_structure.md](DB_structure.md) | Tables and relationships |
|
||
| [API.md](API.md) | REST endpoints |
|
||
| [influence-import.md](influence-import.md) | Curator Influences tool — import wizard, CRUD, graph |
|
||
| [tours.md](tours.md) | Guided tours — editor, public popup, 3D tour hall |
|
||
| [i18n-russian.md](i18n-russian.md) | Russian UI + entity_translations |
|
||
| [data-and-images.md](data-and-images.md) | Image pipeline and seeding |
|