mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17dc7c8a69 | ||
|
|
e3f28a391f | ||
|
|
ad369eb0de | ||
|
|
0b5ae6386e |
No files matched your search
@@ -1,4 +0,0 @@
|
||||
- Cut the stutter while zooming and panning with Street Traffic on in dense
|
||||
cities. Coarse roads are no longer clipped against every detail tile in one
|
||||
task, roads already parsed in a load are not parsed again, and ground floors
|
||||
are read once per cell (mazy1998, #1705).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Halve the time the satellite cloud image takes to prepare on the 3D Tiles
|
||||
map by applying its transparency curve from a lookup table, in place
|
||||
(mazy1998, #1713).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Satellite clouds on the 3D Tiles map apply their transparency in the shader
|
||||
instead of rewriting each 4096 x 2048 image on the page, and changing the
|
||||
cloud strength no longer builds a new image (mazy1998, #1721).
|
||||
@@ -891,9 +891,11 @@ image proxy accepts `bbox=west,south,east,north` for every product: inside the
|
||||
product bounds, 2:1 within 1 %, rounded to 0.25°, up to 4096×2048 (the
|
||||
default), cached by product, time, size and bbox. Diagnostics report
|
||||
`shell.detail` (`bbox`, `size`, `ready`, `enabled`).
|
||||
Clouds only applies a soft brightness ramp to decoded pixels once, using
|
||||
Clouds only applies a soft brightness ramp, using
|
||||
Cesium's sRGB-to-linear conversion (`channel ** 2.2`) and smoothstep from 0.40
|
||||
to 0.70. The old 0.55 threshold is the ramp midpoint. RGB and source alpha are
|
||||
to 0.70. Globe hosts apply it to decoded pixels once; a shell applies the same
|
||||
ramp in its fragment shader from a strength uniform, so its images are drawn
|
||||
as decoded and a strength change neither fetches nor rewrites an image. The old 0.55 threshold is the ramp midpoint. RGB and source alpha are
|
||||
preserved in Full image mode; both modes use the chosen layer opacity. This
|
||||
is a display filter, not a cloud mask. The cloud-filter slider is a continuous
|
||||
0–100% strength: 0% is Full, 100% is the existing Clouds only key, and
|
||||
@@ -909,13 +911,13 @@ and processes it before staging. Globe hosts crop that canvas into 256 px tiles
|
||||
to maximum level 3 on a geographic 2×1 root grid bounded to the manifest extent,
|
||||
avoiding request-dependent contrast seams.
|
||||
On globe hosts, regional infrared retains network tiles and processes each
|
||||
decoded tile once; shells process each whole frame once.
|
||||
decoded tile once; shells never read a frame's pixels back.
|
||||
Exact-time tile and image responses are immutable for 24 hours; manifests and
|
||||
errors remain uncached. Each globe renderer retains up to 6 processed global
|
||||
mosaics in a least-recently-used cache keyed by observation time and infrared
|
||||
mode (up to 48 MiB of canvas pixels). Each shell keeps decoded full-extent and
|
||||
detail images in one least-recently-used cache bounded at 128 MiB, keyed by
|
||||
time, mode and window: the shown frame and the warmed next frame, each
|
||||
time and window: the shown frame and the warmed next frame, each
|
||||
full-extent and detail; nothing older survives them. Disable clears the caches.
|
||||
Cache hits skip
|
||||
fetch/decode and report `mosaic.cached: true` with zero decode time.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { roadIdentity } from './retention.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
floorCellReader,
|
||||
prepareRoadSurfaces,
|
||||
roadHeightStations,
|
||||
trafficSurfaceReady,
|
||||
@@ -302,7 +301,6 @@ export function createFlow({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
}
|
||||
if (depthHeight !== undefined) layerState._viewHeightEstimate = depthHeight;
|
||||
const floorAt = floorCellReader(services.ground);
|
||||
for (const road of roads) {
|
||||
road.key ||= roadIdentity(road);
|
||||
const prepared = !refining && preparedRoads.get(road.key);
|
||||
@@ -321,7 +319,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
|
||||
);
|
||||
for (let i = 0; i < road.coords.length; i++) {
|
||||
const [lon, lat] = road.coords[i];
|
||||
const floor = floorAt(lat, lon);
|
||||
const floor = services.ground?.cachedGroundFloor?.(lat, lon);
|
||||
const height =
|
||||
Number.isFinite(floor) && Math.abs(floor) <= 9000
|
||||
? Math.max(floor, layerState._viewHeightEstimate)
|
||||
|
||||
@@ -39,10 +39,10 @@ export function createTrafficLayer({ services, source }) {
|
||||
parts.controls = createControls(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
state._parseRoads = TRAFFIC_TIMING_ENABLED
|
||||
? (data, trace, parsedBySource) =>
|
||||
? (data, trace) =>
|
||||
trace
|
||||
? parts.timing.parseRoadsTimed(data, trace, parsedBySource)
|
||||
: parts.model.parseRoads(data, trace, parsedBySource)
|
||||
? parts.timing.parseRoadsTimed(data, trace)
|
||||
: parts.model.parseRoads(data)
|
||||
: parts.model.parseRoads;
|
||||
|
||||
state._loadRoadsForBounds = TRAFFIC_TIMING_ENABLED
|
||||
|
||||
@@ -348,16 +348,11 @@ export function createIngestion({
|
||||
layerState._retainedPreview = false;
|
||||
layerState._detailRoadsReady = false;
|
||||
let paintRevision = 0;
|
||||
// The snapshot that ends a pass replaces the streamed roads with the
|
||||
// composed set, which is mostly the tile records already parsed and
|
||||
// drawn. Remembering those per pass means only the records new to that
|
||||
// snapshot (the clipped coarse roads) are parsed, not the whole set again.
|
||||
let parsedBySource = new WeakMap();
|
||||
const onTile = (data, passSignal, preview) => {
|
||||
if (generation !== layerState._loadGeneration || passSignal.aborted)
|
||||
return;
|
||||
const start = performance.now();
|
||||
const parsed = layerState._parseRoads(data, trace, parsedBySource);
|
||||
const parsed = layerState._parseRoads(data, trace);
|
||||
phaseTiming('parse', start, { roads: parsed.length, incremental: true });
|
||||
if (data.replace) streamed = parsed;
|
||||
else streamed.push(...parsed);
|
||||
@@ -536,7 +531,6 @@ export function createIngestion({
|
||||
if (altitude > FAST_FETCH_ALTITUDE) return;
|
||||
|
||||
streamed = [];
|
||||
parsedBySource = new WeakMap();
|
||||
majorPass = false;
|
||||
// Detailed pass: fetch the full road graph (tertiary, residential, etc.)
|
||||
console.log(`[Data:Traffic] Full fetch local roads [${cacheKey}]`);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { floorCellReader, roadSurfaceChunks } from './surface.js';
|
||||
import { roadSurfaceChunks } from './surface.js';
|
||||
import { t, translateKnown } from '../../i18n/index.js';
|
||||
import {
|
||||
flowDensityMult,
|
||||
@@ -13,35 +13,22 @@ import {
|
||||
} from './policy.js';
|
||||
|
||||
export function createModel({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
* Build scene waypoints from source records; thinning and terrain remain rendering policy.
|
||||
*
|
||||
* `parsedBySource` (source record → its parsed roads) is filled as records
|
||||
* are parsed, and a record already in it is answered from it.
|
||||
*/
|
||||
function parseRoads(roadData, _trace, parsedBySource) {
|
||||
/** Build scene waypoints from source records; thinning and terrain remain rendering policy. */
|
||||
function parseRoads(roadData) {
|
||||
if (!roadData || !roadData.roads) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const roads = [];
|
||||
const floorAt = floorCellReader(services.ground);
|
||||
for (const road of roadData.roads) {
|
||||
if (!road.coordinates || road.coordinates.length < 2) continue;
|
||||
// A replacing snapshot repeats records this load already parsed.
|
||||
const known = parsedBySource?.get(road);
|
||||
if (known) {
|
||||
for (const parsed of known) roads.push(parsed);
|
||||
continue;
|
||||
}
|
||||
const first = roads.length;
|
||||
|
||||
for (const coords of roadSurfaceChunks(road.coordinates)) {
|
||||
const type = road.type;
|
||||
const oneway = road.oneway;
|
||||
// Pre-compute Cartesian3 waypoints (lon, lat, height) for fast lerp animation
|
||||
const waypoints = coords.map(([lng, lat]) => {
|
||||
const floor = floorAt(lat, lng);
|
||||
const floor = services.ground?.cachedGroundFloor?.(lat, lng);
|
||||
const h =
|
||||
(Number.isFinite(floor) && Math.abs(floor) <= 9000
|
||||
? floor
|
||||
@@ -73,7 +60,6 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
flow: road.flow || null,
|
||||
});
|
||||
}
|
||||
parsedBySource?.set(road, roads.slice(first));
|
||||
}
|
||||
|
||||
return roads;
|
||||
|
||||
@@ -1,444 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createTrafficSource, outsideDetailTiles } from './source.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createModel } from './model.js';
|
||||
import { floorCellReader, roadSurfaceChunks } from './surface.js';
|
||||
import { DOT_HEIGHT_OFFSET } from './policy.js';
|
||||
import { clipTileLine } from '../../sources/openFreeMap.js';
|
||||
import { tileToBBox } from '../../data/tomtomTiles.js';
|
||||
|
||||
// The clip as it was before the extent shortcut: every road against the four
|
||||
// regions around every tile. The shortcut must return exactly this.
|
||||
function clipEveryRoadAgainstEveryTile(roads, tiles) {
|
||||
return roads.flatMap((road) => {
|
||||
let lines = [road.coordinates];
|
||||
for (const tile of tiles) {
|
||||
const box = tileToBBox(tile.z, tile.x, tile.y);
|
||||
const regions = [
|
||||
{ west: -180, east: box.west, south: -85, north: 85 },
|
||||
{ west: box.east, east: 180, south: -85, north: 85 },
|
||||
{ west: box.west, east: box.east, south: -85, north: box.south },
|
||||
{ west: box.west, east: box.east, south: box.north, north: 85 },
|
||||
];
|
||||
lines = lines.flatMap((line) =>
|
||||
regions.flatMap((region) => clipTileLine(line, region)),
|
||||
);
|
||||
}
|
||||
return lines.map((coordinates) => ({ ...road, coordinates }));
|
||||
});
|
||||
}
|
||||
|
||||
function seeded(seed) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
||||
return state / 2 ** 32;
|
||||
};
|
||||
}
|
||||
|
||||
function tileBlock(x, y, size = 4) {
|
||||
const tiles = [];
|
||||
for (let dx = 0; dx < size; dx++)
|
||||
for (let dy = 0; dy < size; dy++)
|
||||
tiles.push({ z: 14, x: x + dx, y: y + dy });
|
||||
return tiles;
|
||||
}
|
||||
|
||||
/** Roads in and around a tile block: wandering, straight, tiny, and on tile edges. */
|
||||
function roadsAround(tiles, random, count = 400) {
|
||||
const boxes = tiles.map((tile) => tileToBBox(tile.z, tile.x, tile.y));
|
||||
const west = Math.min(...boxes.map((box) => box.west)),
|
||||
east = Math.max(...boxes.map((box) => box.east)),
|
||||
south = Math.min(...boxes.map((box) => box.south)),
|
||||
north = Math.max(...boxes.map((box) => box.north));
|
||||
const width = east - west,
|
||||
height = north - south;
|
||||
const roads = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const step = i % 7 === 0 ? 0.00002 : i % 3 === 0 ? 0.01 : 0.002;
|
||||
let lon = west - width / 2 + random() * width * 2,
|
||||
lat = south - height / 2 + random() * height * 2;
|
||||
const coordinates = [[lon, lat]];
|
||||
const points = 1 + Math.floor(random() * 12);
|
||||
for (let j = 0; j < points; j++) {
|
||||
lon += (random() - 0.5) * step;
|
||||
lat += (random() - 0.5) * step;
|
||||
coordinates.push([lon, lat]);
|
||||
}
|
||||
roads.push({ type: 'primary', oneway: i % 2, id: i, coordinates });
|
||||
}
|
||||
const box = boxes[5];
|
||||
const midLat = (box.south + box.north) / 2,
|
||||
midLon = (box.west + box.east) / 2;
|
||||
roads.push(
|
||||
// Along a tile edge, ending on a corner, and touching an edge from outside.
|
||||
{
|
||||
id: 'edge',
|
||||
coordinates: [
|
||||
[box.west, box.south - 0.01],
|
||||
[box.west, box.north + 0.01],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'corner',
|
||||
coordinates: [
|
||||
[box.west - 0.01, box.south - 0.01],
|
||||
[box.west, box.south],
|
||||
[box.east, box.north],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'touch',
|
||||
coordinates: [
|
||||
[box.west - 0.01, midLat],
|
||||
[box.west, midLat],
|
||||
],
|
||||
},
|
||||
// Across the whole block, and a repeated vertex.
|
||||
{
|
||||
id: 'across',
|
||||
coordinates: [
|
||||
[west - 0.01, midLat],
|
||||
[east + 0.01, midLat + 0.0003],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'repeat',
|
||||
coordinates: [
|
||||
[midLon, midLat],
|
||||
[midLon, midLat],
|
||||
[midLon + 0.001, midLat],
|
||||
],
|
||||
},
|
||||
// Records the clip drops or leaves alone.
|
||||
{ id: 'single', coordinates: [[west - 0.02, midLat]] },
|
||||
{ id: 'empty', coordinates: [] },
|
||||
{
|
||||
id: 'negative-zero',
|
||||
coordinates: [
|
||||
[-0, south - 0.02],
|
||||
[0.001, south - 0.02],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'polar',
|
||||
coordinates: [
|
||||
[west - 0.02, 86],
|
||||
[west - 0.02, 86.01],
|
||||
],
|
||||
},
|
||||
);
|
||||
return roads;
|
||||
}
|
||||
|
||||
for (const [place, x, y] of [
|
||||
['Manhattan', 4823, 6158],
|
||||
['London, across longitude 0', 8190, 5446],
|
||||
['longitude 0 and latitude 0', 8190, 8190],
|
||||
['the southern and eastern hemispheres', 15070, 9826],
|
||||
])
|
||||
test(`coarse roads clipped around detail tiles match the every-tile clip: ${place}`, () => {
|
||||
const tiles = tileBlock(x, y);
|
||||
const roads = roadsAround(tiles, seeded(x * 31 + y));
|
||||
for (const loaded of [tiles, tiles.slice(0, 5), tiles.slice(3, 4), []]) {
|
||||
const expected = clipEveryRoadAgainstEveryTile(roads, loaded);
|
||||
assert.ok(loaded.length === 0 || expected.length !== roads.length);
|
||||
// deepStrictEqual separates -0 from 0 and compares every vertex exactly.
|
||||
assert.deepStrictEqual(outsideDetailTiles(roads, loaded), expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('only a road that reaches a detail tile is clipped against it', () => {
|
||||
const tiles = tileBlock(4823, 6158);
|
||||
const block = tiles.map((tile) => tileToBBox(tile.z, tile.x, tile.y));
|
||||
const west = Math.min(...block.map((box) => box.west)),
|
||||
north = Math.max(...block.map((box) => box.north));
|
||||
// 300 roads beside the block and between its tile edges, none touching one.
|
||||
const clear = [];
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const box = block[i % block.length];
|
||||
const lon = i % 2 ? west - 0.02 - i * 1e-5 : box.west + 0.004;
|
||||
const lat = i % 2 ? box.south + 0.004 : north + 0.01 + i * 1e-5;
|
||||
clear.push({
|
||||
coordinates: [
|
||||
[lon, lat],
|
||||
[lon + 0.002, lat + 0.0005],
|
||||
[lon + 0.003, lat + 0.001],
|
||||
],
|
||||
});
|
||||
}
|
||||
const first = block[0];
|
||||
const crossing = {
|
||||
coordinates: [
|
||||
[first.west + 0.004, north + 0.01],
|
||||
[first.west + 0.004, (first.south + first.north) / 2],
|
||||
],
|
||||
};
|
||||
let clips = 0;
|
||||
const counted = (line, region) => {
|
||||
clips++;
|
||||
return clipTileLine(line, region);
|
||||
};
|
||||
const roads = outsideDetailTiles([...clear, crossing], tiles, counted);
|
||||
assert.deepStrictEqual(
|
||||
roads,
|
||||
clipEveryRoadAgainstEveryTile([...clear, crossing], tiles),
|
||||
);
|
||||
assert.equal(roads.length, 301);
|
||||
// Four regions for each tile the crossing road reaches, never the 19,264
|
||||
// clips of every road against every tile.
|
||||
assert.ok(clips >= 4, `expected the crossing road to be clipped: ${clips}`);
|
||||
assert.ok(clips <= 4 * tiles.length, `clipped ${clips} times`);
|
||||
});
|
||||
|
||||
/** A DEM-regime ground service: one floor per toFixed(3) cell, counting reads. */
|
||||
function cellGround({ mesh = false, warm = () => true } = {}) {
|
||||
const ground = {
|
||||
reads: 0,
|
||||
meshFloorPreferred: () => mesh,
|
||||
cachedGroundFloor(lat, lon) {
|
||||
ground.reads++;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
// Number() as the shared cache does it: "-0.000" and "0.000" are one cell.
|
||||
const cell = `${Number(lat.toFixed(3))},${Number(lon.toFixed(3))}`;
|
||||
if (!warm(cell)) return null;
|
||||
let hash = 0;
|
||||
for (const character of cell)
|
||||
hash = (Math.imul(hash, 31) + character.charCodeAt(0)) | 0;
|
||||
// The mesh regime answers per point, not per cell.
|
||||
return (hash % 4000) / 10 + (mesh ? lat - Math.floor(lat * 1000) / 1000 : 0);
|
||||
},
|
||||
};
|
||||
return ground;
|
||||
}
|
||||
|
||||
test('floor reads are shared per cell and equal the per-coordinate reads', () => {
|
||||
const random = seeded(7);
|
||||
const points = [];
|
||||
for (let i = 0; i < 4000; i++)
|
||||
points.push([40.75 + random() * 0.01, -73.99 + random() * 0.01]);
|
||||
// Cell edges, where toFixed(3) and Math.round can disagree, in each sign.
|
||||
for (const lat of [40.7505, -40.7505, 0.0005, -0.0005, 1.0005, 8.5745])
|
||||
for (const lon of [-73.9905, 73.9905, 0.0005, -0.0005, 1.0005, 2.3515])
|
||||
points.push([lat, lon], [lat + 1e-9, lon - 1e-9], [lat - 1e-9, lon]);
|
||||
points.push([Number.NaN, 0], [0, Infinity], [0, 0], [-0, -0]);
|
||||
|
||||
const direct = cellGround();
|
||||
const expected = points.map(([lat, lon]) =>
|
||||
direct.cachedGroundFloor(lat, lon),
|
||||
);
|
||||
const ground = cellGround();
|
||||
const floorAt = floorCellReader(ground);
|
||||
assert.deepStrictEqual(
|
||||
points.map(([lat, lon]) => floorAt(lat, lon)),
|
||||
expected,
|
||||
);
|
||||
// 4000 points in a 0.01 degree square share about 121 cells.
|
||||
assert.ok(ground.reads < 600, `read the shared cache ${ground.reads} times`);
|
||||
|
||||
// A cold cell is remembered as cold for the pass, not asked again.
|
||||
const cold = cellGround({ warm: () => false });
|
||||
const coldAt = floorCellReader(cold);
|
||||
assert.equal(coldAt(40.7512, -73.9812), null);
|
||||
assert.equal(coldAt(40.7513, -73.9813), null);
|
||||
assert.equal(cold.reads, 1);
|
||||
});
|
||||
|
||||
test('mesh floors and unknown ground services are read per coordinate', () => {
|
||||
const mesh = cellGround({ mesh: true });
|
||||
const meshAt = floorCellReader(mesh);
|
||||
const a = meshAt(40.7512, -73.9812),
|
||||
b = meshAt(40.75121, -73.9812);
|
||||
assert.equal(mesh.reads, 2);
|
||||
assert.notEqual(a, b);
|
||||
|
||||
// No regime to ask: nothing says a floor is the same across a cell.
|
||||
let reads = 0;
|
||||
const bareAt = floorCellReader({ cachedGroundFloor: () => ++reads });
|
||||
bareAt(40.7512, -73.9812);
|
||||
bareAt(40.7512, -73.9812);
|
||||
assert.equal(reads, 2);
|
||||
assert.equal(floorCellReader(undefined)(1, 2), undefined);
|
||||
assert.equal(floorCellReader({})(1, 2), undefined);
|
||||
});
|
||||
|
||||
function road(lon, lat, points = 40) {
|
||||
const coordinates = [];
|
||||
for (let i = 0; i < points; i++)
|
||||
coordinates.push([lon + i * 0.00004, lat + i * 0.00003]);
|
||||
return { type: 'residential', oneway: 1, coordinates };
|
||||
}
|
||||
|
||||
test('parsing reads each floor cell once and builds the same waypoints', () => {
|
||||
const ground = cellGround({ warm: (cell) => !cell.endsWith('3') });
|
||||
const state = { _viewHeightEstimate: 12 };
|
||||
const model = createModel({
|
||||
state,
|
||||
services: { ground },
|
||||
parts: {},
|
||||
source: {},
|
||||
});
|
||||
const records = [road(-73.99, 40.75), road(-73.9899, 40.7501)];
|
||||
const parsed = model.parseRoads({ roads: records });
|
||||
const coordinates = records.reduce(
|
||||
(count, record) => count + record.coordinates.length,
|
||||
0,
|
||||
);
|
||||
assert.ok(
|
||||
ground.reads < coordinates / 3,
|
||||
`${ground.reads} floor reads for ${coordinates} coordinates`,
|
||||
);
|
||||
|
||||
const direct = cellGround({ warm: (cell) => !cell.endsWith('3') });
|
||||
const expected = records.flatMap((record) =>
|
||||
roadSurfaceChunks(record.coordinates).map((coords) =>
|
||||
coords.map(([lon, lat]) => {
|
||||
const floor = direct.cachedGroundFloor(lat, lon);
|
||||
return Cesium.Cartesian3.fromDegrees(
|
||||
lon,
|
||||
lat,
|
||||
(floor ?? state._viewHeightEstimate) + DOT_HEIGHT_OFFSET,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.ok(direct.reads >= coordinates);
|
||||
assert.deepStrictEqual(
|
||||
parsed.map((entry) => entry.waypoints),
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
test('a record parsed earlier in the pass is not parsed again', () => {
|
||||
const ground = cellGround();
|
||||
const model = createModel({
|
||||
state: {},
|
||||
services: { ground },
|
||||
parts: {},
|
||||
source: {},
|
||||
});
|
||||
const first = { ...road(-73.99, 40.75), oneway: 0 },
|
||||
second = road(-73.98, 40.76),
|
||||
short = { type: 'primary', coordinates: [[-73.97, 40.77]] };
|
||||
const parsedBySource = new WeakMap();
|
||||
const tile = model.parseRoads({ roads: [first] }, null, parsedBySource);
|
||||
assert.equal(tile.length, 2, 'a two-way record parses to both directions');
|
||||
const reads = ground.reads;
|
||||
const replaced = model.parseRoads(
|
||||
{ roads: [second, short, first], replace: true },
|
||||
null,
|
||||
parsedBySource,
|
||||
);
|
||||
assert.equal(replaced.length, 3);
|
||||
// The same parsed roads, in the snapshot's order: dots stay attached.
|
||||
assert.equal(replaced[1], tile[0]);
|
||||
assert.equal(replaced[2], tile[1]);
|
||||
const fresh = cellGround();
|
||||
createModel({
|
||||
state: {},
|
||||
services: { ground: fresh },
|
||||
parts: {},
|
||||
source: {},
|
||||
}).parseRoads({ roads: [second] });
|
||||
assert.equal(ground.reads - reads, fresh.reads, 'only the new record read');
|
||||
|
||||
// Without the map every call parses, as before.
|
||||
const again = model.parseRoads({ roads: [first] });
|
||||
assert.notEqual(again[0], tile[0]);
|
||||
assert.deepStrictEqual(again, tile);
|
||||
});
|
||||
|
||||
test('the snapshot that ends a pass parses only the records it adds', async () => {
|
||||
const detail = { z: 14, x: 3743, y: 6745 };
|
||||
const core = tileToBBox(detail.z, detail.x, detail.y);
|
||||
const local = {
|
||||
type: 'residential',
|
||||
oneway: 1,
|
||||
coordinates: [
|
||||
[core.west + 0.002, core.south + 0.002],
|
||||
[core.west + 0.004, core.south + 0.004],
|
||||
],
|
||||
};
|
||||
const distant = {
|
||||
type: 'primary',
|
||||
oneway: 1,
|
||||
coordinates: [
|
||||
[core.east + 0.01, core.south],
|
||||
[core.east + 0.02, core.south],
|
||||
],
|
||||
};
|
||||
const bounds = {
|
||||
...core,
|
||||
east: core.east + 0.03,
|
||||
coverage: {
|
||||
detail: [detail],
|
||||
coarse: [{ z: 12, x: 935, y: 1686 }],
|
||||
key: 'test',
|
||||
},
|
||||
};
|
||||
const source = createTrafficSource({
|
||||
mapTiles: {
|
||||
async fetchBounds(box, { zoom, onTile }) {
|
||||
const tile = { roads: zoom === 14 ? [local] : [distant] };
|
||||
onTile?.(tile);
|
||||
return {
|
||||
tiles: [tile],
|
||||
loadedTiles: zoom === 14 ? [detail] : bounds.coverage.coarse,
|
||||
partial: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const parsedRecords = [];
|
||||
const painted = [];
|
||||
const state = {
|
||||
_enabled: true,
|
||||
_loadGeneration: 0,
|
||||
_tileCache: new Map(),
|
||||
_roadMode: null,
|
||||
_liveMode: false,
|
||||
// The model's contract: fill the map, answer known records from it.
|
||||
_parseRoads: (data, trace, parsedBySource) =>
|
||||
data.roads.flatMap((record) => {
|
||||
const known = parsedBySource?.get(record);
|
||||
if (known) return known;
|
||||
parsedRecords.push(record);
|
||||
const parsed = [{ record }];
|
||||
parsedBySource?.set(record, parsed);
|
||||
return parsed;
|
||||
}),
|
||||
};
|
||||
const ingestion = createIngestion({
|
||||
state,
|
||||
source,
|
||||
services: {},
|
||||
parts: {
|
||||
viewport: {
|
||||
clampBounds: (b) => b,
|
||||
getBoundsCenter: () => ({ lat: 30.26, lon: -97.74 }),
|
||||
},
|
||||
flow: {
|
||||
warmFlow: () => Promise.resolve([]),
|
||||
applyFlowThenRender: async (roads, box, generation, altitude, label) => {
|
||||
if (label === 'Loaded full') painted.push(roads);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await ingestion.loadRoadsForBounds(bounds, 350);
|
||||
assert.equal(
|
||||
parsedRecords.filter((record) => record === local).length,
|
||||
1,
|
||||
'the detail tile record is parsed when its tile streams, not again',
|
||||
);
|
||||
// The full graph is still the detail record plus the coarse road outside it.
|
||||
assert.equal(painted.at(-1).length, 2);
|
||||
assert.equal(painted.at(-1)[0].record, local);
|
||||
assert.deepStrictEqual(
|
||||
painted.at(-1)[1].record.coordinates,
|
||||
distant.coordinates,
|
||||
);
|
||||
});
|
||||
+15
-100
@@ -33,108 +33,23 @@ export function trafficDetailBounds(box) {
|
||||
return detail;
|
||||
}
|
||||
|
||||
/** Longitude/latitude extent of one line; NaN coordinates leave it unusable. */
|
||||
function lineExtent(coordinates) {
|
||||
let west = Infinity,
|
||||
east = -Infinity,
|
||||
south = Infinity,
|
||||
north = -Infinity;
|
||||
for (const [lon, lat] of coordinates) {
|
||||
if (!(lon >= west)) west = lon;
|
||||
if (!(lon <= east)) east = lon;
|
||||
if (!(lat >= south)) south = lat;
|
||||
if (!(lat <= north)) north = lat;
|
||||
}
|
||||
return { coordinates, west, east, south, north, settled: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* What `clipTileLine` returns for a line lying wholly inside the clip region:
|
||||
* the same line with each vertex recomputed from the one before it. Returns
|
||||
* null for a line under the 12 m floor `clipTileLine` drops.
|
||||
*/
|
||||
function unclippedLine(coordinates) {
|
||||
if (coordinates.length < 2) return null;
|
||||
const out = [];
|
||||
let metres = 0;
|
||||
for (let i = 1; i < coordinates.length; i++) {
|
||||
const a = coordinates[i - 1],
|
||||
b = coordinates[i];
|
||||
const dx = b[0] - a[0],
|
||||
dy = b[1] - a[1];
|
||||
if (i === 1) out.push([a[0] + 0 * dx, a[1] + 0 * dy]);
|
||||
out.push([a[0] + dx, a[1] + dy]);
|
||||
metres +=
|
||||
Math.hypot(
|
||||
(out[i][0] - out[i - 1][0]) * Math.cos((out[i][1] * Math.PI) / 180),
|
||||
out[i][1] - out[i - 1][1],
|
||||
) * 111320;
|
||||
}
|
||||
return metres >= 12 ? out : null;
|
||||
}
|
||||
|
||||
const sameLine = (a, b) =>
|
||||
a.every(
|
||||
(point, i) => Object.is(point[0], b[i][0]) && Object.is(point[1], b[i][1]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Keep coarse roads only outside the detailed tile cores.
|
||||
*
|
||||
* Each tile splits a line into the four regions around its core. Nearly every
|
||||
* coarse road lies wholly inside one of those regions for nearly every tile,
|
||||
* where the clip hands the line back unchanged, so that case is answered from
|
||||
* the line's extent and only a line that reaches a tile's core or edge is
|
||||
* clipped. Clipping every road against every tile froze the page for hundreds
|
||||
* of milliseconds per road load at street level.
|
||||
*
|
||||
* `clip` is injectable for tests.
|
||||
*/
|
||||
export function outsideDetailTiles(roads, tiles, clip = clipTileLine) {
|
||||
const boxes = tiles.map((tile) => tileToBBox(tile.z, tile.x, tile.y));
|
||||
/** Keep coarse roads only outside the detailed tile cores. */
|
||||
export function outsideDetailTiles(roads, tiles) {
|
||||
return roads.flatMap((road) => {
|
||||
let lines = [lineExtent(road.coordinates)];
|
||||
for (const box of boxes) {
|
||||
const next = [];
|
||||
for (const line of lines) {
|
||||
// Strictly inside one region, and inside the bounds every region
|
||||
// shares: the other three regions return nothing.
|
||||
const clear =
|
||||
line.west > -180 &&
|
||||
line.east < 180 &&
|
||||
line.south > -85 &&
|
||||
line.north < 85 &&
|
||||
(line.east < box.west ||
|
||||
line.west > box.east ||
|
||||
(line.west > box.west &&
|
||||
line.east < box.east &&
|
||||
(line.north < box.south || line.south > box.north)));
|
||||
if (!clear) {
|
||||
const regions = [
|
||||
{ west: -180, east: box.west, south: -85, north: 85 },
|
||||
{ west: box.east, east: 180, south: -85, north: 85 },
|
||||
{ west: box.west, east: box.east, south: -85, north: box.south },
|
||||
{ west: box.west, east: box.east, south: box.north, north: 85 },
|
||||
];
|
||||
for (const region of regions)
|
||||
for (const coordinates of clip(line.coordinates, region))
|
||||
next.push(lineExtent(coordinates));
|
||||
} else if (line.settled) {
|
||||
next.push(line);
|
||||
} else {
|
||||
// The first unclipped pass can still move a vertex by a rounding
|
||||
// step (a road crossing longitude or latitude 0); once a pass
|
||||
// changes nothing, no later one will.
|
||||
const coordinates = unclippedLine(line.coordinates);
|
||||
if (!coordinates) continue;
|
||||
const kept = lineExtent(coordinates);
|
||||
kept.settled = sameLine(coordinates, line.coordinates);
|
||||
next.push(kept);
|
||||
}
|
||||
}
|
||||
lines = next;
|
||||
let lines = [road.coordinates];
|
||||
for (const tile of tiles) {
|
||||
const box = tileToBBox(tile.z, tile.x, tile.y);
|
||||
const regions = [
|
||||
{ west: -180, east: box.west, south: -85, north: 85 },
|
||||
{ west: box.east, east: 180, south: -85, north: 85 },
|
||||
{ west: box.west, east: box.east, south: -85, north: box.south },
|
||||
{ west: box.west, east: box.east, south: box.north, north: 85 },
|
||||
];
|
||||
lines = lines.flatMap((line) =>
|
||||
regions.flatMap((region) => clipTileLine(line, region)),
|
||||
);
|
||||
}
|
||||
return lines.map(({ coordinates }) => ({ ...road, coordinates }));
|
||||
return lines.map((coordinates) => ({ ...road, coordinates }));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -69,45 +69,6 @@ export function trafficSurfaceReady(scene) {
|
||||
return currentRenderedFrame(scene)?.ready === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ground-floor reads for one synchronous pass over road coordinates, asking
|
||||
* the shared cache once per floor cell instead of once per coordinate.
|
||||
*
|
||||
* A road tile puts thousands of coordinates into a few hundred ~111 m cells,
|
||||
* and each shared read formats and looks up two string keys, which added up
|
||||
* to seconds per street-level load. Mesh floors answer per point (the nearest
|
||||
* reading in the cell), so they are read through unchanged; only the DEM
|
||||
* regime is one value per cell. Build a reader per pass: cells warm up
|
||||
* between passes.
|
||||
*/
|
||||
export function floorCellReader(ground) {
|
||||
const read = ground?.cachedGroundFloor;
|
||||
if (!read) return () => undefined;
|
||||
if (ground.meshFloorPreferred?.() !== false)
|
||||
return (lat, lon) => read(lat, lon);
|
||||
const cells = new Map();
|
||||
return (lat, lon) => {
|
||||
const y = lat * 1000,
|
||||
x = lon * 1000;
|
||||
const row = Math.round(y),
|
||||
column = Math.round(x);
|
||||
// The shared cache rounds with toFixed(3). At a cell edge that and
|
||||
// Math.round can pick different cells, so an edge point asks directly.
|
||||
if (!(
|
||||
Math.abs(Math.abs(y - row) - 0.5) > 1e-6 &&
|
||||
Math.abs(Math.abs(x - column) - 0.5) > 1e-6
|
||||
))
|
||||
return read(lat, lon);
|
||||
const key = row * 1e6 + column;
|
||||
let floor = cells.get(key);
|
||||
if (floor === undefined) {
|
||||
floor = read(lat, lon) ?? null;
|
||||
cells.set(key, floor);
|
||||
}
|
||||
return floor;
|
||||
};
|
||||
}
|
||||
|
||||
/** Preserve bends and insert height samples at most 150 m apart, splitting long roads. */
|
||||
export function roadSurfaceChunks(coordinates) {
|
||||
const chunks = [];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { floorCellReader, roadSurfaceChunks } from './surface.js';
|
||||
import { roadSurfaceChunks } from './surface.js';
|
||||
import { TRAFFIC_TIMING_ENABLED, DOT_HEIGHT_OFFSET } from './policy.js';
|
||||
|
||||
export function createTiming({ state: layerState, services, parts, source }) {
|
||||
@@ -190,7 +190,7 @@ export function createTiming({ state: layerState, services, parts, source }) {
|
||||
* waypoint-materialization time independently.
|
||||
*/
|
||||
|
||||
function parseRoadsTimed(roadData, trace, parsedBySource) {
|
||||
function parseRoadsTimed(roadData, trace) {
|
||||
/* TRACE_ONLY_BEGIN */
|
||||
const _trafficTimingState = trafficTimingPass(
|
||||
trace,
|
||||
@@ -231,19 +231,11 @@ export function createTiming({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
|
||||
const roads = [];
|
||||
const floorAt = floorCellReader(services.ground);
|
||||
/* TRACE_ONLY_BEGIN */
|
||||
let _trafficTimingWaypointMaterializationMs = 0;
|
||||
/* TRACE_ONLY_END */
|
||||
for (const road of roadData.roads) {
|
||||
if (!road.coordinates || road.coordinates.length < 2) continue;
|
||||
// A replacing snapshot repeats records this load already parsed.
|
||||
const known = parsedBySource?.get(road);
|
||||
if (known) {
|
||||
for (const parsed of known) roads.push(parsed);
|
||||
continue;
|
||||
}
|
||||
const first = roads.length;
|
||||
|
||||
for (const coords of roadSurfaceChunks(road.coordinates)) {
|
||||
const type = road.type;
|
||||
@@ -253,7 +245,7 @@ export function createTiming({ state: layerState, services, parts, source }) {
|
||||
const _trafficTimingMaterializeStart = performance.now();
|
||||
/* TRACE_ONLY_END */
|
||||
const waypoints = coords.map(([lng, lat]) => {
|
||||
const floor = floorAt(lat, lng);
|
||||
const floor = services.ground?.cachedGroundFloor?.(lat, lng);
|
||||
const h =
|
||||
(Number.isFinite(floor) && Math.abs(floor) <= 9000
|
||||
? floor
|
||||
@@ -287,7 +279,6 @@ export function createTiming({ state: layerState, services, parts, source }) {
|
||||
flow: road.flow || null,
|
||||
});
|
||||
}
|
||||
parsedBySource?.set(road, roads.slice(first));
|
||||
}
|
||||
/* TRACE_ONLY_BEGIN */
|
||||
const _trafficTimingMetrics = {
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
export const INFRARED_ALPHA_T0 = 0.4;
|
||||
export const INFRARED_ALPHA_T1 = 0.7;
|
||||
|
||||
/** Return display pixels without changing RGB or the source RGBA bytes.
|
||||
/** How much of the cloud key applies, 0–1, for a display mode or a 0–100
|
||||
* strength. The weather shell passes it to its fragment shader. */
|
||||
export function infraredAmount(strength) {
|
||||
return strength === 'full'
|
||||
? 0
|
||||
: strength === 'filtered' || !Number.isFinite(strength)
|
||||
? 1
|
||||
: Math.max(0, Math.min(100, strength)) / 100;
|
||||
}
|
||||
|
||||
/** Scale each pixel's alpha in place; RGB is left as it is. The scale depends
|
||||
* only on the brightest channel, so it is worked out once for each of the 256
|
||||
* byte values instead of once per pixel: a 4096×2048 image has 8.4 million
|
||||
* pixels and the power curve per pixel held the page for over 100 ms. The
|
||||
* table holds the same numbers the per-pixel formula produced, so the output
|
||||
* bytes are unchanged.
|
||||
* `strength` is 0–100: zero is the full image and 100 is the cloud key.
|
||||
*/
|
||||
export function infraredAlpha(rgba, strength = 'filtered') {
|
||||
const pixels = new Uint8ClampedArray(rgba);
|
||||
const amount =
|
||||
strength === 'full'
|
||||
? 0
|
||||
: strength === 'filtered' || !Number.isFinite(strength)
|
||||
? 1
|
||||
: Math.max(0, Math.min(100, strength)) / 100;
|
||||
export function applyInfraredAlpha(pixels, strength = 'filtered') {
|
||||
const amount = infraredAmount(strength);
|
||||
if (amount === 0) return pixels;
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
const scale = new Float64Array(256);
|
||||
for (let value = 0; value < 256; value++) {
|
||||
// Match Cesium's czm_srgbToLinear conversion in draped imagery.
|
||||
const linearMax =
|
||||
(Math.max(rgba[i], rgba[i + 1], rgba[i + 2]) / 255) ** 2.2;
|
||||
const linearMax = (value / 255) ** 2.2;
|
||||
const t = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
@@ -26,7 +35,21 @@ export function infraredAlpha(rgba, strength = 'filtered') {
|
||||
),
|
||||
);
|
||||
const filtered = t * t * (3 - 2 * t);
|
||||
pixels[i + 3] = rgba[i + 3] * (1 - amount + filtered * amount);
|
||||
scale[value] = 1 - amount + filtered * amount;
|
||||
}
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
const r = pixels[i];
|
||||
const g = pixels[i + 1];
|
||||
const b = pixels[i + 2];
|
||||
const max = r > g ? (r > b ? r : b) : g > b ? g : b;
|
||||
pixels[i + 3] = pixels[i + 3] * scale[max];
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
|
||||
/** Return display pixels without changing RGB or the source RGBA bytes.
|
||||
* `strength` is 0–100: zero is the full image and 100 is the cloud key.
|
||||
*/
|
||||
export function infraredAlpha(rgba, strength = 'filtered') {
|
||||
return applyInfraredAlpha(new Uint8ClampedArray(rgba), strength);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyInfraredAlpha,
|
||||
infraredAlpha,
|
||||
INFRARED_ALPHA_T0,
|
||||
INFRARED_ALPHA_T1,
|
||||
@@ -61,3 +62,43 @@ test('full infrared preserves every channel including partial and zero alpha', (
|
||||
]);
|
||||
assert.deepEqual(infraredAlpha(pixels, 'full'), pixels);
|
||||
});
|
||||
|
||||
test('the lookup table gives the per-pixel curve byte for byte, in place', () => {
|
||||
// The transfer as it was written per pixel, before the table.
|
||||
const perPixel = (rgba, amount) => {
|
||||
const out = new Uint8ClampedArray(rgba);
|
||||
for (let i = 0; i < out.length; i += 4) {
|
||||
const linearMax =
|
||||
(Math.max(rgba[i], rgba[i + 1], rgba[i + 2]) / 255) ** 2.2;
|
||||
const t = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
(linearMax - INFRARED_ALPHA_T0) /
|
||||
(INFRARED_ALPHA_T1 - INFRARED_ALPHA_T0),
|
||||
),
|
||||
);
|
||||
out[i + 3] = rgba[i + 3] * (1 - amount + t * t * (3 - 2 * t) * amount);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
// Every brightest-channel value in each channel, against a spread of alphas.
|
||||
const bytes = [];
|
||||
for (let value = 0; value < 256; value++)
|
||||
for (const alpha of [0, 1, 37, 128, 200, 254, 255]) {
|
||||
bytes.push(value, 0, 0, alpha);
|
||||
bytes.push(value >> 1, value, 0, alpha);
|
||||
bytes.push(0, value >> 2, value, alpha);
|
||||
}
|
||||
const source = new Uint8ClampedArray(bytes);
|
||||
for (const strength of [1, 33, 50, 99, 100, 'filtered', Number.NaN]) {
|
||||
const amount = Number.isFinite(strength) ? strength / 100 : 1;
|
||||
const pixels = source.slice();
|
||||
assert.equal(applyInfraredAlpha(pixels, strength), pixels);
|
||||
assert.deepEqual(pixels, perPixel(source, amount), `strength ${strength}`);
|
||||
}
|
||||
for (const strength of [0, 'full']) {
|
||||
const pixels = source.slice();
|
||||
assert.deepEqual(applyInfraredAlpha(pixels, strength), source);
|
||||
}
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
drawWeatherPixels,
|
||||
closeWeatherPixels,
|
||||
} from './source.js';
|
||||
import { infraredAlpha } from './infraredAlpha.js';
|
||||
import { applyInfraredAlpha } from './infraredAlpha.js';
|
||||
|
||||
export const MAX_MOSAIC_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
@@ -31,13 +31,22 @@ export function processInfraredImage(
|
||||
|
||||
function applyInfraredTransfer(context, canvas, mode) {
|
||||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
pixels.data.set(infraredAlpha(pixels.data, mode));
|
||||
// In place: a copy of a 4096×2048 image is 33 MB, made and written back.
|
||||
applyInfraredAlpha(pixels.data, mode);
|
||||
context.putImageData(pixels, 0, 0);
|
||||
}
|
||||
|
||||
/** Draw pixels a frame source supplied into a new canvas, with the display
|
||||
* transfer for infrared products, and release them. They must be `size`. */
|
||||
function drawSuppliedImage(pixels, product, mode, size, signal, createCanvas) {
|
||||
function drawSuppliedImage(
|
||||
pixels,
|
||||
product,
|
||||
mode,
|
||||
size,
|
||||
signal,
|
||||
createCanvas,
|
||||
transfer,
|
||||
) {
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
const { width, height } = weatherPixelSize(pixels);
|
||||
@@ -48,7 +57,7 @@ function drawSuppliedImage(pixels, product, mode, size, signal, createCanvas) {
|
||||
texture.height = height;
|
||||
const context = texture.getContext('2d');
|
||||
drawWeatherPixels(context, pixels);
|
||||
if (product === 'clouds' || product === 'clouds-regional')
|
||||
if (transfer && (product === 'clouds' || product === 'clouds-regional'))
|
||||
applyInfraredTransfer(context, texture, mode);
|
||||
return texture;
|
||||
} finally {
|
||||
@@ -88,7 +97,8 @@ export function decodeInfraredImage(blob, signal) {
|
||||
}
|
||||
|
||||
/** Fetch one capped whole-extent frame, or a `bbox` detail window of it, and draw
|
||||
* it into a canvas. Infrared frames get the display transfer; a canvas also keeps
|
||||
* it into a canvas. Infrared frames get the display transfer unless `transfer`
|
||||
* is false, for a caller that applies it when drawing; a canvas also keeps
|
||||
* Cesium's texture row order, which an ImageBitmap upload would not. The decoded
|
||||
* image is always released. With a `frameSource` that has `getImage`, the host
|
||||
* supplies the decoded pixels instead and nothing is fetched or decoded here. */
|
||||
@@ -107,6 +117,7 @@ export async function acquireWeatherImage(
|
||||
now = () => performance.now(),
|
||||
onFetched = () => {},
|
||||
frameSource = null,
|
||||
transfer = true,
|
||||
},
|
||||
) {
|
||||
if (frameSource?.getImage) {
|
||||
@@ -127,6 +138,7 @@ export async function acquireWeatherImage(
|
||||
size,
|
||||
signal,
|
||||
createCanvas,
|
||||
transfer,
|
||||
);
|
||||
return { texture, decodeMs: now() - started };
|
||||
}
|
||||
@@ -147,7 +159,7 @@ export async function acquireWeatherImage(
|
||||
if (image.width !== size.width || image.height !== size.height)
|
||||
throw new Error('Invalid weather image dimensions');
|
||||
let texture;
|
||||
if (product === 'clouds' || product === 'clouds-regional')
|
||||
if (transfer && (product === 'clouds' || product === 'clouds-regional'))
|
||||
texture = processInfraredImage(image, mode, createCanvas);
|
||||
else {
|
||||
texture = createCanvas();
|
||||
|
||||
@@ -26,6 +26,75 @@ function canvas() {
|
||||
return output;
|
||||
}
|
||||
|
||||
test('the transfer is applied to the canvas pixels in place, with no copy written back', () => {
|
||||
let read = null;
|
||||
const output = {};
|
||||
output.getContext = () => ({
|
||||
drawImage() {},
|
||||
getImageData: () => {
|
||||
read = { data: new Uint8ClampedArray([194, 0, 0, 200]) };
|
||||
// A second full-size buffer copied over the first is what this guards.
|
||||
read.data.set = () => {
|
||||
throw new Error('the canvas pixels were overwritten from a copy');
|
||||
};
|
||||
return read;
|
||||
},
|
||||
putImageData: (pixels) => {
|
||||
output.pixels = pixels;
|
||||
},
|
||||
});
|
||||
processInfraredImage({ width: 1, height: 1 }, 'filtered', () => output);
|
||||
assert.equal(output.pixels, read);
|
||||
assert.deepEqual([...read.data], [194, 0, 0, 98]);
|
||||
});
|
||||
|
||||
test('with transfer off an infrared frame is drawn and never read back', async () => {
|
||||
const drawn = [];
|
||||
const createCanvas = () => {
|
||||
const output = {};
|
||||
output.getContext = () => ({
|
||||
drawImage: (image) => drawn.push(image),
|
||||
getImageData: () => {
|
||||
throw new Error('the frame was read back on the page');
|
||||
},
|
||||
putImageData: () => {
|
||||
throw new Error('the frame was rewritten on the page');
|
||||
},
|
||||
});
|
||||
return output;
|
||||
};
|
||||
const size = { width: 4096, height: 2048 };
|
||||
const image = { ...size, close() {} };
|
||||
const options = {
|
||||
signal: new AbortController().signal,
|
||||
mode: 'filtered',
|
||||
size,
|
||||
maxBytes: 1024,
|
||||
createCanvas,
|
||||
transfer: false,
|
||||
};
|
||||
const fetched = await acquireWeatherImage(
|
||||
'clouds-regional',
|
||||
'2026-09-15T20:00:00.000Z',
|
||||
{
|
||||
...options,
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
arrayBuffer: async () => new ArrayBuffer(1),
|
||||
}),
|
||||
decodeImage: async () => image,
|
||||
},
|
||||
);
|
||||
assert.equal(fetched.texture.width, 4096);
|
||||
const supplied = { ...size };
|
||||
await acquireWeatherImage('clouds-regional', '2026-09-15T20:00:00.000Z', {
|
||||
...options,
|
||||
frameSource: { getImage: async () => supplied },
|
||||
});
|
||||
assert.deepEqual(drawn, [image, supplied]);
|
||||
});
|
||||
|
||||
test('decoded pixels are processed once on a same-sized canvas', () => {
|
||||
const image = { width: 1, height: 1 };
|
||||
const output = processInfraredImage(image, 'filtered', canvas);
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { acquireWeatherImage } from './infraredImage.js';
|
||||
import {
|
||||
INFRARED_ALPHA_T0,
|
||||
INFRARED_ALPHA_T1,
|
||||
infraredAmount,
|
||||
} from './infraredAlpha.js';
|
||||
import { imageryHostStatus } from './imageryHost.js';
|
||||
import { WEATHER_DETAIL_SIZE, WEATHER_IMAGE_SIZES } from './source.js';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
@@ -36,6 +41,10 @@ const LIFT_STEP = 500;
|
||||
// draw nothing until the full-extent image has arrived, and sample the detail
|
||||
// image only once it has arrived. The window is west, south, east, north in the
|
||||
// full-extent image's texture coordinates.
|
||||
// `infrared` is how much of the cloud key applies, 0–1 (infraredAlpha.js has
|
||||
// the same curve for the globe's draped imagery). It is applied here, to the
|
||||
// image as it was decoded, so a 4096×2048 frame is never read back and
|
||||
// rewritten on the page, and a strength change is one uniform, not a new image.
|
||||
const MATERIAL_SOURCE = `czm_material czm_getMaterial(czm_materialInput materialInput)
|
||||
{
|
||||
czm_material material = czm_getDefaultMaterial(materialInput);
|
||||
@@ -46,7 +55,8 @@ const MATERIAL_SOURCE = `czm_material czm_getMaterial(czm_materialInput material
|
||||
float useDetail = inside * step(1.5, float(detailDimensions.x));
|
||||
vec4 c = mix(coarse, texture(detail, clamp(dst, 0.0, 1.0)), useDetail);
|
||||
material.diffuse = czm_gammaCorrect(c.rgb);
|
||||
material.alpha = c.a * alpha * step(1.5, float(imageDimensions.x));
|
||||
float key = smoothstep(${INFRARED_ALPHA_T0.toFixed(4)}, ${INFRARED_ALPHA_T1.toFixed(4)}, pow(max(c.r, max(c.g, c.b)), 2.2));
|
||||
material.alpha = c.a * mix(1.0, key, infrared) * alpha * step(1.5, float(imageDimensions.x));
|
||||
return material;
|
||||
}
|
||||
`;
|
||||
@@ -63,6 +73,7 @@ function registerMaterial(cesium) {
|
||||
image: cesium.Material.DefaultImageId,
|
||||
detail: cesium.Material.DefaultImageId,
|
||||
alpha: 1,
|
||||
infrared: 0,
|
||||
window: { type: 'vec4', x: 0, y: 0, z: -1, w: -1 },
|
||||
},
|
||||
// Diffuse and alpha share these samples, so one source, not components.
|
||||
@@ -245,6 +256,12 @@ export function createShellSurface({
|
||||
material.uniforms.alpha = value;
|
||||
scene.requestRender();
|
||||
},
|
||||
/** How much of the infrared cloud key applies, 0–1. */
|
||||
setInfrared(value) {
|
||||
if (destroyed || material.uniforms.infrared === value) return;
|
||||
material.uniforms.infrared = value;
|
||||
scene.requestRender();
|
||||
},
|
||||
/** Draw `next` inside `rect` (west, south, east, north in the full-extent
|
||||
* image's texture coordinates, 0–1); null clears it. In the same window only
|
||||
* the image changes, and Cesium keeps drawing the previous texture until
|
||||
@@ -438,10 +455,11 @@ export function createWeatherShell({
|
||||
const visible = () => !hidden && !suspended();
|
||||
const boundsKey = ({ west, south, east, north }) =>
|
||||
`${west},${south},${east},${north}`;
|
||||
const cacheKey = (time, mode, box = null) =>
|
||||
`${time}|${infrared ? mode : 'none'}${box ? `|${boundsKey(box)}` : ''}`;
|
||||
// One image per time and window: the infrared mode is a uniform, not pixels.
|
||||
const cacheKey = (time, box = null) =>
|
||||
box ? `${time}|${boundsKey(box)}` : time;
|
||||
const wantedDetail = () =>
|
||||
current && view ? cacheKey(current.time, current.infrared, view) : null;
|
||||
current && view ? cacheKey(current.time, view) : null;
|
||||
|
||||
function evict(keep = []) {
|
||||
const kept = new Set([...keep, current?.key, detail?.key]);
|
||||
@@ -454,12 +472,11 @@ export function createWeatherShell({
|
||||
}
|
||||
async function acquire(
|
||||
time,
|
||||
mode,
|
||||
signal,
|
||||
{ box = null, keep = [], onFetched } = {},
|
||||
) {
|
||||
signal.throwIfAborted();
|
||||
const key = cacheKey(time, mode, box);
|
||||
const key = cacheKey(time, box);
|
||||
const entry = images.get(key);
|
||||
if (entry) {
|
||||
images.delete(key);
|
||||
@@ -468,7 +485,8 @@ export function createWeatherShell({
|
||||
}
|
||||
const result = await acquireWeatherImage(product, time, {
|
||||
signal,
|
||||
mode,
|
||||
// The surface's shader applies the infrared transfer.
|
||||
transfer: false,
|
||||
size: box ? detailSize : size,
|
||||
bbox: box,
|
||||
maxBytes: MAX_IMAGE_BYTES,
|
||||
@@ -564,9 +582,7 @@ export function createWeatherShell({
|
||||
const job = { key, box: view, controller: new AbortController() };
|
||||
detailJob = job;
|
||||
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
|
||||
void acquire(current.time, current.infrared, job.controller.signal, {
|
||||
box: view,
|
||||
})
|
||||
void acquire(current.time, job.controller.signal, { box: view })
|
||||
.then(({ texture }) => installDetail(job, texture))
|
||||
.catch(() => failDetail(job));
|
||||
}
|
||||
@@ -661,6 +677,7 @@ export function createWeatherShell({
|
||||
loadMs: now() - frame.startedAt,
|
||||
};
|
||||
surface.setAlpha(alpha);
|
||||
surface.setInfrared(infrared ? infraredAmount(frame.infrared) : 0);
|
||||
applyVisibility();
|
||||
lastError = null;
|
||||
evict();
|
||||
@@ -715,7 +732,7 @@ export function createWeatherShell({
|
||||
return changed;
|
||||
},
|
||||
cancelPrefetch,
|
||||
async prefetch(snapshot, time, { infrared: mode = 'filtered' } = {}) {
|
||||
async prefetch(snapshot, time) {
|
||||
let job;
|
||||
try {
|
||||
if (
|
||||
@@ -729,8 +746,8 @@ export function createWeatherShell({
|
||||
// Warm the next frame's detail window too while the window stays put.
|
||||
const box =
|
||||
view && viewBounds === boundsKey(snapshot.bounds) ? view : null;
|
||||
const coarse = cacheKey(time, mode);
|
||||
const key = box ? cacheKey(time, mode, box) : coarse;
|
||||
const coarse = cacheKey(time);
|
||||
const key = box ? cacheKey(time, box) : coarse;
|
||||
if (prefetchJob?.key === key || prefetchedKey === key) return false;
|
||||
cancelPrefetch();
|
||||
job = { key, controller: new AbortController() };
|
||||
@@ -739,8 +756,8 @@ export function createWeatherShell({
|
||||
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
|
||||
// Both images at once: one after the other would outlast playback's step.
|
||||
await Promise.all([
|
||||
acquire(time, mode, signal, { keep: box ? [key] : [] }),
|
||||
box ? acquire(time, mode, signal, { box, keep: [coarse] }) : null,
|
||||
acquire(time, signal, { keep: box ? [key] : [] }),
|
||||
box ? acquire(time, signal, { box, keep: [coarse] }) : null,
|
||||
]);
|
||||
signal.throwIfAborted();
|
||||
if (prefetchJob === job) prefetchedKey = key;
|
||||
@@ -782,7 +799,7 @@ export function createWeatherShell({
|
||||
infrared: mode,
|
||||
bounds,
|
||||
product: snapshot.product,
|
||||
key: cacheKey(time, mode),
|
||||
key: cacheKey(time),
|
||||
mosaic: { fetched: false, decodeMs: null, cached: false },
|
||||
controller: new AbortController(),
|
||||
startedAt: now(),
|
||||
@@ -797,7 +814,7 @@ export function createWeatherShell({
|
||||
});
|
||||
watch(frame, signal);
|
||||
frame.timeout = setTimeout(() => fail(frame), timeoutMs);
|
||||
void acquire(time, mode, frame.controller.signal, {
|
||||
void acquire(time, frame.controller.signal, {
|
||||
onFetched: () => {
|
||||
frame.mosaic.fetched = true;
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
WEATHER_SHELL_HEIGHTS,
|
||||
} from './shellRendering.js';
|
||||
import { NO_IMAGERY_HOST } from './imageryHost.js';
|
||||
import { INFRARED_ALPHA_T0, INFRARED_ALPHA_T1 } from './infraredAlpha.js';
|
||||
import {
|
||||
createShellCesium,
|
||||
createShellScene,
|
||||
@@ -147,12 +148,13 @@ test('surface builds a flat raised rectangle drawn in primitive order without de
|
||||
image: Cesium.Material.DefaultImageId,
|
||||
detail: Cesium.Material.DefaultImageId,
|
||||
alpha: 1,
|
||||
infrared: 0,
|
||||
window: { type: 'vec4', x: 0, y: 0, z: -1, w: -1 },
|
||||
});
|
||||
const { source } = template.fabric;
|
||||
assert.match(
|
||||
source,
|
||||
/material\.alpha = c\.a \* alpha \* step\(1\.5, float\(imageDimensions\.x\)\);/,
|
||||
/material\.alpha = c\.a \* mix\(1\.0, key, infrared\) \* alpha \* step\(1\.5, float\(imageDimensions\.x\)\);/,
|
||||
'nothing draws before the full-extent image',
|
||||
);
|
||||
assert.match(
|
||||
@@ -373,6 +375,7 @@ test('real Cesium builds the two-texture material, the opaque-pass appearance an
|
||||
/uniform sampler2D image_\d+;/,
|
||||
/uniform sampler2D detail_\d+;/,
|
||||
/uniform float alpha_\d+;/,
|
||||
/uniform float infrared_\d+;/,
|
||||
/uniform vec4 window_\d+;/,
|
||||
/uniform ivec3 imageDimensions_\d+;/,
|
||||
/uniform ivec3 detailDimensions_\d+;/,
|
||||
@@ -384,9 +387,16 @@ test('real Cesium builds the two-texture material, the opaque-pass appearance an
|
||||
assert.match(shader, /step\(1\.5, float\(detailDimensions_\d+\.x\)\)/);
|
||||
assert.match(
|
||||
shader,
|
||||
/material\.alpha = c\.a \* alpha_\d+ \* step\(1\.5, float\(imageDimensions_\d+\.x\)\);/,
|
||||
/material\.alpha = c\.a \* mix\(1\.0, key, infrared_\d+\) \* alpha_\d+ \* step\(1\.5, float\(imageDimensions_\d+\.x\)\);/,
|
||||
'the uniform is renamed, the material field is not',
|
||||
);
|
||||
// The cloud key the globe's draped imagery gets from infraredAlpha.js:
|
||||
// brightest channel, 2.2 power, the shared thresholds.
|
||||
assert.ok(
|
||||
shader.includes(
|
||||
`float key = smoothstep(${INFRARED_ALPHA_T0.toFixed(4)}, ${INFRARED_ALPHA_T1.toFixed(4)}, pow(max(c.r, max(c.g, c.b)), 2.2));`,
|
||||
),
|
||||
);
|
||||
assert.match(shader, /float useDetail = inside \* step/, 'locals keep names');
|
||||
const detail = { width: 4096, height: 2048 };
|
||||
surface.setDetail(detail, { west: 0.1, south: 0.2, east: 0.3, north: 0.4 });
|
||||
@@ -743,12 +753,12 @@ test('prefetch warms a decoded frame without staging and cancels cleanly', async
|
||||
h.shell.clear();
|
||||
});
|
||||
|
||||
test('infrared frames get the display transfer; radar and lightning keep their pixels', async () => {
|
||||
for (const [product, transfer] of [
|
||||
['clouds-regional', true],
|
||||
['clouds', true],
|
||||
['radar', false],
|
||||
['lightning', false],
|
||||
test('infrared frames reach the shader as decoded, with the cloud key as a uniform; radar and lightning get none', async () => {
|
||||
for (const [product, amount] of [
|
||||
['clouds-regional', 1],
|
||||
['clouds', 1],
|
||||
['radar', 0],
|
||||
['lightning', 0],
|
||||
]) {
|
||||
const h = harness({ product });
|
||||
const pending = h.shell.setFrame(snapshot(product), times[0]);
|
||||
@@ -756,11 +766,10 @@ test('infrared frames get the display transfer; radar and lightning keep their p
|
||||
const image = h.material().uniforms.image;
|
||||
assert.equal(image.source, h.decoded[0], 'drawn into a canvas');
|
||||
assert.equal(h.decoded[0].closed, true);
|
||||
assert.equal(image.pixels !== undefined, transfer, product);
|
||||
if (transfer)
|
||||
assert.deepEqual([...image.pixels], [194, 0, 0, 98], 'clouds only');
|
||||
assert.equal(image.pixels, undefined, `${product}: pixels never rewritten`);
|
||||
h.render();
|
||||
assert.equal(await pending, true);
|
||||
assert.equal(h.material().uniforms.infrared, amount, product);
|
||||
assert.equal(
|
||||
h.shell.getDiagnostics().height,
|
||||
WEATHER_SHELL_HEIGHTS[product],
|
||||
@@ -769,20 +778,28 @@ test('infrared frames get the display transfer; radar and lightning keep their p
|
||||
}
|
||||
});
|
||||
|
||||
test('an infrared mode change restages the same time with its own cached canvas', async () => {
|
||||
test('an infrared mode change is a uniform on the same image: no fetch, no new canvas', async () => {
|
||||
const h = harness({ product: 'clouds' });
|
||||
assert.equal(await show(h, times[0]), true);
|
||||
const filtered = h.material().uniforms.image;
|
||||
const image = h.material().uniforms.image;
|
||||
assert.equal(h.material().uniforms.infrared, 1);
|
||||
assert.equal(await show(h, times[0]), true, 'same frame is a no-op');
|
||||
assert.equal(h.fetches.length, 1);
|
||||
const renders = h.scene.renders;
|
||||
assert.equal(await show(h, times[0], { infrared: 'full' }), true);
|
||||
const full = h.material().uniforms.image;
|
||||
assert.notEqual(full, filtered);
|
||||
assert.deepEqual([...full.pixels], [194, 0, 0, 200]);
|
||||
assert.equal(h.material().uniforms.image, image);
|
||||
assert.equal(h.material().uniforms.infrared, 0);
|
||||
assert.equal(h.shell.getDiagnostics().infrared, 'full');
|
||||
assert.ok(h.scene.renders > renders, 'the new strength is drawn');
|
||||
assert.equal(await show(h, times[0], { infrared: 40 }), true);
|
||||
assert.equal(h.material().uniforms.infrared, 0.4);
|
||||
assert.equal(h.shell.getDiagnostics().infrared, 40);
|
||||
assert.equal(await show(h, times[0], { infrared: 'filtered' }), true);
|
||||
assert.equal(h.material().uniforms.image, filtered, 'cache hit');
|
||||
assert.equal(h.fetches.length, 2);
|
||||
assert.equal(h.material().uniforms.image, image);
|
||||
assert.equal(h.material().uniforms.infrared, 1);
|
||||
assert.equal(h.fetches.length, 1);
|
||||
assert.equal(h.decoded.length, 1);
|
||||
assert.equal(h.shell.getDiagnostics().cache.mosaics, 1);
|
||||
h.shell.clear();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user