Keep selected follow visible and settle rendering during live updates

This commit is contained in:
n30nex
2026-09-06 02:20:32 -04:00
parent 99e8254822
commit 324cd175e5
5 changed files with 64 additions and 8 deletions
+16 -1
View File
@@ -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<void> {
{ 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,
+3 -2
View File
@@ -218,7 +218,7 @@ async function start(): Promise<void> {
required<HTMLElement>('node-inspector-sheet'),
{
appearance: uiPreferences,
onFocusChange(focus) { updateFocusChrome(focus); if (focus) pauseLiveFollow(); },
onFocusChange: updateFocusChrome,
onRouteWindowChange(label) {
const option = routeWindow.querySelector<HTMLOptionElement>('option[value="auto"]');
if (option) option.textContent = label;
@@ -313,6 +313,7 @@ async function start(): Promise<void> {
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<void> {
followPause.addEventListener('click', () => setLiveFollow(followPaused, !followPaused));
required<HTMLButtonElement>('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);
+30
View File
@@ -26,6 +26,7 @@ import {
isRouteInspectable,
isPointInSafeArea,
labelSortKey,
LiveMap,
LIVE_FOLLOW_MIN_INTERVAL_MS,
mapPixelRatio,
mapGlyphLabel,
@@ -746,3 +747,32 @@ function heatWeight(collection: ReturnType<typeof activityHeatCollection>, 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<string>(), 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(); }
});
});
+13 -5
View File
@@ -201,6 +201,8 @@ export class LiveMap {
private readonly reducedMotion = prefersReducedMotion();
private freshnessTimer: number;
private renderEpoch = 0;
private renderingScheduled = false;
private renderingSources = new Set<string>();
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);
+2
View File
@@ -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; }