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:
1 parent
23e1210e86
commit
8111cd0163
13 files changed
+632
-166
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 & right walls — up to ~55 per wing</li>
|
||||
<li>Back door: wing navigator & 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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user