mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Add spatial building conversations and inspectable evidence
This commit is contained in:
1 parent
c9f9896b48
commit
85a187f561
26 files changed
+3837
-7
No files matched your search
@@ -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 });
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user