Improve 3D gallery UX, placeholders, and image accuracy.

Add multi-row dynamic halls, eye-level camera, canvas covers for missing works, preserved view when returning from detail, and corrected image overrides for Kauffman and Raphael. Update documentation and add fetched painting assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-06-19 11:40:08 +03:00
co-authored by Cursor
parent 08f99d7a29
commit 792a0b1a77
42 changed files with 618 additions and 219 deletions
+17 -4
View File
@@ -80,17 +80,30 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi
| Rule | Implementation | | Rule | Implementation |
|------|----------------| |------|----------------|
| One hall per artist | `VirtualGallery.tsx` builds a single room from that artists paintings and periods | | One hall per artist | `VirtualGallery.tsx` builds a single room from that artists paintings |
| Paintings on walls | Works hang on the **back, left, and right** walls at eye level; periods are distributed across walls | | Paintings on walls | Works hang on the **back, left, and right** walls; the room **grows and uses multiple rows** when the catalog is large (e.g. 75+ works) |
| 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 columns, pedestals, or other centre objects | | Open centre | Floor and ceiling only — no columns, pedestals, or other centre objects |
| Single exit | One doorway on the **front wall**; walk to it or click it | | Single exit | One doorway on the **front wall**; walk to it or click it |
| Hall-to-hall travel | Exit opens a panel: **Predecessors** (left) and **Successors** (right), each grouped by art movement | | 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 |
**Controls:** `WASD` / arrow keys to move; click a painting to open its detail view. At the exit, click the doorway or press `E` to choose the next artist. **Controls:**
| 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 the **painting influence graph** (`painting_influences` → other artists). Empty lists mean no influence edges are recorded yet for that artist — run `npm run update-influences` or extend seed data. Predecessors and successors come from the **painting influence graph** (`painting_influences` → other artists). Empty lists mean no influence edges are recorded yet for that artist — run `npm run update-influences` or extend seed data.
**3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; call `POST /api/artists/:id/preload-images` before entering a hall to link disk files. **3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; call `POST /api/artists/:id/preload-images` before entering a hall to link disk files. While a texture is loading, the frame shows the canvas cover instead of a white placeholder.
## Key design decisions ## Key design decisions
+14 -1
View File
@@ -63,6 +63,7 @@ When no image is available:
- `/placeholder-portrait.svg` — timeline / movement band portraits - `/placeholder-portrait.svg` — timeline / movement band portraits
- `/placeholder-art.svg` — paintings in lists and detail view - `/placeholder-art.svg` — paintings in lists and detail view
- **3D gallery** — draped **canvas cover** inside the frame (`CanvasCover` in `VirtualGallery.tsx`); shown when there is no local file, the fetch failed, or the texture has not loaded yet
These live in `client/public/` (and `client/dist/` after build). These live in `client/public/` (and `client/dist/` after build).
@@ -75,4 +76,16 @@ These live in `client/public/` (and `client/dist/` after build).
## Image fetcher overrides ## Image fetcher overrides
`scripts/image-fetcher.js` includes hand-maintained overrides for ambiguous Wikipedia titles and direct URLs (e.g. works whose Commons name does not match the article title). Extend `PAINTING_WIKI_OVERRIDES` and `DIRECT_IMAGE_OVERRIDES` when automated resolution fails. `scripts/image-fetcher.js` includes hand-maintained overrides for ambiguous Wikipedia titles and direct URLs (e.g. works whose Commons name does not match the article title, or when museum search returns the wrong work). Extend these maps when automated resolution fails:
| Map | Use when |
|-----|----------|
| `PAINTING_WIKI_OVERRIDES` | DB / seed title should resolve to a different Wikipedia or Wikidata label |
| `DIRECT_IMAGE_OVERRIDES` | You know the exact Commons URL (bypasses Met / Art Institute false matches) |
Examples already in the repo:
- `Self-Portrait Hesitating` → Kauffman, Wikimedia Commons (National Trust)
- `Cherubs of the Sistine Madonna` → Raphaels putti detail, Wikimedia Commons
After adding an override, delete any wrong cached file under `data/images/paintings/` and re-run fetch or call the on-demand image endpoint for that painting.
+3 -1
View File
@@ -98,7 +98,9 @@ After clone: copy `.env.example` → `.env`, install dependencies, run `npm run
|---------|--------------|-----| |---------|--------------|-----|
| Empty timeline | DB not seeded | `npm run seed` | | Empty timeline | DB not seeded | `npm run seed` |
| 500 on all `/api/*` | Wrong `.env` or Postgres down | Check connection, logs | | 500 on all `/api/*` | Wrong `.env` or Postgres down | Check connection, logs |
| Black frames in 3D gallery | No local image for painting | `POST …/preload-images` or `npm run fetch-images` | | Black frames in 3D gallery | No local image for painting | `POST …/preload-images` or `npm run fetch-images`; missing works show a canvas cover |
| White/grey flicker on frames | Texture loading or z-fighting with wall | Rebuild client (`cd client && npm run build`); ensure latest `VirtualGallery.tsx` |
| Wrong painting image | Bad museum / search match | Add entry to `DIRECT_IMAGE_OVERRIDES` in `scripts/image-fetcher.js`, re-fetch file |
| Empty exit navigation lists | No `painting_influences` edges for artist | `npm run update-influences` or extend seed data | | Empty exit navigation lists | No `painting_influences` edges for artist | `npm run update-influences` or extend seed data |
| Default Vite page instead of gallery | `client/dist` missing or stale | `cd client && npm run build` | | Default Vite page instead of gallery | `client/dist` missing or stale | `cd client && npm run build` |
| Permission denied creating tables | `gallery` user lacks CREATE | Run admin grants, then migrate | | Permission denied creating tables | `gallery` user lacks CREATE | Run admin grants, then migrate |
+1 -1
View File
@@ -1,6 +1,6 @@
# Art Gallery # Art Gallery
Interactive virtual art gallery: zoomable historical timeline, art movement bands, one 3D hall per artist (wall-mounted works, influence-linked exits), and painting detail views. Interactive virtual art gallery: zoomable historical timeline, art movement bands, one 3D hall per artist (dynamic wall layout, canvas placeholders for missing works, influence-linked exits), and painting detail views with preserved gallery camera on return.
## Documentation ## Documentation
+20 -2
View File
@@ -50,7 +50,8 @@
} }
.gallery-back-btn, .gallery-back-btn,
.gallery-bio-btn { .gallery-bio-btn,
.gallery-exit-btn {
padding: 8px 16px; padding: 8px 16px;
border: 1px solid #c9a96e; border: 1px solid #c9a96e;
background: rgba(201, 169, 110, 0.15); background: rgba(201, 169, 110, 0.15);
@@ -62,8 +63,14 @@
transition: background 0.2s; transition: background 0.2s;
} }
.gallery-exit-btn {
border-color: #d4af37;
color: #f5e6c8;
}
.gallery-back-btn:hover, .gallery-back-btn:hover,
.gallery-bio-btn:hover { .gallery-bio-btn:hover,
.gallery-exit-btn:hover {
background: rgba(201, 169, 110, 0.35); background: rgba(201, 169, 110, 0.35);
} }
@@ -71,6 +78,12 @@
flex: 1; flex: 1;
min-height: 0; min-height: 0;
position: relative; position: relative;
cursor: grab;
touch-action: none;
}
.gallery-canvas-container.gallery-canvas-dragging {
cursor: grabbing;
} }
.gallery-loading-overlay { .gallery-loading-overlay {
@@ -187,6 +200,11 @@
pointer-events: none; pointer-events: none;
} }
.gallery-exit-hint strong {
color: #d4af37;
font-weight: normal;
}
.gallery-exit-hint kbd { .gallery-exit-hint kbd {
padding: 1px 6px; padding: 1px 6px;
border: 1px solid rgba(201, 169, 110, 0.5); border: 1px solid rgba(201, 169, 110, 0.5);
+451 -147
View File
@@ -14,6 +14,7 @@ import './VirtualGallery.css';
interface Props { interface Props {
data: ArtistDetail; data: ArtistDetail;
active?: boolean;
onPaintingClick: (paintingId: number) => void; onPaintingClick: (paintingId: number) => void;
onNavigateArtist: (artistId: number) => void; onNavigateArtist: (artistId: number) => void;
onBack: () => void; onBack: () => void;
@@ -22,17 +23,23 @@ interface Props {
const WALL_HEIGHT = 4.2; const WALL_HEIGHT = 4.2;
const WALL_THICKNESS = 0.18; const WALL_THICKNESS = 0.18;
const MOUNT_OFFSET = 0.06; const MOUNT_OFFSET = 0.16;
const WALL_STANDOFF = 0.07;
const BACK_WALL_EXTRA = 0.05;
const FRAME_FACE_Z = 0.018;
const EYE_HEIGHT = 1.65; const EYE_HEIGHT = 1.65;
const HANG_HEIGHT = 1.55;
const FRAME_GAP = 0.18; const FRAME_GAP = 0.18;
const MIN_FRAME_W = 0.45; const MIN_FRAME_W = 0.45;
const MAX_FRAME_W = 1.05; const MAX_FRAME_W = 1.05;
const MAX_FRAME_H = 1.35; const MAX_FRAME_H = 1.35;
const MIN_HALL_SIZE = 9; const MIN_HALL_SIZE = 9;
const MAX_FRAMES_PER_WALL = 10; const ROW_GAP = 0.2;
const WALL_PADDING = 1.4;
const DOOR_WIDTH = 2.4; const DOOR_WIDTH = 2.4;
const DOOR_HEIGHT = 2.5; const DOOR_HEIGHT = 2.5;
const TURN_SPEED = 0.032;
const MOUSE_TURN_SENSITIVITY = 0.004;
const DRAG_START_THRESHOLD_PX = 5;
type WallSide = 'back' | 'left' | 'right'; type WallSide = 'back' | 'left' | 'right';
@@ -41,6 +48,7 @@ interface FrameSlot {
rotationY: number; rotationY: number;
maxW: number; maxW: number;
maxH: number; maxH: number;
side: WallSide;
} }
interface WallSegment { interface WallSegment {
@@ -74,115 +82,185 @@ function layoutRow(count: number, span: number) {
}); });
} }
return { slots, spanNeeded: Math.max(span, rowWidth + padding) }; return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) };
}
function wallRowHeights(count: number, span: number, rows: number) {
const perRow = Math.ceil(count / rows);
const heights: number[] = [];
for (let r = 0; r < rows; r++) {
const inRow = Math.min(perRow, count - r * perRow);
const { slots } = layoutRow(inRow, span);
heights.push(slots[0]?.maxH ?? MAX_FRAME_H);
}
return heights;
}
function wallStackHeight(heights: number[]) {
return heights.reduce((sum, h, i) => sum + h + (i > 0 ? ROW_GAP : 0), 0);
}
function fitsOnWall(count: number, span: number, rows: number) {
const perRow = Math.ceil(count / rows);
const available = span - WALL_PADDING;
const frameW = Math.min(
MAX_FRAME_W,
(available - (perRow - 1) * FRAME_GAP) / Math.max(perRow, 1)
);
if (frameW < MIN_FRAME_W) return false;
const totalHeight = wallStackHeight(wallRowHeights(count, span, rows));
const bottom = EYE_HEIGHT - totalHeight / 2;
const top = EYE_HEIGHT + totalHeight / 2;
if (bottom < 0.35) return false;
if (top > WALL_HEIGHT - 0.45) return false;
return true;
}
function rowCountForWall(count: number, span: number) {
for (let rows = 1; rows <= count; rows++) {
if (fitsOnWall(count, span, rows)) return rows;
}
return count;
}
function minSpanForWall(count: number) {
if (count === 0) return MIN_HALL_SIZE;
let best = Infinity;
for (let rows = 1; rows <= count; rows++) {
const perRow = Math.ceil(count / rows);
const span = perRow * MIN_FRAME_W + (perRow - 1) * FRAME_GAP + WALL_PADDING;
if (fitsOnWall(count, span, rows)) {
best = Math.min(best, span);
}
}
return Math.max(MIN_HALL_SIZE, best === Infinity ? MIN_HALL_SIZE : best);
}
function distributePaintingsAcrossWalls(paintings: Painting[]) {
const sorted = [...paintings].sort((a, b) => (a.year || 0) - (b.year || 0));
const walls: Painting[][] = [[], [], []];
sorted.forEach((p, i) => walls[i % 3].push(p));
return walls;
}
function wallLabelForPaintings(wallPaintings: Painting[], periods: ArtistPeriod[]) {
const periodIds = new Set(
wallPaintings.map((p) => p.period_id).filter((id): id is number => id != null && id !== 0)
);
const names = periods.filter((p) => periodIds.has(p.id)).map((p) => p.name);
const hasUnassigned = wallPaintings.some((p) => !p.period_id);
if (names.length > 0 && hasUnassigned) return `${names.join(' · ')} · Other`;
if (names.length > 0) return names.join(' · ');
if (hasUnassigned) return 'Other Works';
return wallPaintings.length > 0 ? 'Works' : '';
}
function layoutWallSlots(
paintings: Painting[],
span: number,
side: WallSide,
halfW: number,
halfD: number,
inset: number
): FrameSlot[] {
const count = paintings.length;
if (count === 0) return [];
const rows = rowCountForWall(count, span);
const perRow = Math.ceil(count / rows);
const slots: FrameSlot[] = [];
const rowLayouts = [];
for (let r = 0; r < rows; r++) {
const rowStart = r * perRow;
const inRow = Math.min(perRow, count - rowStart);
rowLayouts.push(layoutRow(inRow, span));
}
const rowHeights = rowLayouts.map((row) => row.slots[0]?.maxH ?? MAX_FRAME_H);
const totalHeight = wallStackHeight(rowHeights);
let y = EYE_HEIGHT - totalHeight / 2 + rowHeights[0] / 2;
for (let r = 0; r < rows; r++) {
const { slots: rowSlots } = rowLayouts[r];
const rowFrameH = rowHeights[r];
for (let i = 0; i < rowSlots.length; i++) {
const s = rowSlots[i];
if (side === 'back') {
slots.push({
maxW: s.maxW,
maxH: s.maxH,
rotationY: 0,
side,
position: [s.offset, y, -halfD + inset + WALL_STANDOFF + BACK_WALL_EXTRA],
});
} else if (side === 'left') {
slots.push({
maxW: s.maxW,
maxH: s.maxH,
rotationY: Math.PI / 2,
side,
position: [-halfW + inset + WALL_STANDOFF, y, s.offset],
});
} else {
slots.push({
maxW: s.maxW,
maxH: s.maxH,
rotationY: -Math.PI / 2,
side,
position: [halfW - inset - WALL_STANDOFF, y, s.offset],
});
}
}
y += rowFrameH + ROW_GAP;
}
return slots;
} }
function buildHallLayout(paintings: Painting[], periods: ArtistPeriod[]): HallLayout { function buildHallLayout(paintings: Painting[], periods: ArtistPeriod[]): HallLayout {
const byPeriod = new Map<number, Painting[]>();
for (const p of paintings) {
const key = p.period_id || 0;
const list = byPeriod.get(key) || [];
list.push(p);
byPeriod.set(key, list);
}
const periodGroups: { label: string; paintings: Painting[] }[] = [];
for (const period of periods) {
const list = byPeriod.get(period.id) || [];
if (list.length > 0) {
periodGroups.push({
label: period.name,
paintings: [...list].sort((a, b) => (a.year || 0) - (b.year || 0)),
});
}
}
const other = byPeriod.get(0) || [];
if (other.length > 0) {
periodGroups.push({
label: 'Other Works',
paintings: [...other].sort((a, b) => (a.year || 0) - (b.year || 0)),
});
}
if (periodGroups.length === 0 && paintings.length > 0) {
periodGroups.push({
label: 'Works',
paintings: [...paintings].sort((a, b) => (a.year || 0) - (b.year || 0)),
});
}
const walls: WallSide[] = ['back', 'left', 'right']; const walls: WallSide[] = ['back', 'left', 'right'];
const wallBuckets: { label: string; paintings: Painting[] }[][] = [[], [], []]; const wallPaintings = distributePaintingsAcrossWalls(paintings);
periodGroups.forEach((group, i) => { let width = minSpanForWall(wallPaintings[0].length);
wallBuckets[i % 3].push(group); let depth = Math.max(minSpanForWall(wallPaintings[1].length), minSpanForWall(wallPaintings[2].length));
});
let width = MIN_HALL_SIZE; for (let i = 0; i < 24; i++) {
let depth = MIN_HALL_SIZE; const nextWidth = minSpanForWall(wallPaintings[0].length);
const segments: WallSegment[] = []; const nextDepth = Math.max(
minSpanForWall(wallPaintings[1].length),
const addWallFrames = (side: WallSide, groups: { label: string; paintings: Painting[] }[]) => { minSpanForWall(wallPaintings[2].length)
if (groups.length === 0) return; );
if (nextWidth === width && nextDepth === depth) break;
const flat: Painting[] = []; width = nextWidth;
const labels: string[] = []; depth = nextDepth;
for (const g of groups) { }
for (const p of g.paintings) {
if (flat.length >= MAX_FRAMES_PER_WALL) break;
flat.push(p);
}
if (flat.length <= MAX_FRAMES_PER_WALL) labels.push(g.label);
}
const label = labels.join(' · ');
const span = side === 'back' ? width : depth;
const { spanNeeded } = layoutRow(flat.length, span);
if (side === 'back') depth = Math.max(depth, spanNeeded);
else width = Math.max(width, spanNeeded);
segments.push({ side, label, paintings: flat, slots: [] });
};
walls.forEach((side, i) => addWallFrames(side, wallBuckets[i]));
width = Math.max(width, MIN_HALL_SIZE); width = Math.max(width, MIN_HALL_SIZE);
depth = Math.max(depth, MIN_HALL_SIZE); depth = Math.max(depth, MIN_HALL_SIZE);
const halfW = width / 2; const halfW = width / 2;
const halfD = depth / 2; const halfD = depth / 2;
const y = HANG_HEIGHT;
const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET; const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET;
for (const seg of segments) { const segments: WallSegment[] = walls.map((side, i) => ({
const row = layoutRow(seg.paintings.length, seg.side === 'back' ? width : depth); side,
label: wallLabelForPaintings(wallPaintings[i], periods),
seg.slots = row.slots.map((s) => { paintings: wallPaintings[i],
if (seg.side === 'back') { slots: layoutWallSlots(
return { wallPaintings[i],
maxW: s.maxW, side === 'back' ? width : depth,
maxH: s.maxH, side,
rotationY: 0, halfW,
position: [s.offset, y, -halfD + inset], halfD,
}; inset
} ),
if (seg.side === 'left') { }));
return {
maxW: s.maxW,
maxH: s.maxH,
rotationY: Math.PI / 2,
position: [-halfW + inset, y, s.offset],
};
}
return {
maxW: s.maxW,
maxH: s.maxH,
rotationY: -Math.PI / 2,
position: [halfW - inset, y, s.offset],
};
});
}
return { width, depth, segments }; return { width, depth, segments };
} }
@@ -197,6 +275,108 @@ function computeFrameSize(aspect: number, maxW: number, maxH: number) {
return { width: w, height: h }; return { width: w, height: h };
} }
function paintingHasGalleryImage(painting: Painting) {
return !!(painting.thumbnail_path || painting.image_path);
}
let canvasWeaveTexture: THREE.CanvasTexture | null = null;
function getCanvasWeaveTexture() {
if (canvasWeaveTexture) return canvasWeaveTexture;
const size = 256;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.fillStyle = '#ddd0b8';
ctx.fillRect(0, 0, size, size);
for (let y = 0; y < size; y += 4) {
for (let x = 0; x < size; x += 4) {
ctx.fillStyle = (x + y) % 8 === 0 ? '#c4b494' : '#e4dac8';
ctx.fillRect(x, y, 4, 4);
}
}
ctx.strokeStyle = 'rgba(72, 58, 42, 0.18)';
ctx.lineWidth = 1;
for (let i = 0; i <= size; i += 8) {
ctx.beginPath();
ctx.moveTo(i, 0);
ctx.lineTo(i, size);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, i);
ctx.lineTo(size, i);
ctx.stroke();
}
}
canvasWeaveTexture = new THREE.CanvasTexture(canvas);
canvasWeaveTexture.wrapS = THREE.RepeatWrapping;
canvasWeaveTexture.wrapT = THREE.RepeatWrapping;
canvasWeaveTexture.colorSpace = THREE.SRGBColorSpace;
return canvasWeaveTexture;
}
function CanvasCover({
width,
height,
frameDepth,
faceZ,
matBorder,
hovered,
}: {
width: number;
height: number;
frameDepth: number;
faceZ: number;
matBorder: number;
hovered: boolean;
}) {
const weave = useMemo(() => {
const tex = getCanvasWeaveTexture().clone();
tex.repeat.set(Math.max(3, width * 5), Math.max(3, height * 5));
return tex;
}, [width, height]);
useEffect(() => () => weave.dispose(), [weave]);
const cloth = hovered ? '#ddd3bc' : '#c4b494';
const z = frameDepth + faceZ;
return (
<group renderOrder={20}>
<mesh position={[0, 0, z]}>
<planeGeometry args={[width + matBorder * 1.6, height + matBorder * 1.6]} />
<meshBasicMaterial
map={weave}
color={cloth}
toneMapped={false}
depthWrite
polygonOffset
polygonOffsetFactor={-6}
polygonOffsetUnits={-6}
/>
</mesh>
<mesh position={[-width * 0.22, 0, z + 0.004]} rotation={[0, 0.22, 0.02]}>
<planeGeometry args={[width * 0.48, height * 0.98]} />
<meshBasicMaterial
map={weave}
color={hovered ? '#a89478' : '#958470'}
toneMapped={false}
transparent
opacity={0.55}
depthWrite={false}
polygonOffset
polygonOffsetFactor={-5}
polygonOffsetUnits={-5}
/>
</mesh>
</group>
);
}
function usePaintingTexture(url: string | null) { function usePaintingTexture(url: string | null) {
const [texture, setTexture] = useState<THREE.Texture | null>(null); const [texture, setTexture] = useState<THREE.Texture | null>(null);
const [failed, setFailed] = useState(!url); const [failed, setFailed] = useState(!url);
@@ -220,6 +400,12 @@ function usePaintingTexture(url: string | null) {
tex.dispose(); tex.dispose();
return; return;
} }
const img = tex.image as HTMLImageElement | undefined;
if (!img || img.width < 4 || img.height < 4) {
tex.dispose();
setFailed(true);
return;
}
loaded = tex; loaded = tex;
tex.colorSpace = THREE.SRGBColorSpace; tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 4; tex.anisotropy = 4;
@@ -247,6 +433,7 @@ function PaintingFrame({
rotationY, rotationY,
maxWidth, maxWidth,
maxHeight, maxHeight,
wallSide,
onClick, onClick,
}: { }: {
painting: Painting; painting: Painting;
@@ -254,6 +441,7 @@ function PaintingFrame({
rotationY: number; rotationY: number;
maxWidth: number; maxWidth: number;
maxHeight: number; maxHeight: number;
wallSide: WallSide;
onClick: () => void; onClick: () => void;
}) { }) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
@@ -261,15 +449,20 @@ function PaintingFrame({
const { width, height } = computeFrameSize(aspect, maxWidth, maxHeight); const { width, height } = computeFrameSize(aspect, maxWidth, maxHeight);
const frameDepth = 0.06; const frameDepth = 0.06;
const matBorder = 0.05; const matBorder = 0.05;
const url = galleryImageUrl(painting); const hasImage = paintingHasGalleryImage(painting);
const url = hasImage ? galleryImageUrl(painting) : null;
const { texture, failed } = usePaintingTexture(url); const { texture, failed } = usePaintingTexture(url);
const showImage = hasImage && !failed && !!texture;
const showCanvas = !showImage;
const faceZ = FRAME_FACE_Z + (wallSide === 'back' ? 0.012 : 0);
useEffect(() => { useEffect(() => {
if (!showImage) return;
const img = texture?.image as HTMLImageElement | undefined; const img = texture?.image as HTMLImageElement | undefined;
if (img?.width && img.height) { if (img?.width && img.height) {
setAspect(img.width / img.height); setAspect(img.width / img.height);
} }
}, [texture]); }, [texture, showImage]);
return ( return (
<group position={position} rotation={[0, rotationY, 0]}> <group position={position} rotation={[0, rotationY, 0]}>
@@ -277,14 +470,15 @@ function PaintingFrame({
position={[0, height / 2 + 0.25, 0.3]} position={[0, height / 2 + 0.25, 0.3]}
angle={0.5} angle={0.5}
penumbra={0.75} penumbra={0.75}
intensity={hovered ? 2.6 : 1.9} intensity={showCanvas ? (hovered ? 0.9 : 0.55) : hovered ? 2.6 : 1.9}
distance={4.5} distance={4.5}
color="#fff8ee" color={showCanvas ? '#e8dcc8' : '#fff8ee'}
/> />
<mesh <mesh
position={[0, 0, frameDepth / 2]} position={[0, 0, frameDepth / 2]}
castShadow castShadow
renderOrder={1}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onClick(); onClick();
@@ -293,22 +487,42 @@ function PaintingFrame({
onPointerOut={() => setHovered(false)} onPointerOut={() => setHovered(false)}
> >
<boxGeometry args={[width + matBorder * 2 + 0.04, height + matBorder * 2 + 0.04, frameDepth]} /> <boxGeometry args={[width + matBorder * 2 + 0.04, height + matBorder * 2 + 0.04, frameDepth]} />
<meshStandardMaterial color={hovered ? '#c9a227' : '#6b4f1d'} roughness={0.45} metalness={0.5} /> <meshStandardMaterial
</mesh> color={showCanvas ? (hovered ? '#8a7355' : '#5a4520') : hovered ? '#c9a227' : '#6b4f1d'}
roughness={0.45}
<mesh position={[0, 0, frameDepth + 0.002]}> metalness={showCanvas ? 0.15 : 0.5}
<boxGeometry args={[width + matBorder * 2, height + matBorder * 2, 0.008]} />
<meshStandardMaterial color="#f5f0e6" roughness={0.95} />
</mesh>
<mesh position={[0, 0, frameDepth + 0.006]} renderOrder={2}>
<planeGeometry args={[width, height]} />
<meshBasicMaterial
map={texture}
color={texture ? '#ffffff' : failed ? '#8a7355' : '#4a3828'}
toneMapped={false}
/> />
</mesh> </mesh>
{!showCanvas && (
<mesh position={[0, 0, frameDepth + 0.002]} renderOrder={2}>
<boxGeometry args={[width + matBorder * 2, height + matBorder * 2, 0.008]} />
<meshStandardMaterial color="#f5f0e6" roughness={0.95} />
</mesh>
)}
{showCanvas ? (
<CanvasCover
width={width}
height={height}
frameDepth={frameDepth}
faceZ={faceZ}
matBorder={matBorder}
hovered={hovered}
/>
) : (
<mesh position={[0, 0, frameDepth + faceZ]} renderOrder={20}>
<planeGeometry args={[width, height]} />
<meshBasicMaterial
map={texture}
color="#ffffff"
toneMapped={false}
polygonOffset
polygonOffsetFactor={-4}
polygonOffsetUnits={-4}
/>
</mesh>
)}
</group> </group>
); );
} }
@@ -326,7 +540,7 @@ function GalleryWall({
}) { }) {
const [w, h] = size; const [w, h] = size;
return ( return (
<mesh position={position} rotation={rotation} receiveShadow> <mesh position={position} rotation={rotation} receiveShadow renderOrder={0}>
<boxGeometry args={[w, h, WALL_THICKNESS]} /> <boxGeometry args={[w, h, WALL_THICKNESS]} />
<meshStandardMaterial color={color} roughness={0.92} /> <meshStandardMaterial color={color} roughness={0.92} />
</mesh> </mesh>
@@ -345,17 +559,14 @@ function ExitPortal({
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const glow = active || hovered; const glow = active || hovered;
const handleActivate = (e: THREE.Event & { stopPropagation: () => void }) => {
e.stopPropagation();
onActivate();
};
return ( return (
<group position={position}> <group position={position}>
<mesh <mesh position={[0, DOOR_HEIGHT / 2, 0]} castShadow>
position={[0, DOOR_HEIGHT / 2, 0]}
onClick={(e) => {
e.stopPropagation();
onActivate();
}}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<boxGeometry args={[DOOR_WIDTH, DOOR_HEIGHT, 0.12]} /> <boxGeometry args={[DOOR_WIDTH, DOOR_HEIGHT, 0.12]} />
<meshStandardMaterial <meshStandardMaterial
color={glow ? '#d4af37' : '#8b6914'} color={glow ? '#d4af37' : '#8b6914'}
@@ -365,11 +576,28 @@ function ExitPortal({
metalness={0.35} metalness={0.35}
/> />
</mesh> </mesh>
{/* Large invisible target — easier to click from anywhere in the hall */}
<mesh
position={[0, DOOR_HEIGHT / 2, -0.12]}
rotation={[0, Math.PI, 0]}
onClick={handleActivate}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<planeGeometry args={[DOOR_WIDTH * 3, DOOR_HEIGHT * 1.8]} />
<meshBasicMaterial transparent opacity={0} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
<Text <Text
position={[0, DOOR_HEIGHT + 0.25, 0.08]} position={[0, DOOR_HEIGHT + 0.25, -0.06]}
rotation={[0, Math.PI, 0]}
fontSize={0.22} fontSize={0.22}
color={glow ? '#f5e6c8' : '#c9a96e'} color={glow ? '#f5e6c8' : '#c9a96e'}
anchorX="center" anchorX="center"
onClick={handleActivate}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
> >
EXIT EXIT
</Text> </Text>
@@ -460,6 +688,7 @@ function ArtistHall({
rotationY={seg.slots[i].rotationY} rotationY={seg.slots[i].rotationY}
maxWidth={seg.slots[i].maxW} maxWidth={seg.slots[i].maxW}
maxHeight={seg.slots[i].maxH} maxHeight={seg.slots[i].maxH}
wallSide={seg.slots[i].side}
onClick={() => onPaintingClick(painting.id)} onClick={() => onPaintingClick(painting.id)}
/> />
))} ))}
@@ -493,6 +722,11 @@ function ArtistHall({
); );
} }
function levelHorizontalView(pos: THREE.Vector3, target: THREE.Vector3) {
pos.y = EYE_HEIGHT;
target.y = EYE_HEIGHT;
}
function CameraController({ function CameraController({
position, position,
target, target,
@@ -502,8 +736,9 @@ function CameraController({
}) { }) {
const { camera } = useThree(); const { camera } = useThree();
useFrame(() => { useFrame(() => {
levelHorizontalView(position, target);
camera.position.lerp(position, 0.12); camera.position.lerp(position, 0.12);
camera.lookAt(target); camera.lookAt(target.x, EYE_HEIGHT, target.z);
}); });
return null; return null;
} }
@@ -579,6 +814,7 @@ function NavigationPanel({
export default function VirtualGallery({ export default function VirtualGallery({
data, data,
active = true,
onPaintingClick, onPaintingClick,
onNavigateArtist, onNavigateArtist,
onBack, onBack,
@@ -592,6 +828,7 @@ export default function VirtualGallery({
const [navigation, setNavigation] = useState<ArtistNavigation | null>(null); const [navigation, setNavigation] = useState<ArtistNavigation | null>(null);
const [navLoading, setNavLoading] = useState(false); const [navLoading, setNavLoading] = useState(false);
const [nearExit, setNearExit] = useState(false); const [nearExit, setNearExit] = useState(false);
const [isLooking, setIsLooking] = useState(false);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -632,13 +869,15 @@ export default function VirtualGallery({
[layout.depth] [layout.depth]
); );
const initialTarget = useMemo( const initialTarget = useMemo(
() => new THREE.Vector3(0, HANG_HEIGHT, -layout.depth / 4), () => new THREE.Vector3(0, EYE_HEIGHT, -layout.depth / 4),
[layout.depth] [layout.depth]
); );
const [camPos, setCamPos] = useState(() => initialPos.clone()); const [camPos, setCamPos] = useState(() => initialPos.clone());
const [camTarget, setCamTarget] = useState(() => initialTarget.clone()); const [camTarget, setCamTarget] = useState(() => initialTarget.clone());
const keysPressed = useRef<Set<string>>(new Set()); const keysPressed = useRef<Set<string>>(new Set());
const dragTurnActive = useRef(false);
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const camPosRef = useRef(camPos); const camPosRef = useRef(camPos);
const camTargetRef = useRef(camTarget); const camTargetRef = useRef(camTarget);
camPosRef.current = camPos; camPosRef.current = camPos;
@@ -688,6 +927,8 @@ export default function VirtualGallery({
pos.z = Math.max(-halfD, Math.min(exitZ, pos.z)); pos.z = Math.max(-halfD, Math.min(exitZ, pos.z));
target.z = Math.max(-halfD, Math.min(exitZ, target.z)); target.z = Math.max(-halfD, Math.min(exitZ, target.z));
levelHorizontalView(pos, target);
setCamPos(pos); setCamPos(pos);
setCamTarget(target); setCamTarget(target);
@@ -698,9 +939,19 @@ export default function VirtualGallery({
); );
useEffect(() => { useEffect(() => {
if (active) return;
keysPressed.current.clear();
dragTurnActive.current = false;
dragStartRef.current = null;
setIsLooking(false);
}, [active]);
useEffect(() => {
if (!active) return;
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
keysPressed.current.add(e.key); keysPressed.current.add(e.key);
if ((e.key === 'e' || e.key === 'E') && nearExit && !showExitNav) { if ((e.key === 'e' || e.key === 'E') && !showExitNav) {
openExitNav(); openExitNav();
} }
}; };
@@ -710,10 +961,14 @@ export default function VirtualGallery({
const interval = setInterval(() => { const interval = setInterval(() => {
const keys = keysPressed.current; const keys = keysPressed.current;
if (keys.has('ArrowUp') || keys.has('w')) moveCamera(-0.1, 0, 0); if (keys.has('ArrowUp') || keys.has('w') || keys.has('W')) moveCamera(0.1, 0, 0);
if (keys.has('ArrowDown') || keys.has('s')) moveCamera(0.1, 0, 0); if (keys.has('ArrowDown') || keys.has('s') || keys.has('S')) moveCamera(-0.1, 0, 0);
if (keys.has('ArrowLeft') || keys.has('a')) moveCamera(0, -0.08, 0); if (keys.has('ArrowLeft') || keys.has('a') || keys.has('A') || keys.has('q') || keys.has('Q')) {
if (keys.has('ArrowRight') || keys.has('d')) moveCamera(0, 0.08, 0); moveCamera(0, 0, TURN_SPEED);
}
if (keys.has('ArrowRight') || keys.has('d') || keys.has('D')) {
moveCamera(0, 0, -TURN_SPEED);
}
}, 16); }, 16);
return () => { return () => {
@@ -721,13 +976,45 @@ export default function VirtualGallery({
window.removeEventListener('keyup', onKeyUp); window.removeEventListener('keyup', onKeyUp);
clearInterval(interval); clearInterval(interval);
}; };
}, [moveCamera, nearExit, showExitNav, openExitNav]); }, [active, moveCamera, showExitNav, openExitNav]);
const handleNavigate = (artistId: number) => { const handleNavigate = (artistId: number) => {
setShowExitNav(false); setShowExitNav(false);
onNavigateArtist(artistId); onNavigateArtist(artistId);
}; };
const handleCanvasPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!active || showExitNav || e.button !== 0) return;
dragStartRef.current = { x: e.clientX, y: e.clientY };
};
const handleCanvasPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!active || showExitNav || !dragStartRef.current) return;
const dx = e.clientX - dragStartRef.current.x;
const dy = e.clientY - dragStartRef.current.y;
const distSq = dx * dx + dy * dy;
if (!dragTurnActive.current && distSq >= DRAG_START_THRESHOLD_PX * DRAG_START_THRESHOLD_PX) {
dragTurnActive.current = true;
setIsLooking(true);
e.currentTarget.setPointerCapture(e.pointerId);
}
if (dragTurnActive.current && e.movementX !== 0) {
moveCamera(0, 0, e.movementX * MOUSE_TURN_SENSITIVITY);
}
};
const endCanvasDrag = (e: React.PointerEvent<HTMLDivElement>) => {
dragStartRef.current = null;
dragTurnActive.current = false;
setIsLooking(false);
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
};
return ( return (
<div className="virtual-gallery"> <div className="virtual-gallery">
<div className="gallery-header"> <div className="gallery-header">
@@ -737,20 +1024,36 @@ export default function VirtualGallery({
<p className="gallery-career-path">Personal hall · {paintings.length} works on the walls</p> <p className="gallery-career-path">Personal hall · {paintings.length} works on the walls</p>
</div> </div>
<div className="gallery-header-meta"> <div className="gallery-header-meta">
<button type="button" className="gallery-exit-btn" onClick={openExitNav}>
Exit
</button>
<button className="gallery-bio-btn" onClick={onBioClick}>Biography</button> <button className="gallery-bio-btn" onClick={onBioClick}>Biography</button>
</div> </div>
</div> </div>
<div className="gallery-canvas-container"> <div
className={`gallery-canvas-container${isLooking ? ' gallery-canvas-dragging' : ''}`}
onPointerDown={handleCanvasPointerDown}
onPointerMove={handleCanvasPointerMove}
onPointerUp={endCanvasDrag}
onPointerLeave={endCanvasDrag}
onPointerCancel={endCanvasDrag}
>
{syncStatus && ( {syncStatus && (
<div className="gallery-loading-overlay gallery-sync-badge"> <div className="gallery-loading-overlay gallery-sync-badge">
<p>{syncStatus}</p> <p>{syncStatus}</p>
</div> </div>
)} )}
{nearExit && !showExitNav && ( {!showExitNav && (
<div className="gallery-exit-hint">At the exit click the doorway or press <kbd>E</kbd></div> <div className="gallery-exit-hint">
Click the exit door, <kbd>E</kbd>, or <strong>Exit </strong> above
</div>
)} )}
<Canvas shadows camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}> <Canvas
shadows
frameloop={active ? 'always' : 'never'}
camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}
>
<color attach="background" args={['#0d0906']} /> <color attach="background" args={['#0d0906']} />
<fog attach="fog" args={['#0d0906', 18, 55]} /> <fog attach="fog" args={['#0d0906', 18, 55]} />
<ambientLight intensity={0.42} /> <ambientLight intensity={0.42} />
@@ -782,19 +1085,20 @@ export default function VirtualGallery({
<div className="control-pad"> <div className="control-pad">
<button onClick={() => moveCamera(-0.35, 0, 0)} title="Walk forward"></button> <button onClick={() => moveCamera(-0.35, 0, 0)} title="Walk forward"></button>
<div className="control-row"> <div className="control-row">
<button onClick={() => moveCamera(0, -0.28, 0)} title="Step left"></button> <button onClick={() => moveCamera(0, 0, TURN_SPEED * 4)} title="Turn left"></button>
<button onClick={() => moveCamera(0.35, 0, 0)} title="Walk toward exit"></button> <button onClick={() => moveCamera(0.35, 0, 0)} title="Walk back"></button>
<button onClick={() => moveCamera(0, 0.28, 0)} title="Step right"></button> <button onClick={() => moveCamera(0, 0, -TURN_SPEED * 4)} title="Turn right"></button>
</div> </div>
</div> </div>
<div className="gallery-instructions"> <div className="gallery-instructions">
<h4>{artist.name}&apos;s Hall</h4> <h4>{artist.name}&apos;s Hall</h4>
<ul> <ul>
<li><kbd>W</kbd> / <kbd></kbd> Walk into the room</li> <li><kbd>W</kbd> / <kbd></kbd> Walk forward</li>
<li><kbd>S</kbd> / <kbd></kbd> Walk toward the exit</li> <li><kbd>S</kbd> / <kbd></kbd> Walk back</li>
<li><kbd>A</kbd> / <kbd>D</kbd> Step sideways along the walls</li> <li><kbd>A</kbd> / <kbd></kbd> / <kbd>Q</kbd> Turn left</li>
<li>Paintings hang on three walls the centre stays open</li> <li><kbd>D</kbd> / <kbd></kbd> Turn right</li>
<li>Click a painting for details, or use the exit to visit related artists</li> <li>Drag on the view to look around</li>
<li>Click the exit door or <kbd>E</kbd> to visit related artists</li>
</ul> </ul>
</div> </div>
</div> </div>
+14
View File
@@ -3,6 +3,20 @@
background: linear-gradient(180deg, #0f0f1a 0%, #1a1a2e 40%, #16213e 100%); background: linear-gradient(180deg, #0f0f1a 0%, #1a1a2e 40%, #16213e 100%);
} }
.gallery-session-suspended {
position: fixed;
inset: 0;
z-index: 0;
visibility: hidden;
pointer-events: none;
}
.home-overlay {
position: relative;
z-index: 10;
min-height: 100vh;
}
.site-header { .site-header {
text-align: center; text-align: center;
padding: 24px 16px 8px; padding: 24px 16px 8px;
+84 -63
View File
@@ -16,6 +16,9 @@ type View =
export default function HomePage() { export default function HomePage() {
const [view, setView] = useState<View>({ type: 'timeline' }); const [view, setView] = useState<View>({ type: 'timeline' });
const [gallerySession, setGallerySession] = useState<{ artistId: number; data: ArtistDetail } | null>(
null
);
const [bounds, setBounds] = useState({ min: -800, max: 2025 }); const [bounds, setBounds] = useState({ min: -800, max: 2025 });
const [viewStart, setViewStart] = useState(-800); const [viewStart, setViewStart] = useState(-800);
const [viewEnd, setViewEnd] = useState(2025); const [viewEnd, setViewEnd] = useState(2025);
@@ -36,6 +39,14 @@ export default function HomePage() {
.catch(() => {}); .catch(() => {});
}, []); }, []);
useEffect(() => {
if (view.type === 'gallery') {
setGallerySession({ artistId: view.artistId, data: view.data });
} else if (view.type === 'timeline') {
setGallerySession(null);
}
}, [view]);
const loadTimelineData = useCallback(async (start: number, end: number) => { const loadTimelineData = useCallback(async (start: number, end: number) => {
try { try {
setLoading(true); setLoading(true);
@@ -65,6 +76,7 @@ export default function HomePage() {
const handleArtistClick = async (artistId: number) => { const handleArtistClick = async (artistId: number) => {
try { try {
const data = await api.getArtist(artistId); const data = await api.getArtist(artistId);
setGallerySession({ artistId, data });
setView({ type: 'gallery', artistId, data }); setView({ type: 'gallery', artistId, data });
} catch { } catch {
setError('Failed to load artist gallery.'); setError('Failed to load artist gallery.');
@@ -89,71 +101,80 @@ export default function HomePage() {
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo }); setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo });
}; };
if (view.type === 'gallery') { const galleryActive = view.type === 'gallery';
return (
<VirtualGallery
data={view.data}
onPaintingClick={handlePaintingClick}
onNavigateArtist={handleArtistClick}
onBack={() => setView({ type: 'timeline' })}
onBioClick={() => handleBioClick(view.data, view)}
/>
);
}
if (view.type === 'painting') {
return (
<PaintingDetailView
data={view.data}
onBack={() => setView(view.returnTo)}
onPaintingClick={handlePaintingClick}
onArtistBio={async () => {
const artistData = await api.getArtist(view.data.painting.artist_id);
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view });
}}
/>
);
}
if (view.type === 'bio') {
return (
<ArtistBio
artist={view.data.artist}
onBack={() => setView(view.returnTo)}
onEnterGallery={() => setView({ type: 'gallery', artistId: view.artistId, data: view.data })}
/>
);
}
return ( return (
<div className="home-page"> <>
<header className="site-header"> {gallerySession && (
<h1>Virtual Art Gallery</h1> <div className={galleryActive ? undefined : 'gallery-session-suspended'} aria-hidden={!galleryActive}>
<p className="site-subtitle">Explore the flowing connections of art history</p> <VirtualGallery
</header> data={gallerySession.data}
active={galleryActive}
<Timeline onPaintingClick={handlePaintingClick}
eras={timelineData.eras} onNavigateArtist={handleArtistClick}
viewStart={viewStart} onBack={() => setView({ type: 'timeline' })}
viewEnd={viewEnd} onBioClick={() => handleBioClick(gallerySession.data, { type: 'gallery', ...gallerySession })}
onViewChange={handleViewChange} />
absoluteMin={bounds.min} </div>
absoluteMax={bounds.max}
/>
{error && <div className="error-banner">{error}</div>}
{loading ? (
<div className="loading">Loading art history...</div>
) : (
<MovementBands
movements={timelineData.movements}
artists={artists}
viewStart={viewStart}
viewEnd={viewEnd}
onArtistClick={handleArtistClick}
/>
)} )}
</div>
{view.type === 'painting' && (
<div className="home-overlay">
<PaintingDetailView
data={view.data}
onBack={() => setView(view.returnTo)}
onPaintingClick={handlePaintingClick}
onArtistBio={async () => {
const artistData = await api.getArtist(view.data.painting.artist_id);
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view });
}}
/>
</div>
)}
{view.type === 'bio' && (
<div className="home-overlay">
<ArtistBio
artist={view.data.artist}
onBack={() => setView(view.returnTo)}
onEnterGallery={() =>
setView({ type: 'gallery', artistId: view.artistId, data: view.data })
}
/>
</div>
)}
{view.type === 'timeline' && (
<div className="home-page">
<header className="site-header">
<h1>Virtual Art Gallery</h1>
<p className="site-subtitle">Explore the flowing connections of art history</p>
</header>
<Timeline
eras={timelineData.eras}
viewStart={viewStart}
viewEnd={viewEnd}
onViewChange={handleViewChange}
absoluteMin={bounds.min}
absoluteMax={bounds.max}
/>
{error && <div className="error-banner">{error}</div>}
{loading ? (
<div className="loading">Loading art history...</div>
) : (
<MovementBands
movements={timelineData.movements}
artists={artists}
viewStart={viewStart}
viewEnd={viewEnd}
onArtistClick={handleArtistClick}
/>
)}
</div>
)}
</>
); );
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

+14
View File
@@ -15,6 +15,7 @@ const PAINTING_WIKI_OVERRIDES = {
'Charing Cross Bridge': 'Charing Cross Bridge (Derain)', 'Charing Cross Bridge': 'Charing Cross Bridge (Derain)',
'Cut with the Dada Kitchen Knife': 'Cut with the Dada Kitchen Knife':
'Cut with the Dada Kitchen Knife through the Last Weimar Beer-Belly Cultural Epoch in Germany', 'Cut with the Dada Kitchen Knife through the Last Weimar Beer-Belly Cultural Epoch in Germany',
'Cherubs of the Sistine Madonna': "Raphael's Cherubs",
}; };
const DIRECT_IMAGE_OVERRIDES = { const DIRECT_IMAGE_OVERRIDES = {
@@ -23,6 +24,19 @@ const DIRECT_IMAGE_OVERRIDES = {
fullUrl: 'https://upload.wikimedia.org/wikipedia/en/d/dd/The_Persistence_of_Memory.jpg', fullUrl: 'https://upload.wikimedia.org/wikipedia/en/d/dd/The_Persistence_of_Memory.jpg',
source: 'Wikimedia Commons (Museum of Modern Art, New York)', source: 'Wikimedia Commons (Museum of Modern Art, New York)',
}, },
'Self-Portrait Hesitating': {
thumbUrl:
'https://upload.wikimedia.org/wikipedia/commons/thumb/2/2c/Angelica_Kauffman._Self-Portrait_Hesitating_Between_the_Arts_of_Music_and_Painting.jpg/960px-Angelica_Kauffman._Self-Portrait_Hesitating_Between_the_Arts_of_Music_and_Painting.jpg',
fullUrl:
'https://upload.wikimedia.org/wikipedia/commons/2/2c/Angelica_Kauffman._Self-Portrait_Hesitating_Between_the_Arts_of_Music_and_Painting.jpg',
source: 'Wikimedia Commons (National Trust, Nostell Priory)',
},
'Cherubs of the Sistine Madonna': {
thumbUrl:
'https://upload.wikimedia.org/wikipedia/commons/thumb/5/54/Raffaels_Angels.jpg/960px-Raffaels_Angels.jpg',
fullUrl: 'https://upload.wikimedia.org/wikipedia/commons/5/54/Raffaels_Angels.jpg',
source: 'Wikimedia Commons (detail from Sistine Madonna, Gemäldegalerie Alte Meister)',
},
}; };
function sleep(ms) { function sleep(ms) {