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