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:
co-authored by
Cursor
parent
0972b5df99
commit
df29848d89
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 (50–60 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 };
|
||||
@@ -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'>
|
||||
|
||||
Reference in New Issue
Block a user