diff --git a/web/e2e/customization.spec.ts b/web/e2e/customization.spec.ts index 9dced98..abe577d 100644 --- a/web/e2e/customization.spec.ts +++ b/web/e2e/customization.spec.ts @@ -95,9 +95,23 @@ test('Live Follow holds its activity card for ten seconds and pauses when the us await page.mouse.move(1000, 450); await page.mouse.down(); await page.mouse.move(1100, 460, { steps: 4 }); - await page.mouse.up(); await expect(page.locator('#follow-card')).toHaveAttribute('data-state', 'paused'); + await page.mouse.up(); } + await page.locator('#find-button').click(); + await page.locator('#node-search').fill('Summit'); + await page.locator('.node-search-result').first().click(); + await page.locator('#follow-button').click(); + await emit(page, 5, 'Text'); + await expect(page.locator('#follow-card')).toHaveAttribute('data-state', 'following'); + await expect(page.locator('.node-inspector')).toBeHidden(); + // A new neighbour is a feed update, not a manual selection or a pause request. + await page.evaluate(() => window.dispatchEvent(new CustomEvent('fixture-packet', { detail: { + seq: 6, id: 'fixture-new-neighbour', at: Date.now(), mode: 'route', payloadType: 'Advert', + segments: [{ routeId: 'new-neighbour', fromId: 'summit', toId: 'meadow' }], + } }))); + await expect(page.locator('#map')).toHaveAttribute('data-neighbor-route-count', '3'); + await expect(page.locator('#follow-card')).toHaveAttribute('data-state', 'following'); await page.locator('#follow-close').click(); await expect(page.locator('#follow-card')).toBeHidden(); }); @@ -108,6 +122,7 @@ async function fixture(page: Page): Promise { { id: 'valley', label: 'Valley Fixture', lng: -80.4, lat: 43.5 }, { id: 'summit', label: 'Summit Fixture', lng: -80.2, lat: 43.65 }, { id: 'ridge', label: 'Ridge Fixture', lng: -80.0, lat: 43.55 }, + { id: 'meadow', label: 'Meadow Fixture', lng: -80.3, lat: 43.65 }, ].map((node) => ({ ...node, role: 'repeater' as const, observer: false, lastSeen: now })); const state: StateV2 = { schemaVersion: 2, bootId: 'customization-fixture', seq: 0, serverTime: now, diff --git a/web/src/main.ts b/web/src/main.ts index b487c7c..799696e 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -218,7 +218,7 @@ async function start(): Promise { required('node-inspector-sheet'), { appearance: uiPreferences, - onFocusChange(focus) { updateFocusChrome(focus); if (focus) pauseLiveFollow(); }, + onFocusChange: updateFocusChrome, onRouteWindowChange(label) { const option = routeWindow.querySelector('option[value="auto"]'); if (option) option.textContent = label; @@ -313,6 +313,7 @@ async function start(): Promise { const button = document.getElementById(id); if (button && uiPreferences[key] !== DEFAULT_UI_PREFERENCES[key]) button.click(); } + if (legendExpanded) legendToggle.click(); persistUiPreference({ basemap: 'dark', theme: 'map', routeOpacity: 0.8, relief: 0.75, routeWindow: 'auto' }); routeWindow.value = 'auto'; liveMap.setRouteWindow('auto'); @@ -457,7 +458,7 @@ async function start(): Promise { followPause.addEventListener('click', () => setLiveFollow(followPaused, !followPaused)); required('follow-close').addEventListener('click', () => setLiveFollow(false)); - liveMap.map.on('dragstart', () => { if (liveFollow) setLiveFollow(false, true); }); + for (const gesture of ['dragstart', 'click'] as const) liveMap.map.on(gesture, () => { if (liveFollow) setLiveFollow(false, true); }); for (const type of ['zoomstart', 'rotatestart', 'pitchstart'] as const) { liveMap.map.on(type, (event) => { if (event.originalEvent && liveFollow) setLiveFollow(false, true); diff --git a/web/src/map.test.ts b/web/src/map.test.ts index 9f18376..e9eaad1 100644 --- a/web/src/map.test.ts +++ b/web/src/map.test.ts @@ -26,6 +26,7 @@ import { isRouteInspectable, isPointInSafeArea, labelSortKey, + LiveMap, LIVE_FOLLOW_MIN_INTERVAL_MS, mapPixelRatio, mapGlyphLabel, @@ -746,3 +747,32 @@ function heatWeight(collection: ReturnType, id: s if (!feature) throw new Error(`missing heat feature ${id}`); return Number(feature.properties?.weight); } + + +describe('render readiness under continuous traffic', () => { + it('coalesces updates while still waiting for requested sources and two frames', () => { + const frames: FrameRequestCallback[] = []; + const request = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { frames.push(callback); return frames.length; }); + const view = Object.create(LiveMap.prototype); + let sourceLoaded = false; + Object.assign(view, { + container: document.createElement('div'), renderEpoch: 0, renderingScheduled: false, + renderingSources: new Set(), routesVisible: false, + map: { getSource: () => ({}), isSourceLoaded: () => sourceLoaded }, + }); + try { + view.markRendering(['regions']); + for (let index = 0; index < 20; index++) view.markRendering(); + expect(frames).toHaveLength(1); + frames.shift()!(0); + expect(view.container.dataset.renderState).toBe('rendering'); + sourceLoaded = true; + frames.shift()!(16); + view.markRendering(); + expect(frames).toHaveLength(1); + frames.shift()!(32); + expect(view.container.dataset.renderState).toBe('idle'); + expect(view.renderingScheduled).toBe(false); + } finally { request.mockRestore(); } + }); +}); diff --git a/web/src/map.ts b/web/src/map.ts index e0023a2..3ec4481 100644 --- a/web/src/map.ts +++ b/web/src/map.ts @@ -201,6 +201,8 @@ export class LiveMap { private readonly reducedMotion = prefersReducedMotion(); private freshnessTimer: number; private renderEpoch = 0; + private renderingScheduled = false; + private renderingSources = new Set(); private layersReady = false; constructor( @@ -1335,15 +1337,19 @@ export class LiveMap { } private markRendering(sourceIDs?: readonly string[]): void { - const epoch = ++this.renderEpoch; - const sourceEpoch = this.routeHydrationEpoch; - let settledFrames = 0; this.container.dataset.renderState = 'rendering'; + for (const sourceID of sourceIDs ?? []) this.renderingSources.add(sourceID); + // One readiness loop survives new packets; a busy feed must not keep + // cancelling the two-frame settle check or queue duplicate frame callbacks. + if (this.renderingScheduled) return; + this.renderingScheduled = true; + const epoch = ++this.renderEpoch; + let settledFrames = 0; // Basemap tiles and live packets can keep MapLibre's global loaded/idle // state false indefinitely. Gate readiness on CartoLite's own sources. const settle = (): void => { - if (epoch !== this.renderEpoch || sourceEpoch !== this.routeHydrationEpoch) return; - const sourcesSettled = !sourceIDs?.length || sourceIDs.every((sourceID) => ( + if (epoch !== this.renderEpoch) return; + const sourcesSettled = [...this.renderingSources].every((sourceID) => ( Boolean(this.map.getSource(sourceID)) && this.map.isSourceLoaded(sourceID) )); const awaitingVisibleRoutes = this.routesVisible @@ -1358,6 +1364,8 @@ export class LiveMap { window.requestAnimationFrame(settle); return; } + this.renderingSources.clear(); + this.renderingScheduled = false; this.container.dataset.renderState = 'idle'; }; window.requestAnimationFrame(settle); diff --git a/web/src/styles.css b/web/src/styles.css index a2c3363..8034095 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -694,3 +694,5 @@ button { font: inherit; } #layers-panel { max-height: calc(100dvh - 86px - env(safe-area-inset-top)); } :root[data-view-class="mobile"] #layers-panel, :root[data-view-class="mobile"] .find-panel { top: max(62px, calc(env(safe-area-inset-top) + 54px)); } } + +.director-enabled :is(.node-inspector-sheet, .node-inspector-popup) { visibility: hidden; pointer-events: none; }