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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-06-21 16:54:19 +03:00
co-authored by Cursor
parent 0972b5df99
commit df29848d89
121 changed files with 4765 additions and 396 deletions
@@ -87,7 +87,8 @@
.debug-search-modal-item {
position: relative;
aspect-ratio: 1;
display: flex;
flex-direction: column;
padding: 0;
border: 2px solid rgba(201, 169, 110, 0.35);
border-radius: 6px;
@@ -96,6 +97,13 @@
overflow: hidden;
}
.debug-search-modal-item-media {
position: relative;
aspect-ratio: 1;
width: 100%;
background: #2a1f15;
}
.debug-search-modal-item:hover:not(:disabled) {
border-color: #e8a040;
box-shadow: 0 0 0 1px rgba(232, 160, 64, 0.35);
@@ -117,6 +125,18 @@
display: block;
}
.debug-search-modal-item-resolution {
display: block;
padding: 5px 6px;
font-size: 10px;
font-weight: 600;
line-height: 1.2;
text-align: center;
color: rgba(232, 213, 181, 0.9);
background: rgba(0, 0, 0, 0.45);
border-top: 1px solid rgba(201, 169, 110, 0.2);
}
.debug-search-modal-item-index {
position: absolute;
top: 4px;
@@ -1,7 +1,57 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { debugImageProxyUrl, type DebugImageSearchManyResult, type DebugImageSearchResultItem } from '../api/client';
import './DebugSearchResultsModal.css';
function formatResolution(width?: number, height?: number): string | null {
if (!width || !height || width <= 0 || height <= 0) return null;
return `${width} × ${height}`;
}
function ResultResolution({
item,
searchUrl,
}: {
item: DebugImageSearchResultItem;
searchUrl: string;
}) {
const initial = formatResolution(item.width, item.height);
const [label, setLabel] = useState<string | null>(initial);
useEffect(() => {
if (initial) {
setLabel(initial);
return;
}
let cancelled = false;
const img = new Image();
img.onload = () => {
if (cancelled) return;
const text = formatResolution(img.naturalWidth, img.naturalHeight);
setLabel(text ?? '—');
};
img.onerror = () => {
if (!cancelled) setLabel('—');
};
img.src = debugImageProxyUrl(item.imageUrl, {
searchUrl,
source: item.source,
});
return () => {
cancelled = true;
img.onload = null;
img.onerror = null;
};
}, [item.imageUrl, item.source, item.width, item.height, searchUrl, initial]);
return (
<span className="debug-search-modal-item-resolution" aria-hidden="true">
{label ?? '…'}
</span>
);
}
interface Props {
open: boolean;
title: string;
@@ -76,19 +126,22 @@ export default function DebugSearchResultsModal({
onClick={() => onSelect(item)}
title="Use this image"
>
<img
src={debugImageProxyUrl(item.thumbUrl || item.imageUrl, {
searchUrl: data.searchUrl,
source: item.source,
})}
alt={`Result ${index + 1}`}
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
}}
/>
<span className="debug-search-modal-item-index">{index + 1}</span>
{busy && <span className="debug-search-modal-item-busy-label">Saving</span>}
<span className="debug-search-modal-item-media">
<img
src={debugImageProxyUrl(item.thumbUrl || item.imageUrl, {
searchUrl: data.searchUrl,
source: item.source,
})}
alt={`Result ${index + 1}`}
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
}}
/>
<span className="debug-search-modal-item-index">{index + 1}</span>
{busy && <span className="debug-search-modal-item-busy-label">Saving</span>}
</span>
<ResultResolution item={item} searchUrl={data.searchUrl} />
</button>
);
})}
+259
View File
@@ -0,0 +1,259 @@
import { useMemo } from 'react';
import * as THREE from 'three';
import type { GalleryWindowSpec, GalleryWindowStyle } from '../data/movement-interior-styles';
const WALL_HEIGHT = 4.2;
interface Props {
windows: GalleryWindowSpec[];
halfW: number;
halfD: number;
trimColor: string;
}
function windowWorldPosition(
spec: GalleryWindowSpec,
halfW: number,
halfD: number
): { position: [number, number, number]; rotation: [number, number, number] } {
const inset = 0.1;
switch (spec.wall) {
case 'back':
return {
position: [spec.x, spec.y, -halfD + inset],
rotation: [0, 0, 0],
};
case 'left':
return {
position: [-halfW + inset, spec.y, spec.x],
rotation: [0, Math.PI / 2, 0],
};
case 'right':
return {
position: [halfW - inset, spec.y, spec.x],
rotation: [0, -Math.PI / 2, 0],
};
case 'ceiling':
return {
position: [spec.x, WALL_HEIGHT - 0.06, spec.x === 0 ? 0 : spec.x],
rotation: [Math.PI / 2, 0, 0],
};
default:
return { position: [0, spec.y, -halfD + inset], rotation: [0, 0, 0] };
}
}
function WindowFrame({
style,
width,
height,
trimColor,
}: {
style: GalleryWindowStyle;
width: number;
height: number;
trimColor: string;
}) {
const frameW = 0.08;
const depth = 0.12;
const arch =
style === 'roman-arch' || style === 'gothic-lancet' || style === 'art-nouveau';
return (
<group>
{/* Outer frame */}
<mesh position={[0, 0, -depth / 2]}>
<boxGeometry args={[width + frameW * 2, height + frameW * 2, depth]} />
<meshStandardMaterial color={trimColor} roughness={0.35} metalness={0.45} />
</mesh>
{arch && style === 'gothic-lancet' && (
<mesh position={[0, height / 2 + 0.15, -depth / 2 + 0.02]}>
<coneGeometry args={[width / 2 + frameW, 0.5, 4]} />
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.35} />
</mesh>
)}
{style === 'roman-arch' && (
<mesh position={[0, height / 2 + 0.05, -depth / 2 + 0.02]} rotation={[0, 0, Math.PI]}>
<sphereGeometry args={[width / 2, 16, 8, 0, Math.PI * 2, 0, Math.PI / 2]} />
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.35} />
</mesh>
)}
{style === 'factory' && (
<>
{[-width / 3, 0, width / 3].map((ox) => (
<mesh key={ox} position={[ox, 0, -depth / 2 + 0.02]}>
<boxGeometry args={[0.06, height, depth + 0.02]} />
<meshStandardMaterial color="#3a3a3a" roughness={0.5} metalness={0.6} />
</mesh>
))}
{[height / 4, -height / 4].map((oy) => (
<mesh key={oy} position={[0, oy, -depth / 2 + 0.02]}>
<boxGeometry args={[width, 0.06, depth + 0.02]} />
<meshStandardMaterial color="#3a3a3a" roughness={0.5} metalness={0.6} />
</mesh>
))}
</>
)}
{style === 'glass-block' &&
Array.from({ length: 4 }, (_, row) =>
Array.from({ length: 3 }, (_, col) => (
<mesh
key={`${row}-${col}`}
position={[
-width / 3 + col * (width / 3),
-height / 4 + row * (height / 4),
-depth / 2 + 0.03,
]}
>
<boxGeometry args={[width / 3 - 0.06, height / 4 - 0.06, 0.04]} />
<meshStandardMaterial
color="#d0e8f8"
roughness={0.1}
metalness={0.05}
transparent
opacity={0.75}
/>
</mesh>
))
)}
{style === 'sash' && (
<mesh position={[0, 0, -depth / 2 + 0.03]}>
<boxGeometry args={[0.05, height, depth]} />
<meshStandardMaterial color={trimColor} roughness={0.45} metalness={0.3} />
</mesh>
)}
{style === 'baroque-pair' && (
<mesh position={[0, 0, -depth / 2 + 0.03]}>
<boxGeometry args={[0.06, height, depth]} />
<meshStandardMaterial color={trimColor} roughness={0.3} metalness={0.55} />
</mesh>
)}
</group>
);
}
function SingleWindow({
spec,
trimColor,
}: {
spec: GalleryWindowSpec;
trimColor: string;
}) {
const glassColor = useMemo(() => new THREE.Color(spec.lightColor), [spec.lightColor]);
return (
<group>
<WindowFrame style={spec.style} width={spec.width} height={spec.height} trimColor={trimColor} />
{/* Sky / daylight pane */}
<mesh position={[0, 0, 0.02]}>
<planeGeometry args={[spec.width - 0.12, spec.height - 0.12]} />
<meshStandardMaterial
color={spec.lightColor}
emissive={spec.lightColor}
emissiveIntensity={0.85}
roughness={0.05}
metalness={0.02}
toneMapped={false}
transparent
opacity={0.92}
/>
</mesh>
{/* Soft sky gradient overlay */}
<mesh position={[0, spec.height * 0.15, 0.03]}>
<planeGeometry args={[spec.width - 0.2, spec.height * 0.5]} />
<meshBasicMaterial color="#ffffff" transparent opacity={0.25} toneMapped={false} />
</mesh>
{/* Daylight into room */}
<spotLight
position={[0, 0, 0.15]}
angle={Math.min(1.2, (spec.width / Math.max(spec.height, 0.5)) * 0.55)}
penumbra={0.95}
intensity={spec.lightIntensity}
distance={14}
color={spec.lightColor}
castShadow={false}
/>
<pointLight
position={[0, 0, 0.25]}
intensity={spec.lightIntensity * 0.45}
distance={10}
color={glassColor}
decay={2}
/>
</group>
);
}
export default function GalleryWindows({ windows, halfW, halfD, trimColor }: Props) {
return (
<group>
{windows.map((spec, i) => {
const { position, rotation } = windowWorldPosition(spec, halfW, halfD);
return (
<group key={`${spec.wall}-${spec.x}-${i}`} position={position} rotation={rotation}>
<SingleWindow spec={spec} trimColor={trimColor} />
</group>
);
})}
</group>
);
}
/** Ceiling-mounted gallery track lights for even illumination. */
export function GalleryTrackLights({
width,
depth,
intensity,
color,
}: {
width: number;
depth: number;
intensity: number;
color: string;
}) {
const positions = useMemo(() => {
const pts: [number, number, number][] = [];
const cols = Math.max(2, Math.min(5, Math.floor(width / 4)));
const rows = Math.max(1, Math.min(3, Math.floor(depth / 6)));
for (let c = 0; c < cols; c++) {
for (let r = 0; r < rows; r++) {
const x = -width / 2 + (width / (cols + 1)) * (c + 1);
const z = -depth / 2 + (depth / (rows + 1)) * (r + 1);
pts.push([x, WALL_HEIGHT - 0.15, z]);
}
}
return pts;
}, [width, depth]);
return (
<group>
{positions.map(([x, y, z], i) => (
<group key={i} position={[x, y, z]}>
<mesh rotation={[Math.PI, 0, 0]}>
<cylinderGeometry args={[0.02, 0.025, 0.12, 8]} />
<meshStandardMaterial color="#2a2a2a" metalness={0.8} roughness={0.25} />
</mesh>
<spotLight
position={[0, -0.04, 0]}
angle={0.55}
penumbra={0.85}
intensity={intensity}
distance={8}
color={color}
castShadow={false}
/>
</group>
))}
</group>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { useState } from 'react';
import * as THREE from 'three';
import { Text } from '@react-three/drei';
const DOOR_WIDTH = 2.4;
const DOOR_HEIGHT = 2.5;
const WALL_THICKNESS = 0.18;
/** Open archway connecting to the next movement wing. */
export default function HallPassage({
position,
active,
label,
onActivate,
wallColor = '#f0ebe3',
trimColor = '#ddd5c8',
}: {
position: [number, number, number];
active: boolean;
label: string;
onActivate: () => void;
wallColor?: string;
trimColor?: string;
}) {
const [hovered, setHovered] = useState(false);
const highlight = active || hovered;
const openingW = DOOR_WIDTH;
const openingH = DOOR_HEIGHT;
const jamb = 0.12;
const faceZ = 0.04;
const handleActivate = (e: THREE.Event & { stopPropagation: () => void }) => {
e.stopPropagation();
onActivate();
};
return (
<group position={position}>
<pointLight position={[0, openingH * 0.5, 0.3]} intensity={highlight ? 0.9 : 0.5} distance={5} color="#fff4e8" />
{/* Depth beyond passage */}
<mesh position={[0, openingH * 0.5, 0.15]}>
<planeGeometry args={[openingW * 0.9, openingH * 0.95]} />
<meshStandardMaterial color="#fff8f0" emissive="#ffe8c8" emissiveIntensity={highlight ? 0.28 : 0.14} />
</mesh>
{/* Side jambs */}
{([-1, 1] as const).map((sign) => (
<mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), openingH / 2, faceZ]} castShadow>
<boxGeometry args={[jamb, openingH + 0.1, 0.1]} />
<meshStandardMaterial color={trimColor} roughness={0.45} metalness={0.25} />
</mesh>
))}
{/* Arch header */}
<mesh position={[0, openingH + 0.06, faceZ]} castShadow>
<boxGeometry args={[openingW + jamb * 2, 0.14, 0.1]} />
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.3} />
</mesh>
<mesh position={[0, openingH + 0.22, faceZ]} rotation={[0, 0, Math.PI]}>
<sphereGeometry args={[openingW / 2 + 0.04, 16, 8, 0, Math.PI * 2, 0, Math.PI / 2]} />
<meshStandardMaterial color={wallColor} roughness={0.88} />
</mesh>
<group position={[0, openingH + 0.38, faceZ - 0.01]}>
<mesh onClick={handleActivate} onPointerOver={() => setHovered(true)} onPointerOut={() => setHovered(false)}>
<boxGeometry args={[1.4, 0.18, 0.02]} />
<meshStandardMaterial
color={highlight ? '#d4af37' : trimColor}
roughness={0.3}
metalness={0.5}
emissive={highlight ? '#5a4010' : '#000000'}
emissiveIntensity={highlight ? 0.2 : 0}
/>
</mesh>
<Text
position={[0, 0, -0.012]}
rotation={[0, Math.PI, 0]}
fontSize={0.1}
color={highlight ? '#fff8e8' : '#4a3020'}
anchorX="center"
anchorY="middle"
onClick={handleActivate}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
{label.toUpperCase()}
</Text>
</group>
<mesh
position={[0, openingH / 2, faceZ - 0.12]}
rotation={[0, Math.PI, 0]}
onClick={handleActivate}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<planeGeometry args={[openingW * 1.1, openingH * 1.05]} />
<meshBasicMaterial transparent opacity={0} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
</group>
);
}
export { DOOR_WIDTH, DOOR_HEIGHT, WALL_THICKNESS };
+81 -12
View File
@@ -47,12 +47,44 @@
cursor: grabbing;
}
.movement-lifespan-overlays {
position: absolute;
inset: 0;
z-index: 3;
pointer-events: none;
}
.movement-lifespan-dim {
position: absolute;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
}
.movement-lifespan-highlight {
position: absolute;
top: 0;
bottom: 0;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.16) 40%,
rgba(255, 255, 255, 0.12) 100%
);
box-shadow:
inset 0 0 0 2px rgba(255, 230, 180, 0.45),
inset 0 0 48px rgba(255, 255, 255, 0.08);
border-left: 2px solid rgba(255, 220, 160, 0.65);
border-right: 2px solid rgba(255, 220, 160, 0.65);
}
.movements-flow-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
}
.movement-branch {
@@ -90,13 +122,16 @@
.movements-flow-artists {
position: absolute;
inset: 0;
pointer-events: none;
}
.movements-flow-artists {
pointer-events: auto;
}
.artist-on-band {
pointer-events: none;
}
.artist-lifespan {
position: absolute;
height: 0;
@@ -110,31 +145,45 @@
left: 0;
right: 0;
top: 0;
height: 3px;
height: 4px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 25%, transparent) 0%,
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 70%, #e8d5b5) 12%,
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 85%, #fff) 50%,
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 70%, #e8d5b5) 88%,
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 25%, transparent) 100%
rgba(255, 255, 255, 0) 0%,
var(--lifespan-color, #e8d5b5) 15%,
#fff 50%,
var(--lifespan-color, #e8d5b5) 85%,
rgba(255, 255, 255, 0) 100%
);
box-shadow: 0 0 6px color-mix(in srgb, var(--lifespan-color, #e8d5b5) 40%, transparent);
border-radius: 1px;
box-shadow:
0 0 8px rgba(255, 255, 255, 0.55),
0 0 16px var(--lifespan-color, #e8d5b5);
border-radius: 2px;
pointer-events: none;
}
.artist-lifespan .artist-portrait {
.artist-on-band .artist-portrait {
position: absolute;
top: 0;
transform: translate(-50%, -50%);
pointer-events: auto;
}
.artist-lifespan .artist-portrait:hover {
.artist-on-band .artist-portrait:hover {
transform: translate(-50%, -50%) scale(1.14);
}
.artist-on-band-active .artist-portrait {
box-shadow:
0 0 0 3px rgba(255, 255, 255, 0.85),
0 4px 24px rgba(255, 220, 160, 0.65);
}
.movements-flow-labels {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 3;
}
.movement-flow-label {
position: absolute;
transform: translate(-4px, -100%);
@@ -144,6 +193,26 @@
z-index: 3;
}
.movement-flow-label-btn {
pointer-events: auto;
border: none;
background: rgba(12, 10, 18, 0.55);
border-radius: 4px;
cursor: pointer;
text-align: left;
transition: background 0.2s, box-shadow 0.2s;
}
.movement-flow-label-btn:hover {
background: rgba(24, 20, 32, 0.82);
box-shadow: 0 0 0 1px rgba(255, 220, 160, 0.35);
}
.movement-flow-label-btn:focus-visible {
outline: 2px solid rgba(255, 220, 160, 0.65);
outline-offset: 2px;
}
.movement-name {
display: block;
font-family: 'Georgia', serif;
+232 -128
View File
@@ -15,6 +15,8 @@ interface Props {
absoluteMax: number;
onViewChange: (start: number, end: number) => void;
onArtistClick: (artistId: number) => void;
onMovementClick?: (movementId: number) => void;
onArtistHover?: (info: { birthYear: number; deathYear: number; color: string } | null) => void;
}
interface MovementLayout {
@@ -61,50 +63,159 @@ function artistTimelineYear(artist: Artist): number {
return artist.birth_year ?? artist.death_year ?? 0;
}
interface ArtistLifespanLayout {
lineLeft: number;
lineWidth: number;
lineRight: number;
portraitLeft: number;
y: number;
centerX: number;
}
function artistLifespanLayout(
artist: Artist,
layout: MovementLayout,
viewStart: number,
viewEnd: number
): ArtistLifespanLayout | null {
const birth = artist.birth_year ?? viewStart;
const death = artist.death_year ?? viewEnd;
const centerYear = artistTimelineYear(artist);
const lineLeft = yearToPercent(Math.max(birth, viewStart), viewStart, viewEnd);
const lineRight = yearToPercent(Math.min(death, viewEnd), viewStart, viewEnd);
const centerX = yearToPercent(centerYear, viewStart, viewEnd);
const lineWidth = lineRight - lineLeft;
if (lineWidth <= 0) return null;
const yAnchorX = Math.min(Math.max(centerX, layout.xStart), layout.xEnd);
const y = yOnStream(layout, yAnchorX);
const portraitLeft = ((centerX - lineLeft) / lineWidth) * 100;
return { lineLeft, lineWidth, lineRight, portraitLeft, y, centerX };
}
interface ArtistPlacement {
layout: MovementLayout;
artist: Artist;
lineLeft: number;
lineWidth: number;
portraitLeft: number;
portraitX: number;
y: number;
lane: number;
colorIndex: number;
color: string;
}
interface PortraitCandidate {
artist: Artist;
layout: MovementLayout;
lineLeft: number;
lineRight: number;
lineWidth: number;
minX: number;
maxX: number;
idealX: number;
x: number;
}
function portraitMinGapPct(portraitSizePx: number, canvasWidthPx: number): number {
const width = Math.max(canvasWidthPx, 320);
const diameterPct = (portraitSizePx / width) * 100;
return Math.max(diameterPct * 1.08, 3.5);
}
function resolvePortraitCollisions(candidates: PortraitCandidate[], minGap: number): void {
if (candidates.length === 0) return;
for (const c of candidates) {
c.x = Math.min(c.maxX, Math.max(c.minX, c.idealX));
}
candidates.sort((a, b) => a.x - b.x || a.idealX - b.idealX);
for (let i = 1; i < candidates.length; i++) {
if (candidates[i].x < candidates[i - 1].x + minGap) {
candidates[i].x = candidates[i - 1].x + minGap;
}
}
const last = candidates[candidates.length - 1];
if (last.x > last.maxX) {
last.x = last.maxX;
for (let i = candidates.length - 2; i >= 0; i--) {
candidates[i].x = Math.min(candidates[i].x, candidates[i + 1].x - minGap);
candidates[i].x = Math.max(candidates[i].x, candidates[i].minX);
}
}
for (let i = candidates.length - 2; i >= 0; i--) {
if (candidates[i].x + minGap > candidates[i + 1].x) {
candidates[i].x = candidates[i + 1].x - minGap;
candidates[i].x = Math.max(candidates[i].x, candidates[i].minX);
}
}
for (let i = 1; i < candidates.length; i++) {
if (candidates[i].x < candidates[i - 1].x + minGap) {
candidates[i].x = Math.min(candidates[i - 1].x + minGap, candidates[i].maxX);
}
}
}
function buildArtistPlacements(
layouts: MovementLayout[],
artistsByMovement: Map<number, Artist[]>,
viewStart: number,
viewEnd: number,
portraitSizePx: number,
canvasWidthPx: number
): ArtistPlacement[] {
const placements: ArtistPlacement[] = [];
const minGap = portraitMinGapPct(portraitSizePx, canvasWidthPx);
for (const layout of layouts) {
const movementArtists = artistsByMovement.get(layout.movement.id) || [];
const candidates: PortraitCandidate[] = [];
const portraitHalfPct = minGap / 2;
for (const artist of movementArtists) {
const birth = artist.birth_year ?? viewStart;
const death = artist.death_year ?? viewEnd;
const centerYear = artistTimelineYear(artist);
const lineLeft = yearToPercent(Math.max(birth, viewStart), viewStart, viewEnd);
const lineRight = yearToPercent(Math.min(death, viewEnd), viewStart, viewEnd);
const lineWidth = lineRight - lineLeft;
if (lineWidth <= 0) continue;
const spanLeft = Math.max(lineLeft, layout.xStart);
const spanRight = Math.min(lineRight, layout.xEnd);
if (spanRight <= spanLeft) continue;
const centerX = yearToPercent(centerYear, viewStart, viewEnd);
let minX = spanLeft + portraitHalfPct;
let maxX = spanRight - portraitHalfPct;
if (maxX <= minX) {
const mid = (spanLeft + spanRight) / 2;
minX = mid;
maxX = mid;
}
const idealX = Math.min(maxX, Math.max(minX, centerX));
candidates.push({
artist,
layout,
lineLeft,
lineRight,
lineWidth,
minX,
maxX,
idealX,
x: idealX,
});
}
candidates.sort(
(a, b) =>
a.idealX - b.idealX ||
b.lineWidth - a.lineWidth ||
(a.artist.birth_year ?? 0) - (b.artist.birth_year ?? 0)
);
resolvePortraitCollisions(candidates, minGap);
candidates.sort((a, b) => a.x - b.x);
const colorCount = Math.max(candidates.length, 1);
candidates.forEach((candidate, colorIndex) => {
const { artist, layout, lineLeft, lineWidth, x } = candidate;
const y = yOnStream(layout, x);
placements.push({
layout,
artist,
lineLeft,
lineWidth,
portraitX: x,
y,
colorIndex,
color: artistLifespanColor(layout.movement.color, colorIndex, colorCount),
});
});
}
return placements;
}
function parseHexColor(hex: string): [number, number, number] {
const normalized = hex.replace('#', '');
const value =
@@ -167,76 +278,6 @@ function artistLifespanColor(baseColor: string, laneIndex: number, laneCount: nu
}
}
function buildArtistPlacements(
layouts: MovementLayout[],
artistsByMovement: Map<number, Artist[]>,
viewStart: number,
viewEnd: number,
streamStrokePx: number,
portraitSizePx: number
): ArtistPlacement[] {
const placements: ArtistPlacement[] = [];
for (const layout of layouts) {
const movementArtists = artistsByMovement.get(layout.movement.id) || [];
const candidates: Array<{
artist: Artist;
span: ArtistLifespanLayout;
}> = [];
for (const artist of movementArtists) {
const span = artistLifespanLayout(artist, layout, viewStart, viewEnd);
if (span) candidates.push({ artist, span });
}
candidates.sort(
(a, b) =>
a.span.lineLeft - b.span.lineLeft ||
b.span.lineWidth - a.span.lineWidth ||
a.artist.birth_year! - b.artist.birth_year!
);
const laneEnds: number[] = [];
const minGap = 0.6;
const assigned: Array<{ candidate: (typeof candidates)[0]; lane: number }> = [];
for (const candidate of candidates) {
let lane = laneEnds.findIndex((end) => candidate.span.lineLeft >= end + minGap);
if (lane === -1) {
lane = laneEnds.length;
laneEnds.push(candidate.span.lineRight);
} else {
laneEnds[lane] = Math.max(laneEnds[lane], candidate.span.lineRight);
}
assigned.push({ candidate, lane });
}
const laneCount = Math.max(laneEnds.length, 1);
const laneStep = Math.min(
portraitSizePx * 0.52,
(streamStrokePx * 0.88) / laneCount
);
for (const { candidate, lane } of assigned) {
const { artist, span } = candidate;
const yOffset = (lane - (laneCount - 1) / 2) * laneStep;
placements.push({
layout,
artist,
lineLeft: span.lineLeft,
lineWidth: span.lineWidth,
portraitLeft: span.portraitLeft,
y: span.y + yOffset,
lane,
color: artistLifespanColor(layout.movement.color, lane, laneCount),
});
}
}
return placements;
}
function organicDrift(id: number): number {
return ((id * 17) % 11) - 5;
}
@@ -333,10 +374,13 @@ export default function MovementBands({
absoluteMax,
onViewChange,
onArtistClick,
onMovementClick,
onArtistHover,
}: Props) {
const canvasRef = useRef<HTMLDivElement>(null);
const [panning, setPanning] = useState(false);
const [canvasHeight, setCanvasHeight] = useState(DEFAULT_CANVAS_HEIGHT);
const [canvasWidth, setCanvasWidth] = useState(800);
const [hoveredArtistKey, setHoveredArtistKey] = useState<string | null>(null);
const panStart = useRef({ x: 0, viewStart: 0, viewEnd: 0 });
@@ -424,14 +468,16 @@ export default function MovementBands({
if (!el) return;
const measure = () => {
const h = el.getBoundingClientRect().height;
if (h > 0) setCanvasHeight(Math.round(h));
const rect = el.getBoundingClientRect();
if (rect.height > 0) setCanvasHeight(Math.round(rect.height));
if (rect.width > 0) setCanvasWidth(Math.round(rect.width));
};
measure();
const ro = new ResizeObserver((entries) => {
const h = entries[0]?.contentRect.height;
if (h > 0) setCanvasHeight(Math.round(h));
const rect = entries[0]?.contentRect;
if (rect && rect.height > 0) setCanvasHeight(Math.round(rect.height));
if (rect && rect.width > 0) setCanvasWidth(Math.round(rect.width));
});
ro.observe(el);
window.addEventListener('resize', measure);
@@ -577,12 +623,21 @@ export default function MovementBands({
artistsByMovement,
viewStart,
viewEnd,
streamStrokePx,
portraitSizePx
portraitSizePx,
canvasWidth
),
[layouts, artistsByMovement, viewStart, viewEnd, streamStrokePx, portraitSizePx]
[layouts, artistsByMovement, viewStart, viewEnd, portraitSizePx, canvasWidth]
);
const hoveredPlacement = useMemo(() => {
if (!hoveredArtistKey) return null;
return (
artistPlacements.find(
(p) => `${p.layout.movement.id}-${p.artist.id}` === hoveredArtistKey
) ?? null
);
}, [hoveredArtistKey, artistPlacements]);
if (visibleMovements.length === 0) {
return (
<div className="movements-empty">
@@ -719,26 +774,59 @@ export default function MovementBands({
})}
</svg>
{hoveredPlacement && (
<div className="movement-lifespan-overlays" aria-hidden>
{hoveredPlacement.lineLeft > 0 && (
<div
className="movement-lifespan-dim"
style={{ left: 0, width: `${hoveredPlacement.lineLeft}%` }}
/>
)}
{hoveredPlacement.lineLeft + hoveredPlacement.lineWidth < 100 && (
<div
className="movement-lifespan-dim"
style={{
left: `${hoveredPlacement.lineLeft + hoveredPlacement.lineWidth}%`,
width: `${100 - hoveredPlacement.lineLeft - hoveredPlacement.lineWidth}%`,
}}
/>
)}
<div
className="movement-lifespan-highlight"
style={{
left: `${hoveredPlacement.lineLeft}%`,
width: `${hoveredPlacement.lineWidth}%`,
['--lifespan-color' as string]: hoveredPlacement.color,
}}
/>
</div>
)}
<div className="movements-flow-labels">
{layouts.map((layout) => (
<div
<button
key={`label-${layout.movement.id}`}
className="movement-flow-label"
type="button"
className="movement-flow-label movement-flow-label-btn"
style={{
left: `${layout.xStart}%`,
top: `${((layout.y - 32) / layoutHeight) * 100}%`,
}}
title={`Open ${layout.movement.name} gallery hall`}
onClick={() => onMovementClick?.(layout.movement.id)}
onMouseDown={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
>
<span className="movement-name">{layout.movement.name}</span>
{layout.movement.era_name && (
<span className="movement-era">{layout.movement.era_name}</span>
)}
</div>
</button>
))}
</div>
<div className="movements-flow-artists">
{artistPlacements.map(({ layout, artist, lineLeft, lineWidth, portraitLeft, y, lane, color }) => {
{artistPlacements.map(({ layout, artist, lineLeft, lineWidth, portraitX, y, colorIndex, color }) => {
const birthLabel = artist.birth_year != null ? artist.birth_year : '?';
const deathLabel = artist.death_year != null ? artist.death_year : '?';
const artistKey = `${layout.movement.id}-${artist.id}`;
@@ -747,26 +835,42 @@ export default function MovementBands({
return (
<div
key={artistKey}
className={`artist-lifespan${isHovered ? ' artist-lifespan-active' : ''}`}
style={{
left: `${lineLeft}%`,
width: `${lineWidth}%`,
top: `${(y / layoutHeight) * 100}%`,
zIndex: isHovered ? 12 : 4 + lane,
['--lifespan-color' as string]: color,
}}
className={`artist-on-band${isHovered ? ' artist-on-band-active' : ''}`}
>
{isHovered && <span className="artist-lifespan-line" aria-hidden />}
<div
className={`artist-lifespan${isHovered ? ' artist-lifespan-active' : ''}`}
style={{
left: `${lineLeft}%`,
width: `${lineWidth}%`,
top: `${(y / layoutHeight) * 100}%`,
zIndex: isHovered ? 12 : 4 + colorIndex,
['--lifespan-color' as string]: color,
}}
>
{isHovered && <span className="artist-lifespan-line" aria-hidden />}
</div>
<button
type="button"
className="artist-portrait"
style={{
left: `${portraitLeft}%`,
left: `${portraitX}%`,
top: `${(y / layoutHeight) * 100}%`,
borderColor: color,
zIndex: isHovered ? 13 : 5 + colorIndex,
}}
onClick={() => onArtistClick(artist.id)}
onMouseEnter={() => setHoveredArtistKey(artistKey)}
onMouseLeave={() => setHoveredArtistKey(null)}
onMouseEnter={() => {
setHoveredArtistKey(artistKey);
onArtistHover?.({
birthYear: artist.birth_year ?? viewStart,
deathYear: artist.death_year ?? viewEnd,
color,
});
}}
onMouseLeave={() => {
setHoveredArtistKey(null);
onArtistHover?.(null);
}}
onMouseDown={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
title={`${artist.name} (${birthLabel}${deathLabel}) · ${layout.movement.name}`}
@@ -0,0 +1,222 @@
import { useMemo } from 'react';
import * as THREE from 'three';
import type { MovementInteriorStyle } from '../data/movement-interior-styles';
interface Props {
style: MovementInteriorStyle;
width: number;
depth: number;
halfW: number;
halfD: number;
}
function PalazzoDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
const pilasterPositions = useMemo(
() =>
[
[-halfW + 0.35, -halfD + 0.35],
[halfW - 0.35, -halfD + 0.35],
[-halfW + 0.35, halfD - 0.35],
[halfW - 0.35, halfD - 0.35],
] as [number, number][],
[halfW, halfD]
);
return (
<group>
{pilasterPositions.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 1.8, 0]} castShadow>
<boxGeometry args={[0.22, 3.6, 0.22]} />
<meshStandardMaterial color="#e8dcc8" roughness={0.85} metalness={0.05} />
</mesh>
<mesh position={[0, 3.65, 0]}>
<boxGeometry args={[0.28, 0.14, 0.28]} />
<meshStandardMaterial color={trim} roughness={0.35} metalness={0.55} />
</mesh>
</group>
))}
{/* Wainscoting rail */}
{[
[0, -halfD + 0.09, width, 0.12] as const,
[0, halfD - 0.09, width, 0.12] as const,
[-halfW + 0.09, 0, 0.12, depth] as const,
[halfW - 0.09, 0, 0.12, depth] as const,
].map(([x, z, w, d], i) => (
<mesh key={i} position={[x, 1.05, z]}>
<boxGeometry args={[w, 0.08, d]} />
<meshStandardMaterial color="#ddd0b8" roughness={0.78} metalness={0.08} />
</mesh>
))}
{/* Coffered ceiling */}
{Array.from({ length: Math.min(6, Math.floor(width / 2.5)) }, (_, col) =>
Array.from({ length: Math.min(5, Math.floor(depth / 2.8)) }, (_, row) => {
const cx = -halfW + 1.4 + col * 2.4;
const cz = -halfD + 1.5 + row * 2.6;
return (
<mesh key={`${col}-${row}`} position={[cx, 4.12, cz]} rotation={[Math.PI / 2, 0, 0]}>
<boxGeometry args={[2.0, 2.2, 0.06]} />
<meshStandardMaterial color="#f5efe4" roughness={0.88} />
</mesh>
);
})
)}
</group>
);
}
function BaroqueDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
return (
<group>
{/* Gilded cornice ring */}
{[
[0, -halfD + 0.06, width, 0.1] as const,
[0, halfD - 0.06, width, 0.1] as const,
[-halfW + 0.06, 0, 0.1, depth] as const,
[halfW - 0.06, 0, 0.1, depth] as const,
].map(([x, z, w, d], i) => (
<mesh key={i} position={[x, 3.95, z]}>
<boxGeometry args={[w, 0.18, d]} />
<meshStandardMaterial color={trim} roughness={0.25} metalness={0.75} emissive="#3a2808" emissiveIntensity={0.08} />
</mesh>
))}
{/* Wall panels */}
{[-halfW + 0.12, halfW - 0.12].map((x, i) => (
<mesh key={i} position={[x, 2.1, 0]} rotation={[0, Math.PI / 2, 0]}>
<boxGeometry args={[depth * 0.85, 2.8, 0.04]} />
<meshStandardMaterial color="#3a1820" roughness={0.88} metalness={0.06} />
</mesh>
))}
<pointLight position={[0, 3.6, 0]} intensity={0.9} distance={Math.max(width, depth)} color="#ffd080" />
<mesh position={[0, 3.5, 0]}>
<torusGeometry args={[0.55, 0.04, 8, 24]} />
<meshStandardMaterial color={trim} roughness={0.2} metalness={0.85} emissive="#5a4010" emissiveIntensity={0.15} />
</mesh>
</group>
);
}
function MedievalDetails({ halfW, halfD }: Pick<Props, 'halfW' | 'halfD'>) {
const torchPositions = useMemo(
() =>
[
[-halfW + 0.2, -halfD * 0.5],
[halfW - 0.2, -halfD * 0.5],
[-halfW + 0.2, halfD * 0.3],
[halfW - 0.2, halfD * 0.3],
] as [number, number][],
[halfW, halfD]
);
return (
<group>
{torchPositions.map(([x, z], i) => (
<group key={i} position={[x, 2.2, z]}>
<mesh>
<boxGeometry args={[0.08, 0.35, 0.12]} />
<meshStandardMaterial color="#3a3028" roughness={0.9} />
</mesh>
<pointLight position={[0, 0.15, 0.08]} intensity={0.65} distance={5} color="#ff9830" />
<mesh position={[0, 0.2, 0.06]}>
<sphereGeometry args={[0.06, 8, 8]} />
<meshStandardMaterial color="#ffb040" emissive="#ff8010" emissiveIntensity={0.8} toneMapped={false} />
</mesh>
</group>
))}
{/* Rough stone courses */}
{[-halfD + 0.08, halfD - 0.08].map((z, i) => (
<mesh key={i} position={[0, 1.5, z]}>
<boxGeometry args={[0.04, 3, 0.04]} />
<meshStandardMaterial color="#6a6458" roughness={0.98} />
</mesh>
))}
</group>
);
}
function NeoclassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
const columns = [
[-halfW + 0.45, -halfD + 0.6],
[halfW - 0.45, -halfD + 0.6],
[-halfW + 0.45, halfD - 0.6],
[halfW - 0.45, halfD - 0.6],
] as [number, number][];
return (
<group>
{columns.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 1.85, 0]}>
<cylinderGeometry args={[0.18, 0.2, 3.7, 12]} />
<meshStandardMaterial color="#f0ece8" roughness={0.72} metalness={0.12} />
</mesh>
<mesh position={[0, 3.85, 0]}>
<boxGeometry args={[0.42, 0.12, 0.42]} />
<meshStandardMaterial color={trim} roughness={0.4} metalness={0.35} />
</mesh>
</group>
))}
<mesh position={[0, 3.88, -halfD + 0.12]}>
<boxGeometry args={[2.8, 0.14, 0.2]} />
<meshStandardMaterial color={trim} roughness={0.45} metalness={0.3} />
</mesh>
</group>
);
}
function ClassicalDetails({ halfW, trim }: Pick<Props, 'halfW'> & { trim: string }) {
return (
<group>
{[
[-halfW + 0.5, 0],
[halfW - 0.5, 0],
].map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 2, 0]}>
<cylinderGeometry args={[0.16, 0.18, 4, 10]} />
<meshStandardMaterial color="#e0d8c8" roughness={0.88} />
</mesh>
<mesh position={[0, 4.05, 0]}>
<boxGeometry args={[0.36, 0.1, 0.36]} />
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.25} />
</mesh>
</group>
))}
</group>
);
}
function SalonDetails({ trim }: { trim: string }) {
return (
<mesh position={[0, 4.05, 0]} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.3, 1.2, 32]} />
<meshStandardMaterial color={trim} roughness={0.35} metalness={0.5} side={THREE.DoubleSide} />
</mesh>
);
}
export default function MovementHallDetails({ style, width, depth, halfW, halfD }: Props) {
const trim = style.tints.trim;
switch (style.details) {
case 'palazzo':
return <PalazzoDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
case 'baroque':
return <BaroqueDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
case 'medieval':
return <MedievalDetails halfW={halfW} halfD={halfD} />;
case 'neoclassical':
return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
case 'classical':
return <ClassicalDetails halfW={halfW} trim={trim} />;
case 'salon':
return <SalonDetails trim={trim} />;
default:
return null;
}
}
@@ -0,0 +1,172 @@
.painting-frame-annotated {
position: relative;
}
.painting-frame-image-wrap {
position: relative;
}
.painting-frame-image-wrap img {
width: 100%;
display: block;
user-select: none;
}
.painting-annotation-markers {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 2;
}
.painting-annotation-marker {
position: absolute;
transform: translate(-50%, -50%);
width: 22px;
height: 22px;
padding: 0;
border: 2px solid rgba(255, 255, 255, 0.9);
border-radius: 50%;
font-size: 11px;
font-weight: 700;
line-height: 1;
color: #1a1208;
cursor: pointer;
pointer-events: auto;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
transition: transform 0.15s, box-shadow 0.15s;
}
.painting-annotation-marker:hover,
.painting-annotation-marker-active {
transform: translate(-50%, -50%) scale(1.12);
box-shadow: 0 0 0 2px rgba(255, 215, 0, 0.55), 0 2px 10px rgba(0, 0, 0, 0.5);
z-index: 3;
}
.painting-annotation-marker-technique { background: #7eb8da; }
.painting-annotation-marker-composition { background: #c9a96e; }
.painting-annotation-marker-symbolism { background: #b088cc; }
.painting-annotation-marker-history { background: #8cbe8c; }
.painting-annotation-marker-subject { background: #e8a040; }
.painting-annotations-panel {
max-width: 700px;
width: 100%;
margin-top: 16px;
}
.painting-annotations-title {
margin: 0 0 10px;
font-family: 'Georgia', serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #c9a96e;
}
.painting-annotations-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.painting-annotation-card {
border-radius: 6px;
border: 1px solid rgba(201, 169, 110, 0.2);
background: rgba(201, 169, 110, 0.06);
overflow: hidden;
}
.painting-annotation-card-active {
border-color: rgba(255, 215, 0, 0.45);
background: rgba(201, 169, 110, 0.12);
}
.painting-annotation-card-btn {
display: block;
width: 100%;
padding: 10px 12px;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
color: inherit;
}
.painting-annotation-card-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.painting-annotation-number {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 5px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.35);
font-size: 11px;
font-weight: 700;
color: #ffd700;
}
.painting-annotation-category {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: rgba(201, 169, 110, 0.85);
}
.painting-annotation-label {
font-family: 'Georgia', serif;
font-size: 13px;
color: #e8d5b5;
}
.painting-annotation-body {
margin: 0;
font-family: 'Georgia', serif;
font-size: 13px;
line-height: 1.55;
color: rgba(232, 213, 181, 0.88);
}
.painting-annotation-source {
margin: 6px 0 0;
font-size: 11px;
font-style: italic;
color: rgba(201, 169, 110, 0.75);
}
.painting-annotation-source cite {
font-style: normal;
}
.painting-annotation-source-link {
display: inline-block;
margin: 0 12px 10px;
font-size: 11px;
color: #7eb8da;
text-decoration: none;
}
.painting-annotation-source-link:hover {
text-decoration: underline;
}
.painting-annotation-card-technique { border-left: 3px solid #7eb8da; }
.painting-annotation-card-composition { border-left: 3px solid #c9a96e; }
.painting-annotation-card-symbolism { border-left: 3px solid #b088cc; }
.painting-annotation-card-history { border-left: 3px solid #8cbe8c; }
.painting-annotation-card-subject { border-left: 3px solid #e8a040; }
@@ -0,0 +1,122 @@
import { useRef } from 'react';
import type { PaintingAnnotation } from '../types';
import './PaintingAnnotations.css';
const CATEGORY_LABELS: Record<string, string> = {
technique: 'Technique',
composition: 'Composition',
symbolism: 'Symbolism',
history: 'History',
subject: 'Subject',
};
interface PanelProps {
annotations: PaintingAnnotation[];
activeId: number | null;
onSelect: (id: number | null) => void;
}
export function PaintingAnnotationMarkers({
annotations,
activeId,
onSelect,
}: PanelProps) {
const positioned = annotations.filter(
(a) => a.pos_x != null && a.pos_y != null && Number.isFinite(Number(a.pos_x)) && Number.isFinite(Number(a.pos_y))
);
if (!positioned.length) return null;
return (
<div className="painting-annotation-markers" aria-hidden={false}>
{positioned.map((ann) => {
const number = annotations.indexOf(ann) + 1;
const isActive = activeId === ann.id;
return (
<button
key={ann.id}
type="button"
className={`painting-annotation-marker painting-annotation-marker-${ann.category}${isActive ? ' painting-annotation-marker-active' : ''}`}
style={{ left: `${ann.pos_x}%`, top: `${ann.pos_y}%` }}
title={ann.label || ann.body}
aria-label={`Annotation ${number}: ${ann.label || ann.body}`}
onClick={(e) => {
e.stopPropagation();
onSelect(isActive ? null : ann.id);
}}
>
{number}
</button>
);
})}
</div>
);
}
export default function PaintingAnnotationsPanel({
annotations,
activeId,
onSelect,
}: PanelProps) {
const cardRefs = useRef<Map<number, HTMLLIElement>>(new Map());
if (!annotations.length) return null;
const focusAnnotation = (id: number | null) => {
onSelect(id);
if (id != null) {
cardRefs.current.get(id)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
};
return (
<aside className="painting-annotations-panel" aria-label="Art history annotations">
<h3 className="painting-annotations-title">Art history notes</h3>
<ul className="painting-annotations-list">
{annotations.map((ann, index) => {
const isActive = activeId === ann.id;
const category = CATEGORY_LABELS[ann.category] || ann.category;
return (
<li
key={ann.id}
ref={(el) => {
if (el) cardRefs.current.set(ann.id, el);
else cardRefs.current.delete(ann.id);
}}
className={`painting-annotation-card painting-annotation-card-${ann.category}${isActive ? ' painting-annotation-card-active' : ''}`}
>
<button
type="button"
className="painting-annotation-card-btn"
onClick={() => focusAnnotation(isActive ? null : ann.id)}
>
<span className="painting-annotation-card-head">
<span className="painting-annotation-number">{index + 1}</span>
<span className="painting-annotation-category">{category}</span>
{ann.label && <strong className="painting-annotation-label">{ann.label}</strong>}
</span>
<p className="painting-annotation-body">{ann.body}</p>
{(ann.source_author || ann.source) && (
<p className="painting-annotation-source">
{ann.source_author}
{ann.source && <cite>, {ann.source}</cite>}
</p>
)}
</button>
{ann.source_url && (
<a
className="painting-annotation-source-link"
href={ann.source_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
Read source
</a>
)}
</li>
);
})}
</ul>
</aside>
);
}
+5
View File
@@ -392,6 +392,11 @@
user-select: none;
}
.painting-frame-large .painting-frame-image-wrap img {
width: 100%;
display: block;
}
.painting-description {
max-width: 700px;
margin-top: 20px;
+24 -4
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ChangeEvent, type SyntheticEvent } fr
import type { InfluenceLink, Painting, PaintingDetail } from '../types';
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client';
import DebugSearchResultsModal from './DebugSearchResultsModal';
import PaintingAnnotationsPanel, { PaintingAnnotationMarkers } from './PaintingAnnotations';
import PaintingLightbox from './PaintingLightbox';
import './PaintingDetail.css';
@@ -190,7 +191,7 @@ export default function PaintingDetailView({
onPaintingImageFixed,
onPaintingCheckupFlagsUpdated,
}: Props) {
const { painting, influencedBy, influenced } = data;
const { painting, influencedBy, influenced, annotations = [] } = data;
const [fullscreen, setFullscreen] = useState(false);
const [imageVersion, setImageVersion] = useState(0);
const [debugSearch, setDebugSearch] = useState<DebugImageSearchResult | null>(null);
@@ -205,6 +206,7 @@ export default function PaintingDetailView({
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
const [clearing, setClearing] = useState(false);
const [uploading, setUploading] = useState(false);
const [activeAnnotationId, setActiveAnnotationId] = useState<number | null>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
const baseImageUrl = paintingImageUrl(painting);
@@ -227,6 +229,7 @@ export default function PaintingDetailView({
setMoreOpen(false);
setMoreResults(null);
setMoreError(null);
setActiveAnnotationId(null);
}, [painting.id]);
useEffect(() => {
@@ -475,11 +478,28 @@ export default function PaintingDetailView({
title={imageSrc ? 'View full screen' : undefined}
aria-label={imageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`}
>
{imageSrc ? (
<img src={imageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
) : null}
<div className="painting-frame-image-wrap">
{imageSrc ? (
<img src={imageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
) : null}
{imageSrc && annotations.length > 0 && (
<PaintingAnnotationMarkers
annotations={annotations}
activeId={activeAnnotationId}
onSelect={setActiveAnnotationId}
/>
)}
</div>
</div>
{annotations.length > 0 && (
<PaintingAnnotationsPanel
annotations={annotations}
activeId={activeAnnotationId}
onSelect={setActiveAnnotationId}
/>
)}
{showCatalogNav && (
<button
type="button"
+65 -28
View File
@@ -33,15 +33,17 @@
.timeline-range {
margin-left: 12px;
color: #c9a96e;
color: #f5e6c8;
font-family: 'Georgia', serif;
font-size: 14px;
font-size: 16px;
font-weight: 700;
letter-spacing: 0.5px;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
}
.timeline-container {
position: relative;
height: 88px;
height: 96px;
cursor: grab;
user-select: none;
border: 1px solid rgba(201, 169, 110, 0.3);
@@ -53,6 +55,39 @@
cursor: grabbing;
}
.timeline-lifespan-overlays {
position: absolute;
inset: 0;
z-index: 6;
pointer-events: none;
}
.timeline-lifespan-dim {
position: absolute;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.55);
}
.timeline-lifespan-highlight {
position: absolute;
top: 0;
bottom: 0;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.22) 18%,
rgba(255, 255, 255, 0.38) 50%,
rgba(255, 255, 255, 0.22) 82%,
rgba(255, 255, 255, 0.05) 100%
);
box-shadow:
inset 0 0 0 2px rgba(255, 240, 200, 0.55),
inset 0 0 32px rgba(255, 255, 255, 0.2);
border-left: 2px solid rgba(255, 230, 180, 0.75);
border-right: 2px solid rgba(255, 230, 180, 0.75);
}
.timeline-track {
position: absolute;
inset: 0;
@@ -120,18 +155,16 @@
}
.historical-event-point .event-marker-line {
position: absolute;
top: 18px;
bottom: 22px;
left: 50%;
width: 2px;
transform: translateX(-50%);
background: linear-gradient(
180deg,
rgba(255, 210, 120, 0.95) 0%,
rgba(255, 180, 90, 0.75) 100%
);
box-shadow: 0 0 6px rgba(255, 190, 100, 0.45);
display: none;
}
.historical-event-span {
top: 20px;
bottom: 24px;
background: rgba(180, 70, 55, 0.28);
border-left: 2px solid rgba(255, 150, 110, 0.75);
border-right: 2px solid rgba(255, 150, 110, 0.75);
border-radius: 2px;
}
.historical-event-point::before {
@@ -148,16 +181,13 @@
}
.historical-event-span {
top: 20px;
bottom: 24px;
background: rgba(180, 70, 55, 0.22);
border-left: 2px solid rgba(255, 150, 110, 0.65);
border-right: 2px solid rgba(255, 150, 110, 0.65);
border-radius: 2px;
background: transparent;
border-left-color: transparent;
border-right-color: transparent;
}
.historical-event-span:hover,
.historical-event-point:hover .event-marker-line {
.historical-event-point:hover::before {
filter: brightness(1.2);
}
@@ -197,24 +227,31 @@
bottom: 0;
left: 0;
right: 0;
height: 24px;
height: 34px;
z-index: 5;
}
.tick {
position: absolute;
bottom: 0;
transform: translateX(-50%);
border-left: 1px solid rgba(201, 169, 110, 0.4);
height: 8px;
border-left: 2px solid rgba(245, 230, 200, 0.75);
height: 12px;
}
.tick span {
position: absolute;
bottom: 10px;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
color: rgba(201, 169, 110, 0.7);
font-family: 'Georgia', serif;
font-size: 14px;
font-weight: 700;
color: #f5e6c8;
text-shadow:
0 0 8px rgba(0, 0, 0, 0.95),
0 1px 2px rgba(0, 0, 0, 1),
0 0 1px rgba(0, 0, 0, 1);
white-space: nowrap;
}
+43 -2
View File
@@ -8,6 +8,12 @@ import {
} from '../data/historical-events';
import './Timeline.css';
interface LifespanHighlight {
birthYear: number;
deathYear: number;
color: string;
}
interface Props {
eras: HistoricalEra[];
viewStart: number;
@@ -15,6 +21,7 @@ interface Props {
onViewChange: (start: number, end: number) => void;
absoluteMin: number;
absoluteMax: number;
lifespanHighlight?: LifespanHighlight | null;
}
function yearToPercent(year: number, start: number, end: number): number {
@@ -26,7 +33,7 @@ function formatYear(year: number): string {
return `${year} CE`;
}
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax }: Props) {
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<'left' | 'right' | 'pan' | null>(null);
const dragStart = useRef({ x: 0, viewStart: 0, viewEnd: 0 });
@@ -169,6 +176,15 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
});
}, [viewStart, viewEnd, span]);
const lifespanBand = useMemo(() => {
if (!lifespanHighlight) return null;
const left = yearToPercent(Math.max(lifespanHighlight.birthYear, viewStart), viewStart, viewEnd);
const right = yearToPercent(Math.min(lifespanHighlight.deathYear, viewEnd), viewStart, viewEnd);
const width = right - left;
if (width <= 0) return null;
return { left, width, color: lifespanHighlight.color };
}, [lifespanHighlight, viewStart, viewEnd]);
return (
<div className="timeline-wrapper">
<div className="timeline-controls">
@@ -182,7 +198,7 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
<div
ref={containerRef}
className="timeline-container"
className={`timeline-container${lifespanBand ? ' timeline-container-lifespan-hover' : ''}`}
onWheel={handleWheel}
onMouseDown={(e) => handleMouseDown(e, 'pan')}
>
@@ -218,6 +234,31 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
})}
</div>
{lifespanBand && (
<div className="timeline-lifespan-overlays" aria-hidden>
{lifespanBand.left > 0 && (
<div className="timeline-lifespan-dim" style={{ left: 0, width: `${lifespanBand.left}%` }} />
)}
{lifespanBand.left + lifespanBand.width < 100 && (
<div
className="timeline-lifespan-dim"
style={{
left: `${lifespanBand.left + lifespanBand.width}%`,
width: `${100 - lifespanBand.left - lifespanBand.width}%`,
}}
/>
)}
<div
className="timeline-lifespan-highlight"
style={{
left: `${lifespanBand.left}%`,
width: `${lifespanBand.width}%`,
['--lifespan-color' as string]: lifespanBand.color,
}}
/>
</div>
)}
<div className="timeline-events" aria-hidden={false}>
{visibleEvents.map(({ event, showLabel }) => {
const end = eventEndYear(event);
@@ -0,0 +1,34 @@
.timeline-event-guides {
position: absolute;
top: 78px;
left: 16px;
right: 16px;
bottom: 16px;
pointer-events: none;
z-index: 3;
}
.timeline-event-guide-line {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
transform: translateX(-50%);
background: linear-gradient(
180deg,
rgba(255, 210, 120, 0.55) 0%,
rgba(255, 180, 90, 0.35) 35%,
rgba(255, 160, 80, 0.18) 100%
);
box-shadow: 0 0 8px rgba(255, 190, 100, 0.25);
}
.timeline-event-guide-span {
position: absolute;
top: 0;
bottom: 0;
background: rgba(180, 70, 55, 0.1);
border-left: 2px solid rgba(255, 150, 110, 0.35);
border-right: 2px solid rgba(255, 150, 110, 0.35);
border-radius: 2px;
}
@@ -0,0 +1,53 @@
import { HISTORICAL_EVENTS, eventEndYear, eventInView } from '../data/historical-events';
import './TimelineEventGuides.css';
function yearToPercent(year: number, start: number, end: number): number {
return ((year - start) / (end - start)) * 100;
}
interface Props {
viewStart: number;
viewEnd: number;
}
export default function TimelineEventGuides({ viewStart, viewEnd }: Props) {
const events = HISTORICAL_EVENTS.filter((event) => eventInView(event, viewStart, viewEnd));
return (
<div className="timeline-event-guides" aria-hidden>
{events.map((event) => {
const end = eventEndYear(event);
const isSpan = event.endYear != null && event.endYear !== event.startYear;
if (isSpan) {
const left = yearToPercent(Math.max(event.startYear, viewStart), viewStart, viewEnd);
const right = yearToPercent(Math.min(end, viewEnd), viewStart, viewEnd);
if (right <= 0 || left >= 100) return null;
const width = Math.min(100, right) - Math.max(0, left);
return (
<div
key={event.id}
className="timeline-event-guide-span"
style={{
left: `${Math.max(0, left)}%`,
width: `${width}%`,
}}
/>
);
}
if (event.startYear < viewStart || event.startYear > viewEnd) return null;
const left = yearToPercent(event.startYear, viewStart, viewEnd);
return (
<div
key={event.id}
className="timeline-event-guide-line"
style={{ left: `${left}%` }}
/>
);
})}
</div>
);
}
+39
View File
@@ -380,3 +380,42 @@
border-bottom: 1px solid rgba(201, 169, 110, 0.25);
}
}
.movement-hall-nav-list {
list-style: none;
margin: 0 0 20px;
padding: 0;
}
.movement-hall-nav-list li + li {
margin-top: 8px;
}
.movement-hall-nav-list button {
width: 100%;
text-align: left;
padding: 12px 14px;
border: 1px solid rgba(201, 169, 110, 0.35);
border-radius: 8px;
background: rgba(30, 24, 18, 0.6);
color: #e8d5b5;
cursor: pointer;
}
.movement-hall-nav-list button:hover,
.movement-hall-nav-active {
border-color: rgba(212, 175, 55, 0.75) !important;
background: rgba(60, 48, 32, 0.85) !important;
}
.movement-hall-nav-list small {
display: block;
margin-top: 4px;
font-size: 12px;
opacity: 0.75;
}
.movement-hall-exit-timeline {
width: 100%;
margin-top: 8px;
}
File diff suppressed because it is too large Load Diff