mirror of
https://github.com/n30nex/CartoLite-Server.git
synced 2026-10-17 04:59:53 +00:00
Keep selected follow visible and settle rendering during live updates
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user