diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index 802890154..f7ca6a384 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -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) || diff --git a/scripts/format-scope.json b/scripts/format-scope.json index d13106035..913fcf078 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f6e560971..e0373eece 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/scripts/qa-gbfs-names.mjs b/scripts/qa-gbfs-names.mjs index 3a367abe4..0933ad83d 100644 --- a/scripts/qa-gbfs-names.mjs +++ b/scripts/qa-gbfs-names.mjs @@ -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(); } diff --git a/src/app/layers/bikeshare.js b/src/app/layers/bikeshare.js index 590808a96..42ec8f3c6 100644 --- a/src/app/layers/bikeshare.js +++ b/src/app/layers/bikeshare.js @@ -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 }, }); } diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index 1975d85b1..40d1dfe74 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -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', diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 3eff245c0..b71cc1274 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -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'); diff --git a/src/data/bikeshare.test.mjs b/src/data/bikeshare.test.mjs index 308135d4a..22a353aea 100644 --- a/src/data/bikeshare.test.mjs +++ b/src/data/bikeshare.test.mjs @@ -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', () => { diff --git a/src/data/inputOwnership.test.mjs b/src/data/inputOwnership.test.mjs index d59fa3a60..5cc89edf5 100644 --- a/src/data/inputOwnership.test.mjs +++ b/src/data/inputOwnership.test.mjs @@ -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', diff --git a/src/globe/cesium/movers.js b/src/globe/cesium/movers.js index 3b51c4763..9597d8ce6 100644 --- a/src/globe/cesium/movers.js +++ b/src/globe/cesium/movers.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 = []; diff --git a/src/globe/contract.js b/src/globe/contract.js index 15888bcf1..fc89aa2a9 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -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, diff --git a/src/globe/movers.js b/src/globe/movers.js index 80cae6b32..4a429b9b8 100644 --- a/src/globe/movers.js +++ b/src/globe/movers.js @@ -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; diff --git a/src/globe/navara/movers/moverMesh.js b/src/globe/navara/movers/moverMesh.js index 2adf4fd65..034b330d1 100644 --- a/src/globe/navara/movers/moverMesh.js +++ b/src/globe/navara/movers/moverMesh.js @@ -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, }, diff --git a/src/globe/navara/movers/moverMesh.test.mjs b/src/globe/navara/movers/moverMesh.test.mjs index fe9fd011e..52661fe33 100644 --- a/src/globe/navara/movers/moverMesh.test.mjs +++ b/src/globe/navara/movers/moverMesh.test.mjs @@ -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); diff --git a/src/layers/bikeshare/index.js b/src/layers/bikeshare/index.js index b66bbae35..aa94c95ec 100644 --- a/src/layers/bikeshare/index.js +++ b/src/layers/bikeshare/index.js @@ -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: diff --git a/src/layers/bikeshare/ingestion.js b/src/layers/bikeshare/ingestion.js index 732ec9c90..e2510cfc5 100644 --- a/src/layers/bikeshare/ingestion.js +++ b/src/layers/bikeshare/ingestion.js @@ -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, diff --git a/src/layers/bikeshare/policy.js b/src/layers/bikeshare/policy.js index 14f1edf6e..b2b29c17b 100644 --- a/src/layers/bikeshare/policy.js +++ b/src/layers/bikeshare/policy.js @@ -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); diff --git a/src/layers/bikeshare/presentation.js b/src/layers/bikeshare/presentation.js new file mode 100644 index 000000000..0e4e4a58d --- /dev/null +++ b/src/layers/bikeshare/presentation.js @@ -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; +} diff --git a/src/layers/bikeshare/presentation.test.mjs b/src/layers/bikeshare/presentation.test.mjs new file mode 100644 index 000000000..244667386 --- /dev/null +++ b/src/layers/bikeshare/presentation.test.mjs @@ -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); + } +}); diff --git a/src/layers/bikeshare/queries.js b/src/layers/bikeshare/queries.js index 550dbf030..45e0f9795 100644 --- a/src/layers/bikeshare/queries.js +++ b/src/layers/bikeshare/queries.js @@ -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) diff --git a/src/layers/bikeshare/render/cesium.js b/src/layers/bikeshare/render/cesium.js new file mode 100644 index 000000000..8ac7c2923 --- /dev/null +++ b/src/layers/bikeshare/render/cesium.js @@ -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), + }; +} diff --git a/src/layers/bikeshare/lifecycle.js b/src/layers/bikeshare/render/cesium/lifecycle.js similarity index 88% rename from src/layers/bikeshare/lifecycle.js rename to src/layers/bikeshare/render/cesium/lifecycle.js index 17af85f11..4b10af745 100644 --- a/src/layers/bikeshare/lifecycle.js +++ b/src/layers/bikeshare/render/cesium/lifecycle.js @@ -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(); }, }; diff --git a/src/layers/bikeshare/render/cesium/lifecycle.test.mjs b/src/layers/bikeshare/render/cesium/lifecycle.test.mjs new file mode 100644 index 000000000..21a8154ff --- /dev/null +++ b/src/layers/bikeshare/render/cesium/lifecycle.test.mjs @@ -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); +}); diff --git a/src/layers/bikeshare/rendering.js b/src/layers/bikeshare/render/cesium/rendering.js similarity index 69% rename from src/layers/bikeshare/rendering.js rename to src/layers/bikeshare/render/cesium/rendering.js index f0c737d1d..9c4502a01 100644 --- a/src/layers/bikeshare/rendering.js +++ b/src/layers/bikeshare/render/cesium/rendering.js @@ -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, diff --git a/src/layers/bikeshare/selection.js b/src/layers/bikeshare/render/cesium/selection.js similarity index 62% rename from src/layers/bikeshare/selection.js rename to src/layers/bikeshare/render/cesium/selection.js index 0c948281b..3c3bdeb8a 100644 --- a/src/layers/bikeshare/selection.js +++ b/src/layers/bikeshare/render/cesium/selection.js @@ -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, diff --git a/src/layers/bikeshare/render/cesium/viewport.js b/src/layers/bikeshare/render/cesium/viewport.js new file mode 100644 index 000000000..e980aaab5 --- /dev/null +++ b/src/layers/bikeshare/render/cesium/viewport.js @@ -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 }, + }); +} diff --git a/src/layers/bikeshare/render/navara.js b/src/layers/bikeshare/render/navara.js new file mode 100644 index 000000000..33ccdc79d --- /dev/null +++ b/src/layers/bikeshare/render/navara.js @@ -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 }; +} diff --git a/src/layers/bikeshare/render/navara.test.mjs b/src/layers/bikeshare/render/navara.test.mjs new file mode 100644 index 000000000..7ba043159 --- /dev/null +++ b/src/layers/bikeshare/render/navara.test.mjs @@ -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(); + } +}); diff --git a/src/layers/bikeshare/selectionModel.js b/src/layers/bikeshare/selectionModel.js new file mode 100644 index 000000000..72cf744ed --- /dev/null +++ b/src/layers/bikeshare/selectionModel.js @@ -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, +}; diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index 857be5d0d..eac0aee3b 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -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); } diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index 795bbc654..5536d4e9e 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -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(), diff --git a/src/tooling/importDirections.test.mjs b/src/tooling/importDirections.test.mjs index a35908618..f8c17a01b 100644 --- a/src/tooling/importDirections.test.mjs +++ b/src/tooling/importDirections.test.mjs @@ -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',