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
@@ -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
Reference in New Issue
Block a user