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
+273
View File
@@ -0,0 +1,273 @@
import type { Painting } from '../types';
import type { GalleryWindowSpec, MovementInteriorStyle } from '../data/movement-interior-styles';
import { comparePaintingsChronological } from './paintingUtils';
/** Target capacity per movement wing (5060 works). */
export const MOVEMENT_PAINTINGS_PER_HALL = 55;
export type WallSide = 'back' | 'left' | 'right';
export interface FrameSlot {
position: [number, number, number];
rotationY: number;
maxW: number;
maxH: number;
side: WallSide;
}
export interface WallSegment {
side: WallSide;
label: string;
paintings: Painting[];
slots: FrameSlot[];
}
export interface MovementHallLayout {
hallIndex: number;
hallCount: number;
width: number;
depth: number;
segments: WallSegment[];
paintingCount: number;
yearLabel: string;
}
const WALL_HEIGHT = 4.2;
const WALL_THICKNESS = 0.18;
const MOUNT_OFFSET = 0.16;
const WALL_STANDOFF = 0.07;
const EYE_HEIGHT = 1.65;
const FRAME_GAP = 0.32;
const MIN_FRAME_W = 0.45;
const MAX_FRAME_W = 1.05;
const MAX_FRAME_H = 1.35;
const MIN_HALL_SIZE = 10;
const MIN_HALL_WIDTH = 11;
const WALL_PADDING = 1.4;
const FRAME_MAT_BORDER = 0.1;
const FRAME_RAIL = 0.08;
const REVIEWED_MAT_BORDER = FRAME_MAT_BORDER * 2;
const REVIEWED_RAIL = FRAME_RAIL * 2;
function paintingIsReviewed(p: Painting) {
return !!p.checkup_checked;
}
function frameDims(reviewed: boolean) {
return reviewed
? { matBorder: REVIEWED_MAT_BORDER, rail: REVIEWED_RAIL }
: { matBorder: FRAME_MAT_BORDER, rail: FRAME_RAIL };
}
function frameOuterW(w: number, reviewed: boolean) {
const { matBorder, rail } = frameDims(reviewed);
return w + matBorder * 2 + rail;
}
function layoutRow(paintings: Painting[], span: number) {
const count = paintings.length;
if (count === 0) return { slots: [] as { offset: number; maxW: number; maxH: number }[], spanNeeded: span };
let frameW = MAX_FRAME_W;
for (let attempt = 0; attempt < 40; attempt++) {
let total = 0;
for (let i = 0; i < count; i++) {
total += frameOuterW(frameW, paintingIsReviewed(paintings[i]));
if (i < count - 1) total += FRAME_GAP;
}
if (total <= span - WALL_PADDING) break;
frameW -= 0.015;
}
frameW = Math.max(MIN_FRAME_W, frameW);
const frameH = Math.min(MAX_FRAME_H, frameW * 1.22);
const outers = paintings.map((p) => frameOuterW(frameW, paintingIsReviewed(p)));
const rowWidth = outers.reduce((s, w) => s + w, 0) + (count - 1) * FRAME_GAP;
const slots: { offset: number; maxW: number; maxH: number }[] = [];
let cursor = -rowWidth / 2;
for (let i = 0; i < count; i++) {
slots.push({ offset: cursor + outers[i] / 2, maxW: frameW, maxH: frameH });
cursor += outers[i] + FRAME_GAP;
}
return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) };
}
function orderForWall(paintings: Painting[]) {
return [...paintings].sort(comparePaintingsChronological).reverse();
}
function formatYearLabel(paintings: Painting[]) {
const years = paintings.map((p) => p.year).filter((y): y is number => y != null);
if (years.length === 0) return 'Undated works';
const min = Math.min(...years);
const max = Math.max(...years);
return min === max ? `${min}` : `${min} ${max}`;
}
export function splitPaintingsIntoMovementHalls(paintings: Painting[]): Painting[][] {
const sorted = [...paintings].sort(comparePaintingsChronological);
if (sorted.length === 0) return [[]];
const chunks: Painting[][] = [];
for (let i = 0; i < sorted.length; i += MOVEMENT_PAINTINGS_PER_HALL) {
chunks.push(sorted.slice(i, i + MOVEMENT_PAINTINGS_PER_HALL));
}
return chunks;
}
function distributeToSideWalls(paintings: Painting[]) {
const left: Painting[] = [];
const right: Painting[] = [];
const sorted = [...paintings].sort(comparePaintingsChronological);
sorted.forEach((p, i) => (i % 2 === 0 ? left : right).push(p));
return { left: orderForWall(left), right: orderForWall(right) };
}
function layoutSideSlots(
paintings: Painting[],
span: number,
side: 'left' | 'right',
halfW: number,
inset: number
): FrameSlot[] {
if (paintings.length === 0) return [];
const { slots: rowSlots } = layoutRow(paintings, span);
const y = EYE_HEIGHT;
return rowSlots.map((s) => ({
maxW: s.maxW,
maxH: s.maxH,
rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2,
side,
position:
side === 'left'
? ([-halfW + inset + WALL_STANDOFF, y, s.offset] as [number, number, number])
: ([halfW - inset - WALL_STANDOFF, y, s.offset] as [number, number, number]),
}));
}
export function buildMovementHallLayout(
paintings: Painting[],
hallIndex: number,
hallCount: number
): MovementHallLayout {
const { left, right } = distributeToSideWalls(paintings);
const leftSpan = layoutRow(left, MIN_HALL_SIZE);
const rightSpan = layoutRow(right, MIN_HALL_SIZE);
const depth = Math.max(MIN_HALL_SIZE, leftSpan.spanNeeded, rightSpan.spanNeeded);
const width = MIN_HALL_WIDTH;
const halfW = width / 2;
const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET;
const segments: WallSegment[] = [
{ side: 'back', label: '', paintings: [], slots: [] },
{
side: 'left',
label: left.length > 0 ? `Wing ${hallIndex + 1} · Left wall` : '',
paintings: left,
slots: layoutSideSlots(left, depth, 'left', halfW, inset),
},
{
side: 'right',
label: right.length > 0 ? `Wing ${hallIndex + 1} · Right wall` : '',
paintings: right,
slots: layoutSideSlots(right, depth, 'right', halfW, inset),
},
];
return {
hallIndex,
hallCount,
width,
depth,
segments,
paintingCount: paintings.length,
yearLabel: formatYearLabel(paintings),
};
}
export function buildAllMovementHallLayouts(paintings: Painting[]): MovementHallLayout[] {
const chunks = splitPaintingsIntoMovementHalls(paintings);
return chunks.map((chunk, i) => buildMovementHallLayout(chunk, i, chunks.length));
}
function mergeIntervals(intervals: [number, number][]): [number, number][] {
if (intervals.length === 0) return [];
const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
const out: [number, number][] = [sorted[0]];
for (let i = 1; i < sorted.length; i++) {
const last = out[out.length - 1];
if (sorted[i][0] <= last[1]) last[1] = Math.max(last[1], sorted[i][1]);
else out.push(sorted[i]);
}
return out;
}
function findWallGaps(occupied: [number, number][], halfSpan: number, minGap: number): [number, number][] {
const merged = mergeIntervals(occupied);
const gaps: [number, number][] = [];
let cursor = -halfSpan + 1.2;
for (const [a, b] of merged) {
if (a - cursor >= minGap) gaps.push([cursor, a]);
cursor = Math.max(cursor, b);
}
if (halfSpan - 1.2 - cursor >= minGap) gaps.push([cursor, halfSpan - 1.2]);
return gaps.sort((a, b) => b[1] - b[0] - (a[1] - a[0]));
}
function windowTemplate(style: MovementInteriorStyle): Pick<GalleryWindowSpec, 'style' | 'lightColor' | 'lightIntensity' | 'width' | 'height'> {
const side = style.windows.find((w) => w.wall === 'left' || w.wall === 'right');
if (side) {
return {
style: side.style,
lightColor: side.lightColor,
lightIntensity: side.lightIntensity,
width: Math.min(side.width, 1.6),
height: Math.min(side.height, 1.5),
};
}
return { style: 'sash', lightColor: style.warmLight, lightIntensity: 2.8, width: 1.4, height: 1.4 };
}
/** Place windows on side walls only, in gaps between painting frames. */
export function computeSideWallWindows(
layout: MovementHallLayout,
interiorStyle: MovementInteriorStyle
): GalleryWindowSpec[] {
const halfD = layout.depth / 2;
const tmpl = windowTemplate(interiorStyle);
const specs: GalleryWindowSpec[] = [];
const windowY = 3.15;
const minGap = tmpl.width + 0.6;
for (const side of ['left', 'right'] as const) {
const seg = layout.segments.find((s) => s.side === side);
if (!seg) continue;
const occupied = seg.slots.map((s): [number, number] => {
const outerW = frameOuterW(s.maxW, false);
return [s.position[2] - outerW / 2 - 0.45, s.position[2] + outerW / 2 + 0.45];
});
const gaps = findWallGaps(occupied, halfD, minGap);
const maxWindows = Math.min(3, gaps.length);
for (let i = 0; i < maxWindows; i++) {
const [g0, g1] = gaps[i];
const center = (g0 + g1) / 2;
const w = Math.min(tmpl.width, g1 - g0 - 0.35);
if (w < 0.9) continue;
specs.push({
wall: side,
x: center,
y: windowY,
width: w,
height: tmpl.height,
style: tmpl.style,
lightColor: tmpl.lightColor,
lightIntensity: tmpl.lightIntensity,
});
}
}
return specs;
}
export { WALL_HEIGHT };