mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
feat(bikeshare): render GBFS stations on Navara
This commit is contained in:
1 parent
1a694baf5a
commit
d82a6f1550
32 files changed
+1394
-278
No files matched your search
@@ -34,6 +34,7 @@ const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
|
||||
// engine in their renderers: cesium only in render/cesium*.js, three and
|
||||
// Navara only in render/navara*.js. The list grows with each moved layer.
|
||||
const RENDERED_LAYER_DIRS = [
|
||||
'src/layers/bikeshare/',
|
||||
'src/layers/earthquakes/',
|
||||
'src/layers/firms/',
|
||||
'src/layers/flights/',
|
||||
@@ -63,7 +64,8 @@ const renderedLayer = (file) =>
|
||||
file === 'src/celestialRing.js' ||
|
||||
file.startsWith('src/celestialRing/') ||
|
||||
RENDERED_LAYER_DIRS.some((dir) => file.startsWith(dir));
|
||||
const cesiumLayerRenderer = (file) => /\/render\/cesium[^/]*\.js$/.test(file);
|
||||
const cesiumLayerRenderer = (file) =>
|
||||
/\/render\/cesium(?:[^/]*|\/[^/]+)\.js$/.test(file);
|
||||
const navaraLayerRenderer = (file) => /\/render\/navara[^/]*\.js$/.test(file);
|
||||
const renderer = (file) =>
|
||||
/^src\/(?:ui|app|standalone|overlays)\//.test(file) ||
|
||||
|
||||
@@ -498,20 +498,28 @@
|
||||
"src/layers/traffic/model.js",
|
||||
"src/layers/traffic/timing.js",
|
||||
"src/layers/traffic/style.js",
|
||||
"src/layers/bikeshare/lifecycle.js",
|
||||
"src/layers/bikeshare/render/cesium/lifecycle.js",
|
||||
"src/layers/bikeshare/viewport.js",
|
||||
"src/layers/bikeshare/presentation.js",
|
||||
"src/layers/bikeshare/presentation.test.mjs",
|
||||
"src/layers/bikeshare/selectionModel.js",
|
||||
"src/layers/bikeshare/render/cesium.js",
|
||||
"src/layers/bikeshare/render/cesium/viewport.js",
|
||||
"src/layers/bikeshare/render/cesium/lifecycle.test.mjs",
|
||||
"src/layers/bikeshare/render/navara.js",
|
||||
"src/layers/bikeshare/render/navara.test.mjs",
|
||||
"src/layers/bikeshare/registry.js",
|
||||
"src/layers/bikeshare/controls.js",
|
||||
"src/layers/bikeshare/source.js",
|
||||
"src/layers/bikeshare/state.js",
|
||||
"src/layers/bikeshare/policy.js",
|
||||
"src/layers/bikeshare/rendering.js",
|
||||
"src/layers/bikeshare/render/cesium/rendering.js",
|
||||
"src/layers/bikeshare/testing.js",
|
||||
"src/layers/bikeshare/ingestion.js",
|
||||
"src/layers/bikeshare/queries.js",
|
||||
"src/layers/bikeshare/index.js",
|
||||
"src/layers/bikeshare/model.js",
|
||||
"src/layers/bikeshare/selection.js",
|
||||
"src/layers/bikeshare/render/cesium/selection.js",
|
||||
"src/data/traffic.js",
|
||||
"src/data/bikeshare.js",
|
||||
"src/data/flowTiles.js",
|
||||
|
||||
@@ -1405,15 +1405,15 @@
|
||||
"modules": [
|
||||
"src/layers/bikeshare/controls.js",
|
||||
"src/layers/bikeshare/index.js",
|
||||
"src/layers/bikeshare/selectionModel.js",
|
||||
"src/layers/bikeshare/presentation.js",
|
||||
"src/layers/bikeshare/ingestion.js",
|
||||
"src/layers/bikeshare/lifecycle.js",
|
||||
"src/layers/bikeshare/model.js",
|
||||
"src/sources/gbfsStations.js",
|
||||
"src/layers/bikeshare/policy.js",
|
||||
"src/layers/shared/color.js",
|
||||
"src/layers/bikeshare/queries.js",
|
||||
"src/layers/bikeshare/registry.js",
|
||||
"src/layers/bikeshare/rendering.js",
|
||||
"src/layers/bikeshare/selection.js",
|
||||
"src/layers/bikeshare/source.js",
|
||||
"src/layers/bikeshare/state.js",
|
||||
"src/layers/bikeshare/testing.js",
|
||||
@@ -1439,7 +1439,7 @@
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cesium/engineFromViewer.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": []
|
||||
},
|
||||
"cctv-layer": {
|
||||
"exports": ["./layers/cctv"],
|
||||
@@ -2058,15 +2058,19 @@
|
||||
"src/layers/awareness/testing.js",
|
||||
"src/layers/bikeshare/controls.js",
|
||||
"src/layers/bikeshare/index.js",
|
||||
"src/layers/bikeshare/selectionModel.js",
|
||||
"src/layers/bikeshare/presentation.js",
|
||||
"src/layers/bikeshare/render/cesium.js",
|
||||
"src/layers/bikeshare/render/cesium/viewport.js",
|
||||
"src/layers/bikeshare/ingestion.js",
|
||||
"src/layers/bikeshare/lifecycle.js",
|
||||
"src/layers/bikeshare/render/cesium/lifecycle.js",
|
||||
"src/layers/bikeshare/model.js",
|
||||
"src/sources/gbfsStations.js",
|
||||
"src/layers/bikeshare/policy.js",
|
||||
"src/layers/bikeshare/queries.js",
|
||||
"src/layers/bikeshare/registry.js",
|
||||
"src/layers/bikeshare/rendering.js",
|
||||
"src/layers/bikeshare/selection.js",
|
||||
"src/layers/bikeshare/render/cesium/rendering.js",
|
||||
"src/layers/bikeshare/render/cesium/selection.js",
|
||||
"src/layers/bikeshare/source.js",
|
||||
"src/layers/bikeshare/state.js",
|
||||
"src/layers/bikeshare/testing.js",
|
||||
@@ -2701,6 +2705,7 @@
|
||||
"src/layers/earthquakes/render/navaraGeometry.js",
|
||||
"src/layers/earthquakes/render/navaraGrid.js",
|
||||
"src/layers/installations/render/navara.js",
|
||||
"src/layers/bikeshare/render/navara.js",
|
||||
"src/layers/installations/render/navaraGeometry.js",
|
||||
"src/layers/firms/rectangle.js",
|
||||
"src/layers/firms/render/cesium.js",
|
||||
@@ -2899,15 +2904,19 @@
|
||||
"src/layers/awareness/testing.js",
|
||||
"src/layers/bikeshare/controls.js",
|
||||
"src/layers/bikeshare/index.js",
|
||||
"src/layers/bikeshare/selectionModel.js",
|
||||
"src/layers/bikeshare/presentation.js",
|
||||
"src/layers/bikeshare/render/cesium.js",
|
||||
"src/layers/bikeshare/render/cesium/viewport.js",
|
||||
"src/layers/bikeshare/ingestion.js",
|
||||
"src/layers/bikeshare/lifecycle.js",
|
||||
"src/layers/bikeshare/render/cesium/lifecycle.js",
|
||||
"src/layers/bikeshare/model.js",
|
||||
"src/sources/gbfsStations.js",
|
||||
"src/layers/bikeshare/policy.js",
|
||||
"src/layers/bikeshare/queries.js",
|
||||
"src/layers/bikeshare/registry.js",
|
||||
"src/layers/bikeshare/rendering.js",
|
||||
"src/layers/bikeshare/selection.js",
|
||||
"src/layers/bikeshare/render/cesium/rendering.js",
|
||||
"src/layers/bikeshare/render/cesium/selection.js",
|
||||
"src/layers/bikeshare/source.js",
|
||||
"src/layers/bikeshare/state.js",
|
||||
"src/layers/bikeshare/testing.js",
|
||||
|
||||
+291
-21
@@ -1,19 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Keyless station-name regression against the real globe and bike-share tool.
|
||||
* Run with a dev or preview server: node scripts/qa-gbfs-names.mjs --url http://localhost:4173
|
||||
* Run with a dev or QA preview server and an explicitly assigned --url.
|
||||
* GBFS documents are synthetic; no operator feed or provider key is required.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import sharp from 'sharp';
|
||||
import { watchW1Page, redact } from './qa-browserEvidence.mjs';
|
||||
|
||||
const urlIndex = process.argv.indexOf('--url');
|
||||
const appUrl =
|
||||
urlIndex < 0 ? 'http://localhost:4173' : process.argv[urlIndex + 1];
|
||||
const shots = path.resolve('qa-shots/gbfs-names');
|
||||
if (urlIndex < 0) throw new Error('An explicit --url is required');
|
||||
const appUrl = process.argv[urlIndex + 1];
|
||||
const engineName = new URL(appUrl).searchParams.get('engine') || 'cesium';
|
||||
const extended = process.argv.includes('--w2');
|
||||
const reportIndex = process.argv.indexOf('--report');
|
||||
const reportPath =
|
||||
reportIndex < 0 ? null : path.resolve(process.argv[reportIndex + 1]);
|
||||
const shots = reportPath
|
||||
? reportPath.replace(/\.json$/, '-shots')
|
||||
: path.resolve(`qa-shots/gbfs-names/${engineName}`);
|
||||
fs.mkdirSync(shots, { recursive: true });
|
||||
const report = { engine: engineName, checks: [], poses: {}, browserErrors: [] };
|
||||
const information = {
|
||||
version: '3.0',
|
||||
data: {
|
||||
@@ -67,6 +77,7 @@ const browser = await puppeteer.launch({
|
||||
});
|
||||
let checks = 0;
|
||||
const check = (name, actual, expected) => {
|
||||
report.checks.push({ name, actual, expected });
|
||||
assert.deepEqual(actual, expected, name);
|
||||
checks++;
|
||||
console.log(`[PASS] ${name}`);
|
||||
@@ -75,6 +86,12 @@ async function open(mode = 'normal') {
|
||||
const page = await browser.newPage();
|
||||
await page.bringToFront();
|
||||
const errors = [];
|
||||
if (extended)
|
||||
watchW1Page(page, report, {
|
||||
name: mode,
|
||||
baseUrl: appUrl,
|
||||
preview: process.argv.includes('--preview'),
|
||||
});
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.evaluateOnNewDocument(
|
||||
@@ -134,15 +151,17 @@ async function open(mode = 'normal') {
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const { dataManager, viewer } = window.__godsEyeView;
|
||||
const { dataManager, engine } = window.__godsEyeView;
|
||||
await window.__godsEyeView.styleManager.initialRestorePromise;
|
||||
window.__godsEyeView.styleManager.orbitController.stop();
|
||||
for (const [id, layer] of dataManager.layers) {
|
||||
if (layer.enabled)
|
||||
await dataManager.setEnabled(id, false, { origin: 'user' });
|
||||
}
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
||||
const geo = await import('/src/geo/index.js');
|
||||
engine.camera.cancelFlight();
|
||||
engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
await dataManager.setEnabled('bikeshare', true, { origin: 'user' });
|
||||
@@ -150,6 +169,211 @@ async function open(mode = 'normal') {
|
||||
});
|
||||
return { page, errors };
|
||||
}
|
||||
|
||||
async function nextFrame(page) {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
const engine = window.__godsEyeView.engine;
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('gbfs-raster-qa');
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function settledTiles(page) {
|
||||
await page.evaluate(async () => {
|
||||
const engine = window.__godsEyeView.engine;
|
||||
const start = performance.now();
|
||||
let since = null;
|
||||
while (performance.now() - start < 90000) {
|
||||
if (engine.surface.tilesLoaded()) since ??= performance.now();
|
||||
else since = null;
|
||||
if (since !== null && performance.now() - since > 1500) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error('Map tiles did not settle for raster sampling');
|
||||
});
|
||||
}
|
||||
|
||||
async function w2Checks(page) {
|
||||
await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
const detection = await import('/src/data/detection.js');
|
||||
detection.setMode('OFF');
|
||||
const { appClock } = await import('/src/time/clock.js');
|
||||
appClock.freeze(Date.parse('2026-10-28T19:00:00Z'));
|
||||
const canvas = app.engine.host.canvas;
|
||||
const style = document.createElement('style');
|
||||
style.textContent =
|
||||
'.gbfs-raster-hidden { visibility: hidden !important; }';
|
||||
document.head.append(style);
|
||||
for (const element of document.body.querySelectorAll('*'))
|
||||
if (element !== canvas && !element.contains(canvas))
|
||||
element.classList.add('gbfs-raster-hidden');
|
||||
});
|
||||
for (const [name, height] of [
|
||||
['city', 10000],
|
||||
['close', 3000],
|
||||
['global', 100000],
|
||||
]) {
|
||||
await page.evaluate(async (height) => {
|
||||
const app = window.__godsEyeView;
|
||||
await app.dataManager.setEnabled('bikeshare', false);
|
||||
const geo = await import('/src/geo/index.js');
|
||||
app.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, height),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
}, height);
|
||||
await settledTiles(page);
|
||||
await nextFrame(page);
|
||||
const off = await page.screenshot({
|
||||
path: path.join(shots, `${name}-off.png`),
|
||||
});
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('bikeshare', true),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(count) =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getStats().count === count,
|
||||
{},
|
||||
name === 'global' ? 0 : 5,
|
||||
);
|
||||
await nextFrame(page);
|
||||
const controls = await page.evaluate(() => {
|
||||
const app = window.__godsEyeView;
|
||||
const rect = app.engine.host.canvas.getBoundingClientRect();
|
||||
return app.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getDetectableObjects()
|
||||
.map((record) => {
|
||||
const p = app.engine.projector.worldToWindow(record.position);
|
||||
return { id: record.sourceId, x: p.x + rect.left, y: p.y + rect.top };
|
||||
});
|
||||
});
|
||||
const on = await page.screenshot({
|
||||
path: path.join(shots, `${name}-on.png`),
|
||||
});
|
||||
const a = await sharp(off)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const b = await sharp(on).ensureAlpha().raw().toBuffer();
|
||||
for (const control of controls) {
|
||||
let pixels = 0;
|
||||
for (
|
||||
let y = Math.max(0, Math.floor(control.y) - 16);
|
||||
y < Math.min(a.info.height, control.y + 16);
|
||||
y++
|
||||
)
|
||||
for (
|
||||
let x = Math.max(0, Math.floor(control.x) - 16);
|
||||
x < Math.min(a.info.width, control.x + 16);
|
||||
x++
|
||||
) {
|
||||
const i = (y * a.info.width + x) * 4;
|
||||
if (
|
||||
Math.max(
|
||||
...[0, 1, 2].map((c) => Math.abs(a.data[i + c] - b[i + c])),
|
||||
) > 3
|
||||
)
|
||||
pixels++;
|
||||
}
|
||||
control.changedPixels = pixels;
|
||||
}
|
||||
report.poses[name] = controls;
|
||||
check(
|
||||
`${name} expected station population`,
|
||||
controls.length,
|
||||
name === 'global' ? 0 : 5,
|
||||
);
|
||||
if (controls.length)
|
||||
check(
|
||||
`${name} five nonempty marker raster controls`,
|
||||
controls.every((c) => c.changedPixels > 0),
|
||||
true,
|
||||
);
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
for (const element of document.querySelectorAll('.gbfs-raster-hidden'))
|
||||
element.classList.remove('gbfs-raster-hidden');
|
||||
});
|
||||
await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
const geo = await import('/src/geo/index.js');
|
||||
app.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
await app.dataManager.setEnabled('bikeshare', false);
|
||||
});
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('bikeshare', true),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getStats().count === 5,
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('bikeshare', false),
|
||||
);
|
||||
}
|
||||
check(
|
||||
'ten cycles clear all records',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getStats().count,
|
||||
),
|
||||
0,
|
||||
);
|
||||
const idle = await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
let frames = 0,
|
||||
last = performance.now();
|
||||
const remove = app.engine.frames.postRender.addEventListener(() => {
|
||||
frames++;
|
||||
last = performance.now();
|
||||
});
|
||||
const start = performance.now();
|
||||
while (
|
||||
performance.now() - start < 60000 &&
|
||||
(performance.now() - last < 2000 || !app.engine.surface.tilesLoaded())
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const ready =
|
||||
performance.now() - last >= 2000 && app.engine.surface.tilesLoaded();
|
||||
frames = 0;
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
remove();
|
||||
return { ready, frames, governor: app.getRenderGovernorDiagnostics() };
|
||||
});
|
||||
report.idle = idle;
|
||||
check('camera and tiles settle before idle sample', idle.ready, true);
|
||||
check(
|
||||
'disabled static scene renders zero frames over two seconds',
|
||||
idle.frames,
|
||||
0,
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('bikeshare', true),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers.get('bikeshare').module.getStats()
|
||||
.count === 5,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const { page, errors } = await open();
|
||||
await page.waitForFunction(
|
||||
@@ -158,6 +382,17 @@ try {
|
||||
.count === 5,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const rendered = new Promise((resolve) => {
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
window.__godsEyeView.requestRender('gbfs-name-pick-qa');
|
||||
await rendered;
|
||||
});
|
||||
const records = await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
@@ -198,17 +433,39 @@ try {
|
||||
),
|
||||
true,
|
||||
);
|
||||
if (extended) await w2Checks(page);
|
||||
const clickPoint = await page.evaluate(() => {
|
||||
window.__qaPainted = [];
|
||||
const { viewer } = window.__godsEyeView;
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
const point = viewer.scene.cartesianToCanvasCoordinates(
|
||||
Cartesian3.fromDegrees(-97.7469, 30.2681),
|
||||
);
|
||||
const rect = viewer.scene.canvas.getBoundingClientRect();
|
||||
return { x: rect.left + point.x, y: rect.top + point.y };
|
||||
const { engine } = window.__godsEyeView;
|
||||
const station = window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getDetectableObjects()
|
||||
.find(({ sourceId }) => sourceId === 'austin-capmetro:english');
|
||||
const point = engine.projector.worldToWindow(station.position);
|
||||
const rect = engine.host.canvas.getBoundingClientRect();
|
||||
const picked = engine.picker.pick(point);
|
||||
return {
|
||||
x: rect.left + point.x,
|
||||
y: rect.top + point.y,
|
||||
pickedId: typeof picked?.id === 'string' ? picked.id : null,
|
||||
primitiveId:
|
||||
typeof picked?.primitive?.id === 'string' ? picked.primitive.id : null,
|
||||
};
|
||||
});
|
||||
check(
|
||||
'the scene pick resolves the first stable station key',
|
||||
clickPoint.pickedId || clickPoint.primitiveId,
|
||||
'austin-capmetro:english',
|
||||
);
|
||||
await page.mouse.click(clickPoint.x, clickPoint.y);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getDetectableObjects()
|
||||
.some((record) => record.skipLabel),
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__qaPainted.includes('QA English Square'),
|
||||
{ timeout: 10000 },
|
||||
@@ -263,15 +520,15 @@ try {
|
||||
);
|
||||
}
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
await page.evaluate(() => {
|
||||
await page.evaluate(async () => {
|
||||
window.__qaPainted = [];
|
||||
const app = window.__godsEyeView;
|
||||
// Use the supported clean view to inspect the mobile card clear of panels.
|
||||
app.styleManager.toggleCleanView(true);
|
||||
const Cartesian3 = app.viewer.camera.position.constructor;
|
||||
app.viewer.camera.cancelFlight();
|
||||
app.viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
||||
const geo = await import('/src/geo/index.js');
|
||||
app.engine.camera.cancelFlight();
|
||||
app.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
app.dataManager.layers
|
||||
@@ -330,7 +587,20 @@ try {
|
||||
check(`${mode} produces no page exceptions`, errors, []);
|
||||
await page.close();
|
||||
}
|
||||
if (extended)
|
||||
check(
|
||||
'all pages have no unexpected runtime errors',
|
||||
report.browserErrors,
|
||||
[],
|
||||
);
|
||||
report.pass = true;
|
||||
console.log(`GBFS names: ${checks}/${checks} checks passed; ${appUrl}`);
|
||||
} catch (error) {
|
||||
report.pass = false;
|
||||
report.failure = redact(error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
if (reportPath)
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n');
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createBikeshareLayer } from '../../layers/bikeshare/index.js';
|
||||
import { createBikeshareCesiumRenderer } from '../../layers/bikeshare/render/cesium.js';
|
||||
import { rendererFor } from './renderers.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
@@ -8,6 +10,10 @@ import * as overlays from '../../overlays/worldOverlay.js';
|
||||
export function createApplicationBikeshare({ source }) {
|
||||
return createBikeshareLayer({
|
||||
source,
|
||||
rendererFactory: ({ engine, ...context }) =>
|
||||
engine?.kind === 'navara'
|
||||
? rendererFor('bikeshare', engine, context)
|
||||
: createBikeshareCesiumRenderer(context),
|
||||
services: { render, sprites, picking, overlays },
|
||||
});
|
||||
}
|
||||
@@ -27,6 +27,7 @@ test('the Navara mode registers migrated renderers once', async () => {
|
||||
'earthquakes',
|
||||
'local-firms',
|
||||
'military-installations',
|
||||
'bikeshare',
|
||||
])
|
||||
assert.equal(layerAvailableOn(id, 'navara'), true, id);
|
||||
assert.deepEqual(
|
||||
@@ -40,6 +41,7 @@ test('the Navara mode registers migrated renderers once', async () => {
|
||||
'earthquakes',
|
||||
'satellites',
|
||||
'rocket-launches',
|
||||
'bikeshare',
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
'local-firms',
|
||||
|
||||
@@ -62,6 +62,9 @@ const NAVARA_CREDIT_KEYS = new Set([
|
||||
*/
|
||||
export async function registerNavaraLayerRenderers() {
|
||||
const loaders = {
|
||||
bikeshare: async () =>
|
||||
(await import('../layers/bikeshare/render/navara.js'))
|
||||
.createBikeshareNavaraRenderer,
|
||||
'military-installations': async () => {
|
||||
const { createInstallationsNavaraRenderer } =
|
||||
await import('../layers/installations/render/navara.js');
|
||||
|
||||
@@ -41,7 +41,7 @@ test('selected bikeshare entry preserves source copy and protected-lane policy',
|
||||
assert.equal(entry.paintLane, 'selected');
|
||||
assert.equal(entry.collisionGroup, 'ambient-card');
|
||||
assert.equal(entry.edgeFade, 'keyhole');
|
||||
assert.equal(entry.horizonCull, true);
|
||||
assert.equal(entry.horizonCull, false);
|
||||
});
|
||||
|
||||
test('real station select/clear path publishes one card and creates no native label graphic', () => {
|
||||
|
||||
@@ -118,7 +118,10 @@ test('every scene click handler consults ownership before it picks', () => {
|
||||
['src/data/localGeojsonCore.js', 'viewer.scene.pick(click.position)'],
|
||||
['src/data/cctvGizmo.js', 'pickGizmoPart(event.position)'],
|
||||
['src/layers/alpr/presentation.js', 'viewer.scene.pick(click.position)'],
|
||||
['src/layers/bikeshare/selection.js', 'viewer.scene.pick(click.position)'],
|
||||
[
|
||||
'src/layers/bikeshare/render/cesium/selection.js',
|
||||
'viewer.scene.pick(click.position)',
|
||||
],
|
||||
['src/layers/firms/selection.js', 'scene.pick(click.position)'],
|
||||
[
|
||||
'src/layers/installations/render/cesium/selection.js',
|
||||
|
||||
@@ -160,6 +160,7 @@ function pointPart(options, store, factories, requestRender, owners) {
|
||||
outlineWidth: point.outlinePx,
|
||||
outlineColor: new Cesium.Color(...point.outlineColor),
|
||||
scaleByDistance: nearFar(point.scaleByDistance),
|
||||
translucencyByDistance: nearFar(point.translucencyByDistance),
|
||||
disableDepthTestDistance: depthTestDistance(depthTest),
|
||||
};
|
||||
const points = [];
|
||||
|
||||
@@ -316,7 +316,7 @@
|
||||
* each sprite is a square of sizePx CSS px times its scale, times the
|
||||
* NearFarScalar [nearM, nearScale, farM, farScale] when given (Cesium's
|
||||
* interpolation: over the squared camera distance, eased by ^0.2).
|
||||
* @property {{ sizePx: number, outlinePx?: number, outlineColor?: number[], scaleByDistance?: number[] }} [point]
|
||||
* @property {{ sizePx: number, outlinePx?: number, outlineColor?: number[], scaleByDistance?: number[], translucencyByDistance?: number[] }} [point]
|
||||
* A filled disc of sizePx CSS px diameter plus outlinePx on each side;
|
||||
* outlineColor is rgba in 0..1 (opaque black by default).
|
||||
* @property {{ url: string, minimumPixelSize?: number, maximumScale?: number,
|
||||
|
||||
@@ -123,6 +123,7 @@ export function normalizeMoverSetOptions(options) {
|
||||
sizePx: positive(point.sizePx, 'point.sizePx'),
|
||||
outlinePx: nonNegative(point.outlinePx, 'point.outlinePx', 0),
|
||||
scaleByDistance: scaleByDistanceOf(point.scaleByDistance),
|
||||
translucencyByDistance: scaleByDistanceOf(point.translucencyByDistance),
|
||||
};
|
||||
} else {
|
||||
const model = options.model;
|
||||
|
||||
@@ -39,6 +39,7 @@ uniform float uGevSizePx;
|
||||
uniform float uGevFootprint;
|
||||
uniform vec4 uGevScaleByDistance;
|
||||
uniform float uGevHasScaleByDistance;
|
||||
uniform vec4 uGevTranslucencyByDistance;
|
||||
uniform float uGevDisableWithinM;
|
||||
uniform float uGevOutlinePx;
|
||||
uniform float uGevPixelRatio;
|
||||
@@ -46,6 +47,7 @@ varying vec4 vGevColor;
|
||||
varying vec2 vGevLocal;
|
||||
varying float vGevOutline;
|
||||
varying float vGevPixelDistance;
|
||||
varying float vGevTranslucency;
|
||||
${OVERLAY_DEPTH_VERTEX}
|
||||
`;
|
||||
|
||||
@@ -54,6 +56,13 @@ vec4 mvCenter = modelViewMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
|
||||
float gevScale = gevStyle.y;
|
||||
float gevDistanceScale = 1.0;
|
||||
float gevDistance = length(mvCenter.xyz);
|
||||
float gevAlphaNearSq = uGevTranslucencyByDistance.x * uGevTranslucencyByDistance.x;
|
||||
float gevAlphaFarSq = uGevTranslucencyByDistance.z * uGevTranslucencyByDistance.z;
|
||||
float gevAlphaT = pow(clamp(
|
||||
(gevDistance * gevDistance - gevAlphaNearSq) / max(gevAlphaFarSq - gevAlphaNearSq, 1e-6),
|
||||
0.0, 1.0
|
||||
), 0.2);
|
||||
vGevTranslucency = mix(uGevTranslucencyByDistance.y, uGevTranslucencyByDistance.w, gevAlphaT);
|
||||
if (uGevHasScaleByDistance > 0.5) {
|
||||
// Cesium's czm_nearFarScalar: over the squared distance, eased by ^0.2.
|
||||
float gevNearSq = uGevScaleByDistance.x * uGevScaleByDistance.x;
|
||||
@@ -107,6 +116,7 @@ varying vec4 vGevColor;
|
||||
varying vec2 vGevLocal;
|
||||
varying float vGevOutline;
|
||||
varying float vGevPixelDistance;
|
||||
varying float vGevTranslucency;
|
||||
${OVERLAY_DEPTH_FRAGMENT}
|
||||
`;
|
||||
|
||||
@@ -126,7 +136,7 @@ float gevWholeAlpha = 1.0 - smoothstep(gevMaxDistance, 0.5, gevDistanceToCenter)
|
||||
float gevInner = 1.0 - vGevOutline;
|
||||
float gevInnerAlpha = 1.0 - smoothstep(gevMaxDistance * gevInner, 0.5 * gevInner, gevDistanceToCenter);
|
||||
diffuseColor *= sRGBTransferEOTF(mix(uGevOutlineColor, vGevColor, gevInnerAlpha));
|
||||
diffuseColor.a *= gevWholeAlpha;
|
||||
diffuseColor.a *= gevWholeAlpha * vGevTranslucency;
|
||||
if (diffuseColor.a < 0.005) discard;
|
||||
`
|
||||
: /* glsl */ `
|
||||
@@ -251,6 +261,9 @@ export function createMoverMesh(
|
||||
uGevFootprint: { value: 0 },
|
||||
uGevScaleByDistance: { value: sbd ? [...sbd] : [0, 1, 1, 1] },
|
||||
uGevHasScaleByDistance: { value: sbd ? 1 : 0 },
|
||||
uGevTranslucencyByDistance: {
|
||||
value: style.translucencyByDistance ?? [0, 1, 1, 1],
|
||||
},
|
||||
uGevDisableWithinM: {
|
||||
value: typeof depthTest === 'object' ? depthTest.disableWithinM : -1,
|
||||
},
|
||||
|
||||
@@ -29,6 +29,38 @@ const basicShader = () => ({
|
||||
fragmentShader: ShaderLib.basic.fragmentShader,
|
||||
});
|
||||
|
||||
test('point translucency applies to fill and outline and defaults to no fade', () => {
|
||||
const options = normalizeMoverSetOptions({
|
||||
id: 'stations',
|
||||
kind: 'point',
|
||||
capacity: 1,
|
||||
point: { sizePx: 8, translucencyByDistance: [200, 1, 180000, 0.15] },
|
||||
});
|
||||
const mover = createMoverMesh(options, createMoverStore(options));
|
||||
assert.deepEqual(
|
||||
mover.uniforms.uGevTranslucencyByDistance.value,
|
||||
[200, 1, 180000, 0.15],
|
||||
);
|
||||
const shader = patchMoverShader(basicShader(), mover.uniforms, {
|
||||
point: true,
|
||||
});
|
||||
assert.match(shader.fragmentShader, /gevWholeAlpha \* vGevTranslucency/);
|
||||
assert.match(
|
||||
shader.vertexShader,
|
||||
/gevDistance \* gevDistance - gevAlphaNearSq/,
|
||||
);
|
||||
mover.dispose();
|
||||
const ordinary = createMoverMesh(
|
||||
spriteOptions,
|
||||
createMoverStore(spriteOptions),
|
||||
);
|
||||
assert.deepEqual(
|
||||
ordinary.uniforms.uGevTranslucencyByDistance.value,
|
||||
[0, 1, 1, 1],
|
||||
);
|
||||
ordinary.dispose();
|
||||
});
|
||||
|
||||
test('the shader patch sizes the quad in pixels and colours it per instance', () => {
|
||||
const shader = patchMoverShader(basicShader(), { uGevSizePx: { value: 9 } });
|
||||
assert.equal(shader.uniforms.uGevSizePx.value, 9);
|
||||
|
||||
@@ -1,40 +1,76 @@
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createSelection } from './selection.js';
|
||||
import { createQueries } from './queries.js';
|
||||
import { createTesting } from './testing.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createState } from './state.js';
|
||||
import { createBikeshareSelectedOverlayEntry } from './selectionModel.js';
|
||||
|
||||
/** Construct one layer with its own scene state and supplied application services. */
|
||||
export function createBikeshareLayer({ services, source }) {
|
||||
export function createBikeshareLayer({ services, source, rendererFactory }) {
|
||||
if (typeof source?.getStations !== 'function')
|
||||
throw new TypeError('A bikeshare source is required');
|
||||
if (typeof rendererFactory !== 'function')
|
||||
throw new TypeError('A bikeshare renderer factory is required');
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const context = { state, services, parts, source };
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.model = createModel(context);
|
||||
parts.viewport = createViewport(context);
|
||||
parts.rendering = createRendering(context);
|
||||
parts.selection = createSelection(context);
|
||||
parts.queries = createQueries(context);
|
||||
parts.testing = createTesting(context);
|
||||
parts.controls = createControls(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
let renderer = null;
|
||||
let initialized = false;
|
||||
const lifecycle = {
|
||||
init(viewer, options = {}) {
|
||||
if (initialized)
|
||||
throw new Error('Bikeshare layer is already initialized');
|
||||
renderer ??= rendererFactory({
|
||||
...context,
|
||||
viewer,
|
||||
engine: options.engine ?? null,
|
||||
});
|
||||
if (!renderer?.lifecycle?.methods)
|
||||
throw new Error('Bikeshare renderer is unavailable');
|
||||
Object.assign(parts, renderer);
|
||||
initialized = true;
|
||||
try {
|
||||
return renderer.lifecycle.methods.init(viewer, options);
|
||||
} catch (error) {
|
||||
initialized = false;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enable(...args) {
|
||||
return renderer?.lifecycle.methods.enable(...args);
|
||||
},
|
||||
disable(...args) {
|
||||
return renderer?.lifecycle.methods.disable(...args);
|
||||
},
|
||||
destroy(...args) {
|
||||
try {
|
||||
return renderer?.lifecycle.methods.destroy(...args);
|
||||
} finally {
|
||||
renderer = null;
|
||||
initialized = false;
|
||||
for (const key of ['rendering', 'selection', 'viewport', 'lifecycle'])
|
||||
delete parts[key];
|
||||
}
|
||||
},
|
||||
};
|
||||
return Object.assign(
|
||||
{},
|
||||
parts.controls.methods,
|
||||
parts.lifecycle.methods,
|
||||
lifecycle,
|
||||
parts.ingestion?.methods,
|
||||
{
|
||||
createBikeshareSelectedOverlayEntry:
|
||||
parts.selection.createBikeshareSelectedOverlayEntry,
|
||||
_setBikeshareSelectionStateForTest:
|
||||
parts.testing._setBikeshareSelectionStateForTest,
|
||||
createBikeshareSelectedOverlayEntry,
|
||||
_setBikeshareSelectionStateForTest: (options) => {
|
||||
renderer ??= rendererFactory({ ...context, viewer: options.viewer });
|
||||
Object.assign(parts, renderer);
|
||||
parts.testing._setBikeshareSelectionStateForTest(options);
|
||||
},
|
||||
_selectBikeshareStationForTest:
|
||||
parts.testing._selectBikeshareStationForTest,
|
||||
_clearBikeshareSelectionForTest:
|
||||
|
||||
@@ -55,6 +55,7 @@ export function createIngestion({
|
||||
if (!entry) return;
|
||||
try {
|
||||
entry.controller?.abort();
|
||||
endLoading();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
@@ -97,6 +98,7 @@ export function createIngestion({
|
||||
const payload = await fetchGbfsJson(city.stationInformationUrl, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.signal.throwIfAborted();
|
||||
const stationMap = parts.model.parseStationInformation(payload);
|
||||
if (stationMap.size === 0) {
|
||||
throw new Error(`No station information for ${city.city}`);
|
||||
@@ -104,7 +106,7 @@ export function createIngestion({
|
||||
layerState._stationInfoCache.set(cityId, stationMap);
|
||||
return stationMap;
|
||||
} finally {
|
||||
endLoading();
|
||||
if (!controller.signal.aborted) endLoading();
|
||||
}
|
||||
})().finally(() => {
|
||||
// Clean up in-flight entry only if it is still ours (not replaced by a newer request)
|
||||
@@ -141,6 +143,7 @@ export function createIngestion({
|
||||
const payload = await fetchGbfsJson(city.stationStatusUrl, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.signal.throwIfAborted();
|
||||
const statusMap = parts.model.parseStationStatus(payload);
|
||||
layerState._statusCache.set(cityId, {
|
||||
statusMap,
|
||||
@@ -148,7 +151,7 @@ export function createIngestion({
|
||||
});
|
||||
return statusMap;
|
||||
} finally {
|
||||
endLoading();
|
||||
if (!controller.signal.aborted) endLoading();
|
||||
}
|
||||
})().finally(() => {
|
||||
const current = layerState._inFlightStatus.get(cityId);
|
||||
@@ -184,6 +187,11 @@ export function createIngestion({
|
||||
parts.rendering.applyStatusToPoints(cityId, statusMap);
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return;
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
generation !== layerState._proximityGeneration
|
||||
)
|
||||
return;
|
||||
console.warn(
|
||||
`[Data:Bikeshare] ${cityId} status update error:`,
|
||||
error,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { constantRgba, rgbaFromCss } from '../shared/color.js';
|
||||
|
||||
export const BIKESHARE_SELECTED_OVERLAY_SOURCE_ID = 'bikeshare-selected';
|
||||
|
||||
@@ -57,29 +57,24 @@ export const MAX_TOTAL_POINTS = 8000;
|
||||
// --- Availability color palette ---
|
||||
/** Station has >60% bikes available. */
|
||||
|
||||
export const COLOR_GREEN =
|
||||
Cesium.Color.fromCssColorString('#00ff88').withAlpha(0.95);
|
||||
export const COLOR_GREEN = rgbaFromCss('#00ff88').withAlpha(0.95);
|
||||
|
||||
/** Station has 30-60% bikes available. */
|
||||
|
||||
export const COLOR_YELLOW =
|
||||
Cesium.Color.fromCssColorString('#ffaa00').withAlpha(0.94);
|
||||
export const COLOR_YELLOW = rgbaFromCss('#ffaa00').withAlpha(0.94);
|
||||
|
||||
/** Station has <30% bikes available. */
|
||||
|
||||
export const COLOR_RED =
|
||||
Cesium.Color.fromCssColorString('#ff4444').withAlpha(0.94);
|
||||
export const COLOR_RED = rgbaFromCss('#ff4444').withAlpha(0.94);
|
||||
|
||||
/** No status data available for station. */
|
||||
|
||||
export const COLOR_NEUTRAL =
|
||||
Cesium.Color.fromCssColorString('#91a4b4').withAlpha(0.62);
|
||||
export const COLOR_NEUTRAL = rgbaFromCss('#91a4b4').withAlpha(0.62);
|
||||
|
||||
/** Station is offline (not installed, not renting, or not returning). */
|
||||
|
||||
export const COLOR_MUTED =
|
||||
Cesium.Color.fromCssColorString('#687581').withAlpha(0.48);
|
||||
export const COLOR_MUTED = rgbaFromCss('#687581').withAlpha(0.48);
|
||||
|
||||
/** Outline color for all station points. */
|
||||
|
||||
export const COLOR_OUTLINE = Cesium.Color.BLACK.withAlpha(0.25);
|
||||
export const COLOR_OUTLINE = constantRgba(0, 0, 0, 0.25);
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
DEFAULT_CAPACITY,
|
||||
POINT_SIZE_MIN,
|
||||
POINT_SIZE_MAX,
|
||||
COLOR_NEUTRAL,
|
||||
COLOR_MUTED,
|
||||
COLOR_GREEN,
|
||||
COLOR_YELLOW,
|
||||
COLOR_RED,
|
||||
} from './policy.js';
|
||||
|
||||
export function resolveCapacity(model, capacityFromInfo, status) {
|
||||
const fromInfo = model.toNonNegativeInteger(capacityFromInfo);
|
||||
if (fromInfo && fromInfo > 0) return fromInfo;
|
||||
const bikes = model.toNonNegativeInteger(status?.bikesAvailable, 0);
|
||||
const docks = model.toNonNegativeInteger(status?.docksAvailable, 0);
|
||||
return bikes + docks || DEFAULT_CAPACITY;
|
||||
}
|
||||
|
||||
export function capacityToPixelSize(capacity) {
|
||||
const value = Math.max(1, Math.min(80, Number(capacity) || DEFAULT_CAPACITY));
|
||||
const normalized = Math.sqrt(value) / Math.sqrt(80);
|
||||
const size = POINT_SIZE_MIN + normalized * (POINT_SIZE_MAX - POINT_SIZE_MIN);
|
||||
return Math.max(POINT_SIZE_MIN, Math.min(POINT_SIZE_MAX, size));
|
||||
}
|
||||
|
||||
export function statusToColor(model, status, capacity) {
|
||||
if (!status) return COLOR_NEUTRAL;
|
||||
if (!status.isInstalled || !status.isRenting || !status.isReturning)
|
||||
return COLOR_MUTED;
|
||||
const bikes = model.toNonNegativeInteger(status.bikesAvailable);
|
||||
if (!Number.isFinite(bikes)) return COLOR_NEUTRAL;
|
||||
const resolvedCapacity = Math.max(1, Number(capacity) || DEFAULT_CAPACITY);
|
||||
const ratio = bikes / resolvedCapacity;
|
||||
if (ratio > 0.6) return COLOR_GREEN;
|
||||
if (ratio >= 0.3) return COLOR_YELLOW;
|
||||
return COLOR_RED;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createModel } from './model.js';
|
||||
import {
|
||||
capacityToPixelSize,
|
||||
resolveCapacity,
|
||||
statusToColor,
|
||||
} from './presentation.js';
|
||||
import {
|
||||
COLOR_GREEN,
|
||||
COLOR_YELLOW,
|
||||
COLOR_RED,
|
||||
COLOR_NEUTRAL,
|
||||
COLOR_MUTED,
|
||||
} from './policy.js';
|
||||
|
||||
const model = createModel({});
|
||||
const online = { isInstalled: true, isRenting: true, isReturning: true };
|
||||
|
||||
test('availability boundaries and all disabled flags retain the native palette', () => {
|
||||
for (const [bikes, color] of [
|
||||
[0, COLOR_RED],
|
||||
[2, COLOR_RED],
|
||||
[3, COLOR_YELLOW],
|
||||
[6, COLOR_YELLOW],
|
||||
[7, COLOR_GREEN],
|
||||
]) {
|
||||
assert.equal(
|
||||
statusToColor(model, { ...online, bikesAvailable: bikes }, 10),
|
||||
color,
|
||||
);
|
||||
}
|
||||
assert.equal(statusToColor(model, null, 10), COLOR_NEUTRAL);
|
||||
assert.equal(statusToColor(model, online, 10), COLOR_NEUTRAL);
|
||||
for (const flag of Object.keys(online))
|
||||
assert.equal(
|
||||
statusToColor(model, { ...online, bikesAvailable: 8, [flag]: false }, 10),
|
||||
COLOR_MUTED,
|
||||
);
|
||||
});
|
||||
|
||||
test('capacity prioritizes station metadata, then total docks, then the native fallback', () => {
|
||||
assert.equal(
|
||||
resolveCapacity(model, 20, { bikesAvailable: 4, docksAvailable: 9 }),
|
||||
20,
|
||||
);
|
||||
assert.equal(
|
||||
resolveCapacity(model, null, { bikesAvailable: 4, docksAvailable: 9 }),
|
||||
13,
|
||||
);
|
||||
assert.equal(resolveCapacity(model, null, null), 15);
|
||||
for (const capacity of [1, 13, 15, 80, 120]) {
|
||||
const expected = 4 + Math.sqrt(Math.min(80, capacity) / 80) * 8;
|
||||
assert.ok(Math.abs(capacityToPixelSize(capacity) - expected) < 1e-12);
|
||||
}
|
||||
});
|
||||
@@ -30,12 +30,7 @@ export function createQueries({ state: layerState, services, parts, source }) {
|
||||
*/
|
||||
|
||||
function collectDetectableStations(options = {}) {
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
!layerState._pointCollection ||
|
||||
!layerState._pointCollection.show ||
|
||||
layerState._stationRenderMap.size === 0
|
||||
)
|
||||
if (!layerState._enabled || layerState._stationRenderMap.size === 0)
|
||||
return [];
|
||||
|
||||
// Gather all visible station records (include selected even though its point is hidden)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createLifecycle } from './cesium/lifecycle.js';
|
||||
import { createRendering } from './cesium/rendering.js';
|
||||
import { createSelection } from './cesium/selection.js';
|
||||
import { createViewport } from './cesium/viewport.js';
|
||||
|
||||
/** Compose the existing Cesium-owned bikeshare render modules. */
|
||||
export function createBikeshareCesiumRenderer(context) {
|
||||
return {
|
||||
rendering: createRendering(context),
|
||||
selection: createSelection(context),
|
||||
viewport: createViewport(context),
|
||||
lifecycle: createLifecycle(context),
|
||||
};
|
||||
}
|
||||
+19
-4
@@ -1,10 +1,10 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { GBFS_CITY_REGISTRY } from './registry.js';
|
||||
import { BIKESHARE_SELECTED_OVERLAY_SOURCE_ID } from './policy.js';
|
||||
import { GBFS_CITY_REGISTRY } from '../../registry.js';
|
||||
import { BIKESHARE_SELECTED_OVERLAY_SOURCE_ID } from '../../policy.js';
|
||||
import {
|
||||
claimCameraSensitivity,
|
||||
releaseCameraSensitivity,
|
||||
} from '../../data/cameraSensitivity.js';
|
||||
} from '../../../../data/cameraSensitivity.js';
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
@@ -12,7 +12,11 @@ export function createLifecycle({
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const { registerSpriteCollection, restoreSpriteOrder } = services.sprites;
|
||||
const {
|
||||
registerSpriteCollection,
|
||||
unregisterSpriteCollection,
|
||||
restoreSpriteOrder,
|
||||
} = services.sprites;
|
||||
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
||||
|
||||
const methods = {
|
||||
@@ -74,6 +78,8 @@ export function createLifecycle({
|
||||
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
|
||||
*/
|
||||
enable(viewer) {
|
||||
if (layerState._enabled) return;
|
||||
viewer = layerState._viewer;
|
||||
layerState._enabled = true;
|
||||
layerState._error = null;
|
||||
layerState._pointCollection.show = true;
|
||||
@@ -106,6 +112,8 @@ export function createLifecycle({
|
||||
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
|
||||
*/
|
||||
disable(viewer) {
|
||||
viewer = layerState._viewer;
|
||||
if (!viewer) return;
|
||||
layerState._enabled = false;
|
||||
layerState._proximityGeneration++;
|
||||
layerState._altitudeGateEnabled = false;
|
||||
@@ -143,6 +151,10 @@ export function createLifecycle({
|
||||
|
||||
/** Permanently release primitives, handlers, and the selected host source. */
|
||||
destroy(viewer) {
|
||||
viewer = layerState._viewer;
|
||||
if (!viewer) return;
|
||||
clearTimeout(layerState._cameraDebounceTimer);
|
||||
layerState._cameraDebounceTimer = null;
|
||||
if (layerState._enabled) this.disable(viewer);
|
||||
else {
|
||||
parts.selection._clearSelection();
|
||||
@@ -166,10 +178,13 @@ export function createLifecycle({
|
||||
}
|
||||
parts.ingestion.abortAllInFlight();
|
||||
if (layerState._pointCollection) {
|
||||
unregisterSpriteCollection?.('bikeshare', layerState._pointCollection);
|
||||
viewer.scene.primitives.remove(layerState._pointCollection);
|
||||
layerState._pointCollection = null;
|
||||
}
|
||||
layerState._viewer = null;
|
||||
layerState._stationInfoCache.clear();
|
||||
layerState._statusCache.clear();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createState } from '../../state.js';
|
||||
|
||||
test('destroy releases the original viewer and registered collection even when passed another viewer', (t) => {
|
||||
const previous = globalThis.document;
|
||||
globalThis.document = { removeEventListener() {} };
|
||||
t.after(() => {
|
||||
globalThis.document = previous;
|
||||
});
|
||||
const noop = () => {};
|
||||
const calls = [];
|
||||
const services = {
|
||||
overlays: {
|
||||
setOverlayEntries: noop,
|
||||
setOverlaySourceVisible: noop,
|
||||
clearOverlaySource: noop,
|
||||
},
|
||||
sprites: {
|
||||
registerSpriteCollection: noop,
|
||||
unregisterSpriteCollection: (...args) => calls.push(args),
|
||||
restoreSpriteOrder: noop,
|
||||
},
|
||||
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
|
||||
};
|
||||
const state = createState({ services });
|
||||
const viewer = { scene: { primitives: new Cesium.PrimitiveCollection() } };
|
||||
const other = { scene: { primitives: new Cesium.PrimitiveCollection() } };
|
||||
const lifecycle = createLifecycle({
|
||||
state,
|
||||
services,
|
||||
parts: {
|
||||
selection: {
|
||||
_installClickHandler: noop,
|
||||
_clearSelection: noop,
|
||||
_onKeyDown: noop,
|
||||
},
|
||||
ingestion: { abortAllInFlight: noop },
|
||||
},
|
||||
}).methods;
|
||||
lifecycle.init(viewer);
|
||||
const collection = state._pointCollection;
|
||||
state._stationInfoCache.set('city', new Map());
|
||||
state._statusCache.set('city', {});
|
||||
lifecycle.destroy(other);
|
||||
lifecycle.destroy(other);
|
||||
assert.equal(viewer.scene.primitives.length, 0);
|
||||
assert.equal(other.scene.primitives.length, 0);
|
||||
assert.equal(collection.isDestroyed(), true);
|
||||
assert.deepEqual(calls, [['bikeshare', collection]]);
|
||||
assert.equal(state._stationInfoCache.size, 0);
|
||||
assert.equal(state._statusCache.size, 0);
|
||||
});
|
||||
+9
-77
@@ -1,17 +1,15 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
DEFAULT_CAPACITY,
|
||||
POINT_SIZE_MIN,
|
||||
POINT_SIZE_MAX,
|
||||
COLOR_NEUTRAL,
|
||||
COLOR_MUTED,
|
||||
COLOR_GREEN,
|
||||
COLOR_YELLOW,
|
||||
COLOR_RED,
|
||||
POINT_HEIGHT_OFFSET_M,
|
||||
MAX_TOTAL_POINTS,
|
||||
COLOR_OUTLINE,
|
||||
} from './policy.js';
|
||||
} from '../../policy.js';
|
||||
import {
|
||||
capacityToPixelSize,
|
||||
resolveCapacity,
|
||||
statusToColor,
|
||||
} from '../../presentation.js';
|
||||
|
||||
export function createRendering({
|
||||
state: layerState,
|
||||
@@ -21,71 +19,6 @@ export function createRendering({
|
||||
}) {
|
||||
const { governorRequestRender } = services.render;
|
||||
|
||||
/**
|
||||
* Resolve the effective dock capacity for a station.
|
||||
* Prefers the explicit capacity from station_information; falls back to
|
||||
* bikes+docks from status data; uses DEFAULT_CAPACITY as last resort.
|
||||
* @param {number|null} capacityFromInfo - Capacity from station_information, or null.
|
||||
* @param {Object|null} status - Station status object with bikesAvailable/docksAvailable.
|
||||
* @returns {number} Resolved capacity (always > 0).
|
||||
*/
|
||||
|
||||
function resolveCapacity(capacityFromInfo, status) {
|
||||
const fromInfo = parts.model.toNonNegativeInteger(capacityFromInfo);
|
||||
if (fromInfo && fromInfo > 0) return fromInfo;
|
||||
|
||||
// Derive capacity from current bikes + available docks
|
||||
const bikes = parts.model.toNonNegativeInteger(status?.bikesAvailable, 0);
|
||||
const docks = parts.model.toNonNegativeInteger(status?.docksAvailable, 0);
|
||||
const derived = bikes + docks;
|
||||
if (derived > 0) return derived;
|
||||
|
||||
return DEFAULT_CAPACITY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map station capacity to a point pixel size using a sqrt scale.
|
||||
* Larger-capacity stations render as bigger dots; clamped to [POINT_SIZE_MIN, POINT_SIZE_MAX].
|
||||
* @param {number} capacity - Station dock capacity.
|
||||
* @returns {number} Point size in pixels.
|
||||
*/
|
||||
|
||||
function capacityToPixelSize(capacity) {
|
||||
const c = Math.max(1, Math.min(80, Number(capacity) || DEFAULT_CAPACITY));
|
||||
// sqrt scale so large stations don't dominate visually
|
||||
const normalized = Math.sqrt(c) / Math.sqrt(80);
|
||||
const size =
|
||||
POINT_SIZE_MIN + normalized * (POINT_SIZE_MAX - POINT_SIZE_MIN);
|
||||
return Math.max(POINT_SIZE_MIN, Math.min(POINT_SIZE_MAX, size));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the display color for a station based on its availability ratio.
|
||||
* - No status data: neutral gray.
|
||||
* - Offline (not installed/renting/returning): muted gray.
|
||||
* - >60% bikes available: green.
|
||||
* - 30-60% bikes available: yellow.
|
||||
* - <30% bikes available: red.
|
||||
* @param {Object|null} status - Station status object.
|
||||
* @param {number} capacity - Resolved station capacity.
|
||||
* @returns {Cesium.Color} Color to apply to the station point.
|
||||
*/
|
||||
|
||||
function statusToColor(status, capacity) {
|
||||
if (!status) return COLOR_NEUTRAL;
|
||||
if (!status.isInstalled || !status.isRenting || !status.isReturning)
|
||||
return COLOR_MUTED;
|
||||
|
||||
const bikes = parts.model.toNonNegativeInteger(status.bikesAvailable);
|
||||
if (!Number.isFinite(bikes)) return COLOR_NEUTRAL;
|
||||
|
||||
const cap = Math.max(1, Number(capacity) || DEFAULT_CAPACITY);
|
||||
const ratio = bikes / cap;
|
||||
if (ratio > 0.6) return COLOR_GREEN;
|
||||
if (ratio >= 0.3) return COLOR_YELLOW;
|
||||
return COLOR_RED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Cartesian3 position for a station, terrain-clamped when possible.
|
||||
* Falls back to a fixed small height offset if terrain sampling is unsupported.
|
||||
@@ -245,7 +178,7 @@ export function createRendering({
|
||||
if (!record) continue;
|
||||
|
||||
const status = statusMap.get(record.stationId) || null;
|
||||
const capacity = resolveCapacity(record.capacity, status);
|
||||
const capacity = resolveCapacity(parts.model, record.capacity, status);
|
||||
record.capacity = capacity;
|
||||
|
||||
record.bikesAvailable = parts.model.toNonNegativeInteger(
|
||||
@@ -266,13 +199,12 @@ export function createRendering({
|
||||
|
||||
// Update visual properties based on current status
|
||||
record.point.pixelSize = capacityToPixelSize(capacity);
|
||||
record.point.color = statusToColor(status, capacity);
|
||||
record.point.color = statusToColor(parts.model, status, capacity);
|
||||
}
|
||||
parts.selection.refreshSelectedCard();
|
||||
}
|
||||
return {
|
||||
resolveCapacity,
|
||||
capacityToPixelSize,
|
||||
statusToColor,
|
||||
createStationPosition,
|
||||
ensureCityRuntime,
|
||||
ensureCityPoints,
|
||||
+15
-74
@@ -1,9 +1,13 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
import { isPointerFree } from '../../../../data/inputOwnership.js';
|
||||
import {
|
||||
buildBikeshareSelectionLabel,
|
||||
createBikeshareSelectedOverlayEntry,
|
||||
} from '../../selectionModel.js';
|
||||
import {
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
} from './policy.js';
|
||||
} from '../../policy.js';
|
||||
|
||||
export function createSelection({
|
||||
state: layerState,
|
||||
@@ -11,77 +15,6 @@ export function createSelection({
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
/**
|
||||
* Build a multi-line text label for the selected station popup.
|
||||
* Shows station name, availability counts, and any abnormal operational flags.
|
||||
* @param {Object} record - Render record from _stationRenderMap.
|
||||
* @returns {string} Newline-delimited source text for the selected host card.
|
||||
*/
|
||||
|
||||
function buildSelectionLabel(record) {
|
||||
const stationName = String(record?.stationName || '').trim();
|
||||
const stationLabel =
|
||||
stationName ||
|
||||
(record?.stationId ? `Station ${record.stationId}` : 'Station');
|
||||
const bikes = Number.isFinite(record?.bikesAvailable)
|
||||
? record.bikesAvailable
|
||||
: '?';
|
||||
const docks = Number.isFinite(record?.docksAvailable)
|
||||
? record.docksAvailable
|
||||
: '?';
|
||||
const capacity = Number.isFinite(record?.capacity) ? record.capacity : '?';
|
||||
|
||||
const lines = [
|
||||
stationLabel,
|
||||
`🚲 ${bikes} avail · ${docks} docks · ${capacity} cap`,
|
||||
];
|
||||
|
||||
// Append warnings for stations that are offline or partially non-operational
|
||||
const abnormal = [];
|
||||
if (record?.isInstalled === false) abnormal.push('⚠️ Not installed');
|
||||
if (record?.isRenting === false) abnormal.push('⚠️ Not renting');
|
||||
if (record?.isReturning === false) abnormal.push('⚠️ Not returning');
|
||||
if (abnormal.length > 0) {
|
||||
lines.push(abnormal.join(' · '));
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the protected selected-station entry from source-owned copy.
|
||||
* @param {string} key Stable city/station composite key.
|
||||
* @param {Object} record Bikeshare render record.
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
|
||||
function createBikeshareSelectedOverlayEntry(key, record) {
|
||||
const position = record?.point?.position;
|
||||
if (!key || !position) return null;
|
||||
const [title, ...details] = buildSelectionLabel(record).split('\n');
|
||||
return {
|
||||
id: String(key),
|
||||
position,
|
||||
variant: 'selected',
|
||||
selected: true,
|
||||
protected: true,
|
||||
paintLane: 'selected',
|
||||
collisionGroup: 'ambient-card',
|
||||
priority: Number.MAX_SAFE_INTEGER,
|
||||
title,
|
||||
details,
|
||||
accent: '#00ffff',
|
||||
interactive: false,
|
||||
anchorRadiusPx: 9,
|
||||
minAnchorGapPx: 11,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current station selection.
|
||||
* Re-shows the hidden point primitive and removes the highlight entity.
|
||||
@@ -130,6 +63,13 @@ export function createSelection({
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
});
|
||||
refreshSelectedCard();
|
||||
}
|
||||
|
||||
function refreshSelectedCard() {
|
||||
const key = layerState._selectedKey;
|
||||
const record = layerState._stationRenderMap.get(key);
|
||||
if (!record) return;
|
||||
const entry = createBikeshareSelectedOverlayEntry(key, record);
|
||||
if (entry) {
|
||||
layerState._overlayHost.setEntries(
|
||||
@@ -199,7 +139,8 @@ export function createSelection({
|
||||
}
|
||||
}
|
||||
return {
|
||||
buildSelectionLabel,
|
||||
refreshSelectedCard,
|
||||
buildSelectionLabel: buildBikeshareSelectionLabel,
|
||||
createBikeshareSelectedOverlayEntry,
|
||||
_clearSelection,
|
||||
_selectStation,
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../../../geo/index.js';
|
||||
import { createViewport as createSharedViewport } from '../../viewport.js';
|
||||
|
||||
export function createViewport(context) {
|
||||
/**
|
||||
* Get the camera's current altitude in meters above the ellipsoid.
|
||||
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
|
||||
* @returns {number} Altitude in meters, or Infinity if unavailable.
|
||||
*/
|
||||
|
||||
function getCameraAltitude(viewer) {
|
||||
const carto = viewer?.camera?.positionCartographic;
|
||||
return carto && Number.isFinite(carto.height) ? carto.height : Infinity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the lat/lon the camera is currently looking at.
|
||||
* Prefers the center of the computed view rectangle; falls back to the
|
||||
* camera's own cartographic position when the rectangle is unavailable.
|
||||
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
|
||||
* @returns {{ lat: number, lon: number }|null} Center coordinates in degrees, or null.
|
||||
*/
|
||||
|
||||
function getCameraCenterLatLon(viewer) {
|
||||
// Try view rectangle center first (more accurate for tilted views)
|
||||
const rect = viewer?.camera?.computeViewRectangle?.(
|
||||
viewer.scene.globe?.ellipsoid,
|
||||
);
|
||||
if (rect) {
|
||||
const center = Cesium.Rectangle.center(rect);
|
||||
return {
|
||||
lat: geo.math.toDegrees(center.latitude),
|
||||
lon: geo.math.toDegrees(center.longitude),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: use the camera's own position
|
||||
const carto = viewer?.camera?.positionCartographic;
|
||||
if (carto) {
|
||||
return {
|
||||
lat: geo.math.toDegrees(carto.latitude),
|
||||
lon: geo.math.toDegrees(carto.longitude),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return createSharedViewport({
|
||||
...context,
|
||||
camera: { getCameraAltitude, getCameraCenterLatLon },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js';
|
||||
import { CYAN } from '../../shared/color.js';
|
||||
import { createViewport } from '../viewport.js';
|
||||
import {
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
COLOR_NEUTRAL,
|
||||
COLOR_OUTLINE,
|
||||
MAX_TOTAL_POINTS,
|
||||
POINT_HEIGHT_OFFSET_M,
|
||||
} from '../policy.js';
|
||||
import {
|
||||
capacityToPixelSize,
|
||||
resolveCapacity,
|
||||
statusToColor,
|
||||
} from '../presentation.js';
|
||||
import {
|
||||
buildBikeshareSelectionLabel,
|
||||
createBikeshareSelectedOverlayEntry,
|
||||
} from '../selectionModel.js';
|
||||
|
||||
const LAYER_ID = 'bikeshare';
|
||||
|
||||
function pointSet(engine, { id, capacity, order, depthTest, point }) {
|
||||
return engine.movers.createSet({
|
||||
id,
|
||||
kind: 'point',
|
||||
capacity,
|
||||
order,
|
||||
pickable: true,
|
||||
sharpen: true,
|
||||
depthTest,
|
||||
point,
|
||||
});
|
||||
}
|
||||
|
||||
export function createBikeshareNavaraRenderer(engine, context) {
|
||||
if (!engine?.movers || !engine?.camera?.viewRectangle)
|
||||
throw new TypeError('Bikeshare requires a Navara globe engine');
|
||||
const { state, services, parts } = context;
|
||||
const normalSet = pointSet(engine, {
|
||||
id: LAYER_ID,
|
||||
capacity: MAX_TOTAL_POINTS,
|
||||
order: 30,
|
||||
point: {
|
||||
sizePx: 8,
|
||||
outlinePx: 1,
|
||||
outlineColor: COLOR_OUTLINE.rgba(),
|
||||
scaleByDistance: [200, 1.35, 130000, 0.4],
|
||||
translucencyByDistance: [200, 1, 180000, 0.15],
|
||||
},
|
||||
depthTest: { disableWithinM: 2500 },
|
||||
});
|
||||
const selectedSet = pointSet(engine, {
|
||||
id: `${LAYER_ID}-selected`,
|
||||
capacity: 1,
|
||||
order: 31,
|
||||
point: { sizePx: 14, outlinePx: 2, outlineColor: [0, 0, 0, 1] },
|
||||
depthTest: 'always',
|
||||
});
|
||||
let selectionRemove = null;
|
||||
let cameraRemove = null;
|
||||
let destroyed = false;
|
||||
|
||||
const syncPoint = (record) => {
|
||||
const color = record.point.color;
|
||||
normalSet.set(record.index, {
|
||||
position: record.point.position,
|
||||
color: color.rgba(),
|
||||
scale: capacityToPixelSize(record.capacity) / 8,
|
||||
show: record.point.show,
|
||||
pickId: record.key,
|
||||
});
|
||||
};
|
||||
const resetNormalSet = () => {
|
||||
normalSet.setCount(state._stationRenderMap.size);
|
||||
let index = 0;
|
||||
for (const record of state._stationRenderMap.values()) {
|
||||
record.index = index++;
|
||||
syncPoint(record);
|
||||
}
|
||||
normalSet.commit();
|
||||
state._count = state._stationRenderMap.size;
|
||||
};
|
||||
const request = (reason) => services.render.governorRequestRender(reason);
|
||||
|
||||
const rendering = {
|
||||
resolveCapacity: (capacity, status) =>
|
||||
resolveCapacity(parts.model, capacity, status),
|
||||
capacityToPixelSize,
|
||||
statusToColor: (status, capacity) =>
|
||||
statusToColor(parts.model, status, capacity),
|
||||
createStationPosition(station) {
|
||||
const lon = Number(station?.lon);
|
||||
const lat = Number(station?.lat);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
const sampled = engine.surface.supported
|
||||
? engine.surface.sampleHeight(geo.cartographic.fromDegrees(lon, lat), [
|
||||
normalSet,
|
||||
selectedSet,
|
||||
])
|
||||
: undefined;
|
||||
const height =
|
||||
(Number.isFinite(sampled) ? sampled : 0) + POINT_HEIGHT_OFFSET_M;
|
||||
return geo.cartesian3.fromDegrees(lon, lat, height);
|
||||
},
|
||||
ensureCityRuntime(cityId) {
|
||||
let runtime = state._cityRuntime.get(cityId);
|
||||
if (!runtime) {
|
||||
runtime = { stationKeys: new Set() };
|
||||
state._cityRuntime.set(cityId, runtime);
|
||||
}
|
||||
return runtime;
|
||||
},
|
||||
ensureCityPoints(cityId, stationMap) {
|
||||
request('bikeshare-points');
|
||||
const runtime = this.ensureCityRuntime(cityId);
|
||||
for (const station of stationMap.values()) {
|
||||
const key = parts.model.stationKey(cityId, station.stationId);
|
||||
if (state._stationRenderMap.has(key)) {
|
||||
runtime.stationKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
if (state._stationRenderMap.size >= MAX_TOTAL_POINTS) {
|
||||
if (!state._limitWarned) {
|
||||
state._limitWarned = true;
|
||||
console.warn(
|
||||
`[Data:Bikeshare] Point cap reached (${MAX_TOTAL_POINTS}).`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
const position = this.createStationPosition(station);
|
||||
if (!position) continue;
|
||||
const record = {
|
||||
key,
|
||||
cityId,
|
||||
stationId: station.stationId,
|
||||
stationName: station.name,
|
||||
index: state._stationRenderMap.size,
|
||||
point: {
|
||||
position,
|
||||
pixelSize: capacityToPixelSize(station.capacity),
|
||||
color: COLOR_NEUTRAL,
|
||||
show: true,
|
||||
},
|
||||
capacity: parts.model.toNonNegativeInteger(station.capacity),
|
||||
bikesAvailable: null,
|
||||
docksAvailable: null,
|
||||
isInstalled: station.isInstalled,
|
||||
isRenting: station.isRenting,
|
||||
isReturning: station.isReturning,
|
||||
};
|
||||
state._stationRenderMap.set(key, record);
|
||||
runtime.stationKeys.add(key);
|
||||
syncPoint(record);
|
||||
}
|
||||
normalSet.setCount(state._stationRenderMap.size);
|
||||
normalSet.commit();
|
||||
state._count = state._stationRenderMap.size;
|
||||
},
|
||||
removeCityPoints(cityId) {
|
||||
const runtime = state._cityRuntime.get(cityId);
|
||||
if (!runtime) return;
|
||||
if (state._selectedKey && runtime.stationKeys.has(state._selectedKey))
|
||||
selection._clearSelection();
|
||||
for (const key of runtime.stationKeys)
|
||||
state._stationRenderMap.delete(key);
|
||||
state._cityRuntime.delete(cityId);
|
||||
resetNormalSet();
|
||||
},
|
||||
applyStatusToPoints(cityId, statusMap) {
|
||||
request('bikeshare-status');
|
||||
const runtime = state._cityRuntime.get(cityId);
|
||||
if (!runtime) return;
|
||||
for (const key of runtime.stationKeys) {
|
||||
const record = state._stationRenderMap.get(key);
|
||||
if (!record) continue;
|
||||
const status = statusMap.get(record.stationId) || null;
|
||||
record.capacity = resolveCapacity(parts.model, record.capacity, status);
|
||||
record.bikesAvailable = parts.model.toNonNegativeInteger(
|
||||
status?.bikesAvailable,
|
||||
);
|
||||
record.docksAvailable = parts.model.toNonNegativeInteger(
|
||||
status?.docksAvailable,
|
||||
);
|
||||
record.isInstalled = parts.model.normalizeGbfsBool(
|
||||
status?.isInstalled,
|
||||
true,
|
||||
);
|
||||
record.isRenting = parts.model.normalizeGbfsBool(
|
||||
status?.isRenting,
|
||||
true,
|
||||
);
|
||||
record.isReturning = parts.model.normalizeGbfsBool(
|
||||
status?.isReturning,
|
||||
true,
|
||||
);
|
||||
record.point.pixelSize = capacityToPixelSize(record.capacity);
|
||||
record.point.color = statusToColor(
|
||||
parts.model,
|
||||
status,
|
||||
record.capacity,
|
||||
);
|
||||
syncPoint(record);
|
||||
}
|
||||
normalSet.commit();
|
||||
selection.refreshSelectedCard();
|
||||
},
|
||||
};
|
||||
|
||||
const selection = {
|
||||
buildSelectionLabel(record) {
|
||||
return buildBikeshareSelectionLabel(record);
|
||||
},
|
||||
createBikeshareSelectedOverlayEntry,
|
||||
refreshSelectedCard() {
|
||||
const key = state._selectedKey;
|
||||
const record = state._stationRenderMap.get(key);
|
||||
if (!record) return;
|
||||
state._overlayHost.setEntries(
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
[createBikeshareSelectedOverlayEntry(key, record)],
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
);
|
||||
},
|
||||
_clearSelection() {
|
||||
if (state._selectedKey) {
|
||||
const record = state._stationRenderMap.get(state._selectedKey);
|
||||
if (record?.point) {
|
||||
record.point.show = true;
|
||||
syncPoint(record);
|
||||
normalSet.commit();
|
||||
}
|
||||
}
|
||||
state._selectedKey = null;
|
||||
state._selectedEntity = null;
|
||||
selectedSet.setCount(0);
|
||||
selectedSet.commit();
|
||||
state._overlayHost.clearSource(BIKESHARE_SELECTED_OVERLAY_SOURCE_ID);
|
||||
request('bikeshare-selection-clear');
|
||||
},
|
||||
_selectStation(key) {
|
||||
this._clearSelection();
|
||||
const record = state._stationRenderMap.get(key);
|
||||
if (!record || !record.point?.position) return;
|
||||
state._selectedKey = key;
|
||||
record.point.show = false;
|
||||
syncPoint(record);
|
||||
normalSet.commit();
|
||||
selectedSet.setCount(1);
|
||||
selectedSet.set(0, {
|
||||
position: record.point.position,
|
||||
color: CYAN.rgba(),
|
||||
scale: 1,
|
||||
show: true,
|
||||
pickId: key,
|
||||
});
|
||||
selectedSet.commit();
|
||||
const entry = createBikeshareSelectedOverlayEntry(key, record);
|
||||
if (entry) {
|
||||
state._overlayHost.setEntries(
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
[entry],
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
);
|
||||
state._overlayHost.setVisible(
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
true,
|
||||
);
|
||||
}
|
||||
request('bikeshare-selection');
|
||||
},
|
||||
_installClickHandler() {},
|
||||
_onKeyDown(event) {
|
||||
if (event.key === 'Escape' && state._selectedKey) this._clearSelection();
|
||||
},
|
||||
};
|
||||
|
||||
const viewport = createViewport({
|
||||
...context,
|
||||
camera: {
|
||||
getCameraAltitude: () => engine.camera.height(),
|
||||
getCameraCenterLatLon() {
|
||||
const rect = engine.camera.viewRectangle();
|
||||
if (rect) {
|
||||
let east = rect.east;
|
||||
if (east < rect.west) east += Math.PI * 2;
|
||||
let longitude = (rect.west + east) / 2;
|
||||
if (longitude > Math.PI) longitude -= Math.PI * 2;
|
||||
return {
|
||||
lat: geo.math.toDegrees((rect.south + rect.north) / 2),
|
||||
lon: geo.math.toDegrees(longitude),
|
||||
};
|
||||
}
|
||||
const cartographic = engine.camera.pose()?.cartographic;
|
||||
return cartographic
|
||||
? {
|
||||
lat: geo.math.toDegrees(cartographic.latitude),
|
||||
lon: geo.math.toDegrees(cartographic.longitude),
|
||||
}
|
||||
: null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const lifecycle = {
|
||||
methods: {
|
||||
init() {
|
||||
if (destroyed) throw new Error('Bikeshare renderer is destroyed');
|
||||
state._engine = engine;
|
||||
state._viewer = null;
|
||||
state._enabled = false;
|
||||
state._cameraDebounceTimer = null;
|
||||
state._cameraChangedAttached = false;
|
||||
state._altitudeGateEnabled = false;
|
||||
state._proximityGeneration = 0;
|
||||
state._activeCityIds = new Set();
|
||||
state._cityRuntime = new Map();
|
||||
state._stationInfoCache = new Map();
|
||||
state._statusCache = new Map();
|
||||
state._inFlightInfo = new Map();
|
||||
state._inFlightStatus = new Map();
|
||||
state._stationRenderMap = new Map();
|
||||
state._clickHandler = null;
|
||||
state._selectedKey = null;
|
||||
state._selectedEntity = null;
|
||||
state._count = 0;
|
||||
state._lastUpdate = null;
|
||||
state._loading = false;
|
||||
state._loadingOps = 0;
|
||||
state._error = null;
|
||||
state._limitWarned = false;
|
||||
normalSet.setCount(0);
|
||||
selectedSet.setCount(0);
|
||||
normalSet.commit();
|
||||
selectedSet.commit();
|
||||
state._overlayHost.setVisible(
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
false,
|
||||
);
|
||||
},
|
||||
enable() {
|
||||
if (destroyed || state._enabled) return;
|
||||
state._enabled = true;
|
||||
state._error = null;
|
||||
state._overlayHost.setVisible(
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
true,
|
||||
);
|
||||
selectionRemove = selectionDispatcherFor(engine).register(LAYER_ID, {
|
||||
owns: (id) => state._stationRenderMap.has(id),
|
||||
onSelect: (id) => selection._selectStation(id),
|
||||
onEmptyClick: () => selection._clearSelection(),
|
||||
onEscape: () => selection._clearSelection(),
|
||||
});
|
||||
cameraRemove = engine.camera.moveEnd.addEventListener(() =>
|
||||
viewport.onCameraChanged(),
|
||||
);
|
||||
state._cameraChangedAttached = true;
|
||||
void viewport.runProximityCheck();
|
||||
},
|
||||
disable() {
|
||||
if (destroyed) return;
|
||||
state._enabled = false;
|
||||
state._proximityGeneration++;
|
||||
state._altitudeGateEnabled = false;
|
||||
clearTimeout(state._cameraDebounceTimer);
|
||||
state._cameraDebounceTimer = null;
|
||||
selection._clearSelection();
|
||||
state._overlayHost.setVisible(
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
false,
|
||||
);
|
||||
selectionRemove?.();
|
||||
selectionRemove = null;
|
||||
cameraRemove?.();
|
||||
cameraRemove = null;
|
||||
state._cameraChangedAttached = false;
|
||||
parts.ingestion.abortAllInFlight();
|
||||
viewport.deactivateAllCities();
|
||||
state._cityRuntime.clear();
|
||||
normalSet.setCount(0);
|
||||
selectedSet.setCount(0);
|
||||
normalSet.commit();
|
||||
selectedSet.commit();
|
||||
state._count = 0;
|
||||
state._loading = false;
|
||||
state._loadingOps = 0;
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
this.disable();
|
||||
parts.ingestion.abortAllInFlight();
|
||||
normalSet.destroy();
|
||||
selectedSet.destroy();
|
||||
state._stationInfoCache.clear();
|
||||
state._statusCache.clear();
|
||||
state._engine = null;
|
||||
state._viewer = null;
|
||||
destroyed = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { rendering, selection, viewport, lifecycle };
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
|
||||
import { createBikeshareLayer } from '../index.js';
|
||||
import { createBikeshareNavaraRenderer } from './navara.js';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
|
||||
const city = 'austin-capmetro';
|
||||
const key = `${city}:central`;
|
||||
const information = {
|
||||
data: {
|
||||
stations: [
|
||||
{
|
||||
station_id: 'central',
|
||||
name: '中央 / Central',
|
||||
lat: 30.2672,
|
||||
lon: -97.7431,
|
||||
capacity: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const status = (bikes = 7) => ({
|
||||
data: {
|
||||
stations: [
|
||||
{
|
||||
station_id: 'central',
|
||||
num_bikes_available: bikes,
|
||||
num_docks_available: 10 - bikes,
|
||||
is_installed: true,
|
||||
is_renting: true,
|
||||
is_returning: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
function fixture(getStations) {
|
||||
const engine = createFakeEngine({ kind: 'navara' });
|
||||
engine.fake.setPose({
|
||||
longitude: (-97.7431 * Math.PI) / 180,
|
||||
latitude: (30.2672 * Math.PI) / 180,
|
||||
height: 10000,
|
||||
});
|
||||
engine.fake.script('viewRectangle', null);
|
||||
const entries = new Map();
|
||||
let context, renderer;
|
||||
let bikes = 7;
|
||||
const source = {
|
||||
getStations:
|
||||
getStations ??
|
||||
(async (url) =>
|
||||
url.includes('station_information') ? information : status(bikes)),
|
||||
};
|
||||
const layer = createBikeshareLayer({
|
||||
source,
|
||||
services: {
|
||||
render: { governorRequestRender() {} },
|
||||
overlays: {
|
||||
setOverlayEntries: (id, value) => entries.set(id, value),
|
||||
setOverlaySourceVisible() {},
|
||||
clearOverlaySource: (id) => entries.delete(id),
|
||||
},
|
||||
},
|
||||
rendererFactory(value) {
|
||||
context = value;
|
||||
renderer = createBikeshareNavaraRenderer(engine, value);
|
||||
return renderer;
|
||||
},
|
||||
});
|
||||
layer.init(null, { engine });
|
||||
return {
|
||||
engine,
|
||||
layer,
|
||||
entries,
|
||||
context,
|
||||
renderer,
|
||||
setBikes: (value) => {
|
||||
bikes = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('Navara GBFS loads real parsed stations, picks stable IDs and refreshes a selected status card', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
f.layer.enable();
|
||||
await settle();
|
||||
assert.equal(f.layer.getStats().count, 1);
|
||||
const normal = f.engine.fake.moverSet('bikeshare');
|
||||
const selected = f.engine.fake.moverSet('bikeshare-selected');
|
||||
assert.equal(normal.options.sharpen, true);
|
||||
assert.equal(selected.options.sharpen, true);
|
||||
assert.equal(normal.store.pickIds[0], key);
|
||||
assert.deepEqual(
|
||||
normal.options.point.translucencyByDistance,
|
||||
[200, 1, 180000, 0.15],
|
||||
);
|
||||
assert.equal(selected.options.point.scaleByDistance, null);
|
||||
assert.deepEqual(selected.options.point.outlineColor, [0, 0, 0, 1]);
|
||||
f.engine.fake.script('pick', { id: key });
|
||||
f.engine.fake.input('click', { position: { x: 100, y: 100 } });
|
||||
assert.equal(selected.store.count, 1);
|
||||
assert.equal(normal.store.shows[0], 0);
|
||||
assert.equal(
|
||||
f.entries.get('bikeshare-selected')[0].title,
|
||||
'中央 / Central',
|
||||
);
|
||||
f.setBikes(2);
|
||||
await f.layer.update();
|
||||
assert.match(f.entries.get('bikeshare-selected')[0].details[0], /2 avail/);
|
||||
assert.equal(f.layer.getAnalystRecords()[0].bikesAvailable, 2);
|
||||
f.engine.fake.script('pick', null);
|
||||
f.engine.fake.input('click', { position: { x: 100, y: 100 } });
|
||||
assert.equal(selected.store.count, 0);
|
||||
assert.equal(normal.store.shows[0], 1);
|
||||
assert.equal(f.entries.size, 0);
|
||||
} finally {
|
||||
f.layer.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('camera callbacks preserve hysteresis and ten enable/disable cycles leave no owned data or listeners', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
for (let cycle = 0; cycle < 10; cycle++) {
|
||||
f.layer.enable();
|
||||
f.layer.enable();
|
||||
await settle();
|
||||
assert.equal(f.layer.getStats().count, 1);
|
||||
f.engine.fake.emit('camera.moveEnd');
|
||||
assert.ok(f.context.state._cameraDebounceTimer);
|
||||
f.layer.disable();
|
||||
assert.equal(f.context.state._cameraDebounceTimer, null);
|
||||
assert.equal(f.context.state._cameraChangedAttached, false);
|
||||
assert.equal(f.layer.getStats().count, 0);
|
||||
assert.deepEqual(f.layer.getDetectableObjects(), []);
|
||||
assert.equal(f.engine.fake.moverSet('bikeshare').store.count, 0);
|
||||
f.engine.fake.emit('camera.moveEnd');
|
||||
assert.equal(f.context.state._cameraDebounceTimer, null);
|
||||
}
|
||||
const gate = f.renderer.viewport.shouldActivateAtAltitude;
|
||||
assert.equal(gate(49000), false);
|
||||
assert.equal(gate(48000), true);
|
||||
assert.equal(gate(51000), true);
|
||||
assert.equal(gate(52000), false);
|
||||
} finally {
|
||||
f.layer.destroy();
|
||||
f.layer.destroy();
|
||||
}
|
||||
assert.deepEqual(f.engine.movers.list(), []);
|
||||
});
|
||||
|
||||
test('an aborted response cannot repopulate caches or clear a newer request loading state', async () => {
|
||||
const pending = [];
|
||||
const f = fixture(
|
||||
(url, { signal }) =>
|
||||
new Promise((resolve) => pending.push({ url, signal, resolve })),
|
||||
);
|
||||
f.layer.enable();
|
||||
assert.equal(pending.length, 1);
|
||||
f.layer.disable();
|
||||
assert.equal(pending[0].signal.aborted, true);
|
||||
f.layer.enable();
|
||||
assert.equal(pending.length, 2);
|
||||
pending[0].resolve(information);
|
||||
await settle();
|
||||
assert.equal(f.context.state._stationInfoCache.size, 0);
|
||||
assert.equal(f.layer.getStats().loading, true);
|
||||
f.layer.destroy();
|
||||
pending[1].resolve(information);
|
||||
await settle();
|
||||
assert.equal(f.context.state._stationInfoCache.size, 0);
|
||||
assert.equal(f.layer.getStats().count, 0);
|
||||
assert.deepEqual(f.engine.movers.list(), []);
|
||||
});
|
||||
|
||||
test('station positions sample the visible surface with own point sets excluded', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
f.engine.fake.script('sampleHeight', 175);
|
||||
f.layer.enable();
|
||||
await settle();
|
||||
const point = f.layer.getDetectableObjects()[0].position;
|
||||
const height = geo.cartographic.fromCartesian(point).height;
|
||||
assert.ok(Math.abs(height - 177) < 1e-6);
|
||||
const call = f.engine.fake.callsOf('surface.sampleHeight')[0];
|
||||
assert.deepEqual(
|
||||
call.args[1].map((set) => set.id),
|
||||
['bikeshare', 'bikeshare-selected'],
|
||||
);
|
||||
} finally {
|
||||
f.layer.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
} from './policy.js';
|
||||
|
||||
export function buildBikeshareSelectionLabel(record) {
|
||||
const stationName = String(record?.stationName || '').trim();
|
||||
const stationLabel =
|
||||
stationName ||
|
||||
(record?.stationId ? `Station ${record.stationId}` : 'Station');
|
||||
const bikes = Number.isFinite(record?.bikesAvailable)
|
||||
? record.bikesAvailable
|
||||
: '?';
|
||||
const docks = Number.isFinite(record?.docksAvailable)
|
||||
? record.docksAvailable
|
||||
: '?';
|
||||
const capacity = Number.isFinite(record?.capacity) ? record.capacity : '?';
|
||||
const lines = [
|
||||
stationLabel,
|
||||
`🚲 ${bikes} avail · ${docks} docks · ${capacity} cap`,
|
||||
];
|
||||
const abnormal = [];
|
||||
if (record?.isInstalled === false) abnormal.push('⚠️ Not installed');
|
||||
if (record?.isRenting === false) abnormal.push('⚠️ Not renting');
|
||||
if (record?.isReturning === false) abnormal.push('⚠️ Not returning');
|
||||
if (abnormal.length) lines.push(abnormal.join(' · '));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function createBikeshareSelectedOverlayEntry(key, record) {
|
||||
const position = record?.point?.position;
|
||||
if (!key || !position) return null;
|
||||
const [title, ...details] = buildBikeshareSelectionLabel(record).split('\n');
|
||||
return {
|
||||
id: String(key),
|
||||
position,
|
||||
variant: 'selected',
|
||||
selected: true,
|
||||
protected: true,
|
||||
paintLane: 'selected',
|
||||
collisionGroup: 'ambient-card',
|
||||
priority: Number.MAX_SAFE_INTEGER,
|
||||
title,
|
||||
details,
|
||||
accent: '#00ffff',
|
||||
interactive: false,
|
||||
anchorRadiusPx: 9,
|
||||
minAnchorGapPx: 11,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: false,
|
||||
terrainOcclusion: false,
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
};
|
||||
@@ -1,5 +1,3 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { GBFS_CITY_REGISTRY } from './registry.js';
|
||||
import {
|
||||
ACTIVATION_EXIT_ALTITUDE_M,
|
||||
@@ -8,53 +6,10 @@ import {
|
||||
CAMERA_DEBOUNCE_MS,
|
||||
} from './policy.js';
|
||||
|
||||
export function createViewport({ state: layerState, services, parts, source }) {
|
||||
export function createViewport({ state: layerState, services, parts, camera }) {
|
||||
const { getCameraAltitude, getCameraCenterLatLon } = camera;
|
||||
const { governorRequestRender } = services.render;
|
||||
|
||||
/**
|
||||
* Get the camera's current altitude in meters above the ellipsoid.
|
||||
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
|
||||
* @returns {number} Altitude in meters, or Infinity if unavailable.
|
||||
*/
|
||||
|
||||
function getCameraAltitude(viewer) {
|
||||
const carto = viewer?.camera?.positionCartographic;
|
||||
return carto && Number.isFinite(carto.height) ? carto.height : Infinity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the lat/lon the camera is currently looking at.
|
||||
* Prefers the center of the computed view rectangle; falls back to the
|
||||
* camera's own cartographic position when the rectangle is unavailable.
|
||||
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
|
||||
* @returns {{ lat: number, lon: number }|null} Center coordinates in degrees, or null.
|
||||
*/
|
||||
|
||||
function getCameraCenterLatLon(viewer) {
|
||||
// Try view rectangle center first (more accurate for tilted views)
|
||||
const rect = viewer?.camera?.computeViewRectangle?.(
|
||||
viewer.scene.globe?.ellipsoid,
|
||||
);
|
||||
if (rect) {
|
||||
const center = Cesium.Rectangle.center(rect);
|
||||
return {
|
||||
lat: geo.math.toDegrees(center.latitude),
|
||||
lon: geo.math.toDegrees(center.longitude),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: use the camera's own position
|
||||
const carto = viewer?.camera?.positionCartographic;
|
||||
if (carto) {
|
||||
return {
|
||||
lat: geo.math.toDegrees(carto.latitude),
|
||||
lon: geo.math.toDegrees(carto.longitude),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the layer should be active at the given camera altitude.
|
||||
* Uses hysteresis (separate enter/exit thresholds) to avoid rapid toggling
|
||||
@@ -171,7 +126,12 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
layerState._lastUpdate = Date.now();
|
||||
layerState._error = null;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return;
|
||||
if (
|
||||
error?.name === 'AbortError' ||
|
||||
!layerState._enabled ||
|
||||
generation !== layerState._proximityGeneration
|
||||
)
|
||||
return;
|
||||
console.warn(`[Data:Bikeshare] ${cityId} activate error:`, error);
|
||||
layerState._error = 'GBFS fetch error';
|
||||
deactivateCity(cityId);
|
||||
@@ -189,7 +149,7 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
*/
|
||||
|
||||
async function runProximityCheck() {
|
||||
if (!layerState._enabled || !layerState._viewer) return;
|
||||
if (!layerState._enabled) return;
|
||||
|
||||
const generation = ++layerState._proximityGeneration;
|
||||
const altitude = getCameraAltitude(layerState._viewer);
|
||||
@@ -233,6 +193,7 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
function scheduleProximityCheck() {
|
||||
clearTimeout(layerState._cameraDebounceTimer);
|
||||
layerState._cameraDebounceTimer = setTimeout(() => {
|
||||
layerState._cameraDebounceTimer = null;
|
||||
void runProximityCheck();
|
||||
}, CAMERA_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
@@ -3565,7 +3565,7 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve
|
||||
const { createLifecycle: trafficLifecycle } =
|
||||
await import('../traffic/lifecycle.js');
|
||||
const { createLifecycle: bikeLifecycle } =
|
||||
await import('../bikeshare/lifecycle.js');
|
||||
await import('../bikeshare/render/cesium/lifecycle.js');
|
||||
const app = harness(t);
|
||||
const noop = () => {};
|
||||
const traffic = trafficLifecycle({
|
||||
@@ -3583,6 +3583,7 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve
|
||||
}).methods;
|
||||
const bike = bikeLifecycle({
|
||||
state: {
|
||||
_viewer: app.viewer,
|
||||
_pointCollection: {},
|
||||
_overlayHost: { setVisible: noop },
|
||||
_cityRuntime: new Map(),
|
||||
|
||||
@@ -335,6 +335,7 @@ test('layers moved onto renderers keep each engine in its render file', (t) => {
|
||||
"import 'three'; import '@navaramap/three';",
|
||||
);
|
||||
write('src/layers/aircraft/render/cesium.js', "import 'cesium';");
|
||||
write('src/layers/bikeshare/render/cesium/lifecycle.js', "import 'cesium';");
|
||||
write('src/layers/flights/render/cesium.test.mjs', "import 'cesium';");
|
||||
write(
|
||||
'src/app/navaraMode.js',
|
||||
|
||||
Reference in new issue
Block a user