mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge branch 'main' into codex/shared-geodesy
This commit is contained in:
commit
64d7ed3c61
1373 files changed
+245910
-10664
No files matched your search
@@ -0,0 +1,236 @@
|
||||
// Area annotations draw bundled state/province/county outlines with no
|
||||
// network lookup, and multi-part outlines reach the renderer intact.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||
import { createAnnotationResolver } from './resolver.js';
|
||||
import { createAnnotationEngine } from './annotationEngine.js';
|
||||
import { _resetRenderGovernorForTest } from '../renderGovernor.js';
|
||||
|
||||
function viewerAt(lat, lon, height = 20_000) {
|
||||
return {
|
||||
camera: {
|
||||
positionCartographic: {
|
||||
latitude: (lat * Math.PI) / 180,
|
||||
longitude: (lon * Math.PI) / 180,
|
||||
height,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A resolver whose boundary lookups and geocoder record every call. */
|
||||
function harness(place = null) {
|
||||
const lookups = [];
|
||||
const featureSource = {};
|
||||
for (const method of FEATURE_SOURCE_METHODS)
|
||||
featureSource[method] = async () => {
|
||||
lookups.push(method);
|
||||
return null;
|
||||
};
|
||||
const geocodes = [];
|
||||
const placeSearch = {
|
||||
async geocode(query) {
|
||||
geocodes.push(query);
|
||||
return { place };
|
||||
},
|
||||
async textSearch() {
|
||||
geocodes.push('text-search');
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const { resolveAnnotationTarget } = createAnnotationResolver({
|
||||
featureSource,
|
||||
});
|
||||
const resolve = (target, viewer, extra = {}) =>
|
||||
resolveAnnotationTarget({
|
||||
placeSearch,
|
||||
viewer,
|
||||
target,
|
||||
footprint: true,
|
||||
deferFootprint: true,
|
||||
...extra,
|
||||
});
|
||||
return { resolve, lookups, geocodes };
|
||||
}
|
||||
|
||||
const AUSTIN = viewerAt(30.2672, -97.7431, 3000);
|
||||
|
||||
test('"Texas", "Travis County, Texas" and "Bavaria" outline offline', async () => {
|
||||
const { resolve, lookups, geocodes } = harness();
|
||||
for (const [target, label] of [
|
||||
['Texas', 'Texas'],
|
||||
['Travis County, Texas', 'Travis County'],
|
||||
// Far from the camera, but named outright: no proximity gate applies.
|
||||
['Bavaria', 'Bavaria'],
|
||||
]) {
|
||||
const resolved = await resolve(target, AUSTIN);
|
||||
assert.ok(resolved, target);
|
||||
assert.equal(resolved.source, 'bundled', target);
|
||||
assert.equal(resolved.label, label);
|
||||
assert.equal(resolved.footprintKind, 'area');
|
||||
assert.equal(resolved.synthesized, false);
|
||||
assert.equal(resolved.resolveOutline, undefined, 'drawn at once');
|
||||
assert.ok(resolved.ring.length >= 9, `${target} ring`);
|
||||
assert.deepEqual(resolved.ring[0], resolved.ring.at(-1), 'ring closed');
|
||||
assert.ok(resolved.polygons.length >= 1, `${target} parts`);
|
||||
assert.equal(resolved.polygons[0][0].length + 1, resolved.ring.length);
|
||||
assert.ok(resolved.viewport?.low && resolved.viewport?.high);
|
||||
}
|
||||
assert.deepEqual(geocodes, [], 'no geocoding');
|
||||
assert.deepEqual(lookups, [], 'no boundary lookups');
|
||||
});
|
||||
|
||||
test('duplicate county names go to the one near the camera', async () => {
|
||||
const { resolve } = harness();
|
||||
const nearPortland = await resolve(
|
||||
'Washington County',
|
||||
viewerAt(45.52, -122.68),
|
||||
);
|
||||
assert.ok(nearPortland.lat > 45 && nearPortland.lon < -122, 'Oregon');
|
||||
const nearAustin = await resolve('Washington County', AUSTIN);
|
||||
assert.ok(nearAustin.lat > 29.9 && nearAustin.lat < 30.5, 'Texas');
|
||||
assert.ok(nearAustin.lon > -96.8 && nearAustin.lon < -96, 'Texas');
|
||||
});
|
||||
|
||||
test('multi-part outlines carry every part and hole', async () => {
|
||||
const { resolve } = harness();
|
||||
const hawaii = await resolve('Hawaii', AUSTIN);
|
||||
assert.ok(hawaii.polygons.length >= 7, 'islands');
|
||||
const brandenburg = await resolve('Brandenburg', AUSTIN);
|
||||
assert.ok(
|
||||
brandenburg.polygons.some((part) => part.length === 2),
|
||||
'Berlin hole',
|
||||
);
|
||||
});
|
||||
|
||||
test('Georgia resolves from camera geography without a geocoder', async () => {
|
||||
const { resolve, lookups, geocodes } = harness();
|
||||
for (const [query, viewer, latitude] of [
|
||||
['Georgia', viewerAt(33.75, -84.39, 400_000), 32],
|
||||
['the country of Georgia', AUSTIN, 42],
|
||||
['Georgia', viewerAt(41.7, 44.8), 42],
|
||||
]) {
|
||||
const resolved = await resolve(query, viewer);
|
||||
assert.equal(resolved.source, 'bundled');
|
||||
assert.ok(Math.abs(resolved.lat - latitude) < 2);
|
||||
assert.ok(resolved.ring.length > 20);
|
||||
}
|
||||
assert.deepEqual(lookups, []);
|
||||
assert.deepEqual(geocodes, []);
|
||||
});
|
||||
|
||||
test('country asks and geocoded country scopes use the bundled outline', async () => {
|
||||
const direct = harness();
|
||||
for (const name of ['Switzerland', 'Iran', 'France', 'Japan']) {
|
||||
const resolved = await direct.resolve(name, AUSTIN);
|
||||
assert.equal(resolved.source, 'bundled');
|
||||
assert.ok(resolved.polygons.length);
|
||||
if (name === 'Japan') assert.ok(resolved.polygons.length > 3);
|
||||
}
|
||||
assert.deepEqual(direct.lookups, []);
|
||||
assert.deepEqual(direct.geocodes, []);
|
||||
const geocoded = harness({
|
||||
lat: 46.95,
|
||||
lng: 7.45,
|
||||
label: 'Switzerland',
|
||||
name: 'Switzerland',
|
||||
types: ['country', 'political'],
|
||||
});
|
||||
const resolved = await geocoded.resolve(
|
||||
'Swiss country outline',
|
||||
viewerAt(46.95, 7.45),
|
||||
);
|
||||
assert.equal(resolved.source, 'geocode');
|
||||
const outline = await resolved.resolveOutline();
|
||||
assert.ok(outline.polygons.length);
|
||||
assert.deepEqual(geocoded.lookups, []);
|
||||
});
|
||||
|
||||
test('non-admin asks and entity facts keep the existing path', async () => {
|
||||
const { resolve, geocodes } = harness();
|
||||
await resolve('Texas Capitol', AUSTIN);
|
||||
await resolve('Texas', AUSTIN, { entityKind: 'building' });
|
||||
await resolve('Zilker Park', AUSTIN);
|
||||
assert.equal(
|
||||
geocodes.filter((q) => q !== 'text-search').length,
|
||||
3,
|
||||
'each ask geocoded',
|
||||
);
|
||||
// Points and explicit coordinates never use the pack.
|
||||
const point = await resolve('Texas', AUSTIN, { footprint: false });
|
||||
assert.notEqual(point?.source, 'bundled');
|
||||
});
|
||||
|
||||
test('the engine keeps every part on the mark, drawn at once or upgraded', async (t) => {
|
||||
globalThis.requestAnimationFrame ??= () => 1;
|
||||
globalThis.cancelAnimationFrame ??= () => {};
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
const polygons = [
|
||||
[
|
||||
[
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[0, 2],
|
||||
],
|
||||
[
|
||||
[0.5, 0.5],
|
||||
[0.5, 1],
|
||||
[1, 1],
|
||||
[1, 0.5],
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
[3, 3],
|
||||
[4, 3],
|
||||
[4, 4],
|
||||
],
|
||||
],
|
||||
];
|
||||
const ring = [...polygons[0][0], polygons[0][0][0]];
|
||||
const seen = [];
|
||||
const renderer = {
|
||||
add: (anno) => seen.push(['add', anno.polygons]),
|
||||
update: (anno) => seen.push(['update', anno.polygons]),
|
||||
remove() {},
|
||||
sync() {},
|
||||
destroy() {},
|
||||
};
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
renderer,
|
||||
resolveTarget: async ({ target }) =>
|
||||
target === 'now'
|
||||
? { lon: 1, lat: 1, height: 0, ring, polygons, footprintKind: 'area' }
|
||||
: {
|
||||
lon: 1,
|
||||
lat: 1,
|
||||
height: 0,
|
||||
ring: null,
|
||||
resolveOutline: async () => ({
|
||||
ring,
|
||||
polygons,
|
||||
footprintKind: 'area',
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
height: 0,
|
||||
}),
|
||||
},
|
||||
});
|
||||
await engine.annotate([{ type: 'area', target: 'now' }]);
|
||||
await engine.annotate([{ type: 'area', target: 'later', label: 'later' }]);
|
||||
for (let i = 0; i < 20; i += 1) await Promise.resolve();
|
||||
const marks = engine.list();
|
||||
assert.equal(marks[0].polygons, polygons);
|
||||
assert.ok(
|
||||
marks.some((m) => m.label === 'later' && m.polygons === polygons),
|
||||
'upgrade carries the parts',
|
||||
);
|
||||
assert.ok(
|
||||
seen.some(([kind, parts]) => kind === 'update' && parts === polygons),
|
||||
);
|
||||
engine.destroy();
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isUnavailableCapability } from '../sources/capability.js';
|
||||
import { defaultGeospatial } from '../search/defaults.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { greatCircleMeters } from '../geoDistance.js';
|
||||
@@ -82,6 +83,7 @@ export async function resolveOutlineWithRetry(
|
||||
} catch {
|
||||
fp = null; // hard failure — definitive, keep the honest point
|
||||
}
|
||||
if (isUnavailableCapability(fp)) return fp;
|
||||
if (isRateLimitedOutcome(fp)) {
|
||||
// A throttle gets one deliberately spaced retry. If that retry is throttled
|
||||
// too, stop this mark's outline task instead of replaying the batch storm.
|
||||
@@ -334,6 +336,7 @@ export function createAnnotationEngine({
|
||||
// refreshes it — usually instantly from the footprint cache.
|
||||
if (anno.pendingOutline && !anno.ring && dup.ring) {
|
||||
anno.ring = dup.ring;
|
||||
anno.polygons = dup.polygons || null;
|
||||
anno.footprintKind = dup.footprintKind || null;
|
||||
anno.buildingHeight = dup.buildingHeight || null;
|
||||
anno.synthesized = Boolean(dup.synthesized);
|
||||
@@ -674,8 +677,11 @@ export function createAnnotationEngine({
|
||||
if (myGen !== generation || controller.signal.aborted) return; // board superseded
|
||||
if (!annotations.has(anno.id)) return; // mark replaced/removed while resolving
|
||||
anno.pendingOutline = false;
|
||||
if (fp) {
|
||||
anno.outlineUnavailable = isUnavailableCapability(fp);
|
||||
if (anno.outlineUnavailable) renderer.update(anno);
|
||||
if (fp && !anno.outlineUnavailable) {
|
||||
anno.ring = fp.ring;
|
||||
anno.polygons = fp.polygons || null;
|
||||
anno.footprintKind = fp.footprintKind || null;
|
||||
anno.buildingHeight = fp.buildingHeight || null;
|
||||
anno.synthesized = Boolean(fp.synthesized);
|
||||
@@ -726,7 +732,14 @@ export function createAnnotationEngine({
|
||||
id: anno.id,
|
||||
label: anno.label || null,
|
||||
target: anno.targetKey || null,
|
||||
status: fp ? 'resolved' : 'failed',
|
||||
status: anno.outlineUnavailable
|
||||
? 'unavailable'
|
||||
: fp
|
||||
? 'resolved'
|
||||
: 'failed',
|
||||
...(anno.outlineUnavailable
|
||||
? { message: 'Detailed outline unavailable' }
|
||||
: {}),
|
||||
...(fp && anno.synthesized ? { approximate: true } : {}),
|
||||
});
|
||||
} finally {
|
||||
@@ -754,6 +767,9 @@ export function createAnnotationEngine({
|
||||
// layer narrate without waiting out a slow Overpass — and without calling the
|
||||
// missing outline a failure.
|
||||
...(anno.pendingOutline ? { outlinePending: true } : {}),
|
||||
...(anno.outlineUnavailable
|
||||
? { outlineUnavailable: true, message: 'Detailed outline unavailable' }
|
||||
: {}),
|
||||
// approximate=true means the area was SYNTHESIZED (a buffered blob around a label
|
||||
// point), not a real OSM boundary — so the voice layer can be honest.
|
||||
...(anno.synthesized ? { approximate: true } : {}),
|
||||
@@ -920,6 +936,9 @@ export function createAnnotationEngine({
|
||||
anchor: { lon: resolved.lon, lat: resolved.lat, height: resolved.height },
|
||||
to: null,
|
||||
ring: resolved.ring || null,
|
||||
// Every part with its holes ([outer, ...holes][]), when the outline has
|
||||
// more than the main ring (Hawaii's islands, Berlin inside Brandenburg).
|
||||
polygons: resolved.polygons || null,
|
||||
footprintKind: resolved.footprintKind || null, // 'building' | 'area'
|
||||
buildingHeight: resolved.buildingHeight || null, // meters, for extruded volume
|
||||
synthesized: Boolean(resolved.synthesized), // approximate buffered area → dashed render
|
||||
@@ -927,6 +946,7 @@ export function createAnnotationEngine({
|
||||
// the upgrade task fills ring/kind in place when it lands. Transient render state
|
||||
// (not persisted).
|
||||
pendingOutline: typeof resolved.resolveOutline === 'function',
|
||||
outlineUnavailable: Boolean(resolved.outlineUnavailable),
|
||||
// Which THING + SHAPE was asked for — the dedup identity while geometry is still
|
||||
// pending (see findDuplicate). targetKey is the normalized place name with trailing
|
||||
// locality qualifiers stripped ("California, United States" ≡ "California"; null for
|
||||
@@ -1048,7 +1068,11 @@ export function createAnnotationEngine({
|
||||
// Places viewport box when we have one, so a big compound isn't framed at
|
||||
// building scale while its outline is traced. Never re-fly when the ring lands.
|
||||
const range = anno.ring
|
||||
? ringRange(anno.ring)
|
||||
? ringRange(
|
||||
anno.polygons?.length
|
||||
? anno.polygons.flatMap((poly) => poly[0])
|
||||
: anno.ring,
|
||||
)
|
||||
: viewportRange(anno.viewport) || 600;
|
||||
viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
@@ -1114,12 +1138,14 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'highlight',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||
label: 'Palace of Fine Arts',
|
||||
color: 'amber',
|
||||
},
|
||||
{
|
||||
type: 'area',
|
||||
target: 'Presidio of San Francisco',
|
||||
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||
label: 'The Presidio (former Army base)',
|
||||
color: 'green',
|
||||
footprint: true,
|
||||
@@ -1127,6 +1153,7 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'pin',
|
||||
target: 'Letterman Digital Arts Center, San Francisco',
|
||||
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||
label: 'ILM / Lucasfilm',
|
||||
color: 'cyan',
|
||||
},
|
||||
@@ -1134,6 +1161,7 @@ export function createAnnotationEngine({
|
||||
type: 'arrow',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
toTarget: 'Marina District, San Francisco',
|
||||
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||
label: 'next to the Marina',
|
||||
},
|
||||
],
|
||||
@@ -1163,6 +1191,7 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'highlight',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||
label: 'Palace of Fine Arts',
|
||||
color: 'amber',
|
||||
},
|
||||
@@ -1177,6 +1206,7 @@ export function createAnnotationEngine({
|
||||
type: 'arrow',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
toTarget: 'Marina Green, San Francisco',
|
||||
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||
label: 'next to the Marina',
|
||||
color: 'cyan',
|
||||
},
|
||||
@@ -1200,6 +1230,7 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'area',
|
||||
target: 'Presidio of San Francisco',
|
||||
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||
label: 'The Presidio — a former Army base',
|
||||
color: 'green',
|
||||
footprint: true,
|
||||
@@ -1214,6 +1245,7 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'pin',
|
||||
target: 'Letterman Digital Arts Center, San Francisco',
|
||||
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||
label: 'ILM / Lucasfilm',
|
||||
color: 'red',
|
||||
},
|
||||
@@ -1227,6 +1259,7 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'route',
|
||||
color: 'amber',
|
||||
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||
label: 'Crissy Field shoreline',
|
||||
points: [
|
||||
{ target: 'Palace of Fine Arts, San Francisco' },
|
||||
@@ -1389,7 +1422,11 @@ function ringRange(ring) {
|
||||
let maxLat = -Infinity;
|
||||
let minLon = Infinity;
|
||||
let maxLon = -Infinity;
|
||||
for (const [lon, lat] of ring) {
|
||||
// Unwrap around the first vertex so parts cut at the antimeridian (the
|
||||
// Aleutians) measure as neighbours, not as a globe-wide span.
|
||||
const ref = ring[0]?.[0] ?? 0;
|
||||
for (const [rawLon, lat] of ring) {
|
||||
const lon = rawLon - 360 * Math.round((rawLon - ref) / 360);
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
if (lon < minLon) minLon = lon;
|
||||
|
||||
@@ -254,7 +254,9 @@ test('retry: HTTP 429 Retry-After 5s gets one ladder-spaced retry; a second 429
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 10_000 });
|
||||
globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
|
||||
const requestTimes = [];
|
||||
globalThis.fetch = async () => {
|
||||
globalThis.fetch = async (url) => {
|
||||
// The one-time capability probe is not an outline request.
|
||||
if (String(url).endsWith('/status')) return httpFailure(404);
|
||||
requestTimes.push(Date.now());
|
||||
return httpFailure(429, '5');
|
||||
};
|
||||
@@ -311,7 +313,9 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100_000 });
|
||||
globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
|
||||
const requestTimes = [];
|
||||
globalThis.fetch = async () => {
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).endsWith('/status'))
|
||||
return Response.json({ configured: true });
|
||||
requestTimes.push(Date.now());
|
||||
return httpFailure(500);
|
||||
};
|
||||
@@ -400,7 +404,7 @@ test('outline upgrade updates the rendered element in place without remove/add',
|
||||
};
|
||||
let overpassCall = 0;
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
if (String(url).startsWith('/api/google/geocode')) {
|
||||
return { json: async () => ({
|
||||
status: 'OK',
|
||||
results: [{
|
||||
|
||||
@@ -200,7 +200,7 @@ test('ask-side admin bypass: "the Texas Capitol" recovers near-view despite a fa
|
||||
const calls = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
if (String(url).startsWith('/api/google/geocode')) {
|
||||
return { json: async () => geocodePayload({
|
||||
lat: 31.0000,
|
||||
lon: -99.0000,
|
||||
@@ -235,7 +235,7 @@ test('ask-side admin bypass: explicit "state of Texas" skips recovery and proxim
|
||||
const calls = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
calls.push(String(url));
|
||||
assert.match(String(url), /^https:\/\/maps\.googleapis\.com\/maps\/api\/geocode/);
|
||||
assert.match(String(url), /^\/api\/google\/geocode\?/);
|
||||
return { json: async () => geocodePayload({
|
||||
lat: 31.0000,
|
||||
lon: -99.0000,
|
||||
@@ -273,7 +273,7 @@ for (const fixture of [
|
||||
const calls = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
if (String(url).startsWith('/api/google/geocode')) {
|
||||
return { json: async () => geocodePayload({
|
||||
lat: fixture.lat,
|
||||
lon: fixture.lon,
|
||||
@@ -299,7 +299,7 @@ test('ask-side admin bypass: bare "Texas" remains on the guarded recovery path',
|
||||
const calls = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
if (String(url).startsWith('/api/google/geocode')) {
|
||||
return { json: async () => geocodePayload({
|
||||
lat: 31.0000,
|
||||
lon: -99.0000,
|
||||
@@ -328,7 +328,7 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township
|
||||
const calls = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
if (String(url).startsWith('/api/google/geocode')) {
|
||||
const query = new URL(String(url)).searchParams.get('address');
|
||||
return { json: async () => geocodePayload({
|
||||
lat: 39.7817,
|
||||
@@ -380,7 +380,7 @@ function installCapitolMocks(t, elements, components = [
|
||||
{ long_name: 'Capitol Hill', types: ['neighborhood', 'political'] },
|
||||
]) {
|
||||
installGoogleMocks(t, async (url) => {
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
if (String(url).startsWith('/api/google/geocode')) {
|
||||
return { json: async () => ({ status: 'OK', results: [{
|
||||
formatted_address: 'Washington, DC 20004, USA',
|
||||
types: ['establishment', 'landmark', 'point_of_interest', 'tourist_attraction'],
|
||||
@@ -458,7 +458,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy
|
||||
const requests = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
requests.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
|
||||
if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
@@ -495,7 +495,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy
|
||||
high: { latitude: 30.28, longitude: -97.68 },
|
||||
});
|
||||
// Google is asked first and exactly once; one Photon call answers it.
|
||||
assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1);
|
||||
assert.equal(requests.filter((url) => url.includes('/api/google/geocode')).length, 1);
|
||||
assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1);
|
||||
});
|
||||
|
||||
@@ -512,7 +512,7 @@ test('keyless: OSM matching uses the feature\'s canonical name, not the user\'s
|
||||
|
||||
installGoogleMocks(t, async (url, init) => {
|
||||
const href = String(url);
|
||||
if (href.startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
|
||||
if (href.startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
|
||||
if (href.startsWith('/api/google/text-search')) return { ok: true, json: async () => ({ places: [] }) };
|
||||
if (href.startsWith('https://photon.komoot.io/')) {
|
||||
return {
|
||||
@@ -575,7 +575,7 @@ test('keyless: a Photon outage is not remembered as "no such place"', async (t)
|
||||
const requests = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
requests.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
|
||||
if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
|
||||
if (!photonReachable) throw new Error('network down');
|
||||
return photonUp;
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t, tn } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Manual whiteboard drawing: the pure half.
|
||||
*
|
||||
@@ -279,20 +281,26 @@ export function closeRing(pairs) {
|
||||
|
||||
/** One line of guidance for the person drawing, by state. */
|
||||
export function drawHint(session) {
|
||||
if (!session) return 'Pick a shape, then click the map.';
|
||||
if (!session) return t('Pick a shape, then click the map.');
|
||||
const n = session.vertices.length;
|
||||
if (session.shape === 'pin')
|
||||
return n
|
||||
? 'Enter to place the pin, Esc to cancel.'
|
||||
: 'Click where the pin goes.';
|
||||
? t('Enter to place the pin, Esc to cancel.')
|
||||
: t('Click where the pin goes.');
|
||||
const need = MIN_VERTICES[session.shape] - n;
|
||||
if (need > 0) return `Click ${need} more point${need === 1 ? '' : 's'}.`;
|
||||
if (need > 0)
|
||||
return tn(need, 'Click {count} more point.', 'Click {count} more points.');
|
||||
if (finishReason(session) === 'degenerate') {
|
||||
return session.shape === 'area'
|
||||
? 'Those points are in a line — move one off it to enclose an area.'
|
||||
: 'That line has no length — click somewhere further away.';
|
||||
? t('Those points are in a line — move one off it to enclose an area.')
|
||||
: t('That line has no length — click somewhere further away.');
|
||||
}
|
||||
const full =
|
||||
n >= MAX_VERTICES ? ` · ${MAX_VERTICES}-point limit reached` : '';
|
||||
return `${formatMeasure(session)} · double-click or Enter to finish, Backspace undoes, Esc cancels.${full}`;
|
||||
n >= MAX_VERTICES
|
||||
? t(' · {max}-point limit reached', { max: MAX_VERTICES })
|
||||
: '';
|
||||
return t(
|
||||
'{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}',
|
||||
{ measure: formatMeasure(session), full },
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
pointerOwner,
|
||||
releasePointer,
|
||||
} from '../data/inputOwnership.js';
|
||||
import { pointerOwnerName } from '../data/pointerOwnerNames.js';
|
||||
import {
|
||||
DRAW_SHAPES,
|
||||
MAX_VERTICES,
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
removeLastVertex,
|
||||
} from './drawMode.js';
|
||||
|
||||
import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js';
|
||||
/** The id this tool claims the pointer under. */
|
||||
export const DRAW_POINTER_OWNER = 'draw';
|
||||
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
|
||||
@@ -100,6 +102,17 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
target.addEventListener(type, listener, options);
|
||||
domListeners.push([target, type, listener, options]);
|
||||
};
|
||||
// The board lives only in memory: switching language (a reload) asks first
|
||||
// while it holds a lasting mark or a shape is half drawn.
|
||||
const unregisterUnsavedWork = registerUnsavedWork(() => {
|
||||
if (destroyed) return null;
|
||||
const lasting = (annotations.list?.() || []).some(
|
||||
(mark) => mark && mark.ttlMs == null && !mark.expiring,
|
||||
);
|
||||
return lasting || session?.vertices?.length > 0
|
||||
? msg('drawings on the map')
|
||||
: null;
|
||||
});
|
||||
|
||||
const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME);
|
||||
// `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would
|
||||
@@ -197,10 +210,13 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
// a limit rather than the harmless tail of a double-click.
|
||||
if (reason === 'full')
|
||||
setHint(
|
||||
`That shape already has ${MAX_VERTICES} points — finish it or press Backspace.`,
|
||||
t(
|
||||
'That shape already has {count} points — finish it or press Backspace.',
|
||||
{ count: MAX_VERTICES },
|
||||
),
|
||||
);
|
||||
else if (reason === 'invalid')
|
||||
setHint('That point is off the globe — click on the world.');
|
||||
setHint(t('That point is off the globe — click on the world.'));
|
||||
};
|
||||
const onMove = (event) => {
|
||||
if (!session || session.shape === 'pin' || destroyed) return;
|
||||
@@ -236,11 +252,15 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
flyTo: false,
|
||||
});
|
||||
if (destroyed || attempt !== generation) return result;
|
||||
if (result?.drawn === 0) setHint('That shape could not be placed.');
|
||||
if (result?.drawn === 0) setHint(t('That shape could not be placed.'));
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (destroyed || attempt !== generation) return null;
|
||||
setHint(`Could not place the shape: ${error?.message || error}`);
|
||||
setHint(
|
||||
t('Could not place the shape: {error}', {
|
||||
error: error?.message || error,
|
||||
}),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -258,7 +278,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
if (session) session = createDrawSession(shape);
|
||||
annotations.clear();
|
||||
syncPreview();
|
||||
if (!destroyed && !active) setHint('Board cleared.');
|
||||
if (!destroyed && !active) setHint(t('Board cleared.'));
|
||||
};
|
||||
|
||||
// ---- keys: only while drawing, never while typing in another field ----
|
||||
@@ -324,7 +344,11 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
// Somebody else is using the pointer — possibly an older instance of
|
||||
// this same tool that has not finished tearing down. Say so instead of
|
||||
// half-starting.
|
||||
setHint(`${pointerOwner()} is using the pointer — close it first.`);
|
||||
setHint(
|
||||
t('{owner} is using the pointer — close it first.', {
|
||||
owner: translateKnown(pointerOwnerName(pointerOwner())),
|
||||
}),
|
||||
);
|
||||
return active;
|
||||
}
|
||||
}
|
||||
@@ -502,6 +526,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
generation += 1;
|
||||
if (active) setActive(false);
|
||||
destroyed = true;
|
||||
unregisterUnsavedWork();
|
||||
releaseSceneHandler();
|
||||
releasePointer(lease);
|
||||
lease = null;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { initDrawTool } from './drawTool.js';
|
||||
import { describeUnsavedWork } from '../i18n/index.js';
|
||||
import {
|
||||
isPointerFree,
|
||||
pointerOwner,
|
||||
@@ -240,6 +241,29 @@ const LEFT_DOUBLE_CLICK = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK;
|
||||
|
||||
/* ── the tests ─────────────────────────────────────────────────────────── */
|
||||
|
||||
// First in the file: tools left by later tests would answer the same registry.
|
||||
test('a lasting mark or a half-drawn shape makes the language switch ask first', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
let marks = [];
|
||||
h.annotations.list = () => marks;
|
||||
assert.deepEqual(describeUnsavedWork(), [], 'an empty board loses nothing');
|
||||
marks = [{ id: 'a', ttlMs: 8000, expiring: false }];
|
||||
assert.deepEqual(describeUnsavedWork(), [], 'a fading mark is not work');
|
||||
marks = [{ id: 'b', ttlMs: null, expiring: false }];
|
||||
assert.deepEqual(describeUnsavedWork(), ['drawings on the map']);
|
||||
marks = [];
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
assert.deepEqual(describeUnsavedWork(), ['drawings on the map'], 'a shape in progress');
|
||||
marks = [{ id: 'c', ttlMs: null, expiring: false }];
|
||||
await h.tool.destroy();
|
||||
assert.deepEqual(describeUnsavedWork(), [], 'a destroyed tool unregisters');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('turning Draw on takes a lease and turning it off gives it back', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
@@ -263,7 +287,11 @@ test('Draw refuses to start when something else holds the pointer', async () =>
|
||||
h.element('draw-toggle').emit('click');
|
||||
assert.equal(h.tool.active, false, 'it must not half-start');
|
||||
assert.equal(pointerOwner(), 'directions', 'and must not steal the claim');
|
||||
assert.match(h.element('draw-hint').textContent, /directions/);
|
||||
// Named by its tool, not by the internal owner id.
|
||||
assert.equal(
|
||||
h.element('draw-hint').textContent,
|
||||
'Directions is using the pointer — close it first.',
|
||||
);
|
||||
assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound');
|
||||
|
||||
releasePointer(other);
|
||||
|
||||
@@ -77,7 +77,11 @@ export function createHybridAnnotationRenderer(viewer) {
|
||||
add(anno);
|
||||
return;
|
||||
}
|
||||
if (anno.type !== 'area' || !anno.ring || anno.ring.length < 3) return;
|
||||
if (anno.type !== 'area' || !anno.ring || anno.ring.length < 3) {
|
||||
if (anno.outlineUnavailable && entry.screenProxy)
|
||||
screen.update(entry.screenProxy);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entry.worldProxy) {
|
||||
entry.worldProxy = liveProxy(anno, { label: null });
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||
import { createAnnotationResolver } from './resolver.js';
|
||||
|
||||
function featureSource(overrides = {}) {
|
||||
const source = {};
|
||||
for (const method of FEATURE_SOURCE_METHODS)
|
||||
source[method] = async () => null;
|
||||
return Object.assign(source, overrides);
|
||||
}
|
||||
|
||||
// A state-typed place outside every bundled boundary, so the admin-boundary
|
||||
// feature lookup (not the bundled packs) answers.
|
||||
const offPackGeocode = {
|
||||
async geocode() {
|
||||
return {
|
||||
place: {
|
||||
lat: 0.5,
|
||||
lng: -150.5,
|
||||
label: 'Atlantis',
|
||||
name: 'Atlantis',
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function geocoderFor(place) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
async geocode(query) {
|
||||
calls.push(query);
|
||||
return { place };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('region ring: Natural Earth names resolve without a geocoder', async () => {
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource(),
|
||||
});
|
||||
for (const name of ['Gulf of Mexico', 'the Alps']) {
|
||||
const region = await resolveRegionRingForQuery(name);
|
||||
assert.ok(region?.ring?.length >= 3, `${name} resolves to a ring`);
|
||||
assert.equal(region.error, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => {
|
||||
let adminSignal;
|
||||
let release;
|
||||
let lookupStarted;
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource({
|
||||
getAdministrativeAreas(_point, { signal }) {
|
||||
lookupStarted = Date.now();
|
||||
adminSignal = signal;
|
||||
return new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
const region = await resolveRegionRingForQuery(
|
||||
'Atlantis',
|
||||
undefined,
|
||||
offPackGeocode,
|
||||
{ budgetMs: 50 },
|
||||
);
|
||||
assert.deepEqual(region, {
|
||||
name: 'Atlantis',
|
||||
ring: null,
|
||||
error: 'region-timeout',
|
||||
});
|
||||
// Timed from the boundary lookup, so the bundled pack scans before it do not
|
||||
// count. Under the 3 s default budget, so an ignored budgetMs still fails.
|
||||
assert.ok(
|
||||
Date.now() - lookupStarted < 2_000,
|
||||
'returns at the budget, not the lookup',
|
||||
);
|
||||
// The lookup is left running so it can fill the boundary cache.
|
||||
assert.equal(adminSignal?.aborted ?? false, false);
|
||||
release(null);
|
||||
});
|
||||
|
||||
test('region ring: an unbounded budget waits for the lookup', async () => {
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource({
|
||||
getAdministrativeAreas: () =>
|
||||
new Promise((resolve) => setTimeout(() => resolve(null), 30)),
|
||||
}),
|
||||
});
|
||||
const region = await resolveRegionRingForQuery(
|
||||
'Atlantis',
|
||||
undefined,
|
||||
offPackGeocode,
|
||||
{ budgetMs: Infinity },
|
||||
);
|
||||
assert.equal(region, null);
|
||||
});
|
||||
|
||||
test('region ring: bundled states and counties resolve without a geocoder', async () => {
|
||||
const lookups = [];
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource({
|
||||
getAdministrativeAreas: async () => {
|
||||
lookups.push('admin');
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
});
|
||||
const geocoder = geocoderFor(null);
|
||||
for (const [query, name] of [
|
||||
['Texas', 'Texas'],
|
||||
['Bavaria', 'Bavaria'],
|
||||
['Travis County, Texas', 'Travis County'],
|
||||
]) {
|
||||
const region = await resolveRegionRingForQuery(query, undefined, geocoder);
|
||||
assert.equal(region?.name, name, query);
|
||||
assert.ok(region.ring.length >= 8, `${query} has a real ring`);
|
||||
}
|
||||
assert.deepEqual(geocoder.calls, [], 'no geocoding');
|
||||
assert.deepEqual(lookups, [], 'no boundary lookups');
|
||||
});
|
||||
|
||||
test('region ring: a geocoder-typed state resolves from the bundled pack', async () => {
|
||||
const lookups = [];
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource({
|
||||
getAdministrativeAreas: async () => {
|
||||
lookups.push('admin');
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
});
|
||||
// "Georgia" alone is also a country, so the words don't settle it; the
|
||||
// geocoder's state type and point inside the US state do.
|
||||
const geocoder = geocoderFor({
|
||||
lat: 32.16,
|
||||
lng: -82.9,
|
||||
label: 'Georgia, USA',
|
||||
name: 'Georgia',
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
});
|
||||
const region = await resolveRegionRingForQuery(
|
||||
'Georgia',
|
||||
undefined,
|
||||
geocoder,
|
||||
);
|
||||
assert.equal(region?.name, 'Georgia');
|
||||
assert.ok(region.ring.length >= 8);
|
||||
assert.deepEqual(geocoder.calls, ['Georgia']);
|
||||
assert.deepEqual(lookups, [], 'no boundary lookups');
|
||||
});
|
||||
+186
-14
@@ -1,3 +1,4 @@
|
||||
import { isUnavailableCapability } from '../sources/capability.js';
|
||||
import { requireFeatureSource } from '../sources/featureSource.js';
|
||||
import { createOverpassFeatureSource } from '../sources/overpassFeatures.js';
|
||||
import {
|
||||
@@ -18,13 +19,26 @@ import {
|
||||
lookupNaturalRegionOutline,
|
||||
findNaturalRegion,
|
||||
} from '../data/naturalEarthRegions.js';
|
||||
import { findAdminArea, findAdminAreaAt } from '../data/adminBoundaries.js';
|
||||
import {
|
||||
registerDynamicCredit,
|
||||
NATURAL_EARTH_CREDIT,
|
||||
US_CENSUS_CREDIT,
|
||||
} from '../data/dataCredits.js';
|
||||
import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
||||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
|
||||
/** Ms the analyst region lookup waits on geocode + admin boundary. */
|
||||
export const REGION_FALLBACK_BUDGET_MS = 3_000;
|
||||
|
||||
/** Entity facts that rule out a state/county reading of the ask. */
|
||||
const NON_ADMIN_ENTITY_KINDS = new Set([
|
||||
'building',
|
||||
'compound',
|
||||
'street',
|
||||
'point_feature',
|
||||
]);
|
||||
|
||||
/** Own annotation lookup caches and ranking of supplied feature candidates. */
|
||||
export function createAnnotationResolver({
|
||||
boundarySource,
|
||||
@@ -173,6 +187,23 @@ export function createAnnotationResolver({
|
||||
// ("the Texas Capitol" → the state), so geocode types must never grant it.
|
||||
const bypassNearViewGuards = Boolean(adminScopeFromAsk(target, entityKind));
|
||||
|
||||
// Bundled administrative outlines resolve settled names without a lookup.
|
||||
// Georgia uses its qualifier or camera; city/state homonyms defer to geocoding.
|
||||
if (
|
||||
footprint &&
|
||||
(!Number.isFinite(lat) || !Number.isFinite(lon)) &&
|
||||
trace.query &&
|
||||
!NON_ADMIN_ENTITY_KINDS.has(entityKind)
|
||||
) {
|
||||
const center =
|
||||
pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
|
||||
const admin = await findAdminArea(trace.query, { near: center }).catch(
|
||||
() => null,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (admin) return bundledAdminTarget(viewer, admin, trace.query);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
const query = String(target || '').trim();
|
||||
if (query) {
|
||||
@@ -471,8 +502,32 @@ export function createAnnotationResolver({
|
||||
// GROUNDS-like ask must not short-circuit here: the model phrases "the Capitol
|
||||
// grounds" as around_the_thing, but the grounds ARE the thing — the real enclosing
|
||||
// polygon (below) beats a 400 m disc (field test 8's "spherical round one").
|
||||
const availability = await fetchEnclosingArea(
|
||||
lat,
|
||||
lon,
|
||||
signal,
|
||||
matchName,
|
||||
);
|
||||
if (
|
||||
isUnavailableCapability(availability) ||
|
||||
isRateLimitedOutcome(availability)
|
||||
)
|
||||
return availability;
|
||||
if (availability === undefined) return undefined;
|
||||
fp = synthesizeBufferedArea(lat, lon, AROUND_LANDMARK_RADIUS_M);
|
||||
} else if (isAdmin) {
|
||||
// A country, state or county the geocoder typed: the bundled unit that carries the
|
||||
// name AND contains the geocoded point (offline; the pack's ambiguity
|
||||
// marks don't apply — the geocoder already chose "Georgia" the state).
|
||||
if (scope === 'state' || scope === 'county' || scope === 'country') {
|
||||
const admin = await findAdminAreaAt(
|
||||
[target, matchName],
|
||||
lat,
|
||||
lon,
|
||||
scope,
|
||||
).catch(() => null);
|
||||
if (admin) return bundledAdminOutline(viewer, admin);
|
||||
}
|
||||
// Pure admin: only an admin boundary is correct — never fall back to a
|
||||
// building/landuse (a city is never a single building).
|
||||
fp = await fetchAdminArea(lat, lon, matchName, scope, signal);
|
||||
@@ -498,6 +553,7 @@ export function createAnnotationResolver({
|
||||
// A neighborhood whose canonical name carries a city suffix ("Presidio of San
|
||||
// Francisco") can match the CITY admin; treat an oversized admin as a DEFINITIVE
|
||||
// no-neighborhood-polygon so it still falls through to the named-landuse path.
|
||||
if (isUnavailableCapability(adminFp)) return adminFp;
|
||||
if (adminFp && exceedsScopeArea(adminFp, scope)) adminFp = null;
|
||||
if (adminFp) {
|
||||
fp = adminFp;
|
||||
@@ -512,6 +568,7 @@ export function createAnnotationResolver({
|
||||
signal,
|
||||
'strict',
|
||||
);
|
||||
if (isUnavailableCapability(footFp)) return footFp;
|
||||
if (footFp) {
|
||||
fp = footFp;
|
||||
} else if (footFp === null) {
|
||||
@@ -528,6 +585,7 @@ export function createAnnotationResolver({
|
||||
signal,
|
||||
'loose',
|
||||
);
|
||||
if (isUnavailableCapability(looseFp)) return looseFp;
|
||||
if (looseFp && looseFp.kind !== 'building') fp = looseFp;
|
||||
else if (looseFp === null)
|
||||
fp = synthesizeBufferedArea(lat, lon, NEIGHBORHOOD_RADIUS_M);
|
||||
@@ -577,6 +635,7 @@ export function createAnnotationResolver({
|
||||
// synthesized disc only when OSM DEFINITIVELY has none.
|
||||
if (groundsLike && (fp === null || fp?.kind === 'building')) {
|
||||
const area = await fetchEnclosingArea(lat, lon, signal, matchName);
|
||||
if (isUnavailableCapability(area)) return area;
|
||||
if (area) {
|
||||
fp = area; // real grounds polygon (synthesized:false) — beats both the dome and a disc
|
||||
// It's a grounds/compound feature (already capped at SCOPE_AREA_CAP_M2.compound inside
|
||||
@@ -616,7 +675,7 @@ export function createAnnotationResolver({
|
||||
) {
|
||||
fp = null;
|
||||
}
|
||||
if (isRateLimitedOutcome(fp)) return fp;
|
||||
if (isUnavailableCapability(fp) || isRateLimitedOutcome(fp)) return fp;
|
||||
if (fp === undefined) return undefined; // TRANSIENT — a backoff retry may still find it
|
||||
if (!fp || !Array.isArray(fp.ring) || fp.ring.length < 3) return null;
|
||||
const centroid = ringCentroid(fp.ring);
|
||||
@@ -643,10 +702,12 @@ export function createAnnotationResolver({
|
||||
let ring = null;
|
||||
let footprintKind = null; // 'building' | 'area'
|
||||
let buildingHeight = null; // meters, only for buildings
|
||||
let outlineUnavailable = false;
|
||||
let synthesized = false; // true = buffered/approximate area, render dashed/feathered
|
||||
if (footprint && !deferFootprint) {
|
||||
const fp = await resolveOutline();
|
||||
if (fp && !isRateLimitedOutcome(fp)) {
|
||||
outlineUnavailable = isUnavailableCapability(fp);
|
||||
if (fp && !outlineUnavailable && !isRateLimitedOutcome(fp)) {
|
||||
ring = fp.ring;
|
||||
footprintKind = fp.footprintKind;
|
||||
buildingHeight = fp.buildingHeight;
|
||||
@@ -677,6 +738,7 @@ export function createAnnotationResolver({
|
||||
label,
|
||||
source,
|
||||
synthesized,
|
||||
outlineUnavailable,
|
||||
viewport: placeViewport,
|
||||
...(footprint && deferFootprint ? { resolveOutline } : {}),
|
||||
};
|
||||
@@ -717,6 +779,65 @@ export function createAnnotationResolver({
|
||||
const GROUNDS_RADIUS_MIN_M = 150; // viewport-derived grounds disc is clamped to this band so a tiny
|
||||
const GROUNDS_RADIUS_MAX_M = 1200; // place can't shrink to a dot, nor a city-wide viewport balloon
|
||||
|
||||
/** Credit the pack a bundled boundary came from. */
|
||||
function creditAdminSource(viewer, admin) {
|
||||
registerDynamicCredit(
|
||||
viewer,
|
||||
admin.source === 'us-census' ? US_CENSUS_CREDIT : NATURAL_EARTH_CREDIT,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Outline patch (resolveOutline's contract) for a bundled administrative unit.
|
||||
* `ring` is the main part, closed; `polygons` carries every part with its
|
||||
* holes (Hawaii's islands, Berlin inside Brandenburg) for renderers that
|
||||
* draw them. Like the Natural Earth rung, it bypasses the scope caps and the
|
||||
* centroid drift bound: the unit IS the asked scope.
|
||||
*/
|
||||
function bundledAdminOutline(viewer, admin) {
|
||||
creditAdminSource(viewer, admin);
|
||||
const { lat, lon } = admin.label;
|
||||
return {
|
||||
ring: closeRing([...admin.ring]), // copy: closeRing mutates, the pack is shared
|
||||
polygons: admin.polygons,
|
||||
footprintKind: 'area',
|
||||
buildingHeight: null,
|
||||
synthesized: false,
|
||||
adminArea: admin.name,
|
||||
lat,
|
||||
lon,
|
||||
height: sampleGroundHeight(viewer, lon, lat),
|
||||
};
|
||||
}
|
||||
|
||||
/** A complete resolved target for a bundled administrative unit (no outline pending). */
|
||||
function bundledAdminTarget(viewer, admin, query) {
|
||||
const outline = bundledAdminOutline(viewer, admin);
|
||||
const [west, south, east, north] = admin.bbox;
|
||||
console.log(
|
||||
`[Resolver] "${query}": bundled ${admin.kind} "${admin.name}"` +
|
||||
`${admin.region ? `, ${admin.region}` : ''} (${admin.source}, ` +
|
||||
`${admin.polygons.length} part(s), ${admin.candidates} candidate(s)) → FINAL source=bundled`,
|
||||
);
|
||||
return {
|
||||
lon: outline.lon,
|
||||
lat: outline.lat,
|
||||
height: outline.height,
|
||||
ring: outline.ring,
|
||||
polygons: outline.polygons,
|
||||
footprintKind: 'area',
|
||||
buildingHeight: null,
|
||||
label: admin.name,
|
||||
source: 'bundled',
|
||||
synthesized: false,
|
||||
outlineUnavailable: false,
|
||||
viewport: {
|
||||
low: { latitude: south, longitude: west },
|
||||
high: { latitude: north, longitude: east },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize an approximate circular AREA by buffering a label point. Returns a ring
|
||||
* marked `synthesized:true` so the renderers draw it "approximate" (dashed/feathered),
|
||||
@@ -1001,7 +1122,8 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(candidates)) return candidates;
|
||||
if (isUnavailableCapability(candidates) || isRateLimitedOutcome(candidates))
|
||||
return candidates;
|
||||
if (!candidates) return undefined; // transient upstream failure (vs null = definitive miss)
|
||||
|
||||
const queryWords = normalizedWords(query);
|
||||
@@ -1064,7 +1186,8 @@ export function createAnnotationResolver({
|
||||
// without first trying the place= polygon (and a named landuse via the caller).
|
||||
if (scope === 'neighborhood' && (!best || bestCoverage < 0.8)) {
|
||||
const place = await fetchPlaceArea(lat, lon, query, signal);
|
||||
if (isRateLimitedOutcome(place)) return place;
|
||||
if (isUnavailableCapability(place) || isRateLimitedOutcome(place))
|
||||
return place;
|
||||
if (place) {
|
||||
cacheWrite(footprintCache, cacheKey, place);
|
||||
return place;
|
||||
@@ -1094,7 +1217,8 @@ export function createAnnotationResolver({
|
||||
const relEls = await featureSource.getAreaGeometry(cand.el.id, {
|
||||
signal: signal,
|
||||
});
|
||||
if (isRateLimitedOutcome(relEls)) return relEls;
|
||||
if (isUnavailableCapability(relEls) || isRateLimitedOutcome(relEls))
|
||||
return relEls;
|
||||
if (relEls === null) {
|
||||
transient = true;
|
||||
break;
|
||||
@@ -1123,7 +1247,8 @@ export function createAnnotationResolver({
|
||||
if (exceedsScopeArea(fp, scope)) {
|
||||
if (scope === 'neighborhood') {
|
||||
const place = await fetchPlaceArea(lat, lon, query, signal);
|
||||
if (isRateLimitedOutcome(place)) return place;
|
||||
if (isUnavailableCapability(place) || isRateLimitedOutcome(place))
|
||||
return place;
|
||||
if (place) {
|
||||
cacheWrite(footprintCache, cacheKey, place);
|
||||
return place;
|
||||
@@ -1146,7 +1271,8 @@ export function createAnnotationResolver({
|
||||
// caller relies on that to gate named-landuse fallback / synthesis.
|
||||
if (scope === 'neighborhood') {
|
||||
const place = await fetchPlaceArea(lat, lon, query, signal);
|
||||
if (isRateLimitedOutcome(place)) return place;
|
||||
if (isUnavailableCapability(place) || isRateLimitedOutcome(place))
|
||||
return place;
|
||||
if (place) {
|
||||
cacheWrite(footprintCache, cacheKey, place);
|
||||
return place;
|
||||
@@ -1168,7 +1294,7 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(els)) return els;
|
||||
if (isUnavailableCapability(els) || isRateLimitedOutcome(els)) return els;
|
||||
if (els === null) return undefined; // transient upstream failure (vs [] = no match)
|
||||
let bestRing = null;
|
||||
let bestScore = 0;
|
||||
@@ -1217,7 +1343,8 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(areaEls)) return areaEls;
|
||||
if (isUnavailableCapability(areaEls) || isRateLimitedOutcome(areaEls))
|
||||
return areaEls;
|
||||
if (areaEls) {
|
||||
let bestRing = null;
|
||||
let bestScore = 0;
|
||||
@@ -1250,7 +1377,8 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(wayEls)) return wayEls;
|
||||
if (isUnavailableCapability(wayEls) || isRateLimitedOutcome(wayEls))
|
||||
return wayEls;
|
||||
if (wayEls) {
|
||||
const segments = [];
|
||||
for (const el of wayEls) {
|
||||
@@ -1323,7 +1451,8 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(elements)) return elements;
|
||||
if (isUnavailableCapability(elements) || isRateLimitedOutcome(elements))
|
||||
return elements;
|
||||
if (elements === null || signal?.aborted) return undefined;
|
||||
const fp = selectFootprint(elements, lat, lon, query, mode);
|
||||
if (fp) {
|
||||
@@ -1371,7 +1500,8 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(elements)) return elements;
|
||||
if (isUnavailableCapability(elements) || isRateLimitedOutcome(elements))
|
||||
return elements;
|
||||
if (elements === null) return undefined; // transient upstream failure (vs [] = definitive no-match)
|
||||
|
||||
const queryWords = normalizedWords(query);
|
||||
@@ -1494,7 +1624,11 @@ export function createAnnotationResolver({
|
||||
pending = featureSource
|
||||
.getMonuments({ lat, lon }, { signal: undefined })
|
||||
.then((elements) => {
|
||||
if (elements === null || isRateLimitedOutcome(elements))
|
||||
if (
|
||||
elements === null ||
|
||||
isUnavailableCapability(elements) ||
|
||||
isRateLimitedOutcome(elements)
|
||||
)
|
||||
return null; // transient — NEVER cached (a poisoned bucket
|
||||
// would silently disable the snap for the whole session, field test 7 §1)
|
||||
const feats = [];
|
||||
@@ -1877,14 +2011,22 @@ export function createAnnotationResolver({
|
||||
* resolve to a region-like boundary — the analyst engine reports that
|
||||
* honestly rather than silently scoping to nothing.
|
||||
*
|
||||
* The geocode + admin-boundary rung is capped at `budgetMs`. Past it the
|
||||
* call returns `{name, ring: null, error: 'region-timeout'}` so a voice
|
||||
* answer is not held for tens of seconds; the lookup keeps running and
|
||||
* fills the boundary cache, so asking again shortly is fast.
|
||||
*
|
||||
* @param {string} name e.g. "Texas", "the Alps", "France", "Gulf of Mexico"
|
||||
* @param {AbortSignal} [signal]
|
||||
* @returns {Promise<{name:string, ring:Array<[number,number]>}|null>}
|
||||
* @param {object} [placeSearch]
|
||||
* @param {{budgetMs?: number}} [options] `Infinity` waits for the lookup.
|
||||
* @returns {Promise<{name:string, ring:Array<[number,number]>}|{name:string, ring:null, error:'region-timeout'}|null>}
|
||||
*/
|
||||
async function resolveRegionRingForQuery(
|
||||
name,
|
||||
signal,
|
||||
placeSearch = unavailablePlaceSearch,
|
||||
{ budgetMs = REGION_FALLBACK_BUDGET_MS } = {},
|
||||
) {
|
||||
signal = lifetime
|
||||
? signal
|
||||
@@ -1901,12 +2043,42 @@ export function createAnnotationResolver({
|
||||
const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0];
|
||||
if (ring?.length >= 3) return { name: ne.name, ring };
|
||||
}
|
||||
// Bundled states/provinces/counties by name (offline); the main part only,
|
||||
// like the Natural Earth rung above.
|
||||
const admin = await findAdminArea(q).catch(() => null);
|
||||
if (admin) return { name: admin.name, ring: [...admin.ring] };
|
||||
const lookup = resolveAdminRegionRing(q, signal, placeSearch);
|
||||
if (!Number.isFinite(budgetMs)) return lookup;
|
||||
let timer;
|
||||
const timeout = new Promise((resolve) => {
|
||||
timer = setTimeout(
|
||||
() => resolve({ name: q, ring: null, error: 'region-timeout' }),
|
||||
budgetMs,
|
||||
);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([lookup, timeout]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAdminRegionRing(q, signal, placeSearch) {
|
||||
const geo = await geocodePlace(q, null, signal, placeSearch).catch(
|
||||
() => null,
|
||||
);
|
||||
if (!geo) return null;
|
||||
const scope = scopeFromTypes(geo.types);
|
||||
if (!['country', 'state', 'county', 'city'].includes(scope)) return null;
|
||||
if (scope === 'state' || scope === 'county' || scope === 'country') {
|
||||
const admin = await findAdminAreaAt(
|
||||
[q, geo.primaryName],
|
||||
geo.lat,
|
||||
geo.lon,
|
||||
scope,
|
||||
).catch(() => null);
|
||||
if (admin) return { name: q, ring: [...admin.ring] };
|
||||
}
|
||||
const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
@@ -4,6 +4,12 @@ import {
|
||||
getActiveTrackedReadoutId,
|
||||
TRACKED_OVERLAY_SOURCE_ID,
|
||||
} from '../data/trackedReadout.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/** A label whose detailed outline could not be resolved. */
|
||||
function outlineUnavailableLabel(label) {
|
||||
return t('{label} · Detailed outline unavailable', { label: label || '' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen-space annotation renderer (Direction B — the "whiteboard" aesthetic).
|
||||
@@ -161,7 +167,12 @@ export function createScreenAnnotationRenderer(
|
||||
filter: 'url(#gev-sketch)',
|
||||
});
|
||||
group.appendChild(parts.poly);
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else if (anno.type === 'arrow' && anno.to) {
|
||||
parts.path = svgEl('path', {
|
||||
@@ -174,7 +185,12 @@ export function createScreenAnnotationRenderer(
|
||||
});
|
||||
ensureArrowMarker(defs, anno.color || 'primary', c);
|
||||
group.appendChild(parts.path);
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else if (
|
||||
anno.type === 'route' &&
|
||||
@@ -203,7 +219,12 @@ export function createScreenAnnotationRenderer(
|
||||
group.appendChild(dot);
|
||||
return dot;
|
||||
});
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else {
|
||||
// pin / highlight / label — pulsing target rings + a marker dot + callout
|
||||
@@ -242,7 +263,12 @@ export function createScreenAnnotationRenderer(
|
||||
'stroke-opacity': '0.7',
|
||||
});
|
||||
group.appendChild(parts.leader);
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
}
|
||||
|
||||
@@ -301,6 +327,10 @@ export function createScreenAnnotationRenderer(
|
||||
return;
|
||||
}
|
||||
rec.anno = anno;
|
||||
if (anno.outlineUnavailable && rec.parts.label) {
|
||||
rec.parts.label.text.textContent = outlineUnavailableLabel(anno.label);
|
||||
sizeCallout(rec.parts.label);
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
for (const child of Array.from(rec.group.children)) child.remove();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
||||
|
||||
/**
|
||||
* World-space annotation renderer (Direction A).
|
||||
@@ -10,7 +11,7 @@ import * as Cesium from 'cesium';
|
||||
* IMPORTANT — drawing on Google Photorealistic 3D Tiles:
|
||||
* The Cesium globe is hidden, so there is no terrain to clamp to. Ground
|
||||
* geometry (areas, rings, connectors) is draped onto the photoreal tiles with
|
||||
* `classificationType: CESIUM_3D_TILE`; points and labels clamp to the tile
|
||||
* `classificationType: CESIUM_3D_TILE`; points clamp to the tile
|
||||
* surface with `heightReference: CLAMP_TO_GROUND` (which requires the tileset
|
||||
* to have `enableCollision = true`, set in initAnnotations). This keeps marks
|
||||
* sitting ON the world instead of buried at sea level.
|
||||
@@ -49,6 +50,7 @@ const CLAMP = Cesium.HeightReference.CLAMP_TO_GROUND;
|
||||
export function createWorldAnnotationRenderer(viewer) {
|
||||
const dataSource = new Cesium.CustomDataSource('gev-annotations');
|
||||
viewer.dataSources.add(dataSource);
|
||||
const osmAnnotations = new Set();
|
||||
|
||||
// Register the GevRouteFlow fabric once so Cesium's Material.fromType() can build the
|
||||
// material the route pipeline renders. The animated `time` uniform is read straight
|
||||
@@ -159,51 +161,74 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
} else if (anno.ring && anno.ring.length >= 3) {
|
||||
// Larger area (district / compound / park) → flat fill draped on the tiles
|
||||
// + a glowing outline. Draping is right here: you can't extrude a whole
|
||||
// neighbourhood, and the GIS overlay shows the boundary clearly.
|
||||
const fillPositions = Cesium.Cartesian3.fromDegreesArray(
|
||||
anno.ring.flat(),
|
||||
);
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(fillPositions),
|
||||
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
liveColor(anno, base, {
|
||||
alpha: anno.synthesized ? 0.1 : 0.2,
|
||||
pulse: true,
|
||||
}),
|
||||
),
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
// Larger area (district / compound / park / state) → flat fill draped on
|
||||
// the tiles + a glowing outline. Draping is right here: you can't extrude
|
||||
// a whole neighbourhood, and the GIS overlay shows the boundary clearly.
|
||||
// A multi-part outline (`anno.polygons`: Hawaii's islands, Berlin as a
|
||||
// hole in Brandenburg) draws every part with its holes. All parts share
|
||||
// ONE fill and ONE line material, so Cesium batches the whole mark into a
|
||||
// ground primitive per kind instead of one per island.
|
||||
const parts =
|
||||
Array.isArray(anno.polygons) && anno.polygons.length
|
||||
? anno.polygons
|
||||
: [[anno.ring]];
|
||||
const fillMaterial = new Cesium.ColorMaterialProperty(
|
||||
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
|
||||
liveColor(anno, base, {
|
||||
alpha: anno.synthesized ? 0.1 : 0.2,
|
||||
pulse: true,
|
||||
}),
|
||||
);
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polyline: {
|
||||
positions: fillPositions,
|
||||
width: 6,
|
||||
// Synthesized → DASHED outline (signals "approximate, not an authoritative
|
||||
// boundary", research §8.4/§8.6); real footprints → solid glow.
|
||||
material: anno.synthesized
|
||||
? new Cesium.PolylineDashMaterialProperty({
|
||||
color: liveColor(anno, base, { alpha: 0.95 }),
|
||||
dashLength: 24,
|
||||
})
|
||||
: new Cesium.PolylineGlowMaterialProperty({
|
||||
glowPower: 0.35,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
}),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
// Synthesized → DASHED outline (signals "approximate, not an authoritative
|
||||
// boundary", research §8.4/§8.6); real footprints → solid glow.
|
||||
const lineMaterial = anno.synthesized
|
||||
? new Cesium.PolylineDashMaterialProperty({
|
||||
color: liveColor(anno, base, { alpha: 0.95 }),
|
||||
dashLength: 24,
|
||||
})
|
||||
: new Cesium.PolylineGlowMaterialProperty({
|
||||
glowPower: 0.35,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
});
|
||||
for (const [outer, ...holes] of parts) {
|
||||
if (!Array.isArray(outer) || outer.length < 3) continue;
|
||||
const rings = [outer, ...holes.filter((h) => h?.length >= 3)];
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(
|
||||
Cesium.Cartesian3.fromDegreesArray(outer.flat()),
|
||||
rings
|
||||
.slice(1)
|
||||
.map(
|
||||
(hole) =>
|
||||
new Cesium.PolygonHierarchy(
|
||||
Cesium.Cartesian3.fromDegreesArray(hole.flat()),
|
||||
),
|
||||
),
|
||||
),
|
||||
material: fillMaterial,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
for (const ring of rings) {
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polyline: {
|
||||
positions: Cesium.Cartesian3.fromDegreesArray(
|
||||
closedRing(ring).flat(),
|
||||
),
|
||||
width: 6,
|
||||
material: lineMaterial,
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
anno.type === 'route' &&
|
||||
Array.isArray(anno.path) &&
|
||||
@@ -228,7 +253,8 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
osmAnnotations.add(anno);
|
||||
showOsmCredit(viewer, anno);
|
||||
} else if (anno.type === 'arrow' && anno.to) {
|
||||
// Connector draped across the ground from origin to destination.
|
||||
const positions = [
|
||||
@@ -248,18 +274,6 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) {
|
||||
const mid = {
|
||||
lon: (anno.anchor.lon + anno.to.lon) / 2,
|
||||
lat: (anno.anchor.lat + anno.to.lat) / 2,
|
||||
};
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
|
||||
label: labelGraphic(anno, base),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// pin / highlight / label — a camera-proportional target ring + a marker.
|
||||
if (anno.type !== 'label') {
|
||||
@@ -284,54 +298,28 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
entities.push(labelMarker(anno, base, { point: true }));
|
||||
entities.push(pointMarker(anno, base));
|
||||
}
|
||||
}
|
||||
|
||||
// A clamped point + (optional) label that sits on the tile surface.
|
||||
function labelMarker(anno, base, { point }) {
|
||||
// Captions belong to the hybrid renderer's screen-space callouts.
|
||||
function pointMarker(anno, base) {
|
||||
return dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
|
||||
point: point
|
||||
? {
|
||||
pixelSize: anno.type === 'label' ? 8 : 14,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
|
||||
outlineWidth: 3,
|
||||
heightReference: CLAMP,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
}
|
||||
: undefined,
|
||||
label: anno.label ? labelGraphic(anno, base) : undefined,
|
||||
point: {
|
||||
pixelSize: anno.type === 'label' ? 8 : 14,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
|
||||
outlineWidth: 3,
|
||||
heightReference: CLAMP,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function labelGraphic(anno, base) {
|
||||
return {
|
||||
text: anno.label,
|
||||
font: '600 14px "Inter", system-ui, sans-serif',
|
||||
fillColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 1 }),
|
||||
outlineColor: liveColor(anno, Cesium.Color.BLACK, { alpha: 0.85 }),
|
||||
outlineWidth: 3,
|
||||
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
||||
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||||
pixelOffset: new Cesium.Cartesian2(0, -16),
|
||||
showBackground: true,
|
||||
backgroundColor: new Cesium.CallbackProperty(
|
||||
() =>
|
||||
Cesium.Color.fromCssColorString('#0b1622').withAlpha(
|
||||
0.72 * (anno.alpha ?? 1),
|
||||
),
|
||||
false,
|
||||
),
|
||||
backgroundPadding: new Cesium.Cartesian2(8, 5),
|
||||
heightReference: CLAMP,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
scaleByDistance: new Cesium.NearFarScalar(500, 1.05, 14000, 0.55),
|
||||
};
|
||||
}
|
||||
|
||||
function remove(anno) {
|
||||
hideOsmCredit(viewer, anno);
|
||||
osmAnnotations.delete(anno);
|
||||
if (!anno?._entities) return;
|
||||
for (const entity of anno._entities) {
|
||||
try {
|
||||
@@ -348,6 +336,8 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
for (const anno of osmAnnotations) hideOsmCredit(viewer, anno);
|
||||
osmAnnotations.clear();
|
||||
try {
|
||||
viewer.dataSources.remove(dataSource, true);
|
||||
} catch {
|
||||
@@ -452,6 +442,13 @@ FlowMaterialProperty.prototype.equals = function equals(other) {
|
||||
return this === other;
|
||||
};
|
||||
|
||||
/** The ring with its first vertex repeated at the end, so an outline closes. */
|
||||
function closedRing(ring) {
|
||||
const first = ring[0];
|
||||
const last = ring[ring.length - 1];
|
||||
return first[0] === last[0] && first[1] === last[1] ? ring : [...ring, first];
|
||||
}
|
||||
|
||||
/** Evenly down-sample a [[lon,lat],...] ring to at most n points (keeps shape). */
|
||||
function decimateRing(ring, n) {
|
||||
if (ring.length <= n) return ring;
|
||||
|
||||
Reference in new issue
Block a user