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