mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge branch 'main' into contrib/gods-026-map-subscription-ownership-20261011-reviewed
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
56ca8fd922
11 files changed
+280
-7
No files matched your search
@@ -38,6 +38,12 @@
|
|||||||
|
|
||||||
- Map state updates stop delivering to subscribers removed during the same notification.
|
- Map state updates stop delivering to subscribers removed during the same notification.
|
||||||
|
|
||||||
|
- Director assets cancelled during the final stream read are discarded with reader cleanup.
|
||||||
|
|
||||||
|
- Analyst queries preserve useful rows when an upstream timestamp is outside the supported date range.
|
||||||
|
|
||||||
|
- 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.
|
- Frequently reused terrain batches retain their cached heights ahead of unused cells.
|
||||||
|
|
||||||
- Recent Imagery’s exported `setBox` and `searchHls` reject missing, blank or
|
- Recent Imagery’s exported `setBox` and `searchHls` reject missing, blank or
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ Windows/Claude Desktop Cowork confirmation remains outstanding (#927).
|
|||||||
|
|
||||||
Tool argument string lengths follow JSON Schema Unicode code-point counts, so supplementary characters count once rather than as two UTF-16 units.
|
Tool argument string lengths follow JSON Schema Unicode code-point counts, so supplementary characters count once rather than as two UTF-16 units.
|
||||||
|
|
||||||
|
Query timestamps outside the representable Date range are reported as null, preserving the rest of the result row.
|
||||||
|
|
||||||
`gods-eye-view/tools` defines queries that answer questions from the app's data,
|
`gods-eye-view/tools` defines queries that answer questions from the app's data,
|
||||||
and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context
|
and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context
|
||||||
Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client,
|
Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client,
|
||||||
@@ -396,6 +398,10 @@ and geographic placement remain distinct; Stop/replacement releases resources.
|
|||||||
Import acquires nothing. See [data packs](DIRECTOR-DATA-PACKS.md). Existing scene
|
Import acquires nothing. See [data packs](DIRECTOR-DATA-PACKS.md). Existing scene
|
||||||
content, assets and credit remain unchanged.
|
content, assets and credit remain unchanged.
|
||||||
|
|
||||||
|
The asset directory source rejects with the caller's abort reason if
|
||||||
|
cancellation arrives during any read, including the final one, or while the
|
||||||
|
reader is being released.
|
||||||
|
|
||||||
Director version-4 documents add named geographic anchors and optional explicit
|
Director version-4 documents add named geographic anchors and optional explicit
|
||||||
camera moves with easing, duration and holds. Playback and seek share one
|
camera moves with easing, duration and holds. Playback and seek share one
|
||||||
coordinate sampler; navigation/manual input revokes authored motion and pending
|
coordinate sampler; navigation/manual input revokes authored motion and pending
|
||||||
@@ -1429,6 +1435,8 @@ accessible name remains "Don't show this again". Control behavior is unchanged.
|
|||||||
|
|
||||||
## FIRMS source status
|
## FIRMS source status
|
||||||
|
|
||||||
|
FIRMS ingestion and adaptation require explicit finite coordinates within WGS84 ranges; genuine zero coordinates remain valid and admitted detection indices stay sequential.
|
||||||
|
|
||||||
The FIRMS proxy records source success after appending its rows. If aggregation
|
The FIRMS proxy records source success after appending its rows. If aggregation
|
||||||
throws, that source reports failure without a contradictory success entry.
|
throws, that source reports failure without a contradictory success entry.
|
||||||
The existing per-record append continues to support large feeds; sequential
|
The existing per-record append continues to support large feeds; sequential
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { parseFirmsCsv } from './firmsCsv.js';
|
||||||
|
import { adaptFirmsRecords } from './firmsAdapt.js';
|
||||||
|
test('missing positions do not become fire detections at zero coordinates', () => {
|
||||||
|
const csv =
|
||||||
|
'latitude,longitude,acq_date,acq_time,confidence,frp\n,1,2026-07-16,1200,n,1\n1, ,2026-07-16,1200,n,1\n95,1,2026-07-16,1200,n,1\n1,181,2026-07-16,1200,n,1\n0,0,2026-07-16,1200,n,1\n-90,-180,2026-07-16,1200,n,1';
|
||||||
|
assert.deepEqual(
|
||||||
|
parseFirmsCsv(csv).map((r) => [r.lat, r.lon]),
|
||||||
|
[
|
||||||
|
[0, 0],
|
||||||
|
[-90, -180],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const records = [
|
||||||
|
{ lat: null, lon: 1 },
|
||||||
|
{ lat: '', lon: 1 },
|
||||||
|
{ lat: false, lon: 1 },
|
||||||
|
{ lat: 95, lon: 1 },
|
||||||
|
{ lat: 1, lon: 181 },
|
||||||
|
{ lat: '0', lon: 0 },
|
||||||
|
{ lat: -90, lon: -180 },
|
||||||
|
];
|
||||||
|
assert.deepEqual(
|
||||||
|
adaptFirmsRecords(records).map((r) => [r.index, r.lat, r.lon]),
|
||||||
|
[
|
||||||
|
[0, 0, 0],
|
||||||
|
[1, -90, -180],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('both axes keep their exact bounds and drop values just past them', () => {
|
||||||
|
const row = (lat, lon) => `${lat},${lon},2026-07-16,1200,n,1`;
|
||||||
|
const csv = [
|
||||||
|
'latitude,longitude,acq_date,acq_time,confidence,frp',
|
||||||
|
row(90, 180),
|
||||||
|
row(-90, -180),
|
||||||
|
row(90.000001, 0),
|
||||||
|
row(-90.000001, 0),
|
||||||
|
row(0, 180.000001),
|
||||||
|
row(0, -180.000001),
|
||||||
|
row(' ', 5),
|
||||||
|
row(5, '\t'),
|
||||||
|
].join('\n');
|
||||||
|
assert.deepEqual(
|
||||||
|
parseFirmsCsv(csv).map((r) => [r.lat, r.lon]),
|
||||||
|
[
|
||||||
|
[90, 180],
|
||||||
|
[-90, -180],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const kept = adaptFirmsRecords([
|
||||||
|
{ lat: 90, lon: 180 },
|
||||||
|
{ lat: '-90', lon: '-180' },
|
||||||
|
{ lat: -90.000001, lon: 0 },
|
||||||
|
{ lat: 0, lon: -180.000001 },
|
||||||
|
{ lat: 5, lon: ' ' },
|
||||||
|
{ lat: 5, lon: undefined },
|
||||||
|
{ lat: [5], lon: 5 },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
kept.map((r) => [r.lat, r.lon]),
|
||||||
|
[
|
||||||
|
[90, 180],
|
||||||
|
[-90, -180],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
+19
-3
@@ -7,8 +7,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map proxy fire records into internal fire records. Records with
|
* Map proxy fire records into internal fire records. Records with missing,
|
||||||
* non-finite coordinates are skipped; `index` is the post-skip position
|
* non-numeric or out-of-range (WGS84) coordinates are skipped; `index` is the post-skip position
|
||||||
* (it keys pick ids and context-store ids, so it must stay sequential).
|
* (it keys pick ids and context-store ids, so it must stay sequential).
|
||||||
* `contextEntity`/`position` start null and are lazily filled by the layer.
|
* `contextEntity`/`position` start null and are lazily filled by the layer.
|
||||||
* @param {?Array<Object>} records - /api/firms `fires` array.
|
* @param {?Array<Object>} records - /api/firms `fires` array.
|
||||||
@@ -21,7 +21,15 @@ export function adaptFirmsRecords(records) {
|
|||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
const lat = Number(record?.lat);
|
const lat = Number(record?.lat);
|
||||||
const lon = Number(record?.lon);
|
const lon = Number(record?.lon);
|
||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
if (
|
||||||
|
!hasCoordinateValue(record?.lat) ||
|
||||||
|
!hasCoordinateValue(record?.lon) ||
|
||||||
|
!Number.isFinite(lat) ||
|
||||||
|
!Number.isFinite(lon) ||
|
||||||
|
Math.abs(lat) > 90 ||
|
||||||
|
Math.abs(lon) > 180
|
||||||
|
)
|
||||||
|
continue;
|
||||||
fires.push({
|
fires.push({
|
||||||
index: fires.length,
|
index: fires.length,
|
||||||
lat,
|
lat,
|
||||||
@@ -110,3 +118,11 @@ function finiteNumber(value) {
|
|||||||
const number = Number(value);
|
const number = Number(value);
|
||||||
return Number.isFinite(number) ? number : 0;
|
return Number.isFinite(number) ? number : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same rule as parseFirmsCsv: a blank or non-numeric value is not a zero.
|
||||||
|
function hasCoordinateValue(value) {
|
||||||
|
return (
|
||||||
|
typeof value === 'number' ||
|
||||||
|
(typeof value === 'string' && value.trim() !== '')
|
||||||
|
);
|
||||||
|
}
|
||||||
+11
-1
@@ -105,7 +105,17 @@ export function parseFirmsCsv(text) {
|
|||||||
if (parts.length < header.length) continue; // malformed row — skip
|
if (parts.length < header.length) continue; // malformed row — skip
|
||||||
const lat = Number(parts[iLat]);
|
const lat = Number(parts[iLat]);
|
||||||
const lon = Number(parts[iLon]);
|
const lon = Number(parts[iLon]);
|
||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
// Blank or out-of-range positions are dropped, never read as 0 (see
|
||||||
|
// hasCoordinateValue in firmsAdapt.js for the proxy-record side).
|
||||||
|
if (
|
||||||
|
!parts[iLat].trim() ||
|
||||||
|
!parts[iLon].trim() ||
|
||||||
|
!Number.isFinite(lat) ||
|
||||||
|
!Number.isFinite(lon) ||
|
||||||
|
Math.abs(lat) > 90 ||
|
||||||
|
Math.abs(lon) > 180
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
|
||||||
records.push({
|
records.push({
|
||||||
lat,
|
lat,
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createAssetDirectorySource } from './source.js';
|
||||||
|
test('cancellation during the terminating read cannot return a scene asset', async () => {
|
||||||
|
for (const cancel of [true, false]) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let reads = 0,
|
||||||
|
cancelled = 0,
|
||||||
|
released = 0;
|
||||||
|
const source = createAssetDirectorySource({
|
||||||
|
baseUrl: 'https://fixture.example/assets/',
|
||||||
|
fetchImpl: async () => ({
|
||||||
|
ok: true,
|
||||||
|
headers: new Headers({ 'content-type': 'application/octet-stream' }),
|
||||||
|
body: {
|
||||||
|
getReader() {
|
||||||
|
return {
|
||||||
|
async read() {
|
||||||
|
reads++;
|
||||||
|
if (reads === 1)
|
||||||
|
return { done: false, value: new Uint8Array([1, 2]) };
|
||||||
|
if (cancel) controller.abort();
|
||||||
|
return { done: true };
|
||||||
|
},
|
||||||
|
async cancel() {
|
||||||
|
cancelled++;
|
||||||
|
},
|
||||||
|
releaseLock() {
|
||||||
|
released++;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const pending = source({ path: 'scene.bin', signal: controller.signal });
|
||||||
|
if (cancel) await assert.rejects(pending, { name: 'AbortError' });
|
||||||
|
else assert.deepEqual([...(await pending).bytes], [1, 2]);
|
||||||
|
assert.equal(cancelled, 1);
|
||||||
|
assert.equal(released, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancellation during reader cleanup cannot return a scene asset', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const reason = new DOMException('scene changed', 'AbortError');
|
||||||
|
let released = 0;
|
||||||
|
const source = createAssetDirectorySource({
|
||||||
|
baseUrl: 'https://fixture.example/assets/',
|
||||||
|
fetchImpl: async () => ({
|
||||||
|
ok: true,
|
||||||
|
headers: new Headers(),
|
||||||
|
body: {
|
||||||
|
getReader() {
|
||||||
|
let reads = 0;
|
||||||
|
return {
|
||||||
|
async read() {
|
||||||
|
reads++;
|
||||||
|
return reads === 1
|
||||||
|
? { done: false, value: new Uint8Array([1]) }
|
||||||
|
: { done: true };
|
||||||
|
},
|
||||||
|
async cancel() {
|
||||||
|
controller.abort(reason);
|
||||||
|
},
|
||||||
|
releaseLock() {
|
||||||
|
released++;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
source({ path: 'scene.bin', signal: controller.signal }),
|
||||||
|
(error) => error === reason,
|
||||||
|
);
|
||||||
|
assert.equal(released, 1);
|
||||||
|
});
|
||||||
@@ -41,6 +41,7 @@ export function createAssetDirectorySource({
|
|||||||
for (;;) {
|
for (;;) {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
|
signal?.throwIfAborted();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
length += value.byteLength;
|
length += value.byteLength;
|
||||||
if (length > maxBytes) throw new Error('Asset exceeds byte limit');
|
if (length > maxBytes) throw new Error('Asset exceeds byte limit');
|
||||||
@@ -50,6 +51,8 @@ export function createAssetDirectorySource({
|
|||||||
await reader.cancel().catch(() => {});
|
await reader.cancel().catch(() => {});
|
||||||
reader.releaseLock();
|
reader.releaseLock();
|
||||||
}
|
}
|
||||||
|
// Cleanup awaits too; a cancel during it must still discard the asset.
|
||||||
|
signal?.throwIfAborted();
|
||||||
const bytes = new Uint8Array(length);
|
const bytes = new Uint8Array(length);
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ export function capRows(rows, limit = DEFAULT_LIMIT) {
|
|||||||
|
|
||||||
/** Format an epoch millisecond time as ISO 8601, or null. */
|
/** Format an epoch millisecond time as ISO 8601, or null. */
|
||||||
export function isoTime(ms) {
|
export function isoTime(ms) {
|
||||||
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
if (!Number.isFinite(ms)) return null;
|
||||||
|
const date = new Date(ms);
|
||||||
|
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "1 earthquake", "3 earthquakes". */
|
/** "1 earthquake", "3 earthquakes". */
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { isoTime } from './results.js';
|
||||||
|
import { getBikeShare } from './queries/mobility.js';
|
||||||
|
test('out-of-range finite timestamps are unknown rather than throwing', async () => {
|
||||||
|
for (const value of [1e30, -1e30, NaN, Infinity, null])
|
||||||
|
assert.equal(isoTime(value), null);
|
||||||
|
assert.equal(isoTime(0), '1970-01-01T00:00:00.000Z');
|
||||||
|
assert.equal(isoTime(8640000000000000), '+275760-09-13T00:00:00.000Z');
|
||||||
|
assert.equal(isoTime(-8640000000000000), '-271821-04-20T00:00:00.000Z');
|
||||||
|
for (const value of [8640000000000001, -8640000000000001, -Infinity])
|
||||||
|
assert.equal(isoTime(value), null);
|
||||||
|
assert.equal(isoTime(-1), '1969-12-31T23:59:59.999Z');
|
||||||
|
const station = {
|
||||||
|
station_id: 's1',
|
||||||
|
name: 'Fixture',
|
||||||
|
lat: 0,
|
||||||
|
lon: 0,
|
||||||
|
capacity: 4,
|
||||||
|
};
|
||||||
|
const result = await getBikeShare.run(
|
||||||
|
{ area: { bbox: [-1, -1, 1, 1] } },
|
||||||
|
{
|
||||||
|
services: {
|
||||||
|
bikeshare: {
|
||||||
|
systems: [
|
||||||
|
{
|
||||||
|
id: 'fixture',
|
||||||
|
city: 'Fixture',
|
||||||
|
centerLat: 0,
|
||||||
|
centerLon: 0,
|
||||||
|
loadRadiusKm: 30,
|
||||||
|
stationInformationUrl: 'information',
|
||||||
|
stationStatusUrl: 'status',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
async getStations(url) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
stations:
|
||||||
|
url === 'information'
|
||||||
|
? [station]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
station_id: 's1',
|
||||||
|
last_reported: 1e30,
|
||||||
|
num_bikes_available: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(result.data.returned, 1);
|
||||||
|
assert.equal(result.data.rows[0].last_reported, null);
|
||||||
|
});
|
||||||
@@ -4808,8 +4808,9 @@ function compactAnalystItem(r) {
|
|||||||
for (const [key, spec] of Object.entries(fields)) {
|
for (const [key, spec] of Object.entries(fields)) {
|
||||||
if (!present(r[key]) || key in compact) continue;
|
if (!present(r[key]) || key in compact) continue;
|
||||||
if (spec.type === 'time') {
|
if (spec.type === 'time') {
|
||||||
const ms = Number(r[key]);
|
// A finite value can still be past Date's range, where toISOString throws.
|
||||||
if (Number.isFinite(ms)) compact.time = new Date(ms).toISOString();
|
const time = new Date(Number(r[key]));
|
||||||
|
if (Number.isFinite(time.getTime())) compact.time = time.toISOString();
|
||||||
} else compact[key] = r[key];
|
} else compact[key] = r[key];
|
||||||
}
|
}
|
||||||
if (present(r.ageHours)) compact.ageHours = r.ageHours;
|
if (present(r.ageHours)) compact.ageHours = r.ageHours;
|
||||||
|
|||||||
@@ -3459,6 +3459,26 @@ test('analyst_query refusals carry their code and the allowed values', async ()
|
|||||||
assert.match(ok.items[0].time, /^\d{4}-\d\d-\d\dT/);
|
assert.match(ok.items[0].time, /^\d{4}-\d\d-\d\dT/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('analyst_query keeps its answer when one record has an out-of-range time', async () => {
|
||||||
|
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||||
|
const now = Date.now();
|
||||||
|
const earthquakes = {
|
||||||
|
getStats: () => ({ count: 2, lastUpdate: now }),
|
||||||
|
getAnalystRecords: () => [
|
||||||
|
{ id: 'q', lat: 0, lon: 0, magnitude: 5, timeMs: now },
|
||||||
|
{ id: 'r', lat: 1, lon: 1, magnitude: 4, timeMs: 8.64e15 + 1 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { runner } = manifestRunner({ earthquakes }, { enabled: ['earthquakes'] });
|
||||||
|
const result = await runner('analyst_query', { layers: ['earthquakes'], scope: { kind: 'anywhere' } });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
const q = result.items.find((item) => item.id === 'q');
|
||||||
|
assert.match(q.time, /^\d{4}-\d\d-\d\dT/);
|
||||||
|
const r = result.items.find((item) => item.id === 'r');
|
||||||
|
assert.ok(r, 'the record is still listed');
|
||||||
|
assert.equal(r.time, undefined, 'an unrepresentable time is omitted');
|
||||||
|
});
|
||||||
|
|
||||||
test('spoken layer names from the manifest reach the newer layers', async () => {
|
test('spoken layer names from the manifest reach the newer layers', async () => {
|
||||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||||
const stub = () => ({ getStats: () => ({ count: 0 }) });
|
const stub = () => ({ getStats: () => ({ count: 0 }) });
|
||||||
|
|||||||
Reference in new issue
Block a user