Add spatial building conversations and inspectable evidence

This commit is contained in:
Bilawal Sidhu committed 2026-09-15 03:45:38 -05:00
1 parent c9f9896b48
commit 85a187f561
26 files changed
+3837 -7

No files matched your search

+5
View File
@@ -1,6 +1,7 @@
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
import { initSpatialWorkspace } from '../spatial/workspace.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import {
@@ -45,6 +46,8 @@ export function createApplicationTools({
// lifetime rather than to whoever last pressed the button.
const drawTool = initDrawTool({ viewer, annotations });
defer(() => drawTool?.destroy());
const spatialWorkspace = initSpatialWorkspace({ viewer });
defer(() => spatialWorkspace?.destroy());
if (startChrome)
defer(startChrome({ loadingScreen, styleManager, dataManager, signal }));
// Idle render governor: flips the scene into requestRenderMode whenever
@@ -101,6 +104,7 @@ export function createApplicationTools({
mapStackController,
annotations,
weatherEffects,
spatialWorkspace,
cockpitCloudEffects,
getRenderGovernorDiagnostics,
surfaceServices: operations.surface,
@@ -122,6 +126,7 @@ export function createApplicationTools({
dataManager,
sceneDirector,
annotations,
spatialWorkspace,
});
defer(() => {
voiceCommands.stop({ removeUi: true });
+2 -1
View File
@@ -661,7 +661,8 @@ test('the voice TOOL SCHEMA matches the pinned release — the mission mapping i
// Canonical serialization pins every tool name, description, property and
// ordering while allowing source formatting. Derived from the unchanged
// release schema before formatting (the previous source-byte pin passed).
const block = JSON.stringify(GEV_REALTIME_TOOLS);
// Spatial selection is an intentional additive tool; preserve every shipped tool.
const block = JSON.stringify(GEV_REALTIME_TOOLS.filter(tool => tool.name !== 'spatial_selection'));
assert.equal(block.length, 26208, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
+4 -3
View File
@@ -24,11 +24,11 @@ const css = readStylesheet(new URL('../style.css', import.meta.url));
function realtimeTools() { return GEV_REALTIME_TOOLS; }
test('Realtime schema exposes the authoritative 28-tool inventory', () => {
test('Realtime schema exposes the authoritative 29-tool inventory', () => {
const tools = realtimeTools();
assert.equal(tools.length, 28);
assert.equal(tools.length, 29);
const names = tools.map((tool) => tool.name);
assert.equal(new Set(names).size, 28, 'tool names are unique');
assert.equal(new Set(names).size, 29, 'tool names are unique');
assert.ok(names.includes('set_context_mode'));
assert.ok(names.includes('control_cockpit'));
assert.ok(names.includes('select_nearest_aircraft'));
@@ -170,6 +170,7 @@ test('no unchanged Realtime tool definition drifts silently', () => {
// in the mic-test brief which tools moved — the session cache busts on any
// schema change.
const TOUCHED = new Set([
'spatial_selection',
'set_context_mode',
'control_cockpit',
'set_panel_open',
+33
View File
@@ -0,0 +1,33 @@
import { LOAD_RADIUS_M } from '../spatial/buildings.js';
/** Read only a small neighborhood through the existing bounded/cache-aware proxy. */
export async function fetchSpatialBuildings(center, { signal } = {}) {
const [lon, lat] = center || [];
if (
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
Math.abs(lat) > 85 ||
Math.abs(lon) > 180
)
throw new Error('Choose a place below 85° latitude.');
// Relation radius searches can dominate latency even in a tiny neighborhood.
// The interactive layer explicitly reports way-only coverage; the parser also
// understands complete relations supplied by a future indexed provider.
const query = `[out:json][timeout:15];way["building"](around:${LOAD_RADIUS_M},${lat.toFixed(5)},${lon.toFixed(5)});out body geom;`;
const response = await fetch('/api/overpass', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ data: query }).toString(),
signal,
});
if (!response.ok)
throw new Error(
response.status === 429
? 'Building source is busy. Try again shortly.'
: 'Building source is unavailable. Your selection is safe; try again.',
);
const payload = await response.json();
if (!Array.isArray(payload?.elements))
throw new Error('Building source returned no usable geometry. Try again.');
return payload;
}
+413
View File
@@ -0,0 +1,413 @@
/** Bounded, renderer-independent OSM building geometry and factual summaries. */
export const BUILDING_LIMIT = 500;
export const LOAD_RADIUS_M = 600;
const R = 6371008.8;
const rad = Math.PI / 180;
export const USE_COLORS = {
residential: '#bba0ff',
commercial: '#ffcb77',
civic: '#71dec8',
industrial: '#87afff',
unknown: '#8794a5',
};
export function distanceM(a, b) {
const x =
Math.sin(((b[1] - a[1]) * rad) / 2) ** 2 +
Math.cos(a[1] * rad) *
Math.cos(b[1] * rad) *
Math.sin(((b[0] - a[0]) * rad) / 2) ** 2;
return R * 2 * Math.asin(Math.sqrt(Math.min(1, x)));
}
/** Explicit map commands are local; open-ended questions go to the analyst. */
export function spatialQuestionKind(question) {
const q = String(question)
.trim()
.toLowerCase()
.replace(/[?.!]+$/g, '');
if (
/^(please )?(colou?r|highlight)( these| them| buildings| the buildings)? by (use|type|category)$/.test(
q,
)
)
return 'use';
if (
/^(which( one| building)? is (the )?tallest|show( me)? (the )?tallest( building)?)$/.test(
q,
)
)
return 'height';
if (
/^(how far apart( are (these|they|these buildings))?|compare( these| them| these buildings)?|what is the distance between (these|them|these buildings))$/.test(
q,
)
)
return 'distance';
if (
/^(what.s here|what is here|how many( buildings)?( are (here|selected|there))?)$/.test(
q,
)
)
return 'overview';
return null;
}
export function validRing(input) {
if (!Array.isArray(input) || input.length < 3 || input.length > 2048)
return null;
const ring = input.map((p) => [Number(p?.[0]), Number(p?.[1])]);
if (
ring.some(
(p) =>
!Number.isFinite(p[0]) ||
!Number.isFinite(p[1]) ||
Math.abs(p[0]) > 180 ||
Math.abs(p[1]) > 85,
)
)
return null;
if (ring.some((p) => Math.abs(p[0] - ring[0][0]) > 180)) return null;
if (ring[0][0] !== ring.at(-1)[0] || ring[0][1] !== ring.at(-1)[1])
ring.push([...ring[0]]);
if (new Set(ring.map((p) => p.join(','))).size < 3 || ringArea(ring) < 1)
return null;
return ring;
}
export function ringArea(ring) {
if (!ring?.length) return 0;
const lat = ring[0][1] * rad;
let twice = 0;
for (let i = 0; i < ring.length - 1; i++) {
const a = ring[i],
b = ring[i + 1];
twice +=
(a[0] - ring[0][0]) * (b[1] - ring[0][1]) -
(b[0] - ring[0][0]) * (a[1] - ring[0][1]);
}
return (Math.abs(twice) * R * R * rad * rad * Math.cos(lat)) / 2;
}
function onSegment(p, a, b) {
const cross = (p[0] - a[0]) * (b[1] - a[1]) - (p[1] - a[1]) * (b[0] - a[0]);
return (
Math.abs(cross) < 1e-12 &&
p[0] >= Math.min(a[0], b[0]) - 1e-10 &&
p[0] <= Math.max(a[0], b[0]) + 1e-10 &&
p[1] >= Math.min(a[1], b[1]) - 1e-10 &&
p[1] <= Math.max(a[1], b[1]) + 1e-10
);
}
export function containsPoint(ring, p) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const a = ring[i],
b = ring[j];
if (a[0] === b[0] && a[1] === b[1]) continue;
if (onSegment(p, a, b)) return true;
if (
a[1] > p[1] !== b[1] > p[1] &&
p[0] < ((b[0] - a[0]) * (p[1] - a[1])) / (b[1] - a[1]) + a[0]
)
inside = !inside;
}
return inside;
}
function orient(a, b, c) {
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
}
function segmentsCross(a, b, c, d) {
return (
(orient(a, b, c) * orient(a, b, d) < 0 &&
orient(c, d, a) * orient(c, d, b) < 0) ||
onSegment(a, c, d) ||
onSegment(b, c, d) ||
onSegment(c, a, b) ||
onSegment(d, a, b)
);
}
export function selfIntersects(ring) {
for (let i = 0; i < ring.length - 1; i++)
for (let j = i + 2; j < ring.length - 1; j++) {
if (i === 0 && j === ring.length - 2) continue;
if (segmentsCross(ring[i], ring[i + 1], ring[j], ring[j + 1]))
return true;
}
return false;
}
function bounds(ring) {
let west = Infinity,
south = Infinity,
east = -Infinity,
north = -Infinity;
for (const [x, y] of ring) {
west = Math.min(west, x);
east = Math.max(east, x);
south = Math.min(south, y);
north = Math.max(north, y);
}
return [west, south, east, north];
}
const overlaps = (a, b) =>
a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1];
export function intersectsRing(a, b) {
if (!overlaps(bounds(a), bounds(b))) return false;
if (a.some((p) => containsPoint(b, p)) || b.some((p) => containsPoint(a, p)))
return true;
for (let i = 1; i < a.length; i++)
for (let j = 1; j < b.length; j++)
if (segmentsCross(a[i - 1], a[i], b[j - 1], b[j])) return true;
return false;
}
function parseHeight(value) {
if (typeof value !== 'string' && typeof value !== 'number') return null;
const match = String(value)
.trim()
.match(/^(\d+(?:\.\d+)?)\s*(m|ft|feet)?$/i);
if (!match) return null;
const n = Number(match[1]) * (/ft|feet/i.test(match[2] || '') ? 0.3048 : 1);
return n > 0 && n <= 1000 ? n : null;
}
export function buildingHeight(tags = {}) {
const height = parseHeight(tags.height);
if (height)
return {
heightM: height,
displayHeightM: height,
heightSource: 'recorded height',
};
const levels = Number(tags['building:levels']);
if (Number.isFinite(levels) && levels > 0 && levels <= 200)
return {
heightM: null,
displayHeightM: levels * 3,
heightSource: 'estimated from floors',
};
return {
heightM: null,
displayHeightM: 12,
heightSource: 'display estimate',
};
}
export function buildingUse(tags = {}) {
const use = tags['building:use'] || tags.building || '';
if (
/^(apartments|residential|house|detached|terrace|dormitory|bungalow|semidetached_house)$/.test(
use,
)
)
return 'residential';
if (/^(commercial|retail|office|hotel|supermarket)$/.test(use))
return 'commercial';
if (
/^(civic|public|school|university|hospital|church|cathedral|mosque|synagogue|temple|government|train_station)$/.test(
use,
)
)
return 'civic';
if (/^(industrial|warehouse|manufacture)$/.test(use)) return 'industrial';
return 'unknown';
}
const clean = (value) => (typeof value === 'string' ? value.slice(0, 180) : '');
const coordRing = (geometry) => validRing(geometry?.map((p) => [p.lon, p.lat]));
/** Join relation fragments only when a closed ring exists. Never invent missing edges. */
function joinMembers(members, role) {
const pieces = members
.filter((m) => (m.role || 'outer') === role && Array.isArray(m.geometry))
.map((m) => m.geometry.map((p) => [p.lon, p.lat]));
const rings = [];
const same = (a, b) => a?.[0] === b?.[0] && a?.[1] === b?.[1];
while (pieces.length) {
const line = pieces.shift();
let changed = true;
while (changed && !same(line[0], line.at(-1))) {
changed = false;
for (let i = 0; i < pieces.length; i++) {
let next = pieces[i];
if (same(line.at(-1), next.at(-1))) next = [...next].reverse();
if (same(line.at(-1), next[0])) {
line.push(...next.slice(1));
pieces.splice(i, 1);
changed = true;
break;
}
}
}
if (same(line[0], line.at(-1))) {
const ring = validRing(line);
if (ring) rings.push(ring);
}
}
return rings;
}
export function parseBuildings(payload, { limit = BUILDING_LIMIT } = {}) {
const elements = Array.isArray(payload?.elements) ? payload.elements : [];
const records = [],
ownedWays = new Set(),
seen = new Set();
let skipped = 0;
const add = (element, polygons) => {
if (!polygons.length) {
skipped++;
return false;
}
const id = `osm:${element.type}/${element.id}`;
if (seen.has(id)) return true;
seen.add(id);
const tags = element.tags || {};
const box = bounds(polygons.flatMap((p) => p.outer));
records.push({
id,
osmType: element.type,
osmId: String(element.id),
name:
clean(tags.name) ||
clean(tags['addr:housename']) ||
`Building ${element.id}`,
use: buildingUse(tags),
recordedType: clean(tags['building:use'] || tags.building) || 'unknown',
address: [tags['addr:housenumber'], tags['addr:street']]
.map(clean)
.filter(Boolean)
.join(' '),
...buildingHeight(tags),
polygons,
bounds: box,
center: [(box[0] + box[2]) / 2, (box[1] + box[3]) / 2],
footprintM2: Math.round(
polygons.reduce(
(n, p) =>
n +
ringArea(p.outer) -
p.holes.reduce((s, h) => s + ringArea(h), 0),
0,
),
),
});
return true;
};
for (const e of elements)
if (e.type === 'relation' && e.tags?.building && e.tags.building !== 'no') {
const outers = joinMembers(e.members || [], 'outer'),
holes = joinMembers(e.members || [], 'inner');
if (
add(
e,
outers.map((outer) => ({
outer,
holes: holes.filter((h) => containsPoint(outer, h[0])),
})),
)
)
for (const m of e.members || [])
if (m.type === 'way') ownedWays.add(String(m.ref));
}
for (const e of elements)
if (
e.type === 'way' &&
e.tags?.building &&
e.tags.building !== 'no' &&
!ownedWays.has(String(e.id))
) {
const ring = coordRing(e.geometry);
add(e, ring ? [{ outer: ring, holes: [] }] : []);
}
return {
records: records.slice(0, limit),
truncated: records.length > limit,
totalReceived: records.length,
skipped,
timestamp: payload?.osm3s?.timestamp_osm_base || null,
incomplete: Boolean(payload?.remark),
};
}
export function selectBuildings(records, ring, rule = 'intersects') {
const box = bounds(ring);
return records.filter(
(r) =>
overlaps(r.bounds, box) &&
(rule === 'inside'
? r.polygons.every(
(p) =>
p.outer.every((v) => containsPoint(ring, v)) &&
!ring.some(
(a, i) =>
i &&
p.outer.some(
(b, j) =>
j &&
orient(ring[i - 1], a, p.outer[j - 1]) *
orient(ring[i - 1], a, b) <
0 &&
orient(p.outer[j - 1], b, ring[i - 1]) *
orient(p.outer[j - 1], b, a) <
0,
),
),
)
: r.polygons.some(
(p) =>
intersectsRing(p.outer, ring) &&
!p.holes.some((h) => ring.every((v) => containsPoint(h, v))),
)),
);
}
export function buildingFacts(records, center) {
const categories = Object.fromEntries(
Object.keys(USE_COLORS).map((k) => [k, 0]),
);
let knownHeights = 0,
tallest = null,
areaM2 = 0;
for (const r of records) {
categories[r.use]++;
areaM2 += r.footprintM2;
if (r.heightM !== null) {
knownHeights++;
if (!tallest || r.heightM > tallest.heightM) tallest = r;
}
}
return {
count: records.length,
categories,
knownHeights,
unknownHeights: records.length - knownHeights,
footprintM2: areaM2,
pair:
records.length === 2
? {
from: records[0].id,
to: records[1].id,
distanceM: Math.round(
distanceM(records[0].center, records[1].center),
),
basis:
'Horizontal straight-line distance between footprint bounding-box centers; not a walking route or wall-to-wall gap.',
}
: null,
tallest: tallest
? { id: tallest.id, name: tallest.name, heightM: tallest.heightM }
: null,
evidence: records.map((r) => ({
id: r.id,
name: r.name,
use: r.use,
recordedType: r.recordedType,
address: r.address,
heightM: r.heightM,
heightSource: r.heightSource,
footprintM2: r.footprintM2,
center: [...r.center],
distanceM: center ? Math.round(distanceM(center, r.center)) : null,
})),
};
}
+192
View File
@@ -0,0 +1,192 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
validRing,
ringArea,
containsPoint,
selfIntersects,
intersectsRing,
buildingHeight,
parseBuildings,
selectBuildings,
buildingFacts,
spatialQuestionKind,
} from './buildings.js';
const box = (x, y, width = 0.001) => [
[x, y],
[x + width, y],
[x + width, y + width],
[x, y + width],
[x, y],
];
const way = (id, ring, tags = {}) => ({
type: 'way',
id,
tags: { building: 'yes', ...tags },
geometry: ring.map(([lon, lat]) => ({ lon, lat })),
});
test('point containment includes edges but excludes the rest of the plane for a closed ring', () => {
const r = box(0, 0);
assert.equal(containsPoint(r, [0.0005, 0.0005]), true);
assert.equal(containsPoint(r, [1, 1]), false);
assert.equal(containsPoint(r, [0, 0.0005]), true);
});
test('geometry validity, meter area and crossing detection', () => {
assert.ok(Math.abs(ringArea(box(0, 0)) - 12364) < 10);
assert.equal(
validRing([
[0, 0],
[0, 0],
[0, 0],
]),
null,
);
assert.equal(
validRing([
[179, 0],
[-179, 0],
[179, 1],
]),
null,
);
assert.equal(
validRing([
[0, NaN],
[1, 0],
[1, 1],
]),
null,
);
assert.equal(
selfIntersects([
[0, 0],
[1, 1],
[0, 1],
[1, 0],
[0, 0],
]),
true,
);
assert.equal(selfIntersects(box(0, 0)), false);
});
test('crossing rectangles intersect even when neither has a contained corner', () => {
const a = [
[-2, -0.1],
[2, -0.1],
[2, 0.1],
[-2, 0.1],
[-2, -0.1],
],
b = [
[-0.1, -2],
[0.1, -2],
[0.1, 2],
[-0.1, 2],
[-0.1, -2],
];
assert.equal(intersectsRing(a, b), true);
assert.equal(intersectsRing(box(0, 0), box(1, 1)), false);
});
test('display estimates never masquerade as recorded heights', () => {
assert.deepEqual(buildingHeight({ 'building:levels': '7' }), {
heightM: null,
displayHeightM: 21,
heightSource: 'estimated from floors',
});
assert.equal(buildingHeight({ height: '100 ft' }).heightM, 30.48);
assert.equal(buildingHeight({ height: '12;18' }).heightM, null);
assert.equal(buildingHeight({ height: '10000' }).heightM, null);
});
test('OSM identities deduplicate, missing tags remain unknown, limit is explicit', () => {
const p = parseBuildings(
{
elements: [
way(1, box(0, 0)),
way(1, box(0, 0)),
way(2, box(0.003, 0), { building: 'office', height: '42' }),
],
},
{ limit: 1 },
);
assert.equal(p.records.length, 1);
assert.equal(p.totalReceived, 2);
assert.equal(p.truncated, true);
assert.equal(p.records[0].use, 'unknown');
});
test('relations join reversed fragments, retain courtyards, and suppress their member ways', () => {
const r = box(0, 0, 0.01),
h = box(0.002, 0.002, 0.002);
const members = [
{
type: 'way',
ref: 1,
role: 'outer',
geometry: r.slice(0, 3).map(([lon, lat]) => ({ lon, lat })),
},
{
type: 'way',
ref: 2,
role: 'outer',
geometry: [r[0], r[3], r[2]].map(([lon, lat]) => ({ lon, lat })),
},
{
type: 'way',
ref: 3,
role: 'inner',
geometry: h.map(([lon, lat]) => ({ lon, lat })),
},
];
const parsed = parseBuildings({
elements: [
{ type: 'relation', id: 9, tags: { building: 'school' }, members },
way(1, r),
],
});
assert.equal(parsed.records.length, 1);
assert.equal(parsed.records[0].polygons[0].holes.length, 1);
assert.equal(parsed.records[0].use, 'civic');
assert.equal(
selectBuildings(parsed.records, box(0.0022, 0.0022, 0.001)).length,
0,
);
});
test('inside and intersection selection differ at the boundary', () => {
const { records } = parseBuildings({
elements: [way(1, box(0, 0)), way(2, box(0.0015, 0))],
});
const selection = box(-0.0001, -0.0001, 0.002);
assert.equal(selectBuildings(records, selection, 'intersects').length, 2);
assert.equal(selectBuildings(records, selection, 'inside').length, 1);
});
test('summaries use selected records and recorded heights only', () => {
const { records } = parseBuildings({
elements: [
way(1, box(0, 0), { 'building:levels': '100' }),
way(2, box(0.003, 0), { height: '30', building: 'office' }),
],
});
const f = buildingFacts(records, [0, 0]);
assert.equal(f.count, 2);
assert.equal(f.unknownHeights, 1);
assert.equal(f.tallest.id, 'osm:way/2');
assert.equal(f.categories.commercial, 1);
});
test('explicit map commands stay local while open-ended questions remain model questions', () => {
assert.equal(spatialQuestionKind('Color these by use'), 'use');
assert.equal(spatialQuestionKind('Which is tallest?'), 'height');
assert.equal(spatialQuestionKind('How far apart are these?'), 'distance');
assert.equal(spatialQuestionKind('Why is this building so tall?'), null);
assert.equal(spatialQuestionKind('Do not color these by use'), null);
});
test('two-building comparison is a bounded horizontal center distance', () => {
const { records } = parseBuildings({
elements: [way(1, box(0, 0)), way(2, box(0.001, 0))],
});
const f = buildingFacts(records, [0, 0]);
assert.equal(f.pair.distanceM, 111);
assert.deepEqual(f.evidence[0].center, [0.0005, 0.0005]);
assert.equal(buildingFacts(records.slice(0, 1)).pair, null);
});
+319
View File
@@ -0,0 +1,319 @@
import * as Cesium from 'cesium';
import { USE_COLORS } from './buildings.js';
/** Static geometry. Classification creation is lazy; no continuous-render hold. */
export function createBuildingRenderer(
viewer,
{ onModeChange = () => {} } = {},
) {
const source = new Cesium.CustomDataSource('gev-spatial-buildings');
const credit = new Cesium.Credit(
'<a href="https://www.openstreetmap.org/copyright">© OpenStreetMap contributors</a>',
true,
);
let lastStyle = {},
lastRing = null;
let destroyed = false,
records = [],
tint = false;
const entities = new Map();
const estimateMaterials = new Map();
function volumeMaterial(record, hex) {
if (record.heightM !== null) return color(hex, 1);
if (!estimateMaterials.has(hex)) {
const base = color(hex, 1);
estimateMaterials.set(
hex,
new Cesium.StripeMaterialProperty({
evenColor: base,
oddColor: new Cesium.Color(
base.red * 0.74,
base.green * 0.74,
base.blue * 0.74,
1,
),
repeat: 18,
}),
);
}
return estimateMaterials.get(hex);
}
const masks = viewer.scene.primitives.add(new Cesium.PrimitiveCollection());
const classifiers = new Map();
let pendingColors = new Map(),
detachPending = null;
const attaching = Promise.resolve(viewer.dataSources.add(source));
attaching
.then(() => {
if (destroyed && !viewer.isDestroyed())
viewer.dataSources.remove(source, true);
})
.catch(() => {});
const color = (hex, alpha) =>
Cesium.Color.fromCssColorString(hex).withAlpha(alpha);
const hasTiles = () => {
for (let i = 0; i < viewer.scene.primitives.length; i++) {
const p = viewer.scene.primitives.get(i);
if (p instanceof Cesium.Cesium3DTileset && p.show) return true;
}
return false;
};
function replace(next) {
if (destroyed) return;
source.entities.suspendEvents();
try {
source.entities.removeAll();
entities.clear();
masks.removeAll();
classifiers.clear();
pendingColors.clear();
records = next;
tint = hasTiles();
source.credit = source.show && records.length ? credit : undefined;
if (detachPending) {
detachPending();
detachPending = null;
}
for (const r of records) {
const group = [];
if (tint) continue;
for (const p of r.polygons) {
const positions = Cesium.Cartesian3.fromDegreesArray(p.outer.flat());
const holes = p.holes.map(
(h) =>
new Cesium.PolygonHierarchy(
Cesium.Cartesian3.fromDegreesArray(h.flat()),
),
);
const entity = source.entities.add({
id: `spatial:${r.id}:${group.length}`,
name: r.name,
polygon: {
hierarchy: new Cesium.PolygonHierarchy(positions, holes),
stRotation: Math.PI / 4,
height: 0,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
extrudedHeight: r.displayHeightM,
extrudedHeightReference:
Cesium.HeightReference.RELATIVE_TO_GROUND,
material: volumeMaterial(r, '#61798a'),
outline: false,
},
});
entity.__gevBuildingId = r.id;
group.push(entity);
}
entities.set(r.id, group);
}
} finally {
source.entities.resumeEvents();
}
viewer.scene.requestRender();
}
let previous = new Map();
function setMaskColor(mask, value) {
if (mask.primitive.ready) {
mask.primitive.getGeometryInstanceAttributes(mask.id).color =
Cesium.ColorGeometryInstanceAttribute.toValue(value);
pendingColors.delete(mask);
} else {
pendingColors.set(mask, value);
if (!detachPending)
detachPending = viewer.scene.postRender.addEventListener(() => {
for (const [entry, desired] of pendingColors)
if (entry.primitive.ready) setMaskColor(entry, desired);
if (!pendingColors.size) {
detachPending?.();
detachPending = null;
} else viewer.scene.requestRender();
});
}
}
function masksFor(record, value) {
if (classifiers.has(record.id)) return classifiers.get(record.id);
const group = [];
for (const p of record.polygons) {
const hierarchy = new Cesium.PolygonHierarchy(
Cesium.Cartesian3.fromDegreesArray(p.outer.flat()),
p.holes.map(
(h) =>
new Cesium.PolygonHierarchy(
Cesium.Cartesian3.fromDegreesArray(h.flat()),
),
),
);
let ground =
viewer.scene.globe?.getHeight(
Cesium.Cartographic.fromDegrees(...record.center),
) || 0;
// Sample just outside the footprint so a roof does not become the base.
if (viewer.scene.sampleHeightSupported) {
const sample = viewer.scene.sampleHeight(
Cesium.Cartographic.fromDegrees(
p.outer[0][0] - 0.00003,
p.outer[0][1] - 0.00003,
),
);
if (Number.isFinite(sample)) ground = sample;
}
const id = { __gevBuildingId: record.id };
const primitive = masks.add(
new Cesium.ClassificationPrimitive({
geometryInstances: new Cesium.GeometryInstance({
id,
geometry: new Cesium.PolygonGeometry({
polygonHierarchy: hierarchy,
height: ground - 3,
extrudedHeight: ground + record.displayHeightM + 15,
vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(value),
},
}),
classificationType: Cesium.ClassificationType.CESIUM_3D_TILE,
}),
);
group.push({ primitive, id });
}
classifiers.set(record.id, group);
return group;
}
function style({
ids = [],
hover = null,
focus = null,
categorized = false,
} = {}) {
if (destroyed) return;
lastStyle = { ids, hover, focus, categorized };
const selected = new Set(ids),
next = new Map();
for (const r of records) {
const isSelected = selected.has(r.id),
isFocus = r.id === focus,
isHover = r.id === hover;
const hex = isFocus
? '#ffe8ad'
: isHover
? '#d9faff'
: isSelected
? categorized
? USE_COLORS[r.use]
: '#49bfdb'
: '#8cacc4';
// Opaque OSM solids avoid expensive full-scene order-independent
// transparency passes; surface classification retains texture via alpha.
const alpha = tint ? (isFocus ? 0.9 : isHover ? 0.8 : 0.7) : 1;
const signature = `${hex}/${alpha}/${tint && !isSelected && !isHover}`;
next.set(r.id, signature);
if (previous.get(r.id) === signature) continue;
if (tint) {
const visible = isSelected || isHover;
for (const mask of visible
? masksFor(r, color(hex, alpha))
: classifiers.get(r.id) || []) {
mask.primitive.show = visible;
if (visible) setMaskColor(mask, color(hex, alpha));
}
}
for (const e of entities.get(r.id) || []) {
e.show = !tint || isSelected || isHover;
e.polygon.material = volumeMaterial(r, hex);
}
}
previous = next;
viewer.scene.requestRender();
}
let area = null;
function setArea(ring) {
lastRing = ring;
if (!ring) {
if (area) source.entities.remove(area);
area = null;
} else if (area)
area.polyline.positions = Cesium.Cartesian3.fromDegreesArray(ring.flat());
else
area = source.entities.add({
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray(ring.flat()),
clampToGround: true,
classificationType: Cesium.ClassificationType.BOTH,
width: 3,
material: color('#71e6ff', 0.95),
},
});
viewer.scene.requestRender();
}
function focus(record) {
if (!record) return;
const ground =
viewer.scene.globe?.getHeight(
Cesium.Cartographic.fromDegrees(...record.center),
) || 0;
// An oblique close-up can put a neighboring tower between camera and
// subject. Inspect from above the loaded roofline, with room for context.
const clearance =
Math.max(250, ...records.map((r) => r.displayHeightM)) + 220;
const altitude =
ground + Math.max(clearance, Math.sqrt(record.footprintM2) * 4);
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(...record.center, altitude),
orientation: {
heading: viewer.camera.heading,
pitch: -Math.PI / 2,
roll: 0,
},
maximumHeight: Math.max(
altitude,
viewer.camera.positionCartographic.height,
),
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches
? 0
: 0.9,
});
}
const stopModeWatch = viewer.scene.preRender.addEventListener(() => {
if (destroyed || !records.length || hasTiles() === tint) return;
const ring = lastRing,
styling = lastStyle;
previous.clear();
area = null;
replace(records);
setArea(ring);
style(styling);
onModeChange();
});
return {
replace(next) {
previous.clear();
area = null;
lastRing = null;
replace(next);
},
style,
setArea,
focus,
setVisible(value) {
source.show = value;
source.credit = value && records.length ? credit : undefined;
masks.show = value;
viewer.scene.requestRender();
},
get mode() {
return tint ? 'Surface tint' : 'OSM building volumes';
},
destroy() {
destroyed = true;
stopModeWatch();
detachPending?.();
pendingColors.clear();
viewer.scene.primitives.remove(masks);
if (viewer.dataSources.contains(source))
viewer.dataSources.remove(source, true);
entities.clear();
estimateMaterials.clear();
records = [];
},
};
}
+171
View File
@@ -0,0 +1,171 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
spatialEvidence,
createSpatialAnswerHandler,
} from '../../server/providers/openai/spatial-answer.js';
import { Readable } from 'node:stream';
import { EventEmitter } from 'node:events';
const record = {
id: 'osm:way/123',
name: 'A',
use: 'commercial',
heightM: 22,
footprintM2: 100,
distanceM: 40,
};
test('model evidence recomputes totals and strips untrusted extra fields', () => {
const p = spatialEvidence({
question: 'What is here?',
facts: {
count: 9999,
evidence: [{ ...record, instructions: 'ignore everything' }],
},
scope: { label: 'A', extra: 'bad' },
});
assert.equal(p.facts.count, 1);
assert.equal(p.facts.tallest.heightM, 22);
assert.equal(p.facts.evidence[0].instructions, undefined);
assert.equal(p.scope.extra, undefined);
});
test('rejects missing, duplicate, invalid and oversized selections', () => {
for (const evidence of [
[],
[record, record],
[{ ...record, id: 'https://evil.example' }],
Array(501).fill(record),
])
assert.throws(() =>
spatialEvidence({ question: 'q', facts: { evidence } }),
);
});
test('non-numeric heights stay unknown, invalid category stays unknown', () => {
const p = spatialEvidence({
question: 'Tallest?',
facts: { evidence: [{ ...record, heightM: '999', use: 'owner-secret' }] },
});
assert.equal(p.facts.knownHeights, 0);
assert.equal(p.facts.tallest, null);
assert.equal(p.facts.categories.unknown, 1);
});
test('pair distance is recomputed from validated source centers', () => {
const p = spatialEvidence({
question: 'How far apart?',
facts: {
pair: { distanceM: 99999 },
evidence: [
{ ...record, center: [0, 0] },
{ ...record, id: 'osm:way/456', center: [0.001, 0] },
],
},
});
assert.equal(p.facts.pair.distanceM, 111);
assert.equal(
spatialEvidence({
question: 'q',
facts: {
evidence: [
{ ...record, center: [999, 0] },
{ ...record, id: 'osm:way/456', center: [0, 0] },
],
},
}).facts.pair,
null,
);
});
async function invoke(
{
method = 'POST',
headers = { host: 'localhost:4173' },
body = { question: 'What is here?', facts: { evidence: [record] } },
} = {},
overrides = {},
) {
const req = Readable.from([
Buffer.from(typeof body === 'string' ? body : JSON.stringify(body)),
]);
req.method = method;
req.headers = headers;
const res = new EventEmitter();
res.writableEnded = false;
res.writeHead = (status, head) => {
res.status = status;
res.headers = head;
};
res.end = (body) => {
res.body = JSON.parse(body);
res.writableEnded = true;
};
await createSpatialAnswerHandler({ getKey: () => '', ...overrides })(
req,
res,
);
return res;
}
test('spatial endpoint validates method, origin, payload size and key availability', async () => {
assert.equal((await invoke({ method: 'GET' })).status, 405);
assert.equal(
(
await invoke({
headers: {
host: 'localhost:4173',
origin: 'https://unrelated.example',
},
})
).status,
403,
);
assert.equal((await invoke({ body: 'bad json' })).status, 400);
assert.equal((await invoke({ body: 'x'.repeat(410000) })).status, 413);
const missing = await invoke();
assert.equal(missing.status, 503);
assert.match(missing.body.error, /calculated questions/i);
});
test('spatial endpoint uses Responses, disables storage, and forwards only normalized evidence', async () => {
let request;
const res = await invoke(
{},
{
getKey: () => 'test-only',
fetchImpl: async (url, init) => {
request = { url, ...JSON.parse(init.body) };
return {
ok: true,
json: async () => ({
output: [
{
content: [
{ type: 'output_text', text: 'One recorded building.' },
],
},
],
}),
};
},
},
);
assert.equal(res.status, 200);
assert.equal(res.body.answer, 'One recorded building.');
assert.equal(request.url, 'https://api.openai.com/v1/responses');
assert.equal(request.store, false);
assert.equal(JSON.parse(request.input).facts.count, 1);
assert.equal(res.headers['Cache-Control'], 'no-store');
});
test('provider refusals do not leak credentials or provider diagnostics', async () => {
const res = await invoke(
{},
{
getKey: () => 'test-only',
fetchImpl: async () => ({
ok: false,
json: async () => ({
error: { message: 'sensitive provider diagnostic' },
}),
}),
},
);
assert.equal(res.status, 502);
assert.doesNotMatch(JSON.stringify(res.body), /test-only|sensitive/);
});
+738
View File
@@ -0,0 +1,738 @@
import * as Cesium from 'cesium';
import { claimPointer, releasePointer } from '../data/inputOwnership.js';
import { pickWorldFromScreen } from '../annotations/annotationResolver.js';
import { fetchSpatialBuildings } from '../sources/spatialBuildings.js';
import {
BUILDING_LIMIT,
LOAD_RADIUS_M,
USE_COLORS,
parseBuildings,
containsPoint,
validRing,
selfIntersects,
selectBuildings,
buildingFacts,
distanceM,
spatialQuestionKind,
} from './buildings.js';
import { createBuildingRenderer } from './renderer.js';
const el = (tag, cls, text) => {
const node = document.createElement(tag);
if (cls) node.className = cls;
if (text !== undefined) node.textContent = text;
return node;
};
const number = (n) =>
new Intl.NumberFormat('en', { maximumFractionDigits: 0 }).format(n);
/** A bounded spatial workspace. Owns its listeners, pointer lease, requests and render data. */
export function initSpatialWorkspace({ viewer }) {
if (!viewer) return null;
const root = el('section', 'spatial-workspace');
root.id = 'spatial-workspace';
root.setAttribute('aria-label', 'Spatial conversation');
root.innerHTML = `<button class="spatial-launch" aria-expanded="false"><span class="spatial-orb" aria-hidden="true"></span>Ask the world <span class="spatial-key">3D</span></button>
<div class="spatial-panel" hidden>
<header><div><div class="spatial-eyebrow">GOD’S EYE VIEW / SPATIAL</div><h2>Make the world your context.</h2></div><button class="spatial-close" aria-label="Close spatial workspace">×</button></header>
<div class="spatial-body">
<p class="spatial-intro">Point to a building. Shift-click to compare. Draw around a block.</p>
<div class="spatial-toolbar"><button data-action="load" class="spatial-primary">Load buildings here</button><button data-action="draw">Draw area</button><button data-action="all">Select loaded</button></div>
<div class="spatial-draw-tools" hidden><span>Drag an outline or click corners.</span><button data-action="finish">Finish</button><button data-action="cancel">Cancel</button></div>
<p class="spatial-status" role="status" aria-live="polite">Explore a neighborhood, then load its buildings.</p>
<div class="spatial-scope"><span class="spatial-orb" aria-hidden="true"></span><div><strong class="spatial-scope-name">No selection yet</strong><span class="spatial-scope-detail">Your question will stay attached to this place.</span></div><button data-action="clear" aria-label="Clear selection">×</button></div>
<label class="spatial-rule" hidden>Include buildings <select aria-label="Building selection rule"><option value="intersects">touching the outline</option><option value="inside">fully inside the outline</option></select></label>
<div class="spatial-suggestions"><button data-question="overview">What’s here?</button><button data-question="use">Color by use</button><button data-question="height">Which is tallest?</button></div>
<form class="spatial-question"><label class="spatial-sr" for="spatial-question-input">Ask about your selection</label><textarea id="spatial-question-input" rows="2" maxlength="1200" placeholder="Ask about this building or area…"></textarea><button type="submit" aria-label="Ask about selection">↑</button></form>
<div class="spatial-answer" aria-live="polite" aria-label="Spatial answer" hidden><div class="spatial-answer-meta"></div><p class="spatial-answer-text"></p><button data-action="refresh" hidden>Refresh for this selection</button></div>
<div class="spatial-legend" hidden></div>
<p class="spatial-height-key" hidden><i aria-hidden="true"></i>Hatched volumes have estimated display heights.</p>
<details class="spatial-evidence"><summary>Inspect evidence <span class="spatial-evidence-count">0</span></summary><div class="spatial-metrics"></div><div class="spatial-evidence-list"></div><p class="spatial-evidence-limit"></p></details>
<p class="spatial-coverage">OpenStreetMap geometry and tags. Unrecorded heights are estimates for display only.</p>
</div>
</div>`;
document.body.append(root);
const q = (selector) => root.querySelector(selector);
const renderer = createBuildingRenderer(viewer, {
onModeChange: () => {
q('.spatial-coverage').textContent = coverage();
},
});
let open = false,
destroyed = false,
lease = null,
records = [],
selected = [],
loadInfo = null,
center = null;
let ring = null,
drawing = false,
vertices = [],
dragging = false,
moved = false,
startPixel = null,
oldCameraInputs = null;
let revision = 0,
answerRevision = -1,
requestSequence = 0,
requestController = null,
loadController = null;
let categorized = false,
focused = null,
hover = null,
lastHover = 0,
lastDraw = 0,
lastQuestion = null;
let savedClick, savedDoubleClick;
const metrics = { loads: [], selections: [], queries: [] };
const listeners = [];
const listen = (target, type, fn, opts) => {
target.addEventListener(type, fn, opts);
listeners.push([target, type, fn, opts]);
};
const status = (text) => {
q('.spatial-status').textContent = text;
};
const selection = () => {
const ids = new Set(selected);
return records.filter((r) => ids.has(r.id));
};
const facts = () => buildingFacts(selection(), center);
const paint = () =>
renderer.style({ ids: selected, categorized, focus: focused, hover });
function coverage() {
if (!loadInfo)
return 'OpenStreetMap geometry and tags. Unrecorded heights are estimates for display only.';
return `${number(records.length)} building footprints loaded within ${LOAD_RADIUS_M} m of the search center. OSM ways only; complex relation buildings may be missing. ${loadInfo.truncated ? `Capped at ${BUILDING_LIMIT}; results are partial. ` : ''}${loadInfo.incomplete ? 'Source returned a partial response. ' : ''}${loadInfo.skipped ? `${loadInfo.skipped} records had unusable geometry. ` : ''}${renderer.mode}. Heights may be display estimates. OSM snapshot: ${loadInfo.timestamp || 'not supplied'}.`;
}
function evidence() {
const f = facts();
q('.spatial-evidence-count').textContent = number(f.count);
q('.spatial-metrics').textContent =
`${number(f.footprintM2)} m² mapped footprint · ${f.knownHeights}/${f.count} recorded heights`;
const list = q('.spatial-evidence-list');
list.replaceChildren();
for (const r of selection().slice(0, 60)) {
const row = el('div', 'spatial-evidence-row');
const focus = el('button', '', r.name);
focus.title = 'Focus this building';
focus.dataset.focus = r.id;
const detail = el(
'small',
'',
`${r.use} · ${r.heightM === null ? 'Height unrecorded' : `${number(r.heightM)} m recorded`} · ${number(r.footprintM2)} m²`,
);
const link = el('a', '', 'OSM ↗');
link.href = `https://www.openstreetmap.org/${r.osmType}/${r.osmId}`;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.setAttribute('aria-label', `Open source for ${r.name}`);
row.append(focus, detail, link);
list.append(row);
}
q('.spatial-evidence-limit').textContent =
f.count > 60
? `Showing 60 of ${number(f.count)} selected records. Summaries use every selected record.`
: 'Footprint areas are calculated from mapped geometry. Categories follow recorded building tags.';
q('.spatial-coverage').textContent = coverage();
}
function updateScope(label) {
revision++;
requestSequence++;
requestController?.abort();
focused = null;
q('.spatial-scope-name').textContent = label;
q('.spatial-scope-detail').textContent = selected.length
? `${number(selected.length)} building${selected.length === 1 ? '' : 's'} selected · OSM evidence`
: 'Select a loaded building or draw an area.';
q('.spatial-rule').hidden = !ring;
if (answerRevision >= 0 && answerRevision !== revision) {
q('.spatial-answer-meta').textContent =
'Selection changed · previous answer';
q('.spatial-answer').classList.add('is-stale');
q('[data-action="refresh"]').hidden = false;
}
if (categorized && selected.length) showLegend(facts());
else q('.spatial-legend').hidden = true;
q('.spatial-question button').disabled = false;
paint();
evidence();
}
function setSelection(ids, label) {
const t = performance.now();
selected = ids;
updateScope(label);
metrics.selections.push(performance.now() - t);
metrics.selections = metrics.selections.slice(-100);
}
function world(pixel) {
const p = pickWorldFromScreen(
viewer,
pixel.x / viewer.canvas.clientWidth,
pixel.y / viewer.canvas.clientHeight,
);
return p && Number.isFinite(p.lon) && Number.isFinite(p.lat)
? [p.lon, p.lat]
: null;
}
function setOpen(value) {
if (value === open) return;
if (value) {
lease = claimPointer('spatial');
if (!lease) {
q('.spatial-launch').title = 'Finish the active drawing tool first.';
q('.spatial-launch').setAttribute(
'aria-label',
'Finish the active drawing tool before opening spatial questions',
);
return;
}
q('.spatial-launch').removeAttribute('aria-label');
q('.spatial-launch').title = '';
savedClick = viewer.screenSpaceEventHandler.getInputAction(
Cesium.ScreenSpaceEventType.LEFT_CLICK,
);
savedDoubleClick = viewer.screenSpaceEventHandler.getInputAction(
Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
);
viewer.screenSpaceEventHandler.removeInputAction(
Cesium.ScreenSpaceEventType.LEFT_CLICK,
);
viewer.screenSpaceEventHandler.removeInputAction(
Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
);
} else {
stopDrawing();
requestSequence++;
requestController?.abort();
loadController?.abort();
releasePointer(lease);
lease = null;
if (savedClick)
viewer.screenSpaceEventHandler.setInputAction(
savedClick,
Cesium.ScreenSpaceEventType.LEFT_CLICK,
);
if (savedDoubleClick)
viewer.screenSpaceEventHandler.setInputAction(
savedDoubleClick,
Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
);
}
open = value;
q('.spatial-panel').hidden = !value;
q('.spatial-launch').setAttribute('aria-expanded', String(value));
renderer.setVisible(value);
root.classList.toggle('is-open', value);
}
async function load() {
if (!open) return;
const here = world({
x: viewer.canvas.clientWidth / 2,
y: viewer.canvas.clientHeight / 2,
});
if (!here) {
status('Point the camera at the ground to load a neighborhood.');
return;
}
stopDrawing();
loadController?.abort();
loadController = new AbortController();
const controller = loadController,
started = performance.now();
q('[data-action="load"]').disabled = true;
status('Reading nearby building footprints…');
try {
const timeout = setTimeout(() => controller.abort(), 35000);
let payload;
try {
payload = await fetchSpatialBuildings(here, {
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
if (
destroyed ||
controller !== loadController ||
controller.signal.aborted
)
return;
const parsed = parseBuildings(payload);
stopDrawing();
records = parsed.records;
categorized = false;
q('.spatial-height-key').hidden = !records.length;
loadInfo = parsed;
center = here;
ring = null;
hover = null;
renderer.replace(records);
setSelection([], 'Choose your context');
metrics.loads.push({
durationMs: performance.now() - started,
count: records.length,
});
status(
records.length
? `${number(records.length)} buildings ready. Click one, or draw around a block.`
: 'No mapped buildings were returned here. Move to another neighborhood.',
);
} catch (error) {
if (!destroyed && controller === loadController && open)
status(
controller.signal.aborted
? 'Building request timed out. Your previous selection is still available.'
: error.message,
);
} finally {
if (!destroyed && controller === loadController)
q('[data-action="load"]').disabled = false;
}
}
function stopDrawing() {
if (drawing && oldCameraInputs !== null)
viewer.scene.screenSpaceCameraController.enableInputs = oldCameraInputs;
drawing = false;
root.classList.remove('is-drawing');
dragging = false;
oldCameraInputs = null;
vertices = [];
q('.spatial-draw-tools').hidden = true;
q('[data-action="draw"]').classList.remove('is-active');
viewer.canvas.style.cursor = '';
renderer.setArea(ring);
}
function startDrawing() {
if (!open || !records.length) {
status('Load buildings before drawing an area.');
return;
}
stopDrawing();
drawing = true;
root.classList.add('is-drawing');
oldCameraInputs = viewer.scene.screenSpaceCameraController.enableInputs;
viewer.scene.screenSpaceCameraController.enableInputs = false;
q('.spatial-draw-tools').hidden = false;
q('[data-action="draw"]').classList.add('is-active');
viewer.canvas.style.cursor = 'crosshair';
status(
window.matchMedia('(max-width: 520px), (pointer: coarse)').matches
? 'Drag an outline, or tap corners and choose Finish. Cancel keeps your previous selection.'
: 'Drag around buildings, or click corners and press Enter. Escape cancels.',
);
}
function finishDrawing() {
const next = validRing(vertices);
if (!next || selfIntersects(next)) {
stopDrawing();
status(
'That outline crossed itself or was too small. Your previous selection is retained; choose Draw area to retry.',
);
return;
}
if (next.some((p) => distanceM(center, p) > LOAD_RADIUS_M * 2)) {
stopDrawing();
status(
'Keep the outline near the loaded buildings, or load the new neighborhood.',
);
return;
}
ring = next;
stopDrawing();
const subset = selectBuildings(
records,
ring,
q('.spatial-rule select').value,
);
setSelection(
subset.map((r) => r.id),
'Your drawn area',
);
status(
`${number(subset.length)} loaded buildings ${q('.spatial-rule select').value === 'inside' ? 'fully inside' : 'touch'} this outline. Coverage is limited to the loaded neighborhood.`,
);
}
function showAnswer(text, label, seq) {
if (destroyed || seq !== requestSequence) return false;
answerRevision = revision;
q('.spatial-answer').hidden = false;
q('.spatial-answer').classList.remove('is-stale');
q('.spatial-answer-meta').textContent = label;
q('.spatial-answer-text').textContent = text;
q('[data-action="refresh"]').hidden = true;
requestAnimationFrame(() => {
if (!destroyed && open && seq === requestSequence)
q('.spatial-answer').scrollIntoView({ block: 'nearest' });
});
return true;
}
function showLegend(f) {
const legend = q('.spatial-legend');
legend.replaceChildren();
for (const [use, n] of Object.entries(f.categories))
if (n) {
const item = el('span', '', `${use} ${n}`);
const dot = el('i');
dot.style.background = USE_COLORS[use];
item.prepend(dot);
legend.append(item);
}
legend.hidden = false;
}
async function ask(question, kind = null) {
if (!selected.length) {
status('Select at least one building first.');
return { ok: false, error: 'No building selection.' };
}
requestController?.abort();
kind ||= spatialQuestionKind(question);
requestController = new AbortController();
const controller = requestController,
seq = ++requestSequence,
started = performance.now(),
f = facts();
lastQuestion = { question, kind };
focused = null;
const label = 'Calculated from your selection';
if (kind === 'overview')
showAnswer(
`${number(f.count)} mapped building${f.count === 1 ? '' : 's'}, covering ${number(f.footprintM2)} m² of footprint. ${f.knownHeights} have recorded heights; ${f.unknownHeights} do not. ${f.categories.unknown} have no recognized building-use category.`,
label,
seq,
);
else if (kind === 'use') {
categorized = true;
showLegend(f);
paint();
showAnswer(
Object.entries(f.categories)
.filter(([, n]) => n)
.map(([use, n]) => `${n} ${use}`)
.join(' · ') +
'. Colors follow recorded OSM tags; unknown stays unknown.',
label,
seq,
);
} else if (kind === 'height') {
focused = f.tallest?.id || null;
paint();
showAnswer(
f.tallest
? `${f.unknownHeights ? 'Actual tallest is inconclusive. ' : ''}${f.tallest.name} has the greatest recorded height in this selection: ${number(f.tallest.heightM)} m. ${f.unknownHeights} height${f.unknownHeights === 1 ? ' is' : 's are'} unrecorded.`
: 'None of these buildings has a recorded height. The 3D display uses estimates; those cannot establish which building is tallest.',
label,
seq,
);
} else if (kind === 'distance') {
const pair = selection();
showAnswer(
f.pair
? `${pair[0].name} and ${pair[1].name} are approximately ${number(f.pair.distanceM)} m apart, measured horizontally between footprint bounding-box centers. This is not a walking distance or a gap between walls.`
: 'Select two buildings to compare their distance. Click one, then Shift-click the other, or draw around a pair.',
label,
seq,
);
} else {
q('.spatial-question button').disabled = true;
status('Reasoning over the selected building evidence…');
try {
const timeout = setTimeout(() => controller.abort(), 45000);
let response;
try {
response = await fetch('/api/openai/spatial-answer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
question,
facts: f,
scope: {
label: q('.spatial-scope-name').textContent,
revision,
coverage: coverage(),
rule: ring
? q('.spatial-rule select').value
: 'explicit building selection',
},
}),
});
} finally {
clearTimeout(timeout);
}
const data = await response.json();
if (!response.ok || !data.answer)
throw new Error(
data.error ||
'The assistant could not answer. Calculated questions still work.',
);
if (
showAnswer(data.answer, 'AI explanation · supplied OSM evidence', seq)
)
status(
'Answer attached to this selection. Inspect the source records below.',
);
} catch (error) {
if (seq === requestSequence && !destroyed)
status(
controller.signal.aborted
? 'The answer timed out. Your selection and evidence are preserved.'
: error.message,
);
} finally {
if (seq === requestSequence && !destroyed)
q('.spatial-question button').disabled = false;
}
}
metrics.queries.push({
durationMs: performance.now() - started,
kind: kind || 'model',
count: f.count,
});
metrics.queries = metrics.queries.slice(-100);
return { ok: seq === requestSequence, facts: f, scopeRevision: revision };
}
listen(q('.spatial-launch'), 'click', () => setOpen(!open));
listen(q('.spatial-close'), 'click', () => {
if (!open) q('.spatial-panel').hidden = true;
else setOpen(false);
});
listen(root, 'click', (event) => {
const button = event.target.closest('button');
if (!button) return;
const action = button.dataset.action;
if (action === 'load') load();
if (action === 'draw') startDrawing();
if (action === 'finish') finishDrawing();
if (action === 'cancel') {
stopDrawing();
status('Drawing cancelled. Previous selection retained.');
}
if (action === 'clear') {
ring = null;
stopDrawing();
setSelection([], 'No selection');
}
if (action === 'all') {
ring = null;
stopDrawing();
setSelection(
records.map((r) => r.id),
'Loaded neighborhood',
);
}
if (action === 'refresh' && lastQuestion)
ask(lastQuestion.question, lastQuestion.kind);
if (button.dataset.question)
ask(button.textContent, button.dataset.question);
if (button.dataset.focus) {
focused = button.dataset.focus;
paint();
renderer.focus(records.find((r) => r.id === focused));
}
});
listen(q('.spatial-rule select'), 'change', () => {
if (ring)
setSelection(
selectBuildings(records, ring, q('.spatial-rule select').value).map(
(r) => r.id,
),
'Your drawn area',
);
});
listen(q('form'), 'submit', (event) => {
event.preventDefault();
const text = q('textarea').value.trim();
if (text) ask(text);
});
listen(q('textarea'), 'keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
q('form').requestSubmit();
}
});
listen(window, 'keydown', (event) => {
if (open && event.key === 'Escape') {
event.preventDefault();
if (drawing) stopDrawing();
else {
setOpen(false);
q('.spatial-launch').focus();
}
return;
}
if (
!open ||
event.target.closest?.(
'input,textarea,select,button,a,[contenteditable="true"]',
)
)
return;
if (event.key === 'Escape') {
event.preventDefault();
if (drawing) stopDrawing();
else setOpen(false);
}
if (event.key === 'Enter' && drawing) {
event.preventDefault();
finishDrawing();
}
if (event.key === 'Backspace' && drawing) {
event.preventDefault();
vertices.pop();
renderer.setArea(vertices.length > 1 ? vertices : null);
}
});
const pixel = (e) => {
const b = viewer.canvas.getBoundingClientRect();
return { x: e.clientX - b.left, y: e.clientY - b.top };
};
listen(viewer.canvas, 'pointerdown', (e) => {
if (!open || !drawing || e.button !== 0) return;
dragging = true;
moved = false;
startPixel = pixel(e);
viewer.canvas.setPointerCapture(e.pointerId);
const p = world(startPixel);
if (p) vertices.push(p);
renderer.setArea(vertices.length > 1 ? vertices : null);
});
listen(viewer.canvas, 'pointermove', (e) => {
if (!open || !drawing || !dragging || performance.now() - lastDraw < 32)
return;
const at = pixel(e);
if (Math.hypot(at.x - startPixel.x, at.y - startPixel.y) < 8) return;
lastDraw = performance.now();
moved = true;
startPixel = at;
const p = world(at);
if (p && vertices.length < 128) vertices.push(p);
renderer.setArea(vertices.length > 1 ? vertices : null);
});
listen(viewer.canvas, 'pointerup', () => {
if (!drawing || !dragging) return;
dragging = false;
if (moved && vertices.length >= 3) finishDrawing();
});
listen(viewer.canvas, 'pointercancel', () => {
if (drawing) stopDrawing();
});
const handler = new Cesium.ScreenSpaceEventHandler(viewer.canvas);
function buildingAt(position) {
const picked = viewer.scene.pick(position);
if (picked?.id?.__gevBuildingId) return picked.id.__gevBuildingId;
const p = world(position);
if (!p) return null;
return (
records.find(
(r) =>
p[0] >= r.bounds[0] &&
p[0] <= r.bounds[2] &&
p[1] >= r.bounds[1] &&
p[1] <= r.bounds[3] &&
r.polygons.some(
(poly) =>
containsPoint(poly.outer, p) &&
!poly.holes.some((h) => containsPoint(h, p)),
),
)?.id || null
);
}
function clickBuilding(event, additive = false) {
if (!open || drawing || moved) {
moved = false;
return;
}
const id = buildingAt(event.position);
if (!id) return;
ring = null;
renderer.setArea(null);
const ids = additive
? selected.includes(id)
? selected.filter((x) => x !== id)
: [...selected, id]
: [id];
setSelection(
ids,
ids.length === 1
? records.find((r) => r.id === ids[0]).name
: 'Your building comparison',
);
status(
ids.length === 2
? 'Two buildings selected. Ask “How far apart are these?”'
: 'Building selected. Shift-click another to compare, or ask a question.',
);
}
handler.setInputAction(clickBuilding, Cesium.ScreenSpaceEventType.LEFT_CLICK);
handler.setInputAction(
(event) => clickBuilding(event, true),
Cesium.ScreenSpaceEventType.LEFT_CLICK,
Cesium.KeyboardEventModifier.SHIFT,
);
handler.setInputAction((event) => {
if (
!open ||
drawing ||
!records.length ||
performance.now() - lastHover < 80
)
return;
lastHover = performance.now();
const id = buildingAt(event.endPosition);
if (id !== hover) {
hover = id;
viewer.canvas.style.cursor = id ? 'pointer' : '';
paint();
}
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
const api = {
open: () => setOpen(true),
close: () => setOpen(false),
getContext: () => ({
active: open,
revision,
scope: q('.spatial-scope-name').textContent,
coverage: coverage(),
...facts(),
}),
query: async ({ question = 'What is here?', kind = 'overview' } = {}) => {
if (!open) setOpen(true);
if (!open)
return {
ok: false,
error:
'Finish the active drawing tool before querying the spatial selection.',
};
return ask(
question,
['overview', 'use', 'height', 'distance'].includes(kind) ? kind : null,
);
},
diagnostics: () => ({
open,
drawing,
revision,
answerRevision,
loaded: records.length,
selected: selected.length,
mode: renderer.mode,
metrics: structuredClone(metrics),
}),
destroy() {
if (destroyed) return;
setOpen(false);
destroyed = true;
loadController?.abort();
requestController?.abort();
handler.destroy();
listeners.forEach(([t, type, fn, opts]) =>
t.removeEventListener(type, fn, opts),
);
renderer.destroy();
root.remove();
},
};
return api;
}
+457
View File
@@ -0,0 +1,457 @@
.spatial-workspace {
--sp-ink: #ecf6ff;
--sp-muted: #a1b5c6;
--sp-line: #b6d8ed24;
--sp-cyan: #80e7ff;
position: fixed;
z-index: 850;
bottom: 84px;
left: 50%;
transform: translateX(-50%);
color: var(--sp-ink);
font:
13px/1.45 -apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
}
.spatial-workspace * {
box-sizing: border-box;
}
.spatial-workspace [hidden] {
display: none !important;
}
.spatial-workspace button,
.spatial-workspace select,
.spatial-workspace textarea {
font: inherit;
color: inherit;
}
.spatial-workspace button {
cursor: pointer;
border: 1px solid var(--sp-line);
background: #ffffff07;
border-radius: 8px;
padding: 8px 11px;
transition:
background 0.15s,
border-color 0.15s;
}
.spatial-workspace button:hover {
background: #a7dcff1c;
border-color: #91dfff75;
}
.spatial-workspace button:focus-visible,
.spatial-workspace a:focus-visible,
.spatial-workspace summary:focus-visible {
outline: 2px solid var(--sp-cyan);
outline-offset: 3px;
}
.spatial-workspace button:disabled {
cursor: wait;
opacity: 0.5;
}
.spatial-workspace .spatial-launch {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 20px;
background: #0c1b29ee;
border: 1px solid #83dfff70;
box-shadow: 0 8px 28px #0006;
border-radius: 30px;
font-weight: 600;
white-space: nowrap;
}
.spatial-orb {
display: inline-block;
flex: 0 0 9px;
width: 9px;
height: 9px;
background: var(--sp-cyan);
border-radius: 50%;
box-shadow: 0 0 14px #80e7ff65;
}
.spatial-key {
font-size: 10px;
font-weight: 500;
color: var(--sp-muted);
border-left: 1px solid var(--sp-line);
padding-left: 10px;
}
.spatial-panel {
position: absolute;
bottom: 62px;
left: 50%;
transform: translateX(-50%);
width: 430px;
max-height: calc(100dvh - 180px);
border: 1px solid #a4d9fa40;
border-radius: 18px;
background: #0b1826f5;
box-shadow: 0 16px 72px #0009;
backdrop-filter: blur(20px);
overflow: hidden;
}
.spatial-panel header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 20px 20px 13px;
border-bottom: 1px solid var(--sp-line);
}
.spatial-eyebrow {
color: var(--sp-cyan);
font-size: 9px;
letter-spacing: 1.5px;
margin-bottom: 6px;
}
.spatial-panel h2 {
margin: 0;
font-size: 19px;
font-weight: 550;
letter-spacing: -0.4px;
}
.spatial-workspace .spatial-close {
padding: 0;
width: 28px;
height: 28px;
font-size: 22px;
border: 0;
color: var(--sp-muted);
}
.spatial-body {
padding: 14px 20px 18px;
max-height: calc(100dvh - 274px);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
.spatial-intro {
margin: 0 0 14px;
color: var(--sp-muted);
font-size: 12px;
}
.spatial-toolbar {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.spatial-toolbar button {
font-size: 11px;
}
.spatial-workspace .spatial-primary {
background: #80e7ff;
color: #082131;
font-weight: 650;
border-color: transparent;
}
.spatial-workspace .spatial-primary:hover {
background: #b1f1ff;
}
.spatial-status {
font-size: 11px;
color: #accbdf;
min-height: 30px;
margin: 10px 0;
}
.spatial-draw-tools {
padding: 9px 0 0;
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
color: var(--sp-cyan);
font-size: 11px;
}
.spatial-draw-tools span {
width: 100%;
}
.spatial-toolbar .is-active {
border-color: var(--sp-cyan);
color: var(--sp-cyan);
}
.spatial-scope {
padding: 12px;
border: 1px solid #80e7ff45;
border-radius: 10px;
background: #80e7ff09;
display: flex;
gap: 10px;
align-items: center;
}
.spatial-scope > div {
min-width: 0;
flex: 1;
}
.spatial-scope strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.spatial-scope-detail {
display: block;
margin-top: 2px;
color: var(--sp-muted);
font-size: 10px;
}
.spatial-workspace .spatial-scope button {
padding: 0 7px;
font-size: 19px;
border: 0;
}
.spatial-rule {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
color: var(--sp-muted);
font-size: 11px;
padding-top: 9px;
}
.spatial-rule select {
max-width: 205px;
padding: 4px;
background: #132536;
border: 1px solid var(--sp-line);
border-radius: 5px;
}
.spatial-suggestions {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin: 14px 0 8px;
}
.spatial-suggestions button {
border-radius: 20px;
padding: 5px 10px;
font-size: 11px;
}
.spatial-question {
display: flex;
align-items: flex-end;
gap: 6px;
border: 1px solid #a3c1d445;
border-radius: 10px;
background: #0003;
padding: 8px;
}
.spatial-question:focus-within {
border-color: var(--sp-cyan);
}
.spatial-question textarea {
flex: 1;
min-width: 0;
padding: 2px 3px;
border: 0;
outline: 0;
resize: vertical;
max-height: 110px;
background: transparent;
line-height: 1.5;
}
.spatial-question textarea::placeholder {
color: #95a9b9;
}
.spatial-workspace .spatial-question button {
background: var(--sp-cyan);
color: #092332;
font-size: 21px;
line-height: 1;
height: 32px;
width: 32px;
padding: 0;
}
.spatial-answer {
margin-top: 14px;
padding: 12px 14px;
border-left: 2px solid #ffd495;
border-radius: 0 9px 9px 0;
background: #ffcf8509;
}
.spatial-answer-meta {
font-size: 10px;
color: #e7c899;
}
.spatial-answer-text {
margin: 6px 0 0;
font-size: 13px;
line-height: 1.65;
white-space: pre-wrap;
}
.spatial-answer.is-stale {
border-color: #8b98a5;
opacity: 0.8;
}
.spatial-answer button {
margin-top: 10px;
font-size: 11px;
}
.spatial-legend {
display: flex;
flex-wrap: wrap;
gap: 7px 13px;
margin-top: 12px;
font-size: 10px;
text-transform: capitalize;
}
.spatial-legend i {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 2px;
margin-right: 5px;
}
.spatial-evidence {
margin-top: 14px;
border-top: 1px solid var(--sp-line);
padding-top: 12px;
}
.spatial-evidence summary {
cursor: pointer;
color: #d3e8f7;
font-size: 12px;
}
.spatial-evidence-count {
float: right;
color: var(--sp-cyan);
}
.spatial-metrics {
font-size: 10px;
color: var(--sp-muted);
padding: 10px 0 5px;
}
.spatial-evidence-row {
padding: 9px 0;
border-bottom: 1px solid var(--sp-line);
display: grid;
grid-template-columns: 1fr auto;
gap: 3px 8px;
}
.spatial-workspace .spatial-evidence-row button {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
border: 0;
padding: 0;
border-radius: 2px;
text-align: left;
font-size: 12px;
}
.spatial-evidence-row small {
grid-column: 1;
color: var(--sp-muted);
font-size: 10px;
}
.spatial-evidence-row a {
color: var(--sp-cyan);
font-size: 10px;
grid-row: 1 / 3;
grid-column: 2;
align-self: center;
}
.spatial-evidence-limit,
.spatial-coverage {
color: #97aabd;
font-size: 10px;
line-height: 1.5;
margin: 12px 0 0;
}
.spatial-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
@media (min-width: 1100px) {
.spatial-workspace.is-open {
left: auto;
right: 22px;
bottom: 84px;
transform: none;
}
.spatial-workspace.is-open .spatial-panel {
left: auto;
right: 0;
transform: none;
}
.spatial-workspace.is-open .spatial-launch {
margin-left: auto;
}
}
@media (max-width: 520px) {
.spatial-workspace {
bottom: calc(152px + env(safe-area-inset-bottom, 0px));
}
.spatial-panel {
width: min(430px, calc(100vw - 20px));
max-height: min(62dvh, calc(100dvh - 220px));
}
.spatial-body {
max-height: min(calc(62dvh - 84px), calc(100dvh - 304px));
padding: 12px 15px 16px;
}
.spatial-panel header {
padding: 16px 15px 12px;
}
.spatial-panel h2 {
font-size: 18px;
}
.spatial-workspace button {
min-height: 40px;
}
.spatial-workspace.is-drawing .spatial-panel header,
.spatial-workspace.is-drawing
.spatial-body
> :not(.spatial-draw-tools):not(.spatial-status) {
display: none;
}
.spatial-workspace.is-drawing .spatial-body {
padding-top: 4px;
}
.spatial-workspace.is-drawing .spatial-status {
margin-bottom: 0;
}
body:has(#spatial-workspace.is-open) #left-panel-stack,
body:has(#spatial-workspace.is-open) #right-context-rail {
display: none !important;
}
body:has(#spatial-workspace.is-drawing) #command-dock,
body:has(#spatial-workspace.is-drawing) #key-setup-chip {
display: none !important;
}
}
.spatial-height-key {
display: flex;
align-items: center;
gap: 8px;
color: var(--sp-muted);
font-size: 11px;
}
.spatial-height-key i {
width: 15px;
height: 15px;
border: 1px solid #92b3c1;
border-radius: 3px;
background: repeating-linear-gradient(45deg, #7892a5 0 3px, #354754 3px 5px);
}
body:has(#spatial-workspace.is-open) #cesium-credits {
white-space: normal;
width: min(430px, calc(100vw - 24px)) !important;
max-width: min(430px, calc(100vw - 24px));
}
body:has(#spatial-workspace.is-open)
#cesium-credits
.cesium-credit-textContainer {
max-width: 100%;
}
@media (prefers-reduced-motion: reduce) {
.spatial-workspace button {
transition: none;
}
}
+14
View File
@@ -860,6 +860,20 @@ const schemas = [
},
},
},
{
name: 'spatial_selection',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
action: {
type: 'string',
enum: ['context', 'color_by_use', 'recorded_height'],
},
},
required: ['action'],
},
},
];
function freeze(value) {
+23 -1
View File
@@ -15,9 +15,31 @@ const stable = (value) =>
)
: value;
test('spatial selection exposes only explicit bounded actions', () => {
const tool = GEV_REALTIME_TOOLS.find(
(tool) => tool.name === 'spatial_selection',
);
assert.deepEqual(tool.parameters.properties.action.enum, [
'context',
'color_by_use',
'recorded_height',
]);
assert.deepEqual(tool.parameters.required, ['action']);
assert.equal(tool.parameters.additionalProperties, false);
assert.match(tool.description, /explicitly selected/);
});
test('the complete Realtime tool payload retains its pre-extraction contract and wording', () => {
const digest = createHash('sha256')
.update(JSON.stringify(stable(GEV_REALTIME_TOOLS)))
.update(
JSON.stringify(
stable(
GEV_REALTIME_TOOLS.filter(
(tool) => tool.name !== 'spatial_selection',
),
),
),
)
.digest('hex');
assert.equal(
digest,
+9 -1
View File
@@ -263,7 +263,7 @@ const BASEMAP_CONTEXT_WAIT_MS = 1500;
const viewTargetCache = new WeakMap();
/** Create application actions over the supplied scene and services. */
export function createGevActionRunner({ viewer, styleManager, dataManager, sceneDirector = null, annotations = null, placeSearch = unavailablePlaceSearch, floorServices = defaultFloorServices, annotationResolver = defaultAnnotationResolver, searchNavigation = searchAndFlyTo }) {
export function createGevActionRunner({ viewer, styleManager, dataManager, sceneDirector = null, annotations = null, spatialWorkspace = null, placeSearch = unavailablePlaceSearch, floorServices = defaultFloorServices, annotationResolver = defaultAnnotationResolver, searchNavigation = searchAndFlyTo }) {
// Voice enable times and analyst follow-up memory belong to this runner.
const _layerEnabledAt = new Map();
let analystEngine;
@@ -274,6 +274,14 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene
const args = rawArgs && typeof rawArgs === 'object' ? rawArgs : {};
const current = () => !runOptions.signal?.aborted
&& (typeof runOptions.isCurrent !== 'function' || runOptions.isCurrent());
if (name === 'spatial_selection') {
if (!current()) return { ok: false, error: 'Request superseded.' };
if (!spatialWorkspace) return { ok: false, error: 'Spatial workspace is unavailable.' };
if (args.action === 'color_by_use') return spatialWorkspace.query({ kind: 'use' });
if (args.action === 'recorded_height') return spatialWorkspace.query({ kind: 'height' });
const context = spatialWorkspace.getContext();
return { ok: context.active && context.count > 0, ...context, note: 'Only the explicitly selected OSM buildings. A closed workspace is not active context. Unknown heights and use remain unknown. Display estimates are not measurements.' };
}
// Navigation tools interrupt any continuous camera motion (spec §1.1) —
// checked FIRST because each handler returns.
+19
View File
@@ -21,6 +21,25 @@ import {
import { MAP_STACKS } from '../mapStackController.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
test('spatial voice actions use the active selection and honor cancellation', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const { viewer, styleManager } = createVoiceNavigationHarness();
let active = true;
const calls = [];
const runner = createGevActionRunner({ viewer, styleManager, spatialWorkspace: {
getContext: () => ({ active, count: 2, revision: 4, evidence: [{ id: 'osm:way/1' }, { id: 'osm:way/2' }] }),
query: async (args) => { calls.push(args.kind); return { ok: true }; },
} });
const context = await runner('spatial_selection', { action: 'context' });
assert.equal(context.ok, true); assert.equal(context.revision, 4); assert.equal(context.evidence.length, 2);
active = false;
assert.equal((await runner('spatial_selection', { action: 'context' })).ok, false);
await runner('spatial_selection', { action: 'color_by_use' });
await runner('spatial_selection', { action: 'recorded_height' });
const stopped = await runner('spatial_selection', { action: 'color_by_use' }, { isCurrent: () => false });
assert.equal(stopped.ok, false); assert.deepEqual(calls, ['use', 'height']);
});
test('every live basemap is reachable by its own id — no enum value without a voice alias', () => {
// B1 regression: a stack added to MAP_STACKS (and the set_map_stack enum)
// without a matching STACK_ALIASES entry resolves to null and throws