mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 07:12:21 +00:00
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:
1 parent
7082a02057
commit
b17bfa690f
8 files changed
+394
-145
No files matched your search
@@ -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
@@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user