feat(bikeshare): render GBFS stations on Navara

This commit is contained in:
German Khokhlov committed 2026-10-28 13:12:19 +04:00
1 parent 1a694baf5a
commit d82a6f1550
32 files changed
+1394 -278

No files matched your search

+3 -1
View File
@@ -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) ||
+11 -3
View 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",
+19 -10
View File
@@ -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
View File
@@ -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();
}