Improve movement flow layout/animation and fix Byzantine hall doors, styles, and preload.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-07-28 20:36:55 +03:00
co-authored by Cursor
parent 23e1210e86
commit 8111cd0163
13 changed files with 632 additions and 166 deletions
+10
View File
@@ -302,6 +302,15 @@ export async function preloadArtistImages(artistId: number): Promise<{ fetched:
return res.json();
}
export async function preloadMovementImages(movementId: number): Promise<{ fetched: number; total: number }> {
const res = await fetch(`${API}/movements/${movementId}/preload-images`, {
...fetchCredentials,
method: 'POST',
});
if (!res.ok) throw new Error('Preload failed');
return res.json();
}
export interface FixPaintingImageResult {
imagePath: string | null;
thumbnailPath: string | null;
@@ -864,6 +873,7 @@ export const api = {
}),
preloadArtistImages,
preloadMovementImages,
};
export interface TranslationWorklistItem {
+12 -10
View File
@@ -96,13 +96,13 @@
}
.movement-branch-fast {
stroke-opacity: 0.36;
stroke-opacity: 0.55;
stroke-width: calc(var(--stream-stroke) * 0.45);
stroke-linecap: round;
}
.movement-stream-fast {
stroke-opacity: 0.42;
stroke-opacity: 0.62;
stroke-width: var(--stream-stroke);
stroke-linecap: round;
vector-effect: non-scaling-stroke;
@@ -120,13 +120,14 @@
stroke-width: var(--stream-stroke);
stroke-linecap: round;
vector-effect: non-scaling-stroke;
filter: saturate(1.25) brightness(1.12) contrast(1.08);
transition: filter 0.2s ease, opacity 0.2s ease;
}
.movement-stream-core {
stroke-width: 2px;
stroke-linecap: round;
opacity: 0.22;
opacity: 0.38;
stroke-dasharray: 8 6;
animation: stream-shimmer 16s linear infinite;
vector-effect: non-scaling-stroke;
@@ -134,29 +135,30 @@
}
.movements-flow-canvas.movements-flow-movement-hover .movement-stream-fill:not(.movement-stream-highlighted) {
opacity: 0.55;
opacity: 0.5;
filter: saturate(1.05) brightness(0.95) contrast(1.02);
}
.movements-flow-canvas.movements-flow-movement-hover .movement-stream-core:not(.movement-stream-highlighted) {
opacity: 0.1;
opacity: 0.14;
}
.movements-flow-canvas.movements-flow-movement-hover .movement-branch:not(.movement-branch-highlighted) {
opacity: 0.45;
opacity: 0.4;
}
.movement-stream-fill.movement-stream-highlighted {
filter: brightness(1.65) saturate(1.3);
filter: brightness(1.75) saturate(1.45) contrast(1.12);
}
.movement-stream-core.movement-stream-highlighted {
opacity: 0.72;
filter: brightness(1.5);
opacity: 0.82;
filter: brightness(1.55) saturate(1.2);
stroke-width: 2.5px;
}
.movement-branch.movement-branch-highlighted {
filter: brightness(1.55) saturate(1.2);
filter: brightness(1.65) saturate(1.35) contrast(1.1);
}
@keyframes stream-shimmer {
+379 -91
View File
@@ -29,6 +29,11 @@ interface MovementLayout {
yEnd: number;
depth: number;
parentIds: number[];
/** Band thickness in CSS pixels (proportional to influence_link_count). */
strokePx: number;
portraitSizePx: number;
/** Saturated display color for the dark flow canvas. */
displayColor: string;
}
interface BranchSegment {
@@ -40,6 +45,7 @@ interface BranchSegment {
y1: number;
x2: number;
y2: number;
strokePx: number;
}
const MAX_STREAM_STROKE_PX = 54;
@@ -51,6 +57,22 @@ const CANVAS_TOP_PAD = 38;
const CANVAS_BOTTOM_PAD = 24;
const DEFAULT_CANVAS_HEIGHT = 360;
function movementInfluenceCount(movement: ArtMovement): number {
const n = movement.influence_link_count;
return typeof n === 'number' && Number.isFinite(n) ? Math.max(0, n) : 0;
}
/** Map influence-link count → desired band thickness (linear vs catalog max). */
function desiredStrokeForCount(count: number, maxCount: number): number {
if (maxCount <= 0) return MIN_STREAM_STROKE_PX;
const t = Math.max(0, Math.min(1, count / maxCount));
return ABS_MIN_STREAM_STROKE_PX + t * (MAX_STREAM_STROKE_PX - ABS_MIN_STREAM_STROKE_PX);
}
function portraitSizeForStroke(strokePx: number): number {
return Math.max(14, Math.min(39, strokePx * 0.72));
}
function yearToPercent(year: number, start: number, end: number): number {
return ((year - start) / (end - start)) * 100;
}
@@ -140,15 +162,14 @@ function buildArtistPlacements(
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 minGap = portraitMinGapPct(layout.portraitSizePx, canvasWidthPx);
const portraitHalfPct = minGap / 2;
for (const artist of movementArtists) {
@@ -213,7 +234,7 @@ function buildArtistPlacements(
portraitX: x,
y,
colorIndex,
color: artistLifespanColor(layout.movement.color, colorIndex, colorCount),
color: artistLifespanColor(layout.displayColor, colorIndex, colorCount),
});
});
}
@@ -271,6 +292,24 @@ function hslToHex(h: number, s: number, l: number): string {
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
}
/** Boost saturation / mid lightness so streams read vividly on the dark flow canvas. */
function vividMovementColor(hex: string): string {
try {
const [r, g, b] = parseHexColor(hex);
const [h, s, l] = rgbToHsl(r, g, b);
const s2 = s < 0.1 ? Math.min(0.55, s + 0.42) : Math.min(1, s * 1.65 + 0.08);
const l2 =
l < 0.22
? 0.5
: l > 0.78
? 0.62
: Math.min(0.68, Math.max(0.4, l * 0.75 + 0.28));
return hslToHex(h, s2, l2);
} catch {
return hex;
}
}
function artistLifespanColor(baseColor: string, laneIndex: number, laneCount: number): string {
if (laneCount <= 1) return baseColor;
try {
@@ -332,7 +371,10 @@ function assignDepths(movements: ArtMovement[], lineageParents: Map<number, numb
return depth;
}
/** Pack movements into lanes — only concurrent (overlapping) spans need separate lanes. */
/** Same-lane movements must leave at least this many years between one end and the next start. */
const LANE_MIN_GAP_YEARS = 10;
/** Pack movements into lanes — overlapping or closer than LANE_MIN_GAP_YEARS need separate lanes. */
function assignTemporalLanes(
group: ArtMovement[],
viewStart: number,
@@ -351,7 +393,7 @@ function assignTemporalLanes(
const laneById = new Map<number, number>();
for (const span of spans) {
let lane = laneEnds.findIndex((endYear) => endYear <= span.start);
let lane = laneEnds.findIndex((endYear) => endYear + LANE_MIN_GAP_YEARS <= span.start);
if (lane === -1) {
lane = laneEnds.length;
laneEnds.push(span.end);
@@ -368,8 +410,9 @@ function spansOverlap(a0: number, a1: number, b0: number, b1: number, tol = 0.15
return a0 < b1 - tol && b0 < a1 - tol;
}
/** True when spans overlap or the gap between them is shorter than LANE_MIN_GAP_YEARS. */
function yearSpansConflict(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
return aStart < bEnd && bStart < aEnd;
return !(aEnd + LANE_MIN_GAP_YEARS <= bStart || bEnd + LANE_MIN_GAP_YEARS <= aStart);
}
/** Approximate branch X span (same origin/target X as branch assembly), padded for thick strokes. */
@@ -379,17 +422,42 @@ function branchCorridorXRange(
childXStart: number,
childIndex: number,
childCount: number
): { x0: number; x1: number } {
const t = childCount === 1 ? 0.5 : (childIndex + 1) / (childCount + 1);
const originX = parentXStart + t * (parentXEnd - parentXStart);
): { x0: number; x1: number } | null {
const originX = forwardBranchOriginX(parentXStart, parentXEnd, childXStart, childIndex, childCount);
if (originX == null) return null;
const targetX = childXStart;
const x0 = Math.min(originX, targetX);
const x1 = Math.max(originX, targetX);
const x0 = originX;
const x1 = targetX;
// Near-vertical transitions still have a wide SVG stroke — pad so overlapping streams register.
const pad = Math.max(4, (x1 - x0) * 0.35);
return { x0: x0 - pad, x1: x1 + pad };
}
/** Minimum horizontal gap so transitions always read left→right (earlier→later). */
const BRANCH_MIN_FORWARD_DX = 0.4;
/**
* Pick a parent-stream X that is always strictly left of the child target (time-forward).
* Returns null when the parent band cannot leave before the child start.
*/
function forwardBranchOriginX(
parentXStart: number,
parentXEnd: number,
targetX: number,
childIndex: number,
childCount: number
): number | null {
const maxOrigin = targetX - BRANCH_MIN_FORWARD_DX;
if (maxOrigin < parentXStart) return null;
const t = childCount === 1 ? 0.5 : (childIndex + 1) / (childCount + 1);
let x = parentXStart + t * (parentXEnd - parentXStart);
x = Math.min(x, maxOrigin);
x = Math.max(parentXStart, Math.min(parentXEnd, x));
if (x >= targetX) return null;
return x;
}
function clippedMovementSpan(
movement: ArtMovement,
viewStart: number,
@@ -569,13 +637,15 @@ function refineLanesForLineageCorridors(
const children = childIdsByParent.get(parentId) || [childId];
const childIndex = Math.max(0, children.indexOf(childId));
const { x0, x1 } = branchCorridorXRange(
const corridor = branchCorridorXRange(
parentSpan.xStart,
parentSpan.xEnd,
childSpan.xStart,
childIndex,
children.length
);
if (!corridor) continue;
const { x0, x1 } = corridor;
const lo = Math.min(parentLane, childLane);
const hi = Math.max(parentLane, childLane);
@@ -650,12 +720,118 @@ function branchPath(xFrom: number, yFrom: number, xTo: number, yTo: number): str
return `M ${xFrom} ${yFrom} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${xTo} ${yTo}`;
}
function branchOriginOnParent(parent: MovementLayout, childIndex: number, childCount: number): {
x: number;
y: number;
} {
const t = childCount === 1 ? 0.5 : (childIndex + 1) / (childCount + 1);
const x = parent.xStart + t * (parent.xEnd - parent.xStart);
/** Exponential chase rate (1/s) so pan/zoom layout shifts read as motion, not snaps. */
const LAYOUT_ANIM_RATE = 14;
const LAYOUT_ANIM_EPS = 0.06;
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
function geomSettled(a: number, b: number, eps = LAYOUT_ANIM_EPS): boolean {
return Math.abs(a - b) <= eps;
}
function lerpMovementLayout(from: MovementLayout, to: MovementLayout, t: number): MovementLayout {
return {
...to,
xStart: lerp(from.xStart, to.xStart, t),
xEnd: lerp(from.xEnd, to.xEnd, t),
y: lerp(from.y, to.y, t),
yEnd: lerp(from.yEnd, to.yEnd, t),
strokePx: lerp(from.strokePx, to.strokePx, t),
portraitSizePx: lerp(from.portraitSizePx, to.portraitSizePx, t),
};
}
function movementLayoutSettled(a: MovementLayout, b: MovementLayout): boolean {
return (
geomSettled(a.xStart, b.xStart) &&
geomSettled(a.xEnd, b.xEnd) &&
geomSettled(a.y, b.y) &&
geomSettled(a.yEnd, b.yEnd) &&
geomSettled(a.strokePx, b.strokePx, 0.35)
);
}
function lerpBranchSegment(from: BranchSegment, to: BranchSegment, t: number): BranchSegment {
const x1 = lerp(from.x1, to.x1, t);
const y1 = lerp(from.y1, to.y1, t);
const x2 = lerp(from.x2, to.x2, t);
const y2 = lerp(from.y2, to.y2, t);
const strokePx = lerp(from.strokePx, to.strokePx, t);
return {
...to,
x1,
y1,
x2,
y2,
strokePx,
d: branchPath(x1, y1, x2, y2),
};
}
function branchSegmentSettled(a: BranchSegment, b: BranchSegment): boolean {
return (
geomSettled(a.x1, b.x1) &&
geomSettled(a.y1, b.y1) &&
geomSettled(a.x2, b.x2) &&
geomSettled(a.y2, b.y2) &&
geomSettled(a.strokePx, b.strokePx, 0.35)
);
}
interface AnimatedFlowGeometry {
layouts: MovementLayout[];
branches: BranchSegment[];
streamCurveOffset: number;
}
function blendFlowGeometry(
from: AnimatedFlowGeometry,
to: AnimatedFlowGeometry,
t: number
): { next: AnimatedFlowGeometry; settled: boolean } {
const fromLayouts = new Map(from.layouts.map((l) => [l.movement.id, l]));
const fromBranches = new Map(from.branches.map((b) => [b.key, b]));
let settled = geomSettled(from.streamCurveOffset, to.streamCurveOffset, 0.15);
const layouts = to.layouts.map((target) => {
const prev = fromLayouts.get(target.movement.id);
if (!prev) return target;
if (movementLayoutSettled(prev, target)) return target;
settled = false;
return lerpMovementLayout(prev, target, t);
});
const branches = to.branches.map((target) => {
const prev = fromBranches.get(target.key);
if (!prev) return target;
if (branchSegmentSettled(prev, target)) return target;
settled = false;
return lerpBranchSegment(prev, target, t);
});
return {
next: {
layouts,
branches,
streamCurveOffset: settled
? to.streamCurveOffset
: lerp(from.streamCurveOffset, to.streamCurveOffset, t),
},
settled,
};
}
function branchOriginOnParent(
parent: MovementLayout,
childIndex: number,
childCount: number,
targetX: number
): { x: number; y: number } | null {
const x = forwardBranchOriginX(parent.xStart, parent.xEnd, targetX, childIndex, childCount);
if (x == null) return null;
return { x, y: yOnStream(parent, x) };
}
@@ -791,19 +967,24 @@ function buildLabelPlacements(
layoutHeight: number,
canvasWidth: number,
canvasHeight: number,
streamStrokePx: number,
portraitSizePx: number,
branches: BranchSegment[] = []
): LabelPlacement[] {
if (layouts.length === 0 || canvasWidth <= 0 || canvasHeight <= 0) return [];
const portraitObstacles = artistPlacements.map((p) =>
portraitObstacleRect(p.portraitX, p.y, portraitSizePx, layoutHeight, canvasWidth, canvasHeight)
portraitObstacleRect(
p.portraitX,
p.y,
p.layout.portraitSizePx,
layoutHeight,
canvasWidth,
canvasHeight
)
);
const bandObstacles = layouts.map((layout) => ({
id: layout.movement.id,
rect: streamBandObstacleRect(layout, streamStrokePx, layoutHeight, canvasWidth, canvasHeight),
rect: streamBandObstacleRect(layout, layout.strokePx, layoutHeight, canvasWidth, canvasHeight),
}));
const branchObstacles = branches.map((branch) => {
@@ -813,7 +994,7 @@ function buildLabelPlacements(
toId,
rect: branchCorridorObstacleRect(
branch,
streamStrokePx,
branch.strokePx,
layoutHeight,
canvasWidth,
canvasHeight
@@ -938,6 +1119,7 @@ const MovementArtistPortrait = memo(function MovementArtistPortrait({
portraitX,
y,
layoutHeight,
portraitSizePx,
color,
colorIndex,
isHovered,
@@ -959,6 +1141,7 @@ const MovementArtistPortrait = memo(function MovementArtistPortrait({
portraitX: number;
y: number;
layoutHeight: number;
portraitSizePx: number;
color: string;
colorIndex: number;
isHovered: boolean;
@@ -1017,6 +1200,8 @@ const MovementArtistPortrait = memo(function MovementArtistPortrait({
top: `${(y / layoutHeight) * 100}%`,
borderColor: color,
zIndex: isHovered ? 13 : 5 + colorIndex,
width: `${portraitSizePx}px`,
height: `${portraitSizePx}px`,
}}
onClick={() => onArtistClick(artist.id)}
onMouseEnter={() => {
@@ -1079,6 +1264,7 @@ export default function MovementBands({
const [portraitsLoading, setPortraitsLoading] = useState(false);
const [panning, setPanning] = useState(false);
const [interacting, setInteracting] = useState(false);
const [layoutAnimating, setLayoutAnimating] = useState(false);
const [canvasHeight, setCanvasHeight] = useState(DEFAULT_CANVAS_HEIGHT);
const [canvasWidth, setCanvasWidth] = useState(800);
const [hoveredArtistKey, setHoveredArtistKey] = useState<string | null>(null);
@@ -1114,6 +1300,11 @@ export default function MovementBands({
const interactionTimer = useRef<number | null>(null);
const viewRef = useRef({ viewStart, viewEnd });
const onViewChangeRef = useRef(onViewChange);
const flowTargetRef = useRef<AnimatedFlowGeometry | null>(null);
const flowVisualRef = useRef<AnimatedFlowGeometry | null>(null);
const flowRafRef = useRef<number | null>(null);
const flowLastTsRef = useRef(0);
const [flowVisual, setFlowVisual] = useState<AnimatedFlowGeometry | null>(null);
viewRef.current = { viewStart, viewEnd };
onViewChangeRef.current = onViewChange;
@@ -1139,6 +1330,7 @@ export default function MovementBands({
useEffect(() => () => {
if (interactionTimer.current != null) window.clearTimeout(interactionTimer.current);
if (flowRafRef.current != null) cancelAnimationFrame(flowRafRef.current);
}, []);
useEffect(() => {
@@ -1280,6 +1472,18 @@ export default function MovementBands({
const layoutHeight = Math.max(200, canvasHeight);
const usableHeight = layoutHeight - CANVAS_TOP_PAD - CANVAS_BOTTOM_PAD;
const maxInfluence = Math.max(
1,
...visibleMovements.map((m) => movementInfluenceCount(m))
);
const desiredStrokeById = new Map<number, number>();
for (const movement of visibleMovements) {
desiredStrokeById.set(
movement.id,
desiredStrokeForCount(movementInfluenceCount(movement), maxInfluence)
);
}
// Pack all visible movements into shared horizontal lanes whenever their
// clipped time spans do not overlap, then clear unrelated streams out of
// lineage branch corridors (parent/child adjacency preferred).
@@ -1295,26 +1499,57 @@ export default function MovementBands({
let maxLanes = 0;
const laneOccupancy = new Map<number, number>();
const laneMaxDesiredStroke = new Map<number, number>();
for (const movement of visibleMovements) {
const lane = laneIndex.get(movement.id) ?? 0;
maxLanes = Math.max(maxLanes, lane + 1);
laneOccupancy.set(lane, (laneOccupancy.get(lane) ?? 0) + 1);
const desired = desiredStrokeById.get(movement.id) ?? ABS_MIN_STREAM_STROKE_PX;
laneMaxDesiredStroke.set(lane, Math.max(laneMaxDesiredStroke.get(lane) ?? 0, desired));
}
maxLanes = Math.max(1, maxLanes);
// Always fit every lane inside the canvas — never floor laneStep to min stroke
// (that pushed lower streams/branches past the bottom edge).
const laneStep = usableHeight / maxLanes;
let streamStrokePx = Math.min(MAX_STREAM_STROKE_PX, laneStep - Math.min(LANE_GAP_PX, laneStep * 0.25));
streamStrokePx = Math.max(ABS_MIN_STREAM_STROKE_PX, streamStrokePx);
if (laneStep >= MIN_STREAM_STROKE_PX + LANE_GAP_PX) {
streamStrokePx = Math.max(MIN_STREAM_STROKE_PX, streamStrokePx);
// Lane heights proportional to the thickest band in each lane (influence-weighted).
const laneHeightsDesired: number[] = [];
for (let lane = 0; lane < maxLanes; lane++) {
const stroke = laneMaxDesiredStroke.get(lane) ?? ABS_MIN_STREAM_STROKE_PX;
laneHeightsDesired[lane] = stroke + LANE_GAP_PX;
}
const desiredTotal = laneHeightsDesired.reduce((sum, h) => sum + h, 0) || 1;
const fitScale = usableHeight / desiredTotal;
const laneHeights = laneHeightsDesired.map((h) => h * fitScale);
const laneCenters: number[] = [];
let yCursor = CANVAS_TOP_PAD;
for (let lane = 0; lane < maxLanes; lane++) {
laneCenters[lane] = yCursor + laneHeights[lane] / 2;
yCursor += laneHeights[lane];
}
const portraitSizePx = Math.max(21, Math.min(39, streamStrokePx * 0.72));
const streamCurveOffset = Math.min(12, Math.max(2, (laneStep - streamStrokePx) * 0.35));
const yMax = layoutHeight - CANVAS_BOTTOM_PAD - streamStrokePx / 2;
const yMin = CANVAS_TOP_PAD + streamStrokePx / 2;
let maxStrokePx = ABS_MIN_STREAM_STROKE_PX;
const strokeById = new Map<number, number>();
for (const movement of visibleMovements) {
const lane = laneIndex.get(movement.id) ?? 0;
const raw = (desiredStrokeById.get(movement.id) ?? ABS_MIN_STREAM_STROKE_PX) * fitScale;
const laneCap = Math.max(
ABS_MIN_STREAM_STROKE_PX,
laneHeights[lane] - LANE_GAP_PX * Math.min(1, fitScale)
);
const strokePx = Math.min(
MAX_STREAM_STROKE_PX,
Math.max(ABS_MIN_STREAM_STROKE_PX, Math.min(raw, laneCap))
);
strokeById.set(movement.id, strokePx);
maxStrokePx = Math.max(maxStrokePx, strokePx);
}
const streamStrokePx = maxStrokePx;
const portraitSizePx = portraitSizeForStroke(streamStrokePx);
const medianLaneHeight =
laneHeights.reduce((sum, h) => sum + h, 0) / Math.max(1, laneHeights.length);
const streamCurveOffset = Math.min(12, Math.max(2, medianLaneHeight * 0.12));
const yMax = layoutHeight - CANVAS_BOTTOM_PAD - ABS_MIN_STREAM_STROKE_PX / 2;
const yMin = CANVAS_TOP_PAD + ABS_MIN_STREAM_STROKE_PX / 2;
const layoutById = new Map<number, MovementLayout>();
const branchList: BranchSegment[] = [];
@@ -1327,9 +1562,11 @@ export default function MovementBands({
const depth = depths.get(movement.id) ?? 0;
const lane = laneIndex.get(movement.id) ?? 0;
const yRaw = CANVAS_TOP_PAD + lane * laneStep + laneStep / 2;
const strokePx = strokeById.get(movement.id) ?? ABS_MIN_STREAM_STROKE_PX;
const yRaw = laneCenters[lane] ?? CANVAS_TOP_PAD + usableHeight / 2;
const y = Math.min(yMax, Math.max(yMin, yRaw));
const allowDrift = (laneOccupancy.get(lane) ?? 0) === 1 && laneStep >= 80;
const allowDrift =
(laneOccupancy.get(lane) ?? 0) === 1 && (laneHeights[lane] ?? 0) >= 80;
const drift = allowDrift ? organicDrift(movement.id + 1000) * 0.22 : 0;
const yEnd = Math.min(yMax, Math.max(yMin, y + drift));
const parentIds = lineageParents.get(movement.id) || [];
@@ -1342,6 +1579,9 @@ export default function MovementBands({
yEnd,
depth,
parentIds,
strokePx,
portraitSizePx: portraitSizeForStroke(strokePx),
displayColor: vividMovementColor(movement.color),
});
}
@@ -1368,18 +1608,20 @@ export default function MovementBands({
const children = childIdsByParent.get(parentId)!;
const childIndex = children.indexOf(layout.movement.id);
const origin = branchOriginOnParent(parent, childIndex, children.length);
const target = branchTargetOnChild(layout);
const origin = branchOriginOnParent(parent, childIndex, children.length, target.x);
if (!origin) continue;
branchList.push({
key: `${parentId}-${layout.movement.id}`,
d: branchPath(origin.x, origin.y, target.x, target.y),
colorFrom: parent.movement.color,
colorTo: layout.movement.color,
colorFrom: parent.displayColor,
colorTo: layout.displayColor,
x1: origin.x,
y1: origin.y,
x2: target.x,
y2: target.y,
strokePx: layout.strokePx,
});
}
}
@@ -1395,6 +1637,47 @@ export default function MovementBands({
};
}, [visibleMovements, movements, viewStart, viewEnd, canvasHeight]);
useLayoutEffect(() => {
const target: AnimatedFlowGeometry = { layouts, branches, streamCurveOffset };
flowTargetRef.current = target;
if (!flowVisualRef.current) {
flowVisualRef.current = target;
setFlowVisual(target);
setLayoutAnimating(false);
return;
}
setLayoutAnimating(true);
if (flowRafRef.current != null) return;
flowLastTsRef.current = performance.now();
const step = (now: number) => {
const prev = flowVisualRef.current;
const goal = flowTargetRef.current;
if (!prev || !goal) {
flowRafRef.current = null;
setLayoutAnimating(false);
return;
}
const dt = Math.min(0.048, Math.max(0, (now - flowLastTsRef.current) / 1000));
flowLastTsRef.current = now;
const t = 1 - Math.exp(-LAYOUT_ANIM_RATE * dt);
const { next, settled } = blendFlowGeometry(prev, goal, t);
flowVisualRef.current = settled ? goal : next;
setFlowVisual(flowVisualRef.current);
if (settled) {
flowRafRef.current = null;
setLayoutAnimating(false);
return;
}
flowRafRef.current = requestAnimationFrame(step);
};
flowRafRef.current = requestAnimationFrame(step);
}, [layouts, branches, streamCurveOffset]);
const artistPlacementsForLabels = useMemo(
() =>
buildArtistPlacements(
@@ -1402,16 +1685,15 @@ export default function MovementBands({
artistsByMovement,
viewStart,
viewEnd,
portraitSizePx,
canvasWidth
),
[layouts, artistsByMovement, viewStart, viewEnd, portraitSizePx, canvasWidth]
[layouts, artistsByMovement, viewStart, viewEnd, canvasWidth]
);
const artistPlacements = useMemo(() => {
if (interacting || panning) return [];
if (interacting || panning || layoutAnimating) return [];
return artistPlacementsForLabels;
}, [artistPlacementsForLabels, interacting, panning]);
}, [artistPlacementsForLabels, interacting, panning, layoutAnimating]);
const labelPlacements = useMemo(
() =>
@@ -1421,8 +1703,6 @@ export default function MovementBands({
layoutHeight,
canvasWidth,
canvasHeight,
streamStrokePx,
portraitSizePx,
branches
),
[
@@ -1431,8 +1711,6 @@ export default function MovementBands({
layoutHeight,
canvasWidth,
canvasHeight,
streamStrokePx,
portraitSizePx,
branches,
]
);
@@ -1454,8 +1732,12 @@ export default function MovementBands({
);
}
const layoutById = new Map(layouts.map((l) => [l.movement.id, l]));
const layoutById = new Map((flowVisual?.layouts ?? layouts).map((l) => [l.movement.id, l]));
const drawLayouts = flowVisual?.layouts ?? layouts;
const drawBranches = flowVisual?.branches ?? branches;
const drawCurveOffset = flowVisual?.streamCurveOffset ?? streamCurveOffset;
const fastGraphics = interacting || panning;
const overlaysReady = !fastGraphics && !layoutAnimating;
const showPortraits = hoveredMovementId != null || hoveredArtistKey != null;
return (
@@ -1500,24 +1782,24 @@ export default function MovementBands({
height={layoutHeight}
>
<rect x={0} y={0} width={100} height={layoutHeight} fill="white" />
{layouts.map((layout) => (
<path
key={`branch-cutout-${layout.movement.id}`}
d={streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset)}
fill="none"
stroke="black"
strokeLinecap="round"
strokeLinejoin="round"
style={{
strokeWidth: streamStrokePx * 1.12,
vectorEffect: 'non-scaling-stroke',
}}
/>
))}
{drawLayouts.map((layout) => (
<path
key={`branch-cutout-${layout.movement.id}`}
d={streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, drawCurveOffset)}
fill="none"
stroke="black"
strokeLinecap="round"
strokeLinejoin="round"
style={{
strokeWidth: layout.strokePx * 1.12,
vectorEffect: 'non-scaling-stroke',
}}
/>
))}
</mask>
{!fastGraphics &&
branches.map((branch) => (
drawBranches.map((branch) => (
<linearGradient
key={`branch-grad-${branch.key}`}
id={`branch-grad-${branch.key}`}
@@ -1527,17 +1809,17 @@ export default function MovementBands({
x2={branch.x2}
y2={branch.y2}
>
<stop offset="0%" stopColor={branch.colorFrom} stopOpacity={0.36} />
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0.36} />
<stop offset="0%" stopColor={branch.colorFrom} stopOpacity={0.55} />
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0.55} />
</linearGradient>
))}
{!fastGraphics &&
layouts.map((layout) => {
drawLayouts.map((layout) => {
const primaryParent =
layout.parentIds.length > 0 ? layoutById.get(layout.parentIds[0]) : null;
const hasChildren = (childIdsByParent.get(layout.movement.id)?.length ?? 0) > 0;
const parentColor = primaryParent?.movement.color ?? layout.movement.color;
const parentColor = primaryParent?.displayColor ?? layout.displayColor;
return (
<linearGradient
@@ -1551,29 +1833,29 @@ export default function MovementBands({
>
{primaryParent ? (
<>
<stop offset="0%" stopColor={parentColor} stopOpacity={0.28} />
<stop offset="16%" stopColor={layout.movement.color} stopOpacity={0.34} />
<stop offset="32%" stopColor={layout.movement.color} stopOpacity={0.38} />
<stop offset="0%" stopColor={parentColor} stopOpacity={0.48} />
<stop offset="16%" stopColor={layout.displayColor} stopOpacity={0.58} />
<stop offset="32%" stopColor={layout.displayColor} stopOpacity={0.64} />
</>
) : (
<stop
offset="0%"
stopColor={layout.movement.color}
stopOpacity={layout.movement.start_definite ? 0.28 : 0.08}
stopColor={layout.displayColor}
stopOpacity={layout.movement.start_definite ? 0.5 : 0.18}
/>
)}
<stop offset="50%" stopColor={layout.movement.color} stopOpacity={0.38} />
<stop offset="50%" stopColor={layout.displayColor} stopOpacity={0.64} />
{hasChildren ? (
<>
<stop offset="68%" stopColor={layout.movement.color} stopOpacity={0.38} />
<stop offset="84%" stopColor={layout.movement.color} stopOpacity={0.22} />
<stop offset="100%" stopColor={layout.movement.color} stopOpacity={0} />
<stop offset="68%" stopColor={layout.displayColor} stopOpacity={0.64} />
<stop offset="84%" stopColor={layout.displayColor} stopOpacity={0.4} />
<stop offset="100%" stopColor={layout.displayColor} stopOpacity={0} />
</>
) : (
<stop
offset="100%"
stopColor={layout.movement.color}
stopOpacity={layout.movement.end_definite ? 0.28 : 0.08}
stopColor={layout.displayColor}
stopOpacity={layout.movement.end_definite ? 0.5 : 0.18}
/>
)}
</linearGradient>
@@ -1584,25 +1866,27 @@ export default function MovementBands({
{fastGraphics ? (
<>
<g mask="url(#movement-branch-cutout)">
{branches.map((branch) => (
{drawBranches.map((branch) => (
<path
key={branch.key}
d={branch.d}
className="movement-branch movement-branch-fast"
stroke={branch.colorTo}
fill="none"
style={{ strokeWidth: branch.strokePx * 0.45 }}
/>
))}
</g>
{layouts.map((layout) => {
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset);
{drawLayouts.map((layout) => {
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, drawCurveOffset);
return (
<path
key={`stream-${layout.movement.id}`}
d={d}
className="movement-stream-core movement-stream-fast"
stroke={layout.movement.color}
stroke={layout.displayColor}
fill="none"
style={{ strokeWidth: layout.strokePx }}
/>
);
})}
@@ -1610,7 +1894,7 @@ export default function MovementBands({
) : (
<>
<g mask="url(#movement-branch-cutout)">
{branches.map((branch) => {
{drawBranches.map((branch) => {
const [fromId, toId] = branch.key.split('-').map(Number);
const branchHighlighted =
hoveredMovementId != null &&
@@ -1622,13 +1906,14 @@ export default function MovementBands({
className={`movement-branch${branchHighlighted ? ' movement-branch-highlighted' : ''}`}
stroke={`url(#branch-grad-${branch.key})`}
fill="none"
style={{ strokeWidth: branch.strokePx }}
/>
);
})}
</g>
{layouts.map((layout) => {
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset);
{drawLayouts.map((layout) => {
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, drawCurveOffset);
const streamHighlighted = hoveredMovementId === layout.movement.id;
return (
<path
@@ -1637,19 +1922,20 @@ export default function MovementBands({
className={`movement-stream-fill${streamHighlighted ? ' movement-stream-highlighted' : ''}`}
stroke={`url(#stream-grad-${layout.movement.id})`}
fill="none"
style={{ strokeWidth: layout.strokePx }}
/>
);
})}
{layouts.map((layout) => {
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset);
{drawLayouts.map((layout) => {
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, drawCurveOffset);
const streamHighlighted = hoveredMovementId === layout.movement.id;
return (
<path
key={`core-${layout.movement.id}`}
d={d}
className={`movement-stream-core${streamHighlighted ? ' movement-stream-highlighted' : ''}`}
stroke={layout.movement.color}
stroke={layout.displayColor}
fill="none"
/>
);
@@ -1658,7 +1944,7 @@ export default function MovementBands({
)}
</svg>
{hoveredPlacement && !fastGraphics && (
{hoveredPlacement && overlaysReady && (
<div className="movement-lifespan-overlays" aria-hidden>
{hoveredPlacement.lineLeft > 0 && (
<div
@@ -1686,10 +1972,10 @@ export default function MovementBands({
</div>
)}
{!fastGraphics && (
{overlaysReady && (
<>
<div className="movements-flow-hits" aria-hidden>
{layouts.map((layout) => (
{drawLayouts.map((layout) => (
<div
key={`hit-${layout.movement.id}`}
className="movement-stream-hit"
@@ -1697,6 +1983,7 @@ export default function MovementBands({
left: `${layout.xStart}%`,
width: `${Math.max(layout.xEnd - layout.xStart, 0.5)}%`,
top: `${(((layout.y + layout.yEnd) / 2) / layoutHeight) * 100}%`,
height: `${layout.strokePx}px`,
}}
onMouseEnter={() => setMovementHover(layout.movement.id)}
onMouseLeave={clearMovementHoverSoon}
@@ -1744,6 +2031,7 @@ export default function MovementBands({
portraitX={portraitX}
y={y}
layoutHeight={layoutHeight}
portraitSizePx={layout.portraitSizePx}
color={color}
colorIndex={colorIndex}
isHovered={hoveredArtistKey === artistKey}
+55 -19
View File
@@ -1418,6 +1418,7 @@ function ArtistHall({
nearPassage?: boolean;
}) {
const { width, depth, segments } = layout;
const endWallHasDoor = movementMode && 'endWallHasDoor' in layout ? layout.endWallHasDoor : false;
const halfW = width / 2;
const halfD = depth / 2;
const walls = useMemo(
@@ -1500,7 +1501,7 @@ function ArtistHall({
</>
)}
{/* Front wall — passage to next wing, or solid (artist exit / movement entrance) */}
{/* Front wall — next-wing passage, entrance exit (single-wing), or artist exit */}
{movementMode && hasNextHall ? (
<>
<GalleryWall
@@ -1527,10 +1528,8 @@ function ArtistHall({
trimColor={walls.trim}
/>
</>
) : movementMode ? (
<>
<GalleryWall position={[0, WALL_HEIGHT / 2, halfD]} size={[width, WALL_HEIGHT]} color={walls.main} />
</>
) : movementMode && endWallHasDoor ? (
<GalleryWall position={[0, WALL_HEIGHT / 2, halfD]} size={[width, WALL_HEIGHT]} color={walls.main} />
) : (
<>
<GalleryWall position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} color={walls.main} />
@@ -1547,8 +1546,8 @@ function ArtistHall({
</>
)}
{/* Back wall — movement exit / navigation, or solid with title */}
{movementMode ? (
{/* Back wall — multi-wing exit / navigator, or solid end wall for paintings */}
{movementMode && endWallHasDoor ? (
<>
<GalleryWall
position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, -halfD]}
@@ -1576,6 +1575,17 @@ function ArtistHall({
/>
</group>
</>
) : movementMode ? (
interiorStyle ? (
<TexturedWall
kind={interiorStyle.surfaces.wall}
tint={interiorStyle.tints.wall}
position={[0, WALL_HEIGHT / 2, -halfD]}
size={[width, WALL_HEIGHT, WALL_THICKNESS]}
/>
) : (
<GalleryWall position={[0, WALL_HEIGHT / 2, -halfD]} size={[width, WALL_HEIGHT]} color={walls.main} />
)
) : null}
{interiorStyle && computedWindows && computedWindows.length > 0 && (
@@ -2108,11 +2118,22 @@ export default function VirtualGallery(props: Props) {
for (const seg of layout.segments) {
for (const slot of seg.slots) boxes.push(paintingKeepOutBounds(slot));
}
// Door jambs — keep 0.5 m from the opening posts; opening corridor stays walkable.
boxes.push(...doorJambKeepOuts(wallInnerHalfD, true));
if (isWingedHall) boxes.push(...doorJambKeepOuts(wallInnerHalfD, false));
const endWallHasDoor =
isWingedHall && 'endWallHasDoor' in layout ? layout.endWallHasDoor : false;
// Front door/passage jambs when the entrance wall has an opening.
if (!isWingedHall || hasNextHall || !endWallHasDoor) {
boxes.push(...doorJambKeepOuts(wallInnerHalfD, true));
}
// Back exit jambs for multi-wing halls.
if (isWingedHall && endWallHasDoor) {
boxes.push(...doorJambKeepOuts(wallInnerHalfD, false));
}
return boxes;
}, [layout.segments, wallInnerHalfD, isWingedHall]);
}, [layout, wallInnerHalfD, isWingedHall, hasNextHall]);
const endWallHasDoor =
isWingedHall && 'endWallHasDoor' in layout ? layout.endWallHasDoor : false;
const exitOnFront = !isWingedHall || !endWallHasDoor;
const resolvePlayerPosition = useCallback(
(pos: { x: number; z: number }, allowFrontDoorApproach: boolean) => {
@@ -2123,8 +2144,10 @@ export default function VirtualGallery(props: Props) {
let minZ = -playHalfD;
let maxZ = playHalfD;
if (inDoorBand) {
minZ = -wallInnerHalfD + 0.08;
if (hasNextHall || allowFrontDoorApproach) maxZ = wallInnerHalfD - 0.08;
if (endWallHasDoor) minZ = -wallInnerHalfD + 0.08;
if (hasNextHall || allowFrontDoorApproach || exitOnFront) {
maxZ = wallInnerHalfD - 0.08;
}
}
pos.z = Math.max(minZ, Math.min(maxZ, pos.z));
} else {
@@ -2144,6 +2167,8 @@ export default function VirtualGallery(props: Props) {
exitZ,
isWingedHall,
hasNextHall,
endWallHasDoor,
exitOnFront,
wallInnerHalfD,
collisionObstacles,
]
@@ -2218,23 +2243,30 @@ export default function VirtualGallery(props: Props) {
}
}, [isWingedHall, onBack, !isWingedHall ? props.data.artist.id : undefined]);
const backExitZ = isWingedHall ? -playHalfD : exitZ;
const backExitZ = isWingedHall && endWallHasDoor ? -playHalfD : exitZ;
const frontPassageZ = playHalfD;
const updateProximityFlags = useCallback(
(pos: { x: number; z: number }) => {
if (isWingedHall) {
const atBackExit = pos.z < backExitZ + 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
const atBackExit =
endWallHasDoor &&
pos.z < backExitZ + 0.8 &&
Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
const atFrontExit =
!endWallHasDoor &&
pos.z > frontPassageZ - 0.8 &&
Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
const atFrontPassage =
hasNextHall && pos.z > frontPassageZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
setNearExit(atBackExit);
setNearExit(!!(atBackExit || atFrontExit));
setNearPassage(!!atFrontPassage);
} else {
const atExit = pos.z > exitZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
setNearExit(atExit);
}
},
[isWingedHall, backExitZ, frontPassageZ, hasNextHall, exitZ]
[isWingedHall, endWallHasDoor, backExitZ, frontPassageZ, hasNextHall, exitZ]
);
const moveCamera = useCallback(
@@ -2548,8 +2580,12 @@ export default function VirtualGallery(props: Props) {
{isWingedHall ? (
<>
<li>Date and artist labels appear below each frame</li>
<li>Works hang on left &amp; right walls up to ~55 per wing</li>
<li>Back door: wing navigator &amp; exit to timeline</li>
<li>Works hang on left, end, and right walls up to ~55 per wing</li>
<li>
{movementHalls.length > 1
? 'Back door: wing navigator & exit to timeline · Front: next wing'
: 'Entrance door: exit to timeline'}
</li>
{movementHalls.length > 1 && (
<li>Front archway: walk to the next chronological wing</li>
)}
+38 -2
View File
@@ -578,13 +578,48 @@ function blendAccent(style: MovementInteriorStyle, accentHex?: string): Movement
};
}
/**
* Styles were authored with legacy ids 2752; gallery_dev uses 126.
* Northern (5) / High Renaissance (6) are swapped vs the legacy sequence.
*/
const DB_ID_TO_STYLE_KEY: Record<number, number> = {
1: 27,
2: 28,
3: 29,
4: 30,
5: 32,
6: 31,
7: 33,
8: 34,
9: 35,
10: 36,
11: 37,
12: 38,
13: 39,
14: 40,
15: 41,
16: 42,
17: 43,
18: 44,
19: 45,
20: 46,
21: 47,
22: 48,
23: 49,
24: 50,
25: 51,
26: 52,
};
/** Fallback name-based resolver for movements not in the catalog. */
function fallbackByName(movement: ArtMovement & { era_name?: string }): MovementInteriorStyle {
const n = movement.name.toLowerCase();
const era = (movement.era_name ?? '').toLowerCase();
if (n.includes('byzantine')) return BY_MOVEMENT_ID[28];
if (n.includes('gothic')) return BY_MOVEMENT_ID[29];
if (n.includes('renaissance')) return BY_MOVEMENT_ID[31];
if (n.includes('baroque') || n.includes('rococo')) return BY_MOVEMENT_ID[34];
if (era.includes('medieval') || n.includes('gothic')) return BY_MOVEMENT_ID[29];
if (era.includes('medieval')) return BY_MOVEMENT_ID[29];
if (n.includes('impression')) return BY_MOVEMENT_ID[39];
if (era.includes('modern') || era.includes('contemporary')) return BY_MOVEMENT_ID[52];
return BY_MOVEMENT_ID[36];
@@ -593,7 +628,8 @@ function fallbackByName(movement: ArtMovement & { era_name?: string }): Movement
export function resolveMovementInteriorStyle(
movement: ArtMovement & { era_name?: string }
): MovementInteriorStyle {
const base = BY_MOVEMENT_ID[movement.id] ?? fallbackByName(movement);
const styleKey = DB_ID_TO_STYLE_KEY[movement.id] ?? movement.id;
const base = BY_MOVEMENT_ID[styleKey] ?? fallbackByName(movement);
return blendAccent(base, movement.color);
}
+1
View File
@@ -641,6 +641,7 @@ export default function HomePage() {
const handleMovementClick = async (movementId: number) => {
setGalleryEntryLoading('Opening movement gallery…');
try {
await api.preloadMovementImages(movementId).catch(() => undefined);
const data = await api.getMovementGallery(movementId);
openMovementGallery(movementId, data);
} catch {
+2
View File
@@ -20,6 +20,8 @@ export interface ArtMovement {
era_name?: string;
description: string;
color: string;
/** Influence edges on paintings by artists in this movement (timeline band weight). */
influence_link_count?: number;
}
export interface Artist {
+55 -15
View File
@@ -29,6 +29,8 @@ export interface MovementHallLayout {
segments: WallSegment[];
paintingCount: number;
yearLabel: string;
/** When true, far (Z) wall has a center exit; paintings hang on flanks only. */
endWallHasDoor: boolean;
}
const WALL_HEIGHT = 4.2;
@@ -165,36 +167,59 @@ function layoutSideSlots(
});
}
/** Far wall ahead of the entrance — split across door flanks (exit sits in the center). */
/** Far wall ahead of the entrance — full span, or split across door flanks. */
function layoutBackSlots(
paintings: Painting[],
width: number,
halfD: number,
inset: number
inset: number,
hasDoor: boolean
): FrameSlot[] {
if (paintings.length === 0) return [];
const flankSpan = Math.max(MIN_FRAME_W + WALL_PADDING, (width - DOOR_CLEARANCE) / 2);
const mid = Math.ceil(paintings.length / 2);
const leftFlank = paintings.slice(0, mid);
const rightFlank = paintings.slice(mid);
const y = EYE_HEIGHT;
const z = -halfD + inset + WALL_STANDOFF;
const leftCenterX = -DOOR_CLEARANCE / 2 - flankSpan / 2;
const rightCenterX = DOOR_CLEARANCE / 2 + flankSpan / 2;
const mapFlank = (group: Painting[], centerX: number): FrameSlot[] => {
if (group.length === 0) return [];
const { slots: rowSlots } = layoutRow(group, flankSpan);
if (!hasDoor) {
const { slots: rowSlots } = layoutRow(paintings, width);
return rowSlots.map((s) => ({
maxW: s.maxW,
maxH: s.maxH,
rotationY: 0,
side: 'back' as const,
position: [centerX + s.offset, y, z] as [number, number, number],
position: [s.offset, y, z] as [number, number, number],
}));
}
const flankSpan = Math.max(MIN_FRAME_W + WALL_PADDING, (width - DOOR_CLEARANCE) / 2);
const mid = Math.ceil(paintings.length / 2);
const leftFlank = paintings.slice(0, mid);
const rightFlank = paintings.slice(mid);
const leftCenterX = -DOOR_CLEARANCE / 2 - flankSpan / 2;
const rightCenterX = DOOR_CLEARANCE / 2 + flankSpan / 2;
const doorEdge = DOOR_CLEARANCE / 2 + 0.08;
const mapFlank = (group: Painting[], centerX: number, side: 'left' | 'right'): FrameSlot[] => {
if (group.length === 0) return [];
const { slots: rowSlots } = layoutRow(group, flankSpan);
return rowSlots.map((s) => {
let x = centerX + s.offset;
const halfOuter = frameOuterW(s.maxW, false) / 2;
if (side === 'left' && x + halfOuter > -doorEdge) {
x = -doorEdge - halfOuter;
} else if (side === 'right' && x - halfOuter < doorEdge) {
x = doorEdge + halfOuter;
}
return {
maxW: s.maxW,
maxH: s.maxH,
rotationY: 0,
side: 'back' as const,
position: [x, y, z] as [number, number, number],
};
});
};
return [...mapFlank(leftFlank, leftCenterX), ...mapFlank(rightFlank, rightCenterX)];
return [...mapFlank(leftFlank, leftCenterX, 'left'), ...mapFlank(rightFlank, rightCenterX, 'right')];
}
export function buildMovementHallLayout(
@@ -206,7 +231,21 @@ export function buildMovementHallLayout(
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;
// Single-wing halls put the exit on the entrance wall, so the far wall is solid
// and can use its full width for paintings. Multi-wing halls keep a back exit.
const endWallHasDoor = hallCount > 1;
let width: number;
if (endWallHasDoor) {
const mid = Math.ceil(back.length / 2);
const leftFlankNeed = layoutRow(back.slice(0, mid), MIN_FRAME_W + WALL_PADDING).spanNeeded;
const rightFlankNeed = layoutRow(back.slice(mid), MIN_FRAME_W + WALL_PADDING).spanNeeded;
width = Math.max(MIN_HALL_WIDTH, leftFlankNeed + DOOR_CLEARANCE + rightFlankNeed);
} else {
width = Math.max(MIN_HALL_WIDTH, layoutRow(back, MIN_HALL_SIZE).spanNeeded);
}
const halfW = width / 2;
const halfD = depth / 2;
const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET;
@@ -216,7 +255,7 @@ export function buildMovementHallLayout(
side: 'back',
label: back.length > 0 ? `Wing ${hallIndex + 1} · End wall` : '',
paintings: back,
slots: layoutBackSlots(back, width, halfD, inset),
slots: layoutBackSlots(back, width, halfD, inset, endWallHasDoor),
},
{
side: 'left',
@@ -240,6 +279,7 @@ export function buildMovementHallLayout(
segments,
paintingCount: paintings.length,
yearLabel: formatYearLabel(paintings),
endWallHasDoor,
};
}