feat(transit): smooth coverage heat-map at every zoom, live feeds only

Draw coverage as Gaussian discs blended in the opaque pass: wide spots
from orbit cross-fading into service-area patches at regional zoom.
Translucent billboards darkened into rimmed discs under Cesium's OIT at
orbital distances. Feeds not yet seen live get no spot.
This commit is contained in:
Said Turksever committed 2026-11-13 17:55:06 +00:00
1 parent 7082a02057
commit b17bfa690f
8 files changed
+394 -145

No files matched your search

+6 -5
View File
@@ -18,8 +18,9 @@
- 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).
- Show where Transit has coverage. From orbit each feed is a soft green
spot, hotter where networks cluster; at continental and regional zoom the
spots become patches the size of each network's service area, drawn under
the vehicles; close in they are off. Drawn from the feed registry, so no
feed is polled for it, and only feeds whose live positions have been seen
are shown (#1716).
+61 -11
View File
@@ -3,7 +3,10 @@
* 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.
* where vehicles CAN be shown, not where any are right now. Only feeds whose
* live positions have been seen count: a registry row marked
* `liveSeen: false` (added while its services were not running) gets no
* spot until it is confirmed.
*
* Pure: no Cesium, no DOM.
*/
@@ -13,8 +16,36 @@ 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 });
/**
* Radius of a spot from orbit, km: a lone feed, and the densest cluster.
* Far larger than any network: at whole-globe scale a real service area is
* under a pixel, and the point is to be seen.
*/
export const COVERAGE_FAR_RADIUS_KM = Object.freeze({ lone: 150, dense: 230 });
/**
* Radius of a spot at regional zoom, km: the feed's own polling radius,
* held between these bounds so a village network is still a visible patch
* and a regional coach network does not paint a whole country.
*/
export const COVERAGE_NEAR_RADIUS_KM = Object.freeze({ min: 22, max: 75 });
/** Heat is drawn in this many steps, one draw batch each. */
export const COVERAGE_HEAT_BUCKETS = 6;
/**
* Share of full strength one spot carries: 1 for a lone network, less for one
* in a crowd. Spots in a cluster overlap, and at full strength each they
* pile into a flat white blob with visible disc edges; thinned by the square
* root of the crowd they add up to one smooth field that still brightens
* toward its centre.
* @param {number} neighbours
* @returns {number} 0..1
*/
export function coverageSpotStrength(neighbours) {
const crowd = Math.max(0, Number(neighbours) || 0);
return Number((0.3 + 0.7 / Math.sqrt(1 + crowd)).toFixed(3));
}
/**
* The bus green of the vehicle markers, from a deep green for a lone network
@@ -43,17 +74,19 @@ export function coverageColorAt(t) {
}
/**
* One coverage point per feed that has a centre.
* One coverage point per feed that has a centre and has been seen live.
*
* `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.
* networks do not wash out one in Bismarck. Heat is then stepped into
* COVERAGE_HEAT_BUCKETS buckets, which set the spot's colour and strength.
*
* @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]}>}
* heat: number, bucket: number, farRadiusKm: number, nearRadiusKm: number,
* strength: number, rgb: [number, number, number]}>}
*/
export function buildTransitCoveragePoints(
feeds,
@@ -61,7 +94,9 @@ export function buildTransitCoveragePoints(
) {
const centred = (feeds || []).filter(
(feed) =>
Number.isFinite(feed?.center?.lat) && Number.isFinite(feed?.center?.lon),
feed?.liveSeen !== false &&
Number.isFinite(feed?.center?.lat) &&
Number.isFinite(feed?.center?.lon),
);
const neighbours = centred.map(
(feed) =>
@@ -76,17 +111,32 @@ export function buildTransitCoveragePoints(
const heat = densest
? Math.log1p(neighbours[index]) / Math.log1p(densest)
: 0;
const top = COVERAGE_HEAT_BUCKETS - 1;
const bucket = Math.round(heat * top);
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,
bucket,
farRadiusKm: Math.round(
COVERAGE_FAR_RADIUS_KM.lone +
(COVERAGE_FAR_RADIUS_KM.dense - COVERAGE_FAR_RADIUS_KM.lone) * heat,
),
rgb: coverageColorAt(heat),
nearRadiusKm: Math.min(
COVERAGE_NEAR_RADIUS_KM.max,
Math.max(
COVERAGE_NEAR_RADIUS_KM.min,
Number(feed.loadRadiusKm) || COVERAGE_NEAR_RADIUS_KM.min,
),
),
// Colour and strength follow the bucket, so every spot in a batch
// shares one material.
strength: coverageSpotStrength(
Math.expm1((bucket / top) * Math.log1p(densest || 1)),
),
rgb: coverageColorAt(bucket / top),
};
});
}
+100 -24
View File
@@ -1,15 +1,18 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
COVERAGE_SPOT_PX,
COVERAGE_FAR_RADIUS_KM,
COVERAGE_HEAT_BUCKETS,
COVERAGE_NEAR_RADIUS_KM,
buildTransitCoveragePoints,
coverageColorAt,
coverageSpotStrength,
} from './transitCoverage.js';
import { TRANSIT_ENABLED_FEEDS } from './transitFeeds.js';
import {
ACTIVATION_ENTER_ALTITUDE_M,
ACTIVATION_EXIT_ALTITUDE_M,
COVERAGE_FADE,
COVERAGE_OPACITY_STOPS,
COVERAGE_PEAK_ALPHA,
coverageOpacityAt,
} from '../layers/transit/policy.js';
@@ -34,7 +37,9 @@ 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.equal(point.bucket, 0);
assert.equal(point.strength, 1);
assert.equal(point.farRadiusKm, COVERAGE_FAR_RADIUS_KM.lone);
assert.deepEqual(point.rgb, coverageColorAt(0));
});
@@ -52,7 +57,26 @@ test('a lone feed looks the same however dense the busiest region is', () => {
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);
assert.equal(hottest.bucket, COVERAGE_HEAT_BUCKETS - 1);
assert.equal(hottest.farRadiusKm, COVERAGE_FAR_RADIUS_KM.dense);
assert.ok(hottest.strength < 1, 'a spot in a crowd is thinner');
});
test('a spot in a crowd is thinner, so a cluster sums smoothly instead of clipping', () => {
assert.equal(coverageSpotStrength(0), 1);
assert.equal(coverageSpotStrength(-3), 1);
assert.equal(coverageSpotStrength(NaN), 1);
let previous = 1;
for (const crowd of [1, 3, 8, 15, 28, 100]) {
const strength = coverageSpotStrength(crowd);
assert.ok(strength < previous, `thinner with ${crowd} neighbours`);
assert.ok(strength > 0.3, 'never invisible');
// The crowd as a whole still outshines a lone feed.
assert.ok(strength * (1 + crowd) > 1);
previous = strength;
}
const [lone] = buildTransitCoveragePoints([feed('lone', 0, 0)]);
assert.equal(lone.strength, 1);
});
test('neighbours are counted by distance, across the antimeridian too', () => {
@@ -77,38 +101,90 @@ test('the palette runs from the lone green to a pale core and clamps', () => {
}
});
test('every enabled feed gets exactly one spot, and dense regions read hotter', () => {
test('a feed whose live positions have not been seen gets no spot', () => {
const points = buildTransitCoveragePoints([
feed('seen', 10, 10),
{ ...feed('unseen', 10.1, 10.1), liveSeen: false },
]);
assert.deepEqual(
points.map((p) => p.id),
['seen'],
);
assert.equal(points[0].neighbours, 0, 'an unseen feed is not a neighbour');
// The registry's own unconfirmed rows stay out; the confirmed ones are in.
const unseen = TRANSIT_ENABLED_FEEDS.filter((f) => f.liveSeen === false);
assert.ok(unseen.length > 0);
const real = new Set(
buildTransitCoveragePoints(TRANSIT_ENABLED_FEEDS).map((p) => p.id),
);
for (const row of unseen) assert.equal(real.has(row.id), false, row.id);
assert.ok(real.has('kumamoto-toshi-bus'));
assert.ok(real.has('sendai-city-bus'));
});
test('every feed seen live 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(
points.length,
TRANSIT_ENABLED_FEEDS.filter((f) => f.liveSeen !== false).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,
point.farRadiusKm >= COVERAGE_FAR_RADIUS_KM.lone &&
point.farRadiusKm <= COVERAGE_FAR_RADIUS_KM.dense,
);
assert.ok(
point.nearRadiusKm >= COVERAGE_NEAR_RADIUS_KM.min &&
point.nearRadiusKm <= COVERAGE_NEAR_RADIUS_KM.max,
);
assert.ok(point.bucket >= 0 && point.bucket < COVERAGE_HEAT_BUCKETS);
}
});
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);
test('a regional patch is the feed’s own service area, within bounds', () => {
const at = (loadRadiusKm) =>
buildTransitCoveragePoints([{ ...feed('f', 0, 0), loadRadiusKm }])[0]
.nearRadiusKm;
assert.equal(at(30), 30);
assert.equal(at(5), COVERAGE_NEAR_RADIUS_KM.min);
assert.equal(at(210), COVERAGE_NEAR_RADIUS_KM.max);
assert.equal(at(undefined), COVERAGE_NEAR_RADIUS_KM.min);
});
test('wide spots own the globe view, service-area patches the regional view, and they overlap', () => {
const far = (h) => coverageOpacityAt(h, 'far');
const near = (h) => coverageOpacityAt(h, 'near');
assert.equal(far(20_000_000), 1);
assert.equal(far(Infinity), 1);
assert.equal(near(20_000_000), 0);
assert.equal(far(500_000), 0);
assert.ok(near(500_000) >= 0.5);
assert.equal(far(0), 0);
assert.equal(near(0), 0);
assert.equal(near(60_000), 0, 'off close in, where vehicles are the picture');
assert.equal(far(NaN), 0);
assert.equal(near(NaN), 0);
assert.equal(coverageOpacityAt(20_000_000), 1, 'the default set is far');
// From 250 km up there is never a height with nothing drawn, and the
// cross-fade has no dip: the two sets together stay well visible.
for (let h = 250_000; h <= 6_000_000; h += 25_000) {
assert.ok(far(h) + near(h) >= 0.45, `visible at ${h / 1000} km`);
}
// Both sides of the altitude gate are covered.
assert.ok(
far(ACTIVATION_ENTER_ALTITUDE_M) + near(ACTIVATION_ENTER_ALTITUDE_M) > 0.5,
);
assert.ok(far(ACTIVATION_EXIT_ALTITUDE_M) > 0.5);
// The far set only grows with height.
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;
for (let h = 0; h <= 4_000_000; h += 20_000) {
assert.ok(far(h) >= previous, `far opacity does not fall at ${h} m`);
previous = far(h);
}
assert.ok(COVERAGE_PEAK_ALPHA > 0.5 && COVERAGE_PEAK_ALPHA <= 1);
assert.equal(COVERAGE_OPACITY_STOPS.far.at(-1)[1], 1);
});
+53 -1
View File
@@ -9,7 +9,9 @@
* operator in Japanese, as the operator publishes it.
*
* Most rows were added overnight in Japan, when the feeds answer with an
* empty message: each note says whether vehicle positions have been seen.
* empty message: each note says whether vehicle positions have been seen,
* and a row whose positions have not been seen carries `liveSeen: false`,
* which keeps it out of the globe-view coverage glow until it is confirmed.
* `center` is the catalogue's bounding-box centre and `loadRadiusKm` an
* estimate from the kind of network, to be tightened once vehicles are seen.
*
@@ -37,6 +39,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'kitokito-bus-imizu',
@@ -57,6 +60,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'kaetsuno-bus-himi',
@@ -77,6 +81,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'kaetsuno-bus',
@@ -97,6 +102,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'kaetsuno-world-heritage-bus',
@@ -117,6 +123,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'kamiichi-town-bus',
@@ -137,6 +144,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'kurobe-city-bus',
@@ -157,6 +165,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'kurobe-shinkansen-ikuji-bus',
@@ -177,6 +186,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'noru-my-car-namerikawa',
@@ -197,6 +207,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'nanto-city-bus',
@@ -217,6 +228,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'noran-my-car-nyuzen',
@@ -237,6 +249,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'oyabe-city-bus',
@@ -257,6 +270,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'takaoka-city-bus',
@@ -277,6 +291,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'tateyama-town-bus',
@@ -297,6 +312,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'gruun-tokoname',
@@ -317,6 +333,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'tonami-city-bus',
@@ -337,6 +354,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'asahimachi-bus',
@@ -357,6 +375,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'toyama-fuchu-community-bus',
@@ -377,6 +396,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'toyama-horikawa-minami-bus',
@@ -397,6 +417,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'toyama-jojo-community-bus',
@@ -417,6 +438,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'toyama-kureha-ikiiki-bus',
@@ -437,6 +459,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'toyama-maidohaya-bus',
@@ -457,6 +480,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'toyama-mizuhashi-fureai-bus',
@@ -477,6 +501,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'toyama-oyama-community-bus',
@@ -497,6 +522,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'toyama-yamada-community-bus',
@@ -517,6 +543,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'toyama-yatsuo-community-bus',
@@ -537,6 +564,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'uozu-citizens-bus',
@@ -557,6 +585,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'uozu-omotenashi-bus',
@@ -577,6 +606,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/publicdomain/zero/1.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'aiai-bus-chita',
@@ -597,6 +627,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/licenses/by/4.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'okage-bus-ise',
@@ -617,6 +648,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/licenses/by/4.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'keisei-bus-chiba-west-shiohama',
@@ -638,6 +670,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/licenses/by/4.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'kamoshika-go-komono',
@@ -658,6 +691,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/licenses/by/4.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'kumamoto-bus',
@@ -738,6 +772,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/licenses/by/4.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'mie-kotsu-iga',
@@ -758,6 +793,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/licenses/by/4.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'mie-kotsu-nanki',
@@ -778,6 +814,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/licenses/by/4.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'mie-kotsu-ise',
@@ -798,6 +835,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/licenses/by/4.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'mie-kotsu-kuwana',
@@ -818,6 +856,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/licenses/by/4.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'mie-kotsu-matsusaka',
@@ -838,6 +877,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/licenses/by/4.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'mie-kotsu-shima',
@@ -858,6 +898,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/licenses/by/4.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'mie-kotsu-yokkaichi',
@@ -878,6 +919,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/licenses/by/4.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'nagai-bus-maebashi',
@@ -898,6 +940,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/licenses/by/4.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'nagano-city-bus',
@@ -918,6 +961,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/licenses/by/4.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'asapi-go-owariasahi',
@@ -938,6 +982,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/licenses/by/4.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'sanko-bus-kumamoto',
@@ -978,6 +1023,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/licenses/by/4.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'tsuchimaru-bus-tsuchiura',
@@ -998,6 +1044,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/licenses/by/4.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'yanbaru-express-bus',
@@ -1018,6 +1065,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/licenses/by/4.0/deed.ja',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'kenko-hokubu-kotsu',
@@ -1038,6 +1086,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/licenses/by/4.0/',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'kochi-koryo-kotsu',
@@ -1058,6 +1107,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/licenses/by/4.0/',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'kochi-seinan-kotsu',
@@ -1078,6 +1128,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/licenses/by/4.0/',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'susaki-city-bus',
@@ -1098,6 +1149,7 @@ export const JAPAN_TRANSIT_FEED_ROWS = [
quote: 'https://creativecommons.org/licenses/by/4.0/',
note: 'The Mobility Database record gives this licence for the feed; the quote is the licence it links. Added while services were not running (2026-11-14, 01:40 Japan time): the feed answered with a well-formed, empty message, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network.',
},
liveSeen: false,
},
{
id: 'aomori-city-community-bus',
+5
View File
@@ -38,6 +38,11 @@ test('every Japanese row is a live, keyless feed under CC0 or CC BY', () => {
/Vehicle positions were seen on|its positions have not been seen yet/,
`${row.id} says whether positions were seen`,
);
assert.equal(
row.liveSeen === false,
/have not been seen yet/.test(row.terms.note),
`${row.id} is flagged exactly when its positions are unseen`,
);
assert.equal(new URL(row.url).protocol, 'https:');
assert.ok(
// Japan, Hokkaidō to Okinawa.
+120 -80
View File
@@ -3,124 +3,164 @@ 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;
/** Height of the spots above the ellipsoid, metres: on the surface. */
const SPOT_HEIGHT_M = 0;
/**
* 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.
* A Gaussian falloff from the centre of each spot to exactly zero at its
* rim. No plateau and no edge, so overlapping spots sum into one smooth
* field instead of showing where each circle ends.
*/
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;
}
const GLOW_SHADER = `
czm_material czm_getMaterial(czm_materialInput materialInput) {
czm_material material = czm_getDefaultMaterial(materialInput);
vec2 p = (materialInput.st - 0.5) * 2.0;
float sigma2 = 2.0 * 0.46 * 0.46;
float rim = exp(-1.0 / sigma2);
float glow = clamp((exp(-dot(p, p) / sigma2) - rim) / (1.0 - rim), 0.0, 1.0);
material.diffuse = color.rgb;
material.alpha = color.a * glow;
return material;
}`;
/**
* The globe-view coverage glow.
* The 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.
* anything to show. This draws one soft spot per feed seen live, built once
* from the registry: wide spots from orbit, cross-fading into patches the
* size of each feed's service area as a backdrop under the vehicles at
* continental and regional zoom, and off close in. 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.
* Spots are flat discs drawn in the OPAQUE pass with ordinary alpha
* blending, not billboards and not draped imagery. Translucent billboards
* go through Cesium's order-independent transparency, whose weights clamp at
* orbital distances and turn a faint glow into a dark disc with a rim; and
* the photoreal tiles do not take a draped raster. A disc blended in the
* opaque pass is drawn over the tiles and the globe alike, under the vehicle
* markers, and is smooth at any strength.
*
* @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,
function batch(points, radiusKm, set) {
const [r, g, b] = points[0].rgb;
const material = new Cesium.Material({
translucent: false,
fabric: {
uniforms: { color: Cesium.Color.fromBytes(r, g, b, 0) },
source: GLOW_SHADER,
},
});
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;
const vertexFormat =
Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat;
const primitive = new Cesium.Primitive({
geometryInstances: points.map(
(point) =>
new Cesium.GeometryInstance({
geometry: new Cesium.EllipseGeometry({
center: Cesium.Cartesian3.fromDegrees(point.lon, point.lat),
semiMajorAxis: radiusKm(point) * 1000,
semiMinorAxis: radiusKm(point) * 1000,
height: SPOT_HEIGHT_M,
vertexFormat,
}),
}),
),
appearance: new Cesium.MaterialAppearance({
material,
flat: true,
translucent: false,
materialSupport: Cesium.MaterialAppearance.MaterialSupport.TEXTURED,
renderState: {
// No depth test: at orbital distances the photoreal mesh is a few
// coarse facets that bulge kilometres off the ellipsoid, and a
// disc tested against it comes out speckled. Back-face culling
// does the hiding instead. A disc lies along the surface, so it
// faces away from the camera exactly when it is over the horizon.
depthTest: { enabled: false },
depthMask: false,
cull: { enabled: true, face: Cesium.CullFace.BACK },
blending: Cesium.BlendingState.ALPHA_BLEND,
},
}),
asynchronous: false,
allowPicking: false,
show: false,
});
state._viewer.scene.primitives.add(primitive);
// A regional patch is the network's real footprint, and neighbours there
// overlap far less than the wide orbit spots do, so it is not thinned.
const strength = set === 'near' ? 1 : points[0].strength;
return { primitive, material, strength, set };
}
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);
function build() {
if (!state._viewer?.scene?.primitives) return null;
const buckets = new Map();
for (const point of buildTransitCoveragePoints(TRANSIT_ENABLED_FEEDS)) {
if (!buckets.has(point.bucket)) buckets.set(point.bucket, []);
buckets.get(point.bucket).push(point);
}
collection.show = opacity > 0;
const batches = [];
for (const points of buckets.values()) {
batches.push(batch(points, (point) => point.farRadiusKm, 'far'));
batches.push(batch(points, (point) => point.nearRadiusKm, 'near'));
}
return batches;
}
/**
* 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.
* spots are built on the first call that needs them and never again, and a
* zoom only moves one colour uniform per batch.
* @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;
const opacity = {
far: coverageOpacityAt(altitude, 'far'),
near: coverageOpacityAt(altitude, 'near'),
};
const key = `${opacity.far}/${opacity.near}`;
if (key === state._coverageOpacity) return;
state._coverageOpacity = key;
// 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) {
if (opacity.far === 0 && opacity.near === 0) return;
try {
state._coverageSpots = build();
} catch (error) {
// A host that cannot build geometry (headless tests) has no glow.
state._coverageSpots = [];
console.warn('[Data:Transit] Coverage glow unavailable:', error);
}
}
for (const entry of state._coverageSpots || []) {
const value = opacity[entry.set];
entry.primitive.show = value > 0;
entry.material.uniforms.color = Cesium.Color.clone(
entry.material.uniforms.color,
).withAlpha(value * COVERAGE_PEAK_ALPHA * entry.strength);
}
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;
for (const entry of state._coverageSpots || [])
entry.primitive.show = false;
}
function destroy() {
state._coverageOpacity = null;
if (state._coverageSpots) {
state._viewer?.scene?.primitives?.remove(state._coverageSpots);
state._coverageSpots = null;
for (const entry of state._coverageSpots || []) {
state._viewer?.scene?.primitives?.remove(entry.primitive);
}
state._coverageSpots = null;
}
return { sync, hide, destroy };
+48 -23
View File
@@ -32,35 +32,60 @@ 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.
* How strongly the coverage glow shows at each camera height (m), as
* [height, opacity] stops joined by straight lines. There are two sets of
* spots and they cross-fade:
*
* - `far`: wide spots, far larger than any network, so coverage reads from
* orbit. Full above the altitude gate, gone by regional zoom.
* - `near`: patches the size of each feed's own service area, drawn as a
* backdrop under the vehicles at continental and regional zoom so a person
* can see which neighbouring cities are covered. Off close in, where a
* city's own vehicles are the picture.
*
* Between them there is no height from 250 km up with nothing drawn.
*/
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;
export const COVERAGE_OPACITY_STOPS = Object.freeze({
far: Object.freeze([
Object.freeze([1_200_000, 0]),
Object.freeze([3_400_000, 1]),
]),
near: Object.freeze([
Object.freeze([60_000, 0]),
Object.freeze([250_000, 0.8]),
Object.freeze([1_500_000, 0.9]),
Object.freeze([3_400_000, 0]),
]),
});
/** Fine enough that a zoom reads as a continuous fade, not as steps. */
const COVERAGE_FADE_STEPS = 100;
/** Alpha at the centre of a lone spot at full strength. */
export const COVERAGE_PEAK_ALPHA = 0.9;
/**
* Opacity of the coverage glow at a camera height, quantized so a slow zoom
* does not restyle the imagery layer on every camera event.
* Opacity of one set of coverage spots at a camera height, quantized so a
* zoom does not restyle the glow on every camera event.
* @param {number} altitude Camera height above the ellipsoid, metres.
* @param {'far'|'near'} [set='far']
* @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 function coverageOpacityAt(altitude, set = 'far') {
const stops = COVERAGE_OPACITY_STOPS[set] || COVERAGE_OPACITY_STOPS.far;
const last = stops[stops.length - 1][1];
if (!Number.isFinite(altitude)) return altitude === Infinity ? last : 0;
let value = last;
if (altitude <= stops[0][0]) value = stops[0][1];
else {
for (let i = 1; i < stops.length; i++) {
if (altitude <= stops[i][0]) {
const [h0, v0] = stops[i - 1];
const [h1, v1] = stops[i];
value = v0 + ((v1 - v0) * (altitude - h0)) / (h1 - h0);
break;
}
}
}
return Math.round(value * 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;
+1 -1
View File
@@ -42,7 +42,7 @@ export function createState({ services }) {
state._cameraChangedAttached = false;
state._cameraDebounceTimer = null;
state._altitudeGateOpen = false;
/** Globe-view coverage glow: its billboards and the last opacity drawn. */
/** Coverage glow: its draw batches and the last opacities drawn. */
state._coverageSpots = null;
state._coverageOpacity = null;
/** Camera sensitivity as we found it, so disable() can hand it back. */