Retain the Nepal locator presentation and draw it on either globe

This commit is contained in:
German Khokhlov committed 2026-11-03 03:49:39 +04:00
1 parent 605b9490f3
commit 811bcaff85
11 files changed
+409 -57

No files matched your search

+1
View File
@@ -3679,6 +3679,7 @@
"src/layers/shared/render/navaraStems.js",
"src/layers/submarineCables/render/navara.js",
"src/layers/directions/render/navara.js",
"src/layers/shared/render/navaraStory.js",
"src/layers/weather/render/navara.js",
"src/layers/weather/render/navaraSurface.js",
"src/layers/wind/render/navara.js",
+145
View File
@@ -0,0 +1,145 @@
import { fromDegrees } from '../src/geo/cartesian3.js';
import sharp from 'sharp';
import puppeteer from 'puppeteer';
import assert from 'node:assert/strict';
import { blockPaidQaMaps } from './qa-map-policy.mjs';
const base = new URL(process.argv[2]);
if (
!['localhost', '127.0.0.1'].includes(base.hostname) ||
['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
base.port,
)
)
throw new Error('Supply an unreserved local keyless preview URL');
base.search = '?engine=navara&welcome=0&look=cinematic';
base.hash =
'v=2&lat=28.3326&lon=85.4848&alt=18000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
const b = await puppeteer.launch({
headless: true,
args: ['--use-angle=metal', '--enable-gpu'],
});
try {
const p = await b.newPage();
await blockPaidQaMaps(p);
await p.setViewport({ width: 1024, height: 768 });
const errors = [];
p.on('pageerror', (e) => {
errors.push(e.message);
console.log('error', e.message.split('?')[0]);
});
p.on('console', (msg) => {
if (msg.type() === 'error' && /Shader|WebGLProgram/i.test(msg.text()))
errors.push(msg.text());
});
await p.goto(base.href);
const ready = async (kind) => {
await p.waitForFunction(
(k) =>
window.__godsEyeView?.engine.kind === k &&
!document.querySelector('#globe-engine')?.disabled &&
!document.querySelector('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden'),
{ timeout: 90000 },
kind,
);
await p.evaluate(
() => window.__godsEyeView.styleManager.initialRestorePromise,
);
};
await ready('navara');
await p.evaluate(async () => {
const { tileset, dataManager } = window.__godsEyeView;
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
throw Error('Keyless preview required');
window.qaLocator = dataManager.layers.get('bhote-koshi-locator').module;
window.qaLocator.setParams({ presentation: 'bhote-koshi-path-overview' });
await dataManager.setEnabled('bhote-koshi-locator', true, {
origin: 'user',
});
});
for (const kind of ['navara', 'cesium', 'navara']) {
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
await p.select('#globe-engine', kind);
await ready(kind);
}
const { same, stats, params, at } = await p.evaluate(
(world) => {
const { engine, dataManager } = window.__godsEyeView,
mod = dataManager.layers.get('bhote-koshi-locator').module;
const pos = engine.projector.worldToWindow(world, {}),
box = engine.host.canvas.getBoundingClientRect();
return {
same: mod === window.qaLocator,
stats: mod.getStats(),
params: mod.getParams(),
at: { x: pos.x + box.left, y: pos.y + box.top },
};
},
fromDegrees(85.47741, 28.33299, 1600),
);
assert.equal(same, true);
assert.equal(stats.count, 1);
assert.equal(params.presentation, 'bhote-koshi-path-overview');
// Look for the cyan river in a scene crop away from the UI rails.
await new Promise((r) => setTimeout(r, 1000));
const crop = async () =>
sharp(await p.screenshot())
.extract({ left: 400, top: 320, width: 230, height: 180 })
.removeAlpha()
.raw()
.toBuffer();
const rendered = await crop();
await p.evaluate(() =>
window.qaLocator.setParams({ presentation: 'nepal-context' }),
);
await new Promise((resolve) => setTimeout(resolve, 500));
const baseline = await crop();
let added = 0;
for (let i = 0; i < rendered.length; i += 3) {
const contrast = rendered[i + 1] - rendered[i],
previous = baseline[i + 1] - baseline[i];
if (
rendered[i + 1] > 140 &&
rendered[i + 2] > 140 &&
contrast > 35 &&
contrast - previous > 18
)
added++;
}
console.log(
kind,
stats.coverage,
'cyan pixels added by the path',
added,
at,
);
assert.ok(
added > 50,
'visible river differs from the same scene without a path',
);
await p.evaluate(() =>
window.qaLocator.setParams({ presentation: 'bhote-koshi-path-overview' }),
);
}
await p.evaluate(() => {
window.qaLocator.setParams({ presentation: 'bhote-koshi-incident-places' });
window.qaLocator.setParams({ presentation: 'bhote-koshi-city-context' });
window.qaLocator.setParams({
presentation: 'bhote-koshi-trigger-record',
preview: true,
});
});
await new Promise((resolve) => setTimeout(resolve, 1000));
await p.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('bhote-koshi-locator', false, {
origin: 'user',
}),
);
assert.deepEqual(errors, []);
console.log(
'PASS Nepal locator flood path and presentation retained, trigger and places update; both engines',
);
} finally {
await b.close();
}
+3 -2
View File
@@ -164,9 +164,10 @@ export function createApplicationCatalog({
[
'bhote-koshi-locator',
() =>
createBhoteKoshiLocatorLayer({
sessionModel('bhote-koshi-locator', () => createBhoteKoshiLocatorLayer({
boundaryResolver: nepalBoundaryResolver,
}),
createNativeRenderer: (engine, data) => rendererFor('bhote-koshi-locator', engine, { data }),
})),
],
['flights', flights],
['military', military],
+1
View File
@@ -43,6 +43,7 @@ test('the Navara mode registers migrated renderers once', async () => {
.available()
.map(({ id }) => id),
[
'bhote-koshi-locator',
'flights',
'military',
'local-adsb',
+5
View File
@@ -120,6 +120,10 @@ export async function registerNavaraLayerRenderers() {
return (engine, options) => createWeatherNavaraRenderer(engine, navaraViews.get(engine), options);
},
])),
'bhote-koshi-locator': async () => {
const { drawStorySource } = await import('../layers/shared/render/navaraStory.js');
return (engine, { data }) => drawStorySource(engine, navaraViews.get(engine), data);
},
directions: async () => {
const { createDirectionsNavaraRenderer } = await import('../layers/directions/render/navara.js');
return (engine) => createDirectionsNavaraRenderer(engine, navaraViews.get(engine));
@@ -258,6 +262,7 @@ export async function createNavaraScene({
view: globe.view,
alignDaylightToRestoredView: globe.alignDaylightToRestoredView,
MeshDesc: globe.MeshDesc,
NavaraColor: globe.NavaraColor,
EffectDesc: globe.EffectDesc,
NavaraEffect: globe.NavaraEffect,
DrapedMesh: globe.DrapedMesh,
+69 -49
View File
@@ -276,8 +276,9 @@ export function largestGeoJsonRing(geojson) {
*/
export function createBhoteKoshiLocatorLayer({
boundaryResolver = DEFAULT_BOUNDARY_RESOLVER,
createNativeRenderer,
overlayHost = DEFAULT_OVERLAY_HOST,
requestRender = governorRequestRender,
requestRender: requestSceneRender = governorRequestRender,
now = () => performance.now(),
scheduleFrame = (callback) => requestAnimationFrame(callback),
cancelFrame = (handle) => cancelAnimationFrame(handle),
@@ -285,6 +286,12 @@ export function createBhoteKoshiLocatorLayer({
cancelDelay = (handle) => clearTimeout(handle),
} = {}) {
let _viewer = null;
let _engine = null;
let _native = null;
let _pausedApproach = null;
const cameraForScene = () => _engine?.camera ?? _viewer?.camera;
const creditTarget = () => _engine ?? _viewer;
const requestRender = reason => _engine?.kind === 'navara' ? _engine.render.request(reason) : requestSceneRender(reason);
let _dataSource = null;
let _enabled = false;
let _boundaryReady = false;
@@ -332,7 +339,7 @@ export function createBhoteKoshiLocatorLayer({
_removeTriggerMoveEnd = null;
const wasInFlight = _triggerApproachInFlight;
_triggerApproachInFlight = false;
if (wasInFlight) _viewer?.camera?.cancelFlight?.();
if (wasInFlight) cameraForScene()?.cancelFlight?.();
if (!preserveCallout) _triggerCalloutStartedAt = Number.POSITIVE_INFINITY;
}
@@ -349,6 +356,8 @@ export function createBhoteKoshiLocatorLayer({
function removeDataSource() {
stopAnimations();
overlayHost.clearSource(BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
_native?.destroy();
_native = null;
if (_dataSource && _viewer?.dataSources)
_viewer.dataSources.remove(_dataSource, true);
_dataSource = null;
@@ -618,9 +627,16 @@ export function createBhoteKoshiLocatorLayer({
_removeTriggerMoveEnd = null;
_triggerCalloutStartedAt = now();
requestRender('bhote-koshi-trigger-record-reveal');
const camera = _viewer?.camera;
flyTriggerStage(attempt, 'flying', TRIGGER_APPROACH_DURATION_SEC);
}
function flyTriggerStage(attempt, phase, duration) {
const camera = cameraForScene();
if (!camera?.flyTo) return;
const generation = _generation;
const orbit = phase === 'orbiting';
const pose = orbit ? TRIGGER_ORBIT_CAMERA : TRIGGER_APPROACH_CAMERA;
attempt.phase = phase;
attempt.endsAt = now() + duration * 1000;
_triggerApproachInFlight = true;
const finish = () => {
if (_triggerApproachAttempt !== attempt) return;
@@ -628,46 +644,18 @@ export function createBhoteKoshiLocatorLayer({
_triggerApproachInFlight = false;
};
camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(
TRIGGER_APPROACH_CAMERA.lon,
TRIGGER_APPROACH_CAMERA.lat,
TRIGGER_APPROACH_CAMERA.alt,
),
destination: Cesium.Cartesian3.fromDegrees(pose.lon, pose.lat, pose.alt),
orientation: {
heading: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.heading),
pitch: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.pitch),
roll: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.roll),
heading: Cesium.Math.toRadians(pose.heading),
pitch: Cesium.Math.toRadians(pose.pitch),
roll: Cesium.Math.toRadians(pose.roll),
},
duration: TRIGGER_APPROACH_DURATION_SEC,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
duration,
easingFunction: orbit ? Cesium.EasingFunction.SINUSOIDAL_IN_OUT : Cesium.EasingFunction.CUBIC_IN_OUT,
complete: () => {
if (_triggerApproachAttempt !== attempt || attempt.phase !== 'flying')
return;
if (
!_enabled ||
generation !== _generation ||
_presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION
) {
finish();
return;
}
attempt.phase = 'orbiting';
camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(
TRIGGER_ORBIT_CAMERA.lon,
TRIGGER_ORBIT_CAMERA.lat,
TRIGGER_ORBIT_CAMERA.alt,
),
orientation: {
heading: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.heading),
pitch: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.pitch),
roll: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.roll),
},
duration: TRIGGER_ORBIT_DURATION_SEC,
easingFunction: Cesium.EasingFunction.SINUSOIDAL_IN_OUT,
complete: finish,
cancel: finish,
});
if (_triggerApproachAttempt !== attempt) return;
if (orbit || !_enabled || _presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION) finish();
else flyTriggerStage(attempt, 'orbiting', TRIGGER_ORBIT_DURATION_SEC);
},
cancel: finish,
});
@@ -681,7 +669,7 @@ export function createBhoteKoshiLocatorLayer({
const attempt = { phase: 'armed' };
_triggerApproachAttempt = attempt;
const start = () => startTriggerApproach(attempt);
const camera = _viewer?.camera;
const camera = cameraForScene();
if (
!camera?.moveStart?.addEventListener ||
!camera?.moveEnd?.addEventListener
@@ -1046,11 +1034,42 @@ export function createBhoteKoshiLocatorLayer({
requestRender('bhote-koshi-locator-presentation');
}
async function init(viewer) {
function connectDisplay() {
if (!_dataSource) return;
if (_viewer) _viewer.dataSources.add(_dataSource);
else if (_engine) _native = createNativeRenderer(_engine, _dataSource);
registerDynamicCredit(creditTarget(), BHOTE_KOSHI_LOCATOR_CREDIT);
showOsmCredit(creditTarget(), BHOTE_KOSHI_LOCATOR_LAYER_ID);
}
function attachRenderer(viewer, {engine = null} = {}) {
_viewer = viewer;
_engine = engine;
if (!_enabled) return true;
connectDisplay();
publishOverlayEntries({moving: _placeRevealMoving});
if (_pausedApproach) {
const {phase, remaining} = _pausedApproach;
_pausedApproach = null;
if (phase === 'armed') armTriggerApproach();
else flyTriggerStage((_triggerApproachAttempt = {}), phase, remaining);
}
return true;
}
function detachRenderer() {
const phase = _triggerApproachAttempt?.phase;
if (phase && phase !== 'finished') {
_pausedApproach = {phase, remaining: Math.max(0, (_triggerApproachAttempt.endsAt - now()) / 1000)};
clearTriggerApproach({preserveCallout: true});
}
_native?.destroy();
_native = null;
if (_viewer && _dataSource) _viewer.dataSources.remove(_dataSource, false);
hideOsmCredit(creditTarget(), BHOTE_KOSHI_LOCATOR_LAYER_ID);
_engine = _viewer = null;
}
async function enable(viewer, { signal } = {}) {
_viewer = viewer;
_enabled = true;
@@ -1062,9 +1081,7 @@ export function createBhoteKoshiLocatorLayer({
const dataSource = new Cesium.CustomDataSource('bhote-koshi-locator');
_dataSource = dataSource;
renderPresentation();
viewer.dataSources.add(dataSource);
registerDynamicCredit(viewer, BHOTE_KOSHI_LOCATOR_CREDIT);
showOsmCredit(viewer, BHOTE_KOSHI_LOCATOR_LAYER_ID);
connectDisplay();
requestRender('bhote-koshi-locator-callout');
try {
@@ -1100,7 +1117,8 @@ export function createBhoteKoshiLocatorLayer({
}
async function disable() {
hideOsmCredit(_viewer, BHOTE_KOSHI_LOCATOR_LAYER_ID);
hideOsmCredit(creditTarget(), BHOTE_KOSHI_LOCATOR_LAYER_ID);
_pausedApproach = null;
_enabled = false;
_boundaryReady = false;
_boundaryResolved = false;
@@ -1113,7 +1131,7 @@ export function createBhoteKoshiLocatorLayer({
async function destroy() {
await disable();
_viewer = null;
_viewer = _engine = null;
}
return {
@@ -1124,7 +1142,9 @@ export function createBhoteKoshiLocatorLayer({
icon: '◎',
source: 'OpenStreetMap + GeoPera',
updateInterval: 0,
init,
init: attachRenderer,
attachRenderer,
detachRenderer,
enable,
disable,
update: async () => true,
+6 -4
View File
@@ -645,8 +645,8 @@ export const BHOTE_KOSHI_LOCATOR_CREDIT = {
'(CC BY-NC 4.0)',
};
/** @type {Set<string>} Keys of dynamic credits already registered this session. */
const _dynamicCreditKeys = new Set();
/** Keys registered in each globe's attribution display. */
const dynamicCreditKeys = new WeakMap();
/**
* Register a conditional credit at the moment its data source activates.
@@ -660,9 +660,11 @@ export function registerDynamicCredit(target, credit) {
const credits = creditsOf(target);
if (!credits) return false;
if (!credit?.key || !credit?.html) return false;
if (_dynamicCreditKeys.has(credit.key)) return true;
let keys = dynamicCreditKeys.get(target);
if (!keys) dynamicCreditKeys.set(target, (keys = new Set()));
if (keys.has(credit.key)) return true;
credits.addStatic(credit.html, { onScreen: false });
_dynamicCreditKeys.add(credit.key);
keys.add(credit.key);
return true;
}
+3
View File
@@ -147,6 +147,9 @@ test('data credits go through the globe engine credits', async () => {
engine.fake.credits.filter(({ html }) => html === 'D').length,
1,
);
const other = createFakeEngine();
registerDynamicCredit(other, { key: 'engine-dyn', html: 'D' });
assert.equal(other.fake.credits.filter(({ html }) => html === 'D').length, 1);
engine.fake.resetCalls();
assert.equal(showOsmCredit(engine, 'alpr', { openMapTiles: true }), true);
const inline = engine.fake.credits.filter(({ onScreen }) => onScreen);
+2
View File
@@ -3,6 +3,7 @@
// dynamic import() so Navara stays out of the Cesium-mode bundle.
import ThreeView, {
Color as NavaraColor,
MeshDesc,
EffectDesc,
Effect as NavaraEffect,
@@ -205,6 +206,7 @@ export async function createNavaraGlobe({
return {
view,
engine,
NavaraColor,
MeshDesc,
EffectDesc,
NavaraEffect,
+173
View File
@@ -0,0 +1,173 @@
import * as geo from '../../../geo/index.js';
const val = (p) => (p?.getValue ? p.getValue() : p);
const rgba = (c) => [c.red, c.green, c.blue, c.alpha];
const tint = (c) =>
(Math.round(c.red * 255) << 16) |
(Math.round(c.green * 255) << 8) |
Math.round(c.blue * 255);
function samePath(old, points) {
return (
old?.length === points.length &&
points.every(
(p, i) => p.x === old[i].x && p.y === old[i].y && p.z === old[i].z,
)
);
}
function circleAtlas() {
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'white';
ctx.beginPath();
ctx.arc(32, 32, 29, 0, Math.PI * 2);
ctx.fill();
ctx.lineWidth = 5;
ctx.strokeStyle = 'white';
ctx.beginPath();
ctx.arc(96, 32, 27, 0, Math.PI * 2);
ctx.stroke();
return { canvas, cells: { fill: [0, 0, 0.5, 1], rim: [0.5, 0, 1, 1] } };
}
// The authored story owns the clock and geographic graphics. This adapter only samples them
// while its scene is attached. Neither a Cesium viewer nor an additional globe is needed.
export function drawStorySource(engine, { view, NavaraColor }, data) {
const entries = new Map();
let atlas;
// Story graphics encode map colors. Compensate the planet's photographic
// exposure before its tone-mapping pass so a cyan overlay stays readable.
function displayColor(hex) {
const c = new NavaraColor().setHex(hex);
const exposure = Math.max(1, view.toneMappingExposure ?? 1);
const [r, g, b] = c.toArray();
return c.setRGBLinear(r / exposure, g / exposure, b / exposure);
}
function point(entity) {
const p = entity.point,
nearFar = val(p.scaleByDistance),
depth = val(p.disableDepthTestDistance);
const set = engine.movers.createSet({
id: data.name + '-' + entity.id,
kind: 'sprite',
capacity: 2,
pickable: false,
depthTest: depth === Infinity ? 'always' : 'normal',
sprite: {
sizePx: 64,
atlas: (atlas ??= circleAtlas()),
scaleByDistance: nearFar
? [nearFar.near, nearFar.nearValue, nearFar.far, nearFar.farValue]
: null,
},
});
return {
paint(show) {
const position = val(entity.position);
if (!show || !position) {
set.setCount(0);
set.commit();
return;
}
let at = position;
if (val(p.heightReference) === 1) {
const carto = geo.cartographic.fromCartesian(position);
const h = engine.surface.terrainHeight(carto);
if (Number.isFinite(h))
at = geo.cartesian3.fromRadians(carto.longitude, carto.latitude, h);
}
const scale = Number(val(p.pixelSize)) / 58;
const color = val(p.color),
outline = val(p.outlineColor);
set.set(0, {
position: at,
show: true,
cell: 'fill',
color: rgba(color),
scale,
});
const hasOutline = Boolean(outline && val(p.outlineWidth));
if (hasOutline)
set.set(1, {
position: at,
show: true,
cell: 'rim',
color: rgba(outline),
scale,
});
set.setCount(hasOutline ? 2 : 1);
set.commit();
},
destroy: () => set.destroy(),
};
}
function geometry(entity) {
const key = entity.polyline ? 'polyline' : 'polygon',
g = entity[key];
let handle,
previous = null,
visible = null;
return {
paint(show) {
const path =
key === 'polygon' ? val(g.hierarchy)?.positions : val(g.positions);
show = Boolean(show && path?.length >= (key === 'polygon' ? 3 : 2));
if (!show) {
if (handle && visible) handle.update({ visible: false });
visible = false;
return;
}
if (!handle || !samePath(previous, path)) {
const color = val(g.material)?.color ?? val(g.material?.color);
previous = path.map(({ x, y, z }) => ({ x, y, z }));
const geom = {
points: previous,
clampToGround: true,
color: displayColor(color ? tint(color) : 0x20e7f2),
opacity: color?.alpha ?? 1,
transparent: true,
...(key === 'polyline' ? { width: val(g.width) ?? 2 } : {}),
};
if (handle) handle.update({ [key]: geom, visible: true });
else
handle = view.addMesh({
[key]: geom,
visible: true,
lit: false,
pickable: false,
});
} else if (!visible) handle.update({ visible: true });
visible = true;
},
destroy: () => handle?.delete(),
};
}
function frame() {
const live = new Set(data.entities.values);
for (const [e, item] of entries)
if (!live.has(e)) {
item.destroy();
entries.delete(e);
}
for (const e of live) {
let item = entries.get(e);
if (!item) {
if (!e.point && !e.polyline && !e.polygon) continue;
item = e.point ? point(e) : geometry(e);
entries.set(e, item);
}
item.paint(data.show !== false && val(e.show) !== false);
}
}
const off = engine.frames.preRender.addEventListener(frame);
engine.render.request(data.name);
return {
destroy() {
off();
for (const item of entries.values()) item.destroy();
entries.clear();
engine.render.request(data.name);
},
};
}
+1 -2
View File
@@ -1,5 +1,4 @@
import * as geo from '../../../geo/index.js';
import { Color } from 'three';
import { isPointerFree } from '../../../data/inputOwnership.js';
import {
featureLabel,
@@ -201,7 +200,7 @@ export function createNavaraCables(engine, native, host) {
layer.on('featureUpdated', ({ evaluator }) =>
evaluator.evaluate(({ properties }) => {
const css = properties?.color ?? BASE_CABLE_COLOR;
if (!colors.has(css)) colors.set(css, new Color(css));
if (!colors.has(css)) colors.set(css, new native.NavaraColor().setStyle(css));
return { color: colors.get(css) };
}),
);