From 7082a020572a7a7329364d9fa79de3e45196bf45 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 13 Nov 2026 17:14:33 +0000 Subject: [PATCH] feat(transit): show feed coverage as a glow at globe view Above the altitude gate the layer draws no vehicles, so nothing showed where Transit has data. One soft spot per enabled feed, hotter where networks cluster, fading in across the gate. Built from the registry; no feed is polled for it. --- changelog.d/1716-transit-french-networks.md | 5 + scripts/format-scope.json | 3 + scripts/package-boundaries.json | 8 ++ src/data/transitCoverage.js | 92 +++++++++++++ src/data/transitCoverage.test.mjs | 114 ++++++++++++++++ src/layers/transit/coverage.js | 127 ++++++++++++++++++ src/layers/transit/index.js | 2 + src/layers/transit/lifecycle.js | 3 + src/layers/transit/policy.js | 31 +++++ .../transit/refreshErrorRepaint.test.mjs | 1 + src/layers/transit/state.js | 3 + src/layers/transit/viewport.js | 3 + 12 files changed, 392 insertions(+) create mode 100644 src/data/transitCoverage.js create mode 100644 src/data/transitCoverage.test.mjs create mode 100644 src/layers/transit/coverage.js diff --git a/changelog.d/1716-transit-french-networks.md b/changelog.d/1716-transit-french-networks.md index 8f2427b79..e566e7fc6 100644 --- a/changelog.d/1716-transit-french-networks.md +++ b/changelog.d/1716-transit-french-networks.md @@ -18,3 +18,8 @@ - Add 55 Japanese bus networks to the Transit layer, the first in Toyama, Mie, Kumamoto, Kōchi, Aichi, Okinawa and Aomori prefectures, each recorded under CC0 1.0 or CC BY 4.0 with the operator credited in Japanese (#1716). +- Show where Transit has coverage from globe view. Above about 3,000 km, + where the layer draws no vehicles, each feed is a soft green spot and + regions with many networks glow hotter; the spots fade out as the camera + comes down to the height where vehicles appear. Drawn from the feed + registry, so no feed is polled for it (#1716). diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 0761842bc..310e38473 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -803,6 +803,9 @@ "src/data/rioRouteNames.js", "src/data/transitFeedsJapan.js", "src/data/transitFeedsJapan.test.mjs", + "src/data/transitCoverage.js", + "src/data/transitCoverage.test.mjs", + "src/layers/transit/coverage.js", "src/data/transitFeedsCatalogue.js", "src/data/transitFeedsCatalogue.test.mjs", "src/data/transitFeedsFrance.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 41ddf9a3a..57e1dd0a5 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3322,6 +3322,8 @@ "src/layers/transit/state.js", "src/layers/transit/testing.js", "src/layers/transit/viewport.js", + "src/layers/transit/coverage.js", + "src/data/transitCoverage.js", "src/data/cameraSensitivity.js", "src/data/contactPlayback.js", "src/data/contactTrailRenderer.js", @@ -4309,6 +4311,8 @@ "src/layers/transit/state.js", "src/layers/transit/testing.js", "src/layers/transit/viewport.js", + "src/layers/transit/coverage.js", + "src/data/transitCoverage.js", "src/data/cameraSensitivity.js", "src/data/contactPlayback.js", "src/data/contactTrailRenderer.js", @@ -5228,6 +5232,8 @@ "src/layers/transit/testing.js", "src/layers/transit/trails.js", "src/layers/transit/viewport.js", + "src/layers/transit/coverage.js", + "src/data/transitCoverage.js", "src/overlays/worldOverlay.js", "src/overlays/worldOverlayDraw.js", "src/overlays/worldOverlayTokens.js", @@ -5292,6 +5298,8 @@ "src/layers/transit/state.js", "src/layers/transit/testing.js", "src/layers/transit/viewport.js", + "src/layers/transit/coverage.js", + "src/data/transitCoverage.js", "src/data/cameraSensitivity.js", "src/data/contactPlayback.js", "src/data/contactTrailRenderer.js", diff --git a/src/data/transitCoverage.js b/src/data/transitCoverage.js new file mode 100644 index 000000000..13b7ede8a --- /dev/null +++ b/src/data/transitCoverage.js @@ -0,0 +1,92 @@ +/** + * Coverage points for the Transit layer's globe view: one soft spot per + * registered feed, hotter where networks cluster. + * + * Built from the registry alone. No feed is polled to draw it, so it says + * where vehicles CAN be shown, not where any are right now. + * + * Pure: no Cesium, no DOM. + */ + +import { greatCircleKm } from '../geoDistance.js'; + +/** Feeds within this distance of a feed count as its neighbours. */ +export const COVERAGE_NEIGHBOUR_KM = 150; + +/** Spot diameter in CSS px: a lone feed, and the densest cluster. */ +export const COVERAGE_SPOT_PX = Object.freeze({ lone: 18, dense: 34 }); + +/** + * The bus green of the vehicle markers, from a deep green for a lone network + * to a pale core where many overlap. The lone end is saturated on purpose: a + * light translucent green vanishes into pale basemaps and desert imagery. + */ +export const COVERAGE_PALETTE = Object.freeze([ + { at: 0.0, rgb: [24, 196, 106] }, + { at: 0.5, rgb: [94, 240, 138] }, + { at: 1.0, rgb: [206, 255, 196] }, +]); + +/** + * Palette colour at a normalized heat. + * @param {number} t 0..1 (clamped) + * @returns {[number, number, number]} RGB, 0..255. + */ +export function coverageColorAt(t) { + const v = Math.min(1, Math.max(0, Number(t) || 0)); + let i = 1; + while (i < COVERAGE_PALETTE.length - 1 && COVERAGE_PALETTE[i].at < v) i += 1; + const a = COVERAGE_PALETTE[i - 1]; + const b = COVERAGE_PALETTE[i]; + const k = (v - a.at) / (b.at - a.at || 1); + return [0, 1, 2].map((c) => Math.round(a.rgb[c] + (b.rgb[c] - a.rgb[c]) * k)); +} + +/** + * One coverage point per feed that has a centre. + * + * `heat` is logarithmic in the number of neighbours and normalized against + * the densest feed, so a lone network is always 0 (the same deep green and + * the same size) however crowded the busiest region is, and a hundred French + * networks do not wash out one in Bismarck. + * + * @param {ReadonlyArray<{id?: string, center: {lat: number, lon: number}}>} feeds + * @param {{neighbourKm?: number}} [options] + * @returns {Array<{id: string|null, lat: number, lon: number, neighbours: number, + * heat: number, sizePx: number, rgb: [number, number, number]}>} + */ +export function buildTransitCoveragePoints( + feeds, + { neighbourKm = COVERAGE_NEIGHBOUR_KM } = {}, +) { + const centred = (feeds || []).filter( + (feed) => + Number.isFinite(feed?.center?.lat) && Number.isFinite(feed?.center?.lon), + ); + const neighbours = centred.map( + (feed) => + centred.filter( + (other) => + other !== feed && + greatCircleKm(feed.center, other.center) <= neighbourKm, + ).length, + ); + const densest = Math.max(0, ...neighbours); + return centred.map((feed, index) => { + const heat = densest + ? Math.log1p(neighbours[index]) / Math.log1p(densest) + : 0; + return { + id: feed.id ?? null, + lat: feed.center.lat, + lon: feed.center.lon, + neighbours: neighbours[index], + heat, + sizePx: Math.round( + COVERAGE_SPOT_PX.lone + + (COVERAGE_SPOT_PX.dense - COVERAGE_SPOT_PX.lone) * heat, + ), + rgb: coverageColorAt(heat), + }; + }); +} diff --git a/src/data/transitCoverage.test.mjs b/src/data/transitCoverage.test.mjs new file mode 100644 index 000000000..1b168955f --- /dev/null +++ b/src/data/transitCoverage.test.mjs @@ -0,0 +1,114 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + COVERAGE_SPOT_PX, + buildTransitCoveragePoints, + coverageColorAt, +} from './transitCoverage.js'; +import { TRANSIT_ENABLED_FEEDS } from './transitFeeds.js'; +import { + ACTIVATION_ENTER_ALTITUDE_M, + ACTIVATION_EXIT_ALTITUDE_M, + COVERAGE_FADE, + COVERAGE_PEAK_ALPHA, + coverageOpacityAt, +} from '../layers/transit/policy.js'; + +const feed = (id, lat, lon) => ({ id, center: { lat, lon } }); + +test('an empty registry gives no points, and a feed without a centre is skipped', () => { + assert.deepEqual(buildTransitCoveragePoints([]), []); + assert.deepEqual(buildTransitCoveragePoints(null), []); + const points = buildTransitCoveragePoints([ + feed('a', 10, 10), + { id: 'no-centre' }, + { id: 'bad', center: { lat: NaN, lon: 3 } }, + ]); + assert.deepEqual( + points.map((p) => p.id), + ['a'], + ); +}); + +test('a lone feed is the smallest, deepest spot', () => { + const [point] = buildTransitCoveragePoints([feed('lone', 46.8, -100.8)]); + assert.equal(point.neighbours, 0); + assert.equal(point.heat, 0); + assert.equal(point.sizePx, COVERAGE_SPOT_PX.lone); + assert.deepEqual(point.rgb, coverageColorAt(0)); +}); + +test('a lone feed looks the same however dense the busiest region is', () => { + const lone = feed('lone', 46.8, -100.8); + const cluster = Array.from({ length: 40 }, (_, i) => + feed(`c${i}`, 48 + (i % 8) * 0.1, 2 + Math.floor(i / 8) * 0.1), + ); + const alone = buildTransitCoveragePoints([lone])[0]; + const crowded = buildTransitCoveragePoints([lone, ...cluster]); + assert.deepEqual( + crowded.find((p) => p.id === 'lone'), + alone, + ); + const hottest = crowded.find((p) => p.id === 'c0'); + assert.equal(hottest.neighbours, 39); + assert.equal(hottest.heat, 1); + assert.equal(hottest.sizePx, COVERAGE_SPOT_PX.dense); +}); + +test('neighbours are counted by distance, across the antimeridian too', () => { + const points = buildTransitCoveragePoints([ + feed('west', 0, 179.8), + feed('east', 0, -179.8), + feed('far', 0, 170), + ]); + const by = Object.fromEntries(points.map((p) => [p.id, p.neighbours])); + assert.deepEqual(by, { west: 1, east: 1, far: 0 }); +}); + +test('the palette runs from the lone green to a pale core and clamps', () => { + assert.deepEqual(coverageColorAt(-1), coverageColorAt(0)); + assert.deepEqual(coverageColorAt(2), coverageColorAt(1)); + assert.deepEqual(coverageColorAt(NaN), coverageColorAt(0)); + const brightness = (t) => coverageColorAt(t).reduce((a, b) => a + b, 0); + let previous = 0; + for (let t = 0; t <= 1.0001; t += 0.1) { + assert.ok(brightness(t) >= previous, `does not darken at ${t.toFixed(1)}`); + previous = brightness(t); + } +}); + +test('every enabled feed gets exactly one spot, and dense regions read hotter', () => { + const points = buildTransitCoveragePoints(TRANSIT_ENABLED_FEEDS); + assert.equal(points.length, TRANSIT_ENABLED_FEEDS.length); + assert.equal(new Set(points.map((p) => p.id)).size, points.length); + const heat = (id) => points.find((p) => p.id === id).heat; + assert.ok(heat('axo-creil') > heat('mbta'), 'the Oise outranks Boston'); + for (const point of points) { + assert.ok(point.heat >= 0 && point.heat <= 1); + assert.ok( + point.sizePx >= COVERAGE_SPOT_PX.lone && + point.sizePx <= COVERAGE_SPOT_PX.dense, + ); + } +}); + +test('the glow fades in across the altitude gate and is off near the ground', () => { + assert.equal(coverageOpacityAt(0), 0); + assert.equal(coverageOpacityAt(COVERAGE_FADE.from), 0); + assert.equal(coverageOpacityAt(COVERAGE_FADE.to), 1); + assert.equal(coverageOpacityAt(20_000_000), 1); + assert.equal(coverageOpacityAt(Infinity), 1); + assert.equal(coverageOpacityAt(NaN), 0); + // Never a height with neither vehicles nor glow: the fade has begun before + // the layer stops polling, on the way up and on the way down. + assert.ok(COVERAGE_FADE.from < ACTIVATION_ENTER_ALTITUDE_M); + assert.ok(coverageOpacityAt(ACTIVATION_ENTER_ALTITUDE_M) > 0); + assert.ok(coverageOpacityAt(ACTIVATION_EXIT_ALTITUDE_M) > 0.5); + let previous = 0; + for (let h = 1_000_000; h <= 4_000_000; h += 50_000) { + const opacity = coverageOpacityAt(h); + assert.ok(opacity >= previous, `opacity does not fall at ${h} m`); + previous = opacity; + } + assert.ok(COVERAGE_PEAK_ALPHA > 0.5 && COVERAGE_PEAK_ALPHA <= 1); +}); diff --git a/src/layers/transit/coverage.js b/src/layers/transit/coverage.js new file mode 100644 index 000000000..88eac2631 --- /dev/null +++ b/src/layers/transit/coverage.js @@ -0,0 +1,127 @@ +import * as Cesium from 'cesium'; +import { TRANSIT_ENABLED_FEEDS } from '../../data/transitFeeds.js'; +import { buildTransitCoveragePoints } from '../../data/transitCoverage.js'; +import { COVERAGE_PEAK_ALPHA, coverageOpacityAt } from './policy.js'; + +/** Sprite edge in px; drawn smaller, so the falloff stays smooth. */ +const SPRITE_PX = 64; +/** + * Height of the spots above the ellipsoid, metres. Clear of every mountain + * the photoreal mesh can raise under them, and still nothing at the heights + * the glow is shown from. The depth test stays on, so the far side of the + * globe hides its own spots. + */ +const SPOT_HEIGHT_M = 12_000; + +/** + * A white radial falloff; each billboard tints it with its own colour. Null + * where there is no canvas to draw on (a headless host), and the glow is + * then simply absent. + */ +function glowSprite() { + if (typeof globalThis.document?.createElement !== 'function') return null; + const canvas = globalThis.document.createElement('canvas'); + canvas.width = SPRITE_PX; + canvas.height = SPRITE_PX; + const ctx = canvas.getContext?.('2d'); + if (!ctx) return null; + const half = SPRITE_PX / 2; + const gradient = ctx.createRadialGradient(half, half, 0, half, half, half); + gradient.addColorStop(0, 'rgba(255,255,255,1)'); + gradient.addColorStop(0.35, 'rgba(255,255,255,0.85)'); + gradient.addColorStop(0.7, 'rgba(255,255,255,0.3)'); + gradient.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, SPRITE_PX, SPRITE_PX); + return canvas; +} + +/** + * The globe-view coverage glow. + * + * Above the altitude gate the layer polls nothing and draws no vehicles, so + * a person looking at the whole Earth had no way to tell where Transit has + * anything to show. This draws one soft spot per registered feed, built once + * from the registry, that fades in as the camera climbs past the height + * where vehicles stop being legible and fades out again on the way down. + * Overlapping spots blend, so regions with many networks read hotter. It + * never polls a feed. + * + * Spots are billboards, not draped imagery: the photoreal tiles do not take + * a draped raster, and a glow that only showed on flat basemaps would be + * missing in the default map. + * + * @param {object} context + * @returns {object} + */ +export function createCoverage({ state }) { + function build() { + const image = glowSprite(); + if (!image || !state._viewer?.scene?.primitives) return null; + const collection = new Cesium.BillboardCollection({ + blendOption: Cesium.BlendOption.TRANSLUCENT, + }); + for (const point of buildTransitCoveragePoints(TRANSIT_ENABLED_FEEDS)) { + const [r, g, b] = point.rgb; + collection.add({ + position: Cesium.Cartesian3.fromDegrees( + point.lon, + point.lat, + SPOT_HEIGHT_M, + ), + image, + width: point.sizePx, + height: point.sizePx, + color: Cesium.Color.fromBytes(r, g, b, 0), + }); + } + state._viewer.scene.primitives.add(collection); + return collection; + } + + function paint(opacity) { + const collection = state._coverageSpots; + const alpha = opacity * COVERAGE_PEAK_ALPHA; + for (let i = 0; i < collection.length; i++) { + const spot = collection.get(i); + spot.color = Cesium.Color.clone(spot.color).withAlpha(alpha); + } + collection.show = opacity > 0; + } + + /** + * Match the glow to the camera. Cheap enough for every camera event: the + * spots are built on the first call that needs them and never again, and + * they are restyled only when the quantized opacity moves. + * @param {number} altitude Camera height above the ellipsoid, metres. + */ + function sync(altitude) { + if (!state._enabled || !state._viewer) return; + const opacity = coverageOpacityAt(altitude); + if (opacity === state._coverageOpacity) return; + // Nothing built and nothing to show: do not build for a session that + // never leaves street level. + if (opacity === 0 && !state._coverageSpots) { + state._coverageOpacity = 0; + return; + } + if (!state._coverageSpots) state._coverageSpots = build(); + state._coverageOpacity = opacity; + if (state._coverageSpots) paint(opacity); + } + + function hide() { + state._coverageOpacity = null; + if (state._coverageSpots) state._coverageSpots.show = false; + } + + function destroy() { + state._coverageOpacity = null; + if (state._coverageSpots) { + state._viewer?.scene?.primitives?.remove(state._coverageSpots); + state._coverageSpots = null; + } + } + + return { sync, hide, destroy }; +} diff --git a/src/layers/transit/index.js b/src/layers/transit/index.js index 171e0fb82..288043f36 100644 --- a/src/layers/transit/index.js +++ b/src/layers/transit/index.js @@ -6,6 +6,7 @@ import { createRendering } from './rendering.js'; import { createSelection } from './selection.js'; import { createIngestion } from './ingestion.js'; import { createViewport } from './viewport.js'; +import { createCoverage } from './coverage.js'; import { createQueries } from './queries.js'; import { createControls } from './controls.js'; import { createLifecycle } from './lifecycle.js'; @@ -49,6 +50,7 @@ export function createTransitLayer({ parts.selection = createSelection(context); parts.rendering = createRendering(context); parts.ingestion = createIngestion(context); + parts.coverage = createCoverage(context); parts.viewport = createViewport(context); parts.queries = createQueries(context); parts.controls = createControls(context); diff --git a/src/layers/transit/lifecycle.js b/src/layers/transit/lifecycle.js index 90210063a..f8784c360 100644 --- a/src/layers/transit/lifecycle.js +++ b/src/layers/transit/lifecycle.js @@ -234,6 +234,7 @@ export function createLifecycle({ state, services, parts }) { state._preRenderRemove = null; } parts.ingestion.abortAllInFlight(); + parts.coverage.hide(); state._activeFeeds.clear(); state._feedStatus.clear(); for (const entry of state._vehicles.values()) { @@ -267,6 +268,7 @@ export function createLifecycle({ state, services, parts }) { async update() { if (!state._enabled) return; parts.ingestion.sweepAgedVehicles(Date.now()); + parts.coverage.sync(parts.viewport.getCameraAltitude()); if (state._activeFeeds.size === 0) return; const generation = state._generation; await Promise.all( @@ -282,6 +284,7 @@ export function createLifecycle({ state, services, parts }) { */ destroy(viewer) { this.disable(viewer); + parts.coverage.destroy(); if (state._markers) { unregisterSpriteCollection('transit', state._markers); viewer?.scene?.primitives?.remove(state._markers); diff --git a/src/layers/transit/policy.js b/src/layers/transit/policy.js index eabe5df4a..eaa0d8a33 100644 --- a/src/layers/transit/policy.js +++ b/src/layers/transit/policy.js @@ -31,6 +31,37 @@ export const TRANSIT_POLL_MS = 15_000; */ /** Camera altitude (m) above which the layer idles: a national fleet still reads at 3,000 km. */ export const ACTIVATION_ALTITUDE_M = 3_000_000; +/** + * Camera heights (m) over which the globe-view coverage glow fades in: none + * below `from`, where vehicles themselves are what a person reads, and full + * by `to`, just past the height at which the layer stops polling. The fade + * straddles the altitude gate so there is never a height with neither + * vehicles nor glow. + */ +export const COVERAGE_FADE = Object.freeze({ from: 1_800_000, to: 3_400_000 }); +const COVERAGE_FADE_STEPS = 20; +/** Alpha of a coverage spot at full fade; overlapping spots add up past it. */ +export const COVERAGE_PEAK_ALPHA = 0.8; + +/** + * Opacity of the coverage glow at a camera height, quantized so a slow zoom + * does not restyle the imagery layer on every camera event. + * @param {number} altitude Camera height above the ellipsoid, metres. + * @returns {number} 0..1 + */ +export function coverageOpacityAt(altitude) { + if (!Number.isFinite(altitude)) return altitude === Infinity ? 1 : 0; + const t = Math.min( + 1, + Math.max( + 0, + (altitude - COVERAGE_FADE.from) / (COVERAGE_FADE.to - COVERAGE_FADE.from), + ), + ); + return ( + Math.round(t * t * (3 - 2 * t) * COVERAGE_FADE_STEPS) / COVERAGE_FADE_STEPS + ); +} export const ACTIVATION_ENTER_ALTITUDE_M = ACTIVATION_ALTITUDE_M - 150_000; export const ACTIVATION_EXIT_ALTITUDE_M = ACTIVATION_ALTITUDE_M + 150_000; /** Debounce (ms) for camera-change proximity checks. */ diff --git a/src/layers/transit/refreshErrorRepaint.test.mjs b/src/layers/transit/refreshErrorRepaint.test.mjs index d85a4a375..7ee5e0d14 100644 --- a/src/layers/transit/refreshErrorRepaint.test.mjs +++ b/src/layers/transit/refreshErrorRepaint.test.mjs @@ -56,6 +56,7 @@ test('leaving a feed whose tick failed repaints its error away', async () => { sweepAgedVehicles() {}, }, rendering: { requestVisibility() {}, syncRenderHold() {} }, + coverage: { sync() {} }, }; parts.viewport = createViewport({ state, services, parts }); const queries = createQueries({ state, parts }).methods; diff --git a/src/layers/transit/state.js b/src/layers/transit/state.js index b58f181d5..24abfbe5c 100644 --- a/src/layers/transit/state.js +++ b/src/layers/transit/state.js @@ -42,6 +42,9 @@ export function createState({ services }) { state._cameraChangedAttached = false; state._cameraDebounceTimer = null; state._altitudeGateOpen = false; + /** Globe-view coverage glow: its billboards and the last opacity drawn. */ + state._coverageSpots = null; + state._coverageOpacity = null; /** Camera sensitivity as we found it, so disable() can hand it back. */ state._priorPercentageChanged = null; /** The value this layer actually wrote, for an identity-guarded restore. */ diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index 38315e4ae..affdb0b89 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -83,6 +83,7 @@ export function createViewport({ state, services, parts }) { if (!state._enabled || !state._viewer) return; refreshViewBounds(); const altitude = getCameraAltitude(); + parts.coverage.sync(altitude); const wasGateOpen = state._altitudeGateOpen; state._altitudeGateOpen = altitudeGateOpen(altitude); let changed = state._altitudeGateOpen !== wasGateOpen; @@ -132,6 +133,8 @@ export function createViewport({ state, services, parts }) { // A person dragging the globe should not wait a third of a second for the // vehicles under the cursor to start moving again. refreshViewBounds(); + // The glow follows the zoom as it happens, not a third of a second later. + parts.coverage.sync(getCameraAltitude()); clearTimeout(state._cameraDebounceTimer); state._cameraDebounceTimer = setTimeout(() => { state._cameraDebounceTimer = null;