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();
}
+6
View File
@@ -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 },
});
}
+2
View File
@@ -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',
+3
View File
@@ -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');
+1 -1
View File
@@ -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', () => {
+4 -1
View File
@@ -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',
+1
View File
@@ -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 = [];
+1 -1
View File
@@ -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,
+1
View File
@@ -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;
+14 -1
View File
@@ -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);
+50 -14
View File
@@ -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:
+10 -2
View File
@@ -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,
+7 -12
View File
@@ -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);
+38
View File
@@ -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);
}
});
+1 -6
View File
@@ -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)
+14
View File
@@ -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),
};
}
@@ -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);
});
@@ -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,
@@ -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 },
});
}
+408
View File
@@ -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 };
}
+196
View File
@@ -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();
}
});
+60
View File
@@ -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,
};
+10 -49
View File
@@ -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);
}
+2 -1
View File
@@ -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(),
+1
View File
@@ -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',