Add movement gallery wings with period interiors and expand timeline features.

Movement galleries split large catalogs into chronological wings (~55 works), use era-themed 3D interiors with side-wall windows, wing navigator on the back exit, and front archways between wings. Also adds painting annotations, timeline event guides, portrait hover highlights, and documentation/API updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-06-21 16:54:19 +03:00
co-authored by Cursor
parent 0972b5df99
commit df29848d89
121 changed files with 4765 additions and 396 deletions
+5
View File
@@ -3,6 +3,7 @@ import type {
YearBounds,
Artist,
ArtistDetail,
MovementGalleryDetail,
PaintingDetail,
ArtistNavigation,
} from '../types';
@@ -126,6 +127,8 @@ export interface DebugImageSearchResultItem {
imageUrl: string;
thumbUrl?: string;
source: string;
width?: number;
height?: number;
}
export interface DebugImageSearchManyResult {
@@ -173,6 +176,8 @@ export const api = {
getArtist: (id: number) => fetchJson<ArtistDetail>(`${API}/artists/${id}`),
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(`${API}/movements/${id}/gallery`),
getArtistNavigation: (id: number) =>
fetchJson<ArtistNavigation>(`${API}/artists/${id}/navigation`),
@@ -87,7 +87,8 @@
.debug-search-modal-item {
position: relative;
aspect-ratio: 1;
display: flex;
flex-direction: column;
padding: 0;
border: 2px solid rgba(201, 169, 110, 0.35);
border-radius: 6px;
@@ -96,6 +97,13 @@
overflow: hidden;
}
.debug-search-modal-item-media {
position: relative;
aspect-ratio: 1;
width: 100%;
background: #2a1f15;
}
.debug-search-modal-item:hover:not(:disabled) {
border-color: #e8a040;
box-shadow: 0 0 0 1px rgba(232, 160, 64, 0.35);
@@ -117,6 +125,18 @@
display: block;
}
.debug-search-modal-item-resolution {
display: block;
padding: 5px 6px;
font-size: 10px;
font-weight: 600;
line-height: 1.2;
text-align: center;
color: rgba(232, 213, 181, 0.9);
background: rgba(0, 0, 0, 0.45);
border-top: 1px solid rgba(201, 169, 110, 0.2);
}
.debug-search-modal-item-index {
position: absolute;
top: 4px;
@@ -1,7 +1,57 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { debugImageProxyUrl, type DebugImageSearchManyResult, type DebugImageSearchResultItem } from '../api/client';
import './DebugSearchResultsModal.css';
function formatResolution(width?: number, height?: number): string | null {
if (!width || !height || width <= 0 || height <= 0) return null;
return `${width} × ${height}`;
}
function ResultResolution({
item,
searchUrl,
}: {
item: DebugImageSearchResultItem;
searchUrl: string;
}) {
const initial = formatResolution(item.width, item.height);
const [label, setLabel] = useState<string | null>(initial);
useEffect(() => {
if (initial) {
setLabel(initial);
return;
}
let cancelled = false;
const img = new Image();
img.onload = () => {
if (cancelled) return;
const text = formatResolution(img.naturalWidth, img.naturalHeight);
setLabel(text ?? '—');
};
img.onerror = () => {
if (!cancelled) setLabel('—');
};
img.src = debugImageProxyUrl(item.imageUrl, {
searchUrl,
source: item.source,
});
return () => {
cancelled = true;
img.onload = null;
img.onerror = null;
};
}, [item.imageUrl, item.source, item.width, item.height, searchUrl, initial]);
return (
<span className="debug-search-modal-item-resolution" aria-hidden="true">
{label ?? '…'}
</span>
);
}
interface Props {
open: boolean;
title: string;
@@ -76,19 +126,22 @@ export default function DebugSearchResultsModal({
onClick={() => onSelect(item)}
title="Use this image"
>
<img
src={debugImageProxyUrl(item.thumbUrl || item.imageUrl, {
searchUrl: data.searchUrl,
source: item.source,
})}
alt={`Result ${index + 1}`}
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
}}
/>
<span className="debug-search-modal-item-index">{index + 1}</span>
{busy && <span className="debug-search-modal-item-busy-label">Saving</span>}
<span className="debug-search-modal-item-media">
<img
src={debugImageProxyUrl(item.thumbUrl || item.imageUrl, {
searchUrl: data.searchUrl,
source: item.source,
})}
alt={`Result ${index + 1}`}
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
}}
/>
<span className="debug-search-modal-item-index">{index + 1}</span>
{busy && <span className="debug-search-modal-item-busy-label">Saving</span>}
</span>
<ResultResolution item={item} searchUrl={data.searchUrl} />
</button>
);
})}
+259
View File
@@ -0,0 +1,259 @@
import { useMemo } from 'react';
import * as THREE from 'three';
import type { GalleryWindowSpec, GalleryWindowStyle } from '../data/movement-interior-styles';
const WALL_HEIGHT = 4.2;
interface Props {
windows: GalleryWindowSpec[];
halfW: number;
halfD: number;
trimColor: string;
}
function windowWorldPosition(
spec: GalleryWindowSpec,
halfW: number,
halfD: number
): { position: [number, number, number]; rotation: [number, number, number] } {
const inset = 0.1;
switch (spec.wall) {
case 'back':
return {
position: [spec.x, spec.y, -halfD + inset],
rotation: [0, 0, 0],
};
case 'left':
return {
position: [-halfW + inset, spec.y, spec.x],
rotation: [0, Math.PI / 2, 0],
};
case 'right':
return {
position: [halfW - inset, spec.y, spec.x],
rotation: [0, -Math.PI / 2, 0],
};
case 'ceiling':
return {
position: [spec.x, WALL_HEIGHT - 0.06, spec.x === 0 ? 0 : spec.x],
rotation: [Math.PI / 2, 0, 0],
};
default:
return { position: [0, spec.y, -halfD + inset], rotation: [0, 0, 0] };
}
}
function WindowFrame({
style,
width,
height,
trimColor,
}: {
style: GalleryWindowStyle;
width: number;
height: number;
trimColor: string;
}) {
const frameW = 0.08;
const depth = 0.12;
const arch =
style === 'roman-arch' || style === 'gothic-lancet' || style === 'art-nouveau';
return (
<group>
{/* Outer frame */}
<mesh position={[0, 0, -depth / 2]}>
<boxGeometry args={[width + frameW * 2, height + frameW * 2, depth]} />
<meshStandardMaterial color={trimColor} roughness={0.35} metalness={0.45} />
</mesh>
{arch && style === 'gothic-lancet' && (
<mesh position={[0, height / 2 + 0.15, -depth / 2 + 0.02]}>
<coneGeometry args={[width / 2 + frameW, 0.5, 4]} />
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.35} />
</mesh>
)}
{style === 'roman-arch' && (
<mesh position={[0, height / 2 + 0.05, -depth / 2 + 0.02]} rotation={[0, 0, Math.PI]}>
<sphereGeometry args={[width / 2, 16, 8, 0, Math.PI * 2, 0, Math.PI / 2]} />
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.35} />
</mesh>
)}
{style === 'factory' && (
<>
{[-width / 3, 0, width / 3].map((ox) => (
<mesh key={ox} position={[ox, 0, -depth / 2 + 0.02]}>
<boxGeometry args={[0.06, height, depth + 0.02]} />
<meshStandardMaterial color="#3a3a3a" roughness={0.5} metalness={0.6} />
</mesh>
))}
{[height / 4, -height / 4].map((oy) => (
<mesh key={oy} position={[0, oy, -depth / 2 + 0.02]}>
<boxGeometry args={[width, 0.06, depth + 0.02]} />
<meshStandardMaterial color="#3a3a3a" roughness={0.5} metalness={0.6} />
</mesh>
))}
</>
)}
{style === 'glass-block' &&
Array.from({ length: 4 }, (_, row) =>
Array.from({ length: 3 }, (_, col) => (
<mesh
key={`${row}-${col}`}
position={[
-width / 3 + col * (width / 3),
-height / 4 + row * (height / 4),
-depth / 2 + 0.03,
]}
>
<boxGeometry args={[width / 3 - 0.06, height / 4 - 0.06, 0.04]} />
<meshStandardMaterial
color="#d0e8f8"
roughness={0.1}
metalness={0.05}
transparent
opacity={0.75}
/>
</mesh>
))
)}
{style === 'sash' && (
<mesh position={[0, 0, -depth / 2 + 0.03]}>
<boxGeometry args={[0.05, height, depth]} />
<meshStandardMaterial color={trimColor} roughness={0.45} metalness={0.3} />
</mesh>
)}
{style === 'baroque-pair' && (
<mesh position={[0, 0, -depth / 2 + 0.03]}>
<boxGeometry args={[0.06, height, depth]} />
<meshStandardMaterial color={trimColor} roughness={0.3} metalness={0.55} />
</mesh>
)}
</group>
);
}
function SingleWindow({
spec,
trimColor,
}: {
spec: GalleryWindowSpec;
trimColor: string;
}) {
const glassColor = useMemo(() => new THREE.Color(spec.lightColor), [spec.lightColor]);
return (
<group>
<WindowFrame style={spec.style} width={spec.width} height={spec.height} trimColor={trimColor} />
{/* Sky / daylight pane */}
<mesh position={[0, 0, 0.02]}>
<planeGeometry args={[spec.width - 0.12, spec.height - 0.12]} />
<meshStandardMaterial
color={spec.lightColor}
emissive={spec.lightColor}
emissiveIntensity={0.85}
roughness={0.05}
metalness={0.02}
toneMapped={false}
transparent
opacity={0.92}
/>
</mesh>
{/* Soft sky gradient overlay */}
<mesh position={[0, spec.height * 0.15, 0.03]}>
<planeGeometry args={[spec.width - 0.2, spec.height * 0.5]} />
<meshBasicMaterial color="#ffffff" transparent opacity={0.25} toneMapped={false} />
</mesh>
{/* Daylight into room */}
<spotLight
position={[0, 0, 0.15]}
angle={Math.min(1.2, (spec.width / Math.max(spec.height, 0.5)) * 0.55)}
penumbra={0.95}
intensity={spec.lightIntensity}
distance={14}
color={spec.lightColor}
castShadow={false}
/>
<pointLight
position={[0, 0, 0.25]}
intensity={spec.lightIntensity * 0.45}
distance={10}
color={glassColor}
decay={2}
/>
</group>
);
}
export default function GalleryWindows({ windows, halfW, halfD, trimColor }: Props) {
return (
<group>
{windows.map((spec, i) => {
const { position, rotation } = windowWorldPosition(spec, halfW, halfD);
return (
<group key={`${spec.wall}-${spec.x}-${i}`} position={position} rotation={rotation}>
<SingleWindow spec={spec} trimColor={trimColor} />
</group>
);
})}
</group>
);
}
/** Ceiling-mounted gallery track lights for even illumination. */
export function GalleryTrackLights({
width,
depth,
intensity,
color,
}: {
width: number;
depth: number;
intensity: number;
color: string;
}) {
const positions = useMemo(() => {
const pts: [number, number, number][] = [];
const cols = Math.max(2, Math.min(5, Math.floor(width / 4)));
const rows = Math.max(1, Math.min(3, Math.floor(depth / 6)));
for (let c = 0; c < cols; c++) {
for (let r = 0; r < rows; r++) {
const x = -width / 2 + (width / (cols + 1)) * (c + 1);
const z = -depth / 2 + (depth / (rows + 1)) * (r + 1);
pts.push([x, WALL_HEIGHT - 0.15, z]);
}
}
return pts;
}, [width, depth]);
return (
<group>
{positions.map(([x, y, z], i) => (
<group key={i} position={[x, y, z]}>
<mesh rotation={[Math.PI, 0, 0]}>
<cylinderGeometry args={[0.02, 0.025, 0.12, 8]} />
<meshStandardMaterial color="#2a2a2a" metalness={0.8} roughness={0.25} />
</mesh>
<spotLight
position={[0, -0.04, 0]}
angle={0.55}
penumbra={0.85}
intensity={intensity}
distance={8}
color={color}
castShadow={false}
/>
</group>
))}
</group>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { useState } from 'react';
import * as THREE from 'three';
import { Text } from '@react-three/drei';
const DOOR_WIDTH = 2.4;
const DOOR_HEIGHT = 2.5;
const WALL_THICKNESS = 0.18;
/** Open archway connecting to the next movement wing. */
export default function HallPassage({
position,
active,
label,
onActivate,
wallColor = '#f0ebe3',
trimColor = '#ddd5c8',
}: {
position: [number, number, number];
active: boolean;
label: string;
onActivate: () => void;
wallColor?: string;
trimColor?: string;
}) {
const [hovered, setHovered] = useState(false);
const highlight = active || hovered;
const openingW = DOOR_WIDTH;
const openingH = DOOR_HEIGHT;
const jamb = 0.12;
const faceZ = 0.04;
const handleActivate = (e: THREE.Event & { stopPropagation: () => void }) => {
e.stopPropagation();
onActivate();
};
return (
<group position={position}>
<pointLight position={[0, openingH * 0.5, 0.3]} intensity={highlight ? 0.9 : 0.5} distance={5} color="#fff4e8" />
{/* Depth beyond passage */}
<mesh position={[0, openingH * 0.5, 0.15]}>
<planeGeometry args={[openingW * 0.9, openingH * 0.95]} />
<meshStandardMaterial color="#fff8f0" emissive="#ffe8c8" emissiveIntensity={highlight ? 0.28 : 0.14} />
</mesh>
{/* Side jambs */}
{([-1, 1] as const).map((sign) => (
<mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), openingH / 2, faceZ]} castShadow>
<boxGeometry args={[jamb, openingH + 0.1, 0.1]} />
<meshStandardMaterial color={trimColor} roughness={0.45} metalness={0.25} />
</mesh>
))}
{/* Arch header */}
<mesh position={[0, openingH + 0.06, faceZ]} castShadow>
<boxGeometry args={[openingW + jamb * 2, 0.14, 0.1]} />
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.3} />
</mesh>
<mesh position={[0, openingH + 0.22, faceZ]} rotation={[0, 0, Math.PI]}>
<sphereGeometry args={[openingW / 2 + 0.04, 16, 8, 0, Math.PI * 2, 0, Math.PI / 2]} />
<meshStandardMaterial color={wallColor} roughness={0.88} />
</mesh>
<group position={[0, openingH + 0.38, faceZ - 0.01]}>
<mesh onClick={handleActivate} onPointerOver={() => setHovered(true)} onPointerOut={() => setHovered(false)}>
<boxGeometry args={[1.4, 0.18, 0.02]} />
<meshStandardMaterial
color={highlight ? '#d4af37' : trimColor}
roughness={0.3}
metalness={0.5}
emissive={highlight ? '#5a4010' : '#000000'}
emissiveIntensity={highlight ? 0.2 : 0}
/>
</mesh>
<Text
position={[0, 0, -0.012]}
rotation={[0, Math.PI, 0]}
fontSize={0.1}
color={highlight ? '#fff8e8' : '#4a3020'}
anchorX="center"
anchorY="middle"
onClick={handleActivate}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
{label.toUpperCase()}
</Text>
</group>
<mesh
position={[0, openingH / 2, faceZ - 0.12]}
rotation={[0, Math.PI, 0]}
onClick={handleActivate}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<planeGeometry args={[openingW * 1.1, openingH * 1.05]} />
<meshBasicMaterial transparent opacity={0} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
</group>
);
}
export { DOOR_WIDTH, DOOR_HEIGHT, WALL_THICKNESS };
+81 -12
View File
@@ -47,12 +47,44 @@
cursor: grabbing;
}
.movement-lifespan-overlays {
position: absolute;
inset: 0;
z-index: 3;
pointer-events: none;
}
.movement-lifespan-dim {
position: absolute;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
}
.movement-lifespan-highlight {
position: absolute;
top: 0;
bottom: 0;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.16) 40%,
rgba(255, 255, 255, 0.12) 100%
);
box-shadow:
inset 0 0 0 2px rgba(255, 230, 180, 0.45),
inset 0 0 48px rgba(255, 255, 255, 0.08);
border-left: 2px solid rgba(255, 220, 160, 0.65);
border-right: 2px solid rgba(255, 220, 160, 0.65);
}
.movements-flow-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
}
.movement-branch {
@@ -90,13 +122,16 @@
.movements-flow-artists {
position: absolute;
inset: 0;
pointer-events: none;
}
.movements-flow-artists {
pointer-events: auto;
}
.artist-on-band {
pointer-events: none;
}
.artist-lifespan {
position: absolute;
height: 0;
@@ -110,31 +145,45 @@
left: 0;
right: 0;
top: 0;
height: 3px;
height: 4px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 25%, transparent) 0%,
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 70%, #e8d5b5) 12%,
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 85%, #fff) 50%,
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 70%, #e8d5b5) 88%,
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 25%, transparent) 100%
rgba(255, 255, 255, 0) 0%,
var(--lifespan-color, #e8d5b5) 15%,
#fff 50%,
var(--lifespan-color, #e8d5b5) 85%,
rgba(255, 255, 255, 0) 100%
);
box-shadow: 0 0 6px color-mix(in srgb, var(--lifespan-color, #e8d5b5) 40%, transparent);
border-radius: 1px;
box-shadow:
0 0 8px rgba(255, 255, 255, 0.55),
0 0 16px var(--lifespan-color, #e8d5b5);
border-radius: 2px;
pointer-events: none;
}
.artist-lifespan .artist-portrait {
.artist-on-band .artist-portrait {
position: absolute;
top: 0;
transform: translate(-50%, -50%);
pointer-events: auto;
}
.artist-lifespan .artist-portrait:hover {
.artist-on-band .artist-portrait:hover {
transform: translate(-50%, -50%) scale(1.14);
}
.artist-on-band-active .artist-portrait {
box-shadow:
0 0 0 3px rgba(255, 255, 255, 0.85),
0 4px 24px rgba(255, 220, 160, 0.65);
}
.movements-flow-labels {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 3;
}
.movement-flow-label {
position: absolute;
transform: translate(-4px, -100%);
@@ -144,6 +193,26 @@
z-index: 3;
}
.movement-flow-label-btn {
pointer-events: auto;
border: none;
background: rgba(12, 10, 18, 0.55);
border-radius: 4px;
cursor: pointer;
text-align: left;
transition: background 0.2s, box-shadow 0.2s;
}
.movement-flow-label-btn:hover {
background: rgba(24, 20, 32, 0.82);
box-shadow: 0 0 0 1px rgba(255, 220, 160, 0.35);
}
.movement-flow-label-btn:focus-visible {
outline: 2px solid rgba(255, 220, 160, 0.65);
outline-offset: 2px;
}
.movement-name {
display: block;
font-family: 'Georgia', serif;
+232 -128
View File
@@ -15,6 +15,8 @@ interface Props {
absoluteMax: number;
onViewChange: (start: number, end: number) => void;
onArtistClick: (artistId: number) => void;
onMovementClick?: (movementId: number) => void;
onArtistHover?: (info: { birthYear: number; deathYear: number; color: string } | null) => void;
}
interface MovementLayout {
@@ -61,50 +63,159 @@ function artistTimelineYear(artist: Artist): number {
return artist.birth_year ?? artist.death_year ?? 0;
}
interface ArtistLifespanLayout {
lineLeft: number;
lineWidth: number;
lineRight: number;
portraitLeft: number;
y: number;
centerX: number;
}
function artistLifespanLayout(
artist: Artist,
layout: MovementLayout,
viewStart: number,
viewEnd: number
): ArtistLifespanLayout | null {
const birth = artist.birth_year ?? viewStart;
const death = artist.death_year ?? viewEnd;
const centerYear = artistTimelineYear(artist);
const lineLeft = yearToPercent(Math.max(birth, viewStart), viewStart, viewEnd);
const lineRight = yearToPercent(Math.min(death, viewEnd), viewStart, viewEnd);
const centerX = yearToPercent(centerYear, viewStart, viewEnd);
const lineWidth = lineRight - lineLeft;
if (lineWidth <= 0) return null;
const yAnchorX = Math.min(Math.max(centerX, layout.xStart), layout.xEnd);
const y = yOnStream(layout, yAnchorX);
const portraitLeft = ((centerX - lineLeft) / lineWidth) * 100;
return { lineLeft, lineWidth, lineRight, portraitLeft, y, centerX };
}
interface ArtistPlacement {
layout: MovementLayout;
artist: Artist;
lineLeft: number;
lineWidth: number;
portraitLeft: number;
portraitX: number;
y: number;
lane: number;
colorIndex: number;
color: string;
}
interface PortraitCandidate {
artist: Artist;
layout: MovementLayout;
lineLeft: number;
lineRight: number;
lineWidth: number;
minX: number;
maxX: number;
idealX: number;
x: number;
}
function portraitMinGapPct(portraitSizePx: number, canvasWidthPx: number): number {
const width = Math.max(canvasWidthPx, 320);
const diameterPct = (portraitSizePx / width) * 100;
return Math.max(diameterPct * 1.08, 3.5);
}
function resolvePortraitCollisions(candidates: PortraitCandidate[], minGap: number): void {
if (candidates.length === 0) return;
for (const c of candidates) {
c.x = Math.min(c.maxX, Math.max(c.minX, c.idealX));
}
candidates.sort((a, b) => a.x - b.x || a.idealX - b.idealX);
for (let i = 1; i < candidates.length; i++) {
if (candidates[i].x < candidates[i - 1].x + minGap) {
candidates[i].x = candidates[i - 1].x + minGap;
}
}
const last = candidates[candidates.length - 1];
if (last.x > last.maxX) {
last.x = last.maxX;
for (let i = candidates.length - 2; i >= 0; i--) {
candidates[i].x = Math.min(candidates[i].x, candidates[i + 1].x - minGap);
candidates[i].x = Math.max(candidates[i].x, candidates[i].minX);
}
}
for (let i = candidates.length - 2; i >= 0; i--) {
if (candidates[i].x + minGap > candidates[i + 1].x) {
candidates[i].x = candidates[i + 1].x - minGap;
candidates[i].x = Math.max(candidates[i].x, candidates[i].minX);
}
}
for (let i = 1; i < candidates.length; i++) {
if (candidates[i].x < candidates[i - 1].x + minGap) {
candidates[i].x = Math.min(candidates[i - 1].x + minGap, candidates[i].maxX);
}
}
}
function buildArtistPlacements(
layouts: MovementLayout[],
artistsByMovement: Map<number, Artist[]>,
viewStart: number,
viewEnd: number,
portraitSizePx: number,
canvasWidthPx: number
): ArtistPlacement[] {
const placements: ArtistPlacement[] = [];
const minGap = portraitMinGapPct(portraitSizePx, canvasWidthPx);
for (const layout of layouts) {
const movementArtists = artistsByMovement.get(layout.movement.id) || [];
const candidates: PortraitCandidate[] = [];
const portraitHalfPct = minGap / 2;
for (const artist of movementArtists) {
const birth = artist.birth_year ?? viewStart;
const death = artist.death_year ?? viewEnd;
const centerYear = artistTimelineYear(artist);
const lineLeft = yearToPercent(Math.max(birth, viewStart), viewStart, viewEnd);
const lineRight = yearToPercent(Math.min(death, viewEnd), viewStart, viewEnd);
const lineWidth = lineRight - lineLeft;
if (lineWidth <= 0) continue;
const spanLeft = Math.max(lineLeft, layout.xStart);
const spanRight = Math.min(lineRight, layout.xEnd);
if (spanRight <= spanLeft) continue;
const centerX = yearToPercent(centerYear, viewStart, viewEnd);
let minX = spanLeft + portraitHalfPct;
let maxX = spanRight - portraitHalfPct;
if (maxX <= minX) {
const mid = (spanLeft + spanRight) / 2;
minX = mid;
maxX = mid;
}
const idealX = Math.min(maxX, Math.max(minX, centerX));
candidates.push({
artist,
layout,
lineLeft,
lineRight,
lineWidth,
minX,
maxX,
idealX,
x: idealX,
});
}
candidates.sort(
(a, b) =>
a.idealX - b.idealX ||
b.lineWidth - a.lineWidth ||
(a.artist.birth_year ?? 0) - (b.artist.birth_year ?? 0)
);
resolvePortraitCollisions(candidates, minGap);
candidates.sort((a, b) => a.x - b.x);
const colorCount = Math.max(candidates.length, 1);
candidates.forEach((candidate, colorIndex) => {
const { artist, layout, lineLeft, lineWidth, x } = candidate;
const y = yOnStream(layout, x);
placements.push({
layout,
artist,
lineLeft,
lineWidth,
portraitX: x,
y,
colorIndex,
color: artistLifespanColor(layout.movement.color, colorIndex, colorCount),
});
});
}
return placements;
}
function parseHexColor(hex: string): [number, number, number] {
const normalized = hex.replace('#', '');
const value =
@@ -167,76 +278,6 @@ function artistLifespanColor(baseColor: string, laneIndex: number, laneCount: nu
}
}
function buildArtistPlacements(
layouts: MovementLayout[],
artistsByMovement: Map<number, Artist[]>,
viewStart: number,
viewEnd: number,
streamStrokePx: number,
portraitSizePx: number
): ArtistPlacement[] {
const placements: ArtistPlacement[] = [];
for (const layout of layouts) {
const movementArtists = artistsByMovement.get(layout.movement.id) || [];
const candidates: Array<{
artist: Artist;
span: ArtistLifespanLayout;
}> = [];
for (const artist of movementArtists) {
const span = artistLifespanLayout(artist, layout, viewStart, viewEnd);
if (span) candidates.push({ artist, span });
}
candidates.sort(
(a, b) =>
a.span.lineLeft - b.span.lineLeft ||
b.span.lineWidth - a.span.lineWidth ||
a.artist.birth_year! - b.artist.birth_year!
);
const laneEnds: number[] = [];
const minGap = 0.6;
const assigned: Array<{ candidate: (typeof candidates)[0]; lane: number }> = [];
for (const candidate of candidates) {
let lane = laneEnds.findIndex((end) => candidate.span.lineLeft >= end + minGap);
if (lane === -1) {
lane = laneEnds.length;
laneEnds.push(candidate.span.lineRight);
} else {
laneEnds[lane] = Math.max(laneEnds[lane], candidate.span.lineRight);
}
assigned.push({ candidate, lane });
}
const laneCount = Math.max(laneEnds.length, 1);
const laneStep = Math.min(
portraitSizePx * 0.52,
(streamStrokePx * 0.88) / laneCount
);
for (const { candidate, lane } of assigned) {
const { artist, span } = candidate;
const yOffset = (lane - (laneCount - 1) / 2) * laneStep;
placements.push({
layout,
artist,
lineLeft: span.lineLeft,
lineWidth: span.lineWidth,
portraitLeft: span.portraitLeft,
y: span.y + yOffset,
lane,
color: artistLifespanColor(layout.movement.color, lane, laneCount),
});
}
}
return placements;
}
function organicDrift(id: number): number {
return ((id * 17) % 11) - 5;
}
@@ -333,10 +374,13 @@ export default function MovementBands({
absoluteMax,
onViewChange,
onArtistClick,
onMovementClick,
onArtistHover,
}: Props) {
const canvasRef = useRef<HTMLDivElement>(null);
const [panning, setPanning] = useState(false);
const [canvasHeight, setCanvasHeight] = useState(DEFAULT_CANVAS_HEIGHT);
const [canvasWidth, setCanvasWidth] = useState(800);
const [hoveredArtistKey, setHoveredArtistKey] = useState<string | null>(null);
const panStart = useRef({ x: 0, viewStart: 0, viewEnd: 0 });
@@ -424,14 +468,16 @@ export default function MovementBands({
if (!el) return;
const measure = () => {
const h = el.getBoundingClientRect().height;
if (h > 0) setCanvasHeight(Math.round(h));
const rect = el.getBoundingClientRect();
if (rect.height > 0) setCanvasHeight(Math.round(rect.height));
if (rect.width > 0) setCanvasWidth(Math.round(rect.width));
};
measure();
const ro = new ResizeObserver((entries) => {
const h = entries[0]?.contentRect.height;
if (h > 0) setCanvasHeight(Math.round(h));
const rect = entries[0]?.contentRect;
if (rect && rect.height > 0) setCanvasHeight(Math.round(rect.height));
if (rect && rect.width > 0) setCanvasWidth(Math.round(rect.width));
});
ro.observe(el);
window.addEventListener('resize', measure);
@@ -577,12 +623,21 @@ export default function MovementBands({
artistsByMovement,
viewStart,
viewEnd,
streamStrokePx,
portraitSizePx
portraitSizePx,
canvasWidth
),
[layouts, artistsByMovement, viewStart, viewEnd, streamStrokePx, portraitSizePx]
[layouts, artistsByMovement, viewStart, viewEnd, portraitSizePx, canvasWidth]
);
const hoveredPlacement = useMemo(() => {
if (!hoveredArtistKey) return null;
return (
artistPlacements.find(
(p) => `${p.layout.movement.id}-${p.artist.id}` === hoveredArtistKey
) ?? null
);
}, [hoveredArtistKey, artistPlacements]);
if (visibleMovements.length === 0) {
return (
<div className="movements-empty">
@@ -719,26 +774,59 @@ export default function MovementBands({
})}
</svg>
{hoveredPlacement && (
<div className="movement-lifespan-overlays" aria-hidden>
{hoveredPlacement.lineLeft > 0 && (
<div
className="movement-lifespan-dim"
style={{ left: 0, width: `${hoveredPlacement.lineLeft}%` }}
/>
)}
{hoveredPlacement.lineLeft + hoveredPlacement.lineWidth < 100 && (
<div
className="movement-lifespan-dim"
style={{
left: `${hoveredPlacement.lineLeft + hoveredPlacement.lineWidth}%`,
width: `${100 - hoveredPlacement.lineLeft - hoveredPlacement.lineWidth}%`,
}}
/>
)}
<div
className="movement-lifespan-highlight"
style={{
left: `${hoveredPlacement.lineLeft}%`,
width: `${hoveredPlacement.lineWidth}%`,
['--lifespan-color' as string]: hoveredPlacement.color,
}}
/>
</div>
)}
<div className="movements-flow-labels">
{layouts.map((layout) => (
<div
<button
key={`label-${layout.movement.id}`}
className="movement-flow-label"
type="button"
className="movement-flow-label movement-flow-label-btn"
style={{
left: `${layout.xStart}%`,
top: `${((layout.y - 32) / layoutHeight) * 100}%`,
}}
title={`Open ${layout.movement.name} gallery hall`}
onClick={() => onMovementClick?.(layout.movement.id)}
onMouseDown={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
>
<span className="movement-name">{layout.movement.name}</span>
{layout.movement.era_name && (
<span className="movement-era">{layout.movement.era_name}</span>
)}
</div>
</button>
))}
</div>
<div className="movements-flow-artists">
{artistPlacements.map(({ layout, artist, lineLeft, lineWidth, portraitLeft, y, lane, color }) => {
{artistPlacements.map(({ layout, artist, lineLeft, lineWidth, portraitX, y, colorIndex, color }) => {
const birthLabel = artist.birth_year != null ? artist.birth_year : '?';
const deathLabel = artist.death_year != null ? artist.death_year : '?';
const artistKey = `${layout.movement.id}-${artist.id}`;
@@ -747,26 +835,42 @@ export default function MovementBands({
return (
<div
key={artistKey}
className={`artist-lifespan${isHovered ? ' artist-lifespan-active' : ''}`}
style={{
left: `${lineLeft}%`,
width: `${lineWidth}%`,
top: `${(y / layoutHeight) * 100}%`,
zIndex: isHovered ? 12 : 4 + lane,
['--lifespan-color' as string]: color,
}}
className={`artist-on-band${isHovered ? ' artist-on-band-active' : ''}`}
>
{isHovered && <span className="artist-lifespan-line" aria-hidden />}
<div
className={`artist-lifespan${isHovered ? ' artist-lifespan-active' : ''}`}
style={{
left: `${lineLeft}%`,
width: `${lineWidth}%`,
top: `${(y / layoutHeight) * 100}%`,
zIndex: isHovered ? 12 : 4 + colorIndex,
['--lifespan-color' as string]: color,
}}
>
{isHovered && <span className="artist-lifespan-line" aria-hidden />}
</div>
<button
type="button"
className="artist-portrait"
style={{
left: `${portraitLeft}%`,
left: `${portraitX}%`,
top: `${(y / layoutHeight) * 100}%`,
borderColor: color,
zIndex: isHovered ? 13 : 5 + colorIndex,
}}
onClick={() => onArtistClick(artist.id)}
onMouseEnter={() => setHoveredArtistKey(artistKey)}
onMouseLeave={() => setHoveredArtistKey(null)}
onMouseEnter={() => {
setHoveredArtistKey(artistKey);
onArtistHover?.({
birthYear: artist.birth_year ?? viewStart,
deathYear: artist.death_year ?? viewEnd,
color,
});
}}
onMouseLeave={() => {
setHoveredArtistKey(null);
onArtistHover?.(null);
}}
onMouseDown={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
title={`${artist.name} (${birthLabel}${deathLabel}) · ${layout.movement.name}`}
@@ -0,0 +1,222 @@
import { useMemo } from 'react';
import * as THREE from 'three';
import type { MovementInteriorStyle } from '../data/movement-interior-styles';
interface Props {
style: MovementInteriorStyle;
width: number;
depth: number;
halfW: number;
halfD: number;
}
function PalazzoDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
const pilasterPositions = useMemo(
() =>
[
[-halfW + 0.35, -halfD + 0.35],
[halfW - 0.35, -halfD + 0.35],
[-halfW + 0.35, halfD - 0.35],
[halfW - 0.35, halfD - 0.35],
] as [number, number][],
[halfW, halfD]
);
return (
<group>
{pilasterPositions.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 1.8, 0]} castShadow>
<boxGeometry args={[0.22, 3.6, 0.22]} />
<meshStandardMaterial color="#e8dcc8" roughness={0.85} metalness={0.05} />
</mesh>
<mesh position={[0, 3.65, 0]}>
<boxGeometry args={[0.28, 0.14, 0.28]} />
<meshStandardMaterial color={trim} roughness={0.35} metalness={0.55} />
</mesh>
</group>
))}
{/* Wainscoting rail */}
{[
[0, -halfD + 0.09, width, 0.12] as const,
[0, halfD - 0.09, width, 0.12] as const,
[-halfW + 0.09, 0, 0.12, depth] as const,
[halfW - 0.09, 0, 0.12, depth] as const,
].map(([x, z, w, d], i) => (
<mesh key={i} position={[x, 1.05, z]}>
<boxGeometry args={[w, 0.08, d]} />
<meshStandardMaterial color="#ddd0b8" roughness={0.78} metalness={0.08} />
</mesh>
))}
{/* Coffered ceiling */}
{Array.from({ length: Math.min(6, Math.floor(width / 2.5)) }, (_, col) =>
Array.from({ length: Math.min(5, Math.floor(depth / 2.8)) }, (_, row) => {
const cx = -halfW + 1.4 + col * 2.4;
const cz = -halfD + 1.5 + row * 2.6;
return (
<mesh key={`${col}-${row}`} position={[cx, 4.12, cz]} rotation={[Math.PI / 2, 0, 0]}>
<boxGeometry args={[2.0, 2.2, 0.06]} />
<meshStandardMaterial color="#f5efe4" roughness={0.88} />
</mesh>
);
})
)}
</group>
);
}
function BaroqueDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
return (
<group>
{/* Gilded cornice ring */}
{[
[0, -halfD + 0.06, width, 0.1] as const,
[0, halfD - 0.06, width, 0.1] as const,
[-halfW + 0.06, 0, 0.1, depth] as const,
[halfW - 0.06, 0, 0.1, depth] as const,
].map(([x, z, w, d], i) => (
<mesh key={i} position={[x, 3.95, z]}>
<boxGeometry args={[w, 0.18, d]} />
<meshStandardMaterial color={trim} roughness={0.25} metalness={0.75} emissive="#3a2808" emissiveIntensity={0.08} />
</mesh>
))}
{/* Wall panels */}
{[-halfW + 0.12, halfW - 0.12].map((x, i) => (
<mesh key={i} position={[x, 2.1, 0]} rotation={[0, Math.PI / 2, 0]}>
<boxGeometry args={[depth * 0.85, 2.8, 0.04]} />
<meshStandardMaterial color="#3a1820" roughness={0.88} metalness={0.06} />
</mesh>
))}
<pointLight position={[0, 3.6, 0]} intensity={0.9} distance={Math.max(width, depth)} color="#ffd080" />
<mesh position={[0, 3.5, 0]}>
<torusGeometry args={[0.55, 0.04, 8, 24]} />
<meshStandardMaterial color={trim} roughness={0.2} metalness={0.85} emissive="#5a4010" emissiveIntensity={0.15} />
</mesh>
</group>
);
}
function MedievalDetails({ halfW, halfD }: Pick<Props, 'halfW' | 'halfD'>) {
const torchPositions = useMemo(
() =>
[
[-halfW + 0.2, -halfD * 0.5],
[halfW - 0.2, -halfD * 0.5],
[-halfW + 0.2, halfD * 0.3],
[halfW - 0.2, halfD * 0.3],
] as [number, number][],
[halfW, halfD]
);
return (
<group>
{torchPositions.map(([x, z], i) => (
<group key={i} position={[x, 2.2, z]}>
<mesh>
<boxGeometry args={[0.08, 0.35, 0.12]} />
<meshStandardMaterial color="#3a3028" roughness={0.9} />
</mesh>
<pointLight position={[0, 0.15, 0.08]} intensity={0.65} distance={5} color="#ff9830" />
<mesh position={[0, 0.2, 0.06]}>
<sphereGeometry args={[0.06, 8, 8]} />
<meshStandardMaterial color="#ffb040" emissive="#ff8010" emissiveIntensity={0.8} toneMapped={false} />
</mesh>
</group>
))}
{/* Rough stone courses */}
{[-halfD + 0.08, halfD - 0.08].map((z, i) => (
<mesh key={i} position={[0, 1.5, z]}>
<boxGeometry args={[0.04, 3, 0.04]} />
<meshStandardMaterial color="#6a6458" roughness={0.98} />
</mesh>
))}
</group>
);
}
function NeoclassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
const columns = [
[-halfW + 0.45, -halfD + 0.6],
[halfW - 0.45, -halfD + 0.6],
[-halfW + 0.45, halfD - 0.6],
[halfW - 0.45, halfD - 0.6],
] as [number, number][];
return (
<group>
{columns.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 1.85, 0]}>
<cylinderGeometry args={[0.18, 0.2, 3.7, 12]} />
<meshStandardMaterial color="#f0ece8" roughness={0.72} metalness={0.12} />
</mesh>
<mesh position={[0, 3.85, 0]}>
<boxGeometry args={[0.42, 0.12, 0.42]} />
<meshStandardMaterial color={trim} roughness={0.4} metalness={0.35} />
</mesh>
</group>
))}
<mesh position={[0, 3.88, -halfD + 0.12]}>
<boxGeometry args={[2.8, 0.14, 0.2]} />
<meshStandardMaterial color={trim} roughness={0.45} metalness={0.3} />
</mesh>
</group>
);
}
function ClassicalDetails({ halfW, trim }: Pick<Props, 'halfW'> & { trim: string }) {
return (
<group>
{[
[-halfW + 0.5, 0],
[halfW - 0.5, 0],
].map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 2, 0]}>
<cylinderGeometry args={[0.16, 0.18, 4, 10]} />
<meshStandardMaterial color="#e0d8c8" roughness={0.88} />
</mesh>
<mesh position={[0, 4.05, 0]}>
<boxGeometry args={[0.36, 0.1, 0.36]} />
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.25} />
</mesh>
</group>
))}
</group>
);
}
function SalonDetails({ trim }: { trim: string }) {
return (
<mesh position={[0, 4.05, 0]} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.3, 1.2, 32]} />
<meshStandardMaterial color={trim} roughness={0.35} metalness={0.5} side={THREE.DoubleSide} />
</mesh>
);
}
export default function MovementHallDetails({ style, width, depth, halfW, halfD }: Props) {
const trim = style.tints.trim;
switch (style.details) {
case 'palazzo':
return <PalazzoDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
case 'baroque':
return <BaroqueDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
case 'medieval':
return <MedievalDetails halfW={halfW} halfD={halfD} />;
case 'neoclassical':
return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
case 'classical':
return <ClassicalDetails halfW={halfW} trim={trim} />;
case 'salon':
return <SalonDetails trim={trim} />;
default:
return null;
}
}
@@ -0,0 +1,172 @@
.painting-frame-annotated {
position: relative;
}
.painting-frame-image-wrap {
position: relative;
}
.painting-frame-image-wrap img {
width: 100%;
display: block;
user-select: none;
}
.painting-annotation-markers {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 2;
}
.painting-annotation-marker {
position: absolute;
transform: translate(-50%, -50%);
width: 22px;
height: 22px;
padding: 0;
border: 2px solid rgba(255, 255, 255, 0.9);
border-radius: 50%;
font-size: 11px;
font-weight: 700;
line-height: 1;
color: #1a1208;
cursor: pointer;
pointer-events: auto;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
transition: transform 0.15s, box-shadow 0.15s;
}
.painting-annotation-marker:hover,
.painting-annotation-marker-active {
transform: translate(-50%, -50%) scale(1.12);
box-shadow: 0 0 0 2px rgba(255, 215, 0, 0.55), 0 2px 10px rgba(0, 0, 0, 0.5);
z-index: 3;
}
.painting-annotation-marker-technique { background: #7eb8da; }
.painting-annotation-marker-composition { background: #c9a96e; }
.painting-annotation-marker-symbolism { background: #b088cc; }
.painting-annotation-marker-history { background: #8cbe8c; }
.painting-annotation-marker-subject { background: #e8a040; }
.painting-annotations-panel {
max-width: 700px;
width: 100%;
margin-top: 16px;
}
.painting-annotations-title {
margin: 0 0 10px;
font-family: 'Georgia', serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #c9a96e;
}
.painting-annotations-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.painting-annotation-card {
border-radius: 6px;
border: 1px solid rgba(201, 169, 110, 0.2);
background: rgba(201, 169, 110, 0.06);
overflow: hidden;
}
.painting-annotation-card-active {
border-color: rgba(255, 215, 0, 0.45);
background: rgba(201, 169, 110, 0.12);
}
.painting-annotation-card-btn {
display: block;
width: 100%;
padding: 10px 12px;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
color: inherit;
}
.painting-annotation-card-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.painting-annotation-number {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 5px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.35);
font-size: 11px;
font-weight: 700;
color: #ffd700;
}
.painting-annotation-category {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: rgba(201, 169, 110, 0.85);
}
.painting-annotation-label {
font-family: 'Georgia', serif;
font-size: 13px;
color: #e8d5b5;
}
.painting-annotation-body {
margin: 0;
font-family: 'Georgia', serif;
font-size: 13px;
line-height: 1.55;
color: rgba(232, 213, 181, 0.88);
}
.painting-annotation-source {
margin: 6px 0 0;
font-size: 11px;
font-style: italic;
color: rgba(201, 169, 110, 0.75);
}
.painting-annotation-source cite {
font-style: normal;
}
.painting-annotation-source-link {
display: inline-block;
margin: 0 12px 10px;
font-size: 11px;
color: #7eb8da;
text-decoration: none;
}
.painting-annotation-source-link:hover {
text-decoration: underline;
}
.painting-annotation-card-technique { border-left: 3px solid #7eb8da; }
.painting-annotation-card-composition { border-left: 3px solid #c9a96e; }
.painting-annotation-card-symbolism { border-left: 3px solid #b088cc; }
.painting-annotation-card-history { border-left: 3px solid #8cbe8c; }
.painting-annotation-card-subject { border-left: 3px solid #e8a040; }
@@ -0,0 +1,122 @@
import { useRef } from 'react';
import type { PaintingAnnotation } from '../types';
import './PaintingAnnotations.css';
const CATEGORY_LABELS: Record<string, string> = {
technique: 'Technique',
composition: 'Composition',
symbolism: 'Symbolism',
history: 'History',
subject: 'Subject',
};
interface PanelProps {
annotations: PaintingAnnotation[];
activeId: number | null;
onSelect: (id: number | null) => void;
}
export function PaintingAnnotationMarkers({
annotations,
activeId,
onSelect,
}: PanelProps) {
const positioned = annotations.filter(
(a) => a.pos_x != null && a.pos_y != null && Number.isFinite(Number(a.pos_x)) && Number.isFinite(Number(a.pos_y))
);
if (!positioned.length) return null;
return (
<div className="painting-annotation-markers" aria-hidden={false}>
{positioned.map((ann) => {
const number = annotations.indexOf(ann) + 1;
const isActive = activeId === ann.id;
return (
<button
key={ann.id}
type="button"
className={`painting-annotation-marker painting-annotation-marker-${ann.category}${isActive ? ' painting-annotation-marker-active' : ''}`}
style={{ left: `${ann.pos_x}%`, top: `${ann.pos_y}%` }}
title={ann.label || ann.body}
aria-label={`Annotation ${number}: ${ann.label || ann.body}`}
onClick={(e) => {
e.stopPropagation();
onSelect(isActive ? null : ann.id);
}}
>
{number}
</button>
);
})}
</div>
);
}
export default function PaintingAnnotationsPanel({
annotations,
activeId,
onSelect,
}: PanelProps) {
const cardRefs = useRef<Map<number, HTMLLIElement>>(new Map());
if (!annotations.length) return null;
const focusAnnotation = (id: number | null) => {
onSelect(id);
if (id != null) {
cardRefs.current.get(id)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
};
return (
<aside className="painting-annotations-panel" aria-label="Art history annotations">
<h3 className="painting-annotations-title">Art history notes</h3>
<ul className="painting-annotations-list">
{annotations.map((ann, index) => {
const isActive = activeId === ann.id;
const category = CATEGORY_LABELS[ann.category] || ann.category;
return (
<li
key={ann.id}
ref={(el) => {
if (el) cardRefs.current.set(ann.id, el);
else cardRefs.current.delete(ann.id);
}}
className={`painting-annotation-card painting-annotation-card-${ann.category}${isActive ? ' painting-annotation-card-active' : ''}`}
>
<button
type="button"
className="painting-annotation-card-btn"
onClick={() => focusAnnotation(isActive ? null : ann.id)}
>
<span className="painting-annotation-card-head">
<span className="painting-annotation-number">{index + 1}</span>
<span className="painting-annotation-category">{category}</span>
{ann.label && <strong className="painting-annotation-label">{ann.label}</strong>}
</span>
<p className="painting-annotation-body">{ann.body}</p>
{(ann.source_author || ann.source) && (
<p className="painting-annotation-source">
{ann.source_author}
{ann.source && <cite>, {ann.source}</cite>}
</p>
)}
</button>
{ann.source_url && (
<a
className="painting-annotation-source-link"
href={ann.source_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
Read source
</a>
)}
</li>
);
})}
</ul>
</aside>
);
}
+5
View File
@@ -392,6 +392,11 @@
user-select: none;
}
.painting-frame-large .painting-frame-image-wrap img {
width: 100%;
display: block;
}
.painting-description {
max-width: 700px;
margin-top: 20px;
+24 -4
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ChangeEvent, type SyntheticEvent } fr
import type { InfluenceLink, Painting, PaintingDetail } from '../types';
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client';
import DebugSearchResultsModal from './DebugSearchResultsModal';
import PaintingAnnotationsPanel, { PaintingAnnotationMarkers } from './PaintingAnnotations';
import PaintingLightbox from './PaintingLightbox';
import './PaintingDetail.css';
@@ -190,7 +191,7 @@ export default function PaintingDetailView({
onPaintingImageFixed,
onPaintingCheckupFlagsUpdated,
}: Props) {
const { painting, influencedBy, influenced } = data;
const { painting, influencedBy, influenced, annotations = [] } = data;
const [fullscreen, setFullscreen] = useState(false);
const [imageVersion, setImageVersion] = useState(0);
const [debugSearch, setDebugSearch] = useState<DebugImageSearchResult | null>(null);
@@ -205,6 +206,7 @@ export default function PaintingDetailView({
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
const [clearing, setClearing] = useState(false);
const [uploading, setUploading] = useState(false);
const [activeAnnotationId, setActiveAnnotationId] = useState<number | null>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
const baseImageUrl = paintingImageUrl(painting);
@@ -227,6 +229,7 @@ export default function PaintingDetailView({
setMoreOpen(false);
setMoreResults(null);
setMoreError(null);
setActiveAnnotationId(null);
}, [painting.id]);
useEffect(() => {
@@ -475,11 +478,28 @@ export default function PaintingDetailView({
title={imageSrc ? 'View full screen' : undefined}
aria-label={imageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`}
>
{imageSrc ? (
<img src={imageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
) : null}
<div className="painting-frame-image-wrap">
{imageSrc ? (
<img src={imageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
) : null}
{imageSrc && annotations.length > 0 && (
<PaintingAnnotationMarkers
annotations={annotations}
activeId={activeAnnotationId}
onSelect={setActiveAnnotationId}
/>
)}
</div>
</div>
{annotations.length > 0 && (
<PaintingAnnotationsPanel
annotations={annotations}
activeId={activeAnnotationId}
onSelect={setActiveAnnotationId}
/>
)}
{showCatalogNav && (
<button
type="button"
+65 -28
View File
@@ -33,15 +33,17 @@
.timeline-range {
margin-left: 12px;
color: #c9a96e;
color: #f5e6c8;
font-family: 'Georgia', serif;
font-size: 14px;
font-size: 16px;
font-weight: 700;
letter-spacing: 0.5px;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
}
.timeline-container {
position: relative;
height: 88px;
height: 96px;
cursor: grab;
user-select: none;
border: 1px solid rgba(201, 169, 110, 0.3);
@@ -53,6 +55,39 @@
cursor: grabbing;
}
.timeline-lifespan-overlays {
position: absolute;
inset: 0;
z-index: 6;
pointer-events: none;
}
.timeline-lifespan-dim {
position: absolute;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.55);
}
.timeline-lifespan-highlight {
position: absolute;
top: 0;
bottom: 0;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.22) 18%,
rgba(255, 255, 255, 0.38) 50%,
rgba(255, 255, 255, 0.22) 82%,
rgba(255, 255, 255, 0.05) 100%
);
box-shadow:
inset 0 0 0 2px rgba(255, 240, 200, 0.55),
inset 0 0 32px rgba(255, 255, 255, 0.2);
border-left: 2px solid rgba(255, 230, 180, 0.75);
border-right: 2px solid rgba(255, 230, 180, 0.75);
}
.timeline-track {
position: absolute;
inset: 0;
@@ -120,18 +155,16 @@
}
.historical-event-point .event-marker-line {
position: absolute;
top: 18px;
bottom: 22px;
left: 50%;
width: 2px;
transform: translateX(-50%);
background: linear-gradient(
180deg,
rgba(255, 210, 120, 0.95) 0%,
rgba(255, 180, 90, 0.75) 100%
);
box-shadow: 0 0 6px rgba(255, 190, 100, 0.45);
display: none;
}
.historical-event-span {
top: 20px;
bottom: 24px;
background: rgba(180, 70, 55, 0.28);
border-left: 2px solid rgba(255, 150, 110, 0.75);
border-right: 2px solid rgba(255, 150, 110, 0.75);
border-radius: 2px;
}
.historical-event-point::before {
@@ -148,16 +181,13 @@
}
.historical-event-span {
top: 20px;
bottom: 24px;
background: rgba(180, 70, 55, 0.22);
border-left: 2px solid rgba(255, 150, 110, 0.65);
border-right: 2px solid rgba(255, 150, 110, 0.65);
border-radius: 2px;
background: transparent;
border-left-color: transparent;
border-right-color: transparent;
}
.historical-event-span:hover,
.historical-event-point:hover .event-marker-line {
.historical-event-point:hover::before {
filter: brightness(1.2);
}
@@ -197,24 +227,31 @@
bottom: 0;
left: 0;
right: 0;
height: 24px;
height: 34px;
z-index: 5;
}
.tick {
position: absolute;
bottom: 0;
transform: translateX(-50%);
border-left: 1px solid rgba(201, 169, 110, 0.4);
height: 8px;
border-left: 2px solid rgba(245, 230, 200, 0.75);
height: 12px;
}
.tick span {
position: absolute;
bottom: 10px;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
color: rgba(201, 169, 110, 0.7);
font-family: 'Georgia', serif;
font-size: 14px;
font-weight: 700;
color: #f5e6c8;
text-shadow:
0 0 8px rgba(0, 0, 0, 0.95),
0 1px 2px rgba(0, 0, 0, 1),
0 0 1px rgba(0, 0, 0, 1);
white-space: nowrap;
}
+43 -2
View File
@@ -8,6 +8,12 @@ import {
} from '../data/historical-events';
import './Timeline.css';
interface LifespanHighlight {
birthYear: number;
deathYear: number;
color: string;
}
interface Props {
eras: HistoricalEra[];
viewStart: number;
@@ -15,6 +21,7 @@ interface Props {
onViewChange: (start: number, end: number) => void;
absoluteMin: number;
absoluteMax: number;
lifespanHighlight?: LifespanHighlight | null;
}
function yearToPercent(year: number, start: number, end: number): number {
@@ -26,7 +33,7 @@ function formatYear(year: number): string {
return `${year} CE`;
}
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax }: Props) {
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<'left' | 'right' | 'pan' | null>(null);
const dragStart = useRef({ x: 0, viewStart: 0, viewEnd: 0 });
@@ -169,6 +176,15 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
});
}, [viewStart, viewEnd, span]);
const lifespanBand = useMemo(() => {
if (!lifespanHighlight) return null;
const left = yearToPercent(Math.max(lifespanHighlight.birthYear, viewStart), viewStart, viewEnd);
const right = yearToPercent(Math.min(lifespanHighlight.deathYear, viewEnd), viewStart, viewEnd);
const width = right - left;
if (width <= 0) return null;
return { left, width, color: lifespanHighlight.color };
}, [lifespanHighlight, viewStart, viewEnd]);
return (
<div className="timeline-wrapper">
<div className="timeline-controls">
@@ -182,7 +198,7 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
<div
ref={containerRef}
className="timeline-container"
className={`timeline-container${lifespanBand ? ' timeline-container-lifespan-hover' : ''}`}
onWheel={handleWheel}
onMouseDown={(e) => handleMouseDown(e, 'pan')}
>
@@ -218,6 +234,31 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
})}
</div>
{lifespanBand && (
<div className="timeline-lifespan-overlays" aria-hidden>
{lifespanBand.left > 0 && (
<div className="timeline-lifespan-dim" style={{ left: 0, width: `${lifespanBand.left}%` }} />
)}
{lifespanBand.left + lifespanBand.width < 100 && (
<div
className="timeline-lifespan-dim"
style={{
left: `${lifespanBand.left + lifespanBand.width}%`,
width: `${100 - lifespanBand.left - lifespanBand.width}%`,
}}
/>
)}
<div
className="timeline-lifespan-highlight"
style={{
left: `${lifespanBand.left}%`,
width: `${lifespanBand.width}%`,
['--lifespan-color' as string]: lifespanBand.color,
}}
/>
</div>
)}
<div className="timeline-events" aria-hidden={false}>
{visibleEvents.map(({ event, showLabel }) => {
const end = eventEndYear(event);
@@ -0,0 +1,34 @@
.timeline-event-guides {
position: absolute;
top: 78px;
left: 16px;
right: 16px;
bottom: 16px;
pointer-events: none;
z-index: 3;
}
.timeline-event-guide-line {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
transform: translateX(-50%);
background: linear-gradient(
180deg,
rgba(255, 210, 120, 0.55) 0%,
rgba(255, 180, 90, 0.35) 35%,
rgba(255, 160, 80, 0.18) 100%
);
box-shadow: 0 0 8px rgba(255, 190, 100, 0.25);
}
.timeline-event-guide-span {
position: absolute;
top: 0;
bottom: 0;
background: rgba(180, 70, 55, 0.1);
border-left: 2px solid rgba(255, 150, 110, 0.35);
border-right: 2px solid rgba(255, 150, 110, 0.35);
border-radius: 2px;
}
@@ -0,0 +1,53 @@
import { HISTORICAL_EVENTS, eventEndYear, eventInView } from '../data/historical-events';
import './TimelineEventGuides.css';
function yearToPercent(year: number, start: number, end: number): number {
return ((year - start) / (end - start)) * 100;
}
interface Props {
viewStart: number;
viewEnd: number;
}
export default function TimelineEventGuides({ viewStart, viewEnd }: Props) {
const events = HISTORICAL_EVENTS.filter((event) => eventInView(event, viewStart, viewEnd));
return (
<div className="timeline-event-guides" aria-hidden>
{events.map((event) => {
const end = eventEndYear(event);
const isSpan = event.endYear != null && event.endYear !== event.startYear;
if (isSpan) {
const left = yearToPercent(Math.max(event.startYear, viewStart), viewStart, viewEnd);
const right = yearToPercent(Math.min(end, viewEnd), viewStart, viewEnd);
if (right <= 0 || left >= 100) return null;
const width = Math.min(100, right) - Math.max(0, left);
return (
<div
key={event.id}
className="timeline-event-guide-span"
style={{
left: `${Math.max(0, left)}%`,
width: `${width}%`,
}}
/>
);
}
if (event.startYear < viewStart || event.startYear > viewEnd) return null;
const left = yearToPercent(event.startYear, viewStart, viewEnd);
return (
<div
key={event.id}
className="timeline-event-guide-line"
style={{ left: `${left}%` }}
/>
);
})}
</div>
);
}
+39
View File
@@ -380,3 +380,42 @@
border-bottom: 1px solid rgba(201, 169, 110, 0.25);
}
}
.movement-hall-nav-list {
list-style: none;
margin: 0 0 20px;
padding: 0;
}
.movement-hall-nav-list li + li {
margin-top: 8px;
}
.movement-hall-nav-list button {
width: 100%;
text-align: left;
padding: 12px 14px;
border: 1px solid rgba(201, 169, 110, 0.35);
border-radius: 8px;
background: rgba(30, 24, 18, 0.6);
color: #e8d5b5;
cursor: pointer;
}
.movement-hall-nav-list button:hover,
.movement-hall-nav-active {
border-color: rgba(212, 175, 55, 0.75) !important;
background: rgba(60, 48, 32, 0.85) !important;
}
.movement-hall-nav-list small {
display: block;
margin-top: 4px;
font-size: 12px;
opacity: 0.75;
}
.movement-hall-exit-timeline {
width: 100%;
margin-top: 8px;
}
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -11,10 +11,19 @@ export interface HistoricalEvent {
export const HISTORICAL_EVENTS: readonly HistoricalEvent[] = [
{ id: 'fall-rome', name: 'Fall of Rome', startYear: 476 },
{ id: 'charlemagne', name: 'Charlemagne crowned emperor', startYear: 800 },
{ id: 'battle-hastings', name: 'Battle of Hastings', startYear: 1066 },
{ id: 'first-crusade', name: 'First Crusade', startYear: 1096, endYear: 1099 },
{ id: 'magna-carta', name: 'Magna Carta', startYear: 1215 },
{ id: 'black-death', name: 'Black Death', startYear: 1347 },
{ id: 'printing-press', name: 'Printing press', startYear: 1450 },
{ id: 'reformation', name: 'Protestant Reformation', startYear: 1517 },
{ id: 'fall-constantinople', name: 'Fall of Constantinople', startYear: 1453 },
{ id: 'columbus', name: 'Columbus reaches the Americas', startYear: 1492 },
{ id: 'reformation', name: 'Protestant Reformation', startYear: 1517 },
{ id: 'thirty-years-war', name: 'Thirty Years\' War', startYear: 1618, endYear: 1648, shortLabel: '30 Years\' War' },
{ id: 'english-civil-war', name: 'English Civil War', startYear: 1642, endYear: 1651 },
{ id: 'glorious-revolution', name: 'Glorious Revolution', startYear: 1688 },
{ id: 'war-spanish-succession', name: 'War of the Spanish Succession', startYear: 1701, endYear: 1714, shortLabel: 'Spanish Succession' },
{ id: 'american-revolution', name: 'American Revolution', startYear: 1776 },
{ id: 'french-revolution', name: 'French Revolution', startYear: 1789 },
{ id: 'waterloo', name: 'Battle of Waterloo', startYear: 1815 },
+601
View File
@@ -0,0 +1,601 @@
import type { ArtMovement } from '../types';
import type { SurfaceTextureKind } from '../utils/galleryProceduralTextures';
export type GalleryWindowStyle =
| 'roman-arch'
| 'gothic-lancet'
| 'baroque-pair'
| 'sash'
| 'factory'
| 'skylight'
| 'art-nouveau'
| 'glass-block'
| 'clerestory'
| 'round-oculus';
export interface GalleryWindowSpec {
wall: 'back' | 'left' | 'right' | 'ceiling';
/** Offset along wall axis from center (meters). */
x: number;
/** Vertical center height (meters). */
y: number;
width: number;
height: number;
style: GalleryWindowStyle;
lightColor: string;
lightIntensity: number;
}
export interface MovementInteriorStyle {
id: string;
label: string;
subtitle: string;
surfaces: {
wall: SurfaceTextureKind;
wallSide?: SurfaceTextureKind;
ceiling: SurfaceTextureKind;
floor: SurfaceTextureKind;
};
tints: {
wall: string;
wallSide?: string;
ceiling: string;
floor: string;
trim: string;
};
titleColor: string;
ambient: number;
warmLight: string;
sunLight: string;
fog: string;
background: string;
doorWood: [string, string, string];
windows: GalleryWindowSpec[];
trackLights: number;
details: 'palazzo' | 'baroque' | 'medieval' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier';
}
function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] {
return specs;
}
function mk(
id: string,
label: string,
subtitle: string,
surfaces: MovementInteriorStyle['surfaces'],
tints: MovementInteriorStyle['tints'],
opts: Partial<Omit<MovementInteriorStyle, 'id' | 'label' | 'subtitle' | 'surfaces' | 'tints'>> & {
details: MovementInteriorStyle['details'];
windows: GalleryWindowSpec[];
}
): MovementInteriorStyle {
return {
id,
label,
subtitle,
surfaces,
tints,
titleColor: opts.titleColor ?? '#4a3020',
ambient: opts.ambient ?? 0.55,
warmLight: opts.warmLight ?? '#fff4e8',
sunLight: opts.sunLight ?? '#fffaf0',
fog: opts.fog ?? '#1a1814',
background: opts.background ?? '#121010',
doorWood: opts.doorWood ?? ['#3d2818', '#4e3624', '#261a10'],
windows: opts.windows,
trackLights: opts.trackLights ?? 0.8,
details: opts.details,
};
}
/** Unique photo-real interior per movement (keyed by database id). */
const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
27: mk(
'ancient-classical',
'Roman atrium',
'Marble-clad villa · mosaic floor · clerestory daylight',
{ wall: 'limestone', ceiling: 'plaster-warm', floor: 'mosaic-roman' },
{ wall: '#e8e0d0', ceiling: '#f5f0e8', floor: '#c8b898', trim: '#a89878' },
{
details: 'classical',
titleColor: '#5a4830',
ambient: 0.62,
warmLight: '#fff8e8',
windows: windows(
{ wall: 'back', x: -2.5, y: 3.2, width: 1.4, height: 1.8, style: 'roman-arch', lightColor: '#fff8e0', lightIntensity: 3.2 },
{ wall: 'back', x: 2.5, y: 3.2, width: 1.4, height: 1.8, style: 'roman-arch', lightColor: '#fff8e0', lightIntensity: 3.2 },
{ wall: 'left', x: 0, y: 3.0, width: 2.0, height: 1.6, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.8 },
{ wall: 'right', x: 0, y: 3.0, width: 2.0, height: 1.6, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.8 }
),
trackLights: 0.4,
doorWood: ['#6a5840', '#7a6848', '#5a4830'],
}
),
28: mk(
'byzantine-sanctuary',
'Byzantine chapel',
'Gold mosaic walls · amber light through arched windows',
{ wall: 'mosaic-byzantine', ceiling: 'gilded-stucco', floor: 'marble-checker' },
{ wall: '#d4af37', ceiling: '#c8a840', floor: '#ddd8cc', trim: '#b8860b' },
{
details: 'medieval',
titleColor: '#f0e8c0',
ambient: 0.48,
warmLight: '#ffd898',
sunLight: '#ffe8b0',
fog: '#0a0806',
background: '#060504',
windows: windows(
{ wall: 'back', x: 0, y: 2.8, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 2.4 },
{ wall: 'left', x: -1, y: 2.6, width: 0.7, height: 1.8, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 1.8 },
{ wall: 'right', x: 1, y: 2.6, width: 0.7, height: 1.8, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 1.8 }
),
trackLights: 0.5,
doorWood: ['#3a3020', '#4a3830', '#2a2018'],
}
),
29: mk(
'gothic-cathedral',
'Gothic hall',
'Stone vault · tall lancet windows · flagstone floor',
{ wall: 'rough-stone', ceiling: 'basalt', floor: 'flagstone' },
{ wall: '#8a8478', ceiling: '#3a3630', floor: '#6a6458', trim: '#5c5648' },
{
details: 'medieval',
titleColor: '#e8dcc8',
ambient: 0.52,
warmLight: '#e8d8c0',
sunLight: '#d0e8ff',
fog: '#0c0c10',
windows: windows(
{ wall: 'back', x: -2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.5 },
{ wall: 'back', x: 2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.5 },
{ wall: 'left', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 },
{ wall: 'right', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 }
),
trackLights: 0.3,
}
),
30: mk(
'early-renaissance-palazzo',
'Florentine palazzo',
'Stucco salone · terracotta accents · arched windows',
{ wall: 'stucco-cream', ceiling: 'fresco-worn', floor: 'terracotta-tiles' },
{ wall: '#f4ebe0', ceiling: '#faf6ee', floor: '#c89070', trim: '#c9a227' },
{
details: 'palazzo',
titleColor: '#6b4423',
windows: windows(
{ wall: 'back', x: -2.2, y: 2.6, width: 1.3, height: 1.7, style: 'roman-arch', lightColor: '#fff4e0', lightIntensity: 3.0 },
{ wall: 'back', x: 2.2, y: 2.6, width: 1.3, height: 1.7, style: 'roman-arch', lightColor: '#fff4e0', lightIntensity: 3.0 },
{ wall: 'left', x: 0, y: 2.8, width: 2.2, height: 1.4, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.6 }
),
}
),
31: mk(
'high-renaissance-palazzo',
'Roman palazzo',
'Marble and stucco · coffered ceiling · checker floor',
{ wall: 'marble-veined-carrara', wallSide: 'stucco-cream', ceiling: 'plaster-warm', floor: 'marble-checker' },
{ wall: '#f0ece4', wallSide: '#efe4d4', ceiling: '#faf6ee', floor: '#ddd8cc', trim: '#c9a227' },
{
details: 'palazzo',
titleColor: '#6b4423',
windows: windows(
{ wall: 'back', x: 0, y: 2.8, width: 2.8, height: 1.8, style: 'baroque-pair', lightColor: '#fff8f0', lightIntensity: 3.8 },
{ wall: 'left', x: 0, y: 3.0, width: 2.0, height: 1.5, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.8 },
{ wall: 'right', x: 0, y: 3.0, width: 2.0, height: 1.5, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.8 },
{ wall: 'ceiling', x: 0, y: 0, width: 3.0, height: 2.0, style: 'round-oculus', lightColor: '#ffffff', lightIntensity: 4.0 }
),
}
),
32: mk(
'northern-renaissance-hall',
'Flemish panel hall',
'Oak wainscoting · leaded glass · herringbone floor',
{ wall: 'oak-panel', wallSide: 'plaster-warm', ceiling: 'dark-wood-panel', floor: 'parquet-herringbone' },
{ wall: '#8a6848', wallSide: '#f0ebe3', ceiling: '#3a2818', floor: '#8a6848', trim: '#5c4030' },
{
details: 'salon',
titleColor: '#f0e8d8',
warmLight: '#ffe8c8',
windows: windows(
{ wall: 'back', x: -2, y: 2.5, width: 1.2, height: 1.5, style: 'sash', lightColor: '#e8f0ff', lightIntensity: 2.8 },
{ wall: 'back', x: 2, y: 2.5, width: 1.2, height: 1.5, style: 'sash', lightColor: '#e8f0ff', lightIntensity: 2.8 },
{ wall: 'left', x: 0, y: 2.6, width: 1.8, height: 1.4, style: 'sash', lightColor: '#e8f0ff', lightIntensity: 2.4 }
),
doorWood: ['#4a3020', '#5c3a28', '#3a2010'],
}
),
33: mk(
'mannerist-villa',
'Mannerist gallery',
'Dramatic stucco · elongated windows · veined marble',
{ wall: 'stucco-terracotta', ceiling: 'gilded-stucco', floor: 'marble-veined-emerald' },
{ wall: '#d8b898', ceiling: '#d8c070', floor: '#dce8e0', trim: '#b8860b' },
{
details: 'baroque',
titleColor: '#4a2818',
windows: windows(
{ wall: 'back', x: -1.8, y: 2.7, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#fff0d8', lightIntensity: 2.8 },
{ wall: 'back', x: 1.8, y: 2.7, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#fff0d8', lightIntensity: 2.8 },
{ wall: 'right', x: 0, y: 3.0, width: 1.6, height: 1.2, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.2 }
),
}
),
34: mk(
'baroque-palace',
'Baroque state gallery',
'Crimson velvet · gilded stucco · grand windows',
{ wall: 'velvet-crimson', ceiling: 'gilded-stucco', floor: 'marble-veined-carrara' },
{ wall: '#5c1828', ceiling: '#d8c070', floor: '#ece8e0', trim: '#d4af37' },
{
details: 'baroque',
titleColor: '#f0d890',
ambient: 0.58,
warmLight: '#ffd898',
fog: '#100808',
windows: windows(
{ wall: 'back', x: 0, y: 2.6, width: 3.2, height: 2.2, style: 'baroque-pair', lightColor: '#fff8e8', lightIntensity: 4.5 },
{ wall: 'left', x: 0, y: 2.8, width: 1.8, height: 1.8, style: 'baroque-pair', lightColor: '#fff8e8', lightIntensity: 3.2 },
{ wall: 'right', x: 0, y: 2.8, width: 1.8, height: 1.8, style: 'baroque-pair', lightColor: '#fff8e8', lightIntensity: 3.2 },
{ wall: 'ceiling', x: 0, y: 0, width: 2.5, height: 1.8, style: 'skylight', lightColor: '#ffffff', lightIntensity: 3.5 }
),
trackLights: 0.6,
doorWood: ['#1a1008', '#2a1810', '#120c06'],
}
),
35: mk(
'rococo-salon',
'Rococo salon',
'Pastel stucco · gilt trim · chevron parquet',
{ wall: 'pastel-plaster', ceiling: 'silk-pale', floor: 'parquet-chevrons' },
{ wall: '#e8dce8', ceiling: '#faf6f0', floor: '#c8a882', trim: '#d4af37' },
{
details: 'salon',
titleColor: '#6a4858',
warmLight: '#fff0f0',
windows: windows(
{ wall: 'back', x: -2, y: 2.5, width: 1.4, height: 1.8, style: 'baroque-pair', lightColor: '#fff8ff', lightIntensity: 3.4 },
{ wall: 'back', x: 2, y: 2.5, width: 1.4, height: 1.8, style: 'baroque-pair', lightColor: '#fff8ff', lightIntensity: 3.4 },
{ wall: 'ceiling', x: 0, y: 0, width: 2.0, height: 1.5, style: 'skylight', lightColor: '#ffffff', lightIntensity: 3.0 }
),
}
),
36: mk(
'neoclassical-museum',
'Neoclassical museum',
'White marble · coffered ceiling · skylit salon',
{ wall: 'marble-white', ceiling: 'plaster-white', floor: 'marble-veined-carrara' },
{ wall: '#f2f0ec', ceiling: '#fafafa', floor: '#eceae6', trim: '#b8b0a4' },
{
details: 'neoclassical',
titleColor: '#4a4844',
ambient: 0.65,
windows: windows(
{ wall: 'back', x: 0, y: 2.6, width: 2.8, height: 2.0, style: 'baroque-pair', lightColor: '#ffffff', lightIntensity: 4.0 },
{ wall: 'ceiling', x: 0, y: 0, width: 4.0, height: 2.5, style: 'skylight', lightColor: '#ffffff', lightIntensity: 5.0 },
{ wall: 'left', x: 0, y: 3.0, width: 2.0, height: 1.2, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.5 }
),
trackLights: 0.7,
}
),
37: mk(
'romantic-gothic-revival',
'Romantic gallery',
'Dark walnut paneling · pointed windows · Persian carpet tones',
{ wall: 'walnut-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
{ wall: '#5a4030', ceiling: '#2a2420', floor: '#6a5040', trim: '#8a7050' },
{
details: 'salon',
titleColor: '#e8dcc8',
ambient: 0.5,
warmLight: '#ffe0c0',
windows: windows(
{ wall: 'back', x: -1.5, y: 2.6, width: 0.9, height: 2.2, style: 'gothic-lancet', lightColor: '#c8d8ff', lightIntensity: 2.6 },
{ wall: 'back', x: 1.5, y: 2.6, width: 0.9, height: 2.2, style: 'gothic-lancet', lightColor: '#c8d8ff', lightIntensity: 2.6 },
{ wall: 'right', x: 0, y: 2.5, width: 1.4, height: 1.6, style: 'sash', lightColor: '#e8f0ff', lightIntensity: 2.2 }
),
}
),
38: mk(
'realist-bourgeois',
'Realist picture gallery',
'Warm plaster · bourgeois salon · oak parquet',
{ wall: 'plaster-warm', ceiling: 'plaster-warm', floor: 'parquet-herringbone' },
{ wall: '#e8e2d8', ceiling: '#f5f0e8', floor: '#a08060', trim: '#8a7050' },
{
details: 'salon',
windows: windows(
{ wall: 'back', x: 0, y: 2.5, width: 2.4, height: 1.6, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 3.2 },
{ wall: 'left', x: 0, y: 2.7, width: 1.6, height: 1.3, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.4 }
),
}
),
39: mk(
'impressionist-salon',
'Impressionist salon',
'North-light skylight · pale walls · herringbone floor',
{ wall: 'plaster-warm', ceiling: 'plaster-white', floor: 'parquet-herringbone' },
{ wall: '#f0ebe3', ceiling: '#fafafa', floor: '#c8a882', trim: '#c9a96e' },
{
details: 'salon',
ambient: 0.68,
windows: windows(
{ wall: 'ceiling', x: 0, y: 0, width: 5.0, height: 3.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 6.0 },
{ wall: 'back', x: -2, y: 2.6, width: 1.2, height: 1.4, style: 'sash', lightColor: '#f0f8ff', lightIntensity: 2.5 },
{ wall: 'back', x: 2, y: 2.6, width: 1.2, height: 1.4, style: 'sash', lightColor: '#f0f8ff', lightIntensity: 2.5 }
),
trackLights: 0.5,
}
),
40: mk(
'post-impressionist-atelier',
'Montmartre atelier',
'Studio walls · large north window · worn floorboards',
{ wall: 'plaster-warm', ceiling: 'plaster-warm', floor: 'parquet-herringbone' },
{ wall: '#e8e0d0', ceiling: '#f0ebe0', floor: '#9a7858', trim: '#8a6848' },
{
details: 'atelier',
windows: windows(
{ wall: 'left', x: 0, y: 2.4, width: 2.8, height: 2.0, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 4.5 },
{ wall: 'back', x: 0, y: 2.8, width: 1.0, height: 1.2, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.0 }
),
}
),
41: mk(
'symbolist-chamber',
'Symbolist chamber',
'Deep emerald walls · amber window glow · dark parquet',
{ wall: 'velvet-emerald', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
{ wall: '#1a4030', ceiling: '#1a1814', floor: '#4a3828', trim: '#8a7050' },
{
details: 'salon',
titleColor: '#d8e8c8',
ambient: 0.55,
warmLight: '#ffd898',
windows: windows(
{ wall: 'back', x: 0, y: 2.4, width: 1.0, height: 1.8, style: 'sash', lightColor: '#ffb860', lightIntensity: 2.0 },
{ wall: 'right', x: 0, y: 2.6, width: 0.8, height: 1.4, style: 'gothic-lancet', lightColor: '#ffd080', lightIntensity: 1.6 }
),
trackLights: 0.9,
}
),
42: mk(
'art-nouveau-salon',
'Art Nouveau salon',
'Organic plaster · stained glass · terrazzo floor',
{ wall: 'silk-pale', ceiling: 'silk-gold', floor: 'terrazzo' },
{ wall: '#f0ece4', ceiling: '#d8c890', floor: '#d8d0c4', trim: '#6a9868' },
{
details: 'salon',
titleColor: '#3a5840',
windows: windows(
{ wall: 'back', x: 0, y: 2.5, width: 2.2, height: 2.0, style: 'art-nouveau', lightColor: '#e8ffe8', lightIntensity: 3.2 },
{ wall: 'left', x: 0, y: 2.6, width: 1.4, height: 1.8, style: 'art-nouveau', lightColor: '#ffe8f0', lightIntensity: 2.4 },
{ wall: 'ceiling', x: 0, y: 0, width: 2.0, height: 1.2, style: 'skylight', lightColor: '#ffffff', lightIntensity: 2.8 }
),
}
),
43: mk(
'fauvist-studio',
'Fauvist studio',
'Bold warm plaster · flooded with color and light',
{ wall: 'stucco-terracotta', wallSide: 'silk-gold', ceiling: 'plaster-warm', floor: 'parquet-herringbone' },
{ wall: '#e89060', wallSide: '#e8c860', ceiling: '#f8f0e0', floor: '#a07048', trim: '#c04020' },
{
details: 'atelier',
titleColor: '#402010',
ambient: 0.7,
windows: windows(
{ wall: 'left', x: 0, y: 2.5, width: 3.0, height: 2.2, style: 'factory', lightColor: '#fff8f0', lightIntensity: 5.0 },
{ wall: 'back', x: 0, y: 2.8, width: 1.6, height: 1.2, style: 'sash', lightColor: '#fff0e0', lightIntensity: 2.5 }
),
}
),
44: mk(
'expressionist-room',
'Expressionist room',
'Angular wood panels · dramatic raking light',
{ wall: 'dark-wood-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
{ wall: '#3a2818', ceiling: '#2a2018', floor: '#5a4030', trim: '#8a6040' },
{
details: 'atelier',
titleColor: '#f0d8b0',
ambient: 0.52,
windows: windows(
{ wall: 'back', x: -1.5, y: 2.6, width: 1.0, height: 1.6, style: 'sash', lightColor: '#ffe8c0', lightIntensity: 2.8 },
{ wall: 'right', x: 0, y: 2.4, width: 2.0, height: 1.8, style: 'factory', lightColor: '#fff0d8', lightIntensity: 3.5 }
),
}
),
45: mk(
'cubist-studio',
'Cubist studio',
'Paris atelier · factory windows · raw plaster',
{ wall: 'plaster-warm', ceiling: 'plaster-white', floor: 'parquet-herringbone' },
{ wall: '#e8e4dc', ceiling: '#f5f5f5', floor: '#9a8870', trim: '#888888' },
{
details: 'atelier',
ambient: 0.65,
windows: windows(
{ wall: 'left', x: 0, y: 2.5, width: 3.5, height: 2.4, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 5.5 },
{ wall: 'back', x: 0, y: 3.0, width: 1.8, height: 1.0, style: 'clerestory', lightColor: '#ffffff', lightIntensity: 2.5 }
),
}
),
46: mk(
'futurist-loft',
'Futurist loft',
'Steel and glass · industrial concrete · sweeping daylight',
{ wall: 'concrete-raw', ceiling: 'concrete-raw', floor: 'industrial-floor' },
{ wall: '#a8a8a8', ceiling: '#989898', floor: '#888880', trim: '#606060' },
{
details: 'industrial',
titleColor: '#303030',
ambient: 0.62,
fog: '#181818',
windows: windows(
{ wall: 'back', x: 0, y: 2.6, width: 4.0, height: 2.4, style: 'factory', lightColor: '#ffffff', lightIntensity: 6.0 },
{ wall: 'left', x: 0, y: 2.8, width: 2.5, height: 1.6, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 3.5 },
{ wall: 'ceiling', x: 0, y: 0, width: 3.0, height: 2.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 4.0 }
),
doorWood: ['#606060', '#707070', '#505050'],
}
),
47: mk(
'suprematist-gallery',
'Suprematist white cube',
'Pure white volume · geometric light · polished floor',
{ wall: 'white-plaster', ceiling: 'white-plaster', floor: 'concrete-polished' },
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#e0e0e0', trim: '#cccccc' },
{
details: 'modern',
titleColor: '#222222',
ambient: 0.72,
fog: '#1a1a1a',
background: '#111111',
windows: windows(
{ wall: 'ceiling', x: 0, y: 0, width: 4.5, height: 3.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 6.5 },
{ wall: 'back', x: 0, y: 2.8, width: 2.0, height: 1.2, style: 'clerestory', lightColor: '#ffffff', lightIntensity: 3.0 }
),
trackLights: 0.4,
doorWood: ['#aaaaaa', '#bbbbbb', '#999999'],
}
),
48: mk(
'constructivist-space',
'Constructivist space',
'Glass block · concrete · angular daylight',
{ wall: 'concrete-block', ceiling: 'concrete-raw', floor: 'concrete-polished' },
{ wall: '#b0b0b0', ceiling: '#a0a0a0', floor: '#c0c0c0', trim: '#808080' },
{
details: 'industrial',
titleColor: '#303030',
ambient: 0.65,
windows: windows(
{ wall: 'back', x: 0, y: 2.5, width: 2.8, height: 2.0, style: 'glass-block', lightColor: '#f0f8ff', lightIntensity: 4.0 },
{ wall: 'left', x: 0, y: 2.6, width: 2.0, height: 1.8, style: 'glass-block', lightColor: '#f0f8ff', lightIntensity: 3.2 },
{ wall: 'ceiling', x: 0, y: 0, width: 2.5, height: 1.5, style: 'skylight', lightColor: '#ffffff', lightIntensity: 3.5 }
),
doorWood: ['#707070', '#808080', '#606060'],
}
),
49: mk(
'dada-salon',
'Dada salon',
'Eclectic bourgeois room · mismatched light sources',
{ wall: 'plaster-warm', wallSide: 'brick', ceiling: 'plaster-warm', floor: 'parquet-herringbone' },
{ wall: '#e8dcc8', wallSide: '#8a5040', ceiling: '#f0ebe0', floor: '#8a6848', trim: '#6a4830' },
{
details: 'salon',
windows: windows(
{ wall: 'back', x: -1.5, y: 2.5, width: 1.0, height: 1.4, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.5 },
{ wall: 'back', x: 1.5, y: 2.6, width: 0.8, height: 1.8, style: 'gothic-lancet', lightColor: '#ffe8c0', lightIntensity: 2.0 },
{ wall: 'right', x: 0, y: 2.4, width: 1.6, height: 1.6, style: 'factory', lightColor: '#ffffff', lightIntensity: 3.0 }
),
trackLights: 1.0,
}
),
50: mk(
'surrealist-interior',
'Surrealist interior',
'Bourgeois wallpaper tones · uncanny warm light',
{ wall: 'velvet-navy', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
{ wall: '#1a2848', ceiling: '#2a2428', floor: '#5a4838', trim: '#8a7050' },
{
details: 'salon',
titleColor: '#e8dcc8',
ambient: 0.58,
warmLight: '#ffd898',
windows: windows(
{ wall: 'back', x: 0, y: 2.5, width: 1.4, height: 1.6, style: 'sash', lightColor: '#ffe8c8', lightIntensity: 2.8 },
{ wall: 'left', x: 0, y: 2.7, width: 1.0, height: 1.2, style: 'sash', lightColor: '#c8e0ff', lightIntensity: 2.0 },
{ wall: 'ceiling', x: 0, y: 0, width: 1.5, height: 1.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 2.5 }
),
trackLights: 0.85,
}
),
51: mk(
'abstract-expressionist-loft',
'NYC loft',
'Raw brick and plaster · north-light factory windows',
{ wall: 'brick', wallSide: 'plaster-white', ceiling: 'concrete-raw', floor: 'industrial-floor' },
{ wall: '#8a5040', wallSide: '#f5f5f5', ceiling: '#989898', floor: '#888880', trim: '#606060' },
{
details: 'industrial',
titleColor: '#303030',
ambient: 0.65,
windows: windows(
{ wall: 'left', x: 0, y: 2.5, width: 4.0, height: 2.6, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 6.5 },
{ wall: 'ceiling', x: 0, y: 0, width: 3.5, height: 2.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 4.5 }
),
}
),
52: mk(
'pop-art-gallery',
'Pop Art gallery',
'White cube · fluorescent daylight · polished concrete',
{ wall: 'studio-white', ceiling: 'studio-white', floor: 'concrete-polished' },
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#d8d8d8', trim: '#cccccc' },
{
details: 'modern',
titleColor: '#222222',
ambient: 0.75,
warmLight: '#ffffff',
fog: '#1a1a1a',
background: '#101010',
windows: windows(
{ wall: 'back', x: 0, y: 2.6, width: 3.0, height: 2.0, style: 'factory', lightColor: '#ffffff', lightIntensity: 5.5 },
{ wall: 'ceiling', x: 0, y: 0, width: 5.0, height: 3.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 6.0 }
),
trackLights: 0.6,
doorWood: ['#888888', '#999999', '#777777'],
}
),
};
function blendAccent(style: MovementInteriorStyle, accentHex?: string): MovementInteriorStyle {
if (!accentHex) return style;
const w = 0.08;
const mix = (a: string, b: string) => {
const pa = parseInt(a.replace('#', ''), 16);
const pb = parseInt(b.replace('#', ''), 16);
const r = Math.round(((pa >> 16) & 255) * w + ((pb >> 16) & 255) * (1 - w));
const g = Math.round(((pa >> 8) & 255) * w + ((pb >> 8) & 255) * (1 - w));
const bl = Math.round((pa & 255) * w + (pb & 255) * (1 - w));
return `#${((r << 16) | (g << 8) | bl).toString(16).padStart(6, '0')}`;
};
return {
...style,
tints: {
...style.tints,
wall: mix(accentHex, style.tints.wall),
wallSide: style.tints.wallSide ? mix(accentHex, style.tints.wallSide) : undefined,
trim: mix(accentHex, style.tints.trim),
},
};
}
/** Fallback name-based resolver for movements not in the catalog. */
function fallbackByName(movement: ArtMovement & { era_name?: string }): MovementInteriorStyle {
const n = movement.name.toLowerCase();
const era = (movement.era_name ?? '').toLowerCase();
if (n.includes('renaissance')) return BY_MOVEMENT_ID[31];
if (n.includes('baroque') || n.includes('rococo')) return BY_MOVEMENT_ID[34];
if (era.includes('medieval') || n.includes('gothic')) return BY_MOVEMENT_ID[29];
if (n.includes('impression')) return BY_MOVEMENT_ID[39];
if (era.includes('modern') || era.includes('contemporary')) return BY_MOVEMENT_ID[52];
return BY_MOVEMENT_ID[36];
}
export function resolveMovementInteriorStyle(
movement: ArtMovement & { era_name?: string }
): MovementInteriorStyle {
const base = BY_MOVEMENT_ID[movement.id] ?? fallbackByName(movement);
return blendAccent(base, movement.color);
}
/** @deprecated use surfaces.floor — kept for transitional imports */
export type GalleryFloorKind = string;
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useMemo } from 'react';
import * as THREE from 'three';
import { cloneSurfaceTexture, getSurfaceTexture, type SurfaceTextureKind } from '../utils/galleryProceduralTextures';
export function useTexturedMaterial(
kind: SurfaceTextureKind,
tint: string,
spanW: number,
spanH: number
): THREE.MeshStandardMaterial {
const material = useMemo(() => {
const meters = getSurfaceTexture(kind).metersPerRepeat;
const surf = cloneSurfaceTexture(
kind,
Math.max(1, spanW / meters),
Math.max(1, spanH / meters)
);
return new THREE.MeshStandardMaterial({
map: surf.map,
normalMap: surf.normalMap,
color: tint,
roughness: surf.roughness,
metalness: surf.metalness,
envMapIntensity: 0.65,
});
}, [kind, tint, spanW, spanH]);
useEffect(() => () => material.dispose(), [material]);
return material;
}
+11
View File
@@ -11,6 +11,17 @@
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
z-index: 1;
}
.home-timeline-stack {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
.gallery-session-suspended {
+153 -55
View File
@@ -1,12 +1,13 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import Timeline from '../components/Timeline';
import TimelineEventGuides from '../components/TimelineEventGuides';
import MovementBands from '../components/MovementBands';
import VirtualGallery from '../components/VirtualGallery';
import PaintingDetailView from '../components/PaintingDetail';
import ArtistBio from '../components/ArtistBio';
import CheckupPage from '../pages/CheckupPage';
import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail } from '../types';
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types';
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
import { readDebugMode, writeDebugMode } from '../utils/debugMode';
import './HomePage.css';
@@ -15,9 +16,25 @@ type View =
| { type: 'timeline' }
| { type: 'checkup' }
| { type: 'gallery'; artistId: number; data: ArtistDetail }
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
| { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View }
| { type: 'bio'; artistId: number; data: ArtistDetail; returnTo: View };
type GallerySession =
| { kind: 'artist'; artistId: number; data: ArtistDetail }
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail };
function patchPaintingInMovementDetail(
detail: MovementGalleryDetail,
paintingId: number,
patch: Partial<Painting>
): MovementGalleryDetail {
return {
...detail,
paintings: detail.paintings.map((p) => (p.id === paintingId ? { ...p, ...patch } : p)),
};
}
function patchPaintingInArtistDetail(
detail: ArtistDetail,
paintingId: number,
@@ -38,9 +55,7 @@ function patchArtistInArtistDetail(detail: ArtistDetail, patch: Partial<Artist>)
export default function HomePage() {
const [view, setView] = useState<View>({ type: 'timeline' });
const [gallerySession, setGallerySession] = useState<{ artistId: number; data: ArtistDetail } | null>(
null
);
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
const [bounds, setBounds] = useState({ min: -800, max: 2025 });
const [viewStart, setViewStart] = useState(-800);
const [viewEnd, setViewEnd] = useState(2025);
@@ -52,6 +67,11 @@ export default function HomePage() {
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
const [portraitRevisions, setPortraitRevisions] = useState<Record<number, number>>({});
const [debugMode, setDebugMode] = useState(readDebugMode);
const [hoveredLifespan, setHoveredLifespan] = useState<{
birthYear: number;
deathYear: number;
color: string;
} | null>(null);
const detailReturnToRef = useRef<View>({ type: 'timeline' });
useEffect(() => {
@@ -68,7 +88,9 @@ export default function HomePage() {
useEffect(() => {
if (view.type === 'gallery') {
setGallerySession({ artistId: view.artistId, data: view.data });
setGallerySession({ kind: 'artist', artistId: view.artistId, data: view.data });
} else if (view.type === 'movement-gallery') {
setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data });
} else if (view.type === 'timeline') {
setGallerySession(null);
}
@@ -132,6 +154,12 @@ export default function HomePage() {
data: patchPaintingInArtistDetail(returnTo.data, paintingId, patch),
};
}
if (returnTo.type === 'movement-gallery') {
returnTo = {
...returnTo,
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
};
}
return { ...current, data: updatedData, returnTo };
});
@@ -139,11 +167,15 @@ export default function HomePage() {
list.map((p) => (p.id === paintingId ? { ...p, ...patch } : p))
);
setGallerySession((session) =>
session && session.artistId === data.painting.artist_id
? { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) }
: session
);
setGallerySession((session) => {
if (session?.kind === 'artist' && session.artistId === data.painting.artist_id) {
return { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) };
}
if (session?.kind === 'movement') {
return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) };
}
return session;
});
}, []);
const handlePaintingCheckupFlagsUpdated = useCallback(
@@ -167,6 +199,12 @@ export default function HomePage() {
data: patchPaintingInArtistDetail(returnTo.data, paintingId, patch),
};
}
if (returnTo.type === 'movement-gallery') {
returnTo = {
...returnTo,
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
};
}
return { ...current, data: updatedData, returnTo };
});
@@ -174,11 +212,15 @@ export default function HomePage() {
list.map((p) => (p.id === paintingId ? { ...p, ...patch } : p))
);
setGallerySession((session) =>
session && session.artistId === data.painting.artist_id
? { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) }
: session
);
setGallerySession((session) => {
if (session?.kind === 'artist' && session.artistId === data.painting.artist_id) {
return { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) };
}
if (session?.kind === 'movement') {
return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) };
}
return session;
});
},
[]
);
@@ -209,7 +251,7 @@ export default function HomePage() {
});
setGallerySession((session) =>
session && session.artistId === artistId
session?.kind === 'artist' && session.artistId === artistId
? { ...session, data: patchArtistInArtistDetail(session.data, patch) }
: session
);
@@ -253,13 +295,23 @@ export default function HomePage() {
const handleArtistClick = async (artistId: number) => {
try {
const data = await api.getArtist(artistId);
setGallerySession({ artistId, data });
setGallerySession({ kind: 'artist', artistId, data });
setView({ type: 'gallery', artistId, data });
} catch {
setError('Failed to load artist gallery.');
}
};
const handleMovementClick = async (movementId: number) => {
try {
const data = await api.getMovementGallery(movementId);
setGallerySession({ kind: 'movement', movementId, data });
setView({ type: 'movement-gallery', movementId, data });
} catch {
setError('Failed to load movement gallery.');
}
};
const handlePaintingClick = async (paintingId: number) => {
try {
const data = await api.getPainting(paintingId);
@@ -303,11 +355,17 @@ export default function HomePage() {
}
const artistId = view.data.painting.artist_id;
if (gallerySession?.artistId === artistId) {
if (gallerySession?.kind === 'artist' && gallerySession.artistId === artistId) {
setDetailArtistPaintings(gallerySession.data.paintings);
return;
}
const returnTo = detailReturnToRef.current;
if (returnTo.type === 'movement-gallery') {
setDetailArtistPaintings(sortArtistPaintingsChronological(returnTo.data.paintings));
return;
}
let cancelled = false;
api.getArtist(artistId)
.then((data) => {
@@ -327,21 +385,39 @@ export default function HomePage() {
[detailArtistPaintings]
);
const galleryActive = view.type === 'gallery';
const galleryActive = view.type === 'gallery' || view.type === 'movement-gallery';
return (
<>
{gallerySession && (
<div className={galleryActive ? undefined : 'gallery-session-suspended'} aria-hidden={!galleryActive}>
<VirtualGallery
data={gallerySession.data}
imageRevisions={imageRevisions}
active={galleryActive}
onPaintingClick={handlePaintingClick}
onNavigateArtist={handleArtistClick}
onBack={() => setView({ type: 'timeline' })}
onBioClick={() => handleBioClick(gallerySession.data, { type: 'gallery', ...gallerySession })}
/>
{gallerySession.kind === 'artist' ? (
<VirtualGallery
mode="artist"
data={gallerySession.data}
imageRevisions={imageRevisions}
active={galleryActive}
onPaintingClick={handlePaintingClick}
onNavigateArtist={handleArtistClick}
onBack={() => setView({ type: 'timeline' })}
onBioClick={() =>
handleBioClick(gallerySession.data, {
type: 'gallery',
artistId: gallerySession.artistId,
data: gallerySession.data,
})
}
/>
) : (
<VirtualGallery
mode="movement"
data={gallerySession.data}
imageRevisions={imageRevisions}
active={galleryActive}
onPaintingClick={handlePaintingClick}
onBack={() => setView({ type: 'timeline' })}
/>
)}
</div>
)}
@@ -352,12 +428,26 @@ export default function HomePage() {
artistPaintings={sortedDetailArtistPaintings}
onBack={() => {
const returnTo = view.returnTo;
if (returnTo.type === 'gallery' && gallerySession?.artistId === returnTo.artistId) {
if (
returnTo.type === 'gallery' &&
gallerySession?.kind === 'artist' &&
gallerySession.artistId === returnTo.artistId
) {
setView({
type: 'gallery',
artistId: gallerySession.artistId,
data: gallerySession.data,
});
} else if (
returnTo.type === 'movement-gallery' &&
gallerySession?.kind === 'movement' &&
gallerySession.movementId === returnTo.movementId
) {
setView({
type: 'movement-gallery',
movementId: gallerySession.movementId,
data: gallerySession.data,
});
} else {
setView(returnTo);
}
@@ -424,34 +514,42 @@ export default function HomePage() {
<p className="site-subtitle">Watch art movements branch forward through time each flowing from what came before</p>
</header>
<Timeline
eras={timelineData.eras}
viewStart={viewStart}
viewEnd={viewEnd}
onViewChange={handleViewChange}
absoluteMin={bounds.min}
absoluteMax={bounds.max}
/>
<div className="home-timeline-stack">
<Timeline
eras={timelineData.eras}
viewStart={viewStart}
viewEnd={viewEnd}
onViewChange={handleViewChange}
absoluteMin={bounds.min}
absoluteMax={bounds.max}
lifespanHighlight={hoveredLifespan}
/>
{error && <div className="error-banner">{error}</div>}
{error && <div className="error-banner">{error}</div>}
{loading ? (
<div className="loading home-movements-section">Loading art history...</div>
) : (
<div className="home-movements-section">
<MovementBands
movements={timelineData.movements}
artists={artists}
portraitRevisions={portraitRevisions}
viewStart={viewStart}
viewEnd={viewEnd}
absoluteMin={bounds.min}
absoluteMax={bounds.max}
onViewChange={handleViewChange}
onArtistClick={handleArtistClick}
/>
</div>
)}
{loading ? (
<div className="loading home-movements-section">Loading art history...</div>
) : (
<>
<TimelineEventGuides viewStart={viewStart} viewEnd={viewEnd} />
<div className="home-movements-section">
<MovementBands
movements={timelineData.movements}
artists={artists}
portraitRevisions={portraitRevisions}
viewStart={viewStart}
viewEnd={viewEnd}
absoluteMin={bounds.min}
absoluteMax={bounds.max}
onViewChange={handleViewChange}
onArtistClick={handleArtistClick}
onMovementClick={handleMovementClick}
onArtistHover={setHoveredLifespan}
/>
</div>
</>
)}
</div>
</div>
)}
</>
+20
View File
@@ -96,10 +96,25 @@ export interface InfluenceLink {
movement_color?: string;
}
export interface PaintingAnnotation {
id: number;
label: string | null;
body: string;
category: string;
pos_x: number | null;
pos_y: number | null;
source_author: string | null;
source: string | null;
source_url: string | null;
sort_order: number;
confidence?: string;
}
export interface PaintingDetail {
painting: Painting & { artist_name: string; artist_portrait: string | null };
influencedBy: InfluenceLink[];
influenced: InfluenceLink[];
annotations?: PaintingAnnotation[];
}
export interface ArtistDetail {
@@ -108,6 +123,11 @@ export interface ArtistDetail {
paintings: Painting[];
}
export interface MovementGalleryDetail {
movement: ArtMovement & { era_name?: string };
paintings: Painting[];
}
export interface TimelineData {
eras: HistoricalEra[];
movements: ArtMovement[];
+28
View File
@@ -0,0 +1,28 @@
import * as THREE from 'three';
import { PARQUET_METERS_PER_TILE } from './parquetFloorTexture';
import { getSurfaceTexture, type SurfaceTextureKind } from './galleryProceduralTextures';
export { PARQUET_METERS_PER_TILE };
export type GalleryFloorKind = SurfaceTextureKind | 'parquet' | 'marble-checker' | 'marble-veined' | 'concrete' | 'mosaic';
const LEGACY_MAP: Record<string, SurfaceTextureKind> = {
parquet: 'parquet-herringbone',
'marble-checker': 'marble-checker',
'marble-veined': 'marble-veined-carrara',
flagstone: 'flagstone',
terrazzo: 'terrazzo',
concrete: 'concrete-polished',
mosaic: 'mosaic-roman',
};
function resolveKind(kind: GalleryFloorKind): SurfaceTextureKind {
return LEGACY_MAP[kind as string] ?? (kind as SurfaceTextureKind);
}
export function getGalleryFloorTexture(kind: GalleryFloorKind): THREE.CanvasTexture {
return getSurfaceTexture(resolveKind(kind)).map;
}
export function floorMetersPerTile(kind: GalleryFloorKind): number {
return getSurfaceTexture(resolveKind(kind)).metersPerRepeat;
}
@@ -0,0 +1,483 @@
import * as THREE from 'three';
/** Hi-res procedural textures for photo-realistic gallery surfaces. */
export const TEXTURE_SIZE = 1024;
export type SurfaceTextureKind =
| 'marble-white'
| 'marble-veined-carrara'
| 'marble-veined-emerald'
| 'marble-checker'
| 'limestone'
| 'sandstone'
| 'rough-stone'
| 'basalt'
| 'stucco-cream'
| 'stucco-terracotta'
| 'plaster-white'
| 'plaster-warm'
| 'velvet-crimson'
| 'velvet-navy'
| 'velvet-emerald'
| 'silk-gold'
| 'silk-pale'
| 'oak-panel'
| 'walnut-panel'
| 'dark-wood-panel'
| 'parquet-herringbone'
| 'parquet-chevrons'
| 'terracotta-tiles'
| 'flagstone'
| 'mosaic-byzantine'
| 'mosaic-roman'
| 'terrazzo'
| 'concrete-polished'
| 'concrete-raw'
| 'industrial-floor'
| 'gilded-stucco'
| 'fresco-worn'
| 'brick'
| 'concrete-block'
| 'dark-plaster'
| 'pastel-plaster'
| 'white-plaster'
| 'studio-white';
export interface SurfaceTextureSet {
map: THREE.CanvasTexture;
normalMap: THREE.CanvasTexture;
roughness: number;
metalness: number;
metersPerRepeat: number;
}
const cache = new Map<string, SurfaceTextureSet>();
function seeded(seed: number) {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
function hexToRgb(hex: string): [number, number, number] {
const n = parseInt(hex.replace('#', ''), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function rgb(r: number, g: number, b: number) {
return `rgb(${r | 0},${g | 0},${b | 0})`;
}
function shadeHex(hex: string, amount: number): string {
const [r, g, b] = hexToRgb(hex);
const clamp = (v: number) => Math.min(255, Math.max(0, v + amount));
return rgb(clamp(r), clamp(g), clamp(b));
}
function fill(ctx: CanvasRenderingContext2D, size: number, color: string) {
ctx.fillStyle = color;
ctx.fillRect(0, 0, size, size);
}
function noiseOverlay(ctx: CanvasRenderingContext2D, size: number, alpha: number, seed: number) {
const rand = seeded(seed);
const img = ctx.getImageData(0, 0, size, size);
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
const n = (rand() - 0.5) * alpha * 255;
d[i] += n;
d[i + 1] += n;
d[i + 2] += n;
}
ctx.putImageData(img, 0, 0);
}
function marbleVeined(ctx: CanvasRenderingContext2D, size: number, base: string, vein: string, seed: number) {
fill(ctx, size, base);
const rand = seeded(seed);
for (let i = 0; i < 28; i++) {
ctx.strokeStyle = vein.replace(')', `,${0.06 + rand() * 0.14})`).replace('rgb', 'rgba');
if (vein.startsWith('#')) {
const [r, g, b] = hexToRgb(vein);
ctx.strokeStyle = `rgba(${r},${g},${b},${0.05 + rand() * 0.12})`;
}
ctx.lineWidth = 1 + rand() * 5;
ctx.beginPath();
let x = rand() * size;
let y = rand() * size;
ctx.moveTo(x, y);
for (let s = 0; s < 10; s++) {
x += (rand() - 0.5) * size * 0.18;
y += (rand() - 0.5) * size * 0.12;
ctx.lineTo(x, y);
}
ctx.stroke();
}
noiseOverlay(ctx, size, 0.04, seed + 1);
}
function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d')!;
switch (kind) {
case 'marble-white':
case 'marble-veined-carrara':
marbleVeined(ctx, size, '#ece8e0', '#a8a098', 101);
break;
case 'marble-veined-emerald':
marbleVeined(ctx, size, '#dce8e0', '#4a7868', 102);
break;
case 'marble-checker': {
const tile = size / 10;
for (let row = 0; row < 10; row++) {
for (let col = 0; col < 10; col++) {
const light = (row + col) % 2 === 0;
ctx.fillStyle = light ? '#f2eee6' : '#4a6858';
ctx.fillRect(col * tile, row * tile, tile, tile);
ctx.strokeStyle = 'rgba(0,0,0,0.06)';
ctx.strokeRect(col * tile + 0.5, row * tile + 0.5, tile - 1, tile - 1);
}
}
noiseOverlay(ctx, size, 0.03, 103);
break;
}
case 'limestone':
fill(ctx, size, '#ddd4c4');
noiseOverlay(ctx, size, 0.08, 104);
break;
case 'sandstone':
fill(ctx, size, '#c8b090');
noiseOverlay(ctx, size, 0.1, 105);
break;
case 'rough-stone':
fill(ctx, size, '#7a7468');
for (let row = 0; row < 6; row++) {
for (let col = 0; col < 6; col++) {
const tones = ['#6a6458', '#8a8478', '#5a5448'];
ctx.fillStyle = tones[(row + col) % 3];
const w = size / 6 + (row % 2 ? 6 : -4);
ctx.fillRect(col * (size / 6), row * (size / 6), w, size / 6);
}
}
noiseOverlay(ctx, size, 0.12, 106);
break;
case 'basalt':
fill(ctx, size, '#3a3834');
noiseOverlay(ctx, size, 0.15, 107);
break;
case 'stucco-cream':
fill(ctx, size, '#f0e8d8');
noiseOverlay(ctx, size, 0.06, 108);
break;
case 'stucco-terracotta':
fill(ctx, size, '#c89070');
noiseOverlay(ctx, size, 0.08, 109);
break;
case 'plaster-white':
case 'white-plaster':
case 'studio-white':
fill(ctx, size, '#f8f8f6');
noiseOverlay(ctx, size, 0.035, 110);
break;
case 'plaster-warm':
fill(ctx, size, '#f2ebe0');
noiseOverlay(ctx, size, 0.045, 111);
break;
case 'dark-plaster':
fill(ctx, size, '#3a3230');
noiseOverlay(ctx, size, 0.07, 112);
break;
case 'pastel-plaster':
fill(ctx, size, '#e8dce8');
noiseOverlay(ctx, size, 0.05, 113);
break;
case 'velvet-crimson':
fill(ctx, size, '#5c1828');
noiseOverlay(ctx, size, 0.18, 114);
break;
case 'velvet-navy':
fill(ctx, size, '#1a2848');
noiseOverlay(ctx, size, 0.18, 115);
break;
case 'velvet-emerald':
fill(ctx, size, '#1a4030');
noiseOverlay(ctx, size, 0.18, 116);
break;
case 'silk-gold':
fill(ctx, size, '#d8c890');
noiseOverlay(ctx, size, 0.06, 117);
break;
case 'silk-pale':
fill(ctx, size, '#f0ece4');
noiseOverlay(ctx, size, 0.05, 118);
break;
case 'oak-panel':
case 'walnut-panel':
case 'dark-wood-panel': {
const base = kind === 'oak-panel' ? '#8a6848' : kind === 'walnut-panel' ? '#5a4030' : '#3a2818';
const plankH = size / 14;
for (let i = 0; i < 14; i++) {
const grad = ctx.createLinearGradient(0, i * plankH, size, i * plankH);
grad.addColorStop(0, shadeHex(base, -15));
grad.addColorStop(0.5, base);
grad.addColorStop(1, shadeHex(base, -10));
ctx.fillStyle = grad;
ctx.fillRect(0, i * plankH, size, plankH - 2);
ctx.strokeStyle = 'rgba(0,0,0,0.25)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, i * plankH);
ctx.lineTo(size, i * plankH);
ctx.stroke();
for (let g = 0; g < 12; g++) {
ctx.strokeStyle = 'rgba(0,0,0,0.08)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(g * (size / 12), i * plankH);
ctx.lineTo(g * (size / 12) + size * 0.08, i * plankH + plankH);
ctx.stroke();
}
}
break;
}
case 'parquet-herringbone':
case 'parquet-chevrons': {
const tones = ['#6b4a2e', '#735234', '#624428', '#7a5638'];
const plankW = size / 24;
const plankL = size / 8;
for (let row = 0; row < 16; row++) {
for (let col = 0; col < 16; col++) {
ctx.save();
ctx.translate(col * plankW * 1.4, row * plankW * 1.4);
ctx.rotate(kind === 'parquet-chevrons' ? Math.PI / 4 : (col + row) % 2 ? Math.PI / 4 : -Math.PI / 4);
ctx.fillStyle = tones[(row + col) % tones.length];
ctx.fillRect(-plankL / 2, -plankW / 2, plankL, plankW);
ctx.restore();
}
}
noiseOverlay(ctx, size, 0.04, 119);
break;
}
case 'terracotta-tiles': {
const tile = size / 8;
for (let row = 0; row < 8; row++) {
for (let col = 0; col < 8; col++) {
ctx.fillStyle = ['#b87050', '#c88058', '#a86048'][ (row + col) % 3];
ctx.fillRect(col * tile + 2, row * tile + 2, tile - 4, tile - 4);
}
}
break;
}
case 'flagstone':
fill(ctx, size, '#5a5448');
for (let i = 0; i < 30; i++) {
const rand = seeded(120 + i);
const w = size * (0.12 + rand() * 0.15);
const h = size * (0.1 + rand() * 0.12);
ctx.fillStyle = ['#6a6458', '#7a7468', '#625c50'][i % 3];
ctx.fillRect(rand() * (size - w), rand() * (size - h), w, h);
}
break;
case 'mosaic-byzantine': {
fill(ctx, size, '#1a1814');
const cell = size / 32;
const colors = ['#d4af37', '#8a3020', '#2060a0', '#f0ece0', '#408040'];
for (let y = 0; y < size; y += cell) {
for (let x = 0; x < size; x += cell) {
ctx.fillStyle = colors[((x / cell) + (y / cell)) % colors.length];
ctx.fillRect(x + 1, y + 1, cell - 2, cell - 2);
}
}
break;
}
case 'mosaic-roman': {
fill(ctx, size, '#c8b898');
const cell = 20;
const colors = ['#8a7060', '#a88870', '#706050', '#d0c0a0'];
for (let y = 0; y < size; y += cell) {
for (let x = 0; x < size; x += cell) {
ctx.fillStyle = colors[((x / cell) + (y / cell)) % colors.length];
ctx.fillRect(x + 1, y + 1, cell - 2, cell - 2);
}
}
break;
}
case 'terrazzo':
fill(ctx, size, '#d8d0c4');
for (let i = 0; i < 3000; i++) {
const rand = seeded(121 + i);
ctx.fillStyle = ['#a89888', '#c8b8a8', '#888078'][i % 3];
ctx.beginPath();
ctx.arc(rand() * size, rand() * size, 2 + rand() * 6, 0, Math.PI * 2);
ctx.fill();
}
break;
case 'concrete-polished':
fill(ctx, size, '#c8c8c8');
noiseOverlay(ctx, size, 0.06, 122);
break;
case 'concrete-raw':
case 'concrete-block':
fill(ctx, size, '#a8a8a8');
noiseOverlay(ctx, size, 0.1, 123);
if (kind === 'concrete-block') {
ctx.strokeStyle = 'rgba(0,0,0,0.2)';
ctx.lineWidth = 3;
for (let y = 0; y < size; y += size / 6) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(size, y);
ctx.stroke();
}
for (let x = 0; x < size; x += size / 4) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, size);
ctx.stroke();
}
}
break;
case 'industrial-floor':
fill(ctx, size, '#888880');
noiseOverlay(ctx, size, 0.12, 124);
break;
case 'gilded-stucco':
fill(ctx, size, '#d8c070');
noiseOverlay(ctx, size, 0.05, 125);
break;
case 'fresco-worn':
fill(ctx, size, '#d0c8b8');
noiseOverlay(ctx, size, 0.09, 126);
break;
case 'brick': {
const bh = size / 16;
const bw = size / 8;
fill(ctx, size, '#6a4030');
for (let row = 0; row < 16; row++) {
const offset = row % 2 ? bw / 2 : 0;
for (let col = -1; col < 9; col++) {
ctx.fillStyle = ['#8a5040', '#7a4838', '#9a5848'][(row + col) % 3];
ctx.fillRect(col * bw + offset, row * bh, bw - 3, bh - 3);
}
}
break;
}
default:
fill(ctx, size, '#e8e4dc');
noiseOverlay(ctx, size, 0.05, 999);
}
return ctx.getImageData(0, 0, size, size);
}
function imageDataToNormalMap(data: ImageData, size: number, strength = 2.5): ImageData {
const out = new ImageData(size, size);
const src = data.data;
const dst = out.data;
const idx = (x: number, y: number) => ((y * size + x) * 4);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const xl = src[idx(Math.max(0, x - 1), y)];
const xr = src[idx(Math.min(size - 1, x + 1), y)];
const yt = src[idx(x, Math.max(0, y - 1))];
const yb = src[idx(x, Math.min(size - 1, y + 1))];
const dx = (xr - xl) / 255;
const dy = (yb - yt) / 255;
let nx = -dx * strength;
let ny = -dy * strength;
let nz = 1;
const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
nx /= len;
ny /= len;
nz /= len;
const i = idx(x, y);
dst[i] = ((nx + 1) * 0.5 * 255) | 0;
dst[i + 1] = ((ny + 1) * 0.5 * 255) | 0;
dst[i + 2] = ((nz + 1) * 0.5 * 255) | 0;
dst[i + 3] = 255;
}
}
return out;
}
function imageDataToTexture(data: ImageData): THREE.CanvasTexture {
const canvas = document.createElement('canvas');
canvas.width = data.width;
canvas.height = data.height;
canvas.getContext('2d')!.putImageData(data, 0, 0);
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 16;
return tex;
}
const ROUGHNESS: Partial<Record<SurfaceTextureKind, number>> = {
'marble-white': 0.18,
'marble-veined-carrara': 0.16,
'marble-veined-emerald': 0.18,
'marble-checker': 0.14,
'gilded-stucco': 0.22,
'velvet-crimson': 0.92,
'velvet-navy': 0.92,
'velvet-emerald': 0.92,
'concrete-polished': 0.28,
'concrete-raw': 0.72,
'oak-panel': 0.55,
'parquet-herringbone': 0.42,
};
const METALNESS: Partial<Record<SurfaceTextureKind, number>> = {
'marble-white': 0.08,
'marble-veined-carrara': 0.1,
'gilded-stucco': 0.65,
'concrete-polished': 0.12,
'terrazzo': 0.15,
};
const METERS: Partial<Record<SurfaceTextureKind, number>> = {
'marble-checker': 2.4,
'flagstone': 3.0,
'mosaic-byzantine': 1.8,
'mosaic-roman': 2.0,
'parquet-herringbone': 1.8,
'parquet-chevrons': 1.8,
'concrete-raw': 4.0,
'brick': 2.5,
};
export function getSurfaceTexture(kind: SurfaceTextureKind): SurfaceTextureSet {
const cached = cache.get(kind);
if (cached) return cached;
const colorData = paintSurface(kind, TEXTURE_SIZE);
const normalData = imageDataToNormalMap(colorData, TEXTURE_SIZE, kind.includes('velvet') ? 1.2 : 2.8);
const set: SurfaceTextureSet = {
map: imageDataToTexture(colorData),
normalMap: imageDataToTexture(normalData),
roughness: ROUGHNESS[kind] ?? 0.75,
metalness: METALNESS[kind] ?? 0.04,
metersPerRepeat: METERS[kind] ?? 2.8,
};
cache.set(kind, set);
return set;
}
export function cloneSurfaceTexture(kind: SurfaceTextureKind, repeatW: number, repeatH: number): SurfaceTextureSet {
const base = getSurfaceTexture(kind);
const map = base.map.clone();
const normalMap = base.normalMap.clone();
map.repeat.set(repeatW, repeatH);
normalMap.repeat.set(repeatW, repeatH);
map.needsUpdate = true;
normalMap.needsUpdate = true;
return { ...base, map, normalMap };
}
+273
View File
@@ -0,0 +1,273 @@
import type { Painting } from '../types';
import type { GalleryWindowSpec, MovementInteriorStyle } from '../data/movement-interior-styles';
import { comparePaintingsChronological } from './paintingUtils';
/** Target capacity per movement wing (5060 works). */
export const MOVEMENT_PAINTINGS_PER_HALL = 55;
export type WallSide = 'back' | 'left' | 'right';
export interface FrameSlot {
position: [number, number, number];
rotationY: number;
maxW: number;
maxH: number;
side: WallSide;
}
export interface WallSegment {
side: WallSide;
label: string;
paintings: Painting[];
slots: FrameSlot[];
}
export interface MovementHallLayout {
hallIndex: number;
hallCount: number;
width: number;
depth: number;
segments: WallSegment[];
paintingCount: number;
yearLabel: string;
}
const WALL_HEIGHT = 4.2;
const WALL_THICKNESS = 0.18;
const MOUNT_OFFSET = 0.16;
const WALL_STANDOFF = 0.07;
const EYE_HEIGHT = 1.65;
const FRAME_GAP = 0.32;
const MIN_FRAME_W = 0.45;
const MAX_FRAME_W = 1.05;
const MAX_FRAME_H = 1.35;
const MIN_HALL_SIZE = 10;
const MIN_HALL_WIDTH = 11;
const WALL_PADDING = 1.4;
const FRAME_MAT_BORDER = 0.1;
const FRAME_RAIL = 0.08;
const REVIEWED_MAT_BORDER = FRAME_MAT_BORDER * 2;
const REVIEWED_RAIL = FRAME_RAIL * 2;
function paintingIsReviewed(p: Painting) {
return !!p.checkup_checked;
}
function frameDims(reviewed: boolean) {
return reviewed
? { matBorder: REVIEWED_MAT_BORDER, rail: REVIEWED_RAIL }
: { matBorder: FRAME_MAT_BORDER, rail: FRAME_RAIL };
}
function frameOuterW(w: number, reviewed: boolean) {
const { matBorder, rail } = frameDims(reviewed);
return w + matBorder * 2 + rail;
}
function layoutRow(paintings: Painting[], span: number) {
const count = paintings.length;
if (count === 0) return { slots: [] as { offset: number; maxW: number; maxH: number }[], spanNeeded: span };
let frameW = MAX_FRAME_W;
for (let attempt = 0; attempt < 40; attempt++) {
let total = 0;
for (let i = 0; i < count; i++) {
total += frameOuterW(frameW, paintingIsReviewed(paintings[i]));
if (i < count - 1) total += FRAME_GAP;
}
if (total <= span - WALL_PADDING) break;
frameW -= 0.015;
}
frameW = Math.max(MIN_FRAME_W, frameW);
const frameH = Math.min(MAX_FRAME_H, frameW * 1.22);
const outers = paintings.map((p) => frameOuterW(frameW, paintingIsReviewed(p)));
const rowWidth = outers.reduce((s, w) => s + w, 0) + (count - 1) * FRAME_GAP;
const slots: { offset: number; maxW: number; maxH: number }[] = [];
let cursor = -rowWidth / 2;
for (let i = 0; i < count; i++) {
slots.push({ offset: cursor + outers[i] / 2, maxW: frameW, maxH: frameH });
cursor += outers[i] + FRAME_GAP;
}
return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) };
}
function orderForWall(paintings: Painting[]) {
return [...paintings].sort(comparePaintingsChronological).reverse();
}
function formatYearLabel(paintings: Painting[]) {
const years = paintings.map((p) => p.year).filter((y): y is number => y != null);
if (years.length === 0) return 'Undated works';
const min = Math.min(...years);
const max = Math.max(...years);
return min === max ? `${min}` : `${min} ${max}`;
}
export function splitPaintingsIntoMovementHalls(paintings: Painting[]): Painting[][] {
const sorted = [...paintings].sort(comparePaintingsChronological);
if (sorted.length === 0) return [[]];
const chunks: Painting[][] = [];
for (let i = 0; i < sorted.length; i += MOVEMENT_PAINTINGS_PER_HALL) {
chunks.push(sorted.slice(i, i + MOVEMENT_PAINTINGS_PER_HALL));
}
return chunks;
}
function distributeToSideWalls(paintings: Painting[]) {
const left: Painting[] = [];
const right: Painting[] = [];
const sorted = [...paintings].sort(comparePaintingsChronological);
sorted.forEach((p, i) => (i % 2 === 0 ? left : right).push(p));
return { left: orderForWall(left), right: orderForWall(right) };
}
function layoutSideSlots(
paintings: Painting[],
span: number,
side: 'left' | 'right',
halfW: number,
inset: number
): FrameSlot[] {
if (paintings.length === 0) return [];
const { slots: rowSlots } = layoutRow(paintings, span);
const y = EYE_HEIGHT;
return rowSlots.map((s) => ({
maxW: s.maxW,
maxH: s.maxH,
rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2,
side,
position:
side === 'left'
? ([-halfW + inset + WALL_STANDOFF, y, s.offset] as [number, number, number])
: ([halfW - inset - WALL_STANDOFF, y, s.offset] as [number, number, number]),
}));
}
export function buildMovementHallLayout(
paintings: Painting[],
hallIndex: number,
hallCount: number
): MovementHallLayout {
const { left, right } = distributeToSideWalls(paintings);
const leftSpan = layoutRow(left, MIN_HALL_SIZE);
const rightSpan = layoutRow(right, MIN_HALL_SIZE);
const depth = Math.max(MIN_HALL_SIZE, leftSpan.spanNeeded, rightSpan.spanNeeded);
const width = MIN_HALL_WIDTH;
const halfW = width / 2;
const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET;
const segments: WallSegment[] = [
{ side: 'back', label: '', paintings: [], slots: [] },
{
side: 'left',
label: left.length > 0 ? `Wing ${hallIndex + 1} · Left wall` : '',
paintings: left,
slots: layoutSideSlots(left, depth, 'left', halfW, inset),
},
{
side: 'right',
label: right.length > 0 ? `Wing ${hallIndex + 1} · Right wall` : '',
paintings: right,
slots: layoutSideSlots(right, depth, 'right', halfW, inset),
},
];
return {
hallIndex,
hallCount,
width,
depth,
segments,
paintingCount: paintings.length,
yearLabel: formatYearLabel(paintings),
};
}
export function buildAllMovementHallLayouts(paintings: Painting[]): MovementHallLayout[] {
const chunks = splitPaintingsIntoMovementHalls(paintings);
return chunks.map((chunk, i) => buildMovementHallLayout(chunk, i, chunks.length));
}
function mergeIntervals(intervals: [number, number][]): [number, number][] {
if (intervals.length === 0) return [];
const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
const out: [number, number][] = [sorted[0]];
for (let i = 1; i < sorted.length; i++) {
const last = out[out.length - 1];
if (sorted[i][0] <= last[1]) last[1] = Math.max(last[1], sorted[i][1]);
else out.push(sorted[i]);
}
return out;
}
function findWallGaps(occupied: [number, number][], halfSpan: number, minGap: number): [number, number][] {
const merged = mergeIntervals(occupied);
const gaps: [number, number][] = [];
let cursor = -halfSpan + 1.2;
for (const [a, b] of merged) {
if (a - cursor >= minGap) gaps.push([cursor, a]);
cursor = Math.max(cursor, b);
}
if (halfSpan - 1.2 - cursor >= minGap) gaps.push([cursor, halfSpan - 1.2]);
return gaps.sort((a, b) => b[1] - b[0] - (a[1] - a[0]));
}
function windowTemplate(style: MovementInteriorStyle): Pick<GalleryWindowSpec, 'style' | 'lightColor' | 'lightIntensity' | 'width' | 'height'> {
const side = style.windows.find((w) => w.wall === 'left' || w.wall === 'right');
if (side) {
return {
style: side.style,
lightColor: side.lightColor,
lightIntensity: side.lightIntensity,
width: Math.min(side.width, 1.6),
height: Math.min(side.height, 1.5),
};
}
return { style: 'sash', lightColor: style.warmLight, lightIntensity: 2.8, width: 1.4, height: 1.4 };
}
/** Place windows on side walls only, in gaps between painting frames. */
export function computeSideWallWindows(
layout: MovementHallLayout,
interiorStyle: MovementInteriorStyle
): GalleryWindowSpec[] {
const halfD = layout.depth / 2;
const tmpl = windowTemplate(interiorStyle);
const specs: GalleryWindowSpec[] = [];
const windowY = 3.15;
const minGap = tmpl.width + 0.6;
for (const side of ['left', 'right'] as const) {
const seg = layout.segments.find((s) => s.side === side);
if (!seg) continue;
const occupied = seg.slots.map((s): [number, number] => {
const outerW = frameOuterW(s.maxW, false);
return [s.position[2] - outerW / 2 - 0.45, s.position[2] + outerW / 2 + 0.45];
});
const gaps = findWallGaps(occupied, halfD, minGap);
const maxWindows = Math.min(3, gaps.length);
for (let i = 0; i < maxWindows; i++) {
const [g0, g1] = gaps[i];
const center = (g0 + g1) / 2;
const w = Math.min(tmpl.width, g1 - g0 - 0.35);
if (w < 0.9) continue;
specs.push({
wall: side,
x: center,
y: windowY,
width: w,
height: tmpl.height,
style: tmpl.style,
lightColor: tmpl.lightColor,
lightIntensity: tmpl.lightIntensity,
});
}
}
return specs;
}
export { WALL_HEIGHT };
+14
View File
@@ -15,6 +15,20 @@ export function sortArtistPaintingsChronological(paintings: Painting[]): Paintin
return [...paintings].sort(comparePaintingsChronological);
}
export function formatPaintingYear(painting: Pick<Painting, 'year' | 'year_end'>): string {
if (painting.year == null) return 'Undated';
if (painting.year_end != null && painting.year_end !== painting.year) {
return `${painting.year}${painting.year_end}`;
}
return String(painting.year);
}
export function paintingWallCaption(painting: Pick<Painting, 'year' | 'year_end' | 'artist_name'>): string {
const year = formatPaintingYear(painting);
const artist = painting.artist_name?.trim() || 'Unknown artist';
return `${year} · ${artist}`;
}
/** True when painting appears in the influence graph (influenced by or influenced). */
export function paintingHasInfluenceLinks(
painting: Pick<Painting, 'has_influence_links'>