fix: make nightdrive signage reliable and legible

This commit is contained in:
2026-09-03 02:34:15 +03:00
parent a03d4d8dba
commit cce018d3cb
39 changed files with 4635 additions and 196 deletions

View File

@@ -88,7 +88,7 @@ test('draws only the independently eligible next roadside sign during passive fo
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
const headingTop = profile.getBoundingClientRect().top + window.scrollY;
const destination = Math.max(1, headingTop - viewportHeight * (0.35 + 0.8));
const destination = Math.max(1, headingTop - viewportHeight * (0.35 + 0.45));
return { destination, viewportHeight, expectedLabel: 'Profile' };
});
await page.evaluate(() => window.dispatchEvent(new WheelEvent('wheel', { deltaY: 1 })));
@@ -118,7 +118,7 @@ test('keeps a readable sign immutable through reversal, resize, and a multi-sect
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
const headingTop = profile.getBoundingClientRect().top + window.scrollY;
return {
destination: Math.max(1, headingTop - viewportHeight * (0.35 + 0.8)),
destination: Math.max(1, headingTop - viewportHeight * (0.35 + 0.45)),
viewportHeight,
};
});
@@ -163,7 +163,159 @@ test('suppresses roadside signs for explicit navigation and history restoration'
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual([]);
});
test('reconciles an idle rapid jump to the latest active route without flashing intermediates', async ({
page,
distUrl,
}) => {
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
await page.mouse.wheel(0, 1);
await page.evaluate(() => {
const target = document.getElementById('selected-builds-heading');
if (!(target instanceof HTMLElement)) throw new Error('Missing selected builds heading');
const top = target.getBoundingClientRect().top + window.scrollY;
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
window.scrollTo(0, Math.max(1, top - viewportHeight * 0.35 + 1));
});
await expect.poll(async () => (await readSceneInstrumentation(page)).routeLabelsDrawn)
.toContain('Selected builds');
expect([...new Set((await readSceneInstrumentation(page)).routeLabelsDrawn)])
.toEqual(['Selected builds']);
});
test('keeps the readable Profile face outside its destination panel with verified texel density', async ({
page,
distUrl,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
const destination = await page.evaluate(() => {
const profile = document.getElementById('profile-heading');
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
return Math.max(
1,
profile.getBoundingClientRect().top + window.scrollY
- viewportHeight * (0.35 + 0.45),
);
});
await page.mouse.wheel(0, 1);
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
await expect.poll(async () => (
await readSceneInstrumentation(page)
).signFrames.some((frame) => frame.targetId === 'profile' && frame.phase === 'dwell')).toBe(true);
const instrumentation = await readSceneInstrumentation(page);
const frame = instrumentation.signFrames.findLast((candidate) => (
candidate.targetId === 'profile' && candidate.phase === 'dwell'
));
if (frame === undefined) throw new Error('Missing readable Profile sign frame');
const panel = await page.locator('#profile').evaluate((element) => {
const bounds = element.getBoundingClientRect();
return { left: bounds.left, top: bounds.top, right: bounds.right, bottom: bounds.bottom };
});
expect(frame.bounds.right).toBeLessThanOrEqual(panel.left - 16);
expect(frame.texture).toMatchObject({
width: 2_048,
height: 512,
fontReadyWhenDrawn: true,
});
expect(frame.texture.width / (frame.bounds.width * frame.rendererPixelRatio))
.toBeGreaterThanOrEqual(4);
});
test('keeps a fresh readable sign inside responsive world-safe corridors', async ({
page,
distUrl,
}, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'One exact responsive matrix is sufficient.');
for (const viewport of [
{ width: 390, height: 844, profile: 'tall', minimumFaceWidth: 120 },
{ width: 844, height: 390, profile: 'short', minimumFaceWidth: 130 },
{ width: 768, height: 1_024, profile: 'tall', minimumFaceWidth: 150 },
{ width: 1_440, height: 900, profile: 'wide', minimumFaceWidth: 180 },
{ width: 2_560, height: 1_440, profile: 'wide', minimumFaceWidth: 240 },
] as const) {
await page.setViewportSize(viewport);
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
const destination = await page.evaluate(() => {
const profile = document.getElementById('profile-heading');
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
return Math.max(
1,
profile.getBoundingClientRect().top + window.scrollY
- viewportHeight * (0.35 + 0.45),
);
});
await page.mouse.wheel(0, 1);
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
await expect.poll(async () => (
await readSceneInstrumentation(page)
).signFrames.at(-1)?.phase, { intervals: [16], timeout: 5_000 }).toBe('dwell');
const frame = (await readSceneInstrumentation(page)).signFrames.findLast((candidate) => (
candidate.targetId === 'profile' && candidate.phase === 'dwell'
));
if (frame === undefined) throw new Error(`Missing Profile dwell at ${viewport.width}x${viewport.height}`);
const geometry = await page.evaluate(() => {
const boundsFor = (selector: string) => {
const element = document.querySelector(selector);
if (!(element instanceof HTMLElement)) return null;
const bounds = element.getBoundingClientRect();
return { left: bounds.left, top: bounds.top, right: bounds.right, bottom: bounds.bottom };
};
return {
panel: boundsFor('#profile'),
adjacentPanels: [boundsFor('.hero-journey__content')].filter((bounds) => bounds !== null),
fixedZones: [
boundsFor('.site-navigation'),
boundsFor('.drive-controls'),
boundsFor('.journey-hud'),
].filter((bounds) => bounds !== null),
};
});
if (geometry.panel === null) throw new Error('Missing Profile panel bounds');
expect(frame.viewportProfile).toBe(viewport.profile);
expect(frame.bounds.left).toBeGreaterThanOrEqual(8);
expect(frame.bounds.right).toBeLessThanOrEqual(viewport.width - 8);
expect(frame.bounds.top).toBeGreaterThanOrEqual(8);
expect(frame.bounds.bottom).toBeLessThanOrEqual(viewport.height - 8);
expect(frame.bounds.width).toBeGreaterThanOrEqual(viewport.minimumFaceWidth);
expect(rectanglesOverlap(frame.bounds, geometry.panel, 16)).toBe(false);
for (const adjacentPanel of geometry.adjacentPanels) {
expect(rectanglesOverlap(frame.bounds, adjacentPanel, 16)).toBe(false);
}
for (const fixedZone of geometry.fixedZones) {
expect(rectanglesOverlap(frame.bounds, fixedZone, 8)).toBe(false);
}
expect(frame.texture.fontReadyWhenDrawn).toBe(true);
expect(frame.texture.width / (frame.bounds.width * frame.rendererPixelRatio))
.toBeGreaterThanOrEqual(4);
}
});
function colorEnergy(color: string): number {
const value = Number.parseInt(color.slice(1), 16);
return ((value >> 16) & 0xff) + ((value >> 8) & 0xff) + (value & 0xff);
}
function rectanglesOverlap(
first: { left: number; top: number; right: number; bottom: number },
second: { left: number; top: number; right: number; bottom: number },
gap: number,
): boolean {
return !(
first.right <= second.left - gap
|| first.left >= second.right + gap
|| first.bottom <= second.top - gap
|| first.top >= second.bottom + gap
);
}

View File

@@ -10,6 +10,23 @@ export interface SceneInstrumentationSnapshot {
routeLabelsDrawn: string[];
clearColors: string[];
unhandledRejections: string[];
signFrames: SignFrameInstrumentation[];
}
export interface SignFrameInstrumentation {
now: number;
visible: boolean;
targetId: string;
phase: string;
progress: number;
opacity: number;
activeSectionId: string;
direction: number;
corridor: string;
viewportProfile: string;
bounds: { left: number; top: number; right: number; bottom: number; width: number; height: number };
texture: { width: number; height: number; anisotropy: number; fontReadyWhenDrawn: boolean };
rendererPixelRatio: number;
}
type InstrumentedWindow = Window & {
@@ -34,6 +51,7 @@ export async function installSceneInstrumentation(page: Page): Promise<void> {
routeLabelsDrawn: [],
clearColors: [],
unhandledRejections: [],
signFrames: [],
animationFrameIds: new Set<number>(),
audioContexts: [],
};
@@ -135,6 +153,11 @@ export async function installSceneInstrumentation(page: Page): Promise<void> {
const reason = event.reason;
state.unhandledRejections.push(reason instanceof Error ? reason.message : String(reason));
});
window.addEventListener('portfolio-sign-frame', (event) => {
const detail = (event as CustomEvent<SignFrameInstrumentation>).detail;
if (!detail || state.signFrames.length >= 600) return;
state.signFrames.push(structuredClone(detail));
});
});
}
@@ -150,6 +173,7 @@ export async function readSceneInstrumentation(page: Page): Promise<SceneInstrum
routeLabelsDrawn: [...state.routeLabelsDrawn],
clearColors: [...state.clearColors],
unhandledRejections: [...state.unhandledRejections],
signFrames: state.signFrames.map((frame) => structuredClone(frame)),
};
});
}

View File

@@ -216,7 +216,13 @@ describe('scene controller lifecycle', () => {
await controller.start();
const input = journey({
routeProgress: 0.25,
sign: { targetId: 'selected-builds', phase: 'approach', progress: 0.2, opacity: 0.5 },
sign: {
targetId: 'selected-builds',
phase: 'approach',
progress: 0.2,
opacity: 0.5,
viewportProfile: 'wide',
},
});
controller.update(input);
@@ -225,7 +231,10 @@ describe('scene controller lifecycle', () => {
harness.scheduler.flushFrame(16);
expect(harness.generations[0]?.render).toHaveBeenCalledWith(
expect.objectContaining({ routeProgress: 0.25, sign: expect.objectContaining({ progress: 0.2 }) }),
expect.objectContaining({
routeProgress: 0.25,
sign: expect.objectContaining({ progress: 0.2, viewportProfile: 'wide' }),
}),
16,
);
});

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { createSignController } from '@/journey/sign-controller';
import { SIGN_ROUTE_ORDER, signRouteFor } from '@/journey/sign-routes';
import type { SignControllerSample } from '@/journey/types';
const sample = (
@@ -10,16 +11,30 @@ const sample = (
direction: 1,
acceleration: 0,
deltaY: 16,
viewportWidth: 1_440,
viewportHeight: 800,
crossedBoundaries: 0,
candidateId: 'profile',
candidateDistanceVh: 1,
candidateDistanceVh: 0.5,
activeSectionId: 'start',
motionAllowed: true,
...overrides,
});
describe('clock-driven sign lifecycle', () => {
it('defines one explicit route marker and safe corridor for every eligible destination', () => {
expect(SIGN_ROUTE_ORDER.map((id) => signRouteFor(id))).toEqual([
{ id: 'start', label: 'Start', marker: '01', eligible: false, corridor: null, triggerMaximumVh: 0 },
{ id: 'profile', label: 'Profile', marker: '02', eligible: true, corridor: 'left', triggerMaximumVh: 0.55 },
{ id: 'experience', label: 'Experience', marker: '03', eligible: true, corridor: 'right', triggerMaximumVh: 0.55 },
{ id: 'infrastructure', label: 'Infrastructure', marker: '04', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55 },
{ id: 'selected-builds', label: 'Selected builds', marker: '05', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55 },
{ id: 'additional-work', label: 'Additional work', marker: '06', eligible: true, corridor: 'left', triggerMaximumVh: 0.55 },
{ id: 'toolkit', label: 'Toolkit', marker: '07', eligible: true, corridor: 'outer-left', triggerMaximumVh: 0.55 },
{ id: 'contact', label: 'Contact', marker: '08', eligible: true, corridor: 'right', triggerMaximumVh: 0.55 },
]);
});
it('advances through exact anticipate, approach, dwell, depart, and cooldown timing', () => {
const controller = createSignController();
@@ -28,6 +43,7 @@ describe('clock-driven sign lifecycle', () => {
phase: 'anticipate',
progress: 0,
opacity: 0,
viewportProfile: 'wide',
});
expect(controller.needsFrame()).toBe(true);
expect(controller.sample(sample({ now: 299, direction: 0 }))).toMatchObject({
@@ -87,21 +103,34 @@ describe('clock-driven sign lifecycle', () => {
});
});
it('holds a completed minimum dwell without scheduling idle frames until the target activates', () => {
it('keeps the latched viewport profile immutable across a responsive resize', () => {
const controller = createSignController();
expect(controller.sample(sample())).toMatchObject({ viewportProfile: 'wide' });
expect(controller.sample(sample({
now: 500,
direction: 0,
deltaY: 0,
viewportWidth: 844,
viewportHeight: 390,
}))).toMatchObject({ targetId: 'profile', viewportProfile: 'wide' });
});
it('departs after the minimum dwell even when scrolling stops before the target activates', () => {
const controller = createSignController();
controller.sample(sample());
controller.sample(sample({ now: 1_200, direction: 0 }));
expect(controller.sample(sample({ now: 2_600, direction: 0 }))).toMatchObject({ phase: 'dwell' });
expect(controller.needsFrame()).toBe(false);
expect(controller.sample(sample({ now: 4_000, activeSectionId: 'profile' }))).toMatchObject({
expect(controller.sample(sample({ now: 2_599, direction: 0, deltaY: 0 }))).toMatchObject({ phase: 'dwell' });
expect(controller.needsFrame()).toBe(true);
expect(controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }))).toMatchObject({
phase: 'depart',
progress: 0,
});
expect(controller.needsFrame()).toBe(true);
});
it('cancels a concealed sign on reverse and departs a visible approaching sign cleanly', () => {
it('cancels a concealed sign on reverse but preserves the full dwell for a visible approach', () => {
const early = createSignController();
early.sample(sample());
expect(early.sample(sample({ now: 310, direction: -1, deltaY: -20 }))).toBeNull();
@@ -109,10 +138,14 @@ describe('clock-driven sign lifecycle', () => {
const readable = createSignController();
readable.sample(sample());
expect(readable.sample(sample({ now: 700, direction: -1, deltaY: -20 }))).toMatchObject({
phase: 'depart',
progress: 0,
});
expect(readable.sample(sample({ now: 700, direction: -1, deltaY: -20 })))
.toMatchObject({ phase: 'approach' });
expect(readable.sample(sample({ now: 1_200, direction: 0, deltaY: 0 })))
.toMatchObject({ phase: 'dwell' });
expect(readable.sample(sample({ now: 2_599, direction: 0, deltaY: 0 })))
.toMatchObject({ phase: 'dwell' });
expect(readable.sample(sample({ now: 2_600, direction: 0, deltaY: 0 })))
.toMatchObject({ phase: 'depart', progress: 0 });
});
it('preserves the minimum readable dwell when reverse input requests departure', () => {
@@ -166,7 +199,7 @@ describe('clock-driven sign lifecycle', () => {
});
});
it('departs when the authoritative section has already passed the latched target', () => {
it('preserves approach and minimum dwell when the authoritative section passes the target', () => {
const controller = createSignController();
controller.sample(sample());
@@ -176,9 +209,29 @@ describe('clock-driven sign lifecycle', () => {
candidateId: 'infrastructure',
}))).toMatchObject({
targetId: 'profile',
phase: 'depart',
progress: 0,
phase: 'approach',
});
expect(controller.sample(sample({
now: 1_200,
direction: 0,
deltaY: 0,
activeSectionId: 'experience',
candidateId: 'infrastructure',
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
expect(controller.sample(sample({
now: 2_599,
direction: 0,
deltaY: 0,
activeSectionId: 'experience',
candidateId: 'infrastructure',
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
expect(controller.sample(sample({
now: 2_600,
direction: 0,
deltaY: 0,
activeSectionId: 'experience',
candidateId: 'infrastructure',
}))).toMatchObject({ targetId: 'profile', phase: 'depart', progress: 0 });
});
it('does not truncate dwell when the authoritative section passes the target', () => {
@@ -236,7 +289,7 @@ describe('clock-driven sign lifecycle', () => {
it.each([
['reverse input', { direction: -1 as const, deltaY: -1 }],
['stationary input', { direction: 0 as const, deltaY: 0 }],
['candidate before the approach window', { candidateDistanceVh: 1.200_001 }],
['candidate before the approach window', { candidateDistanceVh: 0.550_001 }],
['candidate already passed', { candidateDistanceVh: 0 }],
['candidate is already active', { activeSectionId: 'profile' as const }],
['missing candidate', { candidateId: null }],
@@ -265,4 +318,144 @@ describe('clock-driven sign lifecycle', () => {
controller.reset();
expect(controller.sample(sample({ now: 100 }))).toMatchObject({ phase: 'anticipate' });
});
it('resolves a rapid multi-section jump to the latest active destination', () => {
const controller = createSignController();
expect(controller.sample(sample({
now: 20,
deltaY: 2_400,
crossedBoundaries: 4,
activeSectionId: 'selected-builds',
candidateId: 'additional-work',
candidateDistanceVh: 0.4,
acceleration: 1,
}))).toMatchObject({
targetId: 'selected-builds',
phase: 'anticipate',
});
});
it('coalesces changing jump targets and reveals only the latest after a readable stale sign departs', () => {
const controller = createSignController();
controller.sample(sample());
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
expect(controller.sample(sample({
now: 1_300,
deltaY: 2_400,
crossedBoundaries: 4,
activeSectionId: 'selected-builds',
candidateId: 'additional-work',
candidateDistanceVh: 0.4,
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
expect(controller.sample(sample({
now: 1_500,
deltaY: 1_200,
crossedBoundaries: 2,
activeSectionId: 'toolkit',
candidateId: 'contact',
candidateDistanceVh: 0.35,
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
expect(controller.sample(sample({
now: 2_600,
direction: 0,
deltaY: 0,
crossedBoundaries: 0,
activeSectionId: 'toolkit',
candidateId: 'contact',
candidateDistanceVh: 0.35,
}))).toMatchObject({ targetId: 'profile', phase: 'depart', progress: 0 });
expect(controller.sample(sample({
now: 3_300,
direction: 0,
deltaY: 0,
crossedBoundaries: 0,
activeSectionId: 'toolkit',
candidateId: 'contact',
candidateDistanceVh: 0.35,
}))).toBeNull();
expect(controller.sample(sample({
now: 3_650,
direction: 0,
deltaY: 0,
crossedBoundaries: 0,
activeSectionId: 'toolkit',
candidateId: 'contact',
candidateDistanceVh: 0.35,
}))).toMatchObject({ targetId: 'toolkit', phase: 'anticipate' });
});
it('retains the latest jump target first observed on the departure-completion frame', () => {
const controller = createSignController();
controller.sample(sample());
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
expect(controller.sample(sample({
now: 3_300,
deltaY: 2_400,
crossedBoundaries: 4,
activeSectionId: 'selected-builds',
candidateId: 'additional-work',
candidateDistanceVh: 0.4,
}))).toBeNull();
expect(controller.sample(sample({
now: 3_650,
direction: 0,
deltaY: 0,
crossedBoundaries: 0,
activeSectionId: 'selected-builds',
candidateId: 'additional-work',
candidateDistanceVh: 0.4,
}))).toMatchObject({ targetId: 'selected-builds', phase: 'anticipate' });
});
it('starts the readable dwell clock on the first rendered dwell frame after a delayed gap', () => {
const controller = createSignController();
controller.sample(sample());
expect(controller.sample(sample({
now: 5_000,
direction: 0,
deltaY: 0,
activeSectionId: 'profile',
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
expect(controller.sample(sample({
now: 6_399,
direction: 0,
deltaY: 0,
activeSectionId: 'profile',
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
expect(controller.sample(sample({
now: 6_400,
direction: 0,
deltaY: 0,
activeSectionId: 'profile',
}))).toMatchObject({ targetId: 'profile', phase: 'depart', progress: 0 });
});
it('does not retrigger a completed route sign during small boundary oscillations', () => {
const controller = createSignController();
controller.sample(sample());
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
controller.sample(sample({ now: 3_300, direction: 0, deltaY: 0 }));
expect(controller.sample(sample({
now: 3_700,
direction: -1,
deltaY: -16,
crossedBoundaries: 1,
activeSectionId: 'start',
}))).toBeNull();
expect(controller.sample(sample({
now: 3_800,
direction: 1,
deltaY: 16,
crossedBoundaries: 1,
activeSectionId: 'profile',
candidateId: 'experience',
}))).toBeNull();
});
});

View File

@@ -576,8 +576,15 @@ describe("procedural world models", () => {
}
const face = sign.group.getObjectByName("sign-face") as {
userData: { textureUpdateCount: number };
material: { opacity: number };
geometry: { getAttribute(name: string): { array: ArrayLike<number> } };
material: {
opacity: number;
map: { anisotropy: number; image: { width: number; height: number } };
};
geometry: {
boundingBox: { min: { x: number; y: number }; max: { x: number; y: number } } | null;
computeBoundingBox(): void;
getAttribute(name: string): { array: ArrayLike<number> };
};
position: { z: number };
};
const backplate = sign.group.getObjectByName("sign-backplate") as {
@@ -593,6 +600,12 @@ describe("procedural world models", () => {
const faceUvs = Array.from(face.geometry.getAttribute("uv").array);
expect(Math.min(...faceUvs)).toBeGreaterThanOrEqual(0);
expect(Math.max(...faceUvs)).toBeLessThanOrEqual(1);
face.geometry.computeBoundingBox();
const faceWidth = (face.geometry.boundingBox?.max.x ?? 0) - (face.geometry.boundingBox?.min.x ?? 0);
const faceHeight = (face.geometry.boundingBox?.max.y ?? 0) - (face.geometry.boundingBox?.min.y ?? 0);
expect(faceWidth / faceHeight).toBeCloseTo(4, 6);
expect(face.material.map.image).toMatchObject({ width: 2_048, height: 512 });
expect(face.material.map.anisotropy).toBeGreaterThanOrEqual(8);
const allocation = resources.snapshot();
sign.update(null);
@@ -667,6 +680,94 @@ describe("procedural world models", () => {
expect(sign.group.scale.x).toBeCloseTo(approachScale, 6);
resources.dispose();
});
it("defers responsive pose changes for the complete lifecycle, including concealed anticipation", () => {
const resources = new SceneResourceBag();
const sign = createSign(resources);
const camera = createChaseCamera(16 / 9);
sign.setViewportSize(1_440, 900);
sign.update({ targetId: "profile", phase: "anticipate", progress: 0, opacity: 0 });
sign.setViewportSize(844, 390);
const concealed = sign.inspect(camera, 844, 390);
expect(concealed?.viewportProfile).toBe("wide");
sign.update({ targetId: "profile", phase: "approach", progress: 0.5, opacity: 0.7 });
expect(sign.inspect(camera, 844, 390)?.viewportProfile).toBe("wide");
sign.update(null);
sign.update({ targetId: "experience", phase: "anticipate", progress: 0, opacity: 0 });
expect(sign.inspect(camera, 844, 390)?.viewportProfile).toBe("short");
resources.dispose();
const rebuiltResources = new SceneResourceBag();
const rebuilt = createSign(rebuiltResources);
rebuilt.setViewportSize(844, 390);
rebuilt.update({
targetId: "profile",
phase: "dwell",
progress: 1,
opacity: 1,
viewportProfile: "wide",
});
expect(rebuilt.inspect(camera, 844, 390)?.viewportProfile).toBe("wide");
rebuiltResources.dispose();
});
it("departs laterally without crossing the chase camera plane or growing toward the car", () => {
const resources = new SceneResourceBag();
const sign = createSign(resources);
sign.setViewportSize(1_440, 900);
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
const dwellX = sign.group.position.x;
const dwellScale = sign.group.scale.x;
sign.update({ targetId: "profile", phase: "depart", progress: 0.9, opacity: 0.1 });
expect(sign.group.position.x).toBeLessThan(dwellX);
expect(sign.group.position.z).toBeLessThanOrEqual(0);
expect(sign.group.scale.x).toBeLessThan(dwellScale);
resources.dispose();
});
it.each([
[390, 844],
[844, 390],
[1_440, 900],
])("keeps the responsive sign footing planted at %ix%i", (width, height) => {
const resources = new SceneResourceBag();
const sign = createSign(resources);
sign.setViewportSize(width, height);
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
const footing = sign.group.getObjectByName("sign-footing") as { position: { y: number } };
const footingWorldY = sign.group.position.y + footing.position.y * sign.group.scale.x;
expect(footingWorldY).toBeGreaterThanOrEqual(-0.5);
expect(footingWorldY).toBeLessThanOrEqual(0.5);
resources.dispose();
});
it("places route signs in the corridor opposite their destination content", () => {
const resources = new SceneResourceBag();
const sign = createSign(resources);
sign.setViewportAspect(16 / 9);
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
expect(sign.group.position.x).toBeLessThan(0);
const profileFace = sign.group.getObjectByName("sign-face");
const camera = createChaseCamera(16 / 9);
camera.position.set(-1.35, 8.6, 22);
camera.lookAt(1.6, 4.8, -128);
sign.group.updateMatrixWorld(true);
const profileBounds = projectedBounds(profileFace, camera);
expect(profileBounds.maxX).toBeLessThan(0.08);
sign.update(null);
sign.update({ targetId: "experience", phase: "dwell", progress: 1, opacity: 1 });
sign.group.updateMatrixWorld(true);
const experienceBounds = projectedBounds(profileFace, camera);
expect(experienceBounds.minX).toBeGreaterThan(0);
resources.dispose();
});
});
function paletteSnapshot(