Merge upstream/main into pr/mapillary-layer

Twenty layers now (weather, wind, cyclones and recent imagery landed
upstream); Street Level sits after Recent Imagery in the right rail and the
Cameras group, and the rail CSS names it beside the other panels.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Fable 5.1 committed 2026-09-23 19:05:59 +01:00
commit 817716f0c1
234 files changed
+45441 -1430

No files matched your search

+113
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { createApplication } from './application.js';
import { installTrackpadPinchZoom } from './viewer.js';
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
function fixture(overrides = {}) {
@@ -217,3 +218,115 @@ test('the separate viewer export imports without constructing a browser viewer',
assert.equal(typeof createApplicationViewer, 'function');
assert.throws(() => createApplicationViewer({}), /containers are required/);
});
function pinchFixture({ zoomEventTypes } = {}) {
const handlers = new Set();
const relayed = [];
const container = {
addEventListener(type, handler, options) {
assert.equal(type, 'wheel');
assert.deepEqual(options, { capture: true, passive: false });
handlers.add(handler);
},
removeEventListener(type, handler, capture) {
assert.equal(type, 'wheel');
assert.equal(capture, true);
handlers.delete(handler);
},
emit(event) {
for (const handler of handlers) handler(event);
},
};
const canvas = {
dispatchEvent(event) {
// A real canvas dispatch crosses the container's capture listener again.
container.emit(event);
relayed.push(event);
return true;
},
};
const controller = { zoomEventTypes };
const viewer = {
container,
canvas,
scene: { screenSpaceCameraController: controller },
};
const createEvent = (init = {}) => ({
deltaX: 0,
deltaY: 0,
deltaZ: 0,
deltaMode: 0,
screenX: 0,
screenY: 0,
clientX: 0,
clientY: 0,
ctrlKey: false,
defaultPrevented: false,
propagationStopped: false,
preventDefault() {
this.defaultPrevented = true;
},
stopPropagation() {
this.propagationStopped = true;
},
...init,
});
return { viewer, controller, container, handlers, relayed, createEvent };
}
test('trackpad pinch adds the Ctrl+wheel binding without replacing Cesium inputs', () => {
const original = [1, 2, 3];
const f = pinchFixture({ zoomEventTypes: original });
const dispose = installTrackpadPinchZoom(f.viewer, {
createWheelEvent: (_type, init) => f.createEvent(init),
});
assert.deepEqual(f.controller.zoomEventTypes.slice(0, 3), original);
assert.equal(f.controller.zoomEventTypes.length, 4);
const plain = f.createEvent({ deltaY: 2 });
f.container.emit(plain);
assert.equal(plain.defaultPrevented, false);
const lineMode = f.createEvent({ ctrlKey: true, deltaY: 2, deltaMode: 1 });
f.container.emit(lineMode);
assert.equal(lineMode.defaultPrevented, false);
assert.equal(f.relayed.length, 0);
dispose();
dispose();
assert.equal(f.controller.zoomEventTypes, original);
assert.equal(f.handlers.size, 0);
});
test('trackpad pinch relays pixel deltas once with bounded amplification', () => {
const f = pinchFixture({ zoomEventTypes: [1, 2, 3] });
installTrackpadPinchZoom(f.viewer, {
createWheelEvent: (_type, init) => f.createEvent(init),
});
const small = f.createEvent({ ctrlKey: true, deltaY: 2 });
f.container.emit(small);
assert.equal(small.defaultPrevented, true);
assert.equal(small.propagationStopped, true);
assert.equal(f.relayed.length, 1);
assert.equal(f.relayed[0].deltaY, 16);
assert.equal(f.relayed[0].ctrlKey, true);
const large = f.createEvent({ ctrlKey: true, deltaY: -100 });
f.container.emit(large);
assert.equal(f.relayed.length, 2);
assert.equal(f.relayed[1].deltaY, -120);
});
test('trackpad pinch falls back to the original event when relay construction fails', () => {
const f = pinchFixture({ zoomEventTypes: [1, 2, 3] });
installTrackpadPinchZoom(f.viewer, {
createWheelEvent() {
throw new Error('unsupported');
},
});
const event = f.createEvent({ ctrlKey: true, deltaY: 2 });
f.container.emit(event);
assert.equal(event.defaultPrevented, false);
assert.equal(event.propagationStopped, false);
assert.equal(f.relayed.length, 0);
});
+34 -1
View File
@@ -1,3 +1,7 @@
import { createWeatherClock } from '../layers/weather/clock.js';
import { createWeatherLayer } from '../layers/weather/index.js';
import { createCyclonesLayer } from '../layers/cyclones/index.js';
import { createWindLayer } from '../layers/wind/index.js';
import { createLayerCatalog } from './catalog.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
@@ -9,6 +13,7 @@ import { createApplicationRadio } from './layers/radio.js';
import { createApplicationTraffic } from './layers/traffic.js';
import { createApplicationBikeshare } from './layers/bikeshare.js';
import { createApplicationDirections } from './layers/directions.js';
import { createApplicationRecentImagery } from './layers/recentImagery.js';
import { createApplicationTransit } from './layers/transit.js';
import { createApplicationInstallations } from './layers/militaryInstallations.js';
import { createApplicationSatellites } from './layers/satellites.js';
@@ -43,6 +48,9 @@ const SOURCE_METHODS = Object.freeze({
launches: ['getLaunches', 'getActiveTle'],
alpr: ['fetch'],
firms: ['getSnapshot'],
wind: ['getSnapshot'],
weather: ['getSnapshot'],
cyclones: ['getSnapshot'],
earthquakes: ['getSnapshot'],
cables: ['fetch'],
mapillary: [
@@ -85,9 +93,11 @@ export function createApplicationCatalog({
throw new TypeError(`Invalid catalog source: ${name}`);
}
const militaryRegistry = createMilitaryRegistry();
const weatherClock = createWeatherClock();
const dispose = () => {
signal.removeEventListener('abort', dispose);
militaryRegistry.dispose();
weatherClock.destroy();
};
signal.addEventListener('abort', dispose, { once: true });
try {
@@ -134,6 +144,7 @@ export function createApplicationCatalog({
createApplicationTransit({ surface, source: sources.transit }),
createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationDirections(),
createApplicationRecentImagery(),
vessels,
installations,
createApplicationAwareness({
@@ -142,6 +153,23 @@ export function createApplicationCatalog({
vessels,
installations,
}),
createWindLayer({ feed: sources.wind, clock: weatherClock }),
createWeatherLayer({
feed: sources.weather,
id: 'weather-radar',
clock: weatherClock,
}),
createWeatherLayer({
feed: sources.weather,
id: 'weather-satellite',
clock: weatherClock,
}),
createWeatherLayer({
feed: sources.weather,
id: 'weather-lightning',
clock: weatherClock,
}),
createCyclonesLayer({ feed: sources.cyclones }),
...createInfrastructureLayers(localGeoJsonServices),
createApplicationCables({ source: sources.cables }),
createApplicationFirms({
@@ -155,7 +183,12 @@ export function createApplicationCatalog({
],
metadata,
);
return Object.freeze({ ...catalog, militaryRegistry, surface });
return Object.freeze({
...catalog,
militaryRegistry,
surface,
weatherClock,
});
} catch (error) {
dispose();
throw error;
+17 -1
View File
@@ -39,7 +39,19 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 22);
assert.equal(first.layers.length, 28);
assert.notEqual(first.weatherClock, second.weatherClock);
await first.weatherClock.setTarget('2026-09-21T12:00:00.000Z');
assert.match(
first.get('wind').getRowControls().info,
/Forecast · does not follow history/,
);
assert.equal(second.get('wind').getRowControls().summary.status, null);
for (const id of ['weather-radar', 'weather-satellite', 'weather-lightning'])
assert.equal(
first.get(id).getDiagnostics().clock.target,
'2026-09-21T12:00:00.000Z',
);
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
@@ -82,6 +94,10 @@ test('catalogs construct distinct layers and classification from their supplied
assert.equal(first.militaryRegistry.isMilitaryIcao('def456'), false);
assert.equal(second.militaryRegistry.isMilitaryIcao('def456'), true);
a.abort();
assert.equal(
await first.weatherClock.setTarget('2026-09-21T13:00:00.000Z'),
false,
);
assert.equal(callsA[0].aborted, true);
assert.equal(first.militaryRegistry.isMilitaryIcao('abc123'), false);
assert.equal(callsB[0].aborted, false);
+3 -1
View File
@@ -20,7 +20,9 @@ export function createApplicationData({
`Data layers could not be destroyed: ${[...dataManager.layers.keys()].join(', ')}`,
);
});
const presentation = new LayerPresentation(dataManager);
const presentation = new LayerPresentation(dataManager, {
weatherClock: catalog?.weatherClock,
});
defer(() => presentation.destroy());
onData?.(dataManager);
if (!catalog?.layers || !catalog?.metadata)
+9
View File
@@ -7,11 +7,13 @@ export class LayerPresentation {
constructor(
manager,
{
weatherClock,
requestRender = governorRequestRender,
invalidateDetection = markDetectionSourcesChanged,
} = {},
) {
this.manager = manager;
this.weatherClock = weatherClock;
this._panel = null;
this.pendingVisible = false;
this._unsubscribe = manager.subscribeActivity((change) => {
@@ -35,6 +37,7 @@ export class LayerPresentation {
get panel() {
if (!this._panel)
this._panel = new LayerPanel({
weatherClock: this.weatherClock,
getLayers: () => this.manager.getAll(),
isEnabled: (id) => this.manager.isEnabled(id),
setEnabled: (id, enabled, options) =>
@@ -67,6 +70,12 @@ export class LayerPresentation {
mount(container) {
this.panel.mount(container);
}
/** Hand the Recent Imagery readout factory to the panel (see LayerPanel). */
attachRecentImagery(factory) {
// Detaching never builds a panel that teardown already released.
if (factory) this.panel.attachRecentImagery(factory);
else this._panel?.attachRecentImagery(null);
}
refresh() {
this._panel?._refreshTogglePanel();
}
+19
View File
@@ -0,0 +1,19 @@
import { createRecentImageryLayer } from '../../layers/recentImagery/index.js';
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
import { governorRequestRender } from '../../renderGovernor.js';
/**
* Construct the Recent Imagery layer with the real GIBS renderer and the
* browser thumbnail loader. It takes no data source: the catalog is NASA
* CMR and the tiles are GIBS, both browser-direct and keyless.
* @returns {object} A fresh layer instance for this catalog.
*/
export function createApplicationRecentImagery() {
return createRecentImageryLayer({
renderer: createRecentImageryRenderer({
requestRender: governorRequestRender,
}),
thumbnails: createThumbnailLoader(),
});
}
+5 -1
View File
@@ -1,6 +1,9 @@
import { createApplicationOperations } from './operations.js';
import * as Cesium from 'cesium';
import { createApplicationViewer } from '../app/viewer.js';
import {
createApplicationViewer,
installTrackpadPinchZoom,
} from '../app/viewer.js';
import { registerDataCredits } from '../data/dataCredits.js';
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
import { MapStackController } from '../mapStackController.js';
@@ -52,6 +55,7 @@ export async function createApplicationScene({
uninstallRenderGovernor(viewer);
if (!viewer.isDestroyed()) viewer.destroy();
});
defer(installTrackpadPinchZoom(viewer));
registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document);
loaderStatus.textContent =
+2
View File
@@ -10,6 +10,7 @@ import { configureInstallationSource } from '../data/militaryInstallations.js';
import { configureSatelliteSource } from '../data/satellites.js';
import { configureLaunchSource } from '../data/rocketLaunches.js';
import { configureFirmsSource } from '../data/firmsHeatmap.js';
import { configureWindSource } from '../data/wind.js';
import { configureMilitaryRegistrySource } from '../data/militaryRegistry.js';
const configure = {
alpr: configureAlprSource,
@@ -21,6 +22,7 @@ const configure = {
satellites: configureSatelliteSource,
launches: configureLaunchSource,
firms: configureFirmsSource,
wind: configureWindSource,
};
/** Configure sources before any registration or state restoration starts. */
export function configureApplicationSources({
+39
View File
@@ -1,6 +1,8 @@
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import {
@@ -53,6 +55,43 @@ export function createApplicationTools({
// lifetime rather than to whoever last pressed the button.
const drawTool = initDrawTool({ viewer, annotations });
defer(() => drawTool?.destroy());
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
// panel lives on the right rail, so both belong to the application
// lifetime. The tileset lets the layer drape while the globe is hidden.
const recentImagery = dataManager.layers.get('recent-imagery')?.module;
if (recentImagery) {
recentImagery.attachTileset(tileset);
const imageryBoxTool = initImageryBoxTool({
viewer,
onBox: (box) => recentImagery.setBox(box),
onCancel: (reason, message, box) => {
if (message) recentImagery.reportBoxRefusal(message, box);
},
onActive: (active) => recentImagery.setToolActive(active),
// The tool takes Escape in a capture listener, so the panel's order
// (clear a preview before cancelling the tool) is applied here.
onEscape: () => recentImagery.clearPreview(),
});
// The readout mounts in its rail body through the layer panel, like the
// weather readout.
data.presentation.attachRecentImagery((container) =>
createRecentImageryPanel({
container,
viewer,
layer: recentImagery,
tool: imageryBoxTool,
}),
);
// The live gate's handle (scripts/qa-recent-imagery.mjs).
const recentImageryHandle = { layer: recentImagery, tool: imageryBoxTool };
window.__gevRecentImagery = recentImageryHandle;
defer(() => {
if (window.__gevRecentImagery === recentImageryHandle)
delete window.__gevRecentImagery;
data.presentation.attachRecentImagery(null);
imageryBoxTool?.destroy();
});
}
if (startChrome)
defer(startChrome({ loadingScreen, styleManager, dataManager, signal }));
// Idle render governor: flips the scene into requestRenderMode whenever
+101
View File
@@ -1,5 +1,106 @@
import * as Cesium from 'cesium';
const PINCH_ZOOM_MULTIPLIER = 8;
const MAX_PINCH_PIXEL_DELTA = 120;
function boundedPinchDelta(delta) {
if (!Number.isFinite(delta) || delta === 0) return delta;
return (
Math.sign(delta) *
Math.min(Math.abs(delta) * PINCH_ZOOM_MULTIPLIER, MAX_PINCH_PIXEL_DELTA)
);
}
/**
* Add browser trackpad pinch to Cesium's zoom inputs and return its disposer.
* Browsers expose this gesture as a small pixel-mode Ctrl+wheel event.
*/
export function installTrackpadPinchZoom(
viewer,
{ createWheelEvent = (type, init) => new WheelEvent(type, init) } = {},
) {
const controller = viewer?.scene?.screenSpaceCameraController;
const container = viewer?.container;
const canvas = viewer?.canvas;
if (!controller || !container || !canvas)
throw new TypeError('A complete Cesium viewer is required');
const originalZoomEventTypes = controller.zoomEventTypes;
const zoomEventTypes = Array.isArray(originalZoomEventTypes)
? originalZoomEventTypes
: originalZoomEventTypes === undefined
? []
: [originalZoomEventTypes];
const alreadyHandlesControlWheel = zoomEventTypes.some(
(binding) =>
binding?.eventType === Cesium.CameraEventType.WHEEL &&
binding?.modifier === Cesium.KeyboardEventModifier.CTRL,
);
const configuredZoomEventTypes = alreadyHandlesControlWheel
? originalZoomEventTypes
: [
...zoomEventTypes,
{
eventType: Cesium.CameraEventType.WHEEL,
modifier: Cesium.KeyboardEventModifier.CTRL,
},
];
if (!alreadyHandlesControlWheel)
controller.zoomEventTypes = configuredZoomEventTypes;
const relayedEvents = new WeakSet();
const relayPinch = (event) => {
if (
!event.ctrlKey ||
relayedEvents.has(event) ||
event.deltaMode !== 0 ||
!Number.isFinite(event.deltaY) ||
event.deltaY === 0
)
return;
let relayed;
try {
relayed = createWheelEvent('wheel', {
deltaX: event.deltaX,
deltaY: boundedPinchDelta(event.deltaY),
deltaZ: event.deltaZ,
deltaMode: event.deltaMode,
screenX: event.screenX,
screenY: event.screenY,
clientX: event.clientX,
clientY: event.clientY,
ctrlKey: true,
bubbles: true,
cancelable: true,
view: globalThis.window,
});
} catch {
// The registered Ctrl+wheel binding can still consume the original.
return;
}
relayedEvents.add(relayed);
event.preventDefault();
event.stopPropagation();
canvas.dispatchEvent(relayed);
};
container.addEventListener('wheel', relayPinch, {
capture: true,
passive: false,
});
let active = true;
return () => {
if (!active) return;
active = false;
container.removeEventListener('wheel', relayPinch, true);
if (
!alreadyHandlesControlWheel &&
controller.zoomEventTypes === configuredZoomEventTypes
)
controller.zoomEventTypes = originalZoomEventTypes;
};
}
/** Create the standard globe viewer in caller-owned, visible containers. */
export function createApplicationViewer({ container, creditContainer }) {
if (!container || !creditContainer)