mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge branch 'main' into contrib/gods-023-firms-coordinate-evidence-20261011-reviewed
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
493b154a6b
8 files changed
+180
-24
No files matched your search
@@ -24,6 +24,9 @@
|
||||
the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is
|
||||
unchanged; extra cameras share it round-robin with the other packs.
|
||||
|
||||
- Street Level counts unique tiles before ranking so overlapping dateline halves
|
||||
in polar views do not waste slots in the 16-tile overview budget.
|
||||
|
||||
- Directions no longer shows a truncation notice for a route with exactly 200
|
||||
turns, and a roundabout at the 200th turn keeps its exit distance.
|
||||
|
||||
@@ -35,6 +38,11 @@
|
||||
|
||||
- Fire detections with missing or impossible positions are skipped while genuine zero coordinates remain valid.
|
||||
|
||||
- Frequently reused terrain batches retain their cached heights ahead of unused cells.
|
||||
|
||||
- Recent Imagery’s exported `setBox` and `searchHls` reject missing, blank or
|
||||
boolean programmatic box edges instead of interpreting them as zero coordinates.
|
||||
|
||||
- Treat explicitly current, complete empty vessel coverage as a successful update,
|
||||
clearing obsolete contacts while retaining stale or incomplete snapshots.
|
||||
|
||||
|
||||
+13
-4
@@ -1198,11 +1198,11 @@ entries (`DEFAULT_MAX_CACHE_ENTRIES`, overridable per instance through
|
||||
visits to one place share a key but does not bound how many distinct places a
|
||||
session visits, and the cache previously had no eviction, so a long session
|
||||
retained every coordinate it ever resolved. Eviction is least-recently-used:
|
||||
a consumer read promotes its entry, so the cell being watched is not dropped for
|
||||
having been resolved early. The bound sits far above a city-scale session, so
|
||||
a consumer read, including a warm hit in a batch lookup, promotes its entry, so
|
||||
the cell being watched is not dropped for having been resolved early. The bound sits far above a city-scale session, so
|
||||
normal use never evicts and issues no additional proxy requests. A batch's
|
||||
results are assembled from what that call resolved, so eviction during a large
|
||||
batch cannot report a just-resolved point as unresolved. Re:Earth and
|
||||
results are assembled from what that call resolved or found warm, so eviction
|
||||
during a large batch cannot report one of its points as unresolved. Re:Earth and
|
||||
geoid-fallback entry semantics, the fallback cooldown and the abort-time flush
|
||||
are unchanged.
|
||||
|
||||
@@ -3134,6 +3134,10 @@ sequence lines below 60 km; the proxy strips the unused `image` layer from z14
|
||||
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
|
||||
requests for a tile.
|
||||
|
||||
Mapillary coverage ranks unique tile addresses before applying the 16-tile
|
||||
overview budget. Overlapping dateline halves in a polar view therefore do not
|
||||
waste overview slots on the same tile; loading already skips duplicate keys.
|
||||
|
||||
On Google 3D (`photoreal`) at street zoom (in below 1,400 m above ground, out
|
||||
above 1,800 m) the surface mode switches from `draped` to `terrain`: draped
|
||||
lines land on roofs and tree tops, so `groundCast.js` instead places lines,
|
||||
@@ -4451,6 +4455,11 @@ are omitted rather than framing the wrong part of the globe.
|
||||
|
||||
### Recent Imagery (September 2026)
|
||||
|
||||
Programmatic boxes passed to the exported `setBox` and `searchHls` operations
|
||||
reject missing, blank or boolean edges instead of interpreting them as zero
|
||||
coordinates. Valid finite numeric edges remain accepted; the app’s drag tool,
|
||||
pins and share links already supply complete boxes.
|
||||
|
||||
- The layer sits in the Data Layers panel's Cameras group beside Cameras (CCTV); Mapped ALPR Cameras moved to the Infrastructure group to make room.
|
||||
- DATA ▸ **Recent Imagery** (`recent-imagery`, `src/layers/recentImagery/`) lists the last 30 UTC days of imagery for a selected box and drapes the days you pick: HLS S30 (Sentinel-2) and L30 (Landsat 8/9) at 30 m, and the VIIRS NOAA-21 daily overview at 250 m. Keyless and browser-direct: NASA CMR for which days have HLS granules in the box (`catalog.js`, both collections concurrently, paged with `CMR-Search-After` up to 2,000 records each, `truncated` against CMR `hits`), GIBS WMTS for tiles, Worldview Snapshots for thumbnails and exports. Worldview's `Data-Present` header is the only availability signal: GIBS answers an empty tile with HTTP 200, and the JPEG overview would drape black, so a day confirmed empty never drapes.
|
||||
- **Box.** SELECT BOX (`src/ui/imageryBoxTool.js`) claims the pointer like Draw, borrows the stock click actions and freezes the camera only mid-drag; USE VIEW takes the camera rectangle; `boxFromPinAt` grows a 10 km box. Boxes must be ≤ 1,000 km a side, on one side of the dateline and inside the Web-Mercator limit (`validateBox`). A refusal (`boxError`, including the tool's own via `reportBoxRefusal`) stays at the top of the panel until a box succeeds; a view over the cap is refused with its size, never shrunk. An oversized box or view (`Box is … km wide · limit 1,000 km`, `View is … wide · …`) sets the hint to `Zoom in or draw a smaller box` and enables ZOOM IN: `zoomToFit()` flies top-down (1.2 s) to the refused box's centre, or the ground under the canvas centre for a view, at the height whose view is about 400 km wide for the canvas aspect and the camera's vertical FOV (`fitViewHeightM`, clamped 5–400 km), and returns that height.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
boxCentre,
|
||||
boxFromRectangle,
|
||||
dequantizeBox,
|
||||
validateBox,
|
||||
} from './model.js';
|
||||
import { searchHls } from './catalog.js';
|
||||
|
||||
test('missing imagery edges do not turn into invented zero coordinates', () => {
|
||||
for (const value of [null, undefined, '', ' ', false, true]) {
|
||||
const box = { west: value, south: 0, east: 1, north: 1 };
|
||||
assert.equal(validateBox(box).ok, false);
|
||||
assert.equal(validateBox(box).reason, 'invalid');
|
||||
assert.equal(boxCentre(box), null);
|
||||
}
|
||||
assert.equal(validateBox({ west: 0, south: 0, east: 1, north: 1 }).ok, true);
|
||||
assert.equal(
|
||||
validateBox({ west: '0', south: '0', east: '1', north: '1' }).ok,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('every edge and every box entry point refuses a missing edge', async () => {
|
||||
const blanks = [null, undefined, '', ' ', false, [], {}];
|
||||
for (const edge of ['west', 'south', 'east', 'north']) {
|
||||
for (const value of blanks) {
|
||||
const box = { west: 1, south: 1, east: 2, north: 2, [edge]: value };
|
||||
assert.equal(validateBox(box).ok, false, `${edge}=${String(value)}`);
|
||||
// Share-link integers and Cesium radians are converted per edge.
|
||||
assert.equal(dequantizeBox(box), null, `share ${edge}`);
|
||||
assert.equal(boxFromRectangle(box), null, `radians ${edge}`);
|
||||
}
|
||||
}
|
||||
let fetched = false;
|
||||
await assert.rejects(
|
||||
searchHls({
|
||||
box: { west: ' ', south: 1, east: 2, north: 2 },
|
||||
fetchImpl: async () => {
|
||||
fetched = true;
|
||||
},
|
||||
}),
|
||||
TypeError,
|
||||
);
|
||||
assert.equal(fetched, false);
|
||||
assert.ok(dequantizeBox({ west: 0, south: 0, east: 100000, north: 100000 }));
|
||||
});
|
||||
@@ -75,6 +75,14 @@ const pad2 = (value) => String(value).padStart(2, '0');
|
||||
* east are not swapped: west > east crosses the dateline and `validateBox`
|
||||
* rejects it. Null when any edge is nonfinite.
|
||||
*/
|
||||
/** A box edge as a number, or null when it is missing, blank or not numeric. */
|
||||
function edgeNumber(value) {
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
||||
if (typeof value !== 'string' || !value.trim()) return null;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function normalizeBox(box) {
|
||||
if (!box || typeof box !== 'object') return null;
|
||||
const [west, south, east, north] = [
|
||||
@@ -82,8 +90,8 @@ function normalizeBox(box) {
|
||||
box.south,
|
||||
box.east,
|
||||
box.north,
|
||||
].map(Number);
|
||||
if (![west, south, east, north].every(Number.isFinite)) return null;
|
||||
].map(edgeNumber);
|
||||
if ([west, south, east, north].some((edge) => edge == null)) return null;
|
||||
return {
|
||||
west,
|
||||
south: Math.min(south, north),
|
||||
@@ -211,11 +219,16 @@ export function boxFromPin(lon, lat, sideKm = PIN_BOX_SIDE_KM) {
|
||||
|
||||
function mapBox(box, fn) {
|
||||
if (!box || typeof box !== 'object') return null;
|
||||
// Converting first would turn a blank edge into 0 before normalizeBox sees it.
|
||||
const edge = (value) => {
|
||||
const number = edgeNumber(value);
|
||||
return number == null ? null : fn(number);
|
||||
};
|
||||
return normalizeBox({
|
||||
west: fn(box.west),
|
||||
south: fn(box.south),
|
||||
east: fn(box.east),
|
||||
north: fn(box.north),
|
||||
west: edge(box.west),
|
||||
south: edge(box.south),
|
||||
east: edge(box.east),
|
||||
north: edge(box.north),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { tilesForBbox } from './tileMath.js';
|
||||
|
||||
test('reachable polar dateline coverage ranks 24 distinct tiles before overview truncation', () => {
|
||||
// coverage uses the actual view bbox above zoom 1; polar views can wrap.
|
||||
const bbox = [-10, 60, -20, 85];
|
||||
const result = tilesForBbox(bbox, 3);
|
||||
const keys = result.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`);
|
||||
assert.equal(keys.length, 24);
|
||||
assert.equal(new Set(keys).size, 24);
|
||||
assert.equal(result.total, 24);
|
||||
assert.equal(result.truncated, false);
|
||||
const overview = tilesForBbox(bbox, 3, { limit: 16 });
|
||||
assert.equal(overview.total, 24);
|
||||
assert.equal(overview.tiles.length, 16);
|
||||
assert.equal(
|
||||
new Set(overview.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`)).size,
|
||||
16,
|
||||
);
|
||||
assert.equal(overview.truncated, true);
|
||||
});
|
||||
|
||||
test('a limit that fits every distinct tile is not spent on repeats', () => {
|
||||
// Before de-duplication this box yielded 27 entries for 24 tiles, so a
|
||||
// 24-slot overview held repeats and still reported itself truncated.
|
||||
const result = tilesForBbox([-10, 60, -20, 85], 3, { limit: 24 });
|
||||
const keys = result.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`);
|
||||
assert.equal(new Set(keys).size, 24);
|
||||
assert.equal(result.truncated, false);
|
||||
});
|
||||
@@ -146,8 +146,16 @@ export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) {
|
||||
const dx = Math.abs(tile.x - centre.x);
|
||||
return Math.min(dx, n - dx) ** 2 + (tile.y - centre.y) ** 2;
|
||||
};
|
||||
// Each tile's distance is computed once; ties keep their row order.
|
||||
const ranked = tiles
|
||||
// The date-line halves share a column when the gap between east and west is
|
||||
// narrower than a tile; count that column once so a capped overview does not
|
||||
// spend its slots on repeats. Each tile's distance is computed once; ties keep
|
||||
// their row order.
|
||||
const unique = [
|
||||
...new Map(
|
||||
tiles.map((tile) => [`${tile.x}/${tile.y}/${tile.z}`, tile]),
|
||||
).values(),
|
||||
];
|
||||
const ranked = unique
|
||||
.map((tile) => ({ tile, d: distance2(tile) }))
|
||||
.sort((a, b) => a.d - b.d)
|
||||
.map(({ tile }) => tile);
|
||||
|
||||
@@ -272,28 +272,26 @@ export function createTerrainHeights({
|
||||
const uncached = [];
|
||||
const seenKeys = new Set();
|
||||
const now = Date.now();
|
||||
// What this call resolved or found warm, so the assembly below can report a
|
||||
// point even if a later chunk's writes pushed it past CACHE_MAX_ENTRIES.
|
||||
// Without it a batch big enough to evict its own entries would report
|
||||
// points as unresolved that it had in fact resolved.
|
||||
const resolvedThisCall = new Map();
|
||||
for (const item of work) {
|
||||
const entry = cache.get(item.key);
|
||||
const entry = cacheTouch(item.key);
|
||||
const fallbackCooling =
|
||||
entry?.source === 'geoid-fallback' &&
|
||||
Number.isFinite(entry.retryAt) &&
|
||||
now < entry.retryAt;
|
||||
if (
|
||||
(entry && entry.source === 'reearth') ||
|
||||
fallbackCooling ||
|
||||
seenKeys.has(item.key)
|
||||
)
|
||||
if ((entry && entry.source === 'reearth') || fallbackCooling) {
|
||||
resolvedThisCall.set(item.key, entry);
|
||||
continue;
|
||||
}
|
||||
if (seenKeys.has(item.key)) continue;
|
||||
seenKeys.add(item.key);
|
||||
uncached.push(item);
|
||||
}
|
||||
|
||||
// What this call resolved, so the assembly below can report a point even
|
||||
// if a later chunk's writes pushed it past CACHE_MAX_ENTRIES. Without it a
|
||||
// batch big enough to evict its own earlier chunks would report points as
|
||||
// unresolved that it had in fact just resolved.
|
||||
const resolvedThisCall = new Map();
|
||||
|
||||
// Resolve the network path in sequential <=CHUNK_SIZE chunks. Each chunk's
|
||||
// failure is isolated to that chunk's points (geoid fallback), so a single
|
||||
// bad chunk doesn't lose results for the rest of a large batch.
|
||||
|
||||
@@ -114,6 +114,47 @@ test('a batch larger than the cache bound still reports every point it resolved'
|
||||
assert.equal(warm.length, 4);
|
||||
});
|
||||
|
||||
test('warm batch consumers retain their terrain cells ahead of unused cells', async () => {
|
||||
let calls = 0;
|
||||
const terrain = createTerrainHeights({
|
||||
maxCacheEntries: 2,
|
||||
source: {
|
||||
async getHeights(points) {
|
||||
calls++;
|
||||
return points.map((p) => ({ ellipsoid: 100 + p.lon }));
|
||||
},
|
||||
},
|
||||
});
|
||||
const p = (lon) => ({ lat: 0, lon });
|
||||
await terrain.resolveEllipsoidalGround([p(1)]);
|
||||
await terrain.resolveEllipsoidalGround([p(2)]);
|
||||
await terrain.resolveEllipsoidalGround([p(1)]);
|
||||
assert.equal(calls, 2);
|
||||
await terrain.resolveEllipsoidalGround([p(3)]);
|
||||
assert.equal(terrain.cachedEllipsoidalGround(0, 1), 101);
|
||||
assert.equal(terrain.cachedEllipsoidalGround(0, 2), null);
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
test('a batch still reports its warm points after its own fetches evict them', async () => {
|
||||
const terrain = createTerrainHeights({
|
||||
source: stubSource(),
|
||||
maxCacheEntries: 2,
|
||||
});
|
||||
await terrain.resolveEllipsoidalGround([pointAt(0)]);
|
||||
|
||||
const results = await terrain.resolveEllipsoidalGround([
|
||||
pointAt(0),
|
||||
pointAt(1),
|
||||
pointAt(2),
|
||||
]);
|
||||
|
||||
results.forEach((result, i) => {
|
||||
assert.equal(result.source, 'reearth', `point ${i} reported resolved`);
|
||||
assert.equal(result.ellipsoid, heightAt(lonAt(i)));
|
||||
});
|
||||
});
|
||||
|
||||
test('an out-of-range cache bound falls back to the default instead of disabling the cache', async () => {
|
||||
for (const maxCacheEntries of [0, -1, Number.NaN, undefined]) {
|
||||
const terrain = createTerrainHeights({
|
||||
|
||||
Reference in new issue
Block a user