# 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 or vertical bottom→up layout (header link). 2. **Movement flow** — art movements as SVG streams on the same year axis; documented predecessor→successor branches (classic); portrait thumbnails along each stream. 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 # Era bar, year ticks, event markers │ │ ├── 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/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 │ │ ├── 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 │ └── 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 two linked views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`). A header link switches between layouts: | Layout | Era rail | Movement flow | Time direction | |--------|----------|---------------|----------------| | **Classic** (default) | `Timeline.tsx` (top bar) | `MovementBands.tsx` | Left → right | | **Vertical** | `VerticalTimeline.tsx` (left rail) | `VerticalMovementBands.tsx` (streams + lineage branches; no portraits) | Bottom → top | | View | Component | Purpose | |------|-----------|---------| | Era bar / rail | `Timeline.tsx` / `VerticalTimeline.tsx` | Historical eras, major event markers, click-to-zoom | | Movement flow | `MovementBands.tsx` / `VerticalMovementBands.tsx` | Streams per movement, artist portraits (classic also draws lineage branches) | Both 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 places the year rail beside the flow (`home-timeline-stack-vertical`). ### 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 `` 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 |