mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge upstream main into PR #1206 for integration review
This commit is contained in:
commit
aff540ad39
1087 files changed
+71826
-20757
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* The shapes drawn by hand, as UTILITIES ▸ Draw lists them: one row per shape
|
||||
* with its colour, its name, and show/hide, fly-to and remove, the way Import
|
||||
* lists its files. The board is the annotation engine's; voice marks are not
|
||||
* listed, only `manual` ones.
|
||||
*/
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
export const DRAW_COLORS = Object.freeze([
|
||||
'primary',
|
||||
'amber',
|
||||
'cyan',
|
||||
'green',
|
||||
'red',
|
||||
]);
|
||||
/** Each colour as drawn: the preview, the swatches and the drawn shape. */
|
||||
export const DRAW_COLOR_CSS = Object.freeze({
|
||||
primary: '#8be9ff',
|
||||
amber: '#ffb547',
|
||||
cyan: '#39d0ff',
|
||||
green: '#5dff9f',
|
||||
red: '#ff6b6b',
|
||||
});
|
||||
export const DRAW_COLOR_NAMES = Object.freeze({
|
||||
primary: msg('Primary'),
|
||||
amber: msg('Amber'),
|
||||
cyan: msg('Cyan'),
|
||||
green: msg('Green'),
|
||||
red: msg('Red'),
|
||||
});
|
||||
const SHAPE_NAMES = Object.freeze({
|
||||
area: msg('Area'),
|
||||
route: msg('Line'),
|
||||
pin: msg('Pin'),
|
||||
});
|
||||
|
||||
/** What a row calls a shape that has no name of its own. */
|
||||
export function drawMarkFallbackName(mark) {
|
||||
const shape = t(SHAPE_NAMES[mark?.type] || SHAPE_NAMES.pin);
|
||||
// An unnamed line's label is its length; keep it, it is the useful part.
|
||||
return mark?.type === 'route' && !mark.name && mark.label
|
||||
? `${shape} · ${mark.label}`
|
||||
: shape;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows for the hand-drawn marks on the board, in board order. Text is
|
||||
* worded here, in the current language; the view only lays it out.
|
||||
* @param {object[]} marks The engine's `list()`.
|
||||
*/
|
||||
export function presentDrawMarks(marks = []) {
|
||||
return marks
|
||||
.filter((mark) => mark?.manual === true)
|
||||
.map((mark) => {
|
||||
const fallback = drawMarkFallbackName(mark);
|
||||
const title = mark.name || fallback;
|
||||
const visible = !mark.hidden;
|
||||
return {
|
||||
id: mark.id,
|
||||
name: mark.name || '',
|
||||
placeholder: fallback,
|
||||
color: DRAW_COLORS.includes(mark.color) ? mark.color : 'primary',
|
||||
hidden: !visible,
|
||||
labels: {
|
||||
name: t('Name of {name}', { name: title }),
|
||||
swatch: t('Change the colour of {name}', { name: title }),
|
||||
colors: t('Colour of {name}', { name: title }),
|
||||
toggle: visible
|
||||
? t('Hide {name}', { name: title })
|
||||
: t('Show {name}', { name: title }),
|
||||
fly: t('Fly to {name}', { name: title }),
|
||||
remove: t('Remove {name}', { name: title }),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DRAW_COLORS,
|
||||
DRAW_COLOR_CSS,
|
||||
drawMarkFallbackName,
|
||||
presentDrawMarks,
|
||||
} from './drawMarks.js';
|
||||
|
||||
test('an unnamed shape is called by its kind, and an unnamed line by its length too', () => {
|
||||
assert.equal(drawMarkFallbackName({ type: 'area' }), 'Area');
|
||||
assert.equal(drawMarkFallbackName({ type: 'pin' }), 'Pin');
|
||||
assert.equal(
|
||||
drawMarkFallbackName({ type: 'route', label: '300 m' }),
|
||||
'Line · 300 m',
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkFallbackName({
|
||||
type: 'route',
|
||||
name: 'Walk',
|
||||
label: 'Walk — 300 m',
|
||||
}),
|
||||
'Line',
|
||||
);
|
||||
});
|
||||
|
||||
test('rows list only hand-drawn marks, in board order, with their words', () => {
|
||||
const rows = presentDrawMarks([
|
||||
{
|
||||
id: 'a1',
|
||||
type: 'route',
|
||||
manual: true,
|
||||
name: null,
|
||||
label: '950 m',
|
||||
color: 'amber',
|
||||
},
|
||||
{ id: 'a2', type: 'area', manual: false, label: 'Texas', color: 'primary' },
|
||||
{
|
||||
id: 'a3',
|
||||
type: 'pin',
|
||||
manual: true,
|
||||
name: 'Camp',
|
||||
color: 'teal',
|
||||
hidden: true,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
rows.map(({ id, name, placeholder, color, hidden }) => ({
|
||||
id,
|
||||
name,
|
||||
placeholder,
|
||||
color,
|
||||
hidden,
|
||||
})),
|
||||
[
|
||||
{
|
||||
id: 'a1',
|
||||
name: '',
|
||||
placeholder: 'Line · 950 m',
|
||||
color: 'amber',
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
id: 'a3',
|
||||
name: 'Camp',
|
||||
placeholder: 'Pin',
|
||||
color: 'primary',
|
||||
hidden: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.equal(rows[0].labels.toggle, 'Hide Line · 950 m');
|
||||
assert.equal(rows[1].labels.toggle, 'Show Camp');
|
||||
assert.equal(rows[1].labels.fly, 'Fly to Camp');
|
||||
assert.equal(rows[1].labels.colors, 'Colour of Camp');
|
||||
});
|
||||
|
||||
test('every colour has its swatch', () => {
|
||||
assert.deepEqual(Object.keys(DRAW_COLOR_CSS), [...DRAW_COLORS]);
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import { t, tn } from '../i18n/index.js';
|
||||
*/
|
||||
|
||||
import { greatCircleMeters } from '../geoDistance.js';
|
||||
import { formatDistanceText } from '../data/distanceFormat.js';
|
||||
|
||||
export const DRAW_SHAPES = Object.freeze(['area', 'line', 'pin']);
|
||||
export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
|
||||
@@ -207,17 +208,16 @@ export function ringCentroid(vertices) {
|
||||
export function formatMeasure(session) {
|
||||
if (!session) return '';
|
||||
if (session.shape === 'line') {
|
||||
const m = pathLengthM(session.vertices);
|
||||
// Round before the kilometre cut. 999.6 m was printed as "1000 m".
|
||||
const whole = Math.round(m);
|
||||
if (whole < 1000) return `${whole} m`;
|
||||
return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`;
|
||||
return formatDistanceText(pathLengthM(session.vertices)) ?? '';
|
||||
}
|
||||
if (session.shape === 'area') {
|
||||
const m2 = ringAreaM2(session.vertices);
|
||||
if (m2 >= 1e6) return `${(m2 / 1e6).toFixed(2)} km²`;
|
||||
if (m2 >= 1e4) return `${(m2 / 1e4).toFixed(1)} ha`;
|
||||
return `${Math.round(m2)} m²`;
|
||||
// Round before each unit cut, as for a line. 9,999.6 m² was "10000 m²".
|
||||
const whole = Math.round(m2);
|
||||
const ha = (m2 / 1e4).toFixed(1);
|
||||
if (whole < 1e4) return `${whole} m²`;
|
||||
if (Number(ha) < 100) return `${ha} ha`;
|
||||
return `${(m2 / 1e6).toFixed(2)} km²`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -345,3 +345,51 @@ test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => {
|
||||
assert.equal(formatMeasure(line), '1.0 km');
|
||||
assert.match(drawHint(line), /^1\.0 km · /);
|
||||
});
|
||||
|
||||
test('a line that rounds to 10.0 km reads in whole kilometres', () => {
|
||||
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
|
||||
const line = createDrawSession('line');
|
||||
addVertex(line, { lon: 0, lat: 0 });
|
||||
addVertex(line, { lon: 0, lat: 9960 / metresPerDegree });
|
||||
assert.equal(formatMeasure(line), '10 km');
|
||||
});
|
||||
|
||||
test('an area that rounds up to the next unit reads in that unit', () => {
|
||||
// A square on the equator whose shoelace area is about `m2`.
|
||||
const square = (m2) => {
|
||||
const side = Math.sqrt(m2) / 111320;
|
||||
const area = createDrawSession('area');
|
||||
for (const [lon, lat] of [
|
||||
[0, 0],
|
||||
[side, 0],
|
||||
[side, side],
|
||||
[0, side],
|
||||
])
|
||||
addVertex(area, { lon, lat });
|
||||
return area;
|
||||
};
|
||||
const almostHectare = square(9999.6);
|
||||
const metres = ringAreaM2(almostHectare.vertices);
|
||||
assert.ok(
|
||||
metres >= 9999.5 && metres < 1e4,
|
||||
`area should round to 10000 m², got ${metres}`,
|
||||
);
|
||||
assert.equal(formatMeasure(almostHectare), '1.0 ha');
|
||||
assert.match(drawHint(almostHectare), /^1\.0 ha · /);
|
||||
assert.equal(formatMeasure(square(9999.4)), '9999 m²');
|
||||
// Pin the shoelace area too, so a geodesy change fails here and not as a unit.
|
||||
const almostKm2 = square(999_960);
|
||||
const nearKm2 = ringAreaM2(almostKm2.vertices);
|
||||
assert.ok(
|
||||
nearKm2 >= 999_500 && nearKm2 < 1e6,
|
||||
`area should round to 100.0 ha, got ${nearKm2}`,
|
||||
);
|
||||
assert.equal(formatMeasure(almostKm2), '1.00 km²');
|
||||
const under = square(999_400);
|
||||
const underM2 = ringAreaM2(under.vertices);
|
||||
assert.ok(
|
||||
underM2 >= 998_500 && underM2 < 999_500,
|
||||
`area should round to 99.9 ha, got ${underM2}`,
|
||||
);
|
||||
assert.equal(formatMeasure(under), '99.9 ha');
|
||||
});
|
||||
+129
-101
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Manual whiteboard drawing: the Cesium + DOM half.
|
||||
*
|
||||
* DISPLAY ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
|
||||
* shape (area, line or pin), click the vertices on the real world, double-click
|
||||
* or press Enter to finish, type a label. Each finished shape goes through the
|
||||
* UTILITIES ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
|
||||
* shape (area, line or pin) and a colour, click the vertices on the real world,
|
||||
* double-click or press Enter to finish, then name, recolour, hide, fly to or
|
||||
* remove the shape from its row (drawMarks.js). Each finished shape goes through the
|
||||
* SAME `annotationEngine.annotate()` the voice agent uses, with its geometry
|
||||
* supplied and `manual: true`, so it renders with the whiteboard look,
|
||||
* persists, de-dups, shows up in `.list()`, and clears with the board.
|
||||
@@ -30,7 +31,6 @@
|
||||
* at finish, deliberately and in one place — see `finishSpec` in drawMode.js.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { createSegmentedControl } from '../ui/segmentedControl.js';
|
||||
import { pickWorldFromScreen } from './annotationResolver.js';
|
||||
import {
|
||||
claimPointer,
|
||||
@@ -50,41 +50,44 @@ import {
|
||||
removeLastVertex,
|
||||
} from './drawMode.js';
|
||||
|
||||
import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js';
|
||||
import { DRAW_COLORS, DRAW_COLOR_CSS, presentDrawMarks } from './drawMarks.js';
|
||||
|
||||
import { onLocaleChange, t, translateKnown } from '../i18n/index.js';
|
||||
/** The id this tool claims the pointer under. */
|
||||
const DRAW_POINTER_OWNER = 'draw';
|
||||
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
|
||||
|
||||
const COLORS = ['primary', 'amber', 'cyan', 'green', 'red'];
|
||||
const PREVIEW = {
|
||||
primary: '#8be9ff',
|
||||
amber: '#ffb547',
|
||||
cyan: '#39d0ff',
|
||||
green: '#5dff9f',
|
||||
red: '#ff6b6b',
|
||||
};
|
||||
const PREVIEW = DRAW_COLOR_CSS;
|
||||
|
||||
/**
|
||||
* Wire the Draw control. Returns a handle with a `destroy()` the application
|
||||
* Run the Draw control. Returns a handle with a `destroy()` the application
|
||||
* lifetime owns, plus the console/test seam (`window.__gevDrawTool`).
|
||||
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}}} deps
|
||||
* The tool hands what the controls show (`{ active, shape, color, hint,
|
||||
* marks }`) to `publish`, which the view renders, and answers the controls
|
||||
* through the actions it gives `connect`, which returns their disconnect.
|
||||
* A row's fly-to is an immediate navigation through `navigation`, like
|
||||
* Import's, so Cockpit can refuse it and a running orbit or follow lets go.
|
||||
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps
|
||||
* @returns {{destroy: Function}|null}
|
||||
*/
|
||||
export function initDrawTool({ viewer, annotations }) {
|
||||
const toggle = document.getElementById('draw-toggle');
|
||||
const modeRow = document.getElementById('draw-mode-row');
|
||||
const labelRow = document.getElementById('draw-label-row');
|
||||
const labelInput = document.getElementById('draw-label-input');
|
||||
const colorSelect = document.getElementById('draw-color-select');
|
||||
const clearButton = document.getElementById('draw-clear');
|
||||
const hint = document.getElementById('draw-hint');
|
||||
if (!viewer || !annotations || !toggle) return null;
|
||||
export function initDrawTool({
|
||||
viewer,
|
||||
annotations,
|
||||
navigation = null,
|
||||
publish = () => {},
|
||||
connect = () => () => {},
|
||||
}) {
|
||||
// A page without the Draw controls (the Display controls view omitted) has
|
||||
// no draw tool.
|
||||
if (!viewer || !annotations || !document.getElementById('draw-toggle'))
|
||||
return null;
|
||||
|
||||
let active = false;
|
||||
let destroyed = false;
|
||||
let session = null;
|
||||
let shape = 'area';
|
||||
let color = 'primary';
|
||||
let hint = '';
|
||||
let handler = null;
|
||||
let lease = null;
|
||||
let savedDoubleClick = null;
|
||||
@@ -95,25 +98,13 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
// one of those must not write its outcome over the newer state.
|
||||
let generation = 0;
|
||||
const previewEntities = [];
|
||||
// Every DOM listener this tool adds, so teardown is one loop rather than a
|
||||
// list that can fall out of step with the bindings below.
|
||||
// Every DOM listener this tool adds (the session's keydown), so teardown is
|
||||
// one loop rather than a list that can fall out of step with the bindings.
|
||||
const domListeners = [];
|
||||
const listen = (target, type, listener, options) => {
|
||||
if (!target) return;
|
||||
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
|
||||
@@ -124,9 +115,25 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
() => null,
|
||||
);
|
||||
|
||||
const present = () =>
|
||||
publish({
|
||||
active,
|
||||
shape,
|
||||
color,
|
||||
hint,
|
||||
marks: presentDrawMarks(annotations.list?.() || []),
|
||||
});
|
||||
// The hint is kept as the call that words it, so a language switch can
|
||||
// word it again.
|
||||
let hintText = () => '';
|
||||
const setHint = (text) => {
|
||||
if (hint) hint.textContent = text;
|
||||
hintText = text;
|
||||
hint = text();
|
||||
present();
|
||||
};
|
||||
const offLocale = onLocaleChange(() => {
|
||||
if (!destroyed) setHint(hintText);
|
||||
});
|
||||
|
||||
// ---- preview ---------------------------------------------------------
|
||||
const vertexPositions = () =>
|
||||
@@ -161,7 +168,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
previewEntities.length = 0;
|
||||
if (!session) {
|
||||
previewLine.show = false;
|
||||
setHint(drawHint(null));
|
||||
setHint(() => drawHint(null));
|
||||
viewer.scene.requestRender();
|
||||
return;
|
||||
}
|
||||
@@ -187,7 +194,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
setHint(drawHint(session));
|
||||
setHint(() => drawHint(session));
|
||||
viewer.scene.requestRender();
|
||||
};
|
||||
|
||||
@@ -210,14 +217,14 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
// A click that changed nothing still deserves an answer when the reason is
|
||||
// a limit rather than the harmless tail of a double-click.
|
||||
if (reason === 'full')
|
||||
setHint(
|
||||
setHint(() =>
|
||||
t(
|
||||
'That shape already has {count} points — finish it or press Backspace.',
|
||||
{ count: MAX_VERTICES },
|
||||
),
|
||||
);
|
||||
else if (reason === 'invalid')
|
||||
setHint(t('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;
|
||||
@@ -235,17 +242,17 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
if (reason !== 'ok') {
|
||||
// 'too-few' is just "keep clicking" and the hint already says so;
|
||||
// a degenerate or off-globe shape needs to be told why it was refused.
|
||||
if (reason !== 'too-few') setHint(drawHint(session));
|
||||
if (reason !== 'too-few') setHint(() => drawHint(session));
|
||||
return null;
|
||||
}
|
||||
const spec = finishSpec(session, { label: labelInput?.value || '', color });
|
||||
// Named afterwards, in the shape's row.
|
||||
const spec = finishSpec(session, { label: '', color });
|
||||
if (!spec) return null;
|
||||
// Start a fresh session BEFORE awaiting, so a second Enter or the second
|
||||
// half of a double-click cannot submit the same shape twice.
|
||||
session = createDrawSession(shape);
|
||||
cursor = null;
|
||||
syncPreview();
|
||||
if (labelInput) labelInput.value = '';
|
||||
const attempt = generation;
|
||||
try {
|
||||
const result = await annotations.annotate([spec], {
|
||||
@@ -253,11 +260,12 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
flyTo: false,
|
||||
});
|
||||
if (destroyed || attempt !== generation) return result;
|
||||
if (result?.drawn === 0) setHint(t('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(
|
||||
setHint(() =>
|
||||
t('Could not place the shape: {error}', {
|
||||
error: error?.message || error,
|
||||
}),
|
||||
@@ -279,13 +287,13 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
if (session) session = createDrawSession(shape);
|
||||
annotations.clear();
|
||||
syncPreview();
|
||||
if (!destroyed && !active) setHint(t('Board cleared.'));
|
||||
if (!destroyed && !active) setHint(() => t('Board cleared.'));
|
||||
};
|
||||
|
||||
// ---- keys: only while drawing, never while typing in another field ----
|
||||
const typingElsewhere = (event) => {
|
||||
const t = event.target;
|
||||
if (!t || t === labelInput) return false;
|
||||
if (!t) return false;
|
||||
return (
|
||||
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable
|
||||
);
|
||||
@@ -293,12 +301,12 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
/**
|
||||
* Enter is the finish key, but on a focused control Enter means "press this
|
||||
* control". Keyboard-focusing Clear and pressing Enter must clear the board,
|
||||
* not finish the shape — so the finish key is only taken from the canvas,
|
||||
* the page body, or the label field this tool owns.
|
||||
* not finish the shape — so the finish key is only taken from the canvas or
|
||||
* the page body.
|
||||
*/
|
||||
const enterBelongsToDrawing = (event) => {
|
||||
const t = event.target;
|
||||
if (!t || t === labelInput) return true;
|
||||
if (!t) return true;
|
||||
if (
|
||||
typeof t.closest === 'function' &&
|
||||
t.closest('button, a, select, [role="button"], [role="radio"]')
|
||||
@@ -310,6 +318,9 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
};
|
||||
const onKey = (event) => {
|
||||
if (!active || destroyed || typingElsewhere(event)) return;
|
||||
// Keys on the shape list's own controls are the list's (Esc closes a
|
||||
// colour picker there), not the drawing's.
|
||||
if (event.target?.closest?.('#draw-mark-list')) return;
|
||||
// Enter goes to finish() once a shape has been started; finish() owns the
|
||||
// decision and says WHY it refused. Swallowing the key on a degenerate
|
||||
// shape left the person pressing Enter at a silent panel. A focused button
|
||||
@@ -328,7 +339,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
else setActive(false);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Backspace' && event.target !== labelInput) {
|
||||
if (event.key === 'Backspace') {
|
||||
if (removeLastVertex(session)) {
|
||||
event.preventDefault();
|
||||
syncPreview();
|
||||
@@ -345,7 +356,7 @@ 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(
|
||||
setHint(() =>
|
||||
t('{owner} is using the pointer — close it first.', {
|
||||
owner: translateKnown(pointerOwnerName(pointerOwner())),
|
||||
}),
|
||||
@@ -354,10 +365,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
}
|
||||
}
|
||||
active = next;
|
||||
toggle.classList.toggle('active', active);
|
||||
toggle.setAttribute('aria-pressed', String(active));
|
||||
modeRow?.classList.toggle('visible', active);
|
||||
labelRow?.classList.toggle('visible', active);
|
||||
present();
|
||||
document.body.classList.toggle('gev-drawing', active);
|
||||
if (active) {
|
||||
session = createDrawSession(shape);
|
||||
@@ -419,8 +427,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
);
|
||||
savedDoubleClick = null;
|
||||
}
|
||||
// Drop just the keydown listener bound alongside this handler; the control
|
||||
// listeners below outlive a session and come off in destroy().
|
||||
// Drop the keydown listener bound alongside this handler.
|
||||
for (let i = domListeners.length - 1; i >= 0; i -= 1) {
|
||||
const [target, type, listener, options] = domListeners[i];
|
||||
if (listener !== onKey) continue;
|
||||
@@ -429,37 +436,56 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
}
|
||||
}
|
||||
|
||||
listen(toggle, 'click', () => setActive(!active));
|
||||
const shapeButtons = [
|
||||
...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []),
|
||||
];
|
||||
const shapeControl = createSegmentedControl({
|
||||
buttons: shapeButtons,
|
||||
valueOf: (button) => button.dataset.shape,
|
||||
listen,
|
||||
onChange: (value) => {
|
||||
shape = normalizeShape(value);
|
||||
shapeControl.update(shape);
|
||||
if (active) {
|
||||
generation += 1;
|
||||
session = createDrawSession(shape);
|
||||
cursor = null;
|
||||
syncPreview();
|
||||
}
|
||||
},
|
||||
});
|
||||
listen(colorSelect, 'change', () => {
|
||||
color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary';
|
||||
syncPreview();
|
||||
});
|
||||
listen(clearButton, 'click', clearAll);
|
||||
listen(labelInput, 'keydown', (event) => {
|
||||
if (event.key === 'Enter' && session?.vertices?.length) {
|
||||
event.preventDefault();
|
||||
void finish();
|
||||
/**
|
||||
* Frame one drawn shape: an immediate navigation, so Cockpit can refuse it
|
||||
* and the current orbit, follow or track lets go first.
|
||||
*/
|
||||
function flyToMark(id) {
|
||||
if (typeof navigation?.runImmediateNavigation === 'function')
|
||||
return (
|
||||
navigation.runImmediateNavigation('drawing', () =>
|
||||
annotations.frame?.(id),
|
||||
) !== false
|
||||
);
|
||||
return annotations.frame?.(id) ?? false;
|
||||
}
|
||||
|
||||
/** Draw `next` from now on; an open shape starts over in it. */
|
||||
function chooseShape(next) {
|
||||
shape = normalizeShape(next);
|
||||
present();
|
||||
if (active) {
|
||||
generation += 1;
|
||||
session = createDrawSession(shape);
|
||||
cursor = null;
|
||||
syncPreview();
|
||||
}
|
||||
}
|
||||
const disconnect = connect({
|
||||
toggleDraw: () => setActive(!active),
|
||||
setDrawShape: chooseShape,
|
||||
setDrawColor: (value) => {
|
||||
color = DRAW_COLORS.includes(value) ? value : 'primary';
|
||||
present();
|
||||
syncPreview();
|
||||
},
|
||||
clearDraw: clearAll,
|
||||
renameDrawMark: (id, name) => annotations.restyle?.(id, { name }),
|
||||
recolorDrawMark: (id, next) => annotations.restyle?.(id, { color: next }),
|
||||
toggleDrawMark: (id) => {
|
||||
const mark = annotations.list?.().find((entry) => entry.id === id);
|
||||
if (mark) annotations.setHidden?.(id, !mark.hidden);
|
||||
},
|
||||
flyToDrawMark: flyToMark,
|
||||
removeDrawMark: (id) => annotations.remove?.(id),
|
||||
});
|
||||
setHint(drawHint(null));
|
||||
// The rows follow the board: a shape drawn, renamed, hidden or removed
|
||||
// here, by voice or by a clear.
|
||||
const offBoard =
|
||||
annotations.onChange?.(() => {
|
||||
if (!destroyed) present();
|
||||
}) || (() => {});
|
||||
setHint(() => drawHint(null));
|
||||
|
||||
const api = {
|
||||
get active() {
|
||||
@@ -472,12 +498,15 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
return session;
|
||||
},
|
||||
setActive,
|
||||
setShape(next) {
|
||||
const btn = modeRow?.querySelector(
|
||||
`.pp-mode-btn[data-shape="${normalizeShape(next)}"]`,
|
||||
);
|
||||
btn?.click();
|
||||
setShape: chooseShape,
|
||||
/** The hand-drawn marks the list shows, as its rows. */
|
||||
marks: () => presentDrawMarks(annotations.list?.() || []),
|
||||
setColor(next) {
|
||||
color = DRAW_COLORS.includes(next) ? next : 'primary';
|
||||
present();
|
||||
syncPreview();
|
||||
},
|
||||
flyToMark,
|
||||
/** Test seam: add a vertex from lon/lat as if it had been clicked. */
|
||||
addVertex(lon, lat, height = 0) {
|
||||
if (!session) return false;
|
||||
@@ -526,7 +555,9 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
generation += 1;
|
||||
if (active) setActive(false);
|
||||
destroyed = true;
|
||||
unregisterUnsavedWork();
|
||||
offLocale();
|
||||
offBoard();
|
||||
disconnect();
|
||||
releaseSceneHandler();
|
||||
releasePointer(lease);
|
||||
lease = null;
|
||||
@@ -546,11 +577,8 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
}
|
||||
});
|
||||
document.body.classList.remove('gev-drawing');
|
||||
toggle.classList.remove('active');
|
||||
toggle.setAttribute('aria-pressed', 'false');
|
||||
modeRow?.classList.remove('visible');
|
||||
labelRow?.classList.remove('visible');
|
||||
if (hint) hint.textContent = '';
|
||||
hint = '';
|
||||
present();
|
||||
if (window.__gevDrawTool === api) delete window.__gevDrawTool;
|
||||
return attaching;
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ test('the application composition wires the draw tool and owns its teardown', ()
|
||||
const tools = read('src/app/tools.js');
|
||||
const engineAt = tools.indexOf('const annotations = initAnnotations({');
|
||||
const drawAt = tools.indexOf(
|
||||
'const drawTool = initDrawTool({ viewer, annotations });',
|
||||
'const drawTool = initDrawTool({\n viewer,\n annotations,\n navigation: styleManager,\n publish: (view) => displayStore.publishDraw(view),',
|
||||
);
|
||||
assert.ok(engineAt >= 0, 'initAnnotations call missing');
|
||||
assert.ok(
|
||||
@@ -62,37 +62,67 @@ test('the engine resolves manual geometry before any name resolution', () => {
|
||||
});
|
||||
|
||||
test('the Draw control markup carries the ids the tool binds to', () => {
|
||||
// The DISPLAY rail is a component template now, assembled into index.html at
|
||||
// build time — the markup is not in index.html any more.
|
||||
const html = read('src/ui/templates/display-controls.html');
|
||||
// UTILITIES ▸ Draw is a Vue view (DrawControls, with DrawMarkList for the
|
||||
// drawn shapes' rows), rendered into the Utilities panel.
|
||||
const html = [
|
||||
read('src/ui/views/panels/DrawControls.vue'),
|
||||
read('src/ui/views/panels/DrawMarkList.vue'),
|
||||
].join('\n');
|
||||
const ids = [
|
||||
'draw-toggle',
|
||||
'draw-mode-row',
|
||||
'draw-colors',
|
||||
'draw-label-row',
|
||||
'draw-label-input',
|
||||
'draw-color-select',
|
||||
'draw-clear',
|
||||
'draw-hint',
|
||||
'draw-mark-list',
|
||||
];
|
||||
for (const id of ids) {
|
||||
assert.match(
|
||||
html,
|
||||
new RegExp(`id="${id}"`),
|
||||
`#${id} missing from index.html`,
|
||||
`#${id} missing from the Draw views`,
|
||||
);
|
||||
}
|
||||
for (const shape of ['area', 'line', 'pin']) {
|
||||
// Shapes are icons, named for screen readers and tooltips.
|
||||
for (const [shape, icon] of [
|
||||
['area', 'pentagon'],
|
||||
['line', 'timeline'],
|
||||
['pin', 'location_on'],
|
||||
]) {
|
||||
assert.match(
|
||||
html,
|
||||
new RegExp(`data-shape="${shape}"`),
|
||||
new RegExp(`icon: '${icon}', attrs: \\{ 'data-shape': '${shape}' \\}`),
|
||||
`shape button ${shape} missing`,
|
||||
);
|
||||
}
|
||||
for (const id of ids) {
|
||||
assert.ok(
|
||||
read('src/ui/views/LayerPanelsView.vue').includes('<DrawControls />'),
|
||||
'the Utilities panel renders Draw',
|
||||
);
|
||||
assert.ok(
|
||||
!read('src/ui/views/panels/DisplayPanel.vue').includes('draw-toggle'),
|
||||
'Draw is no longer in DISPLAY',
|
||||
);
|
||||
// The rows and the hint render what the tool publishes; the controls
|
||||
// call the actions the tool connects to the display store.
|
||||
for (const action of [
|
||||
"act('toggleDraw')",
|
||||
"act('setDrawShape', $event)",
|
||||
"act('setDrawColor', $event)",
|
||||
"act('clearDraw')",
|
||||
"act('renameDrawMark', row.id, next)",
|
||||
"act('recolorDrawMark', row.id, color)",
|
||||
"act('toggleDrawMark', row.id)",
|
||||
"act('flyToDrawMark', row.id)",
|
||||
"act('removeDrawMark', row.id)",
|
||||
]) {
|
||||
assert.ok(html.includes(action), `the Draw views must call ${action}`);
|
||||
const name = /act\('([a-zA-Z]+)'/.exec(action)[1];
|
||||
assert.match(
|
||||
tool(),
|
||||
new RegExp(`getElementById\\('${id}'\\)`),
|
||||
`drawTool must bind #${id}`,
|
||||
new RegExp(`\\b${name}: `),
|
||||
`drawTool must answer ${name}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -102,20 +132,22 @@ test('the DISPLAY rail styles live in their current owner, not the shim', () =>
|
||||
// these rules lived in the root style.css. That file is an import shim now,
|
||||
// and putting rules back into it would silently undo the split.
|
||||
const shim = read('style.css');
|
||||
assert.ok(
|
||||
shim.split('\n').length < 40,
|
||||
'style.css is an import shim, not a stylesheet',
|
||||
);
|
||||
const rules = shim
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.split('\n')
|
||||
.filter((line) => line.trim() && !/^@import '[^']+';$/.test(line.trim()));
|
||||
assert.deepEqual(rules, [], 'style.css is an import shim, not a stylesheet');
|
||||
assert.doesNotMatch(
|
||||
shim,
|
||||
/draw-hint|pp-text-input/,
|
||||
/draw-hint|draw-color/,
|
||||
'draw styles belong to a component stylesheet',
|
||||
);
|
||||
const controls = read('src/ui/styles/controls.css');
|
||||
for (const rule of [
|
||||
'#draw-mode-row',
|
||||
'#draw-label-row',
|
||||
'.pp-text-input',
|
||||
'.draw-color',
|
||||
'.draw-mark-name',
|
||||
'.draw-clear-btn',
|
||||
'.draw-hint',
|
||||
'body.gev-drawing',
|
||||
@@ -138,10 +170,14 @@ test('nothing promises a GeoJSON export for drawn shapes', () => {
|
||||
`${file} still claims a GeoJSON export`,
|
||||
);
|
||||
}
|
||||
for (const file of ['README.md', 'CHANGELOG.md', 'docs/CURRENT-STATE.md']) {
|
||||
// Draw lives in UTILITIES; only the changelog's history still says DISPLAY.
|
||||
for (const [file, heading] of [
|
||||
['README.md', 'UTILITIES ▸ **Draw**'],
|
||||
['CHANGELOG.md', 'DISPLAY ▸ Draw'],
|
||||
['docs/CURRENT-STATE.md', 'UTILITIES ▸ **Draw**'],
|
||||
]) {
|
||||
const source = read(file);
|
||||
const at = source.indexOf('DISPLAY ▸ **Draw**');
|
||||
const start = at >= 0 ? at : source.indexOf('DISPLAY ▸ Draw');
|
||||
const start = source.indexOf(heading);
|
||||
assert.ok(start >= 0, `${file} should describe the Draw control`);
|
||||
const section = source.slice(start, start + 1200);
|
||||
assert.doesNotMatch(
|
||||
|
||||
@@ -6,7 +6,6 @@ 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,
|
||||
@@ -47,7 +46,6 @@ class FakeElement {
|
||||
this.attributes = new Map();
|
||||
this.textContent = '';
|
||||
this.value = '';
|
||||
this.children = [];
|
||||
this.listeners = [];
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
@@ -65,16 +63,6 @@ class FakeElement {
|
||||
);
|
||||
if (at >= 0) this.listeners.splice(at, 1);
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
if (selector === '.pp-mode-btn[data-shape]')
|
||||
return this.children.filter((child) => child.dataset.shape);
|
||||
const shape = /\.pp-mode-btn\[data-shape="([a-z]+)"\]/.exec(selector)?.[1];
|
||||
if (shape) return this.children.filter((child) => child.dataset.shape === shape);
|
||||
return [];
|
||||
}
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] ?? null;
|
||||
}
|
||||
click() {
|
||||
return this.emit('click');
|
||||
}
|
||||
@@ -108,18 +96,10 @@ function fakeDom() {
|
||||
byId.set(id, element);
|
||||
return element;
|
||||
};
|
||||
// The control whose presence means the page has Draw, and a focused button
|
||||
// whose Enter is its own.
|
||||
make('draw-toggle', 'BUTTON');
|
||||
const modeRow = make('draw-mode-row');
|
||||
for (const shape of ['area', 'line', 'pin']) {
|
||||
const button = new FakeElement('BUTTON');
|
||||
button.dataset.shape = shape;
|
||||
modeRow.children.push(button);
|
||||
}
|
||||
make('draw-label-row');
|
||||
make('draw-label-input', 'INPUT');
|
||||
make('draw-color-select', 'SELECT');
|
||||
make('draw-clear', 'BUTTON');
|
||||
make('draw-hint', 'SPAN');
|
||||
|
||||
const body = new FakeElement('BODY');
|
||||
const documentDouble = {
|
||||
@@ -195,9 +175,16 @@ function fakeViewer() {
|
||||
}
|
||||
|
||||
function fakeAnnotations() {
|
||||
const calls = { annotate: [], clear: 0 };
|
||||
const calls = { annotate: [], clear: 0, restyle: [], setHidden: [], frame: [], remove: [] };
|
||||
const marks = [];
|
||||
const listeners = new Set();
|
||||
const changed = () => listeners.forEach((listener) => listener());
|
||||
const find = (id) => marks.find((mark) => mark.id === id);
|
||||
return {
|
||||
calls,
|
||||
marks,
|
||||
listeners,
|
||||
changed,
|
||||
async annotate(specs, options) {
|
||||
calls.annotate.push({ specs, options });
|
||||
return { drawn: specs.length };
|
||||
@@ -205,11 +192,38 @@ function fakeAnnotations() {
|
||||
clear() {
|
||||
calls.clear += 1;
|
||||
},
|
||||
list: () => marks,
|
||||
onChange(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
restyle(id, change) {
|
||||
calls.restyle.push([id, change]);
|
||||
Object.assign(find(id), change);
|
||||
changed();
|
||||
return true;
|
||||
},
|
||||
setHidden(id, hidden) {
|
||||
calls.setHidden.push([id, hidden]);
|
||||
find(id).hidden = hidden;
|
||||
changed();
|
||||
return true;
|
||||
},
|
||||
frame(id) {
|
||||
calls.frame.push(id);
|
||||
return true;
|
||||
},
|
||||
remove(id) {
|
||||
calls.remove.push(id);
|
||||
marks.splice(marks.indexOf(find(id)), 1);
|
||||
changed();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
|
||||
function harness() {
|
||||
function harness({ navigation = null } = {}) {
|
||||
resetPointerOwnership();
|
||||
const { byId, documentDouble } = fakeDom();
|
||||
const viewer = fakeViewer();
|
||||
@@ -218,14 +232,30 @@ function harness() {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.document = documentDouble;
|
||||
globalThis.window = globalThis.window || {};
|
||||
const tool = initDrawTool({ viewer, annotations });
|
||||
const shown = { view: null };
|
||||
// The controls' actions, as the display store holds them.
|
||||
const controls = { actions: null };
|
||||
const tool = initDrawTool({
|
||||
viewer,
|
||||
annotations,
|
||||
navigation,
|
||||
publish: (view) => (shown.view = view),
|
||||
connect: (actions) => {
|
||||
controls.actions = actions;
|
||||
return () => (controls.actions = null);
|
||||
},
|
||||
});
|
||||
return {
|
||||
tool,
|
||||
shown,
|
||||
viewer,
|
||||
annotations,
|
||||
byId,
|
||||
documentDouble,
|
||||
element: (id) => byId.get(id),
|
||||
controls,
|
||||
/** A control's change, as the view sends it. */
|
||||
act: (name, ...args) => controls.actions?.[name]?.(...args),
|
||||
restore() {
|
||||
resetPointerOwnership();
|
||||
if (previousDocument === undefined) delete globalThis.document;
|
||||
@@ -241,38 +271,15 @@ 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 {
|
||||
assert.equal(isPointerFree(), true);
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
assert.equal(pointerOwner(), 'draw', 'the session owns the pointer');
|
||||
assert.equal(h.tool.active, true);
|
||||
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
assert.equal(isPointerFree(), true, 'leaving draw mode frees the pointer');
|
||||
assert.equal(h.tool.active, false);
|
||||
} finally {
|
||||
@@ -284,18 +291,18 @@ test('Draw refuses to start when something else holds the pointer', async () =>
|
||||
const h = harness();
|
||||
try {
|
||||
const other = claimPointer('directions');
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
assert.equal(h.tool.active, false, 'it must not half-start');
|
||||
assert.equal(pointerOwner(), 'directions', 'and must not steal the claim');
|
||||
// Named by its tool, not by the internal owner id.
|
||||
assert.equal(
|
||||
h.element('draw-hint').textContent,
|
||||
h.shown.view.hint,
|
||||
'Directions is using the pointer — close it first.',
|
||||
);
|
||||
assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound');
|
||||
|
||||
releasePointer(other);
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
assert.equal(h.tool.active, true, 'and it starts once the pointer is free');
|
||||
} finally {
|
||||
h.restore();
|
||||
@@ -305,13 +312,13 @@ test('Draw refuses to start when something else holds the pointer', async () =>
|
||||
test('a destroyed instance cannot free the pointer its replacement holds', async () => {
|
||||
const first = harness();
|
||||
try {
|
||||
first.element('draw-toggle').emit('click');
|
||||
first.act('toggleDraw');
|
||||
assert.equal(pointerOwner(), 'draw');
|
||||
// The replacement takes over before the old one finishes tearing down.
|
||||
first.tool.setActive(false);
|
||||
const second = harness();
|
||||
try {
|
||||
second.element('draw-toggle').emit('click');
|
||||
second.act('toggleDraw');
|
||||
assert.equal(pointerOwner(), 'draw');
|
||||
await first.tool.destroy();
|
||||
assert.equal(
|
||||
@@ -336,7 +343,7 @@ test('both stock viewer click actions are borrowed for the session and given bac
|
||||
h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
|
||||
h.viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK);
|
||||
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
assert.equal(
|
||||
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
|
||||
undefined,
|
||||
@@ -347,7 +354,7 @@ test('both stock viewer click actions are borrowed for the session and given bac
|
||||
undefined,
|
||||
);
|
||||
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
assert.equal(
|
||||
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
|
||||
stockClick,
|
||||
@@ -365,7 +372,7 @@ test('both stock viewer click actions are borrowed for the session and given bac
|
||||
test('Enter finishes from the canvas and the label field, never from a focused button', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
h.tool.setShape('area');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
@@ -395,7 +402,7 @@ test('Enter finishes from the canvas and the label field, never from a focused b
|
||||
test('the live preview hangs at the height the click landed on', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
h.tool.setShape('line');
|
||||
h.tool.addVertex(-97.75, 30.26, 187.5);
|
||||
h.tool.addVertex(-97.74, 30.26, 212.25);
|
||||
@@ -449,10 +456,11 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
|
||||
try {
|
||||
const stockClick = () => 'viewer picks and selects';
|
||||
h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
assert.equal(pointerOwner(), 'draw');
|
||||
assert.ok(h.tool.diagnostics().domListeners > 0);
|
||||
assert.equal(h.annotations.listeners.size, 1, 'the rows follow the board');
|
||||
|
||||
await h.tool.destroy();
|
||||
await Promise.resolve();
|
||||
@@ -463,13 +471,14 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
|
||||
assert.equal(after.sceneHandler, false, 'the Cesium handler is gone');
|
||||
assert.equal(after.domListeners, 0, 'every DOM listener came off');
|
||||
assert.equal(h.documentDouble.listeners.length, 0, 'including the keydown');
|
||||
assert.equal(h.element('draw-toggle').listeners.length, 0);
|
||||
assert.equal(h.controls.actions, null, 'and the controls are disconnected');
|
||||
assert.equal(
|
||||
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
|
||||
stockClick,
|
||||
'the viewer got its selecting click back',
|
||||
);
|
||||
assert.equal(h.viewer._attached.length, 0, 'the preview data source is detached');
|
||||
assert.equal(h.annotations.listeners.size, 0, 'and it no longer follows the board');
|
||||
|
||||
// Idempotent: a second destroy is harmless.
|
||||
await h.tool.destroy();
|
||||
@@ -481,15 +490,145 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
|
||||
test('Clear wipes the board and the shape in progress', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
assert.equal(h.tool.session.vertices.length, 2);
|
||||
|
||||
h.element('draw-clear').emit('click');
|
||||
h.act('clearDraw');
|
||||
assert.equal(h.annotations.calls.clear, 1, 'placed marks are removed');
|
||||
assert.equal(h.tool.session.vertices.length, 0, 'and so is the shape in progress');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the controls choose the shape and colour the next shape is drawn with; it is named in its row', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.act('toggleDraw');
|
||||
h.act('setDrawShape', 'line');
|
||||
h.act('setDrawColor', 'amber');
|
||||
assert.deepEqual(
|
||||
{ ...h.shown.view, hint: undefined },
|
||||
{ active: true, shape: 'line', color: 'amber', hint: undefined, marks: [] },
|
||||
'the view shows what the controls chose',
|
||||
);
|
||||
h.act('setDrawColor', 'purple');
|
||||
assert.equal(h.shown.view.color, 'primary', 'an unknown colour falls back');
|
||||
h.act('setDrawColor', 'cyan');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
// Choosing a shape again starts the open one over.
|
||||
h.act('setDrawShape', 'line');
|
||||
assert.equal(h.tool.session.vertices.length, 0);
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
const spec = h.annotations.calls.annotate[0].specs[0];
|
||||
assert.equal(spec.type, 'route', 'a drawn line is a manual route');
|
||||
assert.equal(spec.color, 'cyan');
|
||||
assert.ok(!spec.label, 'a shape is named afterwards, in its row');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('each drawn shape is a row whose actions reach the board, and fly-to is a navigation', async () => {
|
||||
const navigations = [];
|
||||
const h = harness({
|
||||
navigation: {
|
||||
runImmediateNavigation: (noun, navigate) => {
|
||||
navigations.push(noun);
|
||||
return navigate();
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const board = h.annotations;
|
||||
board.marks.push(
|
||||
{ id: 'a1', type: 'route', manual: true, name: null, label: '950 m', color: 'amber', hidden: false },
|
||||
{ id: 'a2', type: 'pin', manual: false, label: 'Voice pin', color: 'primary', hidden: false },
|
||||
);
|
||||
board.changed();
|
||||
assert.deepEqual(
|
||||
h.shown.view.marks.map(({ id, placeholder, hidden }) => ({ id, placeholder, hidden })),
|
||||
[{ id: 'a1', placeholder: 'Line · 950 m', hidden: false }],
|
||||
'only hand-drawn marks are listed, an unnamed line by its length',
|
||||
);
|
||||
h.act('renameDrawMark', 'a1', 'Ridge walk');
|
||||
h.act('recolorDrawMark', 'a1', 'green');
|
||||
h.act('toggleDrawMark', 'a1');
|
||||
h.act('flyToDrawMark', 'a1');
|
||||
h.act('removeDrawMark', 'a1');
|
||||
assert.deepEqual(board.calls.restyle, [
|
||||
['a1', { name: 'Ridge walk' }],
|
||||
['a1', { color: 'green' }],
|
||||
]);
|
||||
assert.deepEqual(board.calls.setHidden, [['a1', true]]);
|
||||
assert.deepEqual(board.calls.frame, ['a1']);
|
||||
assert.deepEqual(navigations, ['drawing'], 'fly-to goes through the navigation policy');
|
||||
assert.deepEqual(board.calls.remove, ['a1']);
|
||||
assert.deepEqual(h.shown.view.marks, [], 'the row goes with its mark');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('Backspace on the page removes the last vertex', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.act('toggleDraw');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
h.documentDouble.emit('keydown', { key: 'Backspace', target: h.documentDouble.body });
|
||||
assert.equal(h.tool.session.vertices.length, 1);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keys on the shape list are the list\'s, not the drawing\'s', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
const listButton = new FakeElement('BUTTON');
|
||||
listButton.closest = (selector) => (selector === '#draw-mark-list' ? {} : null);
|
||||
const elsewhere = new FakeElement('INPUT', 'location-search');
|
||||
h.act('toggleDraw');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
h.documentDouble.emit('keydown', { key: 'Escape', target: listButton });
|
||||
assert.equal(h.tool.session.vertices.length, 2, 'Escape in the list keeps the shape');
|
||||
assert.equal(h.tool.active, true, 'and keeps Draw on');
|
||||
h.documentDouble.emit('keydown', { key: 'Enter', target: elsewhere });
|
||||
await Promise.resolve();
|
||||
assert.equal(h.annotations.calls.annotate.length, 0, 'another field keeps its Enter');
|
||||
h.documentDouble.emit('keydown', { key: 'Escape', target: h.documentDouble.body });
|
||||
assert.equal(h.tool.session.vertices.length, 0, 'Escape on the page drops the open shape');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a page without the Draw controls gets no draw tool', () => {
|
||||
resetPointerOwnership();
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.document = { getElementById: () => null };
|
||||
try {
|
||||
let connected = false;
|
||||
const tool = initDrawTool({
|
||||
viewer: fakeViewer(),
|
||||
annotations: fakeAnnotations(),
|
||||
connect: () => {
|
||||
connected = true;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
assert.equal(tool, null);
|
||||
assert.equal(connected, false);
|
||||
} finally {
|
||||
if (previousDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = previousDocument;
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { recordWorkSlice } from '../sources/phaseTiming.js';
|
||||
import { createWorldAnnotationRenderer } from './worldAnnotationRenderer.js';
|
||||
import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
|
||||
|
||||
@@ -16,15 +17,19 @@ import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
|
||||
* label-less) so every caption shares one consistent style.
|
||||
*
|
||||
* The hybrid surface honors add/update/remove/sync/destroy; update coordinates the
|
||||
* one route change that spans both sub-renderers.
|
||||
* one route change that spans both sub-renderers. `screen` passes options to the
|
||||
* screen renderer (its store, in tests).
|
||||
*/
|
||||
export function createHybridAnnotationRenderer(viewer) {
|
||||
export function createHybridAnnotationRenderer(
|
||||
viewer,
|
||||
{ screen: screenOptions } = {},
|
||||
) {
|
||||
const world = createWorldAnnotationRenderer(viewer);
|
||||
const screen = createScreenAnnotationRenderer(viewer);
|
||||
const screen = createScreenAnnotationRenderer(viewer, screenOptions);
|
||||
// anno.id -> { worldProxy?, screenProxy? } — proxies own the sub-renderer state.
|
||||
const routed = new Map();
|
||||
|
||||
function add(anno) {
|
||||
function add(anno, options = {}) {
|
||||
const entry = {};
|
||||
// Route the entry FIRST, then fill it in. A sub-renderer throw (WebGL loss
|
||||
// mid-add) otherwise stranded whatever the earlier sub-renderer had already
|
||||
@@ -36,7 +41,20 @@ export function createHybridAnnotationRenderer(viewer) {
|
||||
if (anno.type === 'area' && anno.ring && anno.ring.length >= 3) {
|
||||
// Drape the footprint in world space, but without its own label...
|
||||
entry.worldProxy = liveProxy(anno, { label: null });
|
||||
world.add(entry.worldProxy);
|
||||
const completion = world.add(
|
||||
entry.worldProxy,
|
||||
worldOptions(anno, entry, options),
|
||||
);
|
||||
if (completion?.then) {
|
||||
if (options.pendingVisible !== false) {
|
||||
entry.screenProxy = liveProxy(anno, {
|
||||
ring: null,
|
||||
pendingOutline: true,
|
||||
});
|
||||
screen.add(entry.screenProxy);
|
||||
}
|
||||
return finishBuilding(anno, entry, completion, options);
|
||||
}
|
||||
// ...and place the caption as a screen-space callout at the centroid.
|
||||
if (anno.label) {
|
||||
entry.screenProxy = liveProxy(anno, { type: 'label', ring: null });
|
||||
@@ -71,27 +89,45 @@ export function createHybridAnnotationRenderer(viewer) {
|
||||
* World geometry is new, but the reticle's SVG group is converted in place to
|
||||
* the centroid callout so there is no overlapping fade-out/fade-in pair.
|
||||
*/
|
||||
function update(anno) {
|
||||
function update(anno, options = {}) {
|
||||
const entry = routed.get(anno.id);
|
||||
if (!entry) {
|
||||
add(anno);
|
||||
return;
|
||||
return add(anno, options);
|
||||
}
|
||||
if (anno.type !== 'area' || !anno.ring || anno.ring.length < 3) {
|
||||
if (anno.outlineUnavailable && entry.screenProxy)
|
||||
if (anno.outlineUnavailable && entry.screenProxy) {
|
||||
entry.screenProxy = liveProxy(anno, { ring: null });
|
||||
screen.update(entry.screenProxy);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entry.worldProxy) {
|
||||
entry.worldProxy = liveProxy(anno, { label: null });
|
||||
world.add(entry.worldProxy);
|
||||
const completion = world.add(
|
||||
entry.worldProxy,
|
||||
worldOptions(anno, entry, options),
|
||||
);
|
||||
if (completion?.then) {
|
||||
if (entry.screenProxy) {
|
||||
const pending = liveProxy(anno, { ring: null, pendingOutline: true });
|
||||
screen.update(pending);
|
||||
entry.screenProxy = pending;
|
||||
}
|
||||
return finishBuilding(anno, entry, completion, options);
|
||||
}
|
||||
}
|
||||
|
||||
updateCaption(anno, entry);
|
||||
}
|
||||
|
||||
function updateCaption(anno, entry) {
|
||||
const screenProxy = liveProxy(anno, { type: 'label', ring: null });
|
||||
if (entry.screenProxy) {
|
||||
screen.update(screenProxy, { empty: !anno.label });
|
||||
} else if (anno.label) {
|
||||
// Publish before add: a partially created caption must remain removable.
|
||||
entry.screenProxy = screenProxy;
|
||||
screen.add(screenProxy);
|
||||
}
|
||||
entry.screenProxy = entry.screenProxy
|
||||
@@ -101,6 +137,106 @@ export function createHybridAnnotationRenderer(viewer) {
|
||||
: null;
|
||||
}
|
||||
|
||||
function finishBuilding(anno, entry, completion, options) {
|
||||
const workTrace = options.workTrace;
|
||||
const proxy = entry.worldProxy;
|
||||
return completion
|
||||
.then((result) => {
|
||||
const workStart = workTrace ? performance.now() : 0;
|
||||
try {
|
||||
if (
|
||||
routed.get(anno.id) !== entry ||
|
||||
entry.worldProxy !== proxy ||
|
||||
options.signal?.aborted ||
|
||||
!(options.isCurrent?.() ?? true)
|
||||
) {
|
||||
world.remove(proxy);
|
||||
if (routed.get(anno.id) === entry) remove(anno);
|
||||
if (workTrace) workTrace.rendererStatus = 'cancelled';
|
||||
return { ...result, status: 'cancelled' };
|
||||
}
|
||||
if (result.status !== 'installed') {
|
||||
world.remove(proxy);
|
||||
entry.worldProxy = null;
|
||||
if (workTrace) workTrace.rendererStatus = result.status;
|
||||
return result;
|
||||
}
|
||||
if (performance.now() >= result.timing.deadlineMs) {
|
||||
world.remove(proxy);
|
||||
entry.worldProxy = null;
|
||||
if (workTrace) workTrace.rendererStatus = 'timeout';
|
||||
return { ...result, status: 'timeout' };
|
||||
}
|
||||
const at = performance.now();
|
||||
try {
|
||||
updateCaption(anno, entry);
|
||||
} finally {
|
||||
const ms = performance.now() - at;
|
||||
result.timing.workMs += ms;
|
||||
result.timing.lastSliceMs += ms;
|
||||
result.timing.maxSliceMs = Math.max(
|
||||
result.timing.maxSliceMs,
|
||||
result.timing.lastSliceMs,
|
||||
);
|
||||
}
|
||||
if (performance.now() >= result.timing.deadlineMs) {
|
||||
world.remove(proxy);
|
||||
entry.worldProxy = null;
|
||||
if (options.pendingVisible === false) {
|
||||
if (entry.screenProxy) screen.remove(entry.screenProxy);
|
||||
entry.screenProxy = null;
|
||||
} else {
|
||||
// The caption conversion already reshaped the group; redraw the pending point.
|
||||
const pending = liveProxy(anno, {
|
||||
ring: null,
|
||||
pendingOutline: true,
|
||||
});
|
||||
if (entry.screenProxy) screen.remove(entry.screenProxy);
|
||||
entry.screenProxy = pending;
|
||||
screen.add(pending);
|
||||
}
|
||||
if (workTrace) workTrace.rendererStatus = 'timeout';
|
||||
return { ...result, status: 'timeout' };
|
||||
}
|
||||
if (workTrace) workTrace.rendererStatus = result.status;
|
||||
return result;
|
||||
} finally {
|
||||
if (workTrace)
|
||||
recordWorkSlice(workTrace, 'hybrid-complete', workStart);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const workStart = workTrace ? performance.now() : 0;
|
||||
try {
|
||||
// A later caption throw must roll back the already installed world parts too.
|
||||
if (routed.get(anno.id) === entry) {
|
||||
try {
|
||||
remove(anno);
|
||||
} catch {
|
||||
/* original error wins; failed routes stay addressable */
|
||||
}
|
||||
} else world.remove(proxy);
|
||||
throw error;
|
||||
} finally {
|
||||
if (workTrace) {
|
||||
workTrace.rendererStatus = 'failed';
|
||||
recordWorkSlice(workTrace, 'hybrid-rollback', workStart);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function worldOptions(anno, entry, options) {
|
||||
const proxy = entry.worldProxy;
|
||||
return {
|
||||
...options,
|
||||
isCurrent: () =>
|
||||
routed.get(anno.id) === entry &&
|
||||
entry.worldProxy === proxy &&
|
||||
(options.isCurrent?.() ?? true),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Release both routes. Tolerates PARTIAL state (only one sub-renderer got
|
||||
* its content) and an absent id, so it doubles as the rollback path for a
|
||||
|
||||
@@ -1,143 +1,25 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
|
||||
import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
|
||||
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
||||
|
||||
class FakeClassList {
|
||||
constructor(owner) {
|
||||
this.owner = owner;
|
||||
this.names = new Set();
|
||||
}
|
||||
|
||||
reset(value) {
|
||||
this.names = new Set(String(value || '').split(/\s+/).filter(Boolean));
|
||||
}
|
||||
|
||||
add(...names) {
|
||||
for (const name of names) this.names.add(name);
|
||||
this.owner.attributes.set('class', Array.from(this.names).join(' '));
|
||||
}
|
||||
|
||||
remove(...names) {
|
||||
for (const name of names) this.names.delete(name);
|
||||
this.owner.attributes.set('class', Array.from(this.names).join(' '));
|
||||
}
|
||||
|
||||
contains(name) {
|
||||
return this.names.has(name);
|
||||
}
|
||||
/** A hybrid renderer whose screen board is presented to its own store. */
|
||||
function hybridWithBoard(viewer) {
|
||||
const store = useScreenAnnotationsStore(createPinia());
|
||||
const renderer = createHybridAnnotationRenderer(viewer, { screen: { store } });
|
||||
return { store, renderer };
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName) {
|
||||
this.tagName = tagName;
|
||||
this.attributes = new Map();
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.style = {};
|
||||
this.classList = new FakeClassList(this);
|
||||
this.textContent = '';
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
if (name === 'class') this.classList.reset(value);
|
||||
}
|
||||
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name) ?? null;
|
||||
}
|
||||
|
||||
set className(value) {
|
||||
this.setAttribute('class', value);
|
||||
}
|
||||
|
||||
get className() {
|
||||
return this.getAttribute('class') || '';
|
||||
}
|
||||
|
||||
set id(value) {
|
||||
this.setAttribute('id', value);
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.getAttribute('id') || '';
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
child.parentNode = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
removeChild(child) {
|
||||
const index = this.children.indexOf(child);
|
||||
if (index >= 0) this.children.splice(index, 1);
|
||||
child.parentNode = null;
|
||||
return child;
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.parentNode?.removeChild(this);
|
||||
}
|
||||
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
|
||||
querySelectorAll(selector) {
|
||||
const className = selector.startsWith('.') ? selector.slice(1) : null;
|
||||
const matches = [];
|
||||
const visit = (node) => {
|
||||
for (const child of node.children) {
|
||||
if (className && child.classList.contains(className)) matches.push(child);
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
visit(this);
|
||||
return matches;
|
||||
}
|
||||
|
||||
addEventListener() {}
|
||||
|
||||
getBBox() {
|
||||
return { x: 0, y: 0, width: 40, height: 16 };
|
||||
}
|
||||
|
||||
set innerHTML(value) {
|
||||
this._innerHTML = value;
|
||||
}
|
||||
/** The screen marks the board shows, fading ones included. */
|
||||
function annotationGroups(store) {
|
||||
return store.board?.marks ?? [];
|
||||
}
|
||||
|
||||
function fakeDocument() {
|
||||
const head = new FakeElement('head');
|
||||
const body = new FakeElement('body');
|
||||
const roots = [head, body];
|
||||
return {
|
||||
head,
|
||||
body,
|
||||
createElement: (tag) => new FakeElement(tag),
|
||||
createElementNS: (_namespace, tag) => new FakeElement(tag),
|
||||
getElementById(id) {
|
||||
let found = null;
|
||||
const visit = (node) => {
|
||||
if (node.getAttribute('id') === id) found = node;
|
||||
for (const child of node.children) visit(child);
|
||||
};
|
||||
for (const root of roots) visit(root);
|
||||
return found;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function findAnnotationGroup(document) {
|
||||
const layer = document.body.children.find((child) => child.classList.contains('gev-screen-whiteboard'));
|
||||
const svg = layer.children.find((child) => child.classList.contains('gev-screen-whiteboard-svg'));
|
||||
return {
|
||||
svg,
|
||||
group: svg.children.find((child) => child.classList.contains('gev-anno')),
|
||||
};
|
||||
}
|
||||
const childrenWithClass = (mark, name) =>
|
||||
mark.children.filter((child) => child.attrs?.class?.split(' ').includes(name));
|
||||
|
||||
function cameraLookingAt(lon, lat) {
|
||||
const target = Cesium.Cartesian3.fromDegrees(lon, lat);
|
||||
@@ -184,7 +66,6 @@ function cameraLookingAt(lon, lat) {
|
||||
|
||||
/** Install the browser globals both renderers touch, restored after the test. */
|
||||
function installBrowserGlobals(t) {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalWindow = globalThis.window;
|
||||
const originalBrowserConstructors = new Map([
|
||||
@@ -193,13 +74,10 @@ function installBrowserGlobals(t) {
|
||||
['ImageBitmap', globalThis.ImageBitmap],
|
||||
['OffscreenCanvas', globalThis.OffscreenCanvas],
|
||||
]);
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||
globalThis.window = { setTimeout: (fn) => { fn(); return 1; } };
|
||||
for (const name of originalBrowserConstructors.keys()) globalThis[name] = class {};
|
||||
t.after(() => {
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
if (originalWindow === undefined) delete globalThis.window;
|
||||
@@ -213,19 +91,31 @@ function installBrowserGlobals(t) {
|
||||
|
||||
/** A viewer whose scene/camera is just enough for both sub-renderers. */
|
||||
function fakeViewer(lon, lat) {
|
||||
const credits = new Set();
|
||||
const camera = cameraLookingAt(lon, lat);
|
||||
const dataSources = [];
|
||||
const frames = controlledFrames();
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||
clampToHeightSupported: true,
|
||||
clampToHeight: (world) => world,
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
postRender: frames.event,
|
||||
renderRequests: 0,
|
||||
requestRender() {
|
||||
scene.renderRequests += 1;
|
||||
},
|
||||
};
|
||||
return {
|
||||
dataSources,
|
||||
frames,
|
||||
viewer: {
|
||||
credits,
|
||||
creditDisplay: {
|
||||
addStaticCredit: (credit) => credits.add(credit),
|
||||
removeStaticCredit: (credit) => credits.delete(credit),
|
||||
},
|
||||
scene,
|
||||
camera,
|
||||
trackedEntity: null,
|
||||
@@ -244,8 +134,30 @@ function fakeViewer(lon, lat) {
|
||||
};
|
||||
}
|
||||
|
||||
function annotationGroups(svg) {
|
||||
return svg.children.filter((child) => child.classList.contains('gev-anno'));
|
||||
function controlledFrames() {
|
||||
const listeners = new Set();
|
||||
return {
|
||||
event: {
|
||||
addEventListener(listener) { listeners.add(listener); },
|
||||
removeEventListener(listener) { listeners.delete(listener); },
|
||||
},
|
||||
emit() { for (const listener of [...listeners]) listener(); },
|
||||
snapshot() { return [...listeners]; },
|
||||
get size() { return listeners.size; },
|
||||
};
|
||||
}
|
||||
|
||||
function buildingAnnotation(id, offset = 0) {
|
||||
const ring = [
|
||||
[-99 + offset, 31], [-98.999 + offset, 31],
|
||||
[-98.999 + offset, 31.001], [-99 + offset, 31.001],
|
||||
[-99 + offset, 31],
|
||||
];
|
||||
return {
|
||||
id, type: 'area', color: 'primary', label: null, alpha: 1,
|
||||
anchor: { ...naiveRingCentroid(ring), height: 100 },
|
||||
ring, footprintKind: 'building', buildingHeight: 20, outlineCredit: 'tiles',
|
||||
};
|
||||
}
|
||||
|
||||
function naiveRingCentroid(ring) {
|
||||
@@ -256,23 +168,23 @@ function naiveRingCentroid(ring) {
|
||||
return { lon: totals.lon / ring.length, lat: totals.lat / ring.length, height: 0 };
|
||||
}
|
||||
|
||||
test('hybrid outline upgrade preserves the screen group and adds world geometry', (t) => {
|
||||
const originalDocument = globalThis.document;
|
||||
test('hybrid outline upgrade preserves the screen group and adds world geometry', async (t) => {
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalWindow = globalThis.window;
|
||||
globalThis.window = { setTimeout: (fn) => { fn(); return 1; } };
|
||||
const originalBrowserConstructors = new Map([
|
||||
['HTMLCanvasElement', globalThis.HTMLCanvasElement],
|
||||
['HTMLImageElement', globalThis.HTMLImageElement],
|
||||
['ImageBitmap', globalThis.ImageBitmap],
|
||||
['OffscreenCanvas', globalThis.OffscreenCanvas],
|
||||
]);
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||
for (const name of originalBrowserConstructors.keys()) globalThis[name] = class {};
|
||||
t.after(() => {
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
if (originalWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = originalWindow;
|
||||
for (const [name, original] of originalBrowserConstructors) {
|
||||
if (original === undefined) delete globalThis[name];
|
||||
else globalThis[name] = original;
|
||||
@@ -281,13 +193,15 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
|
||||
|
||||
const camera = cameraLookingAt(-99, 31);
|
||||
const dataSources = [];
|
||||
const frames = controlledFrames();
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||
clampToHeightSupported: true,
|
||||
clampToHeight: (world) => world,
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
postRender: frames.event,
|
||||
requestRender() {},
|
||||
};
|
||||
const viewer = {
|
||||
scene,
|
||||
@@ -305,7 +219,7 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
|
||||
},
|
||||
},
|
||||
};
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const { store, renderer } = hybridWithBoard(viewer);
|
||||
const anno = {
|
||||
id: 'anno-hybrid-fb3',
|
||||
type: 'area',
|
||||
@@ -319,10 +233,8 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
|
||||
};
|
||||
|
||||
renderer.add(anno);
|
||||
const before = findAnnotationGroup(globalThis.document);
|
||||
const originalCallout = before.group.querySelector('.gev-anno-callout');
|
||||
const originalDot = before.group.querySelector('.gev-anno-dot');
|
||||
assert.equal(before.group.querySelectorAll('.gev-anno-ring').length, 2);
|
||||
const [before] = annotationGroups(store);
|
||||
assert.equal(childrenWithClass(before, 'gev-anno-ring').length, 2);
|
||||
assert.equal(dataSources.length, 1);
|
||||
assert.equal(dataSources[0].entities.values.length, 0);
|
||||
|
||||
@@ -339,23 +251,83 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
|
||||
anno.footprintKind = 'area';
|
||||
renderer.update(anno);
|
||||
|
||||
const after = findAnnotationGroup(globalThis.document);
|
||||
const [after, ...others] = annotationGroups(store);
|
||||
const expectedWindow = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
Cesium.Cartesian3.fromDegrees(centroid.lon, centroid.lat, centroid.height),
|
||||
);
|
||||
assert.equal(after.group, before.group, 'the real hybrid keeps the existing SVG group');
|
||||
assert.equal(after.svg.children.filter((child) => child.classList.contains('gev-anno')).length, 1);
|
||||
assert.equal(after.group.querySelectorAll('.gev-anno-ring').length, 0, 'screen reticle rings are removed');
|
||||
assert.equal(after.group.querySelector('.gev-anno-callout'), originalCallout, 'the callout node is retained');
|
||||
assert.equal(after.group.querySelector('.gev-anno-dot'), originalDot, 'the anchor dot is retained');
|
||||
assert.equal(originalDot.getAttribute('cx'), expectedWindow.x.toFixed(1));
|
||||
assert.equal(originalDot.getAttribute('cy'), expectedWindow.y.toFixed(1));
|
||||
assert.equal(after.key, before.key, 'the real hybrid keeps the existing screen mark');
|
||||
assert.equal(others.length, 0);
|
||||
assert.equal(childrenWithClass(after, 'gev-anno-ring').length, 0, 'screen reticle rings are removed');
|
||||
assert.deepEqual(
|
||||
after.children.map((child) => child.key),
|
||||
['dot', 'leader', 'callout'],
|
||||
'the dot, leader and callout are retained',
|
||||
);
|
||||
const dot = after.children.find((child) => child.key === 'dot');
|
||||
assert.equal(dot.attrs.cx, expectedWindow.x.toFixed(1));
|
||||
assert.equal(dot.attrs.cy, expectedWindow.y.toFixed(1));
|
||||
assert.deepEqual(centroid, { lon: -100.8, lat: 29.4, height: 0 }, 'closed-ring vertex mean stays pinned');
|
||||
assert.equal(dataSources[0].entities.values.length, 2, 'world area adds one fill and one outline');
|
||||
assert.equal(dataSources[0].entities.values.filter((entity) => entity.polygon).length, 1);
|
||||
assert.equal(dataSources[0].entities.values.filter((entity) => entity.polyline).length, 1);
|
||||
assert.equal(Object.hasOwn(anno, '_entities'), false, 'world entity state stays on the inherited proxy');
|
||||
const outer = [
|
||||
[-99, 31],
|
||||
[-98.999, 31],
|
||||
[-98.999, 31.001],
|
||||
[-99, 31.001],
|
||||
[-99, 31],
|
||||
];
|
||||
const hole = [
|
||||
[-98.9998, 31.0002],
|
||||
[-98.9996, 31.0002],
|
||||
[-98.9996, 31.0004],
|
||||
[-98.9998, 31.0004],
|
||||
[-98.9998, 31.0002],
|
||||
];
|
||||
const island = outer.map(([lon, lat]) => [lon + 0.002, lat]);
|
||||
const building = {
|
||||
...anno,
|
||||
id: 'anno-multipart-building',
|
||||
label: 'Building',
|
||||
anchor: { lon: -99, lat: 31, height: 0 },
|
||||
ring: outer,
|
||||
polygons: [[outer, hole], [island]],
|
||||
footprintKind: 'building',
|
||||
buildingHeight: 20,
|
||||
};
|
||||
const completion = renderer.add(building);
|
||||
assert.equal(dataSources[0].entities.values.length, 2, 'building geometry waits for all probes');
|
||||
for (let frame = 0; frame < 8; frame += 1) frames.emit();
|
||||
assert.equal((await completion).status, 'installed');
|
||||
const added = dataSources[0].entities.values.slice(2);
|
||||
const fills = added.filter(
|
||||
(entity) => entity.polygon && entity.polygon.fill?.getValue() !== false,
|
||||
);
|
||||
assert.equal(
|
||||
fills.length,
|
||||
2,
|
||||
'every building part gets a classification volume',
|
||||
);
|
||||
assert.equal(fills[0].polygon.hierarchy.getValue().holes.length, 1);
|
||||
assert.equal(fills[1].polygon.hierarchy.getValue().holes.length, 0);
|
||||
assert.equal(
|
||||
fills[0].polygon.material,
|
||||
fills[1].polygon.material,
|
||||
'parts share the fill material',
|
||||
);
|
||||
assert.equal(
|
||||
added.filter((entity) => entity.polyline).length,
|
||||
3,
|
||||
'all outer and hole outlines are retained',
|
||||
);
|
||||
renderer.remove(building);
|
||||
assert.equal(
|
||||
dataSources[0].entities.values.length,
|
||||
2,
|
||||
'multipart removal leaves only the earlier area',
|
||||
);
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
@@ -370,7 +342,7 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
|
||||
test('a sub-renderer throw mid-add leaves state the rollback can still remove', (t) => {
|
||||
installBrowserGlobals(t);
|
||||
const { viewer, dataSources } = fakeViewer(-99, 31);
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const { store, renderer } = hybridWithBoard(viewer);
|
||||
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
|
||||
const anno = {
|
||||
id: 'anno-partial-add',
|
||||
@@ -386,20 +358,19 @@ test('a sub-renderer throw mid-add leaves state the rollback can still remove',
|
||||
|
||||
// The world drape lands first; the screen caption then fails the way a lost
|
||||
// context does, AFTER the world geometry is already on the board.
|
||||
const { svg } = findAnnotationGroup(globalThis.document);
|
||||
const appendChild = svg.appendChild.bind(svg);
|
||||
const publish = store.publish;
|
||||
let failNextInsert = true;
|
||||
svg.appendChild = (child) => {
|
||||
store.publish = (board) => {
|
||||
if (failNextInsert) {
|
||||
failNextInsert = false;
|
||||
throw new Error('screen insert failed');
|
||||
}
|
||||
return appendChild(child);
|
||||
return publish(board);
|
||||
};
|
||||
|
||||
assert.throws(() => renderer.add(anno), /screen insert failed/);
|
||||
assert.equal(dataSources[0].entities.values.length, 2, 'the world drape is already live');
|
||||
assert.equal(annotationGroups(svg).length, 0, 'the half-built screen group detached itself');
|
||||
assert.equal(annotationGroups(store).length, 0, 'the half-built screen group detached itself');
|
||||
|
||||
// The engine's rollback path — it must reach the partial state by id.
|
||||
renderer.remove(anno);
|
||||
@@ -409,7 +380,7 @@ test('a sub-renderer throw mid-add leaves state the rollback can still remove',
|
||||
// an orphan nothing owns.
|
||||
renderer.add(anno);
|
||||
assert.equal(dataSources[0].entities.values.length, 2, 'the retry draws exactly one world drape');
|
||||
assert.equal(annotationGroups(svg).length, 1, 'and exactly one screen caption');
|
||||
assert.equal(annotationGroups(store).length, 1, 'and exactly one screen caption');
|
||||
renderer.remove(anno);
|
||||
assert.equal(dataSources[0].entities.values.length, 0);
|
||||
renderer.destroy();
|
||||
@@ -418,7 +389,7 @@ test('a sub-renderer throw mid-add leaves state the rollback can still remove',
|
||||
test('a release that throws keeps the mark addressable for a retry cleanup', (t) => {
|
||||
installBrowserGlobals(t);
|
||||
const { viewer, dataSources } = fakeViewer(-99, 31);
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const { store, renderer } = hybridWithBoard(viewer);
|
||||
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
|
||||
const anno = {
|
||||
id: 'anno-release-throw',
|
||||
@@ -433,35 +404,34 @@ test('a release that throws keeps the mark addressable for a retry cleanup', (t)
|
||||
};
|
||||
|
||||
renderer.add(anno);
|
||||
const { svg } = findAnnotationGroup(globalThis.document);
|
||||
assert.equal(dataSources[0].entities.values.length, 2);
|
||||
assert.equal(annotationGroups(svg).length, 1);
|
||||
assert.equal(annotationGroups(store).length, 1);
|
||||
|
||||
// The world route releases fine, then the screen release fails part-way
|
||||
// (its fade-out timer throws) — so its SVG group is still in the document.
|
||||
// (its fade-out timer throws) — so its mark is still on the board.
|
||||
const workingSetTimeout = globalThis.window.setTimeout;
|
||||
globalThis.window.setTimeout = () => { throw new Error('teardown scheduling failed'); };
|
||||
assert.throws(() => renderer.remove(anno), /teardown scheduling failed/);
|
||||
assert.equal(dataSources[0].entities.values.length, 0, 'the world route did release');
|
||||
assert.equal(annotationGroups(svg).length, 1, 'the screen route did NOT — that is the orphan');
|
||||
assert.equal(annotationGroups(store).length, 1, 'the screen route did NOT — that is the orphan');
|
||||
|
||||
// Dropping the routing entry here would make that orphan permanently
|
||||
// unreachable. It must still be addressable, so a retry finishes the job.
|
||||
globalThis.window.setTimeout = workingSetTimeout;
|
||||
renderer.remove(anno);
|
||||
assert.equal(annotationGroups(svg).length, 0, 'the retry cleanup must reach the orphan');
|
||||
assert.equal(annotationGroups(store).length, 0, 'the retry cleanup must reach the orphan');
|
||||
assert.equal(dataSources[0].entities.values.length, 0, 'without double-releasing the world route');
|
||||
|
||||
// Fully released now — a third call is inert.
|
||||
renderer.remove(anno);
|
||||
assert.equal(annotationGroups(svg).length, 0);
|
||||
assert.equal(annotationGroups(store).length, 0);
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
test('a world add that fails mid-way leaves its landed entities removable', (t) => {
|
||||
installBrowserGlobals(t);
|
||||
const { viewer, dataSources } = fakeViewer(-99, 31);
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const { store, renderer } = hybridWithBoard(viewer);
|
||||
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
|
||||
const anno = {
|
||||
id: 'anno-partial-world-add',
|
||||
@@ -493,9 +463,324 @@ test('a world add that fails mid-way leaves its landed entities removable', (t)
|
||||
assert.equal(entities.values.length, 0,
|
||||
'the rollback must see the entities that landed before the throw');
|
||||
|
||||
const { svg } = findAnnotationGroup(globalThis.document);
|
||||
renderer.add(anno);
|
||||
assert.equal(entities.values.length, 2, 'the retry draws a complete mark');
|
||||
assert.equal(annotationGroups(svg).length, 1);
|
||||
assert.equal(annotationGroups(store).length, 1);
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
function workTrace() {
|
||||
return { timeOrigin: performance.timeOrigin, slices: [], dropped: 0 };
|
||||
}
|
||||
|
||||
test('buildings share one probe per frame and preserve probe geometry, filtering and fallback', async (t) => {
|
||||
installBrowserGlobals(t);
|
||||
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
t.after(() => renderer.destroy());
|
||||
const first = buildingAnnotation('sampled-building');
|
||||
const second = buildingAnnotation('fallback-building', 0.01);
|
||||
second.outlineCredit = 'osm';
|
||||
const otherOwner = {};
|
||||
showOsmCredit(viewer, otherOwner);
|
||||
const probes = [];
|
||||
const outcomes = [-500, 120, 30, null, new Error('tile unavailable'), 60, 9001, 90];
|
||||
viewer.scene.clampToHeight = (position) => {
|
||||
const cartographic = Cesium.Cartographic.fromCartesian(position);
|
||||
const lon = Cesium.Math.toDegrees(cartographic.longitude);
|
||||
const lat = Cesium.Math.toDegrees(cartographic.latitude);
|
||||
probes.push([lon, lat]);
|
||||
const outcome = outcomes[probes.length - 1];
|
||||
if (outcome instanceof Error) throw outcome;
|
||||
return outcome == null ? undefined : Cesium.Cartesian3.fromDegrees(lon, lat, outcome);
|
||||
};
|
||||
// Suppress screen points: these counts concern the eight outside-building
|
||||
// probes, not the screen renderer's separate anchor-height sampling.
|
||||
const firstTrace = workTrace();
|
||||
const secondTrace = workTrace();
|
||||
let diagnosticWriteFaults = 0;
|
||||
Object.defineProperty(secondTrace, 'groundDetailDropped', {
|
||||
get: () => 0,
|
||||
set() {
|
||||
diagnosticWriteFaults += 1;
|
||||
throw new Error('diagnostic sink unavailable');
|
||||
},
|
||||
enumerable: true,
|
||||
});
|
||||
const firstCompletion = renderer.add(first, { pendingVisible: false, workTrace: firstTrace });
|
||||
const secondCompletion = renderer.add(second, { pendingVisible: false, workTrace: secondTrace });
|
||||
assert.equal(probes.length, 0, 'enqueue must not probe synchronously');
|
||||
assert.equal(dataSources[0].entities.values.length, 0);
|
||||
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 1,
|
||||
'pending outlines add no tile credit to the other owner');
|
||||
for (let frame = 0; frame < 16; frame += 1) {
|
||||
const before = probes.length;
|
||||
frames.emit();
|
||||
assert.equal(probes.length, before + 1, 'the two buildings share one total probe budget');
|
||||
if (frame < 7) assert.equal(dataSources[0].entities.values.length, 0, 'no partial world parts');
|
||||
}
|
||||
const [installed, fallback] = await Promise.all([firstCompletion, secondCompletion]);
|
||||
assert.equal(installed.status, 'installed');
|
||||
assert.equal(fallback.status, 'installed');
|
||||
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 2);
|
||||
assert.equal(installed.timing.probes, 8);
|
||||
assert.equal(fallback.timing.probes, 8);
|
||||
assert.ok(diagnosticWriteFaults > 0, 'the diagnostic failure was exercised');
|
||||
for (const trace of [firstTrace, secondTrace]) {
|
||||
assert.equal(trace.probes, 8);
|
||||
assert.equal(trace.samplerStatus, 'installed');
|
||||
assert.equal(trace.rendererStatus, 'installed');
|
||||
assert.equal(trace.slices.filter(s => s.phase === 'ground-step').length, 8);
|
||||
assert.equal(trace.slices.filter(s => s.phase === 'hybrid-complete').length, 1);
|
||||
assert.equal(trace.dropped, 0);
|
||||
assert.ok(trace.slices.every(s => Number.isFinite(s.start) && Number.isFinite(s.end) && s.end >= s.start));
|
||||
assert.equal(trace.timeOrigin, performance.timeOrigin);
|
||||
const parents = trace.slices.filter(s => s.phase === 'ground-step');
|
||||
for (const [index, parent] of parents.entries()) {
|
||||
const detail = parent.detail;
|
||||
assert.equal(detail.index, index);
|
||||
assert.equal(detail.status, trace === firstTrace ? 'recorded' : 'missing');
|
||||
assert.equal(detail.clampResult,
|
||||
trace === firstTrace && index === 4 ? 'threw' : 'returned');
|
||||
assert.ok(Number.isFinite(detail.clampStart) && Number.isFinite(detail.clampEnd));
|
||||
assert.ok(parent.start <= detail.clampStart && detail.clampStart <= detail.clampEnd && detail.clampEnd <= parent.end);
|
||||
if (index === 7) {
|
||||
assert.equal(detail.commitStatus, 'attempted');
|
||||
assert.ok(Number.isFinite(detail.commitStart) && Number.isFinite(detail.commitEnd));
|
||||
assert.ok(detail.clampEnd <= detail.commitStart && detail.commitStart <= detail.commitEnd && detail.commitEnd <= parent.end);
|
||||
} else {
|
||||
assert.equal(detail.commitStatus, 'not-run');
|
||||
assert.equal(detail.commitStart, null);
|
||||
assert.equal(detail.commitEnd, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.notEqual(firstTrace.slices, secondTrace.slices);
|
||||
assert.ok(firstTrace.slices.filter(s => s.phase === 'ground-step').at(-1).end <= secondTrace.slices.find(s => s.phase === 'ground-step').start, 'each queued task records only its own sampling frames');
|
||||
const centroid = naiveRingCentroid(first.ring);
|
||||
const lonScale = 111320 * Math.cos(Cesium.Math.toRadians(centroid.lat));
|
||||
const radius = Math.max(...first.ring.map(([lon, lat]) =>
|
||||
Math.hypot((lon - centroid.lon) * lonScale, (lat - centroid.lat) * 111320)));
|
||||
const distance = radius * 1.5 + 12;
|
||||
const directions = [[1, 0], [-1, 0], [0, 1], [0, -1], [0.7, 0.7], [-0.7, -0.7], [0.7, -0.7], [-0.7, 0.7]];
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
const [dx, dy] = directions[index];
|
||||
assert.ok(Math.abs(probes[index][0] - (centroid.lon + dx * distance / lonScale)) < 1e-10);
|
||||
assert.ok(Math.abs(probes[index][1] - (centroid.lat + dy * distance / 111320)) < 1e-10);
|
||||
assert.ok(Math.abs(probes[index + 8][0] - probes[index][0] - 0.01) < 1e-10);
|
||||
assert.ok(Math.abs(probes[index + 8][1] - probes[index][1]) < 1e-10);
|
||||
}
|
||||
assert.equal(new Set(probes.slice(0, 8).map((point) => point.join(','))).size, 8);
|
||||
const fills = dataSources[0].entities.values.filter((entity) => entity.polygon?.fill?.getValue() !== false && entity.polygon);
|
||||
assert.equal(fills.length, 2);
|
||||
assert.ok(Math.abs(fills[0].polygon.height.getValue() - 57) < 1e-6, 'four accepted samples select floor(4 * 0.3), height 60 minus 3');
|
||||
assert.ok(Math.abs(fills[1].polygon.height.getValue() - 77) < 1e-6, 'all unavailable probes retain anchor minus building height fallback');
|
||||
const afterCompletion = probes.length;
|
||||
frames.emit();
|
||||
assert.equal(probes.length, afterCompletion, 'completion releases the sampler');
|
||||
renderer.remove(first);
|
||||
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 1,
|
||||
'removing the last tile proxy retains the OSM building and other owner');
|
||||
renderer.remove(second);
|
||||
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 1);
|
||||
hideOsmCredit(viewer, otherOwner);
|
||||
assert.equal(viewer.credits.size, 0);
|
||||
});
|
||||
|
||||
test('a building queued behind others gets its full budget on a slow device', async (t) => {
|
||||
installBrowserGlobals(t);
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
t.after(() => t.mock.timers.reset());
|
||||
// The renderers' per-frame budget checks read performance.now(); it follows
|
||||
// the same mocked clock as the timers.
|
||||
let now = 0;
|
||||
t.mock.method(globalThis.performance, 'now', () => now);
|
||||
const { viewer, frames } = fakeViewer(-99, 31);
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
viewer.scene.clampToHeight = (position) => position;
|
||||
const traces = [workTrace(), workTrace(), workTrace()];
|
||||
const completions = [0, 0.01, 0.02].map((offset, index) =>
|
||||
renderer.add(buildingAnnotation(`queued-${index}`, offset), {
|
||||
pendingVisible: false,
|
||||
workTrace: traces[index],
|
||||
}));
|
||||
// Eight probes per building at one probe per frame, at about 5 fps: each
|
||||
// building samples for 1.5 s, so the third waits 3 s in line. Pending
|
||||
// promise work runs between frames, as it does in a browser.
|
||||
for (let building = 0; building < 3; building += 1) {
|
||||
for (let frame = 0; frame < 8; frame += 1) {
|
||||
now += 187.5;
|
||||
t.mock.timers.tick(187.5);
|
||||
frames.emit();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
const results = await Promise.all(completions);
|
||||
assert.deepEqual(results.map((result) => result.status), ['installed', 'installed', 'installed']);
|
||||
assert.deepEqual(traces.map((trace) => trace.samplerStatus), ['installed', 'installed', 'installed']);
|
||||
assert.ok(traces[2].samplerHeadAt - traces[2].samplerStart >= 2000, 'the third waited longer than one budget');
|
||||
assert.ok(traces[2].samplerHeadAt >= traces[1].samplerHeadAt);
|
||||
});
|
||||
|
||||
test('building cancellation, queued deadline and destroy prevent saved callbacks from committing', async (t) => {
|
||||
installBrowserGlobals(t);
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
t.after(() => t.mock.timers.reset());
|
||||
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
let probeCount = 0;
|
||||
viewer.scene.clampToHeight = (position) => { probeCount += 1; return position; };
|
||||
const cancelled = buildingAnnotation('cancelled-building');
|
||||
const cancelledTrace = workTrace();
|
||||
const cancelledCompletion = renderer.add(cancelled, { pendingVisible: false, workTrace: cancelledTrace });
|
||||
const savedCancelled = frames.snapshot();
|
||||
renderer.remove(cancelled);
|
||||
assert.equal((await cancelledCompletion).status, 'cancelled');
|
||||
assert.equal(cancelledTrace.samplerStatus, 'cancelled');
|
||||
assert.equal(cancelledTrace.rendererStatus, 'cancelled');
|
||||
assert.equal(cancelledTrace.slices.filter(s => s.phase === 'ground-step').length, 0);
|
||||
for (const listener of savedCancelled) listener();
|
||||
assert.equal(probeCount, 0);
|
||||
assert.equal(viewer.credits.size, 0);
|
||||
|
||||
const head = buildingAnnotation('deadline-head');
|
||||
const tail = buildingAnnotation('deadline-tail', 0.01);
|
||||
const headCompletion = renderer.add(head, { pendingVisible: false });
|
||||
const tailTrace = workTrace();
|
||||
const tailCompletion = renderer.add(tail, { pendingVisible: false, workTrace: tailTrace });
|
||||
frames.emit();
|
||||
assert.equal(probeCount, 1);
|
||||
const savedTimeout = frames.snapshot();
|
||||
// No further renderer frames arrive: the head's deadline fires, then the
|
||||
// tail gets its own budget from the moment it reaches the head and still
|
||||
// times out without frames, even though it never had a chance to sample.
|
||||
const rendersBefore = viewer.scene.renderRequests;
|
||||
t.mock.timers.tick(2000);
|
||||
assert.equal((await headCompletion).status, 'timeout');
|
||||
assert.ok(viewer.scene.renderRequests > rendersBefore, 'a promoted tail asks for a frame');
|
||||
assert.equal(tailTrace.samplerStatus, undefined, 'queue wait does not spend the tail budget');
|
||||
assert.ok(Number.isFinite(tailTrace.samplerHeadAt), 'the tail starts its own budget at the head');
|
||||
t.mock.timers.tick(2000);
|
||||
const tailResult = await tailCompletion;
|
||||
assert.equal(tailResult.status, 'timeout');
|
||||
assert.equal(tailResult.timing.probes, 0);
|
||||
assert.equal(tailTrace.samplerStatus, 'timeout');
|
||||
assert.equal(tailTrace.rendererStatus, 'timeout');
|
||||
assert.equal(tailTrace.slices.filter(s => s.phase === 'ground-step').length, 0);
|
||||
assert.equal(tailTrace.slices.filter(s => s.phase === 'hybrid-complete').length, 1);
|
||||
assert.equal(viewer.credits.size, 0);
|
||||
for (const listener of savedTimeout) listener();
|
||||
assert.equal(probeCount, 1, 'saved callbacks cannot revive timed-out tasks');
|
||||
assert.equal(dataSources[0].entities.values.length, 0);
|
||||
|
||||
const pending = buildingAnnotation('destroyed-building');
|
||||
const destruction = renderer.add(pending, { pendingVisible: false });
|
||||
const savedDestroy = frames.snapshot();
|
||||
renderer.destroy();
|
||||
assert.equal((await destruction).status, 'cancelled');
|
||||
for (const listener of savedDestroy) listener();
|
||||
t.mock.timers.tick(2000);
|
||||
assert.equal(probeCount, 1);
|
||||
assert.equal(dataSources.length, 0, 'destroy removes the owned data source');
|
||||
assert.equal(frames.size, 0, 'destroy removes frame listeners');
|
||||
assert.equal(viewer.credits.size, 0);
|
||||
});
|
||||
|
||||
test('a timed-out building upgrade keeps its existing pending screen group', async (t) => {
|
||||
installBrowserGlobals(t);
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
t.after(() => t.mock.timers.reset());
|
||||
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
|
||||
const { store, renderer } = hybridWithBoard(viewer);
|
||||
const anno = { ...buildingAnnotation('pending-upgrade'), ring: null, label: 'Building' };
|
||||
renderer.add(anno);
|
||||
const [before] = annotationGroups(store);
|
||||
anno.ring = buildingAnnotation('geometry').ring;
|
||||
const completion = renderer.update(anno);
|
||||
t.mock.timers.tick(2000);
|
||||
assert.equal((await completion).status, 'timeout');
|
||||
const [after, ...others] = annotationGroups(store);
|
||||
assert.equal(after.key, before.key);
|
||||
assert.equal(others.length, 0);
|
||||
assert.ok(childrenWithClass(after, 'gev-anno-ring').length > 0, 'the honest pending point is not converted');
|
||||
assert.equal(dataSources[0].entities.values.length, 0);
|
||||
frames.emit();
|
||||
assert.equal(dataSources[0].entities.values.length, 0);
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
test('an asynchronous caption-conversion throw rolls back multipart world geometry and remains removable', async (t) => {
|
||||
installBrowserGlobals(t);
|
||||
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
|
||||
const { store, renderer } = hybridWithBoard(viewer);
|
||||
const anno = { ...buildingAnnotation('caption-failure'), ring: null, label: 'Building' };
|
||||
renderer.add(anno);
|
||||
anno.ring = buildingAnnotation('geometry').ring;
|
||||
const hole = anno.ring.map(([lon, lat]) => [lon + 0.0002, lat + 0.0002]);
|
||||
const island = anno.ring.map(([lon, lat]) => [lon + 0.002, lat]);
|
||||
anno.polygons = [[anno.ring, hole], [island]];
|
||||
const captionTrace = workTrace();
|
||||
const completion = renderer.update(anno, { workTrace: captionTrace });
|
||||
// Frame projection is still ordinary pending-point work. Arm the failure
|
||||
// only after sampling completes, before its promise converts the caption.
|
||||
for (let frame = 0; frame < 8; frame += 1) frames.emit();
|
||||
assert.ok(dataSources[0].entities.values.length > 0, 'multipart world geometry landed before caption conversion');
|
||||
assert.equal(viewer.credits.size, 2, 'credit belongs to the committed world proxy');
|
||||
// The caption conversion fails when it presents the converted mark.
|
||||
const publish = store.publish;
|
||||
const failure = new Error('caption conversion failed');
|
||||
let armed = true;
|
||||
store.publish = (board) => {
|
||||
if (armed) {
|
||||
armed = false;
|
||||
throw failure;
|
||||
}
|
||||
return publish(board);
|
||||
};
|
||||
// Also fail screen teardown once: the routing entry must survive this second
|
||||
// failure so explicit cleanup can still reach the partial screen state.
|
||||
const workingSetTimeout = globalThis.window.setTimeout;
|
||||
globalThis.window.setTimeout = () => { throw new Error('teardown scheduling failed'); };
|
||||
const rejected = assert.rejects(completion, (error) => error === failure);
|
||||
await rejected;
|
||||
assert.equal(captionTrace.rendererStatus, 'failed');
|
||||
assert.equal(captionTrace.slices.filter(s => s.phase === 'ground-step').length, 8);
|
||||
assert.equal(captionTrace.slices.filter(s => s.phase === 'hybrid-complete').length, 1);
|
||||
assert.equal(captionTrace.slices.filter(s => s.phase === 'hybrid-rollback').length, 1);
|
||||
assert.equal(dataSources[0].entities.values.length, 0, 'all classification, cage and outline parts roll back');
|
||||
assert.equal(viewer.credits.size, 0, 'caption rollback releases that same proxy');
|
||||
assert.equal(annotationGroups(store).length, 1, 'failed screen cleanup remains addressable');
|
||||
globalThis.window.setTimeout = workingSetTimeout;
|
||||
store.publish = publish;
|
||||
renderer.remove(anno);
|
||||
assert.equal(annotationGroups(store).length, 0);
|
||||
renderer.remove(anno);
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
test('a deadline passed during caption conversion leaves a drawn pending point', async (t) => {
|
||||
installBrowserGlobals(t);
|
||||
let now = 0;
|
||||
t.mock.method(globalThis.performance, 'now', () => now);
|
||||
const { viewer, frames } = fakeViewer(-99, 31);
|
||||
const { store, renderer } = hybridWithBoard(viewer);
|
||||
// Unlabeled: the conversion empties the screen group rather than relabelling it.
|
||||
const anno = { ...buildingAnnotation('late-timeout'), ring: null };
|
||||
renderer.add(anno);
|
||||
anno.ring = buildingAnnotation('geometry').ring;
|
||||
const completion = renderer.update(anno);
|
||||
for (let frame = 0; frame < 8; frame += 1) frames.emit();
|
||||
// The clock passes the deadline while the conversion presents the board.
|
||||
const publish = store.publish;
|
||||
store.publish = (board) => {
|
||||
now = 1e9;
|
||||
return publish(board);
|
||||
};
|
||||
assert.equal((await completion).status, 'timeout');
|
||||
store.publish = publish;
|
||||
const live = annotationGroups(store).filter((mark) => !mark.className?.includes('gev-out'));
|
||||
assert.equal(live.length, 1);
|
||||
assert.ok(childrenWithClass(live[0], 'gev-anno-ring').length > 0, 'the pending point still draws');
|
||||
renderer.remove(anno);
|
||||
renderer.destroy();
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
// Multipart outline identity and street highlights in the annotation engine.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createAnnotationEngine,
|
||||
promoteStreetHighlight,
|
||||
} from './annotationEngine.js';
|
||||
|
||||
function installAnimationFrameStubs(t) {
|
||||
const raf = globalThis.requestAnimationFrame;
|
||||
const caf = globalThis.cancelAnimationFrame;
|
||||
globalThis.requestAnimationFrame = () => 1;
|
||||
globalThis.cancelAnimationFrame = () => {};
|
||||
t.after(() => {
|
||||
if (raf === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = raf;
|
||||
if (caf === undefined) delete globalThis.cancelAnimationFrame;
|
||||
else globalThis.cancelAnimationFrame = caf;
|
||||
});
|
||||
}
|
||||
|
||||
const renderer = { add() {}, update() {}, remove() {}, sync() {} };
|
||||
const outer = [
|
||||
[0, 0],
|
||||
[0.01, 0],
|
||||
[0.01, 0.01],
|
||||
[0, 0.01],
|
||||
[0, 0],
|
||||
];
|
||||
const hole = (d) => [
|
||||
[0.004, 0.004],
|
||||
[0.004 + d, 0.004],
|
||||
[0.004 + d, 0.004 + d],
|
||||
[0.004, 0.004],
|
||||
];
|
||||
|
||||
test('outlines sharing an outer ring but not their holes stay two marks', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
const shapes = {
|
||||
a: [[outer, hole(0.001)]],
|
||||
b: [[outer, hole(0.002)]],
|
||||
same: [[outer, hole(0.001)]],
|
||||
};
|
||||
const resolveTarget = async ({ target }) => ({
|
||||
lon: 0.005,
|
||||
lat: 0.005,
|
||||
height: 0,
|
||||
label: target,
|
||||
source: 'fake',
|
||||
ring: outer,
|
||||
polygons: shapes[target],
|
||||
footprintKind: 'area',
|
||||
});
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
renderer,
|
||||
resolveTarget,
|
||||
});
|
||||
await engine.annotate({ type: 'area', target: 'a', label: 'A' });
|
||||
await engine.annotate({ type: 'area', target: 'b', label: 'B' });
|
||||
assert.equal(engine.list().length, 2, 'different holes: different marks');
|
||||
await engine.annotate({ type: 'area', target: 'same', label: 'Same' });
|
||||
assert.equal(engine.list().length, 2, 'identical parts and holes: one mark');
|
||||
});
|
||||
|
||||
test('a street highlight asks for the street outline', () => {
|
||||
assert.deepEqual(
|
||||
promoteStreetHighlight({ type: 'highlight', target: 'Congress Avenue' }),
|
||||
{ type: 'area', target: 'Congress Avenue', entityKind: 'street' },
|
||||
);
|
||||
assert.equal(
|
||||
promoteStreetHighlight({
|
||||
type: 'highlight',
|
||||
target: 'Lombard St, San Francisco',
|
||||
}).type,
|
||||
'area',
|
||||
);
|
||||
assert.equal(
|
||||
promoteStreetHighlight({
|
||||
type: 'highlight',
|
||||
target: 'the Alamo',
|
||||
entityKind: 'street',
|
||||
}).type,
|
||||
'area',
|
||||
);
|
||||
for (const spec of [
|
||||
{ type: 'highlight', target: "St Peter's Basilica" },
|
||||
{ type: 'highlight', target: 'Zilker Park' },
|
||||
{ type: 'highlight', target: 'Congress Avenue', footprint: false },
|
||||
{ type: 'highlight', target: 'Congress Avenue', entityKind: 'building' },
|
||||
{ type: 'pin', target: 'Congress Avenue' },
|
||||
])
|
||||
assert.equal(promoteStreetHighlight(spec), spec, JSON.stringify(spec));
|
||||
});
|
||||
|
||||
test('the engine resolves a bare street highlight with its footprint', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
const seen = [];
|
||||
const resolveTarget = async (args) => {
|
||||
seen.push(args);
|
||||
return {
|
||||
lon: -97.74,
|
||||
lat: 30.27,
|
||||
height: 0,
|
||||
label: args.target,
|
||||
source: 'fake',
|
||||
ring: null,
|
||||
};
|
||||
};
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
renderer,
|
||||
resolveTarget,
|
||||
});
|
||||
await engine.annotate({
|
||||
type: 'highlight',
|
||||
target: 'Congress Avenue',
|
||||
label: 'Congress',
|
||||
});
|
||||
assert.equal(seen[0].footprint, true);
|
||||
assert.equal(seen[0].entityKind, 'street');
|
||||
});
|
||||
|
||||
test('a duplicate that fails to clean up never costs the new outline', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
const polygons = [[outer]];
|
||||
const outline = { ring: outer, polygons, footprintKind: 'area' };
|
||||
const point = { lon: 0.005, lat: 0.005, height: 0, source: 'fake' };
|
||||
// "Old" draws at once; "New" resolves the same polygon later.
|
||||
const resolveTarget = async ({ target }) =>
|
||||
target === 'old'
|
||||
? { ...point, label: target, ...outline }
|
||||
: {
|
||||
...point,
|
||||
label: target,
|
||||
resolveOutline: async () => ({ ...point, ...outline }),
|
||||
};
|
||||
const stuck = new Set();
|
||||
const outcomes = [];
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
renderer: {
|
||||
...renderer,
|
||||
add(anno) {
|
||||
if (anno.label === 'Old') stuck.add(anno);
|
||||
},
|
||||
remove(anno) {
|
||||
if (stuck.has(anno)) throw new Error('renderer refused');
|
||||
},
|
||||
},
|
||||
resolveTarget,
|
||||
});
|
||||
engine.onOutlineEvent((event) => outcomes.push(event));
|
||||
await engine.annotate({ type: 'area', target: 'old', label: 'Old' });
|
||||
await engine.annotate({ type: 'area', target: 'new', label: 'New' });
|
||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
const fresh = engine.list().find((mark) => mark.label === 'New');
|
||||
assert.equal(fresh?.footprintKind, 'area', 'the new mark keeps its outline');
|
||||
assert.notEqual(fresh.outlineUnavailable, true);
|
||||
assert.deepEqual(
|
||||
outcomes.filter((event) => event.label === 'New').map((e) => e.status),
|
||||
['resolved'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
import {
|
||||
bufferCorridor,
|
||||
ringAreaM2,
|
||||
ringCentroid,
|
||||
} from '../sources/featureGeometry.js';
|
||||
import { isUnavailableCapability } from '../sources/capability.js';
|
||||
import { containsAnchor } from '../data/adminBoundaries.js';
|
||||
import { lonLatToTile } from '../data/tomtomTiles.js';
|
||||
import {
|
||||
BUILDING_MAX_DISTANCE_M,
|
||||
GROUNDS_WORDS_RE,
|
||||
OUTLINE_TILE_ZOOM,
|
||||
STREET_RADIUS_M,
|
||||
boxAround,
|
||||
buildingFromTiles,
|
||||
createOpenFreeMapOutlineSource,
|
||||
enclosingAreaFromTiles,
|
||||
groundsClassesFor,
|
||||
streetFromTiles,
|
||||
} from '../sources/openFreeMapOutlines.js';
|
||||
import { createNominatimOutlineClient } from '../sources/nominatimOutlines.js';
|
||||
|
||||
export { containsAnchor };
|
||||
|
||||
/**
|
||||
* Outline rungs that need no Overpass, around the resolver's existing ladder:
|
||||
*
|
||||
* bundled rungs (inside the base ladder)
|
||||
* → OpenFreeMap tiles for streets and pointed-at buildings
|
||||
* → operator Overpass, when configured (the base ladder, unchanged)
|
||||
* → the server's guarded Nominatim outline (cities, landmarks, grounds,
|
||||
* parks, lakes, campuses, named buildings)
|
||||
* → OpenFreeMap building at the point, for a named building nobody outlined
|
||||
* → the base answer: an honest point marked "outline unavailable".
|
||||
*
|
||||
* Every rung returns the resolver's footprint shape
|
||||
* `{ ring, polygons?, kind, heightM, synthesized }` and hands it to the
|
||||
* resolver's `finish`, which applies the scope caps and drift bound and
|
||||
* re-centres the anchor. Nothing here fetches without an explicit ask.
|
||||
*/
|
||||
|
||||
/** Half-width (m) of the ribbon drawn along a street. */
|
||||
const STREET_HALF_WIDTH_M = 11;
|
||||
|
||||
/** A Nominatim "grounds" answer smaller than this is a building, not grounds. */
|
||||
const GROUNDS_MIN_AREA_M2 = 20_000;
|
||||
|
||||
/** Words that point at the thing rather than name it. */
|
||||
const DEICTIC_RE =
|
||||
/^(?:(?:this|that|the|my)\s+)?(?:building|house|structure|tower|place|one|thing|spot|here|there|it)$/i;
|
||||
|
||||
/** Whether the ask names a thing (vs. "this building" at a pointer). */
|
||||
export function isNamedAsk(target) {
|
||||
const text = String(target || '').trim();
|
||||
return Boolean(text) && !DEICTIC_RE.test(text) && /\p{L}/u.test(text);
|
||||
}
|
||||
|
||||
/** The words sent to Nominatim: the ask without grounds words or articles. */
|
||||
export function outlineQueryText(target) {
|
||||
return String(target || '')
|
||||
.replace(GROUNDS_WORDS_RE, ' ')
|
||||
.replace(/^\s*the\s+/i, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Which Nominatim ask kind (if any) suits a resolver scope.
|
||||
* Neighbourhoods and streets are not sent: bundled packs and tiles own them.
|
||||
*/
|
||||
export function nominatimKindFor({ scope, groundsLike, pointLike, around }) {
|
||||
if (pointLike) return null;
|
||||
// "Around" asks get a buffer, except grounds: the grounds ARE the thing.
|
||||
if (around && !groundsLike) return null;
|
||||
if (scope === 'city') return 'city';
|
||||
if (scope === 'state' || scope === 'county' || scope === 'country')
|
||||
return 'admin';
|
||||
if (groundsLike) return 'landmark';
|
||||
if (scope === 'building') return 'building';
|
||||
if (scope === 'compound' || scope === 'auto') return 'landmark';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parts with the one holding the anchor outside its holes first. A boundary
|
||||
* can be largest at sea (Tokyo's island municipalities), so size alone does
|
||||
* not pick the part the ask is about; with no part holding the anchor, the
|
||||
* order is kept.
|
||||
*/
|
||||
export function anchorPartFirst(polygons, points) {
|
||||
for (const { lat, lon } of points) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
const at = polygons.findIndex((polygon) =>
|
||||
containsAnchor([polygon], lat, lon),
|
||||
);
|
||||
if (at > 0) return [polygons[at], ...polygons.filter((_, i) => i !== at)];
|
||||
if (at === 0) return polygons;
|
||||
}
|
||||
return polygons;
|
||||
}
|
||||
|
||||
/** Whether a base answer is final (a real outline, or "try again later"). */
|
||||
function keepBase(result) {
|
||||
if (result === undefined) return true;
|
||||
if (result?.rateLimited === true) return true;
|
||||
if (isUnavailableCapability(result)) return false;
|
||||
return Boolean(result?.ring) && !result.synthesized;
|
||||
}
|
||||
|
||||
/** Street polylines → one ribbon polygon per chain (longest first). */
|
||||
export function streetFootprint(street) {
|
||||
const polygons = street.lines
|
||||
.filter((line) => line.length >= 2)
|
||||
.map((line) => [bufferCorridor(line, STREET_HALF_WIDTH_M)]);
|
||||
if (!polygons.length) return null;
|
||||
return {
|
||||
ring: polygons[0][0],
|
||||
polygons,
|
||||
lines: street.lines,
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
synthesized: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Rungs built without their own tiles share one outline source, so a second
|
||||
// resolver instance cannot add another outline cache to the tile budget.
|
||||
let defaultOutlineTiles = null;
|
||||
const sharedOutlineTiles = () =>
|
||||
(defaultOutlineTiles ??= createOpenFreeMapOutlineSource());
|
||||
|
||||
export function createOutlineRungs({
|
||||
tiles = sharedOutlineTiles(),
|
||||
nominatim = createNominatimOutlineClient(),
|
||||
} = {}) {
|
||||
/**
|
||||
* Decoded tiles around a point, or null when the tiles cannot be read. With
|
||||
* `needAnchor`, a partial load that lost the point's own tile is null too: a
|
||||
* neighbouring tile alone cannot show what is at the point, so a nearby
|
||||
* building or area would stand in for it.
|
||||
*/
|
||||
async function tilesAround(lat, lon, radiusM, signal, needAnchor = false) {
|
||||
try {
|
||||
const {
|
||||
tiles: decoded,
|
||||
partial,
|
||||
loadedTiles,
|
||||
} = await tiles.fetchBounds(boxAround(lat, lon, radiusM), {
|
||||
zoom: OUTLINE_TILE_ZOOM,
|
||||
signal,
|
||||
});
|
||||
if (needAnchor && partial && Array.isArray(loadedTiles)) {
|
||||
const anchor = lonLatToTile(lon, lat, OUTLINE_TILE_ZOOM);
|
||||
if (!loadedTiles.some((t) => t.x === anchor.x && t.y === anchor.y))
|
||||
return null;
|
||||
}
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
if (error?.name === 'AbortError') throw error;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function streetRung(ctx) {
|
||||
const name = ctx.matchName || ctx.target;
|
||||
if (!isNamedAsk(name)) return null;
|
||||
const decoded = await tilesAround(
|
||||
ctx.lat,
|
||||
ctx.lon,
|
||||
STREET_RADIUS_M,
|
||||
ctx.signal,
|
||||
);
|
||||
if (!decoded) return null;
|
||||
const street = streetFromTiles(decoded, {
|
||||
name,
|
||||
lat: ctx.lat,
|
||||
lon: ctx.lon,
|
||||
});
|
||||
return street ? streetFootprint(street) : null;
|
||||
}
|
||||
|
||||
async function buildingRung(ctx, requireContainment = false) {
|
||||
const decoded = await tilesAround(
|
||||
ctx.lat,
|
||||
ctx.lon,
|
||||
BUILDING_MAX_DISTANCE_M * 2,
|
||||
ctx.signal,
|
||||
true,
|
||||
);
|
||||
const building = decoded && buildingFromTiles(decoded, ctx);
|
||||
if (!building || (requireContainment && building.contains !== true))
|
||||
return null;
|
||||
return {
|
||||
ring: building.ring,
|
||||
polygons: building.polygons,
|
||||
kind: 'building',
|
||||
heightM: building.heightM,
|
||||
synthesized: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** The open area enclosing `seed` (the named feature) or the anchor. */
|
||||
async function groundsRung(ctx, seed = null) {
|
||||
const at = seed || { lat: ctx.lat, lon: ctx.lon };
|
||||
const decoded = await tilesAround(at.lat, at.lon, 600, ctx.signal, true);
|
||||
const area =
|
||||
decoded &&
|
||||
enclosingAreaFromTiles(decoded, {
|
||||
lat: at.lat,
|
||||
lon: at.lon,
|
||||
name: outlineQueryText(ctx.target || ctx.matchName),
|
||||
// The kind the ask names ("… School grounds") lets unnamed tile
|
||||
// landuse of that class answer; read before grounds words are cut.
|
||||
unnamedClasses: groundsClassesFor(ctx.target || ctx.matchName),
|
||||
minAreaM2: GROUNDS_MIN_AREA_M2,
|
||||
});
|
||||
if (!area || !containsAnchor(area.polygons, ctx.lat, ctx.lon)) return null;
|
||||
return {
|
||||
ring: area.ring,
|
||||
polygons: area.polygons,
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
synthesized: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function nominatimRung(ctx, kind) {
|
||||
const query = outlineQueryText(
|
||||
kind === 'city' || kind === 'admin'
|
||||
? ctx.matchName || ctx.target
|
||||
: ctx.target || ctx.matchName,
|
||||
);
|
||||
if (!isNamedAsk(query)) return null;
|
||||
const bias = ctx.view || { lat: ctx.lat, lon: ctx.lon };
|
||||
const outline = await nominatim.lookup(
|
||||
{
|
||||
query,
|
||||
kind,
|
||||
lat: bias.lat,
|
||||
lon: bias.lon,
|
||||
anchorLat: ctx.lat,
|
||||
anchorLon: ctx.lon,
|
||||
},
|
||||
{ signal: ctx.signal },
|
||||
);
|
||||
if (!outline?.polygons?.length) return outline; // null | undefined | unavailable | rateLimited
|
||||
// Named outlines must retain the resolved place's authority,
|
||||
// including holes; a nearby same-name polygon is not the selected place.
|
||||
if (!containsAnchor(outline.polygons, ctx.lat, ctx.lon)) return null;
|
||||
const polygons = anchorPartFirst(outline.polygons, [
|
||||
{ lat: ctx.lat, lon: ctx.lon },
|
||||
]);
|
||||
const ring = polygons[0][0];
|
||||
// A grounds ask answered by a building-sized polygon found the named
|
||||
// building, not its grounds: keep its position to find what encloses it.
|
||||
if (ctx.groundsLike && ringAreaM2(ring) < GROUNDS_MIN_AREA_M2)
|
||||
return { groundsSeed: ringCentroid(ring) };
|
||||
return {
|
||||
ring,
|
||||
polygons,
|
||||
kind:
|
||||
kind === 'building' || outline.class === 'building'
|
||||
? 'building'
|
||||
: 'area',
|
||||
heightM: null,
|
||||
synthesized: false,
|
||||
outlineName: outline.name,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the ladder for one resolved anchor.
|
||||
*
|
||||
* @param {object} ctx scope, target, matchName, lat, lon, named anchor facts,
|
||||
* view centre, signal, and `credit(kind)`.
|
||||
* @param {{base: () => Promise<any>, finish: (fp: object, scope: string) => any}} ladder
|
||||
*/
|
||||
async function resolve(ctx, { base, finish }) {
|
||||
const { scope } = ctx;
|
||||
const named = ctx.fromName && isNamedAsk(ctx.target);
|
||||
// A cancelled ask (board cleared, superseded) stops between rungs, so it
|
||||
// never spends a Nominatim request.
|
||||
const alive = () => ctx.signal?.throwIfAborted();
|
||||
const accept = (fp, asScope = scope, provider) => {
|
||||
alive();
|
||||
if (!fp) return null;
|
||||
const patch = finish(fp, asScope);
|
||||
if (patch?.ring) {
|
||||
patch.outlineCredit = provider === 'openfreemap' ? 'tiles' : 'osm';
|
||||
ctx.credit?.(patch.outlineCredit);
|
||||
// One line per answered outline, like the resolver's own trace.
|
||||
console.log(
|
||||
`[Outline] "${ctx.target || ''}": scope=${asScope} → ${provider}` +
|
||||
` (${patch.footprintKind}, ${patch.polygons?.length || 1} part(s))`,
|
||||
);
|
||||
return patch;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Tiles first for streets and for a building pointed at rather than named.
|
||||
if (scope === 'street') {
|
||||
const street = accept(await streetRung(ctx), 'street', 'openfreemap');
|
||||
if (street) return street;
|
||||
} else if (scope === 'building' && !named && !ctx.around) {
|
||||
const building = accept(
|
||||
await buildingRung(ctx),
|
||||
'building',
|
||||
'openfreemap',
|
||||
);
|
||||
if (building) return building;
|
||||
}
|
||||
|
||||
alive();
|
||||
const baseResult = await base();
|
||||
alive();
|
||||
if (keepBase(baseResult)) return baseResult;
|
||||
|
||||
// A transient or busy Nominatim answer is kept aside while the tile
|
||||
// fallbacks run; it is returned only if nothing else answers.
|
||||
let retryLater = null;
|
||||
let groundsSeed = null;
|
||||
const kind = named ? nominatimKindFor(ctx) : null;
|
||||
if (kind) {
|
||||
const found = await nominatimRung(ctx, kind);
|
||||
alive();
|
||||
if (found?.groundsSeed) {
|
||||
groundsSeed = found.groundsSeed;
|
||||
} else if (found?.ring) {
|
||||
const patch = accept(
|
||||
found,
|
||||
ctx.groundsLike ? 'compound' : scope,
|
||||
'nominatim',
|
||||
);
|
||||
if (patch) return patch;
|
||||
} else if (found === undefined || found?.rateLimited) {
|
||||
retryLater = { value: found };
|
||||
}
|
||||
}
|
||||
|
||||
// Grounds absent from the named lookup: a matching named tile area.
|
||||
if (ctx.groundsLike && named) {
|
||||
// Around the named building when Nominatim found it, else the anchor.
|
||||
let grounds = groundsSeed
|
||||
? accept(await groundsRung(ctx, groundsSeed), 'compound', 'openfreemap')
|
||||
: null;
|
||||
grounds ||= accept(await groundsRung(ctx), 'compound', 'openfreemap');
|
||||
if (grounds) return grounds;
|
||||
}
|
||||
|
||||
// A named building nobody outlined: the footprint holding its point. Never for
|
||||
// a grounds ask, where the building is the wrong shape.
|
||||
if (scope === 'building' && named && !ctx.around && !ctx.groundsLike) {
|
||||
const building = accept(
|
||||
await buildingRung(ctx, true),
|
||||
'building',
|
||||
'openfreemap',
|
||||
);
|
||||
if (building) return building;
|
||||
}
|
||||
// Keep the mark retryable only when nothing else could answer. A
|
||||
// definitive miss or an approximation from the configured ladder stands
|
||||
// rather than inviting repeated lookups.
|
||||
if (retryLater && isUnavailableCapability(baseResult))
|
||||
return retryLater.value;
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
return { resolve };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,174 @@
|
||||
// Tile fallbacks still run after a busy or failed Nominatim answer, and
|
||||
// grounds asks phrased "around" still reach their outline rungs.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createOutlineRungs, nominatimKindFor } from './outlineRungs.js';
|
||||
import { decodeOpenFreeMapOutlineTile } from '../sources/openFreeMapOutlines.js';
|
||||
|
||||
const TILES = [6745, 6746].map((y) =>
|
||||
decodeOpenFreeMapOutlineTile(
|
||||
readFileSync(
|
||||
new URL(
|
||||
`../data/fixtures/ofm-outlines-austin-14-3743-${y}.pbf`,
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
14,
|
||||
3743,
|
||||
y,
|
||||
),
|
||||
);
|
||||
const UNAVAILABLE = { unavailable: true, retryable: false };
|
||||
const CAPITOL = { lat: 30.27472, lon: -97.74035 };
|
||||
|
||||
function setup(answer, tiles = TILES) {
|
||||
const tileCalls = [];
|
||||
const asks = [];
|
||||
const rungs = createOutlineRungs({
|
||||
tiles: {
|
||||
fetchBounds: async (box) => {
|
||||
tileCalls.push(box);
|
||||
return { tiles };
|
||||
},
|
||||
},
|
||||
nominatim: {
|
||||
lookup: async (ask) => {
|
||||
asks.push(ask);
|
||||
return answer;
|
||||
},
|
||||
},
|
||||
});
|
||||
return { rungs, tileCalls, asks };
|
||||
}
|
||||
const ladder = {
|
||||
base: async () => UNAVAILABLE,
|
||||
finish: (fp) => ({ ring: fp.ring, footprintKind: fp.kind }),
|
||||
};
|
||||
const ctx = (overrides) => ({
|
||||
scope: 'building',
|
||||
target: 'Texas State Capitol',
|
||||
matchName: 'Texas State Capitol',
|
||||
fromName: true,
|
||||
...CAPITOL,
|
||||
view: CAPITOL,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
for (const [label, answer] of [
|
||||
['busy', { rateLimited: true, retryAfterMs: 5000 }],
|
||||
['transient', undefined],
|
||||
]) {
|
||||
test(`a ${label} Nominatim answer does not skip the tile fallbacks`, async () => {
|
||||
const building = setup(answer);
|
||||
const found = await building.rungs.resolve(ctx(), ladder);
|
||||
assert.equal(building.asks.length, 1);
|
||||
assert.ok(building.tileCalls.length > 0, 'tiles were consulted');
|
||||
assert.equal(found.footprintKind, 'building');
|
||||
|
||||
const grounds = setup(answer);
|
||||
const lawn = await grounds.rungs.resolve(
|
||||
ctx({ target: 'the Texas Capitol grounds', groundsLike: true }),
|
||||
ladder,
|
||||
);
|
||||
assert.ok(grounds.tileCalls.length > 0, 'grounds tiles were consulted');
|
||||
assert.equal(lawn, answer, 'unnamed grounds preserve the retryable answer');
|
||||
});
|
||||
}
|
||||
|
||||
test('with no tile answer either, the busy result keeps the mark retryable', async () => {
|
||||
const busy = { rateLimited: true, retryAfterMs: 5000 };
|
||||
const rungs = createOutlineRungs({
|
||||
tiles: { fetchBounds: async () => ({ tiles: [] }) },
|
||||
nominatim: { lookup: async () => busy },
|
||||
});
|
||||
assert.equal(await rungs.resolve(ctx(), ladder), busy);
|
||||
});
|
||||
|
||||
test('"around the Capitol grounds" is still a grounds ask', async () => {
|
||||
assert.equal(
|
||||
nominatimKindFor({ scope: 'building', groundsLike: true, around: true }),
|
||||
'landmark',
|
||||
);
|
||||
assert.equal(nominatimKindFor({ scope: 'compound', around: true }), null);
|
||||
const { rungs, asks } = setup(null);
|
||||
const lawn = await rungs.resolve(
|
||||
ctx({
|
||||
target: 'the Texas Capitol grounds',
|
||||
groundsLike: true,
|
||||
around: true,
|
||||
}),
|
||||
ladder,
|
||||
);
|
||||
assert.equal(asks.length, 1);
|
||||
});
|
||||
|
||||
test('named grounds around a building seed still require the original anchor', async () => {
|
||||
// A place-search anchor ~250 m from the dome, outside the Capitol lawn.
|
||||
const offLawn = { lat: 30.27265, lon: -97.73925 };
|
||||
const d = 0.0003;
|
||||
const dome = {
|
||||
polygons: [
|
||||
[
|
||||
[
|
||||
[CAPITOL.lon - d, CAPITOL.lat - d],
|
||||
[CAPITOL.lon + d, CAPITOL.lat - d],
|
||||
[CAPITOL.lon + d, CAPITOL.lat + d],
|
||||
[CAPITOL.lon - d, CAPITOL.lat + d],
|
||||
[CAPITOL.lon - d, CAPITOL.lat - d],
|
||||
],
|
||||
],
|
||||
],
|
||||
class: 'office',
|
||||
};
|
||||
const lawnRadius = 0.001;
|
||||
const namedTiles = [
|
||||
{
|
||||
areas: [
|
||||
{
|
||||
name: 'Texas Capitol',
|
||||
class: 'park',
|
||||
clipped: false,
|
||||
rings: [
|
||||
[
|
||||
[CAPITOL.lon - lawnRadius, CAPITOL.lat - lawnRadius],
|
||||
[CAPITOL.lon + lawnRadius, CAPITOL.lat - lawnRadius],
|
||||
[CAPITOL.lon + lawnRadius, CAPITOL.lat + lawnRadius],
|
||||
[CAPITOL.lon - lawnRadius, CAPITOL.lat + lawnRadius],
|
||||
[CAPITOL.lon - lawnRadius, CAPITOL.lat - lawnRadius],
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const withoutSeed = setup(null, namedTiles);
|
||||
assert.equal(
|
||||
await withoutSeed.rungs.resolve(
|
||||
ctx({
|
||||
...offLawn,
|
||||
target: 'the Texas Capitol grounds',
|
||||
groundsLike: true,
|
||||
}),
|
||||
ladder,
|
||||
),
|
||||
UNAVAILABLE,
|
||||
'the anchor alone is not inside the grounds',
|
||||
);
|
||||
const { rungs, tileCalls } = setup(dome, namedTiles);
|
||||
const lawn = await rungs.resolve(
|
||||
ctx({ ...offLawn, target: 'the Texas Capitol grounds', groundsLike: true }),
|
||||
ladder,
|
||||
);
|
||||
assert.ok(tileCalls.length > 0, 'the matching named enclosure was consulted');
|
||||
assert.deepEqual(
|
||||
tileCalls,
|
||||
withoutSeed.tileCalls,
|
||||
'a rejected polygon cannot add a shifted grounds-seed lookup',
|
||||
);
|
||||
assert.equal(
|
||||
lawn,
|
||||
UNAVAILABLE,
|
||||
'a building seed cannot move the original anchor into the named grounds',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,687 @@
|
||||
// Area annotations draw bundled US Census place and neighborhood outlines
|
||||
// (San Francisco DataSF first, then Who's On First) with no network lookup.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||
import { createAnnotationResolver } from './resolver.js';
|
||||
import { polygonsContain } from '../data/adminBoundaries.js';
|
||||
import { streetFootprint } from './outlineRungs.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, { outlineRungs } = {}) {
|
||||
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,
|
||||
...(outlineRungs ? { outlineRungs } : {}),
|
||||
});
|
||||
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);
|
||||
const SAN_FRANCISCO = viewerAt(37.76, -122.42, 3000);
|
||||
const LONDON = viewerAt(51.51, -0.16, 3000);
|
||||
|
||||
function assertBundledArea(resolved, label) {
|
||||
assert.equal(resolved?.source, 'bundled', label);
|
||||
assert.equal(resolved.label, label);
|
||||
assert.equal(resolved.footprintKind, 'area');
|
||||
assert.equal(resolved.synthesized, false);
|
||||
assert.equal(
|
||||
resolved.outlineCredit || null,
|
||||
null,
|
||||
'bundled data is not an OSM outline',
|
||||
);
|
||||
assert.equal(resolved.resolveOutline, undefined, 'drawn at once');
|
||||
assert.deepEqual(resolved.ring[0], resolved.ring.at(-1), 'ring closed');
|
||||
assert.ok(resolved.polygons.length >= 1);
|
||||
}
|
||||
|
||||
test('a city name that is only a neighborhood alias goes to the geocoder', async () => {
|
||||
const { resolve, geocodes } = harness();
|
||||
// Sé lists "São Paulo" among its names; the ask means the city.
|
||||
const saoPaulo = viewerAt(-23.55, -46.633, 60_000);
|
||||
const city = await resolve('São Paulo', saoPaulo);
|
||||
assert.notEqual(city?.label, 'Se');
|
||||
assert.ok(geocodes.includes('São Paulo'));
|
||||
// A district ask still takes the alias.
|
||||
const district = await resolve('São Paulo', saoPaulo, {
|
||||
entityKind: 'district',
|
||||
});
|
||||
assert.equal(district?.label, 'Se');
|
||||
});
|
||||
|
||||
test('"Austin" over Austin draws the Census place with no lookup', async () => {
|
||||
const { resolve, lookups, geocodes } = harness();
|
||||
for (const target of ['Austin', 'Austin, Texas', 'City of Austin']) {
|
||||
const resolved = await resolve(target, AUSTIN);
|
||||
assertBundledArea(resolved, 'Austin');
|
||||
assert.ok(resolved.polygons.length >= 4, 'every part of the city');
|
||||
}
|
||||
// A qualified name needs no view.
|
||||
const springfield = await resolve('Springfield, Illinois', AUSTIN);
|
||||
assertBundledArea(springfield, 'Springfield');
|
||||
assert.ok(Math.abs(springfield.lat - 39.78) < 0.2);
|
||||
assert.deepEqual(geocodes, []);
|
||||
assert.deepEqual(lookups, []);
|
||||
});
|
||||
|
||||
test("the SF DataSF pack answers before Who's On First in San Francisco", async () => {
|
||||
const { resolve, lookups, geocodes } = harness();
|
||||
const sf = JSON.parse(
|
||||
readFileSync(
|
||||
new URL(
|
||||
'../data/local_data/neighborhoods/san-francisco.json',
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
);
|
||||
const mission = sf.features.find((f) => f.properties.name === 'Mission');
|
||||
const missionRing = mission.geometry.coordinates[0];
|
||||
for (const target of ['the Mission District', 'Mission', 'the Mission']) {
|
||||
const resolved = await resolve(target, SAN_FRANCISCO, {
|
||||
entityKind: 'district',
|
||||
});
|
||||
assertBundledArea(resolved, 'Mission');
|
||||
assert.deepEqual(resolved.polygons[0][0], missionRing, target);
|
||||
}
|
||||
// A name only WOF has in San Francisco falls through to it.
|
||||
const dogpatch = await resolve('Dogpatch', SAN_FRANCISCO);
|
||||
assertBundledArea(dogpatch, 'Dogpatch');
|
||||
// A park asked for as a compound is not a neighborhood of its name.
|
||||
await resolve('Mission Dolores Park', SAN_FRANCISCO, {
|
||||
entityKind: 'compound',
|
||||
});
|
||||
assert.equal(geocodes.filter((q) => q !== 'text-search').length, 1);
|
||||
assert.deepEqual(lookups, []);
|
||||
});
|
||||
|
||||
test("Who's On First neighborhoods resolve near the view", async () => {
|
||||
const { resolve, geocodes } = harness();
|
||||
assertBundledArea(await resolve('Notting Hill', LONDON), 'Notting Hill');
|
||||
assertBundledArea(
|
||||
await resolve('Le Marais', viewerAt(48.857, 2.352, 3000)),
|
||||
'Le Marais',
|
||||
);
|
||||
assertBundledArea(
|
||||
await resolve('Williamsburg', viewerAt(40.71, -73.96, 3000)),
|
||||
'Williamsburg',
|
||||
);
|
||||
assert.deepEqual(geocodes, []);
|
||||
});
|
||||
|
||||
test('campuses, qualified hoods and far names still go to the geocoder', async () => {
|
||||
const { resolve, geocodes } = harness();
|
||||
// Stanford is a CDP, but a campus ask wants the campus.
|
||||
const stanford = await resolve('Stanford', viewerAt(37.43, -122.17, 3000), {
|
||||
entityKind: 'compound',
|
||||
});
|
||||
assert.notEqual(stanford?.source, 'bundled');
|
||||
await resolve('the Capitol grounds', AUSTIN);
|
||||
await resolve('Notting Hill', AUSTIN);
|
||||
assert.equal(geocodes.filter((q) => q !== 'text-search').length, 3);
|
||||
// A state settles a place name wherever the camera is: not Brooklyn's
|
||||
// Williamsburg, the Virginia city.
|
||||
const virginia = await resolve(
|
||||
'Williamsburg, Virginia',
|
||||
viewerAt(40.71, -73.96, 3000),
|
||||
);
|
||||
assertBundledArea(virginia, 'Williamsburg');
|
||||
assert.ok(Math.abs(virginia.lat - 37.27) < 0.1, 'Virginia');
|
||||
});
|
||||
|
||||
test('a geocoded city or neighborhood upgrades to the bundled outline', async () => {
|
||||
const city = harness({
|
||||
lat: 30.2672,
|
||||
lng: -97.7431,
|
||||
label: 'Austin, TX, USA',
|
||||
name: 'Austin',
|
||||
types: ['locality', 'political'],
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const credits = [];
|
||||
const cancelled = await city.resolve(
|
||||
'Austin',
|
||||
{
|
||||
...viewerAt(39, -100, 5_000_000),
|
||||
creditDisplay: { addStaticCredit: (credit) => credits.push(credit) },
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const pending = cancelled.resolveOutline();
|
||||
controller.abort();
|
||||
await assert.rejects(pending, { name: 'AbortError' });
|
||||
assert.deepEqual(
|
||||
credits,
|
||||
[],
|
||||
'a cancelled bundled continuation cannot mutate viewer credits',
|
||||
);
|
||||
|
||||
// Seen from far away: the view does not settle the name, the geocoder does.
|
||||
const resolved = await city.resolve('Austin', viewerAt(39, -100, 5_000_000));
|
||||
assert.equal(resolved.source, 'geocode');
|
||||
const outline = await resolved.resolveOutline();
|
||||
assert.equal(outline.adminArea, 'Austin');
|
||||
assert.ok(outline.polygons.length >= 4);
|
||||
assert.deepEqual(city.lookups, [], 'no boundary lookup');
|
||||
|
||||
const hood = harness({
|
||||
lat: 51.5118,
|
||||
lng: -0.2046,
|
||||
label: 'Notting Hill, London, UK',
|
||||
name: 'Notting Hill',
|
||||
types: ['neighborhood', 'political'],
|
||||
});
|
||||
const nottingHill = await hood.resolve(
|
||||
'Notting Hill',
|
||||
viewerAt(48, 10, 5_000_000),
|
||||
);
|
||||
const hoodOutline = await nottingHill.resolveOutline();
|
||||
assert.equal(hoodOutline.adminArea, 'Notting Hill');
|
||||
assert.deepEqual(hood.lookups, []);
|
||||
});
|
||||
|
||||
test('a district ask prefers a neighborhood; a place must hold the view', async () => {
|
||||
const { resolve, geocodes } = harness();
|
||||
// "Richmond" over SF's Richmond District: never Richmond, California.
|
||||
const richmond = await resolve('Richmond', viewerAt(37.78, -122.48, 3000), {
|
||||
entityKind: 'district',
|
||||
});
|
||||
assertBundledArea(richmond, 'Richmond District');
|
||||
assert.ok(Math.abs(richmond.lat - 37.78) < 0.05, 'not the East Bay city');
|
||||
// Omitted kind at a street-level view: the district in view, too.
|
||||
const bare = await resolve('Richmond', viewerAt(37.78, -122.48, 3000));
|
||||
assertBundledArea(bare, 'Richmond District');
|
||||
// Over the East Bay city, the city.
|
||||
const wide = await resolve('Richmond', viewerAt(37.94, -122.35, 40_000));
|
||||
assert.equal(wide?.source, 'bundled');
|
||||
assert.ok(wide.lat > 37.88, 'Richmond, California');
|
||||
assert.deepEqual(geocodes, []);
|
||||
});
|
||||
|
||||
test('landmark-named neighborhoods wait for the landmark ladder', async () => {
|
||||
const manhattan = viewerAt(40.78, -73.965, 3000);
|
||||
const cases = [
|
||||
['Central Park', {}],
|
||||
['Central Park', { entityKind: 'compound' }],
|
||||
['Central Park', { intent: 'around_the_thing' }],
|
||||
['Hyde Park', {}],
|
||||
['Notting Hill', { intent: 'around_the_thing' }],
|
||||
];
|
||||
for (const [target, extra] of cases) {
|
||||
const { resolve, geocodes } = harness();
|
||||
const viewer = target === 'Central Park' ? manhattan : LONDON;
|
||||
const resolved = await resolve(target, viewer, extra);
|
||||
assert.notEqual(
|
||||
resolved?.source,
|
||||
'bundled',
|
||||
`${target} ${JSON.stringify(extra)}`,
|
||||
);
|
||||
assert.ok(geocodes.length >= 1, `${target} is geocoded`);
|
||||
}
|
||||
// Named as a district, the WOF neighborhood answers.
|
||||
const { resolve } = harness();
|
||||
const park = await resolve('Central Park', manhattan, {
|
||||
entityKind: 'district',
|
||||
});
|
||||
assert.equal(park?.source, 'bundled');
|
||||
// "Park Slope" ends in no landmark word: still a neighborhood.
|
||||
const slope = await resolve('Park Slope', viewerAt(40.671, -73.98, 3000));
|
||||
assertBundledArea(slope, 'Park Slope');
|
||||
});
|
||||
|
||||
/** Outline rungs whose Nominatim answers `answer` (null: nothing found). */
|
||||
function rungsAnswering(answer) {
|
||||
const asked = [];
|
||||
return {
|
||||
asked,
|
||||
resolve: async (ctx, { base, finish }) => {
|
||||
asked.push({ scope: ctx.scope, target: ctx.target });
|
||||
const baseResult = await base();
|
||||
if (baseResult?.ring && !baseResult.synthesized) return baseResult;
|
||||
return answer ? finish(answer, ctx.scope) : baseResult;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const PARIS_PLACE = {
|
||||
lat: 48.8566,
|
||||
lng: 2.3522,
|
||||
label: 'Paris, France',
|
||||
name: 'Paris',
|
||||
types: ['locality', 'political'],
|
||||
};
|
||||
const square = (lat, lon, d) => [
|
||||
[lon - d, lat - d],
|
||||
[lon + d, lat - d],
|
||||
[lon + d, lat + d],
|
||||
[lon - d, lat + d],
|
||||
[lon - d, lat - d],
|
||||
];
|
||||
|
||||
test('a courtyard outline with no filled candidate anchor remains a point', async () => {
|
||||
const lat = 0.0005;
|
||||
const lon = 0.0005;
|
||||
const ring = square(lat, lon, 0.0005);
|
||||
const hole = square(lat, lon, 0.0002);
|
||||
const polygons = [[ring, hole]];
|
||||
const { resolve } = harness(null, {
|
||||
outlineRungs: rungsAnswering({
|
||||
ring,
|
||||
polygons,
|
||||
kind: 'building',
|
||||
heightM: 20,
|
||||
}),
|
||||
});
|
||||
const options = { latitude: lat, longitude: lon, entityKind: 'building' };
|
||||
const pending = await resolve(
|
||||
'this building',
|
||||
viewerAt(lat, lon, 1000),
|
||||
options,
|
||||
);
|
||||
const outline = await pending.resolveOutline();
|
||||
assert.equal(
|
||||
outline,
|
||||
null,
|
||||
'neither the mean nor the courtyard point is filled',
|
||||
);
|
||||
const inline = await resolve('this building', viewerAt(lat, lon, 1000), {
|
||||
...options,
|
||||
deferFootprint: false,
|
||||
});
|
||||
assert.equal(inline.ring, null);
|
||||
assert.deepEqual(
|
||||
[inline.lat, inline.lon],
|
||||
[lat, lon],
|
||||
'keep the honest point',
|
||||
);
|
||||
for (const resolved of [outline, inline]) {
|
||||
if (resolved?.polygons)
|
||||
assert.ok(polygonsContain(resolved.polygons, resolved.lat, resolved.lon));
|
||||
}
|
||||
});
|
||||
|
||||
test('a bent street keeps its outline when the anchor sits beside the centreline', async () => {
|
||||
// 500 m east then 500 m north: the ring mean lands off the 22 m ribbon.
|
||||
const lat0 = 30.27;
|
||||
const lon0 = -97.74;
|
||||
const dLon = 500 / (111320 * Math.cos((lat0 * Math.PI) / 180));
|
||||
const dLat = 500 / 111320;
|
||||
const line = [
|
||||
[lon0, lat0],
|
||||
[lon0 + dLon / 2, lat0],
|
||||
[lon0 + dLon, lat0],
|
||||
[lon0 + dLon, lat0 + dLat / 2],
|
||||
[lon0 + dLon, lat0 + dLat],
|
||||
];
|
||||
const footprint = streetFootprint({ lines: [line] });
|
||||
// A geocoded point 15 m north of the first leg, outside the ribbon.
|
||||
const lat = lat0 + 15 / 111320;
|
||||
const lon = lon0 + dLon / 2;
|
||||
assert.equal(polygonsContain(footprint.polygons, lat, lon), false);
|
||||
const { resolve } = harness(
|
||||
{ lat, lng: lon, name: 'Bent Street', label: 'Bent St', types: ['route'] },
|
||||
{ outlineRungs: rungsAnswering(footprint) },
|
||||
);
|
||||
const pending = await resolve('Bent Street', viewerAt(lat, lon, 3000), {
|
||||
entityKind: 'street',
|
||||
});
|
||||
const outline = await pending.resolveOutline();
|
||||
assert.equal(outline?.ring, footprint.ring, 'the street is still drawn');
|
||||
assert.ok(polygonsContain(outline.polygons, outline.lat, outline.lon));
|
||||
assert.ok(Math.abs(outline.lat - lat0) < 1e-7, 'centred on the street');
|
||||
assert.ok(Math.abs(outline.lon - lon) < 1e-7);
|
||||
// A same-name street that never comes near the anchor is not that street.
|
||||
const far = lat0 + 300 / 111320;
|
||||
const away = harness(
|
||||
{
|
||||
lat: far,
|
||||
lng: lon,
|
||||
name: 'Bent Street',
|
||||
label: 'Bent St',
|
||||
types: ['route'],
|
||||
},
|
||||
{ outlineRungs: rungsAnswering(footprint) },
|
||||
);
|
||||
const distant = await away.resolve('Bent Street', viewerAt(far, lon, 3000), {
|
||||
entityKind: 'street',
|
||||
});
|
||||
assert.equal((await distant.resolveOutline())?.ring ?? null, null);
|
||||
});
|
||||
|
||||
test('local dateline outlines share a frame for area, centering and filled anchors', async () => {
|
||||
const ring = [
|
||||
[179.9995, -0.0005],
|
||||
[-179.9995, -0.0005],
|
||||
[-179.9995, 0.0005],
|
||||
[179.9995, 0.0005],
|
||||
[179.9995, -0.0005],
|
||||
];
|
||||
const hole = [
|
||||
[179.9998, -0.0003],
|
||||
[-179.9998, -0.0003],
|
||||
[-179.9998, 0.0003],
|
||||
[179.9998, 0.0003],
|
||||
[179.9998, -0.0003],
|
||||
];
|
||||
const polygons = [[ring, hole]];
|
||||
const { resolve } = harness(null, {
|
||||
outlineRungs: rungsAnswering({
|
||||
ring,
|
||||
polygons,
|
||||
kind: 'building',
|
||||
heightM: 20,
|
||||
}),
|
||||
});
|
||||
for (const deferFootprint of [true, false]) {
|
||||
for (const [lat, lon, filled] of [
|
||||
[0.0004, 179.9996, true],
|
||||
[0, 179.99999, false],
|
||||
[0.0004, 0, false],
|
||||
]) {
|
||||
const result = await resolve('this building', viewerAt(lat, lon, 1000), {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
entityKind: 'building',
|
||||
deferFootprint,
|
||||
});
|
||||
const outline = deferFootprint ? await result.resolveOutline() : result;
|
||||
if (!filled) {
|
||||
assert.equal(outline?.ring ?? null, null);
|
||||
continue;
|
||||
}
|
||||
assert.equal(outline.ring, ring);
|
||||
assert.equal(outline.polygons, polygons);
|
||||
assert.deepEqual(
|
||||
[outline.lat, outline.lon],
|
||||
[lat, lon],
|
||||
'keep the filled shell anchor',
|
||||
);
|
||||
}
|
||||
}
|
||||
const plain = harness(null, {
|
||||
outlineRungs: rungsAnswering({ ring, kind: 'building', heightM: 20 }),
|
||||
});
|
||||
const pending = await plain.resolve(
|
||||
'this building',
|
||||
viewerAt(0, 179.9999, 1000),
|
||||
{
|
||||
latitude: 0,
|
||||
longitude: 179.9999,
|
||||
entityKind: 'building',
|
||||
},
|
||||
);
|
||||
const centered = await pending.resolveOutline();
|
||||
assert.equal(centered.ring, ring);
|
||||
assert.ok(
|
||||
Math.abs(centered.lon) > 179.999 &&
|
||||
centered.lon >= -180 &&
|
||||
centered.lon < 180,
|
||||
);
|
||||
});
|
||||
|
||||
test('a coarse bundled admin-1 city waits for a finer outline', async () => {
|
||||
const overParis = viewerAt(48.8566, 2.3522, 40_000);
|
||||
// The outer-ring vertex mean is in the hole; the resolved anchor is in
|
||||
// the shell. Completion must not move that valid point into the hole.
|
||||
const ring = square(48.8566, 2.3822, 0.04);
|
||||
const hole = square(48.8566, 2.3822, 0.02);
|
||||
const polygons = [[ring, hole]];
|
||||
const rungs = rungsAnswering({ ring, polygons, kind: 'area', heightM: null });
|
||||
const fine = harness(PARIS_PLACE, { outlineRungs: rungs });
|
||||
const resolved = await fine.resolve('Paris', overParis);
|
||||
assert.equal(
|
||||
resolved.source,
|
||||
'geocode',
|
||||
'the 11-vertex admin-1 is not drawn at once',
|
||||
);
|
||||
const outline = await resolved.resolveOutline();
|
||||
assert.deepEqual(outline.ring, ring, 'the finer outline wins');
|
||||
assert.deepEqual(outline.polygons, polygons);
|
||||
assert.equal(outline.lat, PARIS_PLACE.lat);
|
||||
assert.equal(outline.lon, PARIS_PLACE.lng);
|
||||
assert.ok(polygonsContain(outline.polygons, outline.lat, outline.lon));
|
||||
assert.deepEqual(rungs.asked, [{ scope: 'city', target: 'Paris' }]);
|
||||
const inline = await fine.resolve('Paris', overParis, {
|
||||
deferFootprint: false,
|
||||
});
|
||||
assert.deepEqual(inline.polygons, polygons);
|
||||
assert.equal(inline.lat, outline.lat);
|
||||
assert.equal(inline.lon, outline.lon);
|
||||
|
||||
// Nothing finer (Nominatim off, busy or capped): the bundled shape, not a point.
|
||||
const coarse = harness(PARIS_PLACE, { outlineRungs: rungsAnswering(null) });
|
||||
const fallback = await (
|
||||
await coarse.resolve('Paris', overParis)
|
||||
).resolveOutline();
|
||||
assert.equal(fallback.adminArea, 'Paris');
|
||||
assert.ok(fallback.ring.length < 32);
|
||||
|
||||
// No geocoder answer at all: the bundled shape at once.
|
||||
const offline = harness(null, { outlineRungs: rungsAnswering(null) });
|
||||
const direct = await offline.resolve('Paris', overParis);
|
||||
assert.equal(direct.source, 'bundled');
|
||||
assert.equal(direct.label, 'Paris');
|
||||
|
||||
const overDallas = viewerAt(32.7767, -96.797, 20_000);
|
||||
assert.equal(
|
||||
await offline.resolve('Paris', overDallas),
|
||||
null,
|
||||
'a missing geocode cannot select the distant coarse homonym',
|
||||
);
|
||||
assert.equal(
|
||||
await coarse.resolve('Paris', overDallas),
|
||||
null,
|
||||
'a rejected foreign geocode cannot restore the same distant polygon',
|
||||
);
|
||||
const outsideParis = harness(
|
||||
{ ...PARIS_PLACE, lat: 48.9, lng: 2.6 },
|
||||
{ outlineRungs: rungsAnswering(null) },
|
||||
);
|
||||
const anchored = await outsideParis.resolve('Paris', overParis);
|
||||
assert.equal(anchored.source, 'geocode');
|
||||
assert.equal(
|
||||
await anchored.resolveOutline(),
|
||||
undefined,
|
||||
'a nearby anchor outside Paris preserves the transient base, not its coarse polygon',
|
||||
);
|
||||
});
|
||||
|
||||
test('detailed admin-1 units and admin asks keep the bundled shape', async () => {
|
||||
const { resolve } = harness(null, { outlineRungs: rungsAnswering(null) });
|
||||
for (const [name, lat, lon] of [
|
||||
['Berlin', 52.52, 13.4],
|
||||
['Bavaria', 48.9, 11.4],
|
||||
['Texas', 31, -99],
|
||||
]) {
|
||||
const resolved = await resolve(name, viewerAt(lat, lon, 400_000));
|
||||
assert.equal(resolved?.source, 'bundled', name);
|
||||
}
|
||||
// Countries are never coarse cities, however few vertices.
|
||||
const singapore = await resolve('Singapore', viewerAt(1.35, 103.82, 60_000));
|
||||
assert.equal(singapore?.source, 'bundled');
|
||||
// An explicit admin ask takes the admin unit as it is.
|
||||
const dc = await resolve('Paris', viewerAt(48.8566, 2.3522, 40_000), {
|
||||
entityKind: 'state',
|
||||
});
|
||||
assert.equal(dc?.source, 'bundled');
|
||||
});
|
||||
|
||||
test('a campus ask reaches the campus, not the Census place of its town', async () => {
|
||||
const stanfordTown = {
|
||||
lat: 37.4241,
|
||||
lng: -122.1661,
|
||||
label: 'Stanford, CA, USA',
|
||||
name: 'Stanford',
|
||||
types: ['locality', 'political'],
|
||||
};
|
||||
const campus = square(37.4275, -122.1697, 0.01);
|
||||
for (const ask of ['Stanford University', 'Stanford campus']) {
|
||||
const rungs = rungsAnswering({ ring: campus, kind: 'area', heightM: null });
|
||||
const { resolve } = harness(stanfordTown, { outlineRungs: rungs });
|
||||
const resolved = await resolve(ask, viewerAt(37.43, -122.17, 8000));
|
||||
assert.notEqual(resolved?.source, 'bundled', ask);
|
||||
const outline = await resolved.resolveOutline();
|
||||
assert.deepEqual(outline.ring, campus, ask);
|
||||
assert.equal(
|
||||
outline.outlineCredit,
|
||||
null,
|
||||
'an injected outline is not inferred OSM',
|
||||
);
|
||||
assert.equal(
|
||||
rungs.asked[0].scope,
|
||||
'compound',
|
||||
`${ask}: past the Census rung`,
|
||||
);
|
||||
}
|
||||
// "Stanford" said to be a compound is the campus too.
|
||||
const kindRungs = rungsAnswering({
|
||||
ring: campus,
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
});
|
||||
const byKind = harness(stanfordTown, { outlineRungs: kindRungs });
|
||||
const compound = await (
|
||||
await byKind.resolve('Stanford', viewerAt(37.43, -122.17, 8000), {
|
||||
entityKind: 'compound',
|
||||
})
|
||||
).resolveOutline();
|
||||
assert.deepEqual(compound.ring, campus);
|
||||
assert.equal(kindRungs.asked[0].scope, 'compound');
|
||||
const labelRungs = rungsAnswering({
|
||||
ring: campus,
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
});
|
||||
const byLabel = harness(stanfordTown, { outlineRungs: labelRungs });
|
||||
const labelled = await byLabel.resolve(
|
||||
'Stanford',
|
||||
viewerAt(37.43, -122.17, 8000),
|
||||
{ labelHint: 'Stanford campus' },
|
||||
);
|
||||
assert.notEqual(labelled.source, 'bundled');
|
||||
assert.deepEqual((await labelled.resolveOutline()).ring, campus);
|
||||
assert.equal(labelRungs.asked[0].scope, 'compound');
|
||||
// "Stanford" alone is the town (the Stanford CDP), and a town named after a
|
||||
// college stays a town.
|
||||
const { resolve } = harness(stanfordTown, {
|
||||
outlineRungs: rungsAnswering(null),
|
||||
});
|
||||
const town = await (
|
||||
await resolve('Stanford', viewerAt(39, -100, 5_000_000))
|
||||
).resolveOutline();
|
||||
assert.equal(town.adminArea, 'Stanford');
|
||||
const station = harness(
|
||||
{
|
||||
lat: 30.628,
|
||||
lng: -96.3344,
|
||||
label: 'College Station, TX, USA',
|
||||
name: 'College Station',
|
||||
types: ['locality', 'political'],
|
||||
},
|
||||
{ outlineRungs: rungsAnswering(null) },
|
||||
);
|
||||
const collegeStation = await (
|
||||
await station.resolve('College Station', viewerAt(39, -100, 5_000_000))
|
||||
).resolveOutline();
|
||||
assert.equal(collegeStation.adminArea, 'College Station');
|
||||
});
|
||||
|
||||
test('a grounds ask never takes the state the geocoder typed', async () => {
|
||||
const texas = {
|
||||
lat: 31.0,
|
||||
lng: -99.0,
|
||||
label: 'Texas, USA',
|
||||
name: 'Texas',
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
};
|
||||
for (const [ask, extra] of [
|
||||
['the Texas Capitol grounds', {}],
|
||||
['Texas Capitol', { entityKind: 'compound' }],
|
||||
// The grounds wording is only in the label.
|
||||
['Texas Capitol', { labelHint: 'Capitol grounds' }],
|
||||
]) {
|
||||
const rungs = rungsAnswering(null);
|
||||
const { resolve } = harness(texas, { outlineRungs: rungs });
|
||||
const resolved = await resolve(ask, viewerAt(31, -99, 3_000_000), extra);
|
||||
assert.notEqual(resolved?.source, 'bundled', ask);
|
||||
const outline = await resolved.resolveOutline();
|
||||
assert.notEqual(outline?.adminArea, 'Texas', ask);
|
||||
assert.equal(rungs.asked[0].scope, 'compound', ask);
|
||||
}
|
||||
});
|
||||
|
||||
test('configured OSM geometry retains its origin through inline, deferred and cached base results', async () => {
|
||||
const ring = square(31, -99, 0.001);
|
||||
let calls = 0;
|
||||
const resolver = createAnnotationResolver({
|
||||
boundarySource: {
|
||||
query: async () => {
|
||||
calls++;
|
||||
return [
|
||||
{
|
||||
type: 'way',
|
||||
id: 1,
|
||||
tags: { name: 'Library', building: 'yes' },
|
||||
geometry: ring.map(([lon, lat]) => ({ lon, lat })),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
outlineRungs: { resolve: (_ctx, { base }) => base() },
|
||||
});
|
||||
for (const deferFootprint of [false, true]) {
|
||||
const result = await resolver.resolveAnnotationTarget({
|
||||
viewer: viewerAt(31, -99, 3000),
|
||||
latitude: 31,
|
||||
longitude: -99,
|
||||
target: 'Library',
|
||||
entityKind: 'building',
|
||||
footprint: true,
|
||||
deferFootprint,
|
||||
});
|
||||
const outline = deferFootprint ? await result.resolveOutline() : result;
|
||||
assert.equal(outline.footprintKind, 'building');
|
||||
assert.equal(outline.outlineCredit, 'osm');
|
||||
assert.deepEqual(outline.ring, ring);
|
||||
}
|
||||
assert.equal(calls, 1, 'cached geometry retains its original provider');
|
||||
});
|
||||
+418
-21
@@ -11,6 +11,7 @@ import {
|
||||
approximateAreaM2,
|
||||
approximateDistanceM,
|
||||
pointInPolygon,
|
||||
nearestPointOnLines,
|
||||
} from '../sources/featureGeometry.js';
|
||||
import { defaultGeospatial } from '../search/defaults.js';
|
||||
import * as Cesium from 'cesium';
|
||||
@@ -19,18 +20,84 @@ import {
|
||||
lookupNaturalRegionOutline,
|
||||
findNaturalRegion,
|
||||
} from '../data/naturalEarthRegions.js';
|
||||
import { findAdminArea, findAdminAreaAt } from '../data/adminBoundaries.js';
|
||||
import {
|
||||
findAdminArea,
|
||||
findAdminAreaAt,
|
||||
polygonsContain as polygonsContainPoint,
|
||||
} from '../data/adminBoundaries.js';
|
||||
import {
|
||||
findNeighborhoodArea,
|
||||
findNeighborhoodAreaAt,
|
||||
findPlaceArea,
|
||||
findPlaceAreaAt,
|
||||
NEIGHBORHOOD_NEAR_KM,
|
||||
normalizePlaceName,
|
||||
PLACE_NEAR_KM,
|
||||
} from '../data/placeBoundaries.js';
|
||||
import {
|
||||
registerDynamicCredit,
|
||||
NATURAL_EARTH_CREDIT,
|
||||
US_CENSUS_CREDIT,
|
||||
OSM_CREDIT,
|
||||
OPENMAPTILES_CREDIT,
|
||||
WOF_CREDIT,
|
||||
} from '../data/dataCredits.js';
|
||||
import { createOutlineRungs } from './outlineRungs.js';
|
||||
import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
||||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
|
||||
/** Prepare only local dateline outlines for planar admission and centering. */
|
||||
function localOutlineFrame(fp) {
|
||||
const original = { ring: fp?.ring, polygons: fp?.polygons, reference: null };
|
||||
if (!Array.isArray(fp?.ring) || fp.ring.length < 3) return original;
|
||||
let west = Infinity;
|
||||
let east = -Infinity;
|
||||
for (const [lon] of fp.ring) {
|
||||
west = Math.min(west, lon);
|
||||
east = Math.max(east, lon);
|
||||
}
|
||||
if (east - west <= 180) return original;
|
||||
const reference = fp.ring[0][0];
|
||||
west = Infinity;
|
||||
east = -Infinity;
|
||||
const shift = (ring) =>
|
||||
ring.map(([lon, lat]) => {
|
||||
const x = lon - 360 * Math.round((lon - reference) / 360);
|
||||
west = Math.min(west, x);
|
||||
east = Math.max(east, x);
|
||||
return [x, lat];
|
||||
});
|
||||
const ring = shift(fp.ring);
|
||||
const polygons = Array.isArray(fp.polygons)
|
||||
? fp.polygons.map((part) => part.map(shift))
|
||||
: fp.polygons;
|
||||
// Shapes still spanning half the globe retain their existing behavior. Source coordinates
|
||||
// are never rewritten, including holes and unrelated multipart geometry.
|
||||
if (!Number.isFinite(east - west) || east - west >= 180) return original;
|
||||
return { ring, polygons, reference };
|
||||
}
|
||||
|
||||
/** Ms the analyst region lookup waits on geocode + admin boundary. */
|
||||
const REGION_FALLBACK_BUDGET_MS = 3_000;
|
||||
|
||||
/** How far a street may run from its anchor when the ribbon holds neither. */
|
||||
const STREET_SNAP_MAX_M = 100;
|
||||
|
||||
/**
|
||||
* Names that end like a landmark rather than a district: "Central Park",
|
||||
* "Lake Merritt"-style suffixes are checked on the last word only, so "Park
|
||||
* Slope" and "Notting Hill" stay districts.
|
||||
*/
|
||||
const LANDMARK_NAME =
|
||||
/\b(?:park|gardens?|square|plaza|lake|river|bay|beach|pier|harbou?r|airport|station|stadium|arena|cemetery|zoo|university|college|campus|palace|castle|cathedral|church|temple|mosque|bridge|market|mall|cent(?:er|re)|tower|museum|reservoir|forest|woods?|heath|common|green)$/i;
|
||||
|
||||
/** The name part of an ask, before any comma qualifier. */
|
||||
function stripQualifiers(query) {
|
||||
return String(query || '')
|
||||
.split(',')[0]
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Entity facts that rule out a state/county reading of the ask. */
|
||||
const NON_ADMIN_ENTITY_KINDS = new Set([
|
||||
'building',
|
||||
@@ -47,8 +114,12 @@ export function createAnnotationResolver({
|
||||
boundarySource,
|
||||
signal: lifetime,
|
||||
}),
|
||||
// Overpass-free rungs (OpenFreeMap tiles, guarded Nominatim) around the ladder.
|
||||
outlineRungs = createOutlineRungs(),
|
||||
} = {}) {
|
||||
requireFeatureSource(featureSource);
|
||||
const featureOutlineCredit =
|
||||
featureSource.outlineCredit === 'osm' ? 'osm' : null;
|
||||
lifetime?.throwIfAborted();
|
||||
|
||||
/**
|
||||
@@ -164,6 +235,21 @@ export function createAnnotationResolver({
|
||||
// locality suffixes; the types classify the entity (point-like vs area-like).
|
||||
let placesPrimary = null;
|
||||
let placeTypes = [];
|
||||
// A coarse bundled admin-1 shape that carries a city's name (Natural
|
||||
// Earth's 11-vertex "Paris"): kept as the fallback while the finer rungs
|
||||
// (Census, Nominatim) try first.
|
||||
let coarseCityShape = null;
|
||||
let bundledCenter = null;
|
||||
const coarseCityHolds = (point) =>
|
||||
coarseCityShape &&
|
||||
point &&
|
||||
polygonsContainPoint(coarseCityShape.polygons, point.lat, point.lon);
|
||||
const compoundAsk =
|
||||
entityKind === 'compound' ||
|
||||
isCampusAsk(target) ||
|
||||
isGroundsLikeQuery(target) ||
|
||||
isGroundsLikeQuery(labelHint);
|
||||
const askNamesAdmin = Boolean(adminScopeFromAsk(target, entityKind));
|
||||
// Instrumentation (logged once per target at the end): which sources were tried and what they returned.
|
||||
const trace = {
|
||||
query: String(target || '').trim(),
|
||||
@@ -181,15 +267,32 @@ export function createAnnotationResolver({
|
||||
footprint &&
|
||||
(!Number.isFinite(lat) || !Number.isFinite(lon)) &&
|
||||
trace.query &&
|
||||
!compoundAsk &&
|
||||
!NON_ADMIN_ENTITY_KINDS.has(entityKind)
|
||||
) {
|
||||
const center =
|
||||
pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
|
||||
bundledCenter = center;
|
||||
const admin = await findAdminArea(trace.query, { near: center }).catch(
|
||||
() => null,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (admin) return bundledAdminTarget(viewer, admin, trace.query);
|
||||
if (admin && !askNamesAdmin && isCoarseCityShape(admin))
|
||||
coarseCityShape = admin;
|
||||
else if (admin)
|
||||
return bundledAdminTarget(viewer, admin, trace.query, signal);
|
||||
// Bundled US places and neighborhoods named at or near the view. A
|
||||
// campus, grounds or monument ask ("Stanford" as a compound, "the
|
||||
// Capitol grounds") wants that feature, not a district of its name,
|
||||
// and "the area around X" is drawn by the ladder below.
|
||||
if (intent !== 'around_the_thing' && !isMonumentLikeQuery(trace.query)) {
|
||||
const area = await bundledAreaNear(trace.query, center, {
|
||||
entityKind,
|
||||
viewRadiusKm: viewportProximity(viewer)?.radiusKm ?? null,
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
if (area) return bundledAdminTarget(viewer, area, trace.query, signal);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
@@ -317,7 +420,10 @@ export function createAnnotationResolver({
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon))
|
||||
return coarseCityHolds(bundledCenter)
|
||||
? bundledAdminTarget(viewer, coarseCityShape, trace.query, signal)
|
||||
: null;
|
||||
|
||||
// Monument/memorial/statue names geocode unreliably — Google scatters them across the city (e.g.
|
||||
// several Texas Capitol monuments landed blocks-to-miles apart, looking "all over the map"). When
|
||||
@@ -382,7 +488,9 @@ export function createAnnotationResolver({
|
||||
`osmSnap=${trace.osmSnap} → FINAL source=rejected (proximity gate, ${drift.driftKm.toFixed(0)}km > ${drift.limitKm.toFixed(0)}km)`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
return coarseCityHolds(bundledCenter)
|
||||
? bundledAdminTarget(viewer, coarseCityShape, trace.query, signal)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,7 +510,18 @@ export function createAnnotationResolver({
|
||||
// an admin boundary for a city/state, the enclosing compound for a mall/campus, a single
|
||||
// building for a premise. The voice model's entityKind (an entity FACT) refines an
|
||||
// unresolved 'auto' scope only — real geocode types always win.
|
||||
const baseScope = refineScope(scopeFromTypes(geocodeTypes), entityKind);
|
||||
// A campus or grounds ask ("Stanford University", "Stanford campus",
|
||||
// "the Capitol grounds", in the target or its label) or a compound by
|
||||
// entity fact wants that compound even when the geocoder types the town
|
||||
// or the state: never a Census place, neighborhood or admin boundary.
|
||||
const typedScope = refineScope(scopeFromTypes(geocodeTypes), entityKind);
|
||||
const baseScope =
|
||||
compoundAsk &&
|
||||
['country', 'state', 'county', 'city', 'neighborhood'].includes(
|
||||
typedScope,
|
||||
)
|
||||
? 'compound'
|
||||
: typedScope;
|
||||
// Point-like targets (monuments/statues/memorials/…) resolve POINT-FIRST: only an
|
||||
// (almost) exactly-named, monument-scale polygon may replace the point; a nearby polygon
|
||||
// sharing locality words must not (docs/field-test-rootcause-2026-06-30.md §1).
|
||||
@@ -431,7 +550,7 @@ export function createAnnotationResolver({
|
||||
* The inline path awaits it right here; progressive callers (deferFootprint) invoke it
|
||||
* AFTER the anchor mark is drawn and upgrade the mark in place.
|
||||
*/
|
||||
const resolveOutline = async () => {
|
||||
const resolveBaseOutline = async () => {
|
||||
signal?.throwIfAborted();
|
||||
let scope = baseScope;
|
||||
const isAdmin =
|
||||
@@ -461,6 +580,7 @@ export function createAnnotationResolver({
|
||||
signal?.throwIfAborted();
|
||||
if (ne) {
|
||||
registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT);
|
||||
traceBundledOutline(ne.name, 'natural-earth');
|
||||
const neCentroid = ringCentroid(ne.ring);
|
||||
return {
|
||||
ring: ne.ring,
|
||||
@@ -516,7 +636,20 @@ export function createAnnotationResolver({
|
||||
lon,
|
||||
scope,
|
||||
).catch(() => null);
|
||||
if (admin) return bundledAdminOutline(viewer, admin);
|
||||
signal?.throwIfAborted();
|
||||
if (admin && !askNamesAdmin && isCoarseCityShape(admin))
|
||||
coarseCityShape ||= admin;
|
||||
else if (admin) return bundledAdminOutline(viewer, admin, signal);
|
||||
}
|
||||
// A geocoded US city, town or CDP: the Census place that holds it.
|
||||
if (scope === 'city') {
|
||||
const place = await findPlaceAreaAt(
|
||||
[target, matchName],
|
||||
lat,
|
||||
lon,
|
||||
).catch(() => null);
|
||||
signal?.throwIfAborted();
|
||||
if (place) return bundledAdminOutline(viewer, place, signal);
|
||||
}
|
||||
// Pure admin: only an admin boundary is correct — never fall back to a
|
||||
// building/landuse (a city is never a single building).
|
||||
@@ -527,7 +660,21 @@ export function createAnnotationResolver({
|
||||
// resolve here instantly to a REAL boundary, sidestepping the slow/flaky live-Overpass
|
||||
// path that times out and falls back to points (see docs/field-test-2-analysis.md).
|
||||
const ext = await lookupNeighborhoodRing(lat, lon, matchName);
|
||||
if (ext) fp = { ring: ext.ring, kind: 'area', heightM: null };
|
||||
signal?.throwIfAborted();
|
||||
if (ext) {
|
||||
traceBundledOutline(ext.name || matchName, 'datasf');
|
||||
fp = { ring: ext.ring, kind: 'area', heightM: null };
|
||||
}
|
||||
// THEN a bundled Who's On First neighborhood carrying the name at the point.
|
||||
if (!fp) {
|
||||
const wof = await findNeighborhoodAreaAt(
|
||||
[target, matchName],
|
||||
lat,
|
||||
lon,
|
||||
).catch(() => null);
|
||||
signal?.throwIfAborted();
|
||||
if (wof) return bundledAdminOutline(viewer, wof, signal);
|
||||
}
|
||||
// Else fall through to the OSM admin/place → named-landuse → synthesis ladder. Each
|
||||
// returns a footprint, null (definitively no polygon), or undefined (transient
|
||||
// upstream failure). Synthesize a blob (the "Mission" problem, research §3b) ONLY
|
||||
@@ -653,6 +800,12 @@ export function createAnnotationResolver({
|
||||
}
|
||||
}
|
||||
}
|
||||
return finishOutline(fp, scope);
|
||||
};
|
||||
|
||||
/** Validate a footprint for its scope and re-centre it (resolveOutline's patch). */
|
||||
const finishOutline = (fp, scope) => {
|
||||
const frame = localOutlineFrame(fp);
|
||||
// Scope sanity: reject a real footprint whose area is wildly wrong for the asked
|
||||
// scope (a building/compound/neighborhood intent must never draw a state-sized
|
||||
// blob). Synthesized discs are deliberately sized and exempt.
|
||||
@@ -661,15 +814,44 @@ export function createAnnotationResolver({
|
||||
!fp.synthesized &&
|
||||
Array.isArray(fp.ring) &&
|
||||
fp.ring.length >= 3 &&
|
||||
exceedsScopeArea(fp, scope)
|
||||
exceedsScopeArea(frame, scope)
|
||||
) {
|
||||
fp = null;
|
||||
}
|
||||
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);
|
||||
let centroid = ringCentroid(frame.ring);
|
||||
if (!centroid) return null;
|
||||
// An outer-ring mean can lie in a hole or outside a concave part. Keep
|
||||
// a filled original anchor or decline, without changing ordinary centering
|
||||
// or inventing a new interior point.
|
||||
if (
|
||||
Array.isArray(frame.polygons) &&
|
||||
!polygonsContainPoint(frame.polygons, centroid.lat, centroid.lon)
|
||||
) {
|
||||
const anchorLon =
|
||||
frame.reference === null
|
||||
? lon
|
||||
: lon - 360 * Math.round((lon - frame.reference) / 360);
|
||||
if (polygonsContainPoint(frame.polygons, lat, anchorLon)) {
|
||||
centroid = { lat, lon };
|
||||
} else if (Array.isArray(fp.lines)) {
|
||||
// A bent street's ribbon rarely holds its ring mean, and a geocoded
|
||||
// anchor can sit beside the centreline: centre on the street itself,
|
||||
// only when it runs beside the anchor.
|
||||
const onStreet = nearestPointOnLines(fp.lines, lat, lon);
|
||||
if (!onStreet || onStreet.distanceM > STREET_SNAP_MAX_M) return null;
|
||||
centroid = { lat: onStreet.lat, lon: onStreet.lon };
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (
|
||||
frame.reference !== null &&
|
||||
(centroid.lon < -180 || centroid.lon >= 180)
|
||||
)
|
||||
centroid.lon = ((((centroid.lon + 180) % 360) + 360) % 360) - 180;
|
||||
// Same drift bound as the anchor gate: a polygon whose centroid would drag a good
|
||||
// anchor out of plausibility is the wrong feature — drop it, keep the point.
|
||||
if (
|
||||
@@ -683,17 +865,57 @@ export function createAnnotationResolver({
|
||||
footprintKind: fp.kind,
|
||||
buildingHeight: fp.heightM,
|
||||
synthesized: Boolean(fp.synthesized),
|
||||
outlineCredit: fp.outlineCredit || null,
|
||||
...(Array.isArray(fp.polygons) ? { polygons: fp.polygons } : {}),
|
||||
lat: centroid.lat,
|
||||
lon: centroid.lon,
|
||||
height: sampleGroundHeight(viewer, centroid.lon, centroid.lat),
|
||||
};
|
||||
};
|
||||
|
||||
// Streets and pointed-at buildings from tiles, then this ladder, then the
|
||||
// guarded Nominatim outline (see outlineRungs.js).
|
||||
const outlineContext = {
|
||||
scope: baseScope,
|
||||
target,
|
||||
matchName,
|
||||
lat,
|
||||
lon,
|
||||
fromName: fromGeocode || source === 'places' || source === 'osm-local',
|
||||
pointLike,
|
||||
groundsLike,
|
||||
around: intent === 'around_the_thing',
|
||||
view: viewportProximity(viewer),
|
||||
signal,
|
||||
credit: (kind) =>
|
||||
registerDynamicCredit(
|
||||
viewer,
|
||||
kind === 'tiles' ? OPENMAPTILES_CREDIT : OSM_CREDIT,
|
||||
),
|
||||
};
|
||||
const resolveOutline = async () => {
|
||||
const result = await outlineRungs.resolve(outlineContext, {
|
||||
base: resolveBaseOutline,
|
||||
finish: finishOutline,
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
// Nothing finer answered (no polygon, Nominatim off, busy or capped):
|
||||
// the coarse bundled shape beats a point.
|
||||
if (
|
||||
coarseCityHolds({ lat, lon }) &&
|
||||
!(result?.ring && !result.synthesized)
|
||||
)
|
||||
return bundledAdminOutline(viewer, coarseCityShape, signal);
|
||||
return result;
|
||||
};
|
||||
|
||||
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
|
||||
let polygons = null; // every part of a multi-part outline, when known
|
||||
let outlineCredit = null;
|
||||
if (footprint && !deferFootprint) {
|
||||
const fp = await resolveOutline();
|
||||
outlineUnavailable = isUnavailableCapability(fp);
|
||||
@@ -702,6 +924,8 @@ export function createAnnotationResolver({
|
||||
footprintKind = fp.footprintKind;
|
||||
buildingHeight = fp.buildingHeight;
|
||||
synthesized = fp.synthesized;
|
||||
polygons = fp.polygons || null;
|
||||
outlineCredit = fp.outlineCredit || null;
|
||||
// Re-center the anchor on the resolved footprint centroid.
|
||||
lat = fp.lat;
|
||||
lon = fp.lon;
|
||||
@@ -729,6 +953,8 @@ export function createAnnotationResolver({
|
||||
source,
|
||||
synthesized,
|
||||
outlineUnavailable,
|
||||
outlineCredit,
|
||||
...(polygons ? { polygons } : {}),
|
||||
viewport: placeViewport,
|
||||
...(footprint && deferFootprint ? { resolveOutline } : {}),
|
||||
};
|
||||
@@ -768,13 +994,110 @@ export function createAnnotationResolver({
|
||||
const GROUNDS_RADIUS_M = 300; // "X grounds/compound/campus" loose disc when OSM has no polygon
|
||||
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
|
||||
const COARSE_CITY_MAX_KM2 = 5_000; // city-sized admin-1 units (see isCoarseCityShape)
|
||||
const COARSE_CITY_MAX_VERTICES = 32;
|
||||
|
||||
/** Credit the pack a bundled boundary came from. */
|
||||
function creditAdminSource(viewer, admin) {
|
||||
registerDynamicCredit(
|
||||
viewer,
|
||||
admin.source === 'us-census' ? US_CENSUS_CREDIT : NATURAL_EARTH_CREDIT,
|
||||
);
|
||||
const credit = {
|
||||
'natural-earth': NATURAL_EARTH_CREDIT,
|
||||
'us-census': US_CENSUS_CREDIT,
|
||||
wof: WOF_CREDIT,
|
||||
}[admin.source];
|
||||
if (credit) registerDynamicCredit(viewer, credit);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bundled US place or neighborhood an ask names at or near the view,
|
||||
* from Census places, the San Francisco DataSF pack (first in SF), then
|
||||
* Who's On First. How far "near" reaches follows the view (half its
|
||||
* radius); a state-qualified place ("Springfield, Illinois") needs no
|
||||
* view. A district ask takes a neighborhood first and a place only when
|
||||
* the view is inside it ("Richmond" over SF is not Richmond, CA). Without
|
||||
* an entity kind the candidate that holds the view wins, else the nearer.
|
||||
* A name that ends like a landmark ("Central Park", "Lake Merritt") is
|
||||
* left to the landmark ladder unless the ask is a district.
|
||||
*/
|
||||
async function bundledAreaNear(
|
||||
query,
|
||||
center,
|
||||
{ entityKind = null, viewRadiusKm = null } = {},
|
||||
) {
|
||||
const district = entityKind === 'district';
|
||||
if (!district && LANDMARK_NAME.test(stripQualifiers(query))) return null;
|
||||
const reach = Number.isFinite(viewRadiusKm) ? viewRadiusKm / 2 : null;
|
||||
const clamp = (km, lo, hi) =>
|
||||
km === null ? hi : Math.min(hi, Math.max(lo, km));
|
||||
const placeReach = district ? 0 : clamp(reach, 3, PLACE_NEAR_KM);
|
||||
const place = await findPlaceArea(query, {
|
||||
near: center,
|
||||
nearKm: placeReach,
|
||||
}).catch(() => null);
|
||||
const holds = (area) =>
|
||||
Boolean(center) &&
|
||||
polygonsContainPoint(area.polygons, center.lat, center.lon);
|
||||
// A district ask only takes a place the view is inside (or one a state
|
||||
// qualifier named outright).
|
||||
const placeOk =
|
||||
place && (!district || holds(place) || /,/.test(query)) ? place : null;
|
||||
if (!center) return placeOk;
|
||||
let hood = null;
|
||||
const sf = await lookupNeighborhoodRing(center.lat, center.lon, query, {
|
||||
exact: true,
|
||||
}).catch(() => null);
|
||||
if (sf) hood = sfNeighborhoodArea(sf);
|
||||
else
|
||||
hood = await findNeighborhoodArea(query, {
|
||||
near: center,
|
||||
nearKm: clamp(reach, 1, NEIGHBORHOOD_NEAR_KM),
|
||||
}).catch(() => null);
|
||||
// Outside a district ask, an alternate name unrelated to the name is too
|
||||
// weak: Se in Sao Paulo lists "Sao Paulo" among its names, and "outline
|
||||
// Sao Paulo" means the city ("Richmond" for Richmond District still counts).
|
||||
// Whole words: an alias hit on "York" does not keep "Yorkville".
|
||||
if (
|
||||
!district &&
|
||||
hood?.byAlias &&
|
||||
!` ${normalizePlaceName(hood.name)} `.includes(
|
||||
` ${normalizePlaceName(stripQualifiers(query))} `,
|
||||
)
|
||||
)
|
||||
hood = null;
|
||||
if (!hood || !placeOk) return district ? hood || placeOk : placeOk || hood;
|
||||
if (district) return hood;
|
||||
const hoodHolds = holds(hood);
|
||||
const placeHolds = holds(placeOk);
|
||||
if (hoodHolds !== placeHolds) return hoodHolds ? hood : placeOk;
|
||||
const distance = (area) =>
|
||||
approximateDistanceM(
|
||||
center.lat,
|
||||
center.lon,
|
||||
area.label.lat,
|
||||
area.label.lon,
|
||||
);
|
||||
return distance(hood) <= distance(placeOk) ? hood : placeOk;
|
||||
}
|
||||
|
||||
/** An SF DataSF ring in the bundled-area shape `bundledAdminTarget` takes. */
|
||||
function sfNeighborhoodArea({ ring, name }) {
|
||||
const lons = ring.map((p) => p[0]);
|
||||
const lats = ring.map((p) => p[1]);
|
||||
return {
|
||||
kind: 'neighborhood',
|
||||
name,
|
||||
region: null,
|
||||
source: 'datasf',
|
||||
polygons: [[ring]],
|
||||
ring,
|
||||
bbox: [
|
||||
Math.min(...lons),
|
||||
Math.min(...lats),
|
||||
Math.max(...lons),
|
||||
Math.max(...lats),
|
||||
],
|
||||
label: ringCentroid(ring),
|
||||
candidates: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -784,8 +1107,10 @@ export function createAnnotationResolver({
|
||||
* 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) {
|
||||
function bundledAdminOutline(viewer, admin, signal) {
|
||||
signal?.throwIfAborted();
|
||||
creditAdminSource(viewer, admin);
|
||||
traceBundledOutline(admin.name, admin.source, admin.polygons?.length);
|
||||
const { lat, lon } = admin.label;
|
||||
return {
|
||||
ring: closeRing([...admin.ring]), // copy: closeRing mutates, the pack is shared
|
||||
@@ -800,9 +1125,34 @@ export function createAnnotationResolver({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a bundled unit is too coarse to stand for a city of its name: a
|
||||
* Natural Earth admin-1 at city size (up to 5,000 km²) drawn with fewer
|
||||
* than 32 vertices. Paris (11), Buenos Aires (11), Washington DC (9),
|
||||
* Vienna and Prague (23) and Seoul (28) are; Berlin (48), Hamburg (43),
|
||||
* Bremen (39) and every larger state are not. Countries never are.
|
||||
*/
|
||||
function isCoarseCityShape(admin) {
|
||||
if (admin?.source !== 'natural-earth' || admin.kind !== 'state')
|
||||
return false;
|
||||
if (!(admin.areaKm2 <= COARSE_CITY_MAX_KM2)) return false;
|
||||
const vertices = (admin.polygons || []).reduce(
|
||||
(n, poly) => n + poly.reduce((m, ring) => m + ring.length, 0),
|
||||
0,
|
||||
);
|
||||
return vertices < COARSE_CITY_MAX_VERTICES;
|
||||
}
|
||||
|
||||
/** One trace line per outline a bundled pack answered (same shape as outlineRungs). */
|
||||
function traceBundledOutline(name, source, parts = 1) {
|
||||
console.log(
|
||||
`[Outline] "${name}": bundled → ${source} (${parts || 1} part(s))`,
|
||||
);
|
||||
}
|
||||
|
||||
/** A complete resolved target for a bundled administrative unit (no outline pending). */
|
||||
function bundledAdminTarget(viewer, admin, query) {
|
||||
const outline = bundledAdminOutline(viewer, admin);
|
||||
function bundledAdminTarget(viewer, admin, query, signal) {
|
||||
const outline = bundledAdminOutline(viewer, admin, signal);
|
||||
const [west, south, east, north] = admin.bbox;
|
||||
console.log(
|
||||
`[Resolver] "${query}": bundled ${admin.kind} "${admin.name}"` +
|
||||
@@ -1229,7 +1579,12 @@ export function createAnnotationResolver({
|
||||
? 40
|
||||
: 120;
|
||||
ring = simplifyRing(ring, tolM);
|
||||
const fp = { ring, kind: 'area', heightM: null };
|
||||
const fp = {
|
||||
ring,
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
outlineCredit: featureOutlineCredit,
|
||||
};
|
||||
|
||||
// Scope sanity BEFORE caching: a city-suffixed neighborhood name ("Downtown San
|
||||
// Francisco") can match the CITY admin. Caching an oversized ring would poison the
|
||||
@@ -1313,7 +1668,14 @@ export function createAnnotationResolver({
|
||||
bestRing = closeRing(coords.map((p) => [p.lon, p.lat]));
|
||||
}
|
||||
}
|
||||
return bestRing ? { ring: bestRing, kind: 'area', heightM: null } : null;
|
||||
return bestRing
|
||||
? {
|
||||
ring: bestRing,
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
outlineCredit: featureOutlineCredit,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1356,7 +1718,12 @@ export function createAnnotationResolver({
|
||||
}
|
||||
}
|
||||
if (bestRing) {
|
||||
const fp = { ring: bestRing, kind: 'area', heightM: null };
|
||||
const fp = {
|
||||
ring: bestRing,
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
outlineCredit: featureOutlineCredit,
|
||||
};
|
||||
cacheWrite(footprintCache, cacheKey, fp);
|
||||
return fp;
|
||||
}
|
||||
@@ -1387,6 +1754,7 @@ export function createAnnotationResolver({
|
||||
ring: bufferCorridor(line, 11),
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
outlineCredit: featureOutlineCredit,
|
||||
};
|
||||
cacheWrite(footprintCache, cacheKey, fp);
|
||||
return fp;
|
||||
@@ -1525,7 +1893,12 @@ export function createAnnotationResolver({
|
||||
areaM2 < bestArea * 0.999 ||
|
||||
(areaM2 <= bestArea * 1.05 && nameMatch && !bestNameMatch); // near-tie: name-match wins
|
||||
if (better) {
|
||||
best = { ring: ringLonLat, kind: 'area', heightM: null };
|
||||
best = {
|
||||
ring: ringLonLat,
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
outlineCredit: featureOutlineCredit,
|
||||
};
|
||||
bestArea = areaM2;
|
||||
bestNameMatch = nameMatch;
|
||||
}
|
||||
@@ -1551,6 +1924,19 @@ export function createAnnotationResolver({
|
||||
/** A target that reads like an enclosing GROUNDS / COMPOUND / CAMPUS (e.g. "Texas Capitol grounds")
|
||||
* rather than a single building — used to synthesize a loose-footprint disc when OSM has no real
|
||||
* polygon, instead of failing outright. */
|
||||
/**
|
||||
* A university, college or campus named as such: "Stanford University",
|
||||
* "Boston College", "University of Texas", "Stanford campus". Towns named
|
||||
* after one ("College Station", "University Park") are not.
|
||||
*/
|
||||
function isCampusAsk(query) {
|
||||
const text = stripQualifiers(String(query || '')).trim();
|
||||
return (
|
||||
/\b(?:university|college|campus)$/i.test(text) ||
|
||||
/^(?:the\s+)?(?:university|college)\s+of\b/i.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
function isGroundsLikeQuery(query) {
|
||||
return /\b(grounds|compound|campus|complex|quad|plaza)\b/i.test(
|
||||
String(query || ''),
|
||||
@@ -1774,6 +2160,7 @@ export function createAnnotationResolver({
|
||||
best = {
|
||||
ring: closeRing(coords.map((p) => [p.lon, p.lat])),
|
||||
kind: isBuilding ? 'building' : 'area',
|
||||
outlineCredit: featureOutlineCredit,
|
||||
heightM: isBuilding
|
||||
? (element.heightM ??
|
||||
Math.max(10, Math.min(70, Math.sqrt(Math.max(1, areaM2)) * 0.6)))
|
||||
@@ -2097,6 +2484,16 @@ export function createAnnotationResolver({
|
||||
if (admin)
|
||||
return { name: q, ring: [...admin.ring], source: 'bundled-admin' };
|
||||
}
|
||||
if (scope === 'city') {
|
||||
const place = await findPlaceAreaAt(
|
||||
[q, geo.primaryName],
|
||||
geo.lat,
|
||||
geo.lon,
|
||||
).catch(() => null);
|
||||
signal?.throwIfAborted();
|
||||
if (place)
|
||||
return { name: q, ring: [...place.ring], source: 'bundled-admin' };
|
||||
}
|
||||
const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,164 +1,177 @@
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
|
||||
import ScreenAnnotations from '../ui/views/panels/ScreenAnnotations.vue';
|
||||
import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
|
||||
|
||||
class FakeClassList {
|
||||
constructor(owner) {
|
||||
this.owner = owner;
|
||||
this.names = new Set();
|
||||
}
|
||||
|
||||
reset(value) {
|
||||
this.names = new Set(String(value || '').split(/\s+/).filter(Boolean));
|
||||
}
|
||||
|
||||
add(...names) {
|
||||
for (const name of names) this.names.add(name);
|
||||
this.owner.attributes.set('class', Array.from(this.names).join(' '));
|
||||
}
|
||||
|
||||
remove(...names) {
|
||||
for (const name of names) this.names.delete(name);
|
||||
this.owner.attributes.set('class', Array.from(this.names).join(' '));
|
||||
}
|
||||
|
||||
contains(name) {
|
||||
return this.names.has(name);
|
||||
}
|
||||
// happy-dom does not lay out SVG; every element measures as a 40 x 16 box.
|
||||
for (const type of [globalThis.SVGElement, globalThis.SVGGraphicsElement]) {
|
||||
if (type) type.prototype.getBBox = () => ({ x: 0, y: 0, width: 40, height: 16 });
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName) {
|
||||
this.tagName = tagName;
|
||||
this.attributes = new Map();
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.style = {};
|
||||
this.classList = new FakeClassList(this);
|
||||
this.textContent = '';
|
||||
}
|
||||
const settle = async () => {
|
||||
for (let flush = 0; flush < 4; flush += 1) await nextTick();
|
||||
};
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
if (name === 'class') this.classList.reset(value);
|
||||
}
|
||||
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name) ?? null;
|
||||
}
|
||||
|
||||
set className(value) {
|
||||
this.setAttribute('class', value);
|
||||
}
|
||||
|
||||
get className() {
|
||||
return this.getAttribute('class') || '';
|
||||
}
|
||||
|
||||
set id(value) {
|
||||
this.setAttribute('id', value);
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.getAttribute('id') || '';
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
child.parentNode = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
removeChild(child) {
|
||||
const index = this.children.indexOf(child);
|
||||
if (index >= 0) this.children.splice(index, 1);
|
||||
child.parentNode = null;
|
||||
return child;
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.parentNode?.removeChild(this);
|
||||
}
|
||||
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
|
||||
querySelectorAll(selector) {
|
||||
const className = selector.startsWith('.') ? selector.slice(1) : null;
|
||||
const matches = [];
|
||||
const visit = (node) => {
|
||||
for (const child of node.children) {
|
||||
if (className && child.classList.contains(className)) matches.push(child);
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
visit(this);
|
||||
return matches;
|
||||
}
|
||||
|
||||
addEventListener() {}
|
||||
|
||||
getBBox() {
|
||||
return { x: 0, y: 0, width: 40, height: 16 };
|
||||
}
|
||||
|
||||
set innerHTML(value) {
|
||||
this._innerHTML = value;
|
||||
}
|
||||
}
|
||||
|
||||
function fakeDocument() {
|
||||
const head = new FakeElement('head');
|
||||
const body = new FakeElement('body');
|
||||
const roots = [head, body];
|
||||
return {
|
||||
head,
|
||||
body,
|
||||
createElement: (tag) => new FakeElement(tag),
|
||||
createElementNS: (_namespace, tag) => new FakeElement(tag),
|
||||
getElementById(id) {
|
||||
let found = null;
|
||||
const visit = (node) => {
|
||||
if (node.getAttribute('id') === id) found = node;
|
||||
for (const child of node.children) visit(child);
|
||||
};
|
||||
for (const root of roots) visit(root);
|
||||
return found;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function findAnnotationGroup(document) {
|
||||
const layer = document.body.children.find((child) => child.classList.contains('gev-screen-whiteboard'));
|
||||
const svg = layer.children.find((child) => child.classList.contains('gev-screen-whiteboard-svg'));
|
||||
return {
|
||||
svg,
|
||||
group: svg.children.find((child) => child.classList.contains('gev-anno')),
|
||||
};
|
||||
}
|
||||
|
||||
test('outline upgrade preserves the existing SVG group identity', (t) => {
|
||||
const originalDocument = globalThis.document;
|
||||
/** Mount the board's view; animation frames run when `frames()` is called. */
|
||||
function board(t) {
|
||||
document.body.replaceChildren();
|
||||
const pinia = createPinia();
|
||||
const store = useScreenAnnotationsStore(pinia);
|
||||
const app = createApp(ScreenAnnotations).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
const queued = [];
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||
globalThis.requestAnimationFrame = (callback) => queued.push(callback);
|
||||
t.after(() => {
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
app.unmount();
|
||||
});
|
||||
const frames = () => {
|
||||
for (const callback of queued.splice(0)) callback();
|
||||
};
|
||||
const svg = () => document.querySelector('.gev-screen-whiteboard-svg');
|
||||
const groups = () => [...(svg()?.querySelectorAll('g.gev-anno') ?? [])];
|
||||
return { store, frames, svg, groups };
|
||||
}
|
||||
|
||||
function cameraOver(lon, lat) {
|
||||
const positionWC = Cesium.Cartesian3.fromDegrees(lon, lat, 1000);
|
||||
const directionWC = Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
// Low enough that point marks keep their full size.
|
||||
return { positionWC, directionWC, positionCartographic: { height: 500 } };
|
||||
}
|
||||
|
||||
function sceneFor(camera, extra = {}) {
|
||||
return {
|
||||
camera,
|
||||
canvas: { clientWidth: 800, clientHeight: 600, width: 800, height: 600 },
|
||||
clampToHeightSupported: false,
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** Project every point to the given window position for the test's length. */
|
||||
function projectTo(t, at) {
|
||||
const original = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = typeof at === 'function' ? at : () => at;
|
||||
t.after(() => {
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = original;
|
||||
});
|
||||
}
|
||||
|
||||
test('a pin renders its reticle, leader and callout on the page board', async (t) => {
|
||||
const { store, frames, svg, groups } = board(t);
|
||||
projectTo(t, { x: 200, y: 300 });
|
||||
const camera = cameraOver(0, 0);
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene: sceneFor(camera), camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
renderer.add({
|
||||
id: 'pin',
|
||||
type: 'pin',
|
||||
color: 'amber',
|
||||
label: 'Capitol',
|
||||
alpha: 1,
|
||||
anchor: { lon: 0, lat: 0, height: 0 },
|
||||
});
|
||||
await settle();
|
||||
const layer = document.body.querySelector(':scope > .gev-screen-whiteboard');
|
||||
assert.ok(layer);
|
||||
assert.equal(svg().getAttribute('preserveAspectRatio'), 'none');
|
||||
assert.equal(svg().getAttribute('viewBox'), '0 0 800 600');
|
||||
assert.ok(svg().querySelector('defs > filter#gev-sketch > feTurbulence'));
|
||||
const [group] = groups();
|
||||
assert.equal(group.getAttribute('class'), 'gev-anno');
|
||||
assert.equal(group.getAttribute('opacity'), '1');
|
||||
assert.deepEqual(
|
||||
[...group.children].map((child) => [child.tagName.toLowerCase(), child.getAttribute('class')]),
|
||||
[
|
||||
['circle', 'gev-anno-ring gev-pulse'],
|
||||
['circle', 'gev-anno-ring'],
|
||||
['circle', 'gev-anno-dot'],
|
||||
['line', 'gev-anno-leader'],
|
||||
['g', 'gev-anno-callout'],
|
||||
],
|
||||
);
|
||||
const [outer, inner, dot, leader, callout] = group.children;
|
||||
assert.equal(outer.getAttribute('stroke'), '#ffb547');
|
||||
assert.equal(outer.getAttribute('cx'), '200.0');
|
||||
assert.equal(outer.getAttribute('r'), '34.0');
|
||||
assert.equal(inner.getAttribute('fill-opacity'), '0.12');
|
||||
assert.equal(inner.getAttribute('filter'), 'url(#gev-sketch)');
|
||||
assert.equal(dot.getAttribute('r'), '5.0');
|
||||
assert.equal(leader.getAttribute('x1'), '200.0');
|
||||
assert.equal(leader.getAttribute('x2'), '218.0');
|
||||
// The card is sized to its measured text, and the leader meets its bottom.
|
||||
const [card, accent, text] = callout.children;
|
||||
assert.equal(text.textContent, 'Capitol');
|
||||
assert.equal(text.getAttribute('x'), '9');
|
||||
assert.equal(card.getAttribute('width'), '58');
|
||||
assert.equal(card.getAttribute('height'), '26');
|
||||
assert.equal(accent.getAttribute('height'), '18');
|
||||
assert.equal(accent.getAttribute('fill'), '#ffb547');
|
||||
assert.equal(callout.getAttribute('transform'), 'translate(218.0, 266.0)');
|
||||
assert.equal(leader.getAttribute('y2'), '292.0');
|
||||
frames();
|
||||
await settle();
|
||||
assert.equal(group.getAttribute('class'), 'gev-anno gev-in');
|
||||
});
|
||||
|
||||
test('an arrow defines its arrowhead and drops the dash once drawn on', async (t) => {
|
||||
const { store, frames, svg, groups } = board(t);
|
||||
let calls = 0;
|
||||
projectTo(t, () => (calls++ % 2 ? { x: 400, y: 100 } : { x: 100, y: 100 }));
|
||||
const camera = cameraOver(0, 0);
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene: sceneFor(camera), camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
renderer.add({
|
||||
id: 'arrow',
|
||||
type: 'arrow',
|
||||
color: 'green',
|
||||
alpha: 1,
|
||||
anchor: { lon: 0, lat: 0 },
|
||||
to: { lon: 0.01, lat: 0 },
|
||||
});
|
||||
await settle();
|
||||
const marker = svg().querySelector('defs > marker#gev-arrow-green');
|
||||
assert.equal(marker.getAttribute('orient'), 'auto-start-reverse');
|
||||
assert.equal(marker.querySelector('path').getAttribute('fill'), '#5dff9f');
|
||||
const path = groups()[0].querySelector('path');
|
||||
assert.equal(path.getAttribute('class'), 'gev-anno-arrow gev-draw');
|
||||
assert.equal(path.getAttribute('marker-end'), 'url(#gev-arrow-green)');
|
||||
assert.match(path.getAttribute('d'), /^M 100\.0 100\.0 Q /);
|
||||
frames();
|
||||
await settle();
|
||||
const ended = new Event('transitionend', { bubbles: true });
|
||||
ended.propertyName = 'stroke-dashoffset';
|
||||
path.dispatchEvent(ended);
|
||||
await settle();
|
||||
assert.equal(path.style.strokeDasharray, 'none');
|
||||
});
|
||||
|
||||
test('outline upgrade preserves the existing SVG group identity', async (t) => {
|
||||
const { store, svg, groups } = board(t);
|
||||
const camera = {
|
||||
positionWC: Cesium.Cartesian3.ZERO,
|
||||
directionWC: Cesium.Cartesian3.ZERO,
|
||||
positionCartographic: { height: 1000 },
|
||||
};
|
||||
const sampledAnchors = [];
|
||||
const scene = {
|
||||
camera,
|
||||
const scene = sceneFor(camera, {
|
||||
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
||||
clampToHeightSupported: true,
|
||||
clampToHeight(world) {
|
||||
@@ -169,9 +182,12 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
|
||||
]);
|
||||
return world;
|
||||
},
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
};
|
||||
const renderer = createScreenAnnotationRenderer({ scene, camera, trackedEntity: null });
|
||||
});
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene, camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
const anno = {
|
||||
id: 'anno-fb3',
|
||||
type: 'area',
|
||||
@@ -182,8 +198,11 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
|
||||
ring: null,
|
||||
};
|
||||
renderer.add(anno);
|
||||
const before = findAnnotationGroup(globalThis.document);
|
||||
assert.ok(before.group.querySelector('.gev-anno-ring'), 'pending area starts as a reticle');
|
||||
await settle();
|
||||
const [before] = groups();
|
||||
assert.ok(before.querySelector('.gev-anno-ring'), 'pending area starts as a reticle');
|
||||
const callout = before.querySelector('.gev-anno-callout');
|
||||
const dot = before.querySelector('.gev-anno-dot');
|
||||
|
||||
const labelProxy = Object.assign(Object.create(anno), {
|
||||
type: 'label',
|
||||
@@ -191,47 +210,65 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
|
||||
anchor: { lon: -97.5, lat: 31.2, height: 0 },
|
||||
});
|
||||
renderer.update(labelProxy);
|
||||
const after = findAnnotationGroup(globalThis.document);
|
||||
await settle();
|
||||
const [after] = groups();
|
||||
|
||||
assert.equal(after.group, before.group, 'the rendered group is mutated, not replaced');
|
||||
assert.ok(after === before, 'the rendered group is mutated, not replaced');
|
||||
assert.deepEqual(sampledAnchors.at(-1), [-97.5, 31.2], 'the existing group projects from the re-seated anchor');
|
||||
assert.equal(after.svg.children.filter((child) => child.classList.contains('gev-anno')).length, 1);
|
||||
assert.equal(after.group.querySelector('.gev-anno-ring'), null, 'reticle rings are removed in place');
|
||||
assert.ok(after.group.querySelector('.gev-anno-callout'), 'the original callout remains in the group');
|
||||
renderer.destroy();
|
||||
assert.equal(groups().length, 1);
|
||||
assert.equal(after.querySelector('.gev-anno-ring'), null, 'reticle rings are removed in place');
|
||||
assert.ok(after.querySelector('.gev-anno-callout') === callout, 'the original callout remains in the group');
|
||||
assert.ok(after.querySelector('.gev-anno-dot') === dot, 'the anchor dot remains in the group');
|
||||
assert.ok(svg());
|
||||
});
|
||||
|
||||
test('annotation fade consumes the actual tracked host paint rectangle after layout', (t) => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 0, y: 0 });
|
||||
t.after(() => {
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
});
|
||||
|
||||
const positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
|
||||
const directionWC = Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const camera = { positionWC, directionWC, positionCartographic: { height: 1000 } };
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { clientWidth: 800, clientHeight: 600, width: 800, height: 600 },
|
||||
clampToHeightSupported: false,
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
test('a callout whose text changes is sized to the new text', async (t) => {
|
||||
const { store, groups } = board(t);
|
||||
projectTo(t, { x: 100, y: 100 });
|
||||
// Text measures 7px a character; everything else keeps the shared box.
|
||||
const original = globalThis.SVGGraphicsElement.prototype.getBBox;
|
||||
globalThis.SVGGraphicsElement.prototype.getBBox = function () {
|
||||
return this.tagName.toLowerCase() === 'text'
|
||||
? { x: 0, y: 0, width: this.textContent.length * 7, height: 16 }
|
||||
: original.call(this);
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.SVGGraphicsElement.prototype.getBBox = original;
|
||||
});
|
||||
const camera = cameraOver(0, 0);
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene: sceneFor(camera), camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
const anno = {
|
||||
id: 'upgrade',
|
||||
type: 'label',
|
||||
color: 'primary',
|
||||
label: 'Texas',
|
||||
alpha: 1,
|
||||
anchor: { lon: 0, lat: 0 },
|
||||
};
|
||||
renderer.add(anno);
|
||||
await settle();
|
||||
const card = () => groups()[0].querySelector('.gev-anno-card');
|
||||
assert.equal(card().getAttribute('width'), String(5 * 7 + 18));
|
||||
renderer.update({ ...anno, outlineUnavailable: true });
|
||||
await settle();
|
||||
const text = 'Texas · Detailed outline unavailable';
|
||||
assert.equal(groups()[0].querySelector('text').textContent, text);
|
||||
assert.equal(card().getAttribute('width'), String(text.length * 7 + 18));
|
||||
});
|
||||
|
||||
test('annotation fade consumes the actual tracked host paint rectangle after layout', async (t) => {
|
||||
const { store, groups } = board(t);
|
||||
projectTo(t, { x: 0, y: 0 });
|
||||
const camera = cameraOver(0, 0);
|
||||
const paintRectCalls = [];
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene, camera, trackedEntity: null },
|
||||
{ scene: sceneFor(camera), camera, trackedEntity: null },
|
||||
{
|
||||
store,
|
||||
activeTrackedReadoutId: () => 'installations:test',
|
||||
overlayPaintRect(sourceId, entryId) {
|
||||
paintRectCalls.push({ sourceId, entryId });
|
||||
@@ -239,6 +276,7 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
|
||||
},
|
||||
},
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
renderer.add({
|
||||
id: 'anno-overlap',
|
||||
type: 'label',
|
||||
@@ -247,7 +285,8 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
|
||||
alpha: 1,
|
||||
anchor: { lon: 0, lat: 0, height: 0 },
|
||||
});
|
||||
const { group } = findAnnotationGroup(globalThis.document);
|
||||
await settle();
|
||||
const [group] = groups();
|
||||
assert.deepEqual(paintRectCalls.at(-1), {
|
||||
sourceId: 'tracked',
|
||||
entryId: 'installations:test',
|
||||
@@ -256,44 +295,59 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
|
||||
Number(group.getAttribute('opacity')) < 1,
|
||||
'final annotation bbox fades against the complete painted card rectangle',
|
||||
);
|
||||
});
|
||||
|
||||
test('a removed mark fades out, then leaves the board', async (t) => {
|
||||
const { store, groups } = board(t);
|
||||
projectTo(t, { x: 100, y: 100 });
|
||||
const camera = cameraOver(0, 0);
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene: sceneFor(camera), camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
const anno = {
|
||||
id: 'fading',
|
||||
type: 'label',
|
||||
color: 'primary',
|
||||
label: 'Gone soon',
|
||||
alpha: 1,
|
||||
anchor: { lon: 0, lat: 0 },
|
||||
};
|
||||
renderer.add(anno);
|
||||
await settle();
|
||||
const [group] = groups();
|
||||
renderer.remove(anno);
|
||||
// A new mark for the same place gets its own group beside the fading one.
|
||||
renderer.add(anno);
|
||||
await settle();
|
||||
assert.equal(group.getAttribute('class'), 'gev-anno gev-out');
|
||||
assert.equal(groups().length, 2);
|
||||
assert.ok(groups()[1] !== group);
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
await settle();
|
||||
assert.deepEqual(groups().length, 1);
|
||||
assert.equal(group.isConnected, false);
|
||||
renderer.destroy();
|
||||
await settle();
|
||||
assert.equal(document.querySelector('.gev-screen-whiteboard'), null);
|
||||
});
|
||||
|
||||
// ── Partial-add unwind (review round 2) ───────────────────────────────────────
|
||||
//
|
||||
// add() inserts the group and records it, then does more live-document work
|
||||
// (draw-on wiring, the first projection pass). A throw in that tail used to
|
||||
// leave an ORPHANED <g> plus a record nobody owned — the engine's rollback only
|
||||
// deletes its own map entry — so re-annotating the same place stacked a second
|
||||
// mark over the corpse.
|
||||
// add() records the mark, then does more work (presenting it, the first
|
||||
// projection pass). A throw in that tail used to leave an ORPHANED mark plus a
|
||||
// record nobody owned — the engine's rollback only deletes its own map entry —
|
||||
// so re-annotating the same place stacked a second mark over the corpse.
|
||||
|
||||
test('an add that throws after inserting its group unwinds instead of orphaning it', (t) => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||
t.after(() => {
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
});
|
||||
|
||||
const positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
|
||||
const directionWC = Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
|
||||
new Cesium.Cartesian3(),
|
||||
test('an add that throws after inserting its group unwinds instead of orphaning it', async (t) => {
|
||||
const { store, groups } = board(t);
|
||||
const camera = cameraOver(0, 0);
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene: sceneFor(camera, { canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 } }), camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
const camera = { positionWC, directionWC, positionCartographic: { height: 1000 } };
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
||||
clampToHeightSupported: false,
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
};
|
||||
const renderer = createScreenAnnotationRenderer({ scene, camera, trackedEntity: null });
|
||||
t.after(() => renderer.destroy());
|
||||
const anno = {
|
||||
id: 'anno-partial-screen',
|
||||
type: 'pin',
|
||||
@@ -303,26 +357,221 @@ test('an add that throws after inserting its group unwinds instead of orphaning
|
||||
anchor: { lon: 0, lat: 0, height: 0 },
|
||||
};
|
||||
|
||||
// The group is in the document and recorded by the time add() reaches its
|
||||
// first projection pass — so a throw there is real partial state, not a
|
||||
// clean bail-out.
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = () => { throw new Error('projection failed'); };
|
||||
// The mark is on the board by the time add() reaches its first projection
|
||||
// pass — so a throw there is real partial state, not a clean bail-out.
|
||||
projectTo(t, () => {
|
||||
throw new Error('projection failed');
|
||||
});
|
||||
assert.throws(() => renderer.add(anno), /projection failed/);
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 0, y: 0 });
|
||||
const { svg } = findAnnotationGroup(globalThis.document);
|
||||
assert.equal(
|
||||
svg.children.filter((child) => child.classList.contains('gev-anno')).length,
|
||||
0,
|
||||
'the half-built group must not stay in the document',
|
||||
);
|
||||
await settle();
|
||||
assert.equal(groups().length, 0, 'the half-built group must not stay in the document');
|
||||
|
||||
// The id is free again: a retry draws exactly one mark, and remove() still
|
||||
// owns it (a leaked record would leave a second group behind).
|
||||
renderer.add(anno);
|
||||
assert.equal(
|
||||
svg.children.filter((child) => child.classList.contains('gev-anno')).length,
|
||||
1,
|
||||
'the retry must draw one mark, not stack over an orphan',
|
||||
await settle();
|
||||
assert.equal(groups().length, 1, 'the retry must draw one mark, not stack over an orphan');
|
||||
});
|
||||
|
||||
test("an earlier renderer leaves a later renderer's board alone", async (t) => {
|
||||
const { store, groups } = board(t);
|
||||
projectTo(t, { x: 100, y: 100 });
|
||||
const camera = cameraOver(0, 0);
|
||||
const viewer = { scene: sceneFor(camera), camera, trackedEntity: null };
|
||||
const options = { store, activeTrackedReadoutId: () => null };
|
||||
const earlier = createScreenAnnotationRenderer(viewer, options);
|
||||
const later = createScreenAnnotationRenderer(viewer, options);
|
||||
t.after(() => later.destroy());
|
||||
later.add({ id: 'kept', type: 'label', label: 'Kept', alpha: 1, anchor: { lon: 0, lat: 0 } });
|
||||
earlier.add({ id: 'ignored', type: 'label', label: 'Ignored', alpha: 1, anchor: { lon: 0, lat: 0 } });
|
||||
earlier.destroy();
|
||||
await settle();
|
||||
assert.equal(groups().length, 1);
|
||||
assert.equal(groups()[0].querySelector('text').textContent, 'Kept');
|
||||
});
|
||||
|
||||
test('a multipart area with holes draws every visible part and keeps holes open', async (t) => {
|
||||
const { store, svg } = board(t);
|
||||
// A fixed projection: 0.001 degrees spans 100 px.
|
||||
projectTo(t, (_scene, world) => {
|
||||
const c = Cesium.Cartographic.fromCartesian(world);
|
||||
return {
|
||||
x: 100 + Cesium.Math.toDegrees(c.longitude) * 1e5,
|
||||
y: 100 + Cesium.Math.toDegrees(c.latitude) * 1e5,
|
||||
};
|
||||
});
|
||||
const camera = cameraOver(0, 0);
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene: sceneFor(camera), camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
renderer.destroy();
|
||||
t.after(() => renderer.destroy());
|
||||
const outer = [[0, 0], [0.001, 0], [0.001, 0.001], [0, 0.001], [0, 0]];
|
||||
const hole = [[0.0002, 0.0002], [0.0004, 0.0002], [0.0004, 0.0004], [0.0002, 0.0004], [0.0002, 0.0002]];
|
||||
const island = outer.map(([lon, lat]) => [lon + 0.002, lat]);
|
||||
const anno = {
|
||||
id: 'anno-multipart-screen',
|
||||
label: 'Multipart',
|
||||
type: 'area',
|
||||
color: 'primary',
|
||||
alpha: 1,
|
||||
anchor: { lon: 0, lat: 0, height: 0 },
|
||||
ring: outer,
|
||||
polygons: [[outer, hole], [island]],
|
||||
synthesized: false,
|
||||
};
|
||||
renderer.add(anno);
|
||||
await settle();
|
||||
const area = () => svg().querySelector('.gev-anno-area');
|
||||
const subpaths = () => (area().getAttribute('d').match(/M /g) || []).length;
|
||||
assert.equal(area().tagName.toLowerCase(), 'path');
|
||||
assert.equal(area().getAttribute('fill-rule'), 'evenodd');
|
||||
assert.equal(subpaths(), 3, 'screen fill retains both parts and the hole');
|
||||
|
||||
// The first part is behind the globe: the label centres on the next part.
|
||||
anno.polygons = [[island.map(([lon, lat]) => [lon + 180, lat])], [outer, hole]];
|
||||
renderer.sync();
|
||||
await settle();
|
||||
assert.equal(subpaths(), 2, 'an island behind the globe cannot hide the visible part and its hole');
|
||||
assert.notEqual(area().parentNode.style.display, 'none');
|
||||
assert.doesNotMatch(
|
||||
area().parentNode.querySelector('.gev-anno-callout').getAttribute('transform'),
|
||||
/NaN/,
|
||||
);
|
||||
|
||||
anno.polygons = [[outer, hole]];
|
||||
projectTo(t, (_scene, world) => {
|
||||
const lon = Cesium.Math.toDegrees(Cesium.Cartographic.fromCartesian(world).longitude);
|
||||
return { x: lon > 0.0009 ? 2401 : 0, y: 0 };
|
||||
});
|
||||
renderer.sync();
|
||||
await settle();
|
||||
assert.equal(subpaths(), 2, 'finite offscreen shell coordinates cannot hide a visible holed part');
|
||||
assert.notEqual(area().parentNode.style.display, 'none');
|
||||
|
||||
anno.polygons = [[outer, hole], [island]];
|
||||
projectTo(t, (_scene, world) => {
|
||||
const lon = Cesium.Math.toDegrees(Cesium.Cartographic.fromCartesian(world).longitude);
|
||||
return Math.abs(lon - 0.0002) < 1e-7 ? undefined : { x: 0, y: 0 };
|
||||
});
|
||||
renderer.sync();
|
||||
await settle();
|
||||
assert.equal(subpaths(), 1, 'an unprojectable hole omits its entire part, not the independently visible island');
|
||||
assert.notEqual(area().parentNode.style.display, 'none');
|
||||
});
|
||||
|
||||
test('Preview avoids the map callout without moving its world anchor', async (t) => {
|
||||
const { store, svg, groups } = board(t);
|
||||
projectTo(t, (_scene, world) => {
|
||||
const lon = Cesium.Math.toDegrees(Cesium.Cartographic.fromCartesian(world).longitude);
|
||||
return { x: 400, y: lon > 0.0005 ? 166 : 200 };
|
||||
});
|
||||
const camera = cameraOver(0, 0);
|
||||
const scene = sceneFor(camera, {
|
||||
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
||||
});
|
||||
const panel = document.body.appendChild(document.createElement('section'));
|
||||
panel.id = 'gev-voice-panel';
|
||||
panel.dataset.view = 'preview';
|
||||
panel.hidden = true;
|
||||
let panelRect = { left: 210, top: 120, width: 100, height: 120 };
|
||||
let panelVisible = true;
|
||||
panel.getBoundingClientRect = () => panelRect;
|
||||
panel.checkVisibility = () => panelVisible;
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene, camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
const anno = { id: 'preview-label', type: 'label', color: 'primary', label: 'Texas', alpha: 1, anchor: { lon: 0, lat: 0, height: 0 } };
|
||||
renderer.add(anno);
|
||||
await settle();
|
||||
// Non-unit, independent axis scales and a nonzero client offset: the
|
||||
// overlay uses preserveAspectRatio="none", not letterboxing.
|
||||
svg().getBoundingClientRect = () => ({ left: 10, top: 20, width: 640, height: 400 });
|
||||
renderer.sync();
|
||||
await settle();
|
||||
const [group] = groups();
|
||||
const callout = () => group.querySelector('.gev-anno-callout');
|
||||
const leader = group.querySelector('.gev-anno-leader');
|
||||
const dot = group.querySelector('.gev-anno-dot');
|
||||
const natural = callout().getAttribute('transform');
|
||||
const anchor = structuredClone(anno.anchor);
|
||||
panel.hidden = false;
|
||||
renderer.sync();
|
||||
await settle();
|
||||
assert.equal(callout().getAttribute('transform'), 'translate(418.0, 146.0)');
|
||||
assert.equal(leader.getAttribute('x2'), '418.0');
|
||||
assert.equal(leader.getAttribute('y2'), '172.0');
|
||||
assert.equal(leader.getAttribute('x1'), '400.0');
|
||||
assert.equal(leader.getAttribute('y1'), '200.0');
|
||||
assert.equal(dot.getAttribute('cx'), '400.0');
|
||||
assert.equal(dot.getAttribute('cy'), '200.0');
|
||||
assert.deepEqual(anno.anchor, anchor);
|
||||
assert.ok(groups()[0] === group, 'the group is kept, not replaced');
|
||||
panel.hidden = true;
|
||||
renderer.sync();
|
||||
await settle();
|
||||
assert.equal(callout().getAttribute('transform'), natural, 'closing Preview restores natural placement');
|
||||
for (const variant of ['settings', 'invisible', 'no-overlap', 'no-room', 'collision']) {
|
||||
panel.hidden = false;
|
||||
panel.dataset.view = variant === 'settings' ? 'settings' : 'preview';
|
||||
panelVisible = variant !== 'invisible';
|
||||
panelRect = variant === 'no-overlap'
|
||||
? { left: 510, top: 120, width: 100, height: 120 }
|
||||
: variant === 'no-room'
|
||||
? { left: 210, top: 25, width: 100, height: 180 }
|
||||
: { left: 210, top: 120, width: 100, height: 120 };
|
||||
if (variant === 'collision') renderer.add({ ...anno, id: 'above-slot', anchor: { lon: 0.001, lat: 0, height: 0 } });
|
||||
renderer.sync();
|
||||
await settle();
|
||||
assert.equal(callout().getAttribute('transform'), natural, variant);
|
||||
}
|
||||
});
|
||||
|
||||
test('a mark hidden from Draw is not drawn or placed, and comes back when shown', async (t) => {
|
||||
// happy-dom measures every SVG box the same, so callout spreading cannot
|
||||
// settle here; a hidden mark simply has to stay out of drawing and
|
||||
// placement, which is what keeps it from pushing a visible callout aside.
|
||||
const { store, svg } = board(t);
|
||||
projectTo(t, { x: 200, y: 300 });
|
||||
const camera = cameraOver(0, 0);
|
||||
let project = null;
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{
|
||||
scene: sceneFor(camera, {
|
||||
postRender: {
|
||||
addEventListener: (listener) => (project = listener),
|
||||
removeEventListener() {},
|
||||
},
|
||||
}),
|
||||
camera,
|
||||
trackedEntity: null,
|
||||
},
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
const camp = {
|
||||
id: 'camp',
|
||||
type: 'pin',
|
||||
color: 'amber',
|
||||
label: 'Camp',
|
||||
alpha: 1,
|
||||
hidden: true,
|
||||
anchor: { lon: 0, lat: 0, height: 0 },
|
||||
};
|
||||
renderer.add(camp);
|
||||
project();
|
||||
await settle();
|
||||
const drawn = () =>
|
||||
[...svg().querySelectorAll('g.gev-anno')].some(
|
||||
(group) =>
|
||||
group.textContent.includes('Camp') && group.style.display !== 'none',
|
||||
);
|
||||
assert.equal(drawn(), false, 'the hidden mark is not drawn');
|
||||
camp.hidden = false;
|
||||
project();
|
||||
await settle();
|
||||
assert.equal(drawn(), true, 'shown again, it is drawn');
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { recordWorkSlice } from '../sources/phaseTiming.js';
|
||||
import { ringCentroid } from '../sources/featureGeometry.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
||||
|
||||
@@ -51,6 +53,232 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
const dataSource = new Cesium.CustomDataSource('gev-annotations');
|
||||
viewer.dataSources.add(dataSource);
|
||||
const osmAnnotations = new Set();
|
||||
// Every mark with entities on the globe, so sync() can take a hidden one out.
|
||||
const drawn = new Set();
|
||||
const pendingGround = new Map();
|
||||
const groundQueue = [];
|
||||
let observingGround = false;
|
||||
let destroyed = false;
|
||||
|
||||
function sampleBuilding(anno, options) {
|
||||
pendingGround.get(anno)?.finish('cancelled');
|
||||
return new Promise((resolve, reject) => {
|
||||
const queuedAt = performance.now();
|
||||
// The 2 s budget starts when this task reaches the head of the queue, so
|
||||
// waiting behind other buildings on a slow device never spends it.
|
||||
let started = null;
|
||||
const workTrace = options.workTrace;
|
||||
if (workTrace) workTrace.samplerStart = queuedAt;
|
||||
let groundDetailSlots = 0;
|
||||
let groundDetailReady = true;
|
||||
if (workTrace) {
|
||||
try {
|
||||
workTrace.groundDetailDropped = 0;
|
||||
} catch {
|
||||
groundDetailReady = false;
|
||||
}
|
||||
}
|
||||
const task = {
|
||||
anno,
|
||||
points: groundProbePositions(anno.ring),
|
||||
samples: [],
|
||||
index: 0,
|
||||
};
|
||||
const timing = {
|
||||
workMs: 0,
|
||||
maxSliceMs: 0,
|
||||
lastSliceMs: 0,
|
||||
probes: 0,
|
||||
queuedMs: 0,
|
||||
deadlineMs: null,
|
||||
};
|
||||
task.current = () =>
|
||||
!destroyed &&
|
||||
!options.signal?.aborted &&
|
||||
pendingGround.get(anno) === task &&
|
||||
(options.isCurrent?.() ?? true);
|
||||
task.begin = () => {
|
||||
if (started !== null) return;
|
||||
started = performance.now();
|
||||
timing.queuedMs = started - queuedAt;
|
||||
timing.deadlineMs = started + 2000;
|
||||
if (workTrace) workTrace.samplerHeadAt = started;
|
||||
task.timer = setTimeout(() => task.finish('timeout'), 2000);
|
||||
// A task promoted outside a frame (its predecessor timed out or was
|
||||
// cancelled) still needs frames in request-render mode.
|
||||
try {
|
||||
viewer.scene.requestRender?.();
|
||||
} catch {
|
||||
/* the enqueue path reports render failures */
|
||||
}
|
||||
};
|
||||
task.finish = (status, error) => {
|
||||
if (pendingGround.get(anno) !== task) return;
|
||||
pendingGround.delete(anno);
|
||||
const position = groundQueue.indexOf(task);
|
||||
groundQueue.splice(position, 1);
|
||||
clearTimeout(task.timer);
|
||||
if (position === 0) groundQueue[0]?.begin();
|
||||
options.signal?.removeEventListener('abort', task.abort);
|
||||
if (!groundQueue.length && observingGround) {
|
||||
viewer.scene.postRender.removeEventListener(onGroundFrame);
|
||||
observingGround = false;
|
||||
}
|
||||
timing.elapsedMs = performance.now() - (started ?? queuedAt);
|
||||
if (workTrace) {
|
||||
workTrace.probes = timing.probes;
|
||||
workTrace.samplerStatus = status;
|
||||
}
|
||||
if (error) reject(error);
|
||||
else resolve({ status, timing });
|
||||
};
|
||||
task.abort = () => task.finish('cancelled');
|
||||
task.step = () => {
|
||||
const workStart = workTrace ? performance.now() : 0;
|
||||
// Nested call/commit wall-time only; the original parent remains the gate.
|
||||
const detail =
|
||||
workTrace && groundDetailSlots++ < 8
|
||||
? {
|
||||
index: task.index,
|
||||
status: groundDetailReady ? 'recorded' : 'missing',
|
||||
clampStart: null,
|
||||
clampEnd: null,
|
||||
clampResult: 'not-run',
|
||||
commitStart: null,
|
||||
commitEnd: null,
|
||||
commitStatus: 'not-run',
|
||||
}
|
||||
: undefined;
|
||||
const note = detail
|
||||
? (key) => {
|
||||
try {
|
||||
const at = performance.now();
|
||||
if (Number.isFinite(at)) detail[key] = at;
|
||||
else detail.status = 'missing';
|
||||
} catch {
|
||||
detail.status = 'missing';
|
||||
}
|
||||
}
|
||||
: null;
|
||||
if (workTrace && !detail) {
|
||||
try {
|
||||
workTrace.groundDetailDropped += 1;
|
||||
} catch {
|
||||
// Missing attribution must not change the sampler outcome.
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (!task.current()) return task.finish('cancelled');
|
||||
if (performance.now() - started >= 2000)
|
||||
return task.finish('timeout');
|
||||
const sliceStart = performance.now();
|
||||
let error;
|
||||
try {
|
||||
if (
|
||||
viewer.scene.clampToHeightSupported &&
|
||||
typeof viewer.scene.clampToHeight === 'function'
|
||||
) {
|
||||
const [lon, lat] = task.points[task.index++];
|
||||
timing.probes += 1;
|
||||
try {
|
||||
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
|
||||
let c;
|
||||
note?.('clampStart');
|
||||
if (detail) detail.clampResult = 'threw';
|
||||
try {
|
||||
c = viewer.scene.clampToHeight(position);
|
||||
if (detail) detail.clampResult = 'returned';
|
||||
} finally {
|
||||
note?.('clampEnd');
|
||||
}
|
||||
if (c) {
|
||||
const h = Cesium.Cartographic.fromCartesian(c).height;
|
||||
if (Number.isFinite(h) && h > -430 && h < 9000)
|
||||
task.samples.push(h);
|
||||
}
|
||||
} catch {
|
||||
/* surface not ready: preserve the existing sample filter */
|
||||
}
|
||||
} else task.index = task.points.length;
|
||||
if (performance.now() - started >= 2000) {
|
||||
task.finish('timeout');
|
||||
return;
|
||||
}
|
||||
if (!task.current()) return task.finish('cancelled');
|
||||
if (task.index === task.points.length) {
|
||||
if (detail) detail.commitStatus = 'attempted';
|
||||
note?.('commitStart');
|
||||
task.samples.sort((a, b) => a - b);
|
||||
const ground = task.samples.length
|
||||
? task.samples[Math.floor(task.samples.length * 0.3)]
|
||||
: null;
|
||||
add(anno, {
|
||||
sampledGroundHeight: ground,
|
||||
isCurrent: task.current,
|
||||
});
|
||||
if (performance.now() >= timing.deadlineMs) {
|
||||
task.finish('timeout');
|
||||
remove(anno);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
} finally {
|
||||
timing.lastSliceMs = performance.now() - sliceStart;
|
||||
timing.workMs += timing.lastSliceMs;
|
||||
timing.maxSliceMs = Math.max(timing.maxSliceMs, timing.lastSliceMs);
|
||||
}
|
||||
if (error) {
|
||||
task.finish('failed', error);
|
||||
try {
|
||||
remove(anno);
|
||||
} catch {
|
||||
/* retain the original commit error */
|
||||
}
|
||||
} else if (task.index === task.points.length)
|
||||
task.finish('installed');
|
||||
} finally {
|
||||
// Commit includes sort/selection/add/deadline/finish or rollback,
|
||||
// including intervening bookkeeping; it is not exclusive entity cost.
|
||||
if (detail?.commitStatus === 'attempted') note('commitEnd');
|
||||
if (workTrace)
|
||||
recordWorkSlice(
|
||||
workTrace,
|
||||
'ground-step',
|
||||
workStart,
|
||||
undefined,
|
||||
detail,
|
||||
);
|
||||
}
|
||||
};
|
||||
pendingGround.set(anno, task);
|
||||
groundQueue.push(task);
|
||||
if (groundQueue[0] === task) task.begin();
|
||||
options.signal?.addEventListener('abort', task.abort, { once: true });
|
||||
if (!task.current()) return task.finish('cancelled');
|
||||
if (!observingGround) {
|
||||
viewer.scene.postRender.addEventListener(onGroundFrame);
|
||||
observingGround = true;
|
||||
}
|
||||
try {
|
||||
viewer.scene.requestRender?.();
|
||||
} catch (error) {
|
||||
task.finish('failed', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onGroundFrame() {
|
||||
// One building probe TOTAL, not one per mark. Other renderers have their own work.
|
||||
const task = groundQueue[0];
|
||||
try {
|
||||
task?.step();
|
||||
if (groundQueue.length) viewer.scene.requestRender?.();
|
||||
} catch (error) {
|
||||
for (const pending of [...groundQueue]) pending.finish('failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -81,7 +309,7 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
}, false);
|
||||
}
|
||||
|
||||
function add(anno) {
|
||||
function add(anno, options = {}) {
|
||||
const base = colorFor(anno);
|
||||
// Published to the mark BEFORE anything is added, and mutated in place as
|
||||
// each entity lands: a mid-add failure (bad geometry, lost context) must
|
||||
@@ -89,21 +317,42 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
// path cannot reach them and they stay on the globe forever.
|
||||
const entities = [];
|
||||
anno._entities = entities;
|
||||
drawn.add(anno);
|
||||
|
||||
if (
|
||||
anno.ring?.length >= 3 &&
|
||||
anno.footprintKind === 'building' &&
|
||||
!Object.hasOwn(options, 'sampledGroundHeight')
|
||||
) {
|
||||
return sampleBuilding(anno, options);
|
||||
}
|
||||
|
||||
if (
|
||||
anno.ring &&
|
||||
anno.ring.length >= 3 &&
|
||||
anno.footprintKind === 'building'
|
||||
) {
|
||||
const addBuildingEntity = (definition) => {
|
||||
if (!(options.isCurrent?.() ?? true))
|
||||
throw new Error('Building install cancelled');
|
||||
const entity = dataSource.entities.add(definition);
|
||||
if (!(options.isCurrent?.() ?? true)) {
|
||||
dataSource.entities.remove(entity);
|
||||
throw new Error('Building install cancelled');
|
||||
}
|
||||
return entity;
|
||||
};
|
||||
// Single building → the PRIMARY highlight is an extruded CLASSIFICATION
|
||||
// volume that tints the real photogrammetry mesh (dome, walls, roof —
|
||||
// everything inside the column) amber and pulses. A flat footprint
|
||||
// extrusion is only 2.5D and misses domes; classification colors the
|
||||
// actual tiles. A faint wireframe cage rides on top as a secondary cue.
|
||||
const buffered = bufferRing(anno.ring, 3);
|
||||
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
|
||||
const parts =
|
||||
Array.isArray(anno.polygons) && anno.polygons.length
|
||||
? anno.polygons
|
||||
: [[anno.ring]];
|
||||
const groundH =
|
||||
sampleGroundOutside(viewer.scene, anno.ring) ??
|
||||
options.sampledGroundHeight ??
|
||||
(Number.isFinite(anno.anchor.height)
|
||||
? anno.anchor.height - (anno.buildingHeight || 25)
|
||||
: 0);
|
||||
@@ -112,55 +361,73 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
// empty air above the roof isn't tinted — this just guarantees we cover
|
||||
// under-tagged building heights.
|
||||
const topH = groundH + Math.max(18, anno.buildingHeight || 25) + 24;
|
||||
// 1) Classification volume — tints the mesh amber, pulsing.
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(positions),
|
||||
height: baseH,
|
||||
extrudedHeight: topH,
|
||||
perPositionHeight: false,
|
||||
classificationType: CLASSIFY,
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
liveColor(anno, base, { alpha: 0.45, pulse: true }),
|
||||
),
|
||||
},
|
||||
}),
|
||||
const fillMaterial = new Cesium.ColorMaterialProperty(
|
||||
liveColor(anno, base, { alpha: 0.45, pulse: true }),
|
||||
);
|
||||
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
|
||||
// the footprint so the extruded outline draws ~14 clean verticals, not one
|
||||
// per (often 100+) ring vertex.
|
||||
const cagePositions = Cesium.Cartesian3.fromDegreesArray(
|
||||
decimateRing(buffered, 14).flat(),
|
||||
);
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
|
||||
height: baseH,
|
||||
extrudedHeight: topH,
|
||||
perPositionHeight: false,
|
||||
fill: false,
|
||||
outline: true,
|
||||
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
// 3) Crisp draped base outline so the footprint reads on the ground.
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polyline: {
|
||||
positions,
|
||||
width: 3,
|
||||
material: new Cesium.PolylineGlowMaterialProperty({
|
||||
glowPower: 0.25,
|
||||
color: liveColor(anno, base, { alpha: 0.7 }),
|
||||
const lineMaterial = new Cesium.PolylineGlowMaterialProperty({
|
||||
glowPower: 0.25,
|
||||
color: liveColor(anno, base, { alpha: 0.7 }),
|
||||
});
|
||||
for (const [outer, ...holes] of parts) {
|
||||
const buffered = bufferRing(outer, 3);
|
||||
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
|
||||
// 1) Classification volume — tints the mesh amber, pulsing.
|
||||
entities.push(
|
||||
addBuildingEntity({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(
|
||||
positions,
|
||||
holes.map(
|
||||
(hole) =>
|
||||
new Cesium.PolygonHierarchy(
|
||||
Cesium.Cartesian3.fromDegreesArray(hole.flat()),
|
||||
),
|
||||
),
|
||||
),
|
||||
height: baseH,
|
||||
extrudedHeight: topH,
|
||||
perPositionHeight: false,
|
||||
classificationType: CLASSIFY,
|
||||
material: fillMaterial,
|
||||
},
|
||||
}),
|
||||
);
|
||||
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
|
||||
// the footprint so the extruded outline draws ~14 clean verticals, not one
|
||||
// per (often 100+) ring vertex.
|
||||
const cagePositions = Cesium.Cartesian3.fromDegreesArray(
|
||||
decimateRing(buffered, 14).flat(),
|
||||
);
|
||||
entities.push(
|
||||
addBuildingEntity({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
|
||||
height: baseH,
|
||||
extrudedHeight: topH,
|
||||
perPositionHeight: false,
|
||||
fill: false,
|
||||
outline: true,
|
||||
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
// 3) Crisp draped base outline so the footprint reads on the ground.
|
||||
for (const ring of [outer, ...holes]) {
|
||||
entities.push(
|
||||
addBuildingEntity({
|
||||
polyline: {
|
||||
positions: Cesium.Cartesian3.fromDegreesArray(
|
||||
closedRing(ring).flat(),
|
||||
),
|
||||
width: 3,
|
||||
material: lineMaterial,
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (anno.ring && anno.ring.length >= 3) {
|
||||
// Larger area (district / compound / park / state) → flat fill draped on
|
||||
// the tiles + a glowing outline. Draping is right here: you can't extrude
|
||||
@@ -247,6 +514,7 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
width: 9,
|
||||
material: new FlowMaterialProperty(
|
||||
PALETTE[anno.color] || PALETTE.primary,
|
||||
() => anno.alpha ?? 1,
|
||||
),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
@@ -300,6 +568,19 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
}
|
||||
entities.push(pointMarker(anno, base));
|
||||
}
|
||||
if (
|
||||
entities.length &&
|
||||
anno.ring?.length >= 3 &&
|
||||
!anno.synthesized &&
|
||||
(anno.outlineCredit === 'tiles' || anno.outlineCredit === 'osm') &&
|
||||
(options.isCurrent?.() ?? true)
|
||||
) {
|
||||
// The actual world proxy owns both geometry and credit, including rollback.
|
||||
osmAnnotations.add(anno);
|
||||
showOsmCredit(viewer, anno, {
|
||||
openMapTiles: anno.outlineCredit === 'tiles',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Captions belong to the hybrid renderer's screen-space callouts.
|
||||
@@ -318,8 +599,10 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
}
|
||||
|
||||
function remove(anno) {
|
||||
pendingGround.get(anno)?.finish('cancelled');
|
||||
hideOsmCredit(viewer, anno);
|
||||
osmAnnotations.delete(anno);
|
||||
drawn.delete(anno);
|
||||
if (!anno?._entities) return;
|
||||
for (const entity of anno._entities) {
|
||||
try {
|
||||
@@ -331,13 +614,25 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
anno._entities = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CallbackProperty drives per-frame alpha and pulse. A hidden mark is taken
|
||||
* out whole, so no material that keeps its own colour (the route's flow),
|
||||
* ground classification or pick can still show it.
|
||||
*/
|
||||
function sync() {
|
||||
// No-op: CallbackProperty drives per-frame alpha/pulse animation.
|
||||
for (const anno of drawn) {
|
||||
const show = !anno.hidden;
|
||||
for (const entity of anno._entities || [])
|
||||
if (entity.show !== show) entity.show = show;
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
destroyed = true;
|
||||
for (const task of [...pendingGround.values()]) task.finish('cancelled');
|
||||
for (const anno of osmAnnotations) hideOsmCredit(viewer, anno);
|
||||
osmAnnotations.clear();
|
||||
drawn.clear();
|
||||
try {
|
||||
viewer.dataSources.remove(dataSource, true);
|
||||
} catch {
|
||||
@@ -407,8 +702,11 @@ export function ensureFlowFabricRegistered() {
|
||||
* returned a standalone Material Cesium never rendered, or treated `result` as a
|
||||
* Material — both left the real uniforms untouched, so nothing animated.)
|
||||
*/
|
||||
export function FlowMaterialProperty(colorCss) {
|
||||
export function FlowMaterialProperty(colorCss, alphaOf = () => 1) {
|
||||
this._color = Cesium.Color.fromCssColorString(colorCss).withAlpha(0.95);
|
||||
// The mark's live alpha (fade in and out, hidden), applied every frame.
|
||||
this._alphaOf = alphaOf;
|
||||
this._live = this._color.clone();
|
||||
this._definitionChanged = new Cesium.Event();
|
||||
}
|
||||
Object.defineProperties(FlowMaterialProperty.prototype, {
|
||||
@@ -431,7 +729,8 @@ FlowMaterialProperty.prototype.getValue = function getValue(time, result) {
|
||||
// `time` is read straight from the wall clock so the dashes flow every rendered
|
||||
// frame (the scene renders continuously; no requestRenderMode here).
|
||||
if (!Cesium.defined(result)) result = {};
|
||||
result.color = this._color;
|
||||
this._live.alpha = this._color.alpha * this._alphaOf();
|
||||
result.color = this._live;
|
||||
result.time = performance.now() / 1000; // the per-frame animated value
|
||||
result.repeat = 64.0;
|
||||
result.duty = 0.46;
|
||||
@@ -458,25 +757,11 @@ function decimateRing(ring, n) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample the ground height (m) just OUTSIDE a building footprint. Sampling at
|
||||
* the centroid would clamp onto the roof, so we probe several points beyond the
|
||||
* footprint radius and take a low percentile (≈ ground). Returns null if the
|
||||
* tiles under those points aren't loaded yet.
|
||||
* The original eight outside-footprint probes. Keep their coordinates and order
|
||||
* while scheduling the expensive scene picks across rendered frames.
|
||||
*/
|
||||
function sampleGroundOutside(scene, ring) {
|
||||
if (
|
||||
!scene?.clampToHeightSupported ||
|
||||
typeof scene.clampToHeight !== 'function'
|
||||
)
|
||||
return null;
|
||||
let clon = 0;
|
||||
let clat = 0;
|
||||
for (const [lon, lat] of ring) {
|
||||
clon += lon;
|
||||
clat += lat;
|
||||
}
|
||||
clon /= ring.length;
|
||||
clat /= ring.length;
|
||||
function groundProbePositions(ring) {
|
||||
const { lon: clon, lat: clat } = ringCentroid(ring);
|
||||
const latS = 111320;
|
||||
const lonS = latS * Math.cos(Cesium.Math.toRadians(clat));
|
||||
let maxR = 0;
|
||||
@@ -494,23 +779,11 @@ function sampleGroundOutside(scene, ring) {
|
||||
[0.7, -0.7],
|
||||
[-0.7, 0.7],
|
||||
];
|
||||
const samples = [];
|
||||
for (const [dx, dy] of dirs) {
|
||||
return dirs.map(([dx, dy]) => {
|
||||
const lon = clon + (dx * out) / lonS;
|
||||
const lat = clat + (dy * out) / latS;
|
||||
try {
|
||||
const c = scene.clampToHeight(Cesium.Cartesian3.fromDegrees(lon, lat, 0));
|
||||
if (c) {
|
||||
const h = Cesium.Cartographic.fromCartesian(c).height;
|
||||
if (Number.isFinite(h) && h > -430 && h < 9000) samples.push(h);
|
||||
}
|
||||
} catch {
|
||||
/* tile not ready */
|
||||
}
|
||||
}
|
||||
if (!samples.length) return null;
|
||||
samples.sort((a, b) => a - b);
|
||||
return samples[Math.floor(samples.length * 0.3)];
|
||||
return [lon, lat];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// A mark hidden from Draw's list leaves the globe whole: every entity it put
|
||||
// there is taken out on sync and comes back when shown, and the line's
|
||||
// flowing stroke, which keeps its own colour, follows the mark's alpha.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as C from 'cesium';
|
||||
import {
|
||||
FlowMaterialProperty,
|
||||
createWorldAnnotationRenderer,
|
||||
} from './worldAnnotationRenderer.js';
|
||||
|
||||
const noop = () => {};
|
||||
function viewer(t) {
|
||||
for (const name of ['HTMLCanvasElement', 'HTMLImageElement', 'ImageBitmap', 'OffscreenCanvas']) {
|
||||
const original = globalThis[name];
|
||||
globalThis[name] = class {};
|
||||
t.after(() => { if (original === undefined) delete globalThis[name]; else globalThis[name] = original; });
|
||||
}
|
||||
const before = globalThis.document;
|
||||
globalThis.document = { addEventListener: noop, removeEventListener: noop };
|
||||
t.after(() => { if (before === undefined) delete globalThis.document; else globalThis.document = before; });
|
||||
return {
|
||||
creditDisplay: { addStaticCredit: noop, removeStaticCredit: noop },
|
||||
entities: new C.EntityCollection(),
|
||||
dataSources: new C.DataSourceCollection(),
|
||||
camera: { moveEnd: new C.Event(), positionCartographic: { height: 1000 } },
|
||||
scene: { requestRender: noop, canvas: { addEventListener: noop, removeEventListener: noop }, preRender: new C.Event(),
|
||||
primitives: { add: (p) => p, remove: noop }, screenSpaceCameraController: { enableInputs: true } },
|
||||
};
|
||||
}
|
||||
|
||||
const marks = () => [
|
||||
{ id: 'route', type: 'route', color: 'amber', alpha: 1, anchor: { lat: 0, lon: 0 }, path: [{ lat: 0, lon: 0 }, { lat: 0.01, lon: 0.01 }] },
|
||||
{ id: 'area', type: 'area', color: 'green', alpha: 1, anchor: { lat: 0.005, lon: 0.005 }, ring: [[0, 0], [0.01, 0], [0.01, 0.01], [0, 0]] },
|
||||
{ id: 'pin', type: 'pin', color: 'primary', alpha: 1, anchor: { lat: 0.02, lon: 0.02 } },
|
||||
];
|
||||
|
||||
test('a hidden mark has every entity taken off on sync, and back when shown', (t) => {
|
||||
const renderer = createWorldAnnotationRenderer(viewer(t));
|
||||
const [route, area, pin] = marks();
|
||||
for (const anno of [route, area, pin]) renderer.add(anno);
|
||||
const shown = (anno) => anno._entities.map((entity) => entity.show);
|
||||
for (const anno of [route, area, pin]) assert.ok(anno._entities.length > 0, anno.id);
|
||||
|
||||
route.hidden = true;
|
||||
area.hidden = true;
|
||||
renderer.sync();
|
||||
assert.ok(shown(route).every((show) => show === false), 'the line is off');
|
||||
assert.ok(shown(area).every((show) => show === false), 'the area is off');
|
||||
assert.ok(shown(pin).every((show) => show === true), 'a visible mark stays');
|
||||
|
||||
route.hidden = false;
|
||||
renderer.sync();
|
||||
assert.ok(shown(route).every((show) => show === true), 'the line is back');
|
||||
renderer.remove(area);
|
||||
renderer.sync();
|
||||
assert.equal(area._entities, null, 'a removed mark is no longer synced');
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
test('the line\'s flowing stroke follows its mark\'s alpha', () => {
|
||||
let alpha = 1;
|
||||
const material = new FlowMaterialProperty('#ffb547', () => alpha);
|
||||
assert.ok(Math.abs(material.getValue(null, {}).color.alpha - 0.95) < 1e-9);
|
||||
alpha = 0;
|
||||
assert.equal(material.getValue(null, {}).color.alpha, 0, 'hidden: nothing painted');
|
||||
alpha = 0.5;
|
||||
assert.ok(Math.abs(material.getValue(null, {}).color.alpha - 0.475) < 1e-9, 'fading');
|
||||
assert.equal(new FlowMaterialProperty('#fff').getValue(null, {}).color.alpha, 0.95, 'no alpha source: opaque as before');
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { mountApplicationViews } from '../ui/views/index.js';
|
||||
const START_ORDER = ['scene', 'controls', 'data', 'tools'];
|
||||
// Controls cancel restoration while the data manager and viewer still exist.
|
||||
const STOP_ORDER = ['tools', 'controls', 'data', 'scene'];
|
||||
@@ -6,7 +7,9 @@ const STOP_ORDER = ['tools', 'controls', 'data', 'scene'];
|
||||
* Own application startup and teardown without importing an application entry.
|
||||
* Constructors receive earlier components, an AbortSignal and defer(cleanup).
|
||||
* Register cleanup immediately after acquiring each resource, before any await.
|
||||
* @param {{createScene:Function,createControls:Function,createData:Function,createTools:Function}} constructors
|
||||
* The page's views render first; `omitViews` names the ones this application
|
||||
* leaves out (see mountApplicationViews).
|
||||
* @param {{createScene:Function,createControls:Function,createData:Function,createTools:Function,omitViews?:string[]}} constructors
|
||||
* @returns {{start:Function,destroy:Function,subscribe:Function,getState:Function,getComponents:Function}}
|
||||
*/
|
||||
export function createApplication({
|
||||
@@ -14,7 +17,10 @@ export function createApplication({
|
||||
createControls,
|
||||
createData,
|
||||
createTools,
|
||||
omitViews = [],
|
||||
}) {
|
||||
// A host's page needs its views before any phase reads the markup.
|
||||
mountApplicationViews(globalThis.document, { omit: omitViews });
|
||||
const factories = {
|
||||
scene: createScene,
|
||||
controls: createControls,
|
||||
|
||||
@@ -31,6 +31,8 @@ test('importing and constructing the package is inactive without browser globals
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
// Views are single-file components; Node needs the loader hosts use.
|
||||
'--import=gods-eye-view/build/register-vue',
|
||||
'--input-type=module',
|
||||
'-e',
|
||||
`
|
||||
|
||||
@@ -126,6 +126,7 @@ export function createApplicationCatalog({
|
||||
}),
|
||||
createApplicationGnssIntegrity({
|
||||
source: sources['gnss-integrity'],
|
||||
windowMs: sources['gnss-integrity'].windowMs,
|
||||
}),
|
||||
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
|
||||
createApplicationAlpr({ surface, source: sources.alpr }),
|
||||
|
||||
@@ -193,3 +193,42 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
|
||||
/Mapillary source/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a GNSS integrity source can set the layer evidence window', (t) => {
|
||||
const lifetime = new AbortController();
|
||||
t.after(() => lifetime.abort());
|
||||
const gnssLayer = (descriptor) => {
|
||||
const sources = fixtureSources([], []);
|
||||
sources['gnss-integrity'] = {
|
||||
getSnapshot: async () => ({}),
|
||||
...descriptor,
|
||||
};
|
||||
return createApplicationCatalog({
|
||||
sources,
|
||||
signal: lifetime.signal,
|
||||
surface: fixtureSurface(lifetime.signal),
|
||||
}).get('gnss-integrity');
|
||||
};
|
||||
const shows = (layer, minutes) => {
|
||||
assert.equal(layer.provenance.window.minutes, minutes);
|
||||
const { info, legend } = layer.getRowControls();
|
||||
assert.match(info, new RegExp(`visited views over ${minutes} min`));
|
||||
assert.match(legend[0].blurb, new RegExp(`over ${minutes} minutes`));
|
||||
};
|
||||
shows(gnssLayer({ windowMs: 65 * 60_000 }), 65);
|
||||
shows(gnssLayer({}), 30);
|
||||
for (const windowMs of [
|
||||
undefined,
|
||||
null,
|
||||
NaN,
|
||||
Infinity,
|
||||
0,
|
||||
-60_000,
|
||||
'3600000',
|
||||
])
|
||||
assert.equal(
|
||||
gnssLayer({ windowMs }).provenance.window.minutes,
|
||||
30,
|
||||
`${String(windowMs)} falls back to the default`,
|
||||
);
|
||||
});
|
||||
Vendored
+4
-1
@@ -7,7 +7,8 @@ import { t } from '../i18n/index.js';
|
||||
/**
|
||||
* Construct the existing controls and camera presentation. `initialShareCode`
|
||||
* opens a share code (the `<code>` of `/share/<code>`) at start-up, for a host
|
||||
* whose address does not carry one.
|
||||
* whose address does not carry one. `localePicker: false` removes the language
|
||||
* picker, for a host that does not initialize Core's locale.
|
||||
*/
|
||||
export function createApplicationControls({
|
||||
scene: { viewer, mapStackController, operations },
|
||||
@@ -18,6 +19,7 @@ export function createApplicationControls({
|
||||
placeSearch,
|
||||
defer,
|
||||
initialShareCode = null,
|
||||
localePicker = true,
|
||||
}) {
|
||||
// Initialize the style manager (post-processing, HUD, locations, share links)
|
||||
const styleManager = new Controls(viewer, {
|
||||
@@ -34,6 +36,7 @@ export function createApplicationControls({
|
||||
placeSearch,
|
||||
// A share code to open at start-up instead of the page's address.
|
||||
initialShareCode,
|
||||
localePicker,
|
||||
});
|
||||
defer(() => styleManager.orbitController.stop());
|
||||
defer(() => styleManager.hud.destroy());
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as focus from '../../data/focusDeemphasis.js';
|
||||
import * as worldFocus from '../../worldFocus.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as readouts from '../../hudReadouts.js';
|
||||
|
||||
/** Construct one layer using the application scene owners and a supplied source. */
|
||||
export function createApplicationVessels({ source, options = {} }) {
|
||||
@@ -26,6 +27,7 @@ export function createApplicationVessels({ source, options = {} }) {
|
||||
focus,
|
||||
worldFocus,
|
||||
render,
|
||||
readouts,
|
||||
},
|
||||
});
|
||||
}
|
||||
+2
-1
@@ -14,7 +14,7 @@ import {
|
||||
governorRequestRender,
|
||||
} from '../renderGovernor.js';
|
||||
import { describeError } from './errors.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { onLocaleChange, t } from '../i18n/index.js';
|
||||
|
||||
/** Construct the application globe using the caller's local configuration. */
|
||||
export async function createApplicationScene({
|
||||
@@ -60,6 +60,7 @@ export async function createApplicationScene({
|
||||
defer(installTrackpadPinchZoom(viewer));
|
||||
registerDataCredits(viewer, credits);
|
||||
configureCreditKeyboardAccess(document);
|
||||
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
|
||||
loaderStatus.textContent =
|
||||
googleApiKey || googleTokens || cesiumToken
|
||||
? t('Loading Google 3D Tiles...')
|
||||
|
||||
+22
-4
@@ -2,6 +2,8 @@ import { SceneDirector } from '../scenes/director.js';
|
||||
import { initAnnotations } from '../annotations/index.js';
|
||||
import { initDrawTool } from '../annotations/drawTool.js';
|
||||
import { initGeoFileImport } from '../ui/geoFileImport.js';
|
||||
import { useDisplayStore } from '../ui/stores/display.js';
|
||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
|
||||
import { createRecentImageryPanel } from '../ui/recentImagery.js';
|
||||
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
|
||||
@@ -53,12 +55,26 @@ export function createApplicationTools({
|
||||
if (window.__gevAnnotations === annotations) delete window.__gevAnnotations;
|
||||
annotations.destroy();
|
||||
});
|
||||
// DISPLAY ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
|
||||
const displayStore = useDisplayStore(applicationPinia());
|
||||
// The Display panel's controls answer through the store; returns the
|
||||
// disconnect.
|
||||
const connectDisplay = (actions) => {
|
||||
const token = displayStore.connect(actions);
|
||||
return () => displayStore.detach(token);
|
||||
};
|
||||
// UTILITIES ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
|
||||
// while a session is open, so its teardown belongs to the application
|
||||
// lifetime rather than to whoever last pressed the button.
|
||||
const drawTool = initDrawTool({ viewer, annotations });
|
||||
// lifetime rather than to whoever last pressed the button. A row's fly-to
|
||||
// goes through the shell's navigation policy, as Import's does.
|
||||
const drawTool = initDrawTool({
|
||||
viewer,
|
||||
annotations,
|
||||
navigation: styleManager,
|
||||
publish: (view) => displayStore.publishDraw(view),
|
||||
connect: connectDisplay,
|
||||
});
|
||||
defer(() => drawTool?.destroy());
|
||||
// DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
|
||||
// UTILITIES ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
|
||||
// own data source. They live for the page session, so the application
|
||||
// lifetime removes them and the drop listeners on the viewer container.
|
||||
const geoFileImport = initGeoFileImport({
|
||||
@@ -66,6 +82,8 @@ export function createApplicationTools({
|
||||
navigation: styleManager,
|
||||
ground: operations.surface?.groundFloor,
|
||||
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
|
||||
publish: (view) => displayStore.publishGeoImport(view),
|
||||
connect: connectDisplay,
|
||||
});
|
||||
defer(() => geoFileImport?.destroy());
|
||||
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
|
||||
|
||||
@@ -831,10 +831,37 @@ test('orbit-toggle controller stops globe motion before toggling', async (t) =>
|
||||
return true;
|
||||
},
|
||||
},
|
||||
_orbitIndicator: { classList: { toggle() {} } },
|
||||
_locationControls: { showOrbit() {} },
|
||||
});
|
||||
assert.equal(toggled, true);
|
||||
});
|
||||
test('the voice orbit switch lights and clears the ORBIT indicator', async () => {
|
||||
const { LocationNavigation } = await import('./ui/locationNavigation.js');
|
||||
const shown = [];
|
||||
let active = false;
|
||||
const nav = Object.assign(Object.create(LocationNavigation.prototype), {
|
||||
services: {},
|
||||
_currentTarget: {},
|
||||
_currentPoi: {},
|
||||
orbitController: {
|
||||
get active() {
|
||||
return active;
|
||||
},
|
||||
toggle() {
|
||||
active = !active;
|
||||
return active;
|
||||
},
|
||||
stop() {
|
||||
active = false;
|
||||
},
|
||||
},
|
||||
_locationControls: { showOrbit: (value) => shown.push(value) },
|
||||
});
|
||||
assert.deepEqual(nav.setOrbit(true), { ok: true, orbiting: true });
|
||||
assert.deepEqual(nav.setOrbit(true), { ok: true, orbiting: true });
|
||||
assert.deepEqual(nav.setOrbit(false), { ok: true, orbiting: false });
|
||||
assert.deepEqual(shown, [true, false]);
|
||||
});
|
||||
for (const owner of ['tracking', 'cockpit', 'close-up'])
|
||||
test(`actual voice runner refuses ${owner} without releasing or repositioning`, async (t) => {
|
||||
const { createGevActionRunner } = await import('./voice/gevActions.js');
|
||||
|
||||
@@ -353,7 +353,7 @@ test('teardown refuses deferred location work before geocoding begins', () => {
|
||||
'if (!query || this.destroyed) return;',
|
||||
'const authority = this.begin();',
|
||||
'if (authority === false)',
|
||||
'this.input.blur();',
|
||||
'this.input?.blur();',
|
||||
'this.onStart?.(authority);',
|
||||
'await this.search(query',
|
||||
], 'disposed search refusal');
|
||||
|
||||
+6
-1
@@ -1746,8 +1746,13 @@ export function flyRoute(
|
||||
const speed = ROUTE_M_S[String(args.speed || 'normal').toLowerCase()]
|
||||
? String(args.speed || 'normal').toLowerCase()
|
||||
: 'normal';
|
||||
// A route hidden from Draw's list is not on screen, so it is not flown.
|
||||
const routes = (annoList || []).filter(
|
||||
(a) => a.type === 'route' && Array.isArray(a.path) && a.path.length >= 2,
|
||||
(a) =>
|
||||
a.type === 'route' &&
|
||||
!a.hidden &&
|
||||
Array.isArray(a.path) &&
|
||||
a.path.length >= 2,
|
||||
);
|
||||
if (!routes.length) {
|
||||
return {
|
||||
|
||||
+141
-84
@@ -379,15 +379,23 @@ function drawMoonHaze(ctx, cx, cy, radius, angle) {
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Keys each ring's overlay, so a new ring never reuses an old one's canvases.
|
||||
let rings = 0;
|
||||
|
||||
/**
|
||||
* Screen-space ring and celestial direction overlay for the full-globe view.
|
||||
* The overlay is presented to the celestial ring store, whose view renders it
|
||||
* inside the viewer's container and hands back the two canvases this paints.
|
||||
*/
|
||||
export class CelestialRing {
|
||||
/**
|
||||
* @param {Cesium.Viewer} viewer
|
||||
* @param {{enabled?:boolean,onAutoDisable?:Function}} [options]
|
||||
* @param {{enabled?:boolean,onAutoDisable?:Function,store:object}} options
|
||||
* `store` is the celestial ring store (src/ui/stores/celestialRing.js);
|
||||
* the keyhole helpers here stay free of Vue for the packages that use
|
||||
* them.
|
||||
*/
|
||||
constructor(viewer, { enabled = true, onAutoDisable = null } = {}) {
|
||||
constructor(viewer, { enabled = true, onAutoDisable = null, store }) {
|
||||
this.viewer = viewer;
|
||||
this.enabled = !!enabled;
|
||||
this.visible = false;
|
||||
@@ -406,6 +414,10 @@ export class CelestialRing {
|
||||
this._sunRenderKey = '';
|
||||
this._moonRenderKey = '';
|
||||
this._outlineRenderKey = '';
|
||||
this._sunCanvas = null;
|
||||
this._sunCtx = null;
|
||||
this._moonCanvas = null;
|
||||
this._moonCtx = null;
|
||||
|
||||
this._sunInertial = new Cesium.Cartesian3();
|
||||
this._moonInertial = new Cesium.Cartesian3();
|
||||
@@ -415,7 +427,7 @@ export class CelestialRing {
|
||||
this._screenCenter = new Cesium.Cartesian2();
|
||||
this._fixedMatrix = new Cesium.Matrix3();
|
||||
|
||||
this._buildDOM();
|
||||
this._initView(store);
|
||||
this._removePostRender = viewer.scene.postRender.addEventListener(() =>
|
||||
this._draw(),
|
||||
);
|
||||
@@ -436,48 +448,68 @@ export class CelestialRing {
|
||||
this.setEnabled(this.enabled);
|
||||
}
|
||||
|
||||
/** Construct cached effect layers and Material Symbols celestial markers. */
|
||||
_buildDOM() {
|
||||
this._root = document.createElement('div');
|
||||
this._root.id = 'celestial-ring-overlay';
|
||||
this._root.className = 'celestial-ring-overlay';
|
||||
this._root.setAttribute('aria-hidden', 'true');
|
||||
|
||||
this._ringOutline = document.createElement('div');
|
||||
this._ringOutline.className = 'celestial-ring-outline';
|
||||
|
||||
this._sunCanvas = document.createElement('canvas');
|
||||
this._sunCanvas.className = 'celestial-ring-canvas celestial-sun-canvas';
|
||||
this._sunCtx = this._sunCanvas.getContext('2d', {
|
||||
alpha: true,
|
||||
desynchronized: true,
|
||||
/** Present the overlay and take the canvases its view hands back. */
|
||||
_initView(store) {
|
||||
const owner = {};
|
||||
this._store = store;
|
||||
this._owner = owner;
|
||||
this._view = {
|
||||
id: ++rings,
|
||||
owner,
|
||||
container: this.viewer.container,
|
||||
enabled: this.enabled,
|
||||
visible: false,
|
||||
globeVisible: null,
|
||||
ephemerisUpdates: null,
|
||||
size: null,
|
||||
outline: null,
|
||||
sun: null,
|
||||
moon: null,
|
||||
sunMarker: null,
|
||||
moonMarker: null,
|
||||
};
|
||||
this._viewKey = '';
|
||||
store.publish(this._view);
|
||||
store.connect({
|
||||
attach: (from, kind, canvas) => {
|
||||
if (from === owner && this._shown()) this._attach(kind, canvas);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this._moonCanvas = document.createElement('canvas');
|
||||
this._moonCanvas.className = 'celestial-ring-canvas celestial-moon-canvas';
|
||||
this._moonCtx = this._moonCanvas.getContext('2d', {
|
||||
alpha: true,
|
||||
desynchronized: true,
|
||||
});
|
||||
/** Whether the store still shows this ring's overlay. */
|
||||
_shown() {
|
||||
return this._store.ring?.owner === this._owner;
|
||||
}
|
||||
|
||||
this._sunMarker = document.createElement('span');
|
||||
this._sunMarker.className =
|
||||
'celestial-marker celestial-sun material-symbols-outlined';
|
||||
this._sunMarker.textContent = 'light_mode';
|
||||
|
||||
this._moonMarker = document.createElement('span');
|
||||
this._moonMarker.className =
|
||||
'celestial-marker celestial-moon material-symbols-outlined';
|
||||
this._moonMarker.textContent = 'dark_mode';
|
||||
|
||||
this._root.append(
|
||||
this._ringOutline,
|
||||
this._sunCanvas,
|
||||
this._moonCanvas,
|
||||
this._sunMarker,
|
||||
this._moonMarker,
|
||||
/** Publish a change to the overlay, skipping one that changes nothing. */
|
||||
_present(patch) {
|
||||
if (!this._shown()) return;
|
||||
const next = { ...this._view, ...patch };
|
||||
const key = JSON.stringify(next, (name, value) =>
|
||||
name === 'owner' || name === 'container' ? undefined : value,
|
||||
);
|
||||
this.viewer.container.appendChild(this._root);
|
||||
if (key === this._viewKey) return;
|
||||
this._view = next;
|
||||
this._viewKey = key;
|
||||
this._store.publish(next);
|
||||
}
|
||||
|
||||
/** Take (or, with null, drop) the canvas the view rendered for one layer. */
|
||||
_attach(kind, canvas) {
|
||||
const ctx =
|
||||
canvas?.getContext('2d', { alpha: true, desynchronized: true }) ?? null;
|
||||
// A new canvas starts blank, so the next frame repaints its layer.
|
||||
if (kind === 'sun') {
|
||||
this._sunCanvas = canvas;
|
||||
this._sunCtx = ctx;
|
||||
this._sunRenderKey = '';
|
||||
} else if (kind === 'moon') {
|
||||
this._moonCanvas = canvas;
|
||||
this._moonCtx = ctx;
|
||||
this._moonRenderKey = '';
|
||||
} else return;
|
||||
if (canvas && this.enabled) governorRequestRender('celestial-ring');
|
||||
}
|
||||
|
||||
/** Enable or disable the user preference for the effect. */
|
||||
@@ -491,12 +523,14 @@ export class CelestialRing {
|
||||
// all. Camera motion covers every later repaint; the 60 s ephemeris
|
||||
// timer requests its own. (perf wave 2 fix — owner playtest finding)
|
||||
if (this.enabled !== wasEnabled) governorRequestRender('celestial-ring');
|
||||
this._root.classList.toggle('disabled', !this.enabled);
|
||||
if (!this.enabled) {
|
||||
this.visible = false;
|
||||
this._root.classList.remove('visible');
|
||||
this._clear();
|
||||
}
|
||||
this._present({
|
||||
enabled: this.enabled,
|
||||
...(!this.enabled && { visible: false }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether the current camera already frames the complete globe inside the keyhole. */
|
||||
@@ -577,7 +611,7 @@ export class CelestialRing {
|
||||
]) {
|
||||
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
this._ringOutline?.style.setProperty('display', 'none');
|
||||
this._present({ outline: { ...this._view.outline, hidden: true } });
|
||||
this._sunRenderKey = '';
|
||||
this._moonRenderKey = '';
|
||||
this._outlineRenderKey = '';
|
||||
@@ -607,10 +641,9 @@ export class CelestialRing {
|
||||
canvas.width = bw;
|
||||
canvas.height = bh;
|
||||
}
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
this._present({ size: { width, height } });
|
||||
this._renderScale = dpr;
|
||||
}
|
||||
|
||||
@@ -665,8 +698,8 @@ export class CelestialRing {
|
||||
);
|
||||
}
|
||||
|
||||
/** Position one icon-library marker along the keyhole circumference. */
|
||||
_positionMarker(marker, cx, cy, radius, angle, opacity) {
|
||||
/** Where one icon-library marker sits along the keyhole circumference. */
|
||||
_markerPosition(cx, cy, radius, angle, opacity) {
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const margin = 14;
|
||||
@@ -679,20 +712,26 @@ export class CelestialRing {
|
||||
? (cy - margin) / Math.abs(sin)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const markerRadius = Math.min(radius, maxRadiusX, maxRadiusY);
|
||||
marker.style.left = `${cx + cos * markerRadius}px`;
|
||||
marker.style.top = `${cy + sin * markerRadius}px`;
|
||||
marker.style.opacity = String(opacity);
|
||||
return {
|
||||
left: cx + cos * markerRadius,
|
||||
top: cy + sin * markerRadius,
|
||||
opacity: String(opacity),
|
||||
};
|
||||
}
|
||||
|
||||
/** Update the cached effect geometry only when camera distance or viewport changes. */
|
||||
_renderEffectLayers(cx, cy, radius, rayInnerRadius) {
|
||||
const outlineKey = `${cx}:${cy}:${radius}`;
|
||||
if (outlineKey !== this._outlineRenderKey) {
|
||||
this._ringOutline.style.display = '';
|
||||
this._ringOutline.style.left = `${cx - radius}px`;
|
||||
this._ringOutline.style.top = `${cy - radius}px`;
|
||||
this._ringOutline.style.width = `${radius * 2}px`;
|
||||
this._ringOutline.style.height = `${radius * 2}px`;
|
||||
this._present({
|
||||
outline: {
|
||||
hidden: false,
|
||||
left: cx - radius,
|
||||
top: cy - radius,
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
},
|
||||
});
|
||||
this._outlineRenderKey = outlineKey;
|
||||
}
|
||||
|
||||
@@ -724,7 +763,11 @@ export class CelestialRing {
|
||||
|
||||
/** Per-frame camera projection with GPU-composited cached effect layers. */
|
||||
_draw() {
|
||||
if (!this.enabled || !this._sunCtx || !this._moonCtx) return;
|
||||
// A ring the store no longer shows has handed its overlay to a later one.
|
||||
if (!this.enabled || !this._shown()) return;
|
||||
// Without its canvases (no view rendered them) the ring still tracks
|
||||
// whether the globe is framed, so it turns itself off as before.
|
||||
const painted = !!(this._sunCtx && this._moonCtx);
|
||||
const now = performance.now();
|
||||
if (now < this._nextDrawAt) return;
|
||||
this._nextDrawAt = now + 1000 / CELESTIAL_MAX_FRAME_RATE;
|
||||
@@ -732,7 +775,7 @@ export class CelestialRing {
|
||||
const width = canvas.clientWidth || canvas.width;
|
||||
const height = canvas.clientHeight || canvas.height;
|
||||
if (!(width > 0 && height > 0)) return;
|
||||
this._resize(width, height);
|
||||
if (painted) this._resize(width, height);
|
||||
|
||||
const disc = this._projectedEarthDisc(width, height);
|
||||
const wasVisible = this.visible;
|
||||
@@ -740,9 +783,11 @@ export class CelestialRing {
|
||||
? isFullGlobeInsideKeyhole(disc, wasVisible)
|
||||
: false;
|
||||
this.visible = nextVisible;
|
||||
this._root.classList.toggle('visible', nextVisible);
|
||||
this._root.dataset.globeVisible = String(nextVisible);
|
||||
this._root.dataset.ephemerisUpdates = String(this._ephemerisUpdateCount);
|
||||
this._present({
|
||||
visible: nextVisible,
|
||||
globeVisible: String(nextVisible),
|
||||
ephemerisUpdates: String(this._ephemerisUpdateCount),
|
||||
});
|
||||
if (!nextVisible || !disc) {
|
||||
this._clear();
|
||||
this._debug = { enabled: true, visible: false, disc };
|
||||
@@ -751,10 +796,14 @@ export class CelestialRing {
|
||||
}
|
||||
|
||||
if (!wasVisible) this._ephemerisDirty = true;
|
||||
if (!painted) {
|
||||
this._debug = { enabled: true, visible: true, disc };
|
||||
return;
|
||||
}
|
||||
|
||||
const time = Cesium.JulianDate.now();
|
||||
if (!this._updateEphemeris(time)) return;
|
||||
this._root.dataset.ephemerisUpdates = String(this._ephemerisUpdateCount);
|
||||
this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) });
|
||||
const camera = this.viewer.camera;
|
||||
const sunProjection = celestialScreenAngle(
|
||||
Cesium.Cartesian3.dot(this._sunFixed, camera.rightWC),
|
||||
@@ -779,10 +828,6 @@ export class CelestialRing {
|
||||
disc.earthRadius + Math.max(38, radius * 0.065),
|
||||
);
|
||||
this._renderEffectLayers(cx, cy, radius, rayInnerRadius);
|
||||
this._sunCanvas.style.transform = `rotate(${this._sunAngle}rad)`;
|
||||
this._sunCanvas.style.opacity = String(this._sunOpacity);
|
||||
this._moonCanvas.style.transform = `rotate(${this._moonAngle}rad)`;
|
||||
this._moonCanvas.style.opacity = String(this._moonOpacity);
|
||||
|
||||
// Markers sit inside the illuminated band rather than straddling its outer
|
||||
// stroke. Near conjunction (such as a new moon), keep the true bearings but
|
||||
@@ -791,22 +836,30 @@ export class CelestialRing {
|
||||
const markersCollide =
|
||||
circularAngleDistance(this._sunAngle, this._moonAngle) <
|
||||
MARKER_COLLISION_ANGLE;
|
||||
this._positionMarker(
|
||||
this._sunMarker,
|
||||
cx,
|
||||
cy,
|
||||
markerRadius,
|
||||
this._sunAngle,
|
||||
this._sunOpacity,
|
||||
);
|
||||
this._positionMarker(
|
||||
this._moonMarker,
|
||||
cx,
|
||||
cy,
|
||||
markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0),
|
||||
this._moonAngle,
|
||||
this._moonOpacity,
|
||||
);
|
||||
this._present({
|
||||
sun: {
|
||||
transform: `rotate(${this._sunAngle}rad)`,
|
||||
opacity: String(this._sunOpacity),
|
||||
},
|
||||
moon: {
|
||||
transform: `rotate(${this._moonAngle}rad)`,
|
||||
opacity: String(this._moonOpacity),
|
||||
},
|
||||
sunMarker: this._markerPosition(
|
||||
cx,
|
||||
cy,
|
||||
markerRadius,
|
||||
this._sunAngle,
|
||||
this._sunOpacity,
|
||||
),
|
||||
moonMarker: this._markerPosition(
|
||||
cx,
|
||||
cy,
|
||||
markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0),
|
||||
this._moonAngle,
|
||||
this._moonOpacity,
|
||||
),
|
||||
});
|
||||
this._debug = {
|
||||
enabled: true,
|
||||
visible: true,
|
||||
@@ -831,7 +884,7 @@ export class CelestialRing {
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Detach the render hook and remove all overlay DOM. */
|
||||
/** Detach the render hook and withdraw the overlay. */
|
||||
destroy() {
|
||||
if (this._ephemerisTimer) {
|
||||
clearInterval(this._ephemerisTimer);
|
||||
@@ -839,6 +892,10 @@ export class CelestialRing {
|
||||
}
|
||||
if (this._removePostRender) this._removePostRender();
|
||||
this._removePostRender = null;
|
||||
this._root?.remove();
|
||||
if (this._shown()) {
|
||||
this._store.connect(null);
|
||||
this._store.publish(null);
|
||||
}
|
||||
this._sunCanvas = this._sunCtx = this._moonCanvas = this._moonCtx = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// src/celestialRingView.test.mjs — the full-globe ring renders in the
|
||||
// viewer's container from what the ring owner presents, and the owner paints
|
||||
// the canvases the view hands back.
|
||||
import './testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useCelestialRingStore } from './ui/stores/celestialRing.js';
|
||||
import CelestialRingView from './ui/views/panels/CelestialRing.vue';
|
||||
import * as Cesium from 'cesium';
|
||||
import { CelestialRing } from './celestialRing.js';
|
||||
|
||||
// happy-dom has no 2D context; a stand-in takes every drawing call.
|
||||
const context = new Proxy(
|
||||
{},
|
||||
{ get: (target, name) => target[name] ?? (() => context), set: () => true },
|
||||
);
|
||||
globalThis.HTMLCanvasElement.prototype.getContext = () => context;
|
||||
|
||||
function fakeViewer(container) {
|
||||
return {
|
||||
container,
|
||||
camera: {
|
||||
rightWC: new Cesium.Cartesian3(1, 0, 0),
|
||||
upWC: new Cesium.Cartesian3(0, 1, 0),
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: 400, clientHeight: 300, width: 400, height: 300 },
|
||||
postRender: { addEventListener: () => () => {} },
|
||||
requestRender() {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const container = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const store = useCelestialRingStore(pinia);
|
||||
const app = createApp(CelestialRingView).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
const rings = [];
|
||||
const create = (options = {}) => {
|
||||
const ring = new CelestialRing(fakeViewer(container), {
|
||||
enabled: false,
|
||||
store,
|
||||
...options,
|
||||
});
|
||||
rings.push(ring);
|
||||
return ring;
|
||||
};
|
||||
t.after(() => {
|
||||
for (const ring of rings) ring.destroy();
|
||||
app.unmount();
|
||||
});
|
||||
const $ = (selector) => container.querySelector(selector);
|
||||
const settle = async () => {
|
||||
for (let flush = 0; flush < 4; flush += 1) await nextTick();
|
||||
};
|
||||
return { container, store, create, $, settle };
|
||||
}
|
||||
|
||||
test('the ring renders its overlay in the viewer container', async (t) => {
|
||||
const { create, $, settle } = fixture(t);
|
||||
const ring = create();
|
||||
await settle();
|
||||
const root = $('#celestial-ring-overlay');
|
||||
assert.equal(root.className, 'celestial-ring-overlay disabled');
|
||||
assert.equal(root.getAttribute('aria-hidden'), 'true');
|
||||
assert.equal(root.hasAttribute('data-globe-visible'), false);
|
||||
assert.deepEqual(
|
||||
[...root.children].map((child) => [child.tagName, child.className]),
|
||||
[
|
||||
['DIV', 'celestial-ring-outline'],
|
||||
['CANVAS', 'celestial-ring-canvas celestial-sun-canvas'],
|
||||
['CANVAS', 'celestial-ring-canvas celestial-moon-canvas'],
|
||||
['SPAN', 'celestial-marker celestial-sun material-symbols-outlined'],
|
||||
['SPAN', 'celestial-marker celestial-moon material-symbols-outlined'],
|
||||
],
|
||||
);
|
||||
assert.equal(root.children[0].style.display, 'none');
|
||||
assert.equal(root.children[3].textContent, 'light_mode');
|
||||
assert.equal(root.children[4].textContent, 'dark_mode');
|
||||
assert.ok(ring._sunCanvas === root.children[1]);
|
||||
assert.ok(ring._moonCanvas === root.children[2]);
|
||||
ring.setEnabled(true);
|
||||
await settle();
|
||||
assert.equal(root.className, 'celestial-ring-overlay');
|
||||
ring.destroy();
|
||||
await settle();
|
||||
assert.equal($('#celestial-ring-overlay'), null);
|
||||
assert.equal(ring._sunCanvas, null);
|
||||
});
|
||||
|
||||
test('a drawn frame places the outline, layers and markers', async (t) => {
|
||||
const { create, $, settle } = fixture(t);
|
||||
const ring = create({ enabled: true });
|
||||
await settle();
|
||||
ring._present({ visible: true, globeVisible: 'true', ephemerisUpdates: '1' });
|
||||
ring._resize(400, 300);
|
||||
ring._renderEffectLayers(200, 150, 120, 80);
|
||||
ring._present({
|
||||
sun: { transform: 'rotate(1rad)', opacity: '0.5' },
|
||||
sunMarker: ring._markerPosition(200, 150, 100, 0, 1),
|
||||
});
|
||||
await settle();
|
||||
const root = $('#celestial-ring-overlay');
|
||||
assert.equal(root.className, 'celestial-ring-overlay visible');
|
||||
assert.equal(root.dataset.globeVisible, 'true');
|
||||
assert.equal(root.dataset.ephemerisUpdates, '1');
|
||||
const [outline, sun, moon, sunMarker] = root.children;
|
||||
assert.equal(outline.style.display, '');
|
||||
assert.equal(outline.style.left, '80px');
|
||||
assert.equal(outline.style.width, '240px');
|
||||
assert.equal(sun.style.width, '400px');
|
||||
assert.equal(sun.style.transform, 'rotate(1rad)');
|
||||
assert.equal(sun.style.opacity, '0.5');
|
||||
assert.equal(moon.style.height, '300px');
|
||||
assert.equal(sunMarker.style.left, '300px');
|
||||
assert.equal(sunMarker.style.top, '150px');
|
||||
ring.setEnabled(false);
|
||||
await settle();
|
||||
assert.equal(root.className, 'celestial-ring-overlay disabled');
|
||||
assert.equal(outline.style.display, 'none');
|
||||
});
|
||||
|
||||
test("an earlier ring leaves a later ring's overlay alone", async (t) => {
|
||||
const { create, store, $, settle } = fixture(t);
|
||||
const earlier = create();
|
||||
await settle();
|
||||
const later = create();
|
||||
await settle();
|
||||
const root = $('#celestial-ring-overlay');
|
||||
assert.ok(later._sunCanvas === root.children[1]);
|
||||
earlier.setEnabled(true);
|
||||
earlier.destroy();
|
||||
await settle();
|
||||
assert.ok(store.ring.owner === later._owner);
|
||||
assert.equal(
|
||||
$('#celestial-ring-overlay').className,
|
||||
'celestial-ring-overlay disabled',
|
||||
);
|
||||
assert.ok(later._sunCanvas === $('.celestial-sun-canvas'));
|
||||
});
|
||||
|
||||
// A globe framed well inside the keyhole of a 400 x 300 view.
|
||||
const FRAMED = {
|
||||
earthCenterX: 200,
|
||||
earthCenterY: 150,
|
||||
earthRadius: 40,
|
||||
keyholeCenterX: 200,
|
||||
keyholeCenterY: 150,
|
||||
keyholeRadius: 140,
|
||||
};
|
||||
|
||||
/** Run one rendered frame, past the frame-rate throttle. */
|
||||
function frame(ring, disc) {
|
||||
ring._projectedEarthDisc = () => disc;
|
||||
// Fixed sun and moon directions; Cesium's own model would fetch its
|
||||
// orientation data.
|
||||
ring._updateEphemeris = () => {
|
||||
if (ring._ephemerisDirty) {
|
||||
Cesium.Cartesian3.clone(new Cesium.Cartesian3(1, 0, 0), ring._sunFixed);
|
||||
Cesium.Cartesian3.clone(new Cesium.Cartesian3(0, 1, 0), ring._moonFixed);
|
||||
ring._ephemerisDirty = false;
|
||||
ring._ephemerisUpdateCount += 1;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
ring._nextDrawAt = 0;
|
||||
ring._draw();
|
||||
}
|
||||
|
||||
test('a rendered frame publishes the layers and markers it painted', async (t) => {
|
||||
const { create, $, settle } = fixture(t);
|
||||
const ring = create({ enabled: true });
|
||||
await settle();
|
||||
frame(ring, FRAMED);
|
||||
await settle();
|
||||
const root = $('#celestial-ring-overlay');
|
||||
assert.equal(root.className, 'celestial-ring-overlay visible');
|
||||
assert.equal(root.dataset.globeVisible, 'true');
|
||||
assert.equal(root.dataset.ephemerisUpdates, '1');
|
||||
const [outline, sun, moon, sunMarker, moonMarker] = root.children;
|
||||
const debug = ring.getDebugState();
|
||||
assert.equal(debug.visible, true);
|
||||
assert.equal(outline.style.display, '');
|
||||
// The outline is the keyhole circle, a fixed inset inside its radius.
|
||||
const diameter = parseFloat(outline.style.width);
|
||||
assert.ok(diameter > 0 && diameter < 280);
|
||||
assert.equal(outline.style.height, outline.style.width);
|
||||
assert.equal(outline.style.left, `${200 - diameter / 2}px`);
|
||||
assert.equal(outline.style.top, `${150 - diameter / 2}px`);
|
||||
assert.equal(sun.style.width, '400px');
|
||||
assert.equal(moon.style.height, '300px');
|
||||
assert.equal(sun.style.transform, `rotate(${debug.sunAngle}rad)`);
|
||||
assert.equal(moon.style.transform, `rotate(${debug.moonAngle}rad)`);
|
||||
assert.equal(sun.style.opacity, String(debug.sunOpacity));
|
||||
assert.equal(moon.style.opacity, String(debug.moonOpacity));
|
||||
assert.ok(sunMarker.style.left.endsWith('px'));
|
||||
assert.ok(moonMarker.style.top.endsWith('px'));
|
||||
assert.equal(moonMarker.style.opacity, String(debug.moonOpacity));
|
||||
});
|
||||
|
||||
test('a frame without the globe framed turns the ring off', async (t) => {
|
||||
const { create, $, settle } = fixture(t);
|
||||
let disabled = 0;
|
||||
const ring = create({ enabled: true, onAutoDisable: () => (disabled += 1) });
|
||||
await settle();
|
||||
frame(ring, FRAMED);
|
||||
frame(ring, null);
|
||||
await settle();
|
||||
assert.equal(ring.enabled, false);
|
||||
assert.equal(disabled, 1);
|
||||
assert.equal(
|
||||
$('#celestial-ring-overlay').className,
|
||||
'celestial-ring-overlay disabled',
|
||||
);
|
||||
});
|
||||
|
||||
test('without a rendered view the ring still tracks the globe and turns off', () => {
|
||||
const pinia = createPinia();
|
||||
let disabled = 0;
|
||||
const ring = new CelestialRing(fakeViewer({}), {
|
||||
enabled: true,
|
||||
store: useCelestialRingStore(pinia),
|
||||
onAutoDisable: () => (disabled += 1),
|
||||
});
|
||||
frame(ring, FRAMED);
|
||||
assert.equal(ring.visible, true);
|
||||
frame(ring, null);
|
||||
assert.equal(ring.enabled, false);
|
||||
assert.equal(disabled, 1);
|
||||
ring.destroy();
|
||||
});
|
||||
|
||||
test('a ring the store no longer shows stops drawing', async (t) => {
|
||||
const { create, settle } = fixture(t);
|
||||
let disabled = 0;
|
||||
const earlier = create({
|
||||
enabled: true,
|
||||
onAutoDisable: () => (disabled += 1),
|
||||
});
|
||||
await settle();
|
||||
create();
|
||||
await settle();
|
||||
frame(earlier, null);
|
||||
assert.equal(earlier.enabled, true);
|
||||
assert.equal(disabled, 0);
|
||||
});
|
||||
+122
-81
@@ -2,7 +2,7 @@ import {
|
||||
readShellSource,
|
||||
shellMethod,
|
||||
} from './testSupport/readShellSource.mjs';
|
||||
import { expandApplicationHtml } from '../build/application-html.js';
|
||||
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
|
||||
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
||||
import { PanelLayoutController } from './ui/panelLayoutController.js';
|
||||
import { readShellElements } from './ui/shellElements.js';
|
||||
@@ -16,14 +16,20 @@ import {
|
||||
readAircraftInfo,
|
||||
_adoptTrackedEntity,
|
||||
} from './ui/cockpitTrackingController.js';
|
||||
import { setVisionMode, cycleVisionMode } from './ui/cockpitInstruments.js';
|
||||
import { cycleVisionMode } from './ui/cockpitInstruments.js';
|
||||
import { createVisionState } from './ui/cockpitInstrumentState.js';
|
||||
import { COCKPIT_VISION_MODES } from './cockpitVisionPolicy.js';
|
||||
import { updateContext as cockpitContext } from './ui/cockpitContext.js';
|
||||
import {
|
||||
syncSignalLayout,
|
||||
setContextCollapsed,
|
||||
setSignalCollapsed,
|
||||
} from './ui/cockpitLayout.js';
|
||||
import { setBriefAutoRotate } from './ui/cockpitBriefing.js';
|
||||
import { createBriefState } from './ui/cockpitBriefPages.js';
|
||||
import {
|
||||
publishBriefView,
|
||||
setBriefAutoRotate,
|
||||
} from './ui/cockpitBriefing.js';
|
||||
import { CockpitDisplayPortal } from './ui/cockpitDisplayPortal.js';
|
||||
import { _handleContextLayerChange } from './ui/contextLayerChanges.js';
|
||||
import { clearSelectedLayers } from './ui/contextActions.js';
|
||||
@@ -46,7 +52,7 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const html = expandApplicationHtml(
|
||||
const html = await renderApplicationHtml(
|
||||
fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8'),
|
||||
);
|
||||
const ui = readShellSource();
|
||||
@@ -149,11 +155,10 @@ test('Cockpit vision cycle exposes every map preset exactly once', () => {
|
||||
cycleVisionMode.toString(),
|
||||
/const modes = COCKPIT_VISION_MODES;/,
|
||||
);
|
||||
assert.match(
|
||||
setVisionMode.toString(),
|
||||
/const labels = \{\s*optical: t\('NORMAL'\),\s*crt: t\('CRT'\),\s*nvg: t\('NVG'\),\s*thermal: t\('FLIR'\),\s*anime: t\('ANIME'\),\s*noir: t\('NOIR'\),?\s*\};/,
|
||||
assert.deepEqual(
|
||||
COCKPIT_VISION_MODES.map((mode) => createVisionState(mode).label),
|
||||
['NORMAL', 'CRT', 'NVG', 'FLIR', 'ANIME', 'NOIR'],
|
||||
);
|
||||
assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/);
|
||||
assert.doesNotMatch(ui, /getInheritedVisionLabel/);
|
||||
assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/);
|
||||
assert.match(
|
||||
@@ -230,7 +235,7 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
|
||||
test('Cockpit shortcut failures do not leak and open utilities own the first Escape', () => {
|
||||
const keydown = cockpitKeyDown.toString();
|
||||
assert.ok(keydown, 'Cockpit keyboard handler is missing');
|
||||
assert.match(keydown, /#cockpit-utility-controls \[aria-expanded="true"\]/);
|
||||
assert.match(keydown, /if \(this\.isUtilityDisclosureOpen\?\.\(\)\) return;/);
|
||||
assert.match(
|
||||
keydown,
|
||||
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
|
||||
@@ -246,10 +251,10 @@ test('the Contact panel never hides itself out from under its own NEXT button',
|
||||
assert.match(body, /resolveCockpitContextReadout\(\{ snapshot, info \}\)/);
|
||||
assert.match(
|
||||
body,
|
||||
/if \(!readout\.visible\) \{[\s\S]*?this\.context\.hidden = true;/,
|
||||
/if \(!readout\.visible\) \{[\s\S]*?this\.showContextCard\(false\);/,
|
||||
);
|
||||
assert.equal(
|
||||
(body.match(/this\.context\.hidden = true/g) || []).length,
|
||||
(body.match(/this\.showContextCard\(false\)/g) || []).length,
|
||||
1,
|
||||
'the panel has exactly one hide path, and it is the no-snapshot standby case',
|
||||
);
|
||||
@@ -264,7 +269,7 @@ test('the Contact panel never hides itself out from under its own NEXT button',
|
||||
// A culled subject holds last-known content behind the CONTACT LOST cue.
|
||||
assert.match(body, /readout\.contactLost/);
|
||||
assert.match(body, /CONTACT LOST/);
|
||||
assert.match(body, /this\.context\.dataset\.state = 'lost'/);
|
||||
assert.match(body, /view\.state = 'lost'/);
|
||||
assert.match(
|
||||
body,
|
||||
/if \(readout\.contactLost\) \{/,
|
||||
@@ -272,14 +277,14 @@ test('the Contact panel never hides itself out from under its own NEXT button',
|
||||
);
|
||||
// PREVIOUS/NEXT must be written before any early return, so the operator can
|
||||
// always step off the current contact — including a lost one.
|
||||
const navIndex = body.indexOf('this.contextNext.disabled');
|
||||
const navIndex = body.indexOf('view.canNext =');
|
||||
const lostIndex = body.indexOf('if (readout.contactLost)');
|
||||
assert.ok(
|
||||
navIndex >= 0 && lostIndex > navIndex,
|
||||
'nav state must be written before the CONTACT LOST return',
|
||||
);
|
||||
assert.equal(
|
||||
(body.match(/this\.contextNext\.disabled/g) || []).length,
|
||||
(body.match(/view\.canNext =/g) || []).length,
|
||||
1,
|
||||
'one owner for the NEXT enabled state, reached by every visible path',
|
||||
);
|
||||
@@ -471,14 +476,6 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
|
||||
css,
|
||||
/\.cockpit-utility-control\.is-expanded \.cockpit-utility-divider\s*\{[\s\S]*?linear-gradient\(\s*90deg,\s*rgb\(0 212 255 \/ 28%\),\s*rgba\(0, 212, 255, 0\.18\) 58%,\s*transparent\s*\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, 0\.22\);/,
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/,
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/#cockpit-display-panel\s*\{[\s\S]*?display:\s*flex;[\s\S]*?gap:\s*7px;[\s\S]*?padding:\s*0;[\s\S]*?border-top:\s*0;/,
|
||||
@@ -530,7 +527,7 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
|
||||
);
|
||||
assert.match(
|
||||
syncSignalLayout.toString(),
|
||||
/setAttribute\('aria-hidden', String\(hiddenSibling\)\)/,
|
||||
/primaryOnly: utilityLayout\.primaryOnly,\s*hideCollapsed: utilityLayout\.primaryOnly,/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -546,9 +543,14 @@ test('Display orders 3D above Celestial, Clean UI below it, and Parameters below
|
||||
1,
|
||||
'Parameters must have one DOM owner',
|
||||
);
|
||||
// The markup places Parameters below Detection; nothing re-homes it.
|
||||
assert.match(
|
||||
html,
|
||||
/id="detection-toggle"[\s\S]*?id="param-slider-panel"[\s\S]*?id="models3d-toggle"/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
PanelLayoutController.prototype._initRightPanelAdaptiveLayout.toString(),
|
||||
/const detectionGroup = this\._detectionBtn\?\.closest\('\.pp-toggle-group'\);[\s\S]*?detectionGroup\.after\(this\._sliderPanel\)/,
|
||||
/_sliderPanel/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
@@ -631,17 +633,22 @@ test('Cockpit side surfaces behave as two single-expanded accordions', () => {
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);/,
|
||||
/'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);\s*this\.actions\.setPanelCollapsed\('utilities-panel', true\);/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/!nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed[\s\S]*?this\.cockpitView\.setContextCollapsed\(true\);/,
|
||||
/!nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*COCKPIT_LEFT_PANEL_IDS\.has\(panelId\)[\s\S]*?_cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed[\s\S]*?this\.cockpitView\.setContextCollapsed\(true\);/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
|
||||
/nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*COCKPIT_LEFT_PANEL_IDS\.has\(panelId\)[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
|
||||
'closing Data Layers must restore Contact only after an automatic collapse',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/const COCKPIT_LEFT_PANEL_IDS = new Set\(\['data-panel', 'utilities-panel'\]\);/,
|
||||
'Utilities folds Contact away in Cockpit as Data Layers does',
|
||||
);
|
||||
assert.match(
|
||||
setContextCollapsed.toString(),
|
||||
/const wasCollapsed = this\.contextCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.contextCollapsed[\s\S]*?'gev:cockpit-context-expanded'/,
|
||||
@@ -676,6 +683,7 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
|
||||
for (const panelId of [
|
||||
'data-panel',
|
||||
'cctv-panel',
|
||||
'utilities-panel',
|
||||
'scene-panel',
|
||||
'pp-toggles',
|
||||
'global-context-panel',
|
||||
@@ -699,7 +707,7 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
|
||||
);
|
||||
assert.match(
|
||||
callback[1],
|
||||
/_cockpitPanelRestore\.set\(\s*panelId,\s*panel\.classList\.contains\('collapsed'\),?\s*\)/,
|
||||
/_cockpitPanelRestore\.set\(\s*panelId,\s*this\._shownPanel\(panel\)\.collapsed,?\s*\)/,
|
||||
"entry must remember each panel's exact collapsed state before hiding it",
|
||||
);
|
||||
assert.match(
|
||||
@@ -760,7 +768,7 @@ test('real disclosure changes reconsider only their own temporary panel lane', (
|
||||
);
|
||||
assert.match(
|
||||
collapse,
|
||||
/classList\.contains\('collapsed'\) === nextCollapsed\s*&&\s*!wasAutoCollapsed[\s\S]*?return;/,
|
||||
/this\._shownPanel\(panelEl\)\.collapsed === nextCollapsed\s*&&\s*!wasAutoCollapsed[\s\S]*?return;/,
|
||||
);
|
||||
assert.match(
|
||||
collapse,
|
||||
@@ -798,35 +806,29 @@ test('Reset releases Contact camera ownership through its selection-preserving r
|
||||
assert.ok(resetStart >= 0);
|
||||
assert.ok(contextRelease > resetStart);
|
||||
assert.ok(satelliteRelease > contextRelease);
|
||||
assert.match(
|
||||
readShellElements.toString(),
|
||||
/_cockpitResetGlobeBtn: document\.getElementById\('cockpit-reset-globe'\)/,
|
||||
);
|
||||
const controls = fs.readFileSync(
|
||||
path.join(ROOT, 'src', 'ui', 'locationControls.js'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/resetButtons: \[this\._resetGlobeBtn, this\._cockpitResetGlobeBtn\]/,
|
||||
);
|
||||
assert.match(ui, /onReset: \(\) => this\.resetToGlobeView\(\)/);
|
||||
assert.match(
|
||||
controls,
|
||||
/for \(const button of elements\.resetButtons\)[\s\S]*?this\.bind\(button, 'click', onReset\)/,
|
||||
'both reset controls delegate to the same supplied reset action',
|
||||
assert.match(ui, /this\._connectGlobeReset\?\.\(this\._locationControls\)/);
|
||||
assert.match(controls, /reset\(\) \{\s*if \(!this\.destroyed\) return this\.onReset\(\);/);
|
||||
// Both reset buttons ask the one reset owner.
|
||||
const globeActions = fs.readFileSync(
|
||||
path.join(ROOT, 'src', 'ui', 'views', 'panels', 'GlobeActions.vue'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(globeActions, /id="reset-globe-view"[^>]*@click="act\('reset'\)"/);
|
||||
const cockpitView = readRadioSource(
|
||||
new URL('./ui/views/CockpitView.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(cockpitView, /id="cockpit-reset-globe"[^>]*@click="globe\.act\('reset'\)"/);
|
||||
assert.match(
|
||||
cockpitEnter.toString(),
|
||||
/if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = false;/,
|
||||
);
|
||||
assert.match(
|
||||
readRadioSource(
|
||||
new URL('./ui/cockpitTrackingController.js', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
/if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = true;/,
|
||||
/Object\.assign\(this\.presence, \{\s*active: true,/,
|
||||
);
|
||||
assert.match(cockpitView, /id="cockpit-reset-globe"[^>]*:hidden="!active"/);
|
||||
});
|
||||
|
||||
test('Location navigation releases immediate routes before flight and deferred routes after resolution', () => {
|
||||
@@ -888,21 +890,24 @@ test('Cockpit Radio station changes preserve first-person camera ownership', ()
|
||||
);
|
||||
assert.ok(cycleHelper, 'shared Radio cycle helper is missing');
|
||||
assert.match(cycleHelper[1], /cycleStation\(direction, \{[\s\S]*?rotate,/);
|
||||
assert.match(radioBindings, /\n cycleRadio,\n/);
|
||||
const panel = fs.readFileSync(
|
||||
new URL('./ui/views/panels/RadioPanel.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const cockpit = fs.readFileSync(
|
||||
new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(panel, /id="radio-prev-btn" @click="act\('cycleRadio', -1\)"/);
|
||||
assert.match(panel, /id="radio-next-btn" @click="act\('cycleRadio', 1\)"/);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/listen\(this\._radioPrevBtn, 'click', \(\) => cycleRadio\(-1\)\)/,
|
||||
cockpit,
|
||||
/id="cockpit-radio-prev-btn" @click="act\('cycleRadio', -1, \{ rotate: false \}\)"/,
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/listen\(this\._radioNextBtn, 'click', \(\) => cycleRadio\(1\)\)/,
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/listen\(this\._cockpitRadioPrevBtn, 'click', \(\) =>\s*cycleRadio\(-1, \{ rotate: false \}\),?\s*\)/,
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/listen\(this\._cockpitRadioNextBtn, 'click', \(\) =>\s*cycleRadio\(1, \{ rotate: false \}\),?\s*\)/,
|
||||
cockpit,
|
||||
/id="cockpit-radio-next-btn" @click="act\('cycleRadio', 1, \{ rotate: false \}\)"/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -940,7 +945,7 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => {
|
||||
assert.ok(signalLayout, 'Cockpit signal layout method is missing');
|
||||
assert.match(
|
||||
signalLayout,
|
||||
/setProperty\(\s*'--cockpit-utility-top',\s*`\$\{utilityAnchor\.top\.toFixed\(1\)\}px`,?\s*\)/,
|
||||
/publishUtilityLayout\([\s\S]*?top: `\$\{utilityAnchor\.top\.toFixed\(1\)\}px`,/,
|
||||
'Cockpit owns the utility strip anchor and republishes it every layout tick',
|
||||
);
|
||||
assert.match(
|
||||
@@ -976,7 +981,7 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => {
|
||||
);
|
||||
assert.match(
|
||||
signalLayout,
|
||||
/cockpit-utility-controls[\s\S]*?--cockpit-utility-max-height/,
|
||||
/maxHeight: `\$\{availableHeight\.toFixed\(2\)\}px`,/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
@@ -1061,15 +1066,16 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls
|
||||
assert.doesNotMatch(ui, /\['presets',/);
|
||||
assert.match(
|
||||
CockpitDisplayPortal.toString(),
|
||||
/group\.before\(anchor\)[\s\S]*?window\.addEventListener\(\s*'gev:cockpit-mode-changed'/,
|
||||
/window\.addEventListener\(\s*'gev:cockpit-mode-changed'/,
|
||||
);
|
||||
// The portal publishes the slots; the Display panel teleports its groups.
|
||||
assert.match(
|
||||
CockpitDisplayPortal.prototype.setActive.toString(),
|
||||
/record\.slot\.append\(record\.group\)[\s\S]*?record\.anchor\.after\(record\.group\)/,
|
||||
/this\.publish\(nextActive \? this\.slots : null\)/,
|
||||
);
|
||||
assert.match(
|
||||
CockpitDisplayPortal.prototype.destroy.toString(),
|
||||
/this\.stop\(\)[\s\S]*?record\.anchor\.after\(record\.group\)[\s\S]*?record\.anchor\.remove\(\)/,
|
||||
/this\.stop\(\)[\s\S]*?this\.publish\(null\)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
ui,
|
||||
@@ -1091,7 +1097,7 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?aria-expanded'\) !== 'true'[\s\S]*?classList\.remove\('collapsed'\)/,
|
||||
/_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?!this\._cockpitDisclosureOpen\?\.\('display'\)[\s\S]*?_presentPanel\(this\._sliderPanel, \{ collapsed: false \}\)/,
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
@@ -1176,7 +1182,7 @@ test('cockpit aircraft handoff invalidates the prior world-position anchor', ()
|
||||
|
||||
test('cockpit weather control is off before JavaScript restores an explicit opt-in', () => {
|
||||
const match = html.match(
|
||||
/<button\s+id="cockpit-weather-toggle"[\s\S]*?<\/button>/,
|
||||
/<button[^>]*\sid="cockpit-weather-toggle"[\s\S]*?<\/button>/,
|
||||
);
|
||||
assert.ok(match, 'cockpit weather toggle markup is missing');
|
||||
assert.match(match[0], /aria-label="Enable cockpit weather effects"/);
|
||||
@@ -1188,7 +1194,7 @@ test('cockpit summary presents the focused item as Contact', () => {
|
||||
const match = html.match(/<aside id="cockpit-context"[\s\S]*?<\/aside>/);
|
||||
assert.ok(match, 'cockpit Contact summary is missing');
|
||||
assert.match(match[0], /aria-label="Contact cockpit summary"/);
|
||||
assert.match(match[0], /class="cockpit-context-kicker">CONTACT</);
|
||||
assert.match(match[0], /class="cockpit-card-kicker cockpit-context-kicker">CONTACT</);
|
||||
assert.match(match[0], /aria-label="Contact navigation"/);
|
||||
assert.match(
|
||||
match[0],
|
||||
@@ -1200,9 +1206,13 @@ test('cockpit summary presents the focused item as Contact', () => {
|
||||
);
|
||||
assert.match(match[0], /aria-label="Collapse Contact panel"/);
|
||||
assert.doesNotMatch(match[0], />GLOBAL CONTEXT</);
|
||||
// The toggle renders the collapse the controller presents.
|
||||
assert.match(
|
||||
setContextCollapsed.toString(),
|
||||
/expanded \? t\('Collapse Contact panel'\) : t\('Expand Contact panel'\)/,
|
||||
fs.readFileSync(
|
||||
new URL('./ui/views/panels/CockpitContext.vue', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
/collapsed \? t\('Expand Contact panel'\) : t\('Collapse Contact panel'\)/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1258,7 +1268,7 @@ test('Global Context standby describes both chooser modes', () => {
|
||||
|
||||
test('cockpit briefing cycle control keeps its state as the accessible name', () => {
|
||||
const match = html.match(
|
||||
/<button\s+id="cockpit-brief-auto"[\s\S]*?<\/button>/,
|
||||
/<button[^>]*\sid="cockpit-brief-auto"[\s\S]*?<\/button>/,
|
||||
);
|
||||
assert.ok(match, 'cockpit briefing cycle toggle is missing');
|
||||
assert.match(match[0], /aria-label="CYCLE OFF"/);
|
||||
@@ -1269,15 +1279,22 @@ test('cockpit briefing cycle control keeps its state as the accessible name', ()
|
||||
/title="Cycle briefing pages automatically every 9 seconds \(Signals → News → Local\)\./,
|
||||
);
|
||||
|
||||
const update = setBriefAutoRotate.toString();
|
||||
assert.ok(update, 'cockpit briefing cycle state updater is missing');
|
||||
assert.match(
|
||||
update,
|
||||
/const label = this\.briefAutoRotateEnabled \? 'CYCLE ON' : 'CYCLE OFF';/,
|
||||
);
|
||||
assert.match(update, /setAttribute\('aria-label', label\)/);
|
||||
assert.match(update, /\.title = help;/);
|
||||
assert.doesNotMatch(update, /setAttribute\('aria-label', help\)/);
|
||||
// The presented label is the state; the help is only the tooltip.
|
||||
const views = [];
|
||||
const owner = {
|
||||
brief: createBriefState(),
|
||||
publishBrief: (view) => views.push(view),
|
||||
publishBriefView,
|
||||
startBriefRotation() {},
|
||||
stopBriefRotation() {},
|
||||
};
|
||||
setBriefAutoRotate.call(owner, true);
|
||||
assert.equal(views.at(-1).autoLabel, 'CYCLE ON');
|
||||
assert.equal(views.at(-1).autoRotate, true);
|
||||
assert.match(views.at(-1).autoHelp, /^Stop automatic page cycling\./);
|
||||
setBriefAutoRotate.call(owner, false);
|
||||
assert.equal(views.at(-1).autoLabel, 'CYCLE OFF');
|
||||
assert.match(views.at(-1).autoHelp, /^Cycle briefing pages automatically/);
|
||||
});
|
||||
|
||||
test('voice Cockpit entry honours a requested contact layer before it enters', () => {
|
||||
@@ -1368,3 +1385,27 @@ test('cockpit state cannot report entryAllowed while already active', () => {
|
||||
assert.match(state, /'contacts-inactive'/);
|
||||
assert.match(state, /'no-tracked-aircraft'/);
|
||||
});
|
||||
|
||||
test('Contact folded for one Cockpit left panel comes back only once neither is open', async () => {
|
||||
assert.match(
|
||||
ui,
|
||||
/if \(!this\._cockpitContextCollapsedForDataPanel\)\s*this\._cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed;/,
|
||||
'opening the second left panel keeps the fold the first recorded',
|
||||
);
|
||||
const { PanelChrome } = await import('./ui/panelChrome.js');
|
||||
const previous = globalThis.document;
|
||||
const panels = {};
|
||||
globalThis.document = { getElementById: (id) => panels[id] ?? null };
|
||||
const panel = (...classes) => ({ classList: { contains: (name) => classes.includes(name) } });
|
||||
try {
|
||||
const otherOpen = (id) => PanelChrome.prototype._otherCockpitLeftPanelOpen(id);
|
||||
panels['data-panel'] = panel('collapsed');
|
||||
assert.equal(otherOpen('utilities-panel'), false, 'Data Layers closed: restore');
|
||||
panels['data-panel'] = panel('collapsed', 'layout-auto-collapsed');
|
||||
assert.equal(otherOpen('utilities-panel'), true, 'Data Layers comes back: keep Contact folded');
|
||||
panels['data-panel'] = panel();
|
||||
assert.equal(otherOpen('utilities-panel'), true, 'Data Layers open: keep it folded');
|
||||
} finally {
|
||||
globalThis.document = previous;
|
||||
}
|
||||
});
|
||||
+7
-7
@@ -1,10 +1,10 @@
|
||||
export { resolveHudRailLayout } from './ui/panelRailGeometry.js';
|
||||
import {
|
||||
normalizeHeading,
|
||||
roundHeadingDegrees,
|
||||
} from './data/headingDegrees.js';
|
||||
|
||||
/** Normalize a heading into the [0, 360) range. */
|
||||
export function normalizeHeading(value) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return ((value % 360) + 360) % 360;
|
||||
}
|
||||
export { resolveHudRailLayout } from './ui/panelRailGeometry.js';
|
||||
export { normalizeHeading };
|
||||
|
||||
/**
|
||||
* Three-digit heading readout, 000–359.
|
||||
@@ -12,7 +12,7 @@ export function normalizeHeading(value) {
|
||||
*/
|
||||
export function formatHeadingReadout(degrees) {
|
||||
if (!Number.isFinite(degrees)) return '---';
|
||||
return String(Math.round(normalizeHeading(degrees)) % 360).padStart(3, '0');
|
||||
return String(roundHeadingDegrees(degrees)).padStart(3, '0');
|
||||
}
|
||||
|
||||
/** Advance a displayed heading along the shortest arc without exceeding a slew rate. */
|
||||
|
||||
@@ -161,6 +161,7 @@ test('a heading that rounds to north reads 000, not 360', () => {
|
||||
assert.equal(formatHeadingReadout(359.4), '359');
|
||||
assert.equal(formatHeadingReadout(0), '000');
|
||||
assert.equal(formatHeadingReadout(10.2), '010');
|
||||
assert.equal(formatHeadingReadout(-0.6), '359');
|
||||
assert.equal(formatHeadingReadout(Number.NaN), '---');
|
||||
});
|
||||
|
||||
|
||||
+264
-243
@@ -1,187 +1,119 @@
|
||||
// src/cockpitSignalFocus.test.mjs — the Cockpit signal rows keep keyboard
|
||||
// focus while they refresh and reorder, over happy-dom with the real view.
|
||||
import './testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach } from 'node:test';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
|
||||
import { CockpitViewController } from './ui/cockpitController.js';
|
||||
const previousDocument = globalThis.document;
|
||||
afterEach(() => {
|
||||
globalThis.document = previousDocument;
|
||||
});
|
||||
import { createBriefState } from './ui/cockpitBriefPages.js';
|
||||
import {
|
||||
handleSignalClick,
|
||||
publishCockpitSignals,
|
||||
pushCockpitSignal,
|
||||
updateCockpitSignals,
|
||||
} from './ui/cockpitSignals.js';
|
||||
import { useCockpitStore } from './ui/stores/cockpit.js';
|
||||
import CockpitBriefing from './ui/views/panels/CockpitBriefing.vue';
|
||||
|
||||
// Model native focus loss on removal AND on ordinary DOM moves. Keeping an
|
||||
// object reference alone is insufficient if reconciliation disconnects it.
|
||||
function fixture() {
|
||||
const document = { activeElement: null };
|
||||
let focusLosses = 0;
|
||||
let focusCalls = 0;
|
||||
class Node {
|
||||
constructor(tagName) {
|
||||
this.tagName = tagName.toUpperCase();
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.dataset = {};
|
||||
this.attributes = new Map();
|
||||
this.className = '';
|
||||
this._text = '';
|
||||
this.classList = {
|
||||
add: (...names) => {
|
||||
this.className = [
|
||||
...new Set([
|
||||
...this.className.split(/\s+/).filter(Boolean),
|
||||
...names,
|
||||
]),
|
||||
].join(' ');
|
||||
},
|
||||
};
|
||||
/**
|
||||
* A browser blurs an element it removes or moves. happy-dom only models the
|
||||
* removal, so count both: any insert or removal of a node that holds focus
|
||||
* is a loss, and a moved node gives its focus up as a browser's would.
|
||||
*/
|
||||
function trackFocus(t) {
|
||||
const counts = { losses: 0, focusCalls: 0 };
|
||||
const holdsFocus = (node) =>
|
||||
node.isConnected && node.contains(document.activeElement);
|
||||
const insertBefore = Node.prototype.insertBefore;
|
||||
t.mock.method(Node.prototype, 'insertBefore', function (node, anchor) {
|
||||
const lost = holdsFocus(node);
|
||||
const result = insertBefore.call(this, node, anchor);
|
||||
if (lost) {
|
||||
counts.losses += 1;
|
||||
document.activeElement.blur();
|
||||
}
|
||||
get isConnected() {
|
||||
return this === document.body || Boolean(this.parentNode?.isConnected);
|
||||
}
|
||||
get firstElementChild() {
|
||||
return this.children[0] || null;
|
||||
}
|
||||
get nextElementSibling() {
|
||||
return (
|
||||
this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] ||
|
||||
null
|
||||
);
|
||||
}
|
||||
get textContent() {
|
||||
return (
|
||||
this._text + this.children.map((node) => node.textContent).join('')
|
||||
);
|
||||
}
|
||||
set textContent(value) {
|
||||
this.replaceChildren();
|
||||
this._text = String(value);
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
}
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name) ?? null;
|
||||
}
|
||||
contains(node) {
|
||||
return (
|
||||
this === node || this.children.some((child) => child.contains(node))
|
||||
);
|
||||
}
|
||||
append(...nodes) {
|
||||
for (const node of nodes) this.insertBefore(node, null);
|
||||
}
|
||||
insertBefore(node, anchor) {
|
||||
assert.ok(
|
||||
anchor === null || anchor.parentNode === this,
|
||||
'insert anchor belongs to its parent',
|
||||
);
|
||||
if (node === anchor) return node;
|
||||
node.remove();
|
||||
const index =
|
||||
anchor === null ? this.children.length : this.children.indexOf(anchor);
|
||||
this.children.splice(index, 0, node);
|
||||
node.parentNode = this;
|
||||
return node;
|
||||
}
|
||||
remove() {
|
||||
if (!this.parentNode) return;
|
||||
if (this.contains(document.activeElement)) {
|
||||
document.activeElement = document.body;
|
||||
focusLosses += 1;
|
||||
}
|
||||
this.parentNode.children.splice(
|
||||
this.parentNode.children.indexOf(this),
|
||||
1,
|
||||
);
|
||||
this.parentNode = null;
|
||||
}
|
||||
replaceChildren(...nodes) {
|
||||
for (const child of [...this.children]) child.remove();
|
||||
this._text = '';
|
||||
this.append(...nodes);
|
||||
}
|
||||
matches(selector) {
|
||||
if (selector === 'button[data-signal-layer][data-signal-id]') {
|
||||
return (
|
||||
this.tagName === 'BUTTON' &&
|
||||
this.dataset.signalLayer != null &&
|
||||
this.dataset.signalId != null
|
||||
);
|
||||
}
|
||||
return selector.startsWith('.')
|
||||
? this.className.split(/\s+/).includes(selector.slice(1))
|
||||
: this.tagName === selector.toUpperCase();
|
||||
}
|
||||
closest(selector) {
|
||||
for (let node = this; node; node = node.parentNode)
|
||||
if (node.matches(selector)) return node;
|
||||
return null;
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
return this.children.flatMap((child) => [
|
||||
...(child.matches(selector) ? [child] : []),
|
||||
...child.querySelectorAll(selector),
|
||||
]);
|
||||
}
|
||||
focus() {
|
||||
assert.ok(
|
||||
this.isConnected,
|
||||
'a focus destination must still be connected',
|
||||
);
|
||||
focusCalls += 1;
|
||||
document.activeElement = this;
|
||||
}
|
||||
}
|
||||
document.body = new Node('body');
|
||||
document.createElement = (tag) => new Node(tag);
|
||||
document.activeElement = document.body;
|
||||
globalThis.document = document;
|
||||
const controller = Object.create(CockpitViewController.prototype);
|
||||
controller.services = { formatAwarenessLabel };
|
||||
controller.signalList = new Node('ol');
|
||||
controller.signalToggle = new Node('button');
|
||||
controller.briefTabs = [
|
||||
new Node('button'),
|
||||
new Node('button'),
|
||||
new Node('button'),
|
||||
];
|
||||
controller.briefPageIndex = 0;
|
||||
controller.signalItems = [];
|
||||
controller.signalSignatures = new Map();
|
||||
controller.scheduleContextLayout = () => {};
|
||||
const display = new Node('button');
|
||||
const radio = new Node('button');
|
||||
const outside = new Node('button');
|
||||
document.body.append(
|
||||
controller.signalToggle,
|
||||
controller.signalList,
|
||||
...controller.briefTabs,
|
||||
display,
|
||||
radio,
|
||||
outside,
|
||||
);
|
||||
return result;
|
||||
});
|
||||
const removeChild = Node.prototype.removeChild;
|
||||
t.mock.method(Node.prototype, 'removeChild', function (node) {
|
||||
if (holdsFocus(node)) counts.losses += 1;
|
||||
return removeChild.call(this, node);
|
||||
});
|
||||
const focus = HTMLElement.prototype.focus;
|
||||
t.mock.method(HTMLElement.prototype, 'focus', function (...args) {
|
||||
assert.ok(this.isConnected, 'a focus destination must still be connected');
|
||||
counts.focusCalls += 1;
|
||||
return focus.apply(this, args);
|
||||
});
|
||||
return counts;
|
||||
}
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(CockpitBriefing).use(pinia);
|
||||
app.mount(host);
|
||||
const store = useCockpitStore(pinia);
|
||||
const button = () => {
|
||||
const element = document.createElement('button');
|
||||
element.type = 'button';
|
||||
return document.body.appendChild(element);
|
||||
};
|
||||
const display = button();
|
||||
const radio = button();
|
||||
const outside = button();
|
||||
const counts = trackFocus(t);
|
||||
t.after(() => app.unmount());
|
||||
const selected = [];
|
||||
controller.services.militaryAwarenessLayer = {
|
||||
focusTarget: (...args) => selected.push(args),
|
||||
// The signal presenters need only this much of the controller.
|
||||
const owner = {
|
||||
destroyed: false,
|
||||
brief: createBriefState(),
|
||||
signalItems: [],
|
||||
signalSignatures: new Map(),
|
||||
publishSignals: (rows) => store.publishSignals(rows),
|
||||
publishCockpitSignals,
|
||||
scheduleContextLayout() {},
|
||||
services: {
|
||||
formatAwarenessLabel,
|
||||
militaryAwarenessLayer: {
|
||||
focusTarget: (...args) => selected.push(args),
|
||||
},
|
||||
},
|
||||
};
|
||||
const click = (event) => controller.handleSignalClick(event);
|
||||
const buttons = () => controller.signalList.querySelectorAll('button');
|
||||
// The controller shows the card on entering Cockpit.
|
||||
document.getElementById('cockpit-signal-stream').hidden = false;
|
||||
const list = document.getElementById('cockpit-signal-list');
|
||||
list.addEventListener('click', (event) =>
|
||||
handleSignalClick.call(owner, event),
|
||||
);
|
||||
const buttons = () => [...list.querySelectorAll('button')];
|
||||
// Tab visits the enabled buttons no hidden ancestor removes from view.
|
||||
const tab = () => {
|
||||
const nodes = document.body.querySelectorAll('button');
|
||||
const index = nodes.indexOf(document.activeElement);
|
||||
nodes[(index + 1) % nodes.length].focus();
|
||||
const nodes = [...document.querySelectorAll('button')].filter(
|
||||
(node) => !node.disabled && !node.closest('[hidden]'),
|
||||
);
|
||||
nodes[(nodes.indexOf(document.activeElement) + 1) % nodes.length].focus();
|
||||
};
|
||||
const pageTab = (index) =>
|
||||
document.querySelector(`[data-cockpit-brief-index="${index}"]`);
|
||||
const signalTab = () => pageTab(0);
|
||||
return {
|
||||
controller,
|
||||
document,
|
||||
owner,
|
||||
list,
|
||||
buttons,
|
||||
display,
|
||||
radio,
|
||||
outside,
|
||||
selected,
|
||||
click,
|
||||
tab,
|
||||
losses: () => focusLosses,
|
||||
focusCalls: () => focusCalls,
|
||||
pageTab,
|
||||
signalTab,
|
||||
store,
|
||||
losses: () => counts.losses,
|
||||
focusCalls: () => counts.focusCalls,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -194,9 +126,14 @@ const item = (id, overrides = {}) => ({
|
||||
timestamp: 1_700_000_000_000,
|
||||
...overrides,
|
||||
});
|
||||
const render = (f, items) => {
|
||||
f.controller.signalItems = items;
|
||||
f.controller.renderCockpitSignals();
|
||||
const render = async (f, items) => {
|
||||
f.owner.signalItems = items;
|
||||
publishCockpitSignals.call(f.owner);
|
||||
await nextTick();
|
||||
};
|
||||
const refresh = async (f, distanceM) => {
|
||||
updateCockpitSignals.call(f.owner, snapshot(distanceM), 0);
|
||||
await nextTick();
|
||||
};
|
||||
const snapshot = (distanceM = 2000) => ({
|
||||
evaluatedAt: 1_700_000_000_000,
|
||||
@@ -210,31 +147,61 @@ const snapshot = (distanceM = 2000) => ({
|
||||
],
|
||||
});
|
||||
|
||||
test('actual Context refresh retains the focused signal button across unchanged and changed details', () => {
|
||||
const f = fixture();
|
||||
f.controller.updateCockpitSignals(snapshot(), 0);
|
||||
test('rows render the signals the controller presents', async (t) => {
|
||||
const f = fixture(t);
|
||||
await render(f, [
|
||||
item('a'),
|
||||
item('status', { tone: 'warning', target: null, title: 'UNKNOWN' }),
|
||||
]);
|
||||
const [action, status] = f.list.children;
|
||||
assert.equal(action.className, 'nearby actionable');
|
||||
assert.equal(action.querySelector('time').textContent, '22:13:20Z');
|
||||
const button = action.querySelector('button.cockpit-signal-target');
|
||||
assert.equal(button.type, 'button');
|
||||
assert.equal(button.dataset.signalLayer, 'flights');
|
||||
assert.equal(button.dataset.signalId, 'a');
|
||||
assert.equal(button.getAttribute('aria-label'), 'Select flight Flight a');
|
||||
assert.equal(
|
||||
button.querySelector('.cockpit-signal-target-label').textContent,
|
||||
'Flight a',
|
||||
);
|
||||
assert.equal(
|
||||
button.querySelector('.cockpit-signal-target-chevron').textContent,
|
||||
'chevron_right',
|
||||
);
|
||||
assert.equal(
|
||||
action.querySelector('div > span').textContent,
|
||||
'COMMERCIAL FLIGHT · 2 KM',
|
||||
);
|
||||
assert.equal(status.className, 'warning');
|
||||
assert.equal(status.querySelector('div > strong').textContent, 'UNKNOWN');
|
||||
assert.equal(status.querySelector('button'), null);
|
||||
});
|
||||
|
||||
test('actual Context refresh retains the focused signal button across unchanged and changed details', async (t) => {
|
||||
const f = fixture(t);
|
||||
await refresh(f);
|
||||
const target = f.buttons()[1];
|
||||
target.focus();
|
||||
const calls = f.focusCalls();
|
||||
for (let tick = 0; tick < 8; tick += 1)
|
||||
f.controller.updateCockpitSignals(snapshot(2500), 0);
|
||||
assert.equal(f.buttons()[1], target);
|
||||
assert.equal(f.document.activeElement, target);
|
||||
for (let tick = 0; tick < 8; tick += 1) await refresh(f, 2500);
|
||||
assert.equal(f.buttons()[1] === target, true);
|
||||
assert.equal(document.activeElement === target, true);
|
||||
assert.equal(f.losses(), 0);
|
||||
assert.equal(
|
||||
f.focusCalls(),
|
||||
calls,
|
||||
'refresh must preserve focus without repeatedly restoring it',
|
||||
);
|
||||
assert.match(f.controller.signalList.textContent, /2\.5\u00a0KM/);
|
||||
assert.match(f.list.textContent, /2\.5\u00a0KM/);
|
||||
});
|
||||
|
||||
test('signal content and accessible name update without replacing the active target', () => {
|
||||
const f = fixture();
|
||||
render(f, [item('a')]);
|
||||
test('signal content and accessible name update without replacing the active target', async (t) => {
|
||||
const f = fixture(t);
|
||||
await render(f, [item('a')]);
|
||||
const target = f.buttons()[0];
|
||||
target.focus();
|
||||
render(f, [
|
||||
await render(f, [
|
||||
item('a', {
|
||||
title: 'RENAMED',
|
||||
tone: 'track',
|
||||
@@ -242,21 +209,18 @@ test('signal content and accessible name update without replacing the active tar
|
||||
timestamp: 1_700_000_001_000,
|
||||
}),
|
||||
]);
|
||||
assert.equal(f.buttons()[0], target);
|
||||
assert.equal(f.buttons()[0] === target, true);
|
||||
assert.equal(target.getAttribute('aria-label'), 'Select flight RENAMED');
|
||||
assert.match(target.textContent, /RENAMED/);
|
||||
assert.match(f.controller.signalList.textContent, /CURRENT/);
|
||||
assert.equal(
|
||||
f.controller.signalList.children[0].className,
|
||||
'track actionable',
|
||||
);
|
||||
assert.equal(f.document.activeElement, target);
|
||||
assert.match(f.list.textContent, /CURRENT/);
|
||||
assert.equal(f.list.children[0].className, 'track actionable');
|
||||
assert.equal(document.activeElement === target, true);
|
||||
assert.equal(f.losses(), 0);
|
||||
});
|
||||
|
||||
test('signal ranking changes move surrounding rows without disconnecting the focused row', () => {
|
||||
const f = fixture();
|
||||
render(
|
||||
test('signal ranking changes move surrounding rows without disconnecting the focused row', async (t) => {
|
||||
const f = fixture(t);
|
||||
await render(
|
||||
f,
|
||||
['a', 'b', 'c', 'd'].map((id) => item(id)),
|
||||
);
|
||||
@@ -267,8 +231,10 @@ test('signal ranking changes move surrounding rows without disconnecting the foc
|
||||
['d', 'c', 'b', 'a'],
|
||||
['b', 'c', 'd', 'a'],
|
||||
['a', 'd', 'c', 'b'],
|
||||
['c', 'a', 'b', 'd'],
|
||||
['b', 'a', 'd', 'c'],
|
||||
]) {
|
||||
render(
|
||||
await render(
|
||||
f,
|
||||
order.map((id) => item(id)),
|
||||
);
|
||||
@@ -276,97 +242,152 @@ test('signal ranking changes move surrounding rows without disconnecting the foc
|
||||
f.buttons().map((button) => button.dataset.signalId),
|
||||
order,
|
||||
);
|
||||
assert.equal(f.document.activeElement, target);
|
||||
assert.equal(document.activeElement === target, true);
|
||||
assert.equal(f.losses(), 0);
|
||||
}
|
||||
assert.equal(f.focusCalls(), calls);
|
||||
});
|
||||
|
||||
test('a new pushed signal preserves existing focus and delegated selection reads the current target', () => {
|
||||
const f = fixture();
|
||||
render(f, [item('a'), item('b')]);
|
||||
test('a new pushed signal preserves existing focus and delegated selection reads the current target', async (t) => {
|
||||
const f = fixture(t);
|
||||
await render(f, [item('a'), item('b')]);
|
||||
const target = f.buttons()[1];
|
||||
target.focus();
|
||||
f.controller.pushCockpitSignal(
|
||||
pushCockpitSignal.call(
|
||||
f.owner,
|
||||
'status',
|
||||
'warning',
|
||||
'INPUT UNKNOWN',
|
||||
'SOURCE UNAVAILABLE',
|
||||
);
|
||||
assert.equal(f.document.activeElement, target);
|
||||
await nextTick();
|
||||
assert.equal(f.list.children.length, 3);
|
||||
assert.equal(document.activeElement === target, true);
|
||||
assert.equal(f.losses(), 0);
|
||||
const event = {
|
||||
target: target.children[0],
|
||||
prevented: false,
|
||||
preventDefault() {
|
||||
this.prevented = true;
|
||||
},
|
||||
};
|
||||
f.click(event);
|
||||
assert.equal(event.prevented, true);
|
||||
const click = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||
target.querySelector('.cockpit-signal-target-label').dispatchEvent(click);
|
||||
assert.equal(click.defaultPrevented, true);
|
||||
assert.deepEqual(f.selected, [['flights', 'b', { origin: 'user' }]]);
|
||||
});
|
||||
|
||||
test('removing the focused identity uses the existing briefing tab once and permits forward traversal', () => {
|
||||
const f = fixture();
|
||||
render(f, [item('a'), item('b')]);
|
||||
test('removing the focused identity uses the shown briefing tab once and permits forward traversal', async (t) => {
|
||||
const f = fixture(t);
|
||||
await render(f, [item('a'), item('b')]);
|
||||
const removed = f.buttons()[1];
|
||||
removed.focus();
|
||||
const calls = f.focusCalls();
|
||||
render(f, [item('a')]);
|
||||
await render(f, [item('a')]);
|
||||
assert.equal(removed.isConnected, false);
|
||||
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
|
||||
assert.equal(document.activeElement === f.signalTab(), true);
|
||||
assert.equal(f.focusCalls(), calls + 1);
|
||||
assert.equal(f.losses(), 0);
|
||||
assert.equal(f.signalTab().closest('[hidden]'), null);
|
||||
// Traversal carries on forward from the footer tab.
|
||||
f.tab();
|
||||
f.tab();
|
||||
f.tab();
|
||||
assert.equal(f.document.activeElement, f.display);
|
||||
render(f, [item('a', { detail: 'UPDATED' })]);
|
||||
assert.equal(document.activeElement === f.pageTab(1), true);
|
||||
while (document.activeElement !== f.display) f.tab();
|
||||
await render(f, [item('a', { detail: 'UPDATED' })]);
|
||||
assert.equal(
|
||||
f.document.activeElement,
|
||||
f.display,
|
||||
document.activeElement === f.display,
|
||||
true,
|
||||
'later refresh must not pull focus back',
|
||||
);
|
||||
f.tab();
|
||||
assert.equal(f.document.activeElement, f.radio);
|
||||
assert.equal(document.activeElement === f.radio, true);
|
||||
});
|
||||
|
||||
test('a changed target cannot reuse a focused action for another flight', () => {
|
||||
const f = fixture();
|
||||
render(f, [item('a', { key: 'shared-status-key' })]);
|
||||
test('a changed target cannot reuse a focused action for another flight', async (t) => {
|
||||
const f = fixture(t);
|
||||
await render(f, [item('a', { key: 'shared-status-key' })]);
|
||||
const removed = f.buttons()[0];
|
||||
removed.focus();
|
||||
render(f, [
|
||||
await render(f, [
|
||||
item('b', {
|
||||
key: 'shared-status-key',
|
||||
target: { layerId: 'military', id: 'b' },
|
||||
}),
|
||||
]);
|
||||
const target = f.buttons()[0];
|
||||
assert.notEqual(target, removed);
|
||||
assert.equal(target === removed, false);
|
||||
assert.equal(removed.isConnected, false);
|
||||
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
|
||||
f.click({ target, preventDefault() {} });
|
||||
assert.equal(document.activeElement === f.signalTab(), true);
|
||||
target.click();
|
||||
assert.deepEqual(f.selected, [['military', 'b', { origin: 'user' }]]);
|
||||
});
|
||||
|
||||
test('empty, informational and duplicate-key rows have no stale or aliased actions', () => {
|
||||
const f = fixture();
|
||||
render(f, [item('a'), item('a')]);
|
||||
test('empty, informational and duplicate-key rows have no stale or aliased actions', async (t) => {
|
||||
const f = fixture(t);
|
||||
await render(f, [item('a'), item('a')]);
|
||||
const [first, second] = f.buttons();
|
||||
assert.notEqual(first, second);
|
||||
assert.equal(first === second, false);
|
||||
second.focus();
|
||||
render(f, [item('a'), item('a')]);
|
||||
assert.deepEqual(f.buttons(), [first, second]);
|
||||
assert.equal(f.document.activeElement, second);
|
||||
render(f, [item('status', { target: null, title: 'UNKNOWN' })]);
|
||||
await render(f, [item('a'), item('a')]);
|
||||
const again = f.buttons();
|
||||
assert.equal(again[0] === first && again[1] === second, true);
|
||||
assert.equal(document.activeElement === second, true);
|
||||
await render(f, [item('status', { target: null, title: 'UNKNOWN' })]);
|
||||
assert.equal(f.buttons().length, 0);
|
||||
assert.match(f.controller.signalList.textContent, /UNKNOWN/);
|
||||
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
|
||||
assert.match(f.list.textContent, /UNKNOWN/);
|
||||
assert.equal(document.activeElement === f.signalTab(), true);
|
||||
f.outside.focus();
|
||||
render(f, []);
|
||||
assert.equal(f.controller.signalList.children.length, 0);
|
||||
render(f, [item('a')]);
|
||||
assert.notEqual(f.buttons()[0], first);
|
||||
assert.equal(f.document.activeElement, f.outside);
|
||||
await render(f, []);
|
||||
assert.equal(f.list.children.length, 0);
|
||||
await render(f, [item('a')]);
|
||||
assert.equal(f.buttons()[0] === first, false);
|
||||
assert.equal(document.activeElement === f.outside, true);
|
||||
assert.equal(f.losses(), 0);
|
||||
});
|
||||
|
||||
test('any reordering of five rows keeps focus wherever it sits', async (t) => {
|
||||
const f = fixture(t);
|
||||
const ids = ['a', 'b', 'c', 'd', 'e'];
|
||||
// A fixed pseudo-random sequence, so a failure reproduces.
|
||||
let seed = 7;
|
||||
const random = () => {
|
||||
seed = (seed * 48271) % 2147483647;
|
||||
return seed / 2147483647;
|
||||
};
|
||||
const shuffle = (list) => {
|
||||
const next = [...list];
|
||||
for (let index = next.length - 1; index > 0; index -= 1) {
|
||||
const swap = Math.floor(random() * (index + 1));
|
||||
[next[index], next[swap]] = [next[swap], next[index]];
|
||||
}
|
||||
return next;
|
||||
};
|
||||
await render(
|
||||
f,
|
||||
ids.map((id) => item(id)),
|
||||
);
|
||||
for (let round = 0; round < 60; round += 1) {
|
||||
const target = f.buttons()[round % ids.length];
|
||||
target.focus();
|
||||
const order = shuffle(ids);
|
||||
await render(
|
||||
f,
|
||||
order.map((id) => item(id)),
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.buttons().map((button) => button.dataset.signalId),
|
||||
order,
|
||||
);
|
||||
assert.equal(document.activeElement === target, true);
|
||||
}
|
||||
assert.equal(f.losses(), 0);
|
||||
});
|
||||
|
||||
test('the fallback follows the shown page, and the collapse toggle without tabs', async (t) => {
|
||||
const f = fixture(t);
|
||||
f.store.publishBrief({ ...createBriefState(), pageIndex: 2 });
|
||||
await render(f, [item('a'), item('b')]);
|
||||
f.buttons()[1].focus();
|
||||
await render(f, [item('a')]);
|
||||
assert.equal(document.activeElement === f.pageTab(2), true);
|
||||
for (const index of [0, 1, 2]) f.pageTab(index).remove();
|
||||
f.buttons()[0].focus();
|
||||
await render(f, []);
|
||||
assert.equal(
|
||||
document.activeElement === document.getElementById('cockpit-signal-toggle'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -243,6 +243,8 @@ test('Codex login hides spawn errors and can cancel only its owned child', async
|
||||
const child = new EventEmitter();
|
||||
await assert.rejects(
|
||||
startCodexChatGptLogin({
|
||||
environment: {},
|
||||
home: '/home/fixture',
|
||||
executable: 'codex',
|
||||
spawnImpl() {
|
||||
queueMicrotask(() =>
|
||||
@@ -264,6 +266,8 @@ test('Codex login hides spawn errors and can cancel only its owned child', async
|
||||
running.emit('exit', null);
|
||||
};
|
||||
const login = await startCodexChatGptLogin({
|
||||
environment: {},
|
||||
home: '/home/fixture',
|
||||
executable: 'codex',
|
||||
spawnImpl() {
|
||||
queueMicrotask(() => running.emit('spawn'));
|
||||
|
||||
@@ -168,7 +168,7 @@ test('every user-facing Context exit route settles through the failure surface',
|
||||
test('the Radio chip catches lifecycle rejection and semantic false through the toast wrapper', () => {
|
||||
const radioControls = radioBindings.slice(
|
||||
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
|
||||
radioBindings.indexOf("this.listen(this._radioFilter, 'change'"),
|
||||
radioBindings.indexOf('this._storeToken = this.store?.connect?.('),
|
||||
);
|
||||
assert.match(radioControls, /await this\.actions\.runUserAction\(/);
|
||||
assert.match(radioControls, /Radio could not \$\{enabling \? 'start' : 'stop'\} cleanly/);
|
||||
@@ -178,7 +178,7 @@ test('the Radio chip catches lifecycle rejection and semantic false through the
|
||||
test('only the expanded Radio Enable gesture requests the contained post-enable reveal', () => {
|
||||
const radioControls = radioBindings.slice(
|
||||
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
|
||||
radioBindings.indexOf("this.listen(this._radioFilter, 'change'"),
|
||||
radioBindings.indexOf('this._storeToken = this.store?.connect?.('),
|
||||
);
|
||||
assert.match(radioControls, /revealAfterEnable = enabling && trigger === this\._radioEnableBtn/);
|
||||
assert.match(radioControls, /if \(revealAfterEnable\)\s*await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/);
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { expandApplicationHtml } from '../build/application-html.js';
|
||||
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
|
||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||
import { _selectContextMode } from './ui/contextTransactions.js';
|
||||
import { _syncContextModeButtons } from './ui/contextPresentation.js';
|
||||
import { _initGlobalContextPanel } from './ui/contextBindings.js';
|
||||
import { clearSelectedLayers } from './ui/contextActions.js';
|
||||
import { readFileSync as readRadioSource } from 'node:fs';
|
||||
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
|
||||
const contextView = readRadioSource(new URL('./ui/views/ContextView.vue', import.meta.url), 'utf8');
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
||||
const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
|
||||
@@ -22,75 +21,12 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence'
|
||||
assert.match(button[0], /tabindex="0"/);
|
||||
}
|
||||
|
||||
const syncSource = _syncContextModeButtons.toString();
|
||||
assert.match(syncSource, /\[\s*this\._globalContextFlightsBtn,\s*this\._globalContextMissionsBtn,?\s*\]/);
|
||||
assert.match(syncSource, /button\.tabIndex = 0/);
|
||||
assert.doesNotMatch(syncSource, /tabIndex\s*=\s*[^;]*\?\s*-1/);
|
||||
});
|
||||
|
||||
test('Context transition state preserves focus and Tab availability until settle', () => {
|
||||
const attributes = () => new Map();
|
||||
const makeButton = () => {
|
||||
const attrs = attributes();
|
||||
let disabled = false;
|
||||
const button = {
|
||||
attrs,
|
||||
tabIndex: -1,
|
||||
classList: { toggle() {} },
|
||||
setAttribute(name, value) { attrs.set(name, String(value)); },
|
||||
get disabled() { return disabled; },
|
||||
set disabled(value) {
|
||||
disabled = Boolean(value);
|
||||
// Model the browser behavior that exposed this regression: native
|
||||
// disabled drops focus immediately.
|
||||
if (disabled && globalThis.document?.activeElement === button) {
|
||||
globalThis.document.activeElement = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
return button;
|
||||
};
|
||||
const contacts = makeButton();
|
||||
const missions = makeButton();
|
||||
const panel = { classList: { toggle() {} }, setAttribute() {} };
|
||||
const priorDocument = globalThis.document;
|
||||
globalThis.document = { activeElement: missions, getElementById: () => panel };
|
||||
const owner = {
|
||||
_globalContextPanel: panel,
|
||||
actions: { syncDetection() {}, scheduleLayout() {} },
|
||||
_contextMode: null,
|
||||
_contextModeChanging: true,
|
||||
_globalContextFlightsBtn: contacts,
|
||||
_globalContextMissionsBtn: missions,
|
||||
_contextModeStandby: {},
|
||||
_contextFlightsView: {},
|
||||
_contextMissionsView: {},
|
||||
cockpitView: { syncEntry() {} },
|
||||
_syncContactsDetection() {},
|
||||
_scheduleRightPanelLayout() {},
|
||||
};
|
||||
try {
|
||||
_syncContextModeButtons.call(owner);
|
||||
assert.equal(globalThis.document.activeElement, missions, 'busy sync retains focused Space Missions');
|
||||
for (const button of [contacts, missions]) {
|
||||
assert.equal(button.disabled, false);
|
||||
assert.equal(button.tabIndex, 0);
|
||||
assert.equal(button.attrs.get('aria-disabled'), 'true');
|
||||
assert.equal(button.attrs.get('aria-busy'), 'true');
|
||||
}
|
||||
|
||||
owner._contextModeChanging = false;
|
||||
owner._contextMode = 'space-missions';
|
||||
_syncContextModeButtons.call(owner);
|
||||
assert.equal(globalThis.document.activeElement, missions, 'settled sync retains focused Space Missions');
|
||||
for (const button of [contacts, missions]) {
|
||||
assert.equal(button.disabled, false);
|
||||
assert.equal(button.tabIndex, 0);
|
||||
assert.equal(button.attrs.get('aria-disabled'), 'false');
|
||||
assert.equal(button.attrs.get('aria-busy'), 'false');
|
||||
}
|
||||
} finally {
|
||||
globalThis.document = priorDocument;
|
||||
// The view keeps both tabs' tabindex static and never disables them; the
|
||||
// transition itself is covered over happy-dom in ui/contextPanel.test.mjs.
|
||||
for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) {
|
||||
const tag = contextView.match(new RegExp(`<button id="${id}"[^>]*>`))?.[0];
|
||||
assert.ok(tag, `${id} is missing from the view`);
|
||||
assert.doesNotMatch(tag, /:tabindex|:disabled|\sdisabled/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -129,30 +65,45 @@ test('Context tabs draw a visible keyboard-focus outline including active tabs',
|
||||
|
||||
test('Context async action buttons remain focused while busy', () => {
|
||||
const init = _initGlobalContextPanel.toString();
|
||||
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._cockpitRadioToggleBtn,'));
|
||||
const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)'));
|
||||
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this._storeToken = this.store?.connect?.('));
|
||||
const radioPanel = readFileSync(new URL('./ui/views/panels/RadioPanel.vue', import.meta.url), 'utf8');
|
||||
const clear = clearSelectedLayers.toString();
|
||||
|
||||
assert.match(init, /button\.getAttribute\('aria-busy'\) === 'true'/);
|
||||
assert.doesNotMatch(init, /button\.disabled\s*=\s*true/);
|
||||
assert.match(init, /if \(this\._installationsSearchBusy\) return;/);
|
||||
assert.doesNotMatch(init, /\.disabled\s*=\s*true/);
|
||||
const search = contextView.match(/<button id="installations-search-btn"[^>]*>/)?.[0];
|
||||
assert.ok(search, 'nearby sites search is missing');
|
||||
assert.doesNotMatch(search, /:disabled|\sdisabled/);
|
||||
assert.match(search, /:aria-busy=/);
|
||||
assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/);
|
||||
assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
|
||||
for (const name of ['_radioEnableBtn', '_cockpitRadioEnableBtn']) {
|
||||
assert.match(radioSync, new RegExp(`${name}\\.disabled = false`));
|
||||
}
|
||||
assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/);
|
||||
const cockpitRadio = readFileSync(new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url), 'utf8');
|
||||
const cockpitEnable = cockpitRadio.match(/<UiButton id="cockpit-radio-enable-btn"[^>]*>/)?.[0];
|
||||
assert.ok(cockpitEnable, 'Cockpit Radio Enable button is missing');
|
||||
assert.doesNotMatch(cockpitEnable, /disabled(?!=")|:disabled/);
|
||||
assert.match(cockpitEnable, /:aria-busy=/);
|
||||
// The Radio view never disables its Enable button; it marks it busy.
|
||||
const enable = radioPanel.match(/<UiButton id="radio-enable-btn"[^>]*>/)?.[0];
|
||||
assert.ok(enable, 'Radio Enable button is missing');
|
||||
assert.doesNotMatch(enable, /disabled(?!=")|:disabled/);
|
||||
assert.match(enable, /:aria-busy=/);
|
||||
assert.match(clear, /this\.setClearBusy\(true\)/);
|
||||
const control = readFileSync(new URL('./ui/clearLayersControl.js', import.meta.url), 'utf8');
|
||||
assert.match(control, /button\.setAttribute\('aria-busy', String\(busy\)\)/);
|
||||
assert.doesNotMatch(control, /button\.disabled\s*=/);
|
||||
// The globe actions view marks the clear button busy, never disabled.
|
||||
const globeActions = readFileSync(new URL('./ui/views/panels/GlobeActions.vue', import.meta.url), 'utf8');
|
||||
const clearButton = globeActions.match(/<button id="clear-selected-layers"[^>]*>/)?.[0];
|
||||
assert.ok(clearButton, 'Clear selected layers button is missing');
|
||||
assert.doesNotMatch(clearButton, /disabled(?!=")|:disabled=/);
|
||||
assert.match(clearButton, /:aria-busy=/);
|
||||
});
|
||||
|
||||
test('Context header holds only its title and collapse control', () => {
|
||||
const panel = html.match(/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body"/);
|
||||
const panel = html.match(/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body[ "]/);
|
||||
assert.ok(panel, 'Global Context panel is missing');
|
||||
const header = panel[0].match(/<div class="panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body"/);
|
||||
const header = panel[0].match(/<div class="ui-panel__head panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body[ "]/);
|
||||
assert.ok(header, 'Global Context header is missing');
|
||||
const tags = [...header[1].matchAll(/<(\w+)\s+class="([^"]+)"/g)].map(([, tag, cls]) => `${tag}.${cls}`);
|
||||
// The kit's own ui-* classes carry no Core styles.
|
||||
const tags = [...header[1].matchAll(/<(\w+)\s+(?:type="button"\s+)?class="([^"]+)"/g)].map(([, tag, cls]) =>
|
||||
`${tag}.${cls.split(' ').filter((name) => !/^ui-/.test(name)).join(' ')}`);
|
||||
assert.deepEqual(tags, ['span.panel-title', 'span.panel-divider', 'button.panel-collapse-btn']);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Adds the keyboard semantics omitted by Cesium's href-less attribution links.
|
||||
* Cesium retains ownership of showing and hiding its required credit lightbox.
|
||||
*/
|
||||
import { getLocale, t } from './i18n/index.js';
|
||||
import { t } from './i18n/index.js';
|
||||
|
||||
export function configureCreditKeyboardAccess(root = document) {
|
||||
const expand = root?.querySelector?.(
|
||||
@@ -14,19 +14,19 @@ export function configureCreditKeyboardAccess(root = document) {
|
||||
const overlay = lightbox.parentElement;
|
||||
|
||||
// Cesium writes these two texts in English once, when it builds the credit
|
||||
// display, and never rewrites them; English keeps Cesium's own text.
|
||||
if (getLocale() !== 'en') {
|
||||
expand.textContent = t('Data attribution');
|
||||
const title = lightbox.querySelector?.('.cesium-credit-lightbox-title');
|
||||
if (title) title.textContent = t('Data provided by:');
|
||||
}
|
||||
// display, and never rewrites them; English is Cesium's own text. Called
|
||||
// again after a locale change, this words them again.
|
||||
expand.textContent = t('Data attribution');
|
||||
const title = lightbox.querySelector?.('.cesium-credit-lightbox-title');
|
||||
if (title) title.textContent = t('Data provided by:');
|
||||
|
||||
if (!lightbox.id) lightbox.id = 'cesium-credit-lightbox';
|
||||
expand.setAttribute('role', 'button');
|
||||
expand.setAttribute('tabindex', '0');
|
||||
expand.setAttribute('aria-haspopup', 'dialog');
|
||||
expand.setAttribute('aria-controls', lightbox.id);
|
||||
expand.setAttribute('aria-expanded', 'false');
|
||||
if (!expand.hasAttribute?.('aria-expanded'))
|
||||
expand.setAttribute('aria-expanded', 'false');
|
||||
close.setAttribute('role', 'button');
|
||||
close.setAttribute('tabindex', '0');
|
||||
close.setAttribute('aria-label', t('Close data attribution'));
|
||||
|
||||
@@ -221,3 +221,16 @@ for (const [locale, link, title] of [
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('setting up again after a switch back to English restores the English texts', () => {
|
||||
const f = textFixture();
|
||||
setLocale('ru');
|
||||
try {
|
||||
configureCreditKeyboardAccess(f.root);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
configureCreditKeyboardAccess(f.root);
|
||||
assert.equal(f.expand.textContent, 'Data attribution');
|
||||
assert.equal(f.title.textContent, 'Data provided by:');
|
||||
});
|
||||
+9
-3
@@ -480,11 +480,16 @@ export function setCyberSonarEnabled(
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/** Whether the map-mode sweep should alter flight presentation. */
|
||||
/**
|
||||
* Whether the map-mode sweep should alter flight presentation. `hudActive`
|
||||
* is the HUD's visibility from its owner, which a caller acting on it in the
|
||||
* same turn passes; otherwise the rendered `#intel-hud` class answers.
|
||||
*/
|
||||
export function isCyberSonarActive({
|
||||
root = globalThis.document?.documentElement,
|
||||
body = globalThis.document?.body,
|
||||
hud,
|
||||
hudActive,
|
||||
reducedMotion,
|
||||
} = {}) {
|
||||
// Keep optional method calls in the body: production downleveling of nested
|
||||
@@ -498,7 +503,7 @@ export function isCyberSonarActive({
|
||||
return !!(
|
||||
root?.dataset?.uiTheme === 'cyber' &&
|
||||
isCyberSonarEnabled(root) &&
|
||||
hud?.classList?.contains?.('active') &&
|
||||
(hudActive ?? hud?.classList?.contains?.('active')) &&
|
||||
!body?.classList?.contains?.('cockpit-mode') &&
|
||||
!reducedMotion
|
||||
);
|
||||
@@ -508,12 +513,13 @@ export function isCyberMapThemeActive({
|
||||
root = globalThis.document?.documentElement,
|
||||
body = globalThis.document?.body,
|
||||
hud,
|
||||
hudActive,
|
||||
} = {}) {
|
||||
if (hud === undefined)
|
||||
hud = globalThis.document?.getElementById?.('intel-hud');
|
||||
return !!(
|
||||
root?.dataset?.uiTheme === 'cyber' &&
|
||||
hud?.classList?.contains?.('active') &&
|
||||
(hudActive ?? hud?.classList?.contains?.('active')) &&
|
||||
!body?.classList?.contains?.('cockpit-mode')
|
||||
);
|
||||
}
|
||||
|
||||
+95
-14
@@ -123,7 +123,7 @@ export function decodeRing(encoded, decimals) {
|
||||
}
|
||||
|
||||
/** Even-odd point-in-ring test; ring is [[lon, lat], …], open or closed. */
|
||||
function pointInRing(ring, lat, lon) {
|
||||
export function pointInRing(ring, lat, lon) {
|
||||
if (!Array.isArray(ring) || ring.length < 3) return false;
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
||||
@@ -150,17 +150,60 @@ export function polygonsContain(polygons, lat, lon) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether polygons contain a point. A part whose outer ring crosses the
|
||||
* antimeridian ([179.99, …], [-179.99, …]) is read contiguously in its own
|
||||
* longitude frame, with the point moved into that frame; every other part,
|
||||
* including one genuinely wider than 180°, is tested as stored. Crossing is
|
||||
* read from an edge jumping more than 180°, which assumes densely noded rings
|
||||
* (OSM, Nominatim, tiles); a ring that is both wider than 180° and crossing, or
|
||||
* a non-crossing ring with a single edge over 180°, is not supported.
|
||||
*/
|
||||
export function containsAnchor(polygons, lat, lon) {
|
||||
if (!Number.isFinite(lon)) return false;
|
||||
for (const part of polygons || []) {
|
||||
const outer = part?.[0];
|
||||
if (!Array.isArray(outer) || !outer.length) continue;
|
||||
const reference = outer[0][0];
|
||||
const shift = (x) => x - 360 * Math.round((x - reference) / 360);
|
||||
const span = (ring, f) => {
|
||||
let west = Infinity;
|
||||
let east = -Infinity;
|
||||
for (const [x] of ring) {
|
||||
west = Math.min(west, f(x));
|
||||
east = Math.max(east, f(x));
|
||||
}
|
||||
return east - west;
|
||||
};
|
||||
// An edge jumping more than 180° wraps the line; a polar ring whose
|
||||
// edges run along ±180 collapses when reframed and is read as stored.
|
||||
const jumps = outer.some(
|
||||
([x], i) => i > 0 && Math.abs(x - outer[i - 1][0]) > 180,
|
||||
);
|
||||
const framedSpan = jumps ? span(outer, shift) : 0;
|
||||
const crosses = framedSpan > 0 && framedSpan < 180;
|
||||
const framed = crosses
|
||||
? part.map((ring) => ring.map(([x, y]) => [shift(x), y]))
|
||||
: part;
|
||||
if (polygonsContain([framed], lat, crosses ? shift(lon) : lon)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A unit's geometry, decoded on first use. Longitudes are unwrapped around
|
||||
* the largest part for the box so units cut at the antimeridian (Alaska,
|
||||
* Chukotka) get a box on the right side of the globe.
|
||||
* Chukotka) get a box on the right side of the globe. An entry from another
|
||||
* encoding supplies `decode()` returning the same `[outer, ...holes][]`.
|
||||
*/
|
||||
function geometryOf(entry) {
|
||||
export function geometryOf(entry) {
|
||||
if (entry.geometry) return entry.geometry;
|
||||
const decimals = entry.feature.d ?? entry.decimals;
|
||||
const polygons = entry.feature.polygons.map((poly) =>
|
||||
poly.map((ring) => decodeRing(ring, decimals)),
|
||||
);
|
||||
const polygons = entry.decode
|
||||
? entry.decode()
|
||||
: entry.feature.polygons.map((poly) =>
|
||||
poly.map((ring) => decodeRing(ring, decimals)),
|
||||
);
|
||||
const ref = polygons[0]?.[0]?.[0]?.[0] ?? 0;
|
||||
let west = Infinity;
|
||||
let south = Infinity;
|
||||
@@ -178,7 +221,7 @@ function geometryOf(entry) {
|
||||
if (lat > north) north = lat;
|
||||
}
|
||||
}
|
||||
const label = entry.feature.label
|
||||
const label = Array.isArray(entry.feature.label)
|
||||
? { lon: entry.feature.label[0], lat: entry.feature.label[1] }
|
||||
: ringCentroid(polygons[0][0]);
|
||||
entry.geometry = {
|
||||
@@ -208,7 +251,7 @@ function ringCentroid(ring) {
|
||||
}
|
||||
|
||||
/** Km from a point to a unit's box (0 inside it). */
|
||||
function boxDistanceKm(bbox, lat, lon) {
|
||||
export function boxDistanceKm(bbox, lat, lon) {
|
||||
const [west, south, east, north] = bbox;
|
||||
const mid = (west + east) / 2;
|
||||
const x = lon - 360 * Math.round((lon - mid) / 360);
|
||||
@@ -399,8 +442,8 @@ const COUNTY_WORDS = [
|
||||
];
|
||||
const COUNTY_WORD_RE = new RegExp(`\\b(${COUNTY_WORDS.join('|')})\\b`);
|
||||
const STATE_PREFIX_RE =
|
||||
/^(?:free state|state|commonwealth|province|territory|prefecture|region|land|canton|department) of (.+)$/;
|
||||
const STATE_SUFFIX_RE = /^(.+) (?:state|province|prefecture|territory)$/;
|
||||
/^(free state|state|commonwealth|province|territory|prefecture|region|land|canton|department) of (.+)$/;
|
||||
const STATE_SUFFIX_RE = /^(.+) (state|province|prefecture|territory)$/;
|
||||
|
||||
/**
|
||||
* Split an ask into what it names and how.
|
||||
@@ -461,14 +504,17 @@ export function parseAdminQuery(text) {
|
||||
if (prefix)
|
||||
return {
|
||||
kind: 'state',
|
||||
name: prefix[1],
|
||||
name: prefix[2],
|
||||
qualifiers,
|
||||
postal: null,
|
||||
countyWord: null,
|
||||
full: head,
|
||||
form: 'prefix',
|
||||
adminType: prefix[1],
|
||||
};
|
||||
const suffix = STATE_SUFFIX_RE.exec(head);
|
||||
// South Africa's canonical province name is literally "Free State"; do not
|
||||
// strip its final word as though the ask explicitly named an admin type.
|
||||
const suffix = head === 'free state' ? null : STATE_SUFFIX_RE.exec(head);
|
||||
if (suffix)
|
||||
return {
|
||||
kind: 'state',
|
||||
@@ -478,6 +524,7 @@ export function parseAdminQuery(text) {
|
||||
countyWord: null,
|
||||
full: head,
|
||||
form: 'suffix',
|
||||
adminType: suffix[2],
|
||||
};
|
||||
return {
|
||||
kind: 'any',
|
||||
@@ -489,6 +536,30 @@ export function parseAdminQuery(text) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Explicit wording is stronger than a stale same-name geometry. */
|
||||
function explicitStateTypeFits(entry, parsed) {
|
||||
if (!parsed.adminType) return true;
|
||||
const words = new Set(
|
||||
normalizeAdminName(entry.feature?.type)
|
||||
.split(/[^a-z]+/)
|
||||
.filter(Boolean),
|
||||
);
|
||||
const accepted =
|
||||
{
|
||||
'free state': ['state'],
|
||||
state: ['state'],
|
||||
commonwealth: ['state'],
|
||||
province: ['province'],
|
||||
territory: ['territory'],
|
||||
prefecture: ['prefecture'],
|
||||
region: ['region'],
|
||||
land: ['state'],
|
||||
canton: ['canton'],
|
||||
department: ['department', 'departamento'],
|
||||
}[parsed.adminType] || [];
|
||||
return accepted.some((word) => words.has(word));
|
||||
}
|
||||
|
||||
/** A qualifier names the unit's country ("Bavaria, Germany") or the unit
|
||||
* itself ("Washington, DC"). */
|
||||
function qualifierMatches(entry, qualifier) {
|
||||
@@ -505,7 +576,7 @@ function qualifierMatches(entry, qualifier) {
|
||||
* Rank candidates: a unit containing `near` first, then the match tier,
|
||||
* then distance from `near`, then larger area. Returns the ranked list.
|
||||
*/
|
||||
function rank(candidates, near) {
|
||||
export function rank(candidates, near) {
|
||||
const hasNear = Number.isFinite(near?.lat) && Number.isFinite(near?.lon);
|
||||
const scored = candidates.map(({ entry, tier }) => {
|
||||
const geometry = geometryOf(entry);
|
||||
@@ -626,6 +697,10 @@ async function stateCandidates(parsed, { allowAmbiguous = false } = {}) {
|
||||
list = list.filter((i) =>
|
||||
parsed.qualifiers.every((q) => qualifierMatches(i.entry, q)),
|
||||
);
|
||||
// Preserve explicit identity at the selector seam. In particular,
|
||||
// "Bagmati Province" must not select Natural Earth's historical NP-BA
|
||||
// Bagmati Zone merely because the point and normalized name overlap.
|
||||
list = list.filter((i) => explicitStateTypeFits(i.entry, parsed));
|
||||
if (allowAmbiguous) return list;
|
||||
// A bare name answers alone only for a prominent unit — a US state, a
|
||||
// Canadian province, Bavaria — never a French département, an English
|
||||
@@ -738,6 +813,12 @@ export async function findAdminAreaAt(names, lat, lon, scope) {
|
||||
.map(parseAdminQuery)
|
||||
.filter((p) => p?.name);
|
||||
if (!parsedList.length) return null;
|
||||
// If the ask explicitly names an admin type, do not let the geocoder's
|
||||
// untyped alias reopen an incompatible historical same-name boundary.
|
||||
const explicitStates = parsedList.filter(
|
||||
(parsed) => parsed.kind === 'state' && parsed.adminType,
|
||||
);
|
||||
const stateParses = explicitStates.length ? explicitStates : parsedList;
|
||||
const containing = (items) => {
|
||||
const out = [];
|
||||
for (const item of items)
|
||||
@@ -782,7 +863,7 @@ export async function findAdminAreaAt(names, lat, lon, scope) {
|
||||
return toResult(rank(found, { lat, lon })[0], found.length);
|
||||
}
|
||||
const states = [];
|
||||
for (const parsed of parsedList)
|
||||
for (const parsed of stateParses)
|
||||
states.push(
|
||||
...(await stateCandidates(
|
||||
{ ...parsed, qualifiers: [] },
|
||||
|
||||
@@ -225,6 +225,28 @@ test('geocoder-confirmed lookup: the named unit that contains the point', async
|
||||
assert.equal(await findAdminAreaAt(['Georgia'], 41.7, 44.8, 'state'), null);
|
||||
const newYork = await findAdminAreaAt(['New York'], 42.9, -75.5, 'state');
|
||||
assert.equal(newYork?.id, 'US-NY');
|
||||
assert.equal(
|
||||
(
|
||||
await findAdminAreaAt(
|
||||
['Ontario Province', 'Ontario'],
|
||||
43.6532,
|
||||
-79.3832,
|
||||
'state',
|
||||
)
|
||||
)?.id,
|
||||
'CA-ON',
|
||||
);
|
||||
// Natural Earth still carries the historical NP-BA Bagmati Zone. An ask
|
||||
// for today's province must not silently select that incompatible geometry.
|
||||
assert.equal(
|
||||
await findAdminAreaAt(
|
||||
['Bagmati Province', 'Bagmati'],
|
||||
27.7172,
|
||||
85.324,
|
||||
'state',
|
||||
),
|
||||
null,
|
||||
);
|
||||
// A county the geocoder typed, named without the county word.
|
||||
const travis = await findAdminAreaAt(
|
||||
['Travis', 'Travis County'],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readAdsbVerticalRateFpm } from '../sources/adsbFields.js';
|
||||
|
||||
const KNOT_TO_MPS = 0.514444;
|
||||
const FOOT_TO_M = 0.3048;
|
||||
const FPM_TO_MPS = 0.00508;
|
||||
@@ -61,8 +63,7 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
|
||||
const barometricFeet = onGround ? null : finiteNumber(aircraft?.alt_baro);
|
||||
const geometricFeet = finiteNumber(aircraft?.alt_geom);
|
||||
const groundSpeedKnots = finiteNumber(aircraft?.gs);
|
||||
const verticalRateFpm =
|
||||
finiteNumber(aircraft?.baro_rate) ?? finiteNumber(aircraft?.geom_rate);
|
||||
const verticalRateFpm = readAdsbVerticalRateFpm(aircraft);
|
||||
const track = finiteNumber(aircraft?.track);
|
||||
|
||||
return [
|
||||
|
||||
@@ -35,6 +35,10 @@ import { ensureGeoidReady, geoidHeight } from './geoid.js';
|
||||
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
|
||||
import { applyVesselOverlayPolicy } from './vesselLabels.js';
|
||||
import { layerFeedState } from './manager.js';
|
||||
import {
|
||||
presentSelectedVessel,
|
||||
subscribeSelectedVessel,
|
||||
} from '../hudReadouts.js';
|
||||
|
||||
test('open feed with vessels is healthy (null)', () => {
|
||||
assert.equal(deriveAisFeedError({ status: 'open', lastMessageAt: 1, error: null }, 42), null);
|
||||
@@ -856,15 +860,6 @@ function makeTrailSpy() {
|
||||
};
|
||||
}
|
||||
|
||||
function makeClassList(...initial) {
|
||||
const classes = new Set(initial);
|
||||
return {
|
||||
add(value) { classes.add(value); },
|
||||
remove(value) { classes.delete(value); },
|
||||
contains(value) { return classes.has(value); },
|
||||
};
|
||||
}
|
||||
|
||||
function makeInteractionHandler() {
|
||||
return {
|
||||
click: null,
|
||||
@@ -934,14 +929,20 @@ function installWireHarness(picked, stateOverrides = {}) {
|
||||
};
|
||||
const record = makeRecord();
|
||||
const trail = makeTrailSpy();
|
||||
presentSelectedVessel({ text: `AIS: ${record.name}` });
|
||||
let vesselReadout;
|
||||
const stopReadout = subscribeSelectedVessel(
|
||||
(readout) => (vesselReadout = readout),
|
||||
);
|
||||
const hud = {
|
||||
textContent: `AIS: ${record.name}`,
|
||||
classList: makeClassList('active'),
|
||||
get readout() {
|
||||
return vesselReadout;
|
||||
},
|
||||
};
|
||||
|
||||
globalThis.window = windowTarget;
|
||||
globalThis.document = {
|
||||
getElementById: (id) => (id === 'hud-ais-vessel' ? hud : null),
|
||||
getElementById: () => null,
|
||||
};
|
||||
_setVesselStateForTest({
|
||||
records: [record],
|
||||
@@ -966,6 +967,7 @@ function installWireHarness(picked, stateOverrides = {}) {
|
||||
reinstalledHandlers,
|
||||
interactionHandlerFactory,
|
||||
cleanup() {
|
||||
stopReadout();
|
||||
aisLiveVesselsLayer.destroy();
|
||||
if (hadWindow) globalThis.window = priorWindow;
|
||||
else delete globalThis.window;
|
||||
@@ -1091,8 +1093,7 @@ test('vessel interaction wire: id-less 3D Tiles pick deselects and resets the HU
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||||
assert.equal(harness.hud.textContent, 'AIS: --');
|
||||
assert.equal(harness.hud.classList.contains('active'), false);
|
||||
assert.equal(harness.hud.readout, null);
|
||||
// Clicking away is a deliberate deselect, not the vessel aging out of the
|
||||
// feed. Consumers that keep a readout on screen (the Cockpit Contact
|
||||
// panel) tear down on 'deliberate' and hold last-known on 'evicted'.
|
||||
@@ -1119,8 +1120,7 @@ test('vessel interaction wire: empty pick deselects and emits gev:entity-selecti
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||||
assert.equal(harness.hud.textContent, 'AIS: --');
|
||||
assert.equal(harness.hud.classList.contains('active'), false);
|
||||
assert.equal(harness.hud.readout, null);
|
||||
// Empty pick is a deliberate deselect — see the note above.
|
||||
assert.deepEqual(cleared, [{ layerId: 'ais-live-vessels', reason: 'deliberate' }]);
|
||||
} finally {
|
||||
@@ -1133,8 +1133,7 @@ test('vessel interaction wire: Escape deselects the selected vessel', () => {
|
||||
try {
|
||||
harness.keyTarget.dispatch({ key: 'Escape' });
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||||
assert.equal(harness.hud.textContent, 'AIS: --');
|
||||
assert.equal(harness.hud.classList.contains('active'), false);
|
||||
assert.equal(harness.hud.readout, null);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
@@ -1322,6 +1321,27 @@ test('buildVesselCard: heading falls back to course; missing parts are omitted',
|
||||
assert.deepEqual(bare.details, []);
|
||||
});
|
||||
|
||||
test('vessel cards read a course that rounds to north as 0°, not 360°', () => {
|
||||
const card = (course) => buildVesselCard(makeRecord({ heading: null, course })).details[0];
|
||||
assert.equal(card(359.4), 'CONTAINER SHIP · 14.5KT · 359°');
|
||||
assert.equal(card(359.7), 'CONTAINER SHIP · 14.5KT · 0°');
|
||||
assert.equal(
|
||||
buildSelectedVesselCard(makeRecord({ heading: null, course: 359.7 })).details[0],
|
||||
'CONTAINER SHIP · 14.5KT · 0°',
|
||||
);
|
||||
});
|
||||
|
||||
test('the vessel HUD reads a course that rounds to north as HDG 0DEG', () => {
|
||||
const harness = installWireHarness(undefined, { selectedRecord: null });
|
||||
try {
|
||||
Object.assign(harness.record, { heading: null, course: 359.7 });
|
||||
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
|
||||
assert.match(harness.hud.readout.text, /HDG: 0DEG/);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('buildVesselCard: unnamed vessels title as MMSI; long names truncate', () => {
|
||||
const unnamed = buildVesselCard(makeRecord({ name: 'VESSEL' }));
|
||||
assert.equal(unnamed.title, 'MMSI 353136000');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { t, msg, onLocaleChange } from '../i18n/index.js';
|
||||
|
||||
const YOUTUBE_HOSTS = new Set([
|
||||
'youtube.com',
|
||||
@@ -569,7 +569,8 @@ function anchorOnScreen(viewer, anchor, root) {
|
||||
return { x, y, width: rootBounds.width, height: rootBounds.height };
|
||||
}
|
||||
|
||||
function placeCard(card, leader, anchor) {
|
||||
/** Place a media card beside its map anchor, in the lane between the panels. */
|
||||
export function placeCard(card, leader, anchor) {
|
||||
const margin = 18;
|
||||
const gap = 42;
|
||||
const width = card.offsetWidth || 392;
|
||||
@@ -580,22 +581,22 @@ function placeCard(card, leader, anchor) {
|
||||
let laneRight = anchor.width - margin;
|
||||
let laneBottom = anchor.height - margin;
|
||||
if (rootBounds && documentRef?.querySelector) {
|
||||
const leftPanel = documentRef
|
||||
.querySelector('#scene-panel')
|
||||
?.getBoundingClientRect?.();
|
||||
const rightRail = documentRef
|
||||
.querySelector('#right-context-rail')
|
||||
?.getBoundingClientRect?.();
|
||||
const commandDock = documentRef
|
||||
.querySelector('#command-dock')
|
||||
?.getBoundingClientRect?.();
|
||||
if (leftPanel?.width > 0)
|
||||
laneLeft = Math.max(laneLeft, leftPanel.right - rootBounds.left + margin);
|
||||
if (rightRail?.width > 0)
|
||||
laneRight = Math.min(
|
||||
laneRight,
|
||||
rightRail.left - rootBounds.left - margin,
|
||||
);
|
||||
// The scene panel and the context rail swap sides in right-to-left
|
||||
// languages: each narrows the lane from the side it sits on.
|
||||
for (const selector of ['#scene-panel', '#right-context-rail']) {
|
||||
const panel = documentRef
|
||||
.querySelector(selector)
|
||||
?.getBoundingClientRect?.();
|
||||
if (!(panel?.width > 0)) continue;
|
||||
const start = panel.left - rootBounds.left;
|
||||
const end = panel.right - rootBounds.left;
|
||||
if (start + end < rootBounds.width)
|
||||
laneLeft = Math.max(laneLeft, end + margin);
|
||||
else laneRight = Math.min(laneRight, start - margin);
|
||||
}
|
||||
if (commandDock?.height > 0)
|
||||
laneBottom = Math.min(
|
||||
laneBottom,
|
||||
@@ -674,6 +675,30 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
let record = null;
|
||||
let warmRecord = null;
|
||||
let generation = 0;
|
||||
// Text the cards show, kept as the calls that word it so a locale change
|
||||
// words it again. A node is forgotten once it has left the page.
|
||||
const worded = new Map();
|
||||
function forgetDetached() {
|
||||
for (const node of worded.keys())
|
||||
if (!node.isConnected) worded.delete(node);
|
||||
}
|
||||
function word(node, property, text) {
|
||||
let properties = worded.get(node);
|
||||
if (!properties) worded.set(node, (properties = new Map()));
|
||||
properties.set(property, text);
|
||||
writeWord(node, property, text());
|
||||
}
|
||||
function writeWord(node, property, value) {
|
||||
if (property === 'textContent' || property === 'title')
|
||||
node[property] = value;
|
||||
else node.setAttribute(property, value);
|
||||
}
|
||||
const offLocale = onLocaleChange(() => {
|
||||
forgetDetached();
|
||||
for (const [node, properties] of worded)
|
||||
for (const [property, text] of properties)
|
||||
writeWord(node, property, text());
|
||||
});
|
||||
const leavingRecords = new Set();
|
||||
|
||||
const prefersReducedMotion = () =>
|
||||
@@ -818,6 +843,7 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
current.youtubeSession?.destroy();
|
||||
current.card?.remove?.();
|
||||
leavingRecords.delete(current);
|
||||
forgetDetached();
|
||||
}
|
||||
|
||||
const position = () => {
|
||||
@@ -871,6 +897,7 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
destroyFacebookSession(warmRecord.facebookSession);
|
||||
else warmRecord.iframe?.remove?.();
|
||||
warmRecord = null;
|
||||
forgetDetached();
|
||||
}
|
||||
|
||||
function takeWarmFacebook(key, host) {
|
||||
@@ -917,9 +944,11 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
startAtSec,
|
||||
endAtSec,
|
||||
});
|
||||
iframe.title = t('Preloading {provider} media', {
|
||||
provider: providerLabel(source.provider),
|
||||
});
|
||||
word(iframe, 'title', () =>
|
||||
t('Preloading {provider} media', {
|
||||
provider: providerLabel(source.provider),
|
||||
}),
|
||||
);
|
||||
iframe.tabIndex = -1;
|
||||
iframe.setAttribute('aria-hidden', 'true');
|
||||
iframe.loading = 'eager';
|
||||
@@ -930,7 +959,7 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
}
|
||||
|
||||
async function mountX(current, token) {
|
||||
current.status.textContent = t('LOADING X POST…');
|
||||
word(current.status, 'textContent', () => t('LOADING X POST…'));
|
||||
try {
|
||||
const api = await xLoader(documentRef, globalRef);
|
||||
if (token !== generation || record !== current) return;
|
||||
@@ -950,14 +979,18 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
throw new Error(
|
||||
msg('The X post is unavailable or cannot be embedded.'),
|
||||
);
|
||||
current.status.textContent = t('X POST · PROVIDER CONTROLS');
|
||||
word(current.status, 'textContent', () =>
|
||||
t('X POST · PROVIDER CONTROLS'),
|
||||
);
|
||||
position();
|
||||
} catch (error) {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.card.classList.add('is-unavailable');
|
||||
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
|
||||
reason: t(String(error?.message || msg('X embed unavailable'))),
|
||||
});
|
||||
word(current.status, 'textContent', () =>
|
||||
t('{reason} OPEN ORIGINAL.', {
|
||||
reason: t(String(error?.message || msg('X embed unavailable'))),
|
||||
}),
|
||||
);
|
||||
position();
|
||||
}
|
||||
}
|
||||
@@ -970,22 +1003,28 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
session.shouldPlay = autoplay === true;
|
||||
session.onReady = () => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.status.textContent = t('FACEBOOK · PROVIDER CONTROLS');
|
||||
word(current.status, 'textContent', () =>
|
||||
t('FACEBOOK · PROVIDER CONTROLS'),
|
||||
);
|
||||
current.card.classList.remove('is-unavailable');
|
||||
position();
|
||||
};
|
||||
session.onError = (error) => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.card.classList.add('is-unavailable');
|
||||
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
|
||||
reason: t(String(error?.message || msg('Facebook embed unavailable'))),
|
||||
});
|
||||
word(current.status, 'textContent', () =>
|
||||
t('{reason} OPEN ORIGINAL.', {
|
||||
reason: t(
|
||||
String(error?.message || msg('Facebook embed unavailable')),
|
||||
),
|
||||
}),
|
||||
);
|
||||
position();
|
||||
};
|
||||
session.onPlaybackBlocked = () => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.status.textContent = t(
|
||||
'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS',
|
||||
word(current.status, 'textContent', () =>
|
||||
t('AUTOPLAY BLOCKED · USE PROVIDER CONTROLS'),
|
||||
);
|
||||
};
|
||||
if (session.ready) session.onReady();
|
||||
@@ -1036,8 +1075,7 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
const portraitClass = isPortraitMedia(media) ? ' is-portrait' : '';
|
||||
card.className = `bhote-embedded-callout is-${source.provider}${portraitClass}`;
|
||||
card.dataset.provider = source.provider;
|
||||
card.setAttribute(
|
||||
'aria-label',
|
||||
word(card, 'aria-label', () =>
|
||||
t('Embedded {provider} media for {source}', {
|
||||
provider: providerLabel(source.provider),
|
||||
source: observation?.title
|
||||
@@ -1054,22 +1092,26 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
leader.innerHTML =
|
||||
'<path pathLength="1" fill="none" stroke="rgba(147,213,228,.72)" stroke-width="1.5"/><circle r="4" fill="#6be8ff" stroke="#ffffff" stroke-width="1.5"/>';
|
||||
const player = documentRef.createElement('div');
|
||||
player.className = 'bhote-embedded-callout-player';
|
||||
player.className = 'bhote-embedded-callout-player ui-scroll';
|
||||
const footer = documentRef.createElement('footer');
|
||||
const title = documentRef.createElement('strong');
|
||||
title.textContent = bhoteEventText(
|
||||
observation?.shortTitle || observation?.title || t('Witness source'),
|
||||
).toUpperCase();
|
||||
word(title, 'textContent', () =>
|
||||
bhoteEventText(
|
||||
observation?.shortTitle || observation?.title || t('Witness source'),
|
||||
).toUpperCase(),
|
||||
);
|
||||
const status = documentRef.createElement('small');
|
||||
status.setAttribute('role', 'status');
|
||||
status.textContent = t('{provider} · EMBEDDED SOURCE', {
|
||||
provider: providerLabel(source.provider),
|
||||
});
|
||||
word(status, 'textContent', () =>
|
||||
t('{provider} · EMBEDDED SOURCE', {
|
||||
provider: providerLabel(source.provider),
|
||||
}),
|
||||
);
|
||||
const link = documentRef.createElement('a');
|
||||
link.href = source.url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.textContent = t('OPEN ORIGINAL ↗');
|
||||
word(link, 'textContent', () => t('OPEN ORIGINAL ↗'));
|
||||
footer.append(title, status, link);
|
||||
const body = documentRef.createElement('div');
|
||||
body.className = 'bhote-embedded-callout-body';
|
||||
@@ -1109,12 +1151,14 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
url.searchParams.set('origin', globalRef.location.origin);
|
||||
iframe.src = url.href;
|
||||
}
|
||||
iframe.title = t('{provider} media: {source}', {
|
||||
provider: providerLabel(source.provider),
|
||||
source: observation?.title
|
||||
? bhoteEventText(observation.title)
|
||||
: t('witness source'),
|
||||
});
|
||||
word(iframe, 'title', () =>
|
||||
t('{provider} media: {source}', {
|
||||
provider: providerLabel(source.provider),
|
||||
source: observation?.title
|
||||
? bhoteEventText(observation.title)
|
||||
: t('witness source'),
|
||||
}),
|
||||
);
|
||||
iframe.allow =
|
||||
'autoplay; encrypted-media; picture-in-picture; fullscreen';
|
||||
iframe.allowFullscreen = true;
|
||||
@@ -1131,9 +1175,11 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
record.youtubeSession.getState().phase,
|
||||
)
|
||||
) {
|
||||
status.textContent = t('{provider} · PROVIDER CONTROLS', {
|
||||
provider: providerLabel(source.provider),
|
||||
});
|
||||
word(status, 'textContent', () =>
|
||||
t('{provider} · PROVIDER CONTROLS', {
|
||||
provider: providerLabel(source.provider),
|
||||
}),
|
||||
);
|
||||
}
|
||||
position();
|
||||
},
|
||||
@@ -1151,11 +1197,13 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
isCurrent: () => token === generation && record === current,
|
||||
onStatus: (phase) => {
|
||||
if (phase !== 'completed')
|
||||
status.textContent = t('YOUTUBE {state} · OPEN ORIGINAL', {
|
||||
state: YOUTUBE_PHASE_LABELS[phase]
|
||||
? t(YOUTUBE_PHASE_LABELS[phase])
|
||||
: phase.toUpperCase(),
|
||||
});
|
||||
word(status, 'textContent', () =>
|
||||
t('YOUTUBE {state} · OPEN ORIGINAL', {
|
||||
state: YOUTUBE_PHASE_LABELS[phase]
|
||||
? t(YOUTUBE_PHASE_LABELS[phase])
|
||||
: phase.toUpperCase(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1165,6 +1213,8 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
offLocale();
|
||||
worded.clear();
|
||||
hide({ immediate: true });
|
||||
for (const current of [...leavingRecords]) removeRecord(current);
|
||||
clearWarm();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createBhoteKoshiEmbeddedMedia,
|
||||
createTrimmedYouTubePlayback,
|
||||
embeddedMediaFrameUrl,
|
||||
placeCard,
|
||||
publicEmbeddedMediaUrl,
|
||||
resolveEmbeddedMediaSource,
|
||||
} from './bhoteKoshiEmbeddedMedia.js';
|
||||
@@ -348,7 +349,7 @@ test('camera updates retain a preload until the visible player loads', () => {
|
||||
assert.equal(frame.parent, root, 'keep the preload while the visible player starts');
|
||||
const card = root.children.find(node => node.tag === 'section');
|
||||
const body = card.children.find(node => node.className === 'bhote-embedded-callout-body');
|
||||
const player = body.children.find(node => node.className === 'bhote-embedded-callout-player');
|
||||
const player = body.children.find(node => node.className.split(' ').includes('bhote-embedded-callout-player'));
|
||||
player.children.find(node => node.tag === 'iframe').dispatchEvent(new Event('load'));
|
||||
assert.equal(frame.parent, null, 'visible player readiness releases the preload');
|
||||
media.destroy();
|
||||
@@ -394,3 +395,25 @@ test('pausing cancels a mounted Facebook preload and rejects its late ready call
|
||||
assert.deepEqual(playerCalls, []);
|
||||
media.destroy();
|
||||
});
|
||||
|
||||
test('a media card stays between the panels when right-to-left swaps their sides', () => {
|
||||
const rect = (left, right, top = 0, bottom = 900) => ({ left, right, top, bottom, width: right - left, height: bottom - top });
|
||||
// Arabic: the scene panel sits on the right, the context rail on the left.
|
||||
const panels = {
|
||||
'#scene-panel': rect(1040, 1420),
|
||||
'#right-context-rail': rect(20, 340),
|
||||
'#command-dock': rect(500, 940, 820, 880),
|
||||
};
|
||||
const card = {
|
||||
offsetWidth: 392,
|
||||
offsetHeight: 360,
|
||||
parentElement: { getBoundingClientRect: () => rect(0, 1440) },
|
||||
ownerDocument: { querySelector: (selector) => ({ getBoundingClientRect: () => panels[selector] }) },
|
||||
classList: { contains: () => false },
|
||||
style: {},
|
||||
};
|
||||
placeCard(card, { querySelector: () => null }, { x: 700, y: 400, width: 1440, height: 900 });
|
||||
const left = parseFloat(card.style.left);
|
||||
assert.ok(left >= 340 + 18, `card at ${left}px lands on the context rail`);
|
||||
assert.ok(left + 392 <= 1040 - 18, `card at ${left}px lands on the scene panel`);
|
||||
});
|
||||
+158
-201
@@ -25,7 +25,9 @@ import {
|
||||
} from './bhoteKoshiEmbeddedMedia.js';
|
||||
import { acquireImageryComparison } from '../maps/imageryComparison.js';
|
||||
import { createImagerySplit } from '../ui/imagerySplit.js';
|
||||
import { formatDate, t, tn, msg } from '../i18n/index.js';
|
||||
import { formatDate, t, tn, msg, onLocaleChange } from '../i18n/index.js';
|
||||
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
|
||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||
|
||||
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
|
||||
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
|
||||
@@ -449,18 +451,6 @@ function imageryEndpointText(entry) {
|
||||
return [name, date].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
/** Name, then the date kept on one line so the year never wraps alone. */
|
||||
function renderImageryEndpoint(element, entry) {
|
||||
if (!element) return;
|
||||
const { name, date } = imageryEndpointLabel(entry);
|
||||
element.textContent = date ? `${name} · ` : name;
|
||||
if (!date) return;
|
||||
const dateElement = document.createElement('span');
|
||||
dateElement.className = 'bhote-event-date';
|
||||
dateElement.textContent = date;
|
||||
element.appendChild(dateElement);
|
||||
}
|
||||
|
||||
/** Compact, source-bounded disclosure for multi-source geolocation beats. */
|
||||
export function evidenceCorroborationLabel(observation) {
|
||||
const count = Math.max(
|
||||
@@ -929,185 +919,70 @@ function setElementProperty(element, property, value) {
|
||||
}
|
||||
|
||||
function setElementAttribute(element, attribute, value) {
|
||||
if (!element || element.getAttribute?.(attribute) === value) return false;
|
||||
element.setAttribute(attribute, value);
|
||||
if (!element || element.attributes[attribute] === value) return false;
|
||||
element.attributes[attribute] = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
function collectPanelReferences(panel) {
|
||||
/** One panel control's state, as the panel view renders it. */
|
||||
function panelRecord(initial = {}) {
|
||||
return {
|
||||
textContent: '',
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
value: '',
|
||||
...initial,
|
||||
attributes: { ...initial.attributes },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel's controls as the layer writes them, starting from the panel's
|
||||
* markup. The panel view renders what the layer publishes from them.
|
||||
*/
|
||||
function createPanelRecords(event) {
|
||||
return Object.freeze({
|
||||
imagerySection: panel.querySelector('[data-role="imagery-comparison"]'),
|
||||
observedStatus: panel.querySelector('.bhote-event-status.observed'),
|
||||
beforeDate: panel.querySelector('[data-role="before-date"]'),
|
||||
afterDate: panel.querySelector('[data-role="after-date"]'),
|
||||
time: panel.querySelector('[data-role="time"]'),
|
||||
caveat: panel.querySelector('[data-role="caveat"]'),
|
||||
imageryCloudNote: panel.querySelector('[data-role="imagery-cloud-note"]'),
|
||||
reportList: panel.querySelector('.bhote-event-report-list'),
|
||||
playButton: panel.querySelector('[data-action="play"]'),
|
||||
playSceneButton: panel.querySelector('[data-action="play-scene"]'),
|
||||
cinematicButton: panel.querySelector('[data-action="cinematic"]'),
|
||||
storyReplayButton: panel.querySelector('[data-action="story-replay"]'),
|
||||
corridorButton: panel.querySelector('[data-action="corridor"]'),
|
||||
previousButton: panel.querySelector('[data-action="previous-beat"]'),
|
||||
nextButton: panel.querySelector('[data-action="next-beat"]'),
|
||||
openSourceButton: panel.querySelector('[data-action="open-source"]'),
|
||||
progressInput: panel.querySelector('[data-role="progress"]'),
|
||||
splitInput: panel.querySelector('[data-role="split"]'),
|
||||
splitReadout: panel.querySelector('[data-role="split-readout"]'),
|
||||
timelineLabel: panel.querySelector('[data-role="timeline-label"]'),
|
||||
beatIndex: panel.querySelector('[data-role="beat-index"]'),
|
||||
beatTitle: panel.querySelector('[data-role="beat-title"]'),
|
||||
beatMeta: panel.querySelector('[data-role="beat-meta"]'),
|
||||
root: { sceneBeat: false },
|
||||
imagerySection: panelRecord(),
|
||||
observedStatus: panelRecord(),
|
||||
time: panelRecord({
|
||||
textContent: `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`,
|
||||
}),
|
||||
caveat: panelRecord({
|
||||
textContent: bhoteEventText(event.reconstruction.caveat),
|
||||
}),
|
||||
imageryCloudNote: panelRecord(),
|
||||
playButton: panelRecord({ textContent: t('▶ PLAY') }),
|
||||
playSceneButton: panelRecord({
|
||||
textContent: t('▶ PLAY SCENE'),
|
||||
hidden: true,
|
||||
}),
|
||||
cinematicButton: panelRecord({
|
||||
textContent: t('◉ CINEMATIC'),
|
||||
attributes: { 'aria-pressed': 'false' },
|
||||
}),
|
||||
storyReplayButton: panelRecord({ textContent: t('↺ FULL STORY') }),
|
||||
corridorButton: panelRecord(),
|
||||
previousButton: panelRecord({
|
||||
attributes: { 'aria-label': t('Previous story beat') },
|
||||
}),
|
||||
nextButton: panelRecord({
|
||||
attributes: { 'aria-label': t('Next story beat') },
|
||||
}),
|
||||
openSourceButton: panelRecord(),
|
||||
progressInput: panelRecord({
|
||||
value: '0',
|
||||
attributes: { 'aria-label': t('Schematic downstream progression') },
|
||||
}),
|
||||
splitInput: panelRecord({ value: '50' }),
|
||||
splitReadout: panelRecord({ textContent: '50 / 50' }),
|
||||
timelineLabel: panelRecord({ textContent: t('RECONSTRUCTION CLOCK') }),
|
||||
beatIndex: panelRecord({ textContent: '01 / 06' }),
|
||||
beatTitle: panelRecord({ textContent: t('CAUSE') }),
|
||||
beatMeta: panelRecord({ textContent: t('CAPTURE TIME UNVERIFIED') }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Escape translated text for the panel markup template. */
|
||||
function escapeMarkup(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function createPanel(event, handlers) {
|
||||
const panel = document.createElement('aside');
|
||||
panel.id = 'bhote-koshi-event-panel';
|
||||
panel.setAttribute(
|
||||
'aria-label',
|
||||
t('Bhote Koshi flood reconstruction controls'),
|
||||
);
|
||||
panel.innerHTML = `
|
||||
<div class="bhote-event-header">
|
||||
<div>
|
||||
<div class="bhote-event-kicker">${escapeMarkup(t('EVENT RECONSTRUCTION · 26 AUG 2026'))}</div>
|
||||
<div class="bhote-event-title">${escapeMarkup(t('BHOTE KOSHI OUTBURST FLOOD'))}</div>
|
||||
</div>
|
||||
<button class="bhote-event-icon-btn" type="button" data-action="close" title="${escapeMarkup(t('Close event layer'))}" aria-label="${escapeMarkup(t('Close event layer'))}">×</button>
|
||||
</div>
|
||||
<div class="bhote-event-status-row">
|
||||
<span class="bhote-event-status observed">${escapeMarkup(t('OBSERVED IMAGERY'))}</span>
|
||||
<span class="bhote-event-status reconstructed">${escapeMarkup(t('SCHEMATIC CORRIDOR'))}</span>
|
||||
</div>
|
||||
<section class="bhote-event-section" data-role="imagery-comparison">
|
||||
<div class="bhote-event-section-head">
|
||||
<span>${escapeMarkup(t('2021 HISTORICAL REFERENCE / 2026 POST-EVENT'))}</span>
|
||||
<span data-role="split-readout">50 / 50</span>
|
||||
</div>
|
||||
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="${escapeMarkup(t('Historical reference and post-event image split'))}" />
|
||||
<div class="bhote-event-dates">
|
||||
<span data-role="before-date"></span>
|
||||
<span data-role="after-date"></span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="bhote-event-section">
|
||||
<div class="bhote-event-section-head">
|
||||
<span data-role="timeline-label">${escapeMarkup(t('RECONSTRUCTION CLOCK'))}</span>
|
||||
<span data-role="time"></span>
|
||||
</div>
|
||||
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="${escapeMarkup(t('Schematic downstream progression'))}" />
|
||||
<div class="bhote-event-story-nav" aria-label="${escapeMarkup(t('Story beat navigation'))}">
|
||||
<button type="button" data-action="previous-beat" title="${escapeMarkup(t('Previous story beat'))}" aria-label="${escapeMarkup(t('Previous story beat'))}">‹</button>
|
||||
<div class="bhote-event-story-current">
|
||||
<span data-role="beat-index">01 / 06</span>
|
||||
<strong data-role="beat-title" aria-live="polite">${escapeMarkup(t('CAUSE'))}</strong>
|
||||
<small data-role="beat-meta">${escapeMarkup(t('CAPTURE TIME UNVERIFIED'))}</small>
|
||||
</div>
|
||||
<button type="button" data-action="next-beat" title="${escapeMarkup(t('Next story beat'))}" aria-label="${escapeMarkup(t('Next story beat'))}">›</button>
|
||||
</div>
|
||||
<div class="bhote-event-actions">
|
||||
<button type="button" data-action="play">${escapeMarkup(t('▶ PLAY'))}</button>
|
||||
<button type="button" data-action="play-scene" hidden title="${escapeMarkup(t('Play the next shot and continue through this scene'))}">${escapeMarkup(t('▶ PLAY SCENE'))}</button>
|
||||
<button type="button" data-action="cinematic" aria-pressed="false">${escapeMarkup(t('◉ CINEMATIC'))}</button>
|
||||
<button type="button" data-action="story-replay">${escapeMarkup(t('↺ FULL STORY'))}</button>
|
||||
<button type="button" data-action="open-source">${escapeMarkup(t('↗ OPEN ORIGINAL'))}</button>
|
||||
<button type="button" data-action="corridor">${escapeMarkup(t('⌖ LOWER GORGE'))}</button>
|
||||
</div>
|
||||
</section>
|
||||
<details class="bhote-event-evidence">
|
||||
<summary>${escapeMarkup(t('FIELD REPORTS · {count}', { count: event.fieldReports.length }))}</summary>
|
||||
<div class="bhote-event-report-list"></div>
|
||||
</details>
|
||||
<div class="bhote-event-credit">
|
||||
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
|
||||
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
|
||||
</div>
|
||||
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note">${escapeMarkup(t(' Clouds are preserved from the source imagery.'))}</span></p>
|
||||
`;
|
||||
|
||||
const refs = collectPanelReferences(panel);
|
||||
renderImageryEndpoint(refs.beforeDate, event.imagery.before);
|
||||
renderImageryEndpoint(refs.afterDate, event.imagery.after);
|
||||
refs.time.textContent = `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`;
|
||||
refs.caveat.textContent = bhoteEventText(event.reconstruction.caveat);
|
||||
|
||||
for (const report of event.fieldReports) {
|
||||
const button = document.createElement('button');
|
||||
const label = document.createElement('span');
|
||||
const status = document.createElement('small');
|
||||
button.type = 'button';
|
||||
button.className = 'bhote-event-report';
|
||||
label.textContent = bhoteEventText(report.label);
|
||||
status.textContent = bhoteEventText(report.status);
|
||||
button.append(label, status);
|
||||
button.addEventListener('click', () => openExternal(report.url));
|
||||
refs.reportList.appendChild(button);
|
||||
}
|
||||
|
||||
panel
|
||||
.querySelector('[data-action="close"]')
|
||||
.addEventListener('click', handlers.close);
|
||||
panel
|
||||
.querySelector('[data-action="play"]')
|
||||
.addEventListener('click', handlers.play);
|
||||
panel
|
||||
.querySelector('[data-action="play-scene"]')
|
||||
.addEventListener('click', handlers.playScene);
|
||||
panel
|
||||
.querySelector('[data-action="cinematic"]')
|
||||
.addEventListener('click', handlers.toggleCinematic);
|
||||
panel
|
||||
.querySelector('[data-action="previous-beat"]')
|
||||
.addEventListener('click', handlers.previousBeat);
|
||||
panel
|
||||
.querySelector('[data-action="next-beat"]')
|
||||
.addEventListener('click', handlers.nextBeat);
|
||||
panel
|
||||
.querySelector('[data-action="story-replay"]')
|
||||
.addEventListener('click', handlers.replayCinematic);
|
||||
panel
|
||||
.querySelector('[data-action="open-source"]')
|
||||
.addEventListener('click', handlers.openActiveSource);
|
||||
panel
|
||||
.querySelector('[data-action="geolocation-map"]')
|
||||
.addEventListener('click', handlers.openGeolocationMap);
|
||||
panel
|
||||
.querySelector('[data-action="corridor"]')
|
||||
.addEventListener('click', handlers.focusCorridor);
|
||||
panel
|
||||
.querySelector('[data-role="split"]')
|
||||
.addEventListener('input', (inputEvent) => {
|
||||
handlers.setSplit(Number(inputEvent.target.value) / 100);
|
||||
});
|
||||
panel
|
||||
.querySelector('[data-role="progress"]')
|
||||
.addEventListener('input', (inputEvent) => {
|
||||
handlers.previewProgress(Number(inputEvent.target.value) / 1000);
|
||||
});
|
||||
panel
|
||||
.querySelector('[data-role="progress"]')
|
||||
.addEventListener('change', (inputEvent) => {
|
||||
handlers.commitProgress(Number(inputEvent.target.value) / 1000, {
|
||||
final: true,
|
||||
});
|
||||
});
|
||||
const rightRail = document.getElementById?.('right-context-rail');
|
||||
rightRail?.classList.add('bhote-event-active');
|
||||
(rightRail || document.body).appendChild(panel);
|
||||
return { panel, refs };
|
||||
}
|
||||
|
||||
export function createBhoteKoshiEventLayer({
|
||||
overlayHost = DEFAULT_OVERLAY_HOST,
|
||||
renderHost = DEFAULT_RENDER_HOST,
|
||||
@@ -1119,14 +994,22 @@ export function createBhoteKoshiEventLayer({
|
||||
evidenceFrameUpdater = updateEvidenceFrame,
|
||||
videoFactory = DEFAULT_VIDEO_FACTORY,
|
||||
embeddedMediaFactory = createBhoteKoshiEmbeddedMedia,
|
||||
panelStore = useBhoteKoshiPanelStore(applicationPinia()),
|
||||
} = {}) {
|
||||
let _viewer = null;
|
||||
let _dataManager = null;
|
||||
let _mapStackController = null;
|
||||
let _event = null;
|
||||
let _enabled = false;
|
||||
let _panel = null;
|
||||
// The panel's controls; null while the layer is off. Their state is
|
||||
// published to the panel store, which the context rail renders.
|
||||
let _panelRefs = null;
|
||||
let _panelOwner = null;
|
||||
let _panelPublished = '';
|
||||
// The dates and field reports, which stay as they were when the panel
|
||||
// opened, until a locale change words them again.
|
||||
let _panelFixed = null;
|
||||
let _offPanelLocale = null;
|
||||
let _splitControl = null;
|
||||
let _beforeLayer = null;
|
||||
let _afterLayer = null;
|
||||
@@ -2723,9 +2606,9 @@ export function createBhoteKoshiEventLayer({
|
||||
id: 'bhote-koshi-split-line',
|
||||
handleClass: 'bhote-koshi-split-handle',
|
||||
cssProperty: '--bhote-koshi-split',
|
||||
beforeTitle: imageryEndpointText(event.imagery.before),
|
||||
afterTitle: imageryEndpointText(event.imagery.after),
|
||||
ariaLabel: t('Historical reference and post-event image divider'),
|
||||
beforeTitle: () => imageryEndpointText(event.imagery.before),
|
||||
afterTitle: () => imageryEndpointText(event.imagery.after),
|
||||
ariaLabel: () => t('Historical reference and post-event image divider'),
|
||||
formatValueText: (beforePercent, afterPercent) =>
|
||||
t(
|
||||
'A historical reference {before} percent, B post-event {after} percent',
|
||||
@@ -2756,15 +2639,63 @@ export function createBhoteKoshiEventLayer({
|
||||
_lastCinematicProgress = _progress;
|
||||
}
|
||||
|
||||
/** Present the panel's controls as they stand; an unchanged panel is not sent again. */
|
||||
function publishPanel() {
|
||||
if (!_panelRefs) return;
|
||||
const { root, ...controls } = _panelRefs;
|
||||
const refs = Object.fromEntries(
|
||||
Object.entries(controls).map(([key, record]) => [
|
||||
key,
|
||||
{ ...record, attributes: { ...record.attributes } },
|
||||
]),
|
||||
);
|
||||
const signature = JSON.stringify([root.sceneBeat, refs]);
|
||||
if (signature === _panelPublished) return;
|
||||
_panelPublished = signature;
|
||||
_panelFixed ??= {
|
||||
beforeDate: imageryEndpointLabel(_event.imagery.before),
|
||||
afterDate: imageryEndpointLabel(_event.imagery.after),
|
||||
reports: _event.fieldReports.map((report) => ({
|
||||
label: bhoteEventText(report.label),
|
||||
status: bhoteEventText(report.status),
|
||||
})),
|
||||
};
|
||||
panelStore.publish({
|
||||
owner: _panelOwner,
|
||||
sceneBeat: root.sceneBeat,
|
||||
refs,
|
||||
..._panelFixed,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Word the panel again in the current locale: fresh records, written by
|
||||
* the same syncs that keep them current.
|
||||
*/
|
||||
function relocalizePanel() {
|
||||
if (!_panelRefs || !_event) return;
|
||||
_panelRefs = createPanelRecords(_event);
|
||||
_panelFixed = null;
|
||||
writeCinematicButton();
|
||||
syncPanel();
|
||||
syncPresentationMode();
|
||||
}
|
||||
|
||||
function syncCinematicButton() {
|
||||
if (writeCinematicButton()) publishPanel();
|
||||
}
|
||||
|
||||
/** Write the cinematic button's label and state; false without a panel. */
|
||||
function writeCinematicButton() {
|
||||
const button = _panelRefs?.cinematicButton;
|
||||
if (!button) return;
|
||||
if (!button) return false;
|
||||
setElementProperty(
|
||||
button,
|
||||
'textContent',
|
||||
_cinematicActive ? t('■ RELEASE CAMERA') : t('◉ CINEMATIC'),
|
||||
);
|
||||
setElementAttribute(button, 'aria-pressed', String(_cinematicActive));
|
||||
return true;
|
||||
}
|
||||
|
||||
function claimCinematicCamera() {
|
||||
@@ -3029,6 +2960,7 @@ export function createBhoteKoshiEventLayer({
|
||||
'textContent',
|
||||
`~${elapsedLabel(_sceneScrubProgress, sceneDurationSec)} / ${elapsedLabel(1, sceneDurationSec)}`,
|
||||
);
|
||||
publishPanel();
|
||||
if (_sceneSeekFrame != null) return;
|
||||
const schedule =
|
||||
globalThis.requestAnimationFrame ||
|
||||
@@ -3302,7 +3234,7 @@ export function createBhoteKoshiEventLayer({
|
||||
'disabled',
|
||||
!observation?.media?.sourceUrl,
|
||||
);
|
||||
syncCinematicButton();
|
||||
writeCinematicButton();
|
||||
setElementProperty(
|
||||
_panelRefs.splitInput,
|
||||
'value',
|
||||
@@ -3314,6 +3246,7 @@ export function createBhoteKoshiEventLayer({
|
||||
'textContent',
|
||||
`${beforePercent} / ${100 - beforePercent}`,
|
||||
);
|
||||
publishPanel();
|
||||
}
|
||||
|
||||
function syncPresentationMode() {
|
||||
@@ -3330,7 +3263,7 @@ export function createBhoteKoshiEventLayer({
|
||||
const evidenceAvailable =
|
||||
!sceneDirected ||
|
||||
(_sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT && knownSceneBeat);
|
||||
_panel?.classList?.toggle('bhote-event-scene-beat', sceneDirected);
|
||||
if (_panelRefs) _panelRefs.root.sceneBeat = sceneDirected;
|
||||
if (_panelRefs?.playSceneButton)
|
||||
_panelRefs.playSceneButton.hidden = !sceneDirected;
|
||||
if (_panelRefs?.imagerySection)
|
||||
@@ -3364,6 +3297,7 @@ export function createBhoteKoshiEventLayer({
|
||||
}
|
||||
if (_panelRefs?.openSourceButton)
|
||||
_panelRefs.openSourceButton.hidden = !evidenceAvailable;
|
||||
publishPanel();
|
||||
overlayHost.setVisible(BHOTE_KOSHI_OVERLAY_SOURCE_ID, evidenceAvailable);
|
||||
}
|
||||
|
||||
@@ -3561,7 +3495,12 @@ export function createBhoteKoshiEventLayer({
|
||||
throwIfEnableCancelled(signal, _enabled);
|
||||
createFloodDataSource();
|
||||
createSplitControl(event);
|
||||
const panelView = createPanel(event, {
|
||||
_panelRefs = createPanelRecords(event);
|
||||
_panelOwner = {};
|
||||
_panelPublished = '';
|
||||
_panelFixed = null;
|
||||
const owner = _panelOwner;
|
||||
const controls = {
|
||||
close: () =>
|
||||
_dataManager?.setEnabled(BHOTE_KOSHI_LAYER_ID, false, {
|
||||
origin: 'user',
|
||||
@@ -3578,9 +3517,23 @@ export function createBhoteKoshiEventLayer({
|
||||
commitProgress,
|
||||
setSplit,
|
||||
focusCorridor,
|
||||
});
|
||||
_panel = panelView.panel;
|
||||
_panelRefs = panelView.refs;
|
||||
openReport: (index) => {
|
||||
const url = event.fieldReports[index]?.url;
|
||||
if (url) openExternal(url);
|
||||
},
|
||||
};
|
||||
// Only the panel this enable published may act.
|
||||
panelStore.connect(
|
||||
Object.fromEntries(
|
||||
Object.entries(controls).map(([name, control]) => [
|
||||
name,
|
||||
(...args) => (_panelOwner === owner ? control(...args) : undefined),
|
||||
]),
|
||||
),
|
||||
);
|
||||
publishPanel();
|
||||
_offPanelLocale?.();
|
||||
_offPanelLocale = onLocaleChange(relocalizePanel);
|
||||
syncPresentationMode();
|
||||
overlayHost.setVisible(
|
||||
BHOTE_KOSHI_OVERLAY_SOURCE_ID,
|
||||
@@ -3673,12 +3626,16 @@ export function createBhoteKoshiEventLayer({
|
||||
_surgePoint = null;
|
||||
_lastSurgePosition = null;
|
||||
_lastSurgeVisible = null;
|
||||
_panel?.remove();
|
||||
document
|
||||
.getElementById?.('right-context-rail')
|
||||
?.classList.remove('bhote-event-active');
|
||||
_panel = null;
|
||||
if (_panelOwner && panelStore.panel?.owner === _panelOwner) {
|
||||
panelStore.connect(null);
|
||||
panelStore.publish(null);
|
||||
}
|
||||
_offPanelLocale?.();
|
||||
_offPanelLocale = null;
|
||||
_panelOwner = null;
|
||||
_panelRefs = null;
|
||||
_panelPublished = '';
|
||||
_panelFixed = null;
|
||||
_splitControl?.destroy();
|
||||
_splitControl = null;
|
||||
_progress = 0;
|
||||
|
||||
@@ -3,6 +3,9 @@ import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import { claimPointer, releasePointer } from './inputOwnership.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
|
||||
import { useImagerySplitStore } from '../ui/stores/imagerySplit.js';
|
||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||
import { SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js';
|
||||
import {
|
||||
BHOTE_KOSHI_LAYER_ID,
|
||||
@@ -36,6 +39,95 @@ import { SceneDirector } from '../scenes/director.js';
|
||||
const eventUrl = new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url);
|
||||
const eventModuleUrl = new URL('./bhoteKoshiEvent.js', import.meta.url);
|
||||
|
||||
/** The event's divider as the imagery split store holds it, or undefined. */
|
||||
const eventSplit = () =>
|
||||
useImagerySplitStore(applicationPinia()).splits.findLast(
|
||||
(split) => split.view.id === 'bhote-koshi-split-line',
|
||||
);
|
||||
|
||||
// The panel controls the layer presents, by the selector the panel view gives each.
|
||||
const PANEL_REFS = {
|
||||
'[data-role="imagery-comparison"]': 'imagerySection',
|
||||
'[data-role="time"]': 'time',
|
||||
'[data-role="caveat"]': 'caveat',
|
||||
'[data-role="imagery-cloud-note"]': 'imageryCloudNote',
|
||||
'[data-role="progress"]': 'progressInput',
|
||||
'[data-role="split"]': 'splitInput',
|
||||
'[data-role="timeline-label"]': 'timelineLabel',
|
||||
'[data-role="beat-index"]': 'beatIndex',
|
||||
'[data-role="beat-title"]': 'beatTitle',
|
||||
'[data-role="beat-meta"]': 'beatMeta',
|
||||
'[data-action="play"]': 'playButton',
|
||||
'[data-action="play-scene"]': 'playSceneButton',
|
||||
'[data-action="cinematic"]': 'cinematicButton',
|
||||
'[data-action="story-replay"]': 'storyReplayButton',
|
||||
'[data-action="corridor"]': 'corridorButton',
|
||||
'[data-action="previous-beat"]': 'previousButton',
|
||||
'[data-action="next-beat"]': 'nextButton',
|
||||
'[data-action="open-source"]': 'openSourceButton',
|
||||
};
|
||||
// What each control calls, as the panel view wires it.
|
||||
const PANEL_EVENTS = {
|
||||
playButton: { click: 'play' },
|
||||
playSceneButton: { click: 'playScene' },
|
||||
cinematicButton: { click: 'toggleCinematic' },
|
||||
storyReplayButton: { click: 'replayCinematic' },
|
||||
corridorButton: { click: 'focusCorridor' },
|
||||
previousButton: { click: 'previousBeat' },
|
||||
nextButton: { click: 'nextBeat' },
|
||||
openSourceButton: { click: 'openActiveSource' },
|
||||
progressInput: {
|
||||
input: (controls, value) => controls.previewProgress(Number(value) / 1000),
|
||||
change: (controls, value) => controls.commitProgress(Number(value) / 1000, { final: true }),
|
||||
},
|
||||
splitInput: { input: (controls, value) => controls.setSplit(Number(value) / 100) },
|
||||
};
|
||||
|
||||
/**
|
||||
* The event panel as the layer presents it to the panel store, or undefined
|
||||
* while it is off. Controls read the latest presentation, and dispatching an
|
||||
* event calls the connected controls as the panel view does.
|
||||
*/
|
||||
function eventPanel() {
|
||||
const store = useBhoteKoshiPanelStore(applicationPinia());
|
||||
if (!store.panel) return undefined;
|
||||
const control = (key) => {
|
||||
let typed = null;
|
||||
const record = () => store.panel.refs[key];
|
||||
return {
|
||||
get textContent() { return record().textContent; },
|
||||
get hidden() { return record().hidden; },
|
||||
get disabled() { return record().disabled; },
|
||||
get value() { return typed ?? record().value; },
|
||||
set value(value) { typed = value; },
|
||||
getAttribute: (name) => record().attributes[name] ?? null,
|
||||
dispatch(type) {
|
||||
const value = typed ?? record().value;
|
||||
typed = null;
|
||||
const handler = PANEL_EVENTS[key]?.[type];
|
||||
return typeof handler === 'function'
|
||||
? handler(store.controls, value)
|
||||
: store.controls[handler]?.();
|
||||
},
|
||||
};
|
||||
};
|
||||
const named = (name) => ({ dispatch: () => store.controls[name]() });
|
||||
return {
|
||||
querySelector(selector) {
|
||||
if (selector === '.bhote-event-report-list') {
|
||||
return {
|
||||
children: store.panel.reports.map((_, index) => ({
|
||||
dispatch: () => store.controls.openReport(index),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (selector === '[data-action="geolocation-map"]') return named('openGeolocationMap');
|
||||
if (selector === '[data-action="close"]') return named('close');
|
||||
return control(PANEL_REFS[selector]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName = 'div') {
|
||||
this.tagName = tagName;
|
||||
@@ -840,6 +932,7 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever
|
||||
const originalDocument = globalThis.document;
|
||||
const originalWindow = globalThis.window;
|
||||
const { body, removedProperties } = installEventDom();
|
||||
assert.equal(eventSplit(), undefined, 'no divider is left from an earlier test');
|
||||
const clearedTimeouts = [];
|
||||
globalThis.window = {
|
||||
setTimeout: () => 71,
|
||||
@@ -879,7 +972,8 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever
|
||||
assert.equal(await layer.enable(viewer, { origin: 'scene' }), true);
|
||||
assert.equal(viewer._test.imagery.length, 2);
|
||||
assert.equal(viewer._test.dataSources.length, 1);
|
||||
assert.equal(body.children.length, 2);
|
||||
assert.ok(eventPanel());
|
||||
assert.ok(eventSplit());
|
||||
|
||||
activeMap = 'operator-choice';
|
||||
generation += 1;
|
||||
@@ -888,7 +982,8 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever
|
||||
assert.deepEqual(mapCalls, ['esri-imagery']);
|
||||
assert.equal(viewer._test.removedImagery.length, 2);
|
||||
assert.equal(viewer._test.removedDataSources.length, 1);
|
||||
assert.ok(body.children.every((element) => element.removed));
|
||||
assert.equal(eventPanel(), undefined);
|
||||
assert.equal(eventSplit(), undefined);
|
||||
assert.equal(viewer.scene.splitPosition, 0.37);
|
||||
assert.deepEqual(clearedTimeouts, [71]);
|
||||
assert.ok(overlayCalls.some(([kind]) => kind === 'clear'));
|
||||
@@ -934,7 +1029,7 @@ test('Bhote Koshi disable stops active playback and releases its global render h
|
||||
|
||||
try {
|
||||
await layer.enable(viewer, { origin: 'scene' });
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
const panel = eventPanel();
|
||||
assert.equal(panel.querySelector('[data-action="play-scene"]').hidden, true);
|
||||
assert.equal(await panel.querySelector('[data-action="play-scene"]').dispatch('click'), false);
|
||||
panel.querySelector('[data-action="play"]').dispatch('click');
|
||||
@@ -1075,6 +1170,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
|
||||
const { body } = installEventDom();
|
||||
assert.equal(eventSplit(), undefined, 'no divider is left from an earlier test');
|
||||
const timers = [];
|
||||
const openedUrls = [];
|
||||
const cancelledFrames = [];
|
||||
@@ -1181,11 +1277,10 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
|
||||
assert.deepEqual(timers, []);
|
||||
assert.equal(viewer._test.imagery.length, 2, 'the event surface keeps its cached imagery pair');
|
||||
assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false));
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
const panel = eventPanel();
|
||||
assert.ok(panel);
|
||||
const splitLine = body.children.find((element) => element.id === 'bhote-koshi-split-line');
|
||||
assert.ok(splitLine);
|
||||
assert.equal(splitLine.hidden, true);
|
||||
assert.ok(eventSplit());
|
||||
assert.equal(eventSplit().view.hidden, true);
|
||||
assert.equal(panel.querySelector('[data-role="imagery-comparison"]').hidden, true);
|
||||
assert.match(panel.querySelector('[data-role="caveat"]').textContent, /SCHEMATIC CORRIDOR/i);
|
||||
assert.doesNotMatch(panel.querySelector('[data-role="caveat"]').textContent, /historical/i);
|
||||
@@ -1220,8 +1315,6 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
|
||||
assert.equal(panel.querySelector('[data-action="cinematic"]').disabled, true);
|
||||
assert.equal(panel.querySelector('[data-action="corridor"]').hidden, false);
|
||||
assert.equal(panel.querySelector('[data-action="corridor"]').disabled, false);
|
||||
assert.doesNotMatch(panel.innerHTML, /data-action="witness"|◎ WITNESS/);
|
||||
assert.match(panel.innerHTML, /data-action="open-source"/);
|
||||
const fullStory = panel.querySelector('[data-action="story-replay"]');
|
||||
assert.equal(fullStory.textContent, '↺ FULL STORY');
|
||||
assert.equal(fullStory.hidden, true, 'PLAY SCENE owns authored scene playback');
|
||||
@@ -1240,30 +1333,27 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
|
||||
beatId: 'timure-cluster',
|
||||
sceneControls: { imageryComparison: true },
|
||||
}, { origin: 'scene' });
|
||||
assert.equal(splitLine.hidden, false);
|
||||
assert.equal(eventSplit().view.hidden, false);
|
||||
assert.equal(panel.querySelector('[data-role="imagery-comparison"]').hidden, false);
|
||||
assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === true));
|
||||
const splitHandle = splitLine.children.find(
|
||||
(element) => element.className === 'bhote-koshi-split-handle',
|
||||
);
|
||||
assert.ok(splitHandle);
|
||||
assert.equal(splitHandle.textContent, '↔');
|
||||
assert.equal(splitHandle.getAttribute('aria-valuenow'), '62');
|
||||
splitHandle.dispatch('pointerdown', {
|
||||
assert.equal(eventSplit().view.handleClass, 'bhote-koshi-split-handle');
|
||||
assert.equal(eventSplit().view.valueNow, 62);
|
||||
const splitHandle = eventSplit().controls;
|
||||
splitHandle.pointerdown({
|
||||
button: 0,
|
||||
clientX: 250,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
});
|
||||
splitHandle.dispatch('pointermove', {
|
||||
splitHandle.pointermove({
|
||||
clientX: 750,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
});
|
||||
splitHandle.dispatch('pointerup', { pointerId: 4 });
|
||||
splitHandle.pointerup({ pointerId: 4 });
|
||||
assert.equal(viewer.scene.splitPosition, 0.75);
|
||||
assert.equal(splitHandle.getAttribute('aria-valuenow'), '75');
|
||||
splitHandle.dispatch('keydown', {
|
||||
assert.equal(eventSplit().view.valueNow, 75);
|
||||
splitHandle.keydown({
|
||||
key: 'ArrowLeft',
|
||||
shiftKey: true,
|
||||
preventDefault() {},
|
||||
@@ -1276,7 +1366,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
|
||||
split: 0.62,
|
||||
sceneControls: {},
|
||||
}, { origin: 'scene' });
|
||||
assert.equal(splitLine.hidden, true);
|
||||
assert.equal(eventSplit().view.hidden, true);
|
||||
assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false));
|
||||
assert.equal(viewer.scene.splitPosition, 0.62);
|
||||
assert.equal(overlayVisibility.at(-1).visible, false);
|
||||
@@ -1347,7 +1437,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
|
||||
assert.equal(layer.getCinematicState().active, false);
|
||||
assert.equal(viewer._test.cameraLooks.length, 0);
|
||||
assert.deepEqual(mapCalls, []);
|
||||
assert.equal(body.children.filter((element) => element.tagName === 'aside').length, 1);
|
||||
assert.ok(eventPanel());
|
||||
assert.equal(viewer.scene.splitPosition, 0.62);
|
||||
for (const beatId of [
|
||||
'debris-dammed-lake',
|
||||
@@ -1594,7 +1684,7 @@ test('Bhote Koshi publishes the evidence spine once and scrubs through cards and
|
||||
assert.equal(breadcrumbs.filter((entry) => entry.position()).length, 0);
|
||||
assert.ok(cards.every((entry) => entry.requireImage && entry.image.stamp === 1));
|
||||
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
const panel = eventPanel();
|
||||
const progress = panel.querySelector('[data-role="progress"]');
|
||||
progress.value = '285';
|
||||
progress.dispatch('input');
|
||||
@@ -1685,7 +1775,7 @@ test('Bhote Koshi keeps one local fallback video active and unloads it on beat c
|
||||
videos[0].dispatch('loadeddata');
|
||||
assert.equal(layer.getCinematicState().mediaReady, true);
|
||||
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
const panel = eventPanel();
|
||||
const progress = panel.querySelector('[data-role="progress"]');
|
||||
progress.value = '285';
|
||||
progress.dispatch('input');
|
||||
@@ -1808,7 +1898,7 @@ test('Upper Valley Collapse autoplays its muted local fallback without starting
|
||||
assert.ok(video.pauseCalls >= 1);
|
||||
assert.ok(video.cancelledFrameHandles.length >= 1);
|
||||
assert.equal(layer.getCinematicState().mediaEvidenceId, null);
|
||||
assert.equal(body.children.find((element) => element.tagName === 'aside')?.removed, false);
|
||||
assert.ok(eventPanel());
|
||||
await layer.disable();
|
||||
} finally {
|
||||
globalThis.document = originalDocument;
|
||||
@@ -2121,10 +2211,8 @@ test('Bhote Koshi beat controls keep cards, flood, camera, and panel on the one
|
||||
|
||||
try {
|
||||
await layer.enable(viewer, { origin: 'local-restore' });
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
assert.match(panel.innerHTML, /2021 HISTORICAL REFERENCE \/ 2026 POST-EVENT/);
|
||||
assert.match(panel.innerHTML, /RECONSTRUCTION CLOCK/);
|
||||
assert.match(panel.innerHTML, /OPEN PUBLIC MAP/);
|
||||
const panel = eventPanel();
|
||||
assert.equal(panel.querySelector('[data-role="timeline-label"]').textContent, 'RECONSTRUCTION CLOCK');
|
||||
const entries = publications[0].entries;
|
||||
const cards = entries.filter((entry) => entry.variant === 'thumbnail');
|
||||
const breadcrumbs = entries.filter((entry) => entry.variant === 'label');
|
||||
@@ -2203,7 +2291,7 @@ test('Bhote Koshi beat controls keep cards, flood, camera, and panel on the one
|
||||
}
|
||||
});
|
||||
|
||||
test('Bhote Koshi repeated event frames perform no panel lookup, DOM write, or camera churn', async () => {
|
||||
test('Bhote Koshi repeated event frames republish no panel and churn no camera', async () => {
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const originalDocument = globalThis.document;
|
||||
const originalWindow = globalThis.window;
|
||||
@@ -2238,26 +2326,19 @@ test('Bhote Koshi repeated event frames perform no panel lookup, DOM write, or c
|
||||
|
||||
try {
|
||||
await layer.enable(viewer, { origin: 'local-restore' });
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
const panel = eventPanel();
|
||||
const cinematic = panel.querySelector('[data-action="cinematic"]');
|
||||
const progress = panel.querySelector('[data-role="progress"]');
|
||||
cinematic.dispatch('click');
|
||||
progress.value = '500';
|
||||
progress.dispatch('input');
|
||||
|
||||
const queryCount = panel._queryCount;
|
||||
const writeCount = [...panel._selectors.values()].reduce((sum, element) => (
|
||||
sum + element._writeCount
|
||||
), 0);
|
||||
const panelStore = useBhoteKoshiPanelStore(applicationPinia());
|
||||
const published = panelStore.panel;
|
||||
const cameraLookCount = viewer._test.cameraLooks.length;
|
||||
for (let index = 0; index < 20; index += 1) progress.dispatch('input');
|
||||
|
||||
assert.equal(panel._queryCount, queryCount, 'hot path uses cached panel references');
|
||||
assert.equal(
|
||||
[...panel._selectors.values()].reduce((sum, element) => sum + element._writeCount, 0),
|
||||
writeCount,
|
||||
'unchanged frames do not rewrite panel state',
|
||||
);
|
||||
assert.ok(panelStore.panel === published, 'unchanged frames do not republish the panel');
|
||||
assert.equal(
|
||||
viewer._test.cameraLooks.length,
|
||||
cameraLookCount,
|
||||
@@ -2328,7 +2409,7 @@ test('Bhote Koshi cinematic camera stays synchronized through pause and determin
|
||||
|
||||
try {
|
||||
await layer.enable(viewer, { origin: 'local-restore' });
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
const panel = eventPanel();
|
||||
panel.querySelector('[data-action="cinematic"]').dispatch('click');
|
||||
assert.equal(layer.getCinematicState().active, true);
|
||||
assert.equal(layer.getPlaybackState().playing, true);
|
||||
@@ -2413,7 +2494,7 @@ test('Bhote Koshi replay restores cinematic ownership unless the operator releas
|
||||
|
||||
try {
|
||||
await layer.enable(viewer, { origin: 'local-restore' });
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
const panel = eventPanel();
|
||||
panel.querySelector('[data-action="cinematic"]').dispatch('click');
|
||||
animationCallbacks[0](91001);
|
||||
assert.equal(layer.getPlaybackState().progress, 1);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from './dataCredits.js';
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
|
||||
import { t, msg, getLocale } from '../i18n/index.js';
|
||||
import { t, msg, getLocale, isolate } from '../i18n/index.js';
|
||||
|
||||
const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
|
||||
export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
|
||||
@@ -67,7 +67,10 @@ const PLACE_TITLE_TEXT = new Map(
|
||||
|
||||
function placeTitle(title) {
|
||||
const text = PLACE_TITLE_TEXT.get(title);
|
||||
return text ? t(text).toLocaleUpperCase(getLocale()) : title;
|
||||
// Isolated: the card draws left to right after its "11 · " counter, so in
|
||||
// Arabic a translated title with a Latin place name («نقطة الإطلالة في
|
||||
// DANDAGAUN») must keep its own right-to-left order.
|
||||
return text ? isolate(t(text).toLocaleUpperCase(getLocale())) : title;
|
||||
}
|
||||
|
||||
const INCIDENT = Object.freeze({
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// src/data/bhoteKoshiPanelView.test.mjs — the context rail renders the Bhote
|
||||
// Koshi reconstruction panel the event layer presents and sends its clicks
|
||||
// and slider moves back.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
|
||||
import ContextView from '../ui/views/ContextView.vue';
|
||||
|
||||
const record = (initial = {}) => ({
|
||||
textContent: '',
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
value: '',
|
||||
attributes: {},
|
||||
...initial,
|
||||
});
|
||||
|
||||
function presentation(patch = {}) {
|
||||
return {
|
||||
owner: {},
|
||||
sceneBeat: false,
|
||||
beforeDate: { name: 'Historical reference', date: '2021-05-01' },
|
||||
afterDate: { name: 'Post-event', date: '' },
|
||||
reports: [
|
||||
{ label: 'Report A', status: 'Verified' },
|
||||
{ label: 'Report B', status: 'Unverified' },
|
||||
],
|
||||
refs: {
|
||||
imagerySection: record(),
|
||||
observedStatus: record(),
|
||||
time: record({ textContent: '~0:00 / 2:00' }),
|
||||
caveat: record({ textContent: 'Schematic only.' }),
|
||||
imageryCloudNote: record(),
|
||||
playButton: record({ textContent: '▶ PLAY' }),
|
||||
playSceneButton: record({ textContent: '▶ PLAY SCENE', hidden: true }),
|
||||
cinematicButton: record({
|
||||
textContent: '◉ CINEMATIC',
|
||||
attributes: { 'aria-pressed': 'false' },
|
||||
}),
|
||||
storyReplayButton: record({ textContent: '↺ FULL STORY' }),
|
||||
corridorButton: record(),
|
||||
previousButton: record({
|
||||
disabled: true,
|
||||
attributes: { 'aria-label': 'Previous story beat' },
|
||||
}),
|
||||
nextButton: record({ attributes: { 'aria-label': 'Next story beat' } }),
|
||||
openSourceButton: record(),
|
||||
progressInput: record({
|
||||
value: '0',
|
||||
attributes: { 'aria-label': 'Schematic downstream progression' },
|
||||
}),
|
||||
splitInput: record({ value: '50' }),
|
||||
splitReadout: record({ textContent: '50 / 50' }),
|
||||
timelineLabel: record({ textContent: 'RECONSTRUCTION CLOCK' }),
|
||||
beatIndex: record({ textContent: '01 / 06' }),
|
||||
beatTitle: record({ textContent: 'CAUSE' }),
|
||||
beatMeta: record({ textContent: 'CAPTURE TIME UNVERIFIED' }),
|
||||
},
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const pinia = createPinia();
|
||||
const app = createApp(ContextView).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => app.unmount());
|
||||
const store = useBhoteKoshiPanelStore(pinia);
|
||||
const calls = [];
|
||||
store.connect(
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get:
|
||||
(_, name) =>
|
||||
(...args) =>
|
||||
calls.push([name, ...args]),
|
||||
},
|
||||
),
|
||||
);
|
||||
const rail = document.getElementById('right-context-rail');
|
||||
const $ = (selector) => rail.querySelector(selector);
|
||||
return { store, calls, rail, $ };
|
||||
}
|
||||
|
||||
test('the panel renders last in the context rail and marks the rail while on', async (t) => {
|
||||
const { store, rail, $ } = fixture(t);
|
||||
assert.equal($('#bhote-koshi-event-panel'), null);
|
||||
assert.equal(rail.classList.contains('bhote-event-active'), false);
|
||||
store.publish(presentation());
|
||||
await nextTick();
|
||||
const panel = $('#bhote-koshi-event-panel');
|
||||
assert.equal(panel.tagName, 'ASIDE');
|
||||
assert.ok(rail.lastElementChild === panel);
|
||||
assert.ok(rail.classList.contains('ui-scroll'));
|
||||
assert.ok(rail.classList.contains('bhote-event-active'));
|
||||
assert.equal(
|
||||
panel.getAttribute('aria-label'),
|
||||
'Bhote Koshi flood reconstruction controls',
|
||||
);
|
||||
assert.match(
|
||||
panel.textContent,
|
||||
/2021 HISTORICAL REFERENCE \/ 2026 POST-EVENT/,
|
||||
);
|
||||
assert.match(panel.textContent, /RECONSTRUCTION CLOCK/);
|
||||
assert.match(panel.textContent, /OPEN PUBLIC MAP/);
|
||||
assert.equal(
|
||||
$('[data-role="before-date"]').textContent,
|
||||
'Historical reference · 2021-05-01',
|
||||
);
|
||||
assert.equal(
|
||||
$('[data-role="before-date"] .bhote-event-date').textContent,
|
||||
'2021-05-01',
|
||||
);
|
||||
assert.equal($('[data-role="after-date"]').textContent, 'Post-event');
|
||||
assert.equal($('[data-role="after-date"] .bhote-event-date'), null);
|
||||
assert.equal($('[data-action="play-scene"]').hidden, true);
|
||||
assert.ok($('[data-action="open-source"]'));
|
||||
assert.equal($('[data-action="witness"]'), null);
|
||||
assert.doesNotMatch(panel.innerHTML, /witness/i);
|
||||
assert.equal($('[data-action="previous-beat"]').disabled, true);
|
||||
assert.equal($('[data-action="previous-beat"]').title, 'Previous story beat');
|
||||
assert.equal(
|
||||
$('[data-action="cinematic"]').getAttribute('aria-pressed'),
|
||||
'false',
|
||||
);
|
||||
assert.equal($('[data-action="play"]').hasAttribute('aria-label'), false);
|
||||
assert.equal($('[data-role="progress"]').value, '0');
|
||||
assert.equal($('[data-role="split"]').value, '50');
|
||||
assert.equal($('summary').textContent, 'FIELD REPORTS · 2');
|
||||
assert.deepEqual(
|
||||
[...$('.bhote-event-report-list').children].map(
|
||||
(button) => button.textContent,
|
||||
),
|
||||
['Report AVerified', 'Report BUnverified'],
|
||||
);
|
||||
store.publish(null);
|
||||
await nextTick();
|
||||
assert.equal($('#bhote-koshi-event-panel'), null);
|
||||
assert.equal(rail.classList.contains('bhote-event-active'), false);
|
||||
assert.ok(rail.classList.contains('ui-scroll'));
|
||||
});
|
||||
|
||||
test('a later presentation updates the controls in place', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
store.publish(presentation());
|
||||
await nextTick();
|
||||
const play = $('[data-action="play"]');
|
||||
const next = presentation({ sceneBeat: true });
|
||||
next.refs.playButton = record({
|
||||
textContent: '▶ PLAY SHOT',
|
||||
disabled: true,
|
||||
attributes: { 'aria-label': 'Play Global context' },
|
||||
});
|
||||
next.refs.progressInput = record({ value: '250', hidden: true });
|
||||
next.refs.imagerySection = record({ hidden: true });
|
||||
store.publish(next);
|
||||
await nextTick();
|
||||
assert.ok($('[data-action="play"]') === play);
|
||||
assert.equal(play.textContent, '▶ PLAY SHOT');
|
||||
assert.equal(play.disabled, true);
|
||||
assert.equal(play.getAttribute('aria-label'), 'Play Global context');
|
||||
assert.ok(
|
||||
$('#bhote-koshi-event-panel').classList.contains('bhote-event-scene-beat'),
|
||||
);
|
||||
assert.equal($('[data-role="progress"]').value, '250');
|
||||
assert.equal($('[data-role="progress"]').hidden, true);
|
||||
assert.equal($('[data-role="imagery-comparison"]').hidden, true);
|
||||
});
|
||||
|
||||
test('buttons, sliders and reports call the connected controls', async (t) => {
|
||||
const { store, calls, $ } = fixture(t);
|
||||
store.publish(presentation());
|
||||
await nextTick();
|
||||
for (const action of [
|
||||
'close',
|
||||
'play',
|
||||
'cinematic',
|
||||
'story-replay',
|
||||
'open-source',
|
||||
'corridor',
|
||||
'next-beat',
|
||||
'geolocation-map',
|
||||
])
|
||||
$(`[data-action="${action}"]`).click();
|
||||
$('.bhote-event-report-list').children[1].click();
|
||||
const progress = $('[data-role="progress"]');
|
||||
progress.value = '500';
|
||||
progress.dispatchEvent(new Event('input'));
|
||||
progress.dispatchEvent(new Event('change'));
|
||||
const split = $('[data-role="split"]');
|
||||
split.value = '25';
|
||||
split.dispatchEvent(new Event('input'));
|
||||
assert.deepEqual(calls, [
|
||||
['close'],
|
||||
['play'],
|
||||
['toggleCinematic'],
|
||||
['replayCinematic'],
|
||||
['openActiveSource'],
|
||||
['focusCorridor'],
|
||||
['nextBeat'],
|
||||
['openGeolocationMap'],
|
||||
['openReport', 1],
|
||||
['previewProgress', 0.5],
|
||||
['commitProgress', 0.5, { final: true }],
|
||||
['setSplit', 0.25],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
// Outline names are drawn by the DOM annotation overlay, never as Cesium
|
||||
// text labels: the boundary data modules, the outline resolver and the
|
||||
// annotation renderers must not import or construct Cesium label primitives,
|
||||
// and drawing a bundled outline must create none at runtime.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import * as Cesium from 'cesium';
|
||||
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||
import { createAnnotationResolver } from '../annotations/resolver.js';
|
||||
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
|
||||
|
||||
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8');
|
||||
|
||||
/** Pure data modules: no Cesium at all. */
|
||||
const DATA_MODULES = [
|
||||
'./adminBoundaries.js',
|
||||
'./placeBoundaries.js',
|
||||
'./neighborhoodPolygons.js',
|
||||
'./local_data/us_census_places/files.js',
|
||||
'./local_data/wof_neighborhoods/files.js',
|
||||
];
|
||||
|
||||
/** Modules that use Cesium for outlines but must not draw text with it. */
|
||||
const CESIUM_MODULES = [
|
||||
'../annotations/resolver.js',
|
||||
'../annotations/worldAnnotationRenderer.js',
|
||||
'../annotations/hybridAnnotationRenderer.js',
|
||||
];
|
||||
|
||||
/** The DOM overlay: callout elements, never Cesium label primitives. */
|
||||
const OVERLAY_MODULES = ['../annotations/screenAnnotationRenderer.js'];
|
||||
|
||||
const LABEL_PRIMITIVES = [
|
||||
/\bLabelCollection\b/,
|
||||
/\bLabelGraphics\b/,
|
||||
/\bLabelStyle\b/,
|
||||
/\bLabelVisualizer\b/,
|
||||
];
|
||||
|
||||
/** Entity label options, inline or assigned later. */
|
||||
const ENTITY_LABELS = [
|
||||
// `label: { text: … }` or `label: new …`
|
||||
/\blabel\s*:\s*\{[^{}]*\btext\s*:/s,
|
||||
/\blabel\s*:\s*new\b/,
|
||||
// `label: labelOptions` (anything but `null`)
|
||||
/\blabel\s*:\s*(?!null\b)[A-Za-z_$][\w$]*\s*[,}\n]/,
|
||||
// `entity.label = …`
|
||||
/\.label\s*=(?!=)/,
|
||||
];
|
||||
|
||||
test('boundary data modules never import Cesium', () => {
|
||||
for (const path of DATA_MODULES) {
|
||||
const source = read(path);
|
||||
assert.doesNotMatch(source, /from\s+['"]cesium['"]/, path);
|
||||
assert.doesNotMatch(source, /import\(\s*['"]cesium['"]/, path);
|
||||
}
|
||||
});
|
||||
|
||||
test('outline modules and renderers construct no Cesium labels', () => {
|
||||
for (const path of [...DATA_MODULES, ...CESIUM_MODULES, ...OVERLAY_MODULES])
|
||||
for (const pattern of LABEL_PRIMITIVES)
|
||||
assert.doesNotMatch(read(path), pattern, `${path}: ${pattern}`);
|
||||
for (const path of [...DATA_MODULES, ...CESIUM_MODULES])
|
||||
for (const pattern of ENTITY_LABELS)
|
||||
assert.doesNotMatch(read(path), pattern, `${path}: ${pattern}`);
|
||||
});
|
||||
|
||||
test('the static check catches a Cesium label however it is written', () => {
|
||||
const offending = [
|
||||
'viewer.scene.primitives.add(new Cesium.LabelCollection());',
|
||||
"viewer.entities.add({ position, label: { text: 'Austin' } });",
|
||||
'const graphics = new LabelGraphics({ text });',
|
||||
'viewer.entities.add({ position, label: labelOptions });',
|
||||
'entity.label = new Cesium.LabelGraphics(options);',
|
||||
'entity.label = options;',
|
||||
];
|
||||
const all = [...LABEL_PRIMITIVES, ...ENTITY_LABELS];
|
||||
for (const source of offending)
|
||||
assert.ok(
|
||||
all.some((pattern) => pattern.test(source)),
|
||||
source,
|
||||
);
|
||||
// Data, not Cesium labels: a result's label point, a stripped proxy label.
|
||||
for (const source of [
|
||||
'label: geometry.label,',
|
||||
'liveProxy(anno, { label: null });',
|
||||
'if (entity.label === x) {}',
|
||||
])
|
||||
assert.ok(!ENTITY_LABELS.some((p) => p.test(source)), source);
|
||||
});
|
||||
|
||||
test('drawing bundled place and neighborhood outlines creates no Cesium label', async (t) => {
|
||||
// Cesium's material setup checks for these browser image types.
|
||||
for (const name of [
|
||||
'HTMLCanvasElement',
|
||||
'HTMLImageElement',
|
||||
'ImageBitmap',
|
||||
'OffscreenCanvas',
|
||||
]) {
|
||||
if (name in globalThis) continue;
|
||||
globalThis[name] = class {};
|
||||
t.after(() => delete globalThis[name]);
|
||||
}
|
||||
const featureSource = {};
|
||||
for (const method of FEATURE_SOURCE_METHODS)
|
||||
featureSource[method] = async () => null;
|
||||
const { resolveAnnotationTarget } = createAnnotationResolver({
|
||||
featureSource,
|
||||
});
|
||||
const primitives = [];
|
||||
const dataSources = [];
|
||||
const viewer = {
|
||||
camera: {
|
||||
positionCartographic: Cesium.Cartographic.fromDegrees(
|
||||
-97.7431,
|
||||
30.2672,
|
||||
3000,
|
||||
),
|
||||
},
|
||||
scene: { primitives: { add: (p) => primitives.push(p) } },
|
||||
dataSources: {
|
||||
add: (ds) => dataSources.push(ds),
|
||||
remove() {},
|
||||
},
|
||||
};
|
||||
const renderer = createWorldAnnotationRenderer(viewer);
|
||||
for (const [target, lon, lat] of [
|
||||
['Austin', -97.7431, 30.2672],
|
||||
['Notting Hill', -0.2, 51.511],
|
||||
]) {
|
||||
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
|
||||
lon,
|
||||
lat,
|
||||
3000,
|
||||
);
|
||||
const resolved = await resolveAnnotationTarget({
|
||||
// The resolver reads only the camera (no scene to pick from).
|
||||
viewer: { camera: viewer.camera },
|
||||
target,
|
||||
footprint: true,
|
||||
deferFootprint: true,
|
||||
});
|
||||
assert.equal(resolved.source, 'bundled', target);
|
||||
renderer.add({
|
||||
id: target,
|
||||
type: 'area',
|
||||
label: resolved.label,
|
||||
ring: resolved.ring,
|
||||
polygons: resolved.polygons,
|
||||
footprintKind: resolved.footprintKind,
|
||||
synthesized: resolved.synthesized,
|
||||
anchor: { lon: resolved.lon, lat: resolved.lat, height: 0 },
|
||||
color: 'primary',
|
||||
});
|
||||
}
|
||||
assert.equal(primitives.length, 0, 'no primitive collections');
|
||||
const entities = dataSources.flatMap((ds) => ds.entities.values);
|
||||
assert.ok(entities.length >= 2, 'the outlines were drawn');
|
||||
for (const entity of entities) assert.equal(entity.label, undefined);
|
||||
renderer.destroy();
|
||||
});
|
||||
@@ -25,9 +25,6 @@ function componentFunctionSource(fn) {
|
||||
.replace(/\(\s+/g, '(');
|
||||
}
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as Cesium from 'cesium';
|
||||
import cctvLayer, {
|
||||
CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS,
|
||||
@@ -83,16 +80,6 @@ import {
|
||||
LIVE_CAMERA_COLOR,
|
||||
} from '../layers/cctv/policy.js';
|
||||
|
||||
const CCTV_PRESENTATION_SOURCE = fs.readFileSync(
|
||||
path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'ui',
|
||||
'cctvPresentation.js',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const ASPECT = 16 / 9;
|
||||
const toRad = (deg) => (deg * Math.PI) / 180;
|
||||
const GESTURE_TYPES = {
|
||||
@@ -1302,20 +1289,6 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
|
||||
assert.equal(cctvCycleIndex(-1, -1, records.length), records.length - 1);
|
||||
assert.equal(cctvCycleIndex(2, 1, records.length), 0);
|
||||
assert.equal(cctvCycleIndex(0, -1, records.length), records.length - 1);
|
||||
|
||||
const renderer = CCTV_PRESENTATION_SOURCE.match(
|
||||
/_renderCctvState\(state\) \{[\s\S]*?\n\}\n/,
|
||||
);
|
||||
assert.ok(renderer, '_renderCctvState is missing');
|
||||
assert.match(
|
||||
renderer[0],
|
||||
// Nothing active clears the pick; only the outside-view note may show.
|
||||
/else if \(!activeId\)[\s\S]*?selectedIndex =[^;]*\? 0 : -1;/,
|
||||
);
|
||||
assert.match(
|
||||
renderer[0],
|
||||
/_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*catalogCount === 0\s*\|\|\s*!activeId/,
|
||||
);
|
||||
} finally {
|
||||
cctvLayer.setParams({ autoHop: false });
|
||||
_setCctvOverlayHostForTest();
|
||||
|
||||
+16
-5
@@ -25,11 +25,11 @@ import * as Cesium from 'cesium';
|
||||
* follows DATA_SOURCES.md (live sources, then bundled snapshots).
|
||||
* @type {{ key: string, html: string }[]}
|
||||
*/
|
||||
const OSM_CREDIT = {
|
||||
export const OSM_CREDIT = {
|
||||
key: 'openstreetmap',
|
||||
html: 'Map and place data <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (<a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener">ODbL</a>)',
|
||||
};
|
||||
const OPENMAPTILES_CREDIT = {
|
||||
export const OPENMAPTILES_CREDIT = {
|
||||
key: 'openfreemap',
|
||||
html: 'Vector tiles: <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://openmaptiles.org" target="_blank" rel="noopener">OpenMapTiles</a>',
|
||||
};
|
||||
@@ -538,15 +538,26 @@ export const NATURAL_EARTH_CREDIT = {
|
||||
'<a href="https://www.naturalearthdata.com" target="_blank" rel="noopener">Natural Earth</a> (public domain)',
|
||||
};
|
||||
|
||||
/** Registered when the first bundled US county outline resolves (public
|
||||
* domain — credited as a courtesy). */
|
||||
/** Registered when the first bundled US county or place outline resolves
|
||||
* (public domain — credited as a courtesy). */
|
||||
export const US_CENSUS_CREDIT = {
|
||||
key: 'us-census-counties',
|
||||
html:
|
||||
'US county boundaries from the ' +
|
||||
'US county and place boundaries from the ' +
|
||||
'<a href="https://www.census.gov/geographies/mapping-files/time-series/geo/cartographic-boundary.html" target="_blank" rel="noopener">U.S. Census Bureau</a> (public domain)',
|
||||
};
|
||||
|
||||
/** Registered when the first bundled Who's On First neighborhood outline
|
||||
* resolves. The link leads to the pack's per-source credits and licences and
|
||||
* says how the boundaries were modified. */
|
||||
export const WOF_CREDIT = {
|
||||
key: 'wof-neighborhoods',
|
||||
html:
|
||||
'Neighborhood boundaries: ' +
|
||||
'<a href="https://whosonfirst.org/docs/licenses/" target="_blank" rel="noopener">Who\'s On First</a> and contributors, simplified (' +
|
||||
'<a href="https://github.com/bilawalsidhu/gods-eye-view/blob/main/src/data/local_data/wof_neighborhoods/ATTRIBUTION.md" target="_blank" rel="noopener">sources and licences</a>)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-feed transit credit, registered the first time that feed's vehicles
|
||||
* render (see `src/data/transitFeeds.js` for the license of each).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expandApplicationHtml } from '../../build/application-html.js';
|
||||
import { renderApplicationHtml } from '../testSupport/applicationHtml.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from './detectionPolicy.js';
|
||||
import { KEYHOLE_OUTSIDE_OPACITY_DEFAULT } from '../celestialRing.js';
|
||||
|
||||
const indexHtml = expandApplicationHtml(fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8'));
|
||||
const indexHtml = await renderApplicationHtml(fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8'));
|
||||
|
||||
test('side aircraft brackets stay readable without changing zero-opacity intent', () => {
|
||||
assert.equal(detectionBracketAlpha('AIR', 0), 0);
|
||||
|
||||
@@ -849,6 +849,8 @@ test('route numbers follow the page language and never break from their unit', (
|
||||
assert.equal(routeDistanceText(994), '990\u00a0m');
|
||||
assert.equal(routeDistanceText(996), '1.0\u00a0km');
|
||||
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
|
||||
assert.equal(routeDistanceText(9940), '9.9\u00a0km');
|
||||
assert.equal(routeDistanceText(9960), '10\u00a0km');
|
||||
assert.equal(routeDistanceText(2_345_000), '2345\u00a0km');
|
||||
assert.equal(routeDurationText(39), '39\u00a0s');
|
||||
assert.equal(routeDurationText(59.4), '59\u00a0s');
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Kilometre decimals for a distance: one below 10 km, none from there on.
|
||||
* The cut is taken on the tenth that prints, so 9.96 km reads 10 km, not 10.0.
|
||||
* @param {number} meters
|
||||
* @returns {0|1}
|
||||
*/
|
||||
export function kilometreDigits(meters) {
|
||||
return Math.round(meters / 100) < 100 ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The unit and precision a distance prints with: metres below 1 km, then
|
||||
* kilometres with `kilometreDigits`. Metres round to `metreStep` (10 for tens),
|
||||
* except below `exactBelowM`, where they stay whole. Each cut is taken after
|
||||
* rounding, so 999.6 m reads 1.0 km rather than 1000 m.
|
||||
* @param {number} meters
|
||||
* @param {{metreStep?: number, exactBelowM?: number}} [options]
|
||||
* @returns {{value: number, unit: 'm'|'km', digits: 0|1}|null} null for a
|
||||
* distance that is negative or not finite. The value is already rounded to
|
||||
* `digits`, so toFixed and Intl render it the same way.
|
||||
*/
|
||||
export function distanceParts(meters, { metreStep = 1, exactBelowM = 0 } = {}) {
|
||||
if (!Number.isFinite(meters) || meters < 0) return null;
|
||||
const step = meters < exactBelowM ? 1 : metreStep;
|
||||
const shownMeters = Math.round(meters / step) * step;
|
||||
if (shownMeters < 1000) return { value: shownMeters, unit: 'm', digits: 0 };
|
||||
const digits = kilometreDigits(meters);
|
||||
const value =
|
||||
digits === 1 ? Math.round(meters / 100) / 10 : Math.round(meters / 1000);
|
||||
return { value, unit: 'km', digits };
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain `850 m` / `4.6 km` / `12 km` text for labels that are not localized.
|
||||
* @param {number} meters
|
||||
* @param {{metreStep?: number, exactBelowM?: number}} [options]
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function formatDistanceText(meters, options) {
|
||||
const parts = distanceParts(meters, options);
|
||||
if (!parts) return null;
|
||||
return `${parts.value.toFixed(parts.digits)} ${parts.unit}`;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
distanceParts,
|
||||
formatDistanceText,
|
||||
kilometreDigits,
|
||||
} from './distanceFormat.js';
|
||||
|
||||
test('a distance switches to kilometres where its metres round to 1,000', () => {
|
||||
assert.equal(formatDistanceText(999.4), '999 m');
|
||||
assert.equal(formatDistanceText(999.5), '1.0 km');
|
||||
assert.equal(formatDistanceText(999.6), '1.0 km');
|
||||
assert.equal(formatDistanceText(994, { metreStep: 10 }), '990 m');
|
||||
assert.equal(formatDistanceText(995, { metreStep: 10 }), '1.0 km');
|
||||
});
|
||||
|
||||
test('kilometres drop their decimal where the tenth rounds to 10.0', () => {
|
||||
assert.equal(kilometreDigits(9949), 1);
|
||||
assert.equal(kilometreDigits(9950), 0);
|
||||
assert.equal(formatDistanceText(9949), '9.9 km');
|
||||
assert.equal(formatDistanceText(9950), '10 km');
|
||||
assert.equal(formatDistanceText(9960), '10 km');
|
||||
assert.equal(formatDistanceText(99950), '100 km');
|
||||
});
|
||||
|
||||
test('metres below exactBelowM stay whole while the rest round to the step', () => {
|
||||
const options = { metreStep: 10, exactBelowM: 100 };
|
||||
assert.equal(formatDistanceText(8, options), '8 m');
|
||||
assert.equal(formatDistanceText(99.4, options), '99 m');
|
||||
assert.equal(formatDistanceText(846, options), '850 m');
|
||||
assert.deepEqual(distanceParts(846, options), {
|
||||
value: 850,
|
||||
unit: 'm',
|
||||
digits: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('a negative or non-finite distance has no parts', () => {
|
||||
for (const value of [-1, Number.NaN, Infinity, -Infinity, null, undefined]) {
|
||||
assert.equal(distanceParts(value), null);
|
||||
assert.equal(formatDistanceText(value), null);
|
||||
}
|
||||
assert.equal(formatDistanceText(0), '0 m');
|
||||
});
|
||||
|
||||
test('kilometres come back rounded, so toFixed and Intl agree', () => {
|
||||
assert.deepEqual(distanceParts(1450), { value: 1.5, unit: 'km', digits: 1 });
|
||||
assert.equal(formatDistanceText(1450), '1.5 km');
|
||||
assert.equal(formatDistanceText(1150), '1.2 km');
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
FIRMS_OVERLAY_SOURCE_ID,
|
||||
} from './firmsLabels.js';
|
||||
import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js';
|
||||
import { getLocale, setLocale } from '../i18n/index.js';
|
||||
import { reportMeshFloorCell, setMeshFloorPreferred } from './groundFloor.js';
|
||||
|
||||
const NOW = Date.UTC(2026, 6, 17, 4, 0);
|
||||
@@ -100,6 +101,30 @@ test('buildCellCard: plural noun, max FRP and newest age, accent passthrough', (
|
||||
assert.equal(card.accent, accentForSeverity('orange'));
|
||||
});
|
||||
|
||||
test('buildCellCard: the count picks the plural form of the language', () => {
|
||||
const title = (count) =>
|
||||
buildCellCard(
|
||||
{
|
||||
cell: { count, maxFrp: 10, newestAcqMs: 0 },
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
},
|
||||
NOW,
|
||||
).title;
|
||||
const previous = getLocale();
|
||||
try {
|
||||
assert.equal(title(1), '1 FIRE');
|
||||
setLocale('ar');
|
||||
assert.deepEqual(
|
||||
[2, 3, 14].map((count) => title(count).replace(/[\u2068\u2069]/g, '')),
|
||||
['حريقان (2)', '3 حرائق', '14 حريقًا'],
|
||||
);
|
||||
setLocale('ru');
|
||||
assert.equal(title(14), '14 ПОЖАРОВ');
|
||||
} finally {
|
||||
setLocale(previous);
|
||||
}
|
||||
});
|
||||
|
||||
// Owner field finding 2026-07-21: anchors must sit on the DEM once the shared
|
||||
// ground floor is warm — and the cached per-fire position must re-anchor when
|
||||
// the floor lands AFTER the first (cold, height-0) render. Distinct coords
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import dns from 'node:dns/promises';
|
||||
import dnsLookup from 'node:dns';
|
||||
import https from 'node:https';
|
||||
import net from 'node:net';
|
||||
import { Readable } from 'node:stream';
|
||||
@@ -258,7 +259,8 @@ test('the IPv4 retry answering an invalid reason phrase rejects instead of crash
|
||||
|
||||
// The fixtures above copy Node's error shape; check it against a real fetch so
|
||||
// a Node or undici change that stops matching fails here instead of silently
|
||||
// disabling the retry. A closed port on `localhost` refuses both addresses.
|
||||
// disabling the retry. A closed port refuses both loopback addresses; resolve
|
||||
// `localhost` to both here, since some hosts map it to 127.0.0.1 only.
|
||||
test('a real all-addresses connect failure is recognised as address selection', async (t) => {
|
||||
const probe = net.createServer();
|
||||
try {
|
||||
@@ -272,7 +274,10 @@ test('a real all-addresses connect failure is recognised as address selection',
|
||||
const v4 = net.createServer();
|
||||
await new Promise((resolve, reject) => v4.once('error', reject).listen(port, '127.0.0.1', resolve));
|
||||
await new Promise((resolve) => v4.close(resolve));
|
||||
const loopback = [{ address: '::1', family: 6 }, { address: '127.0.0.1', family: 4 }];
|
||||
const lookup = t.mock.method(dnsLookup, 'lookup', (_host, _options, callback) => callback(null, loopback));
|
||||
const err = await fetch(`http://localhost:${port}/`).then(() => null, (error) => error);
|
||||
assert.equal(lookup.mock.callCount(), 1, 'fetch resolves localhost through dns.lookup');
|
||||
assert.ok(err, 'a closed port refuses the connection');
|
||||
assert.equal(isAddressSelectionFailure(err), true, describeFetchError(err));
|
||||
assert.match(describeFetchError(err), /ECONNREFUSED .*; .*ECONNREFUSED/);
|
||||
|
||||
@@ -41,3 +41,14 @@
|
||||
2026-09-25. Each record names its XYZ tile and expected traffic eligibility.
|
||||
Modified, test-only OpenStreetMap data; same ODbL attribution and database
|
||||
share-alike terms as the OpenFreeMap fixtures above.
|
||||
|
||||
- `ofm-outlines-austin-14-3743-{6745,6746}.pbf` — OpenFreeMap / OpenMapTiles
|
||||
z14 tiles from
|
||||
`https://tiles.openfreemap.org/planet/20260913_164504_pt/{z}/{x}/{y}.pbf`,
|
||||
retrieved 2026-09-28. Trimmed to the `transportation_name` features for
|
||||
Congress Avenue, South Congress Avenue and East 11th Street, and the
|
||||
`building` features within about 150 m of the Texas Capitol and the
|
||||
`landcover` park polygon around it, preserving
|
||||
original geometry and dictionaries. Used by the street and building outline
|
||||
tests. Same OpenFreeMap / OpenMapTiles / OpenStreetMap attribution and ODbL
|
||||
1.0 share-alike terms as the fixtures above. Test-only.
|
||||
Binary file not shown.
@@ -0,0 +1,175 @@
|
||||
¬
|
||||
transportation_name\œÝ”�——–———"> Ú €BŠ-3oxÇ:ÑNƒøçÀË‚£~—ˆ�ô©~§Ð‡b—èó:©
|
||||
ref_lengthsubclassrefclassnamename_int
|
||||
name:latinname_dename_enname:esroute_1_refroute_2_networkroute_1_networkroute_2_refnetworklayer"0"
|
||||
|
||||
junction"
|
||||
233"
|
||||
|
||||
motorway"
|
||||
232"0"
|
||||
234B"
|
||||
234A"
|
||||
South Interstate 35"
|
||||
secondary_construction"
|
||||
|
||||
The Circle"
|
||||
footway"
|
||||
path"$
|
||||
"Lady Bird Lake Hike and Bike Trail"
|
||||
|
||||
cycleway"
|
||||
Waller Creek Greenbelt Trail"&
|
||||
$Ann & Roy Butler Hike and Bike Trail"
|
||||
North Interstate 35"
|
||||
path_construction"
|
||||
Woodland Trail"
|
||||
Harper's Branch Connection"
|
||||
steps"
|
||||
|
||||
Snake Path"
|
||||
Sendero del Serpiente"
|
||||
Nellie Street Crossing"
|
||||
Woodland Avenue"
|
||||
Alameda Drive"
|
||||
minor"
|
||||
Forest Avenue"
|
||||
South 2nd Street"
|
||||
Post Road"
|
||||
Taylor Street"
|
||||
Pecan Grove Road"
|
||||
East Avenue"
|
||||
Fletcher Street"
|
||||
Lockhart Drive"
|
||||
Alta Vista Avenue"
|
||||
Retama Street"
|
||||
Haywood Avenue"
|
||||
Ramona Street"
|
||||
Ravine Drive"
|
||||
Betty Jo Drive"
|
||||
Spence Street"
|
||||
|
||||
Rebel Road"
|
||||
Bickler Road"
|
||||
Clifton Street"
|
||||
Columbus Street"
|
||||
Reagan Terrace"
|
||||
Daniel Drive"
|
||||
East Mary Street"
|
||||
Lindell Avenue"
|
||||
Drake Avenue"
|
||||
Mariposa Drive"
|
||||
Travis Heights Boulevard"
|
||||
Christopher Street"
|
||||
|
||||
Music Lane"
|
||||
Flores Street"
|
||||
Sunset Lane"
|
||||
Mission Ridge"
|
||||
Oak Heights Drive"
|
||||
Bonniview Street"
|
||||
Algarita Avenue"
|
||||
Edgecliff Terrace"
|
||||
Melissa Lane"
|
||||
|
||||
Sunny Lane"
|
||||
Le Grande Avenue"
|
||||
Avondale Road"
|
||||
Milam Place"
|
||||
Fairmount Avenue"
|
||||
River Street"
|
||||
Garden Street"
|
||||
Rosedale Terrace"
|
||||
Nash Hernandez Senior Road"
|
||||
West Monroe Street"
|
||||
Gillespie Place"
|
||||
Newning Avenue"
|
||||
West James Street"
|
||||
College Avenue"
|
||||
Sylvan Drive"
|
||||
Leland Street"
|
||||
Bonham Terrace"
|
||||
Sunny Vale Street"
|
||||
Bartlett Street"
|
||||
Ebony Street"
|
||||
Lupine Lane"
|
||||
Terrace Drive"
|
||||
Newton Street"
|
||||
Parkinson Drive"
|
||||
Inglewood Street"
|
||||
Manlove Street"
|
||||
Academy Drive"
|
||||
Clermont Avenue"
|
||||
Copeland Street"
|
||||
South 3rd Street"
|
||||
Crockett Street"
|
||||
East Gibson Street"
|
||||
West Gibson Street"
|
||||
Summit Street"
|
||||
Chelsea Lane"
|
||||
Willow Street"
|
||||
San Marcos Street"
|
||||
East Side Drive"
|
||||
Lone Oak Drive"
|
||||
East Elizabeth Street"
|
||||
West Elizabeth Street"
|
||||
Lambie Street"
|
||||
Wilson Street"
|
||||
|
||||
Eva Street"
|
||||
Roma Street"
|
||||
Waller Street"
|
||||
Elmhurst Drive"
|
||||
Kenwood Avenue"
|
||||
Brackenridge Street"
|
||||
Hillside Avenue"
|
||||
East Milton Street"
|
||||
West Milton Street"
|
||||
Fairlawn Lane"
|
||||
Euclid Avenue"
|
||||
Rutherford Place"
|
||||
Cummings Street"
|
||||
Nickerson Street"
|
||||
Nellie Street"
|
||||
Park Lane"
|
||||
Bellaire Drive"
|
||||
West Johanna Street"
|
||||
Post Oak Street"
|
||||
Rainey Street"
|
||||
Taylor Gaines Street"
|
||||
Harwood Place"
|
||||
Brooklyn Street"
|
||||
Jewell Street"
|
||||
LBJ Way"
|
||||
Bierce Street"
|
||||
Festival Beach Road"
|
||||
Art Dilly Drive"
|
||||
Haskell Street"
|
||||
Holly Street"
|
||||
|
||||
tertiary"
|
||||
West Mary Street"
|
||||
West Live Oak Street"
|
||||
East Live Oak Street"
|
||||
East Monroe Street"
|
||||
Bouldin Avenue"
|
||||
East Annie Street"
|
||||
West Annie Street"
|
||||
Dawson Road"
|
||||
secondary"
|
||||
South 1st Street"
|
||||
West Oltorf Street"
|
||||
East Riverside Drive"
|
||||
primary"
|
||||
South Congress Avenue"
|
||||
Barton Springs Road"
|
||||
35"
|
||||
Purple Heart Trail"
|
||||
US:US"
|
||||
US:I"
|
||||
290"
|
||||
us-interstate"0"
|
||||
Skyline Views"
|
||||
Blunn Creek Connection"
|
||||
Waterfront Promenade"
|
||||
Hartman Bridge"0(€ x
|
||||
@@ -65,7 +65,8 @@ const KML_REMOVED_ELEMENTS = new Set([
|
||||
/**
|
||||
* An error whose message is meant for the person who chose the file. The
|
||||
* message stays English; `template` and `values`, when given, let the UI show
|
||||
* it in the active language.
|
||||
* it in the active language. Numbers stay numbers in `values`: the UI formats
|
||||
* them in that language's numerals.
|
||||
*/
|
||||
export class GeoImportError extends Error {
|
||||
constructor(message, template = message, values = {}) {
|
||||
@@ -259,7 +260,7 @@ function overLimitError({ over, features }) {
|
||||
return new GeoImportError(
|
||||
`That file creates more than ${MAX_IMPORT_ENTITIES.toLocaleString()} map objects (the limit).`,
|
||||
msg('That file creates more than {limit} map objects (the limit).'),
|
||||
{ limit: MAX_IMPORT_ENTITIES.toLocaleString() },
|
||||
{ limit: MAX_IMPORT_ENTITIES },
|
||||
);
|
||||
if (over === 'links')
|
||||
return new GeoImportError(
|
||||
@@ -267,13 +268,13 @@ function overLimitError({ over, features }) {
|
||||
msg(
|
||||
'That file has more than {limit} style definitions and image links after cleanup (the limit).',
|
||||
),
|
||||
{ limit: MAX_KML_LINKS.toLocaleString() },
|
||||
{ limit: MAX_KML_LINKS },
|
||||
);
|
||||
if (over === 'features')
|
||||
return new GeoImportError(
|
||||
`That file has more than ${MAX_IMPORT_ENTITIES.toLocaleString()} features (the limit).`,
|
||||
msg('That file has more than {limit} features (the limit).'),
|
||||
{ limit: MAX_IMPORT_ENTITIES.toLocaleString() },
|
||||
{ limit: MAX_IMPORT_ENTITIES },
|
||||
);
|
||||
const limit = MAX_IMPORT_POSITIONS.toLocaleString();
|
||||
if (features)
|
||||
@@ -282,12 +283,12 @@ function overLimitError({ over, features }) {
|
||||
msg(
|
||||
'That file has more than {limit} points (the limit) across {features} features.',
|
||||
),
|
||||
{ limit, features: features.toLocaleString() },
|
||||
{ limit: MAX_IMPORT_POSITIONS, features },
|
||||
);
|
||||
return new GeoImportError(
|
||||
`That file has more than ${limit} points (the limit).`,
|
||||
msg('That file has more than {limit} points (the limit).'),
|
||||
{ limit },
|
||||
{ limit: MAX_IMPORT_POSITIONS },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1466,8 +1467,8 @@ export async function loadGeoFile(
|
||||
`That file has ${entityCount.toLocaleString()} map objects; the limit is ${MAX_IMPORT_ENTITIES.toLocaleString()}.`,
|
||||
msg('That file has {count} map objects; the limit is {limit}.'),
|
||||
{
|
||||
count: entityCount.toLocaleString(),
|
||||
limit: MAX_IMPORT_ENTITIES.toLocaleString(),
|
||||
count: entityCount,
|
||||
limit: MAX_IMPORT_ENTITIES,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Normalize a heading into the [0, 360) range; 0 when it is not finite. */
|
||||
export function normalizeHeading(value) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
// Only a negative remainder takes the extra turn, so a positive bearing
|
||||
// keeps its precision (0.49999999999999 must not become 360.5).
|
||||
const remainder = value % 360;
|
||||
const wrapped = remainder < 0 ? remainder + 360 : remainder;
|
||||
return wrapped === 360 ? 0 : wrapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole degrees for display, 0–359. 359.5° and above rounds to north (0),
|
||||
* and negative or multi-turn values wrap. Null when the value is not finite,
|
||||
* so each caller keeps its own placeholder.
|
||||
*/
|
||||
export function roundHeadingDegrees(value) {
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return Math.round(normalizeHeading(value)) % 360;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { normalizeHeading, roundHeadingDegrees } from './headingDegrees.js';
|
||||
|
||||
test('normalizeHeading wraps into [0, 360) and reads a non-finite value as 0', () => {
|
||||
assert.equal(normalizeHeading(370), 10);
|
||||
assert.equal(normalizeHeading(-10), 350);
|
||||
assert.equal(normalizeHeading(720), 0);
|
||||
assert.equal(normalizeHeading(Number.NaN), 0);
|
||||
assert.equal(normalizeHeading(Infinity), 0);
|
||||
});
|
||||
|
||||
test('roundHeadingDegrees reads 359.5° and above as north, never 360', () => {
|
||||
assert.equal(roundHeadingDegrees(359.4), 359);
|
||||
assert.equal(roundHeadingDegrees(359.5), 0);
|
||||
assert.equal(roundHeadingDegrees(359.6), 0);
|
||||
assert.equal(roundHeadingDegrees(360), 0);
|
||||
assert.equal(roundHeadingDegrees(0), 0);
|
||||
assert.equal(roundHeadingDegrees(194.2), 194);
|
||||
});
|
||||
|
||||
test('roundHeadingDegrees wraps negative and multi-turn bearings', () => {
|
||||
assert.equal(Object.is(roundHeadingDegrees(-0.4), 0), true);
|
||||
assert.equal(roundHeadingDegrees(-0.6), 359);
|
||||
assert.equal(roundHeadingDegrees(-1), 359);
|
||||
assert.equal(roundHeadingDegrees(719.6), 0);
|
||||
assert.equal(roundHeadingDegrees(-360.6), 359);
|
||||
});
|
||||
|
||||
test('roundHeadingDegrees returns null for a value that is not finite', () => {
|
||||
for (const value of [
|
||||
Number.NaN,
|
||||
Infinity,
|
||||
-Infinity,
|
||||
null,
|
||||
undefined,
|
||||
'12',
|
||||
]) {
|
||||
assert.equal(roundHeadingDegrees(value), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizeHeading keeps a positive bearing just under a half degree', () => {
|
||||
assert.equal(roundHeadingDegrees(0.49999999999999), 0);
|
||||
assert.equal(roundHeadingDegrees(359.49999999999994), 359);
|
||||
assert.equal(normalizeHeading(-1e-15) < 360, true);
|
||||
});
|
||||
@@ -17,6 +17,7 @@ export const LAYER_FEED_STATES = Object.freeze([
|
||||
'loading',
|
||||
'degraded',
|
||||
'stale',
|
||||
'partial',
|
||||
'fallback',
|
||||
'unavailable',
|
||||
]);
|
||||
@@ -27,6 +28,7 @@ export const LAYER_FEED_STATE_LABELS = Object.freeze({
|
||||
loading: 'LOADING',
|
||||
degraded: 'DEGRADED',
|
||||
stale: 'STALE',
|
||||
partial: 'PARTIAL',
|
||||
fallback: 'FALLBACK',
|
||||
unavailable: 'UNAVAILABLE',
|
||||
});
|
||||
@@ -48,8 +50,9 @@ export const FEED_STATE_SEVERITY = Object.freeze({
|
||||
degraded: 2,
|
||||
stale: 3,
|
||||
fallback: 4,
|
||||
nominal: 5,
|
||||
off: 6,
|
||||
partial: 5,
|
||||
nominal: 6,
|
||||
off: 7,
|
||||
});
|
||||
|
||||
export { layerFeedState } from './feedState.js';
|
||||
@@ -90,7 +93,7 @@ export function snapshotAgeLabel(lastUpdate, now = Date.now()) {
|
||||
/**
|
||||
* Pick the most severe feed-state from a list.
|
||||
* @param {Iterable<string|null|undefined>} states Feed-state strings.
|
||||
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'|'off'|null}
|
||||
* @returns {'nominal'|'loading'|'degraded'|'stale'|'partial'|'fallback'|'unavailable'|'off'|null}
|
||||
*/
|
||||
export function worstFeedState(states) {
|
||||
let worst = null;
|
||||
@@ -205,6 +208,8 @@ export function feedProvenanceNote(layers = [], overall = null) {
|
||||
return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`;
|
||||
});
|
||||
const body = bits.join('; ') || `${String(overall).toUpperCase()} feed`;
|
||||
if (overall === 'partial')
|
||||
return `${body}. Narrate that coverage is incomplete with any count; do not claim full coverage.`;
|
||||
return `${body}. Narrate that provenance with any count; do not describe this as live.`;
|
||||
}
|
||||
|
||||
@@ -251,3 +256,63 @@ export function viewStateLayerRow(layer, options) {
|
||||
lastUpdate: snap.lastUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The feed state and up to two layer names of a provenance envelope that is
|
||||
* not nominal, or null; the worst layers come first, so the names match the
|
||||
* state. Values stay English (`state` is a feed-state id, `names` are
|
||||
* `layer.name`); the HUD translates them for display.
|
||||
* @param {{ overall?: string|null, layers?: Array<object> }|null} envelope
|
||||
* @returns {{ state: string, names: string[] }|null}
|
||||
*/
|
||||
export function provenanceTag(envelope) {
|
||||
const overall = envelope?.overall;
|
||||
if (!overall || overall === 'nominal' || overall === 'off') return null;
|
||||
const names = (envelope.layers || [])
|
||||
.filter(
|
||||
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
FEED_STATE_SEVERITY[a.feedState] - FEED_STATE_SEVERITY[b.feedState],
|
||||
)
|
||||
.map((s) => String(s.name || s.id || 'LAYER'))
|
||||
.slice(0, 2);
|
||||
return { state: overall, names };
|
||||
}
|
||||
|
||||
const upperCase = (word) => word.toUpperCase();
|
||||
|
||||
/**
|
||||
* `STATE | text | LAYERS`: the state leads so a narrow readout never clips
|
||||
* it; the layer names trail so they never push out the text.
|
||||
* @param {string} text
|
||||
* @param {{ state: string, names: string[] }|null} tag From `provenanceTag`.
|
||||
* @param {(state: string) => string} [stateLabel]
|
||||
* @param {(name: string) => string} [layerLabel]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function provenanceLine(
|
||||
text,
|
||||
tag,
|
||||
stateLabel = upperCase,
|
||||
layerLabel = upperCase,
|
||||
) {
|
||||
if (!tag) return text;
|
||||
return [stateLabel(tag.state), text, tag.names.map(layerLabel).join('/')]
|
||||
.filter(Boolean)
|
||||
.join(' | ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a matching feedState for warning states: a view that is not
|
||||
* nominal falls back on a missing or different state.
|
||||
* @param {{ feedState?: string|null }|null} response The proxy's reply.
|
||||
* @param {{ overall?: string|null }|null} provenance The context's envelope.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hudSummaryMatchesProvenance(response, provenance) {
|
||||
const state = provenance?.overall;
|
||||
if (!state || state === 'nominal' || state === 'off') return true;
|
||||
return response?.feedState === state;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
layerFeedState,
|
||||
layerSnapshot,
|
||||
layerSnapshots,
|
||||
provenanceTag,
|
||||
snapshotAgeLabel,
|
||||
snapshotAgeSec,
|
||||
viewStateLayerRow,
|
||||
@@ -90,7 +91,15 @@ test('layerFeedState matches the chip classifier for every honesty class', () =>
|
||||
test('chip labels and severity cover every feed-state the envelope can emit', () => {
|
||||
assert.deepEqual(
|
||||
[...LAYER_FEED_STATES],
|
||||
['nominal', 'loading', 'degraded', 'stale', 'fallback', 'unavailable'],
|
||||
[
|
||||
'nominal',
|
||||
'loading',
|
||||
'degraded',
|
||||
'stale',
|
||||
'partial',
|
||||
'fallback',
|
||||
'unavailable',
|
||||
],
|
||||
);
|
||||
assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']);
|
||||
for (const state of LAYER_FEED_STATES) {
|
||||
@@ -107,6 +116,33 @@ test('chip labels and severity cover every feed-state the envelope can emit', ()
|
||||
assert.equal(worstFeedState([]), null);
|
||||
});
|
||||
|
||||
test('partial is a feed-state of its own: above nominal, below every fault', () => {
|
||||
const layer = (id, stats) => ({ id, name: id, enabled: true, stats });
|
||||
const partial = layer('flights', {
|
||||
partial: true,
|
||||
count: 4,
|
||||
lastUpdate: NOW,
|
||||
});
|
||||
const nominal = layer('vessels', { count: 9, lastUpdate: NOW });
|
||||
const stale = layer('cctv', { stale: true, count: 2, lastUpdate: NOW });
|
||||
const overall = (layers) =>
|
||||
feedProvenanceEnvelope(layers, { now: NOW }).overall;
|
||||
assert.equal(overall([partial]), 'partial');
|
||||
assert.equal(overall([partial, nominal]), 'partial');
|
||||
assert.equal(overall([nominal, partial]), 'partial');
|
||||
assert.equal(overall([partial, stale]), 'stale');
|
||||
assert.equal(overall([stale, partial]), 'stale');
|
||||
assert.equal(LAYER_FEED_STATE_LABELS.partial, 'PARTIAL');
|
||||
const note = feedProvenanceEnvelope([partial], { now: NOW }).note;
|
||||
assert.match(note, /coverage is incomplete/);
|
||||
assert.doesNotMatch(note, /do not describe this as live/);
|
||||
assert.equal(
|
||||
FEED_STATE_SEVERITY.fallback < FEED_STATE_SEVERITY.partial &&
|
||||
FEED_STATE_SEVERITY.partial < FEED_STATE_SEVERITY.nominal,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('a disabled layer is off even when leftover stats would read stale', () => {
|
||||
const snap = layerSnapshot(
|
||||
{
|
||||
@@ -258,3 +294,18 @@ test('age labels match the Data Layers meta clock', () => {
|
||||
assert.equal(snapshotAgeLabel(NOW - 240_000, NOW), '4m ago');
|
||||
assert.equal(snapshotAgeLabel(NOW - 7_200_000, NOW), '2h ago');
|
||||
});
|
||||
|
||||
test('the warning tag names the layers in the reported state first', () => {
|
||||
const tag = provenanceTag({
|
||||
overall: 'unavailable',
|
||||
layers: [
|
||||
{ name: 'Live Flights', feedState: 'partial' },
|
||||
{ name: 'Live AIS Vessels', feedState: 'partial' },
|
||||
{ name: 'CCTV', feedState: 'unavailable' },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(tag, {
|
||||
state: 'unavailable',
|
||||
names: ['CCTV', 'Live Flights'],
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { encodeParams, decodeParams } from '../layers/streetLevel/params.js';
|
||||
import { readShellSource } from '../testSupport/readShellSource.mjs';
|
||||
import { expandApplicationHtml } from '../../build/application-html.js';
|
||||
import { renderApplicationHtml } from '../testSupport/applicationHtml.mjs';
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -734,18 +734,27 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
|
||||
/this\.(?:flightState\.)?_models3dMode = 'proximity';/,
|
||||
'ui.js: and believes the mode is proximity',
|
||||
);
|
||||
const html = expandApplicationHtml(
|
||||
const html = await renderApplicationHtml(
|
||||
await readFile(new URL('../../index.html', import.meta.url), 'utf8'),
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
/class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true"/,
|
||||
/<button(?=[^>]*\sid="models3d-toggle")(?=[^>]*\sclass="(?=[^"]*\bpp-toggle-btn\b)(?=[^"]*\bactive\b))(?=[^>]*\saria-pressed="true")/,
|
||||
'index.html: the 3D button paints lit on first paint, before ui.js runs — and says so',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/this\._models3dBtn\?\.setAttribute\(\s*'aria-pressed',\s*String\(this\.(?:flightState\.)?_models3dEnabled\),?\s*\)/,
|
||||
'ui.js: and keeps aria-pressed synchronized, so the lit state is not colour-only',
|
||||
/this\._models3dPressedShown = this\._models3dEnabled;/,
|
||||
'ui.js: and keeps the pressed state synchronized, so the lit state is not colour-only',
|
||||
);
|
||||
const panel = await readFile(
|
||||
new URL('../ui/views/panels/DisplayPanel.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(
|
||||
panel,
|
||||
/:pressed="models3dOn" pressed-class="active" id="models3d-toggle"/,
|
||||
'DisplayPanel.vue: the view renders aria-pressed with the lit state',
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import {
|
||||
LOCAL_RECEIVERS_ROUTE,
|
||||
createLocalReceiversHandler,
|
||||
@@ -8,6 +9,10 @@ import {
|
||||
parseLocalReceiverFeeds,
|
||||
} from '../../server/providers/local-receivers.js';
|
||||
import { localProviderPlugins } from '../../server/providers/local.js';
|
||||
import { makeFixtureRoot } from '../tooling/fixtureRoot.mjs';
|
||||
|
||||
const nominatimStorageDir = await makeFixtureRoot('gev-receivers-composition-');
|
||||
test.after(() => rm(nominatimStorageDir, { recursive: true, force: true }));
|
||||
|
||||
const FIXTURE = readFileSync(
|
||||
new URL('./fixtures/adsb-austin-dump1090-aircraft.json', import.meta.url),
|
||||
@@ -118,7 +123,7 @@ test('construction mounts the route once and neither parses nor fetches', (t) =>
|
||||
t.mock.method(globalThis, 'fetch', () => {
|
||||
throw Error('construction must not fetch');
|
||||
});
|
||||
const plugins = localProviderPlugins();
|
||||
const plugins = localProviderPlugins({ nominatimStorageDir });
|
||||
assert.equal(
|
||||
plugins.filter((plugin) => plugin.name === 'local-receivers-proxy').length,
|
||||
1,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"AK","state":"Alaska","fips":"02","features":[{"geoid":"0200065","name":"Adak","full":"Adak city","lsad":"city","polygons":[[[-1766965,518536,115,-143,3,-288,815,0,533,339,-114,0,-127,-30,-39,-2,-35,-34,-50,-11,-6,-11,-45,-3,16,41,-38,-6,-28,11,5,56,19,6,4,14,31,24,-8,17,19,7,6,10,68,10,-65,38,-44,-15,8,-20,-25,-19,-25,3,-15,11,-24,-11,-32,0,-36,11,-96,3,-27,-11,-40,10,-49,0,-37,-11,-67,4,-16,-29,-59,-8,-48,9,-14,17,-8,19,10,42,123,22,23,13,94,8,18,19,22,44,-22,21,-8,65,12,20,0,51,76,163,23,15,157,49,40,4,37,-5,36,24,63,24,115,22,75,-7,31,-11,2,-41,16,-15,45,8,10,9,-23,16,9,29,14,14,52,24,-16,30,5,17,25,18,8,25,24,20,-32,22,-34,3,-96,71,-41,98,-10,0,-17,49,-70,21,-455,7,-56,44,-56,10,-78,-32,-176,-51,-96,1,-9,-21,24,-39,-7,-48,23,-17,37,2,61,-18,23,2,12,-20,35,-26,4,-23,28,-14,-1,-22,-22,-33,7,-12,26,-11,23,0,22,-19,-13,-14,-111,0,-468,-437]]]},{"geoid":"0200650","name":"Akhiok","full":"Akhiok city","lsad":"city","polygons":[[[-1542668,569259,517,0,528,72,0,114,39,18,-7,13,36,12,-11,22,-21,-1,-5,20,-19,-4,53,42,9,18,-25,25,16,10,-1111,0]]]},{"geoid":"0200760","name":"Akiachak","full":"Akiachak CDP","lsad":"CDP","cdp":true,"polygons":[[[-1614587,609108,106,-27,47,9,21,-11,45,0,45,-14,-58,1,-41,10,-48,-4,38,-30,36,-13,147,-22,67,11,80,48,61,16,64,-4,91,-37,107,-82,66,27,34,28,-16,17,-54,22,-68,8,-33,35,42,19,63,14,30,-10,18,-26,5,-29,17,-16,25,-3,13,16,-23,19,19,18,61,4,35,-6,53,12,-4,10,-54,36,-3,42,-121,158,-29,-1,-30,22,19,18,-24,-2,-18,10,3,37,-117,-10,-76,18,-22,-4,-17,-24,-27,-2,-38,11,-15,34,-49,6,-46,-6,-79,-34,53,-21,-9,-24,37,-17,-32,-23,20,-27,-34,-3,-40,16,-20,-17,-23,-1,-7,-15,25,-13,-27,-13,-41,-6,4,-23,-20,-27,-55,44,-57,19,-7,-22,-24,-17,-3,-58,-96,-14]]]},{"geoid":"0200870","name":"Akiak","full":"Akiak city","lsad":"city","polygons":[[[-1612344,608917,445,0,-1,289,-446,0]]]},{"geoid":"0201090","name":"Akutan","full":"Akutan city","lsad":"city","polygons":[[[-1659430,541813,247,0,0,-578,246,0,0,-145,731,0,0,-145,738,0,0,145,736,0,-5,19,15,18,128,53,24,19,-5,14,-43,14,-9,25,9,34,-102,26,-36,-25,-35,-4,-19,-23,-7,-26,-131,-19,-83,-42,-131,-7,-7,32,-17,18,-62,0,-33,-34,-5,-33,-73,-24,-169,45,-93,4,-81,25,-61,-1,-36,23,-64,7,-60,24,-52,0,-59,17,-171,-2,-19,26,7,30,38,21,78,13,195,-20,31,-18,45,1,38,18,57,-5,29,15,95,2,64,16,57,41,114,40,88,-5,26,6,-45,33,-43,-3,-16,14,-10,30,-90,85,-38,10,-45,-6,-105,43,-67,-5,-68,22,-100,57,-81,-22,-38,-20,-33,3,-27,21,0,24,29,32,45,22,41,4,21,14,2,15,-45,17,-59,-14,-81,-48,-202,-163,-45,-3,-233,51,-55,25,-17,21,5,28,-57,22,-59,38,-24,32,2,21,55,35,26,7,10,54,26,23,31,10,19,25,-12,31,5,25,-67,76,-159,39,-69,7,-57,-19,-67,-36,-109,-12,-81,9,-43,14,-7,65,-40,31,0,19,-22,17,-47,-17,-22,-31]],[[-1656791,542343,100,-53,316,4,114,49,19,19,2,21,38,18,24,29,38,18,140,-22,95,-35,57,-50,22,-40,0,-46,-34,-30,-42,-16,-41,-44,-78,-3,-188,-89,-71,-19,-36,-45,-45,-29,-10,-31,50,-27,62,-77,50,-42,74,-29,145,-11,26,-24,-2,-40,-17,-57,-33,-36,-81,-50,-36,-42,-93,-66,-45,-15,-69,10,-102,-21,-43,-22,86,-10,43,-13,43,-23,0,-24,23,-17,45,-8,41,-22,28,-63,48,-10,12,-48,40,-21,31,22,57,4,240,-78,112,-4,17,31,33,12,90,3,36,22,14,57,26,23,33,11,19,46,-19,21,-59,26,-50,8,-90,-1,-12,18,19,18,26,4,29,21,-29,16,-5,21,69,15,50,29,41,6,38,-7,90,-46,-1,663,-493,0,-1,289,-106,0,0,145,-494,0,0,-145,-489,0]]]},{"geoid":"0201200","name":"Alakanuk","full":"Alakanuk city","lsad":"city","polygons":[[[-1647500,627112,305,0,4,-722,1568,0,0,144,308,0,-3,433,315,0,-1,289,-1249,1,1,144,-1248,0]]]},{"geoid":"0201305","name":"Alatna","full":"Alatna CDP","lsad":"CDP","cdp":true,"polygons":[[[-1530015,665958,25,-62,23,-27,81,-60,52,-5,44,-17,21,-46,120,-63,7,-16,-17,-35,8,-10,1,-71,39,-23,-47,-33,0,-16,68,-18,86,0,56,-9,-1,-7,94,-14,91,-1,64,-15,79,8,30,-16,41,6,72,-8,68,10,51,-5,120,37,44,0,24,11,36,-4,22,9,106,-19,20,-19,18,-2,18,-39,38,-12,74,-7,16,-11,-25,-3,-25,-53,-58,-19,-17,-20,-72,-5,-40,-27,2,-16,-37,-6,27,-19,-43,-4,11,-25,-43,-17,20,-13,-42,-2,4,-17,-92,-88,210,-6,187,5,329,-33,53,2,68,21,16,32,45,15,14,34,-6,42,-15,37,-25,36,-14,7,10,33,62,37,118,54,57,45,35,12,34,-1,71,-15,-8,-11,74,-4,70,8,53,12,100,68,76,26,158,12,68,-19,-1,345,163,0,13,15,-97,98,-33,-8,-642,14,-2462,147,5,-35,-47,-30]]]},{"geoid":"0201390","name":"Alcan Border","full":"Alcan Border CDP","lsad":"CDP","cdp":true,"polygons":[[[-1413223,626355,17,-62,101,-91,134,-59,3,-37,-57,-55,72,-56,-24,-3,16,-19,-12,-17,28,-10,-36,-2,23,-8,18,-26,14,1,-17,-23,70,-32,4,18,21,-17,5,17,52,-22,19,10,14,-10,29,1,24,14,14,-9,-26,-13,25,0,-24,-14,24,-11,42,0,-42,-20,66,5,-1,-16,24,-5,3,12,68,-26,-28,-13,36,-22,-9,-8,-53,-1,-22,-18,51,8,33,-5,9,-22,-14,-12,57,-15,19,9,30,-2,-14,-11,58,0,31,-21,-1,25,17,1,3,-16,18,-10,46,4,17,-4,-23,-5,64,-19,-10,12,46,4,-1,-13,38,-9,8,16,-27,12,66,2,14,17,0,-22,20,-5,75,6,6,12,27,-20,-19,-7,19,-12,-31,6,-17,-11,19,4,29,-16,4,12,19,-15,69,-14,-23,-24,31,-1,-17,11,61,2,22,10,5,-14,55,-12,-13,-27,36,-6,-2,16,30,-17,-1,12,25,-4,1,-12,67,13,14,-20,33,-6,56,16,-4,11,-38,6,62,23,33,3,3,9,38,-3,39,24,130,-27,30,-14,37,Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"AL","state":"Alabama","fips":"01","features":[{"geoid":"0100100","name":"Abanda","full":"Abanda CDP","lsad":"CDP","cdp":true,"polygons":[[[-855420,331037,13,-55,47,-73,4,-26,72,-49,-9,-23,-40,-43,-9,-29,46,9,98,0,42,22,0,43,83,21,-1,29,-96,106,-35,19,-34,-12,25,44,47,5,30,17,-7,45,-271,-6,0,-27]]]},{"geoid":"0100124","name":"Abbeville","full":"Abbeville city","lsad":"city","polygons":[[[-852958,315472,246,0,0,31,86,0,31,-64,-7,-118,77,1,2,38,88,-6,4,68,134,-5,-4,-53,91,-5,2,26,27,-2,-2,-26,34,-3,3,23,31,-2,2,28,-22,1,-9,14,-32,3,16,281,-9,147,-81,4,-7,72,-71,3,-12,72,-38,0,10,-71,-506,12,2,-190,-45,-4,-2,-124,-38,1]]]},{"geoid":"0100460","name":"Adamsville","full":"Adamsville city","lsad":"city","polygons":[[[-870488,336355,26,-1,16,8,1,-19,44,0,1,-72,212,0,-3,12,12,24,21,-1,1,-36,-23,1,0,-38,-47,1,0,-36,17,-16,17,-59,-32,1,1,-37,46,-1,8,65,18,-29,19,0,0,9,93,23,1,-181,11,-11,16,0,6,11,40,0,0,-18,-16,0,-20,-27,-9,0,0,-18,-30,18,2,-27,87,-1,1,54,5,0,1,-23,36,12,1,-25,23,0,2,18,14,-4,-19,-28,23,-5,-1,-36,-32,0,-11,15,-22,-15,0,-36,-22,0,0,-37,36,-36,48,-1,-1,-62,7,-10,55,-1,20,-49,11,27,36,21,5,-13,19,0,8,-9,10,14,-2,-16,53,-23,0,-22,-20,-16,-18,-42,8,-6,118,-20,43,-60,18,-9,36,-1,10,19,2,28,15,7,32,-21,63,2,-6,-4,27,-20,18,0,34,17,-5,18,-25,8,-1,9,17,7,-26,1,0,7,25,-1,0,14,-40,4,-11,18,-4,66,27,-5,9,10,6,-14,38,-6,0,-158,24,-59,16,0,5,-85,-10,-19,12,6,9,-15,-16,-5,-1,-50,-13,-7,39,-18,36,14,-18,10,13,23,-13,19,1,175,97,-11,1,9,-22,0,0,9,32,0,1,27,-154,1,1,112,43,-1,0,37,-18,0,0,-21,-21,0,0,14,-19,-5,15,13,0,29,28,-4,4,10,-32,1,2,69,-38,-18,-20,21,55,37,4,-18,80,-5,-2,16,-77,15,0,11,17,9,-4,21,-23,-7,10,-5,-1,-36,-15,0,1,30,-12,13,0,-13,-17,-4,11,21,-42,15,-1,-24,32,-2,0,-9,-63,-6,-5,34,7,27,-28,15,4,51,-39,9,-4,64,-58,-3,-29,4,-16,10,-5,-8,-20,7,3,14,84,-9,-4,38,-87,2,-1,60,-14,-3,-15,10,-6,16,-5,-26,-8,7,-42,-1,-83,-19,44,-1,1,-21,-25,-1,-20,10,1,-29,11,0,0,-18,-45,0,0,18,-37,9,-3,-26,-13,0,0,35,43,0,0,35,-40,0,0,10,-33,27,-27,0,8,-8,-14,-15,-3,23,-108,4,0,37,85,-3,0,66,8,6,81,1,-2,207,11,11,12,0,22,71,-141,3,1,53,-86,22,-44,0,-4,22,-37,-34,-5,-19,3,62,-27,-36,-24,-13,-28,21,-47,64,-35,35,-19,11,-20,-3,-27,-22,-48,-90,8,-19,19,0,-7,-20,17,1,0,5,34,-5,3,20,41,-18,-52,-36,-100,-38,-24,-34,-19,-47,1,-55,-21,0,-40,-23],[-869779,335825,89,-1,-1,73,174,-2,-1,-36,-86,1,-3,-110,-174,2]],[[-869343,335929,44,-1,1,37,35,-1,8,35,13,0,1,-19,15,-1,0,-16,15,0,-1,-37,43,-1,0,-30,-54,4,10,-9,152,-3,-7,11,28,-1,1,79,43,-1,0,-38,25,0,0,14,17,-12,-1,75,-40,5,-1,47,22,-1,-22,17,6,21,-28,0,0,-37,-56,1,1,65,-52,9,0,37,-43,0,0,-18,-23,0,22,-5,-1,-50,43,-1,1,-36,-108,3,-22,14,0,-13,-45,0,1,-28,23,1,3,-14,-69,5]],[[-869707,336730,6,-82,8,-5,-12,-14,-21,-80,66,0,14,26,43,35,-21,50,42,20,-8,13,-55,2,4,-18,-35,-3,-20,41,0,42,36,0,-3,10,-33,6,0,20,-19,9,-12,26,8,13,-16,0]]]},{"geoid":"0100484","name":"Addison","full":"Addison town","lsad":"town","polygons":[[[-872034,342013,49,-1,0,-44,15,-15,14,-1,6,15,12,0,4,-28,-15,-10,-33,-2,6,-24,21,0,22,28,10,0,1,-35,-13,-25,11,-39,64,0,-9,-7,13,-26,7,23,-7,10,7,79,44,0,1,27,38,0,0,-76,14,14,-2,26,35,1,4,19,16,0,0,15,156,0,0,-33,45,0,0,30,10,-2,14,14,0,15,-24,9,-45,-8,-8,14,0,68,-121,-1,1,71,43,4,0,-12,24,0,0,37,26,20,0,-13,14,0,1,15,-12,0,32,23,26,-13,15,29,-32,14,-94,-73,0,42,-85,0,0,-36,-133,1,0,-32,-51,0,0,-41,-42,-1,0,-34,-79,7,-17,-8]]]},{"geoid":"0100676","name":"Akron","full":"Akron town","lsad":"town","polygons":[[[-877497,328726,160,-2,1,29,-107,134,-53,-58]]]},{"geoid":"0100820","name":"Alabaster","full":"Alabaster city","lsad":"city","d":5,"polygons":[[[-8687877,3323076,438,-6,-8,-594,434,-7,-1,-140,769,-20,-8,-144,-99,-323,-240,6,-1,-257,268,-1,39,-153,163,-219,-185,3,-4,-36,-523,5,36,-59,477,-9,-6,-30,-285,-60,-89,2,187,-155,114,-12,-1,-67,43,-146,-181,208,-493,8,1,374,-301,1,-133,-40,5,-1042,432,-20,13,-2548,431,-15,-8,366,492,-10,83,357,-321,7,-41,-268,-218,-14,-19,1032,489,-38,-37,-73,300,20,-16,100,-115,341,260,-2,-3,-378,434,-7,-2,-368,291,-6,4,379,133,-47,-9,-703,452,-10,2,-351,1141,-31,153,-117,-5,-247,434,-10,6,366,177,52,-34,1,35,60,220,69,-119,-121,241,101,-60,-177,1,-500,481,-5,-8,-62,282,-6,4,553,-328,8,-1,94,-320,0,-62,158,-11,80,1019,-77,-185,-273,41,-538,141,-270,115,-60,347,78,57,-161,131,-6,-282,-224,311,-5,1,245,243,134,-151,177,-46,-33,-126,-38,0,387,29,172,242,-10,-1,-143,208,-11,32,240,432,-22,9,-862,16,-3,2,129,422,-13,-4,178,438,-6,1,180,589,-12,-571,1125,757,-25,-2,47,43,-1,27,-642,167,-252,214,-92,81,3,-7,361,445,-16,0,381,59,-109,888,-46,-166,307,241,102,78,-119,663,-16,7,735,-895,17,-5,125,-537,24,-129,215,-145,126,-67,-163,-496,-59,65,133,-136,5,-84,362,237,48,-186,210,-250,521,536,-34,-3,333,-67,55,-263,-19,-1,75,-220,-11,1,-80,-309,-11,-9,-186,202,-229,-176,-160,-186,151,-419,9,8,130,-689,34,-623,769,462,-2,-1,194,-234,30,-66,-116,-176,-106,-126,205,7,250,287,-98,307,-12,2,184,68,46,-4,116,-65,-4,-4,390,1094,-22,25,336,-32,4,4,-337,-1094,21,3,-395,69,-107,-150,-44,-176,150,-48,-206,-34,262,-137,166,-257,425,90,123,632,-15,-1,723,425,3,-3,356,437,Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"AR","state":"Arkansas","fips":"05","features":[{"geoid":"0500190","name":"Acorn","full":"Acorn CDP","lsad":"CDP","cdp":true,"polygons":[[[-942258,346517,72,-69,24,-34,32,-78,-5,-80,-34,-42,37,-22,88,17,30,-2,25,-11,10,-28,15,-8,16,0,38,34,10,49,-9,13,0,35,44,12,25,19,15,45,-16,29,-17,3,-18,28,-4,29,18,4,1,47,-93,2,-18,39,-107,1,-1,-66,-15,5,-38,53,-36,14,-62,-2,-61,-19]]]},{"geoid":"0500250","name":"Adona","full":"Adona city","lsad":"city","polygons":[[[-929103,350346,221,-3,3,109,-222,4]]]},{"geoid":"0500580","name":"Alexander","full":"Alexander city","lsad":"city","polygons":[[[-924723,346094,46,-2,0,-37,277,-2,-4,34,7,16,-19,18,-13,-5,12,30,-21,0,3,28,44,22,1,36,-35,1,-7,15,45,2,6,19,-49,0,50,44,-47,38,-22,-15,2,44,-8,15,9,9,3,61,-52,-8,-2,-31,8,0,8,-47,-27,0,-27,-11,0,-9,43,-1,-1,-52,32,-12,-21,-23,-191,-143,-49,1]]]},{"geoid":"0500700","name":"Alicia","full":"Alicia town","lsad":"town","polygons":[[[-910886,358875,100,-5,2,110,-56,3,30,36,-10,6,4,-8,-28,-33,-39,2]]]},{"geoid":"0500730","name":"Alix","full":"Alix CDP","lsad":"CDP","cdp":true,"polygons":[[[-937366,354212,40,-1,5,-38,12,1,-1,-33,45,-2,0,34,45,0,-24,54,-17,10,1,18,43,23,-132,7,-1,-20,21,0,-1,-30,-34,-7]]]},{"geoid":"0500820","name":"Alleene","full":"Alleene CDP","lsad":"CDP","cdp":true,"polygons":[[[-942841,337672,290,-175,24,37,4,39,14,12,-10,21,41,19,16,32,77,27,-313,76,-18,-6,-18,-25,-24,-11,-19,4,-5,-21,-39,-12]]]},{"geoid":"0500940","name":"Allport","full":"Allport town","lsad":"town","polygons":[[[-917973,345415,110,-1,4,-39,17,7,-1,-58,-17,1,17,-6,-1,-46,-20,1,0,-10,23,0,2,119,45,0,1,51,10,8,-48,1,-6,19,-1,-33,-26,-3,0,-8,-104,2,-5,6,0,-6]]]},{"geoid":"0500970","name":"Alma","full":"Alma city","lsad":"city","polygons":[[[-942543,354939,9,-12,134,-2,-14,-6,-1,-12,24,-15,-1,-77,40,-1,8,-36,18,-12,24,6,-2,-67,134,-3,-1,-36,24,0,0,-14,-24,0,0,-23,45,0,1,54,-30,1,-11,18,86,-1,2,72,-27,1,5,22,-22,-1,1,20,135,9,24,57,66,-2,4,36,82,6,91,35,65,12,1,44,-49,76,-110,-2,0,-8,42,-2,0,-58,-67,1,-19,-25,37,-17,47,-8,-49,-16,9,-30,-71,-7,-155,15,0,21,-150,-23,-15,-16,-17,6,2,18,37,9,0,17,20,12,2,44,17,32,-22,17,1,17,26,16,15,1,9,21,47,-8,16,11,-31,15,17,21,-19,45,-13,-5,0,-23,-32,-17,-58,-51,-15,-18,-8,-28,-57,52,8,-31,13,-5,0,-9,-12,-6,-44,2,0,18,-33,0,-11,18,-2,-72,-40,1,-14,-26,-28,1,-7,19,6,32,-12,12,-32,0,2,-35,20,-1,0,-24,17,5,-2,-8,-39,1,-1,8,-20,1,0,18,-22,1,-2,-46,45,-1,-1,-28,-66,2]]]},{"geoid":"0501030","name":"Almyra","full":"Almyra town","lsad":"town","polygons":[[[-914176,344020,133,0,2,74,-133,0]]]},{"geoid":"0501060","name":"Alpena","full":"Alpena town","lsad":"town","polygons":[[[-933157,362819,84,0,6,-31,43,-1,1,31,44,0,2,74,92,0,2,80,-270,-5]]]},{"geoid":"0501090","name":"Alpine","full":"Alpine CDP","lsad":"CDP","cdp":true,"polygons":[[[-933999,342255,19,-8,17,2,35,-29,142,10,7,-52,115,27,41,79,3,24,-77,-7,1,70,-21,-19,-145,-26,-36,-15,-9,4,-14,-33,-23,-4,-9,-9,-18,5,-31,-10]]]},{"geoid":"0501150","name":"Altheimer","full":"Altheimer city","lsad":"city","polygons":[[[-918728,343168,161,-5,0,-26,155,-11,2,71,76,-3,3,118,-44,1,1,72,-14,0,23,41,-310,-229,-53,-18]]]},{"geoid":"0501210","name":"Altus","full":"Altus city","lsad":"city","polygons":[[[-937892,354759,58,-12,-1,-12,31,-15,-13,-18,15,-6,37,-72,-18,0,-1,-24,31,-1,-1,-11,-30,1,1,-11,41,-31,3,22,40,-62,-2,-50,-28,-16,-17,1,-1,-37,44,-1,-2,-72,223,-7,2,73,-44,1,4,109,-134,4,2,37,-37,1,-1,-28,-11,3,-22,44,27,-1,1,18,-41,1,-37,74,35,-1,1,19,-44,1,3,54,43,-1,2,36,45,-2,2,37,-90,3,-1,-37,-23,1,-1,-33,-12,1,9,18,-18,15,2,36,-12,1,-2,-53,-58,18]]]},{"geoid":"0501270","name":"Amagon","full":"Amagon town","lsad":"town","polygons":[[[-911119,355604,30,-3,8,12,18,2,1,41,-60,0]]]},{"geoid":"0501360","name":"Amity","full":"Amity city","lsad":"city","polygons":[[[-934804,342563,11,24,34,0,-1,-17,-18,-37,304,-5,0,32,-22,6,1,35,17,-1,26,-22,2,58,-44,1,1,36,-44,1,1,36,44,0,0,18,44,-1,1,55,-44,1,1,26,-44,1,-1,-27,-263,4,-4,-135]]]},{"geoid":"0501457","name":"Anthonyville","full":"Anthonyville town","lsad":"town","polygons":[[[-903425,350372,21,-1,0,12,25,0,1,36,-47,1]]]},{"geoid":"0501540","name":"Antoine","full":"Antoine town","lsad":"town","polygons":[[[-934283,340278,88,0,-1,61,61,7,-26,34,-30,20,-88,2]]]},{"geoid":"0501630","name":"Aplin","full":"Aplin CDP","lsad":"CDP","cdp":true,"polygons":[[[-930023,349657,38,-10,30,1,32,-30,38,-13,19,-31,43,1,14,8,8,26,14,2,-1,-25,16,-16,34,-7,-1,35,23,7,18,-6,25,-27,10,0,35,31,-26,14,7,33,30,55,-10,26,-79,9,-15,31,-34,-11,-1,15,-19,0,-35,-26,-211,1]]]},{"geoid":"0501660","name":"Appleton","full":"Appleton CDP","lsad":"CDP","cdp":true,"polygons":[[[-929068,354342,11,-23,32,-14,28,-32,26,-10,22,-52,33,-39,8,10,34,-23,2,-20,44,-21,41,-62,40,-35,113,-57,114,-24,6,336,-44,-8,-64,6,17,31,-9,21,11,19,-397,5,-59,10]]]},{"geoid":"0501870","name":"Arkadelphia","full":"Arkadelphia city","lsad":"city","polygons":[[[-931112,341076,49,-1,38,36,44,-2,0,22,17,0,5,14,18,-1,-7,-13,10,-9,-16,0,-28,-48,42,-2,1,36,64,-2,1,36,22,0,-1,-36,42,-1,0,-37,45,-1,1,18,88,-3,Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"AS","state":"American Samoa","fips":"60","features":[{"geoid":"6000100","name":"Aasu","full":"Aasu village","lsad":"village","polygons":[[[-1707739,-143135,28,-8,32,1,42,-13,32,7,-8,-11,14,-21,10,17,62,4,31,15,28,-47,26,31,3,48,40,69,7,43,12,14,59,10,-101,68,-12,-9,-30,-4,-21,9,-27,-24,-11,-24,-11,-1,-19,46,-14,4,-31,-11,-16,9,10,28,-10,11,-18,-4,-10,-20,-26,8,-10,-4,-57,-138,2,-86]]]},{"geoid":"6002500","name":"Afao","full":"Afao village","lsad":"village","polygons":[[[-1708043,-143324,28,5,21,-11,0,-9,27,178,-5,11,-68,2,-9,-21]]]},{"geoid":"6003300","name":"Afono","full":"Afono village","lsad":"village","polygons":[[[-1706592,-142641,29,-13,24,-60,78,15,13,79,63,77,-16,71,-6,-2,4,-42,-10,-13,-17,-1,0,25,-24,13,-5,-20,6,-20,-8,-22,-17,-5,-12,9,-12,-11,-3,-25,-19,-4,-14,12,-1,16,7,48,12,20,-8,8,-42,-93,-34,-33]]]},{"geoid":"6004500","name":"Agugulu","full":"Agugulu village","lsad":"village","polygons":[[[-1708215,-143310,20,-2,12,-12,10,4,-34,131,-13,-34]]]},{"geoid":"6004900","name":"Alao","full":"Alao village","lsad":"village","polygons":[[[-1705782,-142619,110,-61,12,-27,15,9,-5,12,30,88,2,17,-10,11,-15,-9,-22,5,-68,-7,-28,-21]]]},{"geoid":"6006500","name":"Alega","full":"Alega village","lsad":"village","polygons":[[[-1706461,-142699,10,-36,49,-130,11,19,0,33,9,11,41,-8,-66,109,-46,3]]]},{"geoid":"6007300","name":"Alofau","full":"Alofau village","lsad":"village","polygons":[[[-1706049,-142725,13,-48,-36,-31,49,-37,35,23,24,6,-1,105,-37,26,-71,-38]]]},{"geoid":"6008100","name":"Amaluia","full":"Amaluia village","lsad":"village","polygons":[[[-1707946,-143337,10,-14,6,12,13,0,14,-7,-1,-13,10,-10,62,179,27,39,-33,9,-37,31,-19,6,-8,-2,2,-42,-11,-29,9,-24,-4,-20,-21,-14,-17,-78]]]},{"geoid":"6008900","name":"Amanave","full":"Amanave village","lsad":"village","polygons":[[[-1708462,-143233,46,-11,23,7,33,-23,36,13,23,-20,8,-31,27,33,-13,6,21,54,-24,6,-119,-22,-19,10,-49,-13]]]},{"geoid":"6009700","name":"Amaua","full":"Amaua village","lsad":"village","polygons":[[[-1706316,-142664,31,-46,44,-33,1,11,30,20,-13,50,-21,39,-49,-32]]]},{"geoid":"6010500","name":"Amouli","full":"Amouli village","lsad":"village","polygons":[[[-1705964,-142812,8,10,45,5,13,21,30,10,-5,11,24,16,23,6,16,-3,1,-11,23,-1,20,8,-27,28,-30,61,-116,-69,-26,13,-5,-12]]]},{"geoid":"6011300","name":"Anua","full":"Anua village","lsad":"village","polygons":[[[-1706893,-142718,35,3,-24,61,-16,6]]]},{"geoid":"6012100","name":"Aoa","full":"Aoa village","lsad":"village","polygons":[[[-1705939,-142720,116,69,22,-1,19,33,21,17,-103,60,2,-29,14,-30,-26,-16,-31,-3,-11,43,-47,-131]]]},{"geoid":"6012800","name":"Aoloau","full":"Aoloau village","lsad":"village","polygons":[[[-1707946,-142928,29,-44,15,-135,27,-4,37,-31,145,-44,80,-11,0,38,8,11,-32,-7,-102,20,6,17,-2,86,57,138,-62,-37,-13,21,-27,-10,-6,-23,-21,-8,-22,77,-21,-2,-6,-25,-42,-3,-7,6,-12,-8,-29,-2]]]},{"geoid":"6013700","name":"Asili","full":"Asili village","lsad":"village","polygons":[[[-1707975,-143316,25,1,4,-22,19,101,21,14,4,20,-9,24,11,29,-2,42,-37,-9,-33,-34,5,-11,-27,-178]]]},{"geoid":"6014500","name":"Atu'u","full":"Atu'u village","lsad":"village","polygons":[[[-1706876,-142674,20,-37,14,14,-16,46,-19,-1]]]},{"geoid":"6015300","name":"Aua","full":"Aua village","lsad":"village","polygons":[[[-1706671,-142699,19,-10,10,-26,-14,-27,10,-26,-5,-14,4,-39,14,-37,43,56,51,108,-24,60,-58,18,-78,-5,-18,-54]]]},{"geoid":"6016100","name":"Auasi","full":"Auasi village","lsad":"village","polygons":[[[-1705823,-142651,30,-61,27,-28,2,11,30,18,9,-2,-5,22,-52,72,-19,-33]]]},{"geoid":"6016900","name":"Auma","full":"Auma village","lsad":"village","polygons":[[[-1707860,-143368,33,37,-15,41,-12,-6,-7,15,-32,-86]]]},{"geoid":"6017700","name":"Aumi","full":"Aumi village","lsad":"village","polygons":[[[-1706544,-142729,12,-40,31,-57,-12,-52,4,-17,40,21,17,-8,22,18,28,-1,-59,166,-78,-15]]]},{"geoid":"6018500","name":"Aunu'u","full":"Aunu'u village","lsad":"village","polygons":[[[-1705617,-142857,30,-24,26,-5,16,11,39,-17,24,9,23,26,2,25,-33,36,-16,-3,-29,16,-50,-14,-17,-13,-8,-28]]]},{"geoid":"6020100","name":"Auto","full":"Auto village","lsad":"village","polygons":[[[-1706324,-142776,41,-21,8,33,34,21,-44,33,-31,46,-38,-14]]]},{"geoid":"6020900","name":"Avaio","full":"Avaio village","lsad":"village","polygons":[[[-1706341,-142810,21,-3,37,16,-41,21,-30,98,-53,-23]]]},{"geoid":"6022500","name":"Faga'alu","full":"Faga'alu village","lsad":"village","polygons":[[[-1706952,-142918,44,-25,42,2,49,-19,46,10,-3,8,-54,28,18,25,16,1,38,31,-119,-17,-20,26,-32,-25,-14,-40]]]},{"geoid":"6023300","name":"Faga'itua","full":"Faga'itua village","lsad":"village","polygons":[[[-1706199,-142663,20,-45,32,27,33,-4,4,-17,8,14,69,41,-22,0,-41,25,-56,-6,-52,6]]]},{"geoid":"6024900","name":"Fagali'i","full":"Fagali'i village","lsad":"village","polygons":[[[-1708274,-143171,16,-34,17,1,62,32,-58,147,-34,-51,-22,-5,-30,-28]]]},{"geoid":"6025700","name":"Fagamalo","full":"Fagamalo village","lsad":"village","polygons":[[[-1708122,-1Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"AZ","state":"Arizona","fips":"04","features":[{"geoid":"0400730","name":"Aguila","full":"Aguila CDP","lsad":"CDP","cdp":true,"polygons":[[[-1131855,339431,59,-3,31,7,-39,-19,1,-59,38,0,0,-73,217,3,1,143,-43,0,-1,42,-84,-18,-178,0]]]},{"geoid":"0400870","name":"Ajo","full":"Ajo CDP","lsad":"CDP","cdp":true,"polygons":[[[-1129509,323966,33,-131,4,-72,19,-21,-4,-11,11,-24,30,-20,41,-46,29,-13,40,14,37,-35,29,-55,19,-12,65,0,41,-22,22,-1,21,-22,19,-7,36,-7,17,-12,68,-10,40,-21,60,-1,65,-16,53,6,23,-17,29,-5,55,10,111,-22,38,41,22,10,84,11,40,26,16,38,-7,61,-99,89,-122,24,-32,27,-39,0,30,113,74,126,86,307,2,24,-18,60,2,25,31,104,-37,45,-69,7,-4,54,-94,67,-153,2,19,-68,-1,-186,16,1,-1,-71,-303,-3,-28,-42,-10,-32,8,-25,-11,-51,-44,-49,-43,-29,-37,-46,-22,-4,-6,-17,-89,-28,-117,29,-56,-22,-18,-28,-12,-2]]]},{"geoid":"0400940","name":"Ak Chin","full":"Ak Chin CDP","lsad":"CDP","cdp":true,"polygons":[[[-1120184,322839,19,-9,53,9,18,-38,43,16,17,19,11,49,-4,12,-33,7,10,41,-64,9,2,-37,11,-19,-30,1,-18,-33,-19,6]]]},{"geoid":"0401090","name":"Ak-Chin Village","full":"Ak-Chin Village CDP","lsad":"CDP","cdp":true,"polygons":[[[-1121173,330003,692,-2,2,363,-86,0,0,35,-87,-1,0,71,-37,-12,15,-13,-6,-9,-244,-2,-32,-53,-12,-4,-19,-22,-29,-4,-4,-20,15,-40,-166,2,-1,-17]]]},{"geoid":"0401170","name":"Alamo Lake","full":"Alamo Lake CDP","lsad":"CDP","cdp":true,"polygons":[[[-1136127,342297,10,-11,48,-15,16,-18,47,4,30,-15,11,7,13,32,-21,9,8,28,37,9,5,-12,50,-22,40,1,-5,-9,8,-8,97,-5,410,-167,-2,-20,12,3,16,22,-7,25,-14,14,-7,38,18,28,212,-186,54,-12,71,14,60,-61,12,78,33,60,-3,75,14,37,-8,35,44,-73,107,-52,70,-10,-76,249,307,-145,-4,31,-51,122,-22,34,-1,17,-10,14,-67,33,45,23,-2,21,-11,12,7,20,12,6,46,-11,35,21,-19,40,49,53,7,50,-7,42,21,28,53,24,32,-3,26,18,39,-10,43,8,10,13,1,21,-30,22,-5,20,2,33,23,14,-24,39,16,8,-68,18,-40,-2,-27,12,-134,-22,-58,7,-135,39,-15,0,-32,-20,-19,1,-12,24,-24,10,-51,-22,-87,20,-47,-23,-86,0,-49,19,-25,-4,-62,60,-29,2,-8,-16,-40,-1,-25,17,-41,-5,-84,-36,-75,-91,1,-9,-69,-108,-16,-3,-63,-52,-43,-45,-27,-10,-4,-47,-7,-10,-37,-15,-27,-32,-59,-45,-13,-19,-34,-145,-105,-62,-22,-39,-56,-38,-11,-2,-13,24,-48,-7]]]},{"geoid":"0401560","name":"Ali Chuk","full":"Ali Chuk CDP","lsad":"CDP","cdp":true,"polygons":[[[-1125721,318126,139,-30,-25,-21,45,-10,15,22,36,-7,15,10,18,28,1,43,22,18,-1,40,-23,30,-45,12,-6,-15,-38,-33,3,24,-136,12]]]},{"geoid":"0401570","name":"Ali Chukson","full":"Ali Chukson CDP","lsad":"CDP","cdp":true,"polygons":[[[-1118201,319030,52,-35,94,-9,15,12,6,32,34,31,21,-3,19,-17,60,22,-18,60,56,-5,7,38,17,23,-75,85,-19,4,-8,-23,-32,-5,-42,-20,-30,-67,-12,-11,-64,-8,-45,11,-18,-12,12,-64,-22,-20]]]},{"geoid":"0401620","name":"Ali Molina","full":"Ali Molina CDP","lsad":"CDP","cdp":true,"polygons":[[[-1117875,318989,-19,-13,9,-25,69,18,86,11,-6,-14,48,15,-19,10,-22,44,-27,30,12,54,-43,-9,13,-23,-46,3,-48,-20,-26,-23]]]},{"geoid":"0401920","name":"Alpine","full":"Alpine CDP","lsad":"CDP","cdp":true,"polygons":[[[-1091539,338431,35,11,0,-27,18,12,41,5,50,-5,41,-22,29,-4,16,23,-61,31,16,21,-13,2,-6,26,12,27,-21,-3,-19,-28,-33,8,-17,18,-22,-18,-19,7,-1,-44,-12,16,0,-23,-35,-6]]]},{"geoid":"0401990","name":"Amado","full":"Amado CDP","lsad":"CDP","cdp":true,"polygons":[[[-1110769,316988,9,-138,24,6,25,24,65,-3,16,-169,36,-180,82,-2,-24,81,25,74,-16,138,-65,45,-6,22,25,20,2,38,-11,24,71,-2,56,-35,13,5,-1,181,43,-1,-30,65,10,3,9,24,-9,15,32,39,-192,0,-36,-71,-26,-77,-15,9,-14,-6,4,-10,-53,-35,-72,-14]]]},{"geoid":"0402270","name":"Anegam","full":"Anegam CDP","lsad":"CDP","cdp":true,"polygons":[[[-1120524,323637,329,1,-4,98,-47,7,-8,46,-22,-8,18,143,-37,-16,-20,-29,-19,-11,-112,-34]]]},{"geoid":"0402410","name":"Antares","full":"Antares CDP","lsad":"CDP","cdp":true,"polygons":[[[-1138169,354165,84,21,87,-2,4,97,-94,1,0,-10,-82,0]]]},{"geoid":"0402430","name":"Anthem","full":"Anthem CDP","lsad":"CDP","cdp":true,"polygons":[[[-1121434,338483,-13,-147,8,-62,89,1,0,147,522,5,1,254,-43,0,0,36,-478,-5,0,72,-120,0]]]},{"geoid":"0402830","name":"Apache Junction","full":"Apache Junction city","lsad":"city","polygons":[[[-1115849,334164,43,5,-1,-292,17,0,0,-12,24,-22,3,-55,-80,0,1,-63,164,1,0,-36,-44,0,0,-36,-119,0,7,-582,27,-2,0,-69,583,0,-17,33,-19,101,-44,140,-153,234,688,0,-3,316,-126,0,0,36,-43,0,0,218,-86,0,-1,217,86,0,-1,218,-28,0,-14,-10,-1,-63,-86,0,0,73,-734,-1,-1,-300,-22,0,0,-26,-20,0]]]},{"geoid":"0403320","name":"Arivaca","full":"Arivaca CDP","lsad":"CDP","cdp":true,"polygons":[[[-1113553,315889,38,-28,-5,-10,102,-75,-36,-7,-19,-17,4,-22,18,-17,12,-46,-1,-57,32,-3,8,-19,-3,-46,55,-72,21,-125,16,-13,8,-39,16,-1,31,25,52,14,24,23,57,8,35,-4,-4,24,46,-6,41,6,39,-32,6,-18,17,-4,48,-68,22,-6,36,20,54,1,59,19,-8,16,17,14,16,5,11,-12,-52,135,-30,54,-18,4,4,22,-10,12,-21,0,-22,34,0,75,49,-20,76,10,51,-17,79,18,105,-23,41,0,13,-8,95,-5,10,72,-9,21,0,171,-75,0,-2,13,30,17,-11,31,33,25,-10,9,23,57,-16,75,-42,-12,-18,10,-25,-3,-31,16,-690,-23,-91,-69,-6,-16,-24,-15,-4,5,-35,-25,-197,-24,-33,-23]]]},{"geoiLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"CA","state":"California","fips":"06","features":[{"geoid":"0600135","name":"Acalanes Ridge","full":"Acalanes Ridge CDP","lsad":"CDP","cdp":true,"polygons":[[[-1220847,379081,41,-17,-1,-32,78,1,-4,5,19,4,-18,28,36,23,-51,0,-21,21,-30,-10,-68,2]]]},{"geoid":"0600156","name":"Acampo","full":"Acampo CDP","lsad":"CDP","cdp":true,"polygons":[[[-1212882,381773,1,-26,-13,0,2,-72,63,-1,0,-25,13,-5,77,1,-21,58,50,0,-3,117,-91,0,7,-20,-13,-7,-86,0]]]},{"geoid":"0600212","name":"Acton","full":"Acton CDP","lsad":"CDP","cdp":true,"polygons":[[[-1182577,344818,117,-1,10,-36,-3,-108,83,1,0,-40,174,-7,-1,30,117,4,2,-32,12,0,3,-68,216,-4,0,-20,356,-17,6,141,298,2,2,141,8,2,210,2,0,-73,170,0,0,219,-411,-2,-19,20,-7,31,-81,76,-14,0,-214,199,-87,-2,1,-39,-133,1,2,76,-92,-2,0,36,-43,2,1,36,-176,4,0,37,-132,1,0,-37,-353,2,-6,-6,6,-17,0,-411,-44,0]]]},{"geoid":"0600296","name":"Adelanto","full":"Adelanto city","lsad":"city","polygons":[[[-1175068,345575,31,0,1,-287,393,-2,2,72,559,0,0,-73,-265,0,0,-110,-252,1,1,35,-155,3,0,-37,-110,2,0,-109,868,-3,1,364,352,-1,1,142,-100,2,-1,148,-243,-2,0,73,-22,0,-1,219,109,0,0,146,-18,0,-1,296,327,-2,-11,24,4,19,-17,103,-393,1,0,-368,-176,-1,-177,3,-2,370,-529,1,-6,-293,89,0,0,-147,89,0,5,-293,-136,-2,-1,-73,-222,-1]]]},{"geoid":"0600310","name":"Adin","full":"Adin CDP","lsad":"CDP","cdp":true,"polygons":[[[-1209714,411843,335,-1,4,63,58,-1,38,14,22,18,17,33,35,28,-41,2,-83,64,-4,12,-13,-7,-38,9,8,-41,-33,-30,-24,-10,-17,0,-123,52,-142,0]]]},{"geoid":"0600394","name":"Agoura Hills","full":"Agoura Hills city","lsad":"city","polygons":[[[-1188004,341394,130,-31,176,-17,132,62,182,-80,16,-16,20,14,63,0,0,-11,46,1,-2,-63,43,0,0,37,-13,24,13,7,0,42,-43,-1,0,19,-51,3,-79,46,0,114,44,0,40,135,-602,3,-39,-222]]]},{"geoid":"0600450","name":"Agua Dulce","full":"Agua Dulce CDP","lsad":"CDP","cdp":true,"polygons":[[[-1183432,344674,-1,-63,27,38,32,13,74,8,-10,31,21,10,26,-2,-1,143,17,35,162,-1,-2,-72,197,2,20,26,9,55,31,26,122,47,102,15,22,9,28,24,1,352,-6,17,8,44,-9,7,-18,-5,-79,16,-14,-4,-30,-34,2,-12,-50,-55,-42,-6,-20,26,1,10,-37,3,-47,-15,1,16,-24,-10,-10,3,-32,-20,-4,10,-57,-24,-28,17,-56,12,-43,-39,-22,-10,-10,-16,-55,-26,-15,-20,-243,-3,-63,-16,-28,21,-32,-32,12,-29,-12,-17,2,-213,-111,-4,-32,-16,-15,-21,-7,-35,5,-78,-14,-136]]]},{"geoid":"0600464","name":"Aguanga","full":"Aguanga CDP","lsad":"CDP","cdp":true,"polygons":[[[-1169035,334466,13,7,9,-15,25,-5,5,-17,36,10,44,-26,117,-15,0,-28,-47,2,-9,-23,-32,-7,-6,-37,9,-19,30,-11,3,-13,620,0,13,15,20,6,6,41,-37,12,-4,12,8,27,-19,0,-20,13,18,13,0,171,-19,4,0,40,-37,16,-3,21,24,3,15,-11,30,28,14,-4,6,12,15,4,-7,9,36,22,-10,38,-15,7,-22,41,-18,-17,-10,-34,-21,-11,-17,6,-13,17,-10,-7,-52,7,3,63,-7,11,-83,-19,-53,-40,-56,0,-45,-18,-1,19,-18,18,-17,-2,-37,-32,-20,8,-19,-13,-15,8,-5,-8,-29,-1,12,-13,-16,-19,14,-49,-3,-20,39,-1,37,-24,-58,-6,-19,-63,-18,52,-73,79,-99,2,5,-138,-53,-13,16,-34,-1,-16,-15,-9,-24,1,-19,16,-22,1,12,-18,-15,-5]]]},{"geoid":"0600478","name":"Ahwahnee","full":"Ahwahnee CDP","lsad":"CDP","cdp":true,"polygons":[[[-1197528,373759,53,-62,1,-32,17,-12,-3,-24,44,-49,-24,-68,-18,-10,41,-55,21,16,23,-16,20,-2,16,15,11,-18,41,-14,79,-49,8,-20,65,-26,20,5,-10,-9,1,-30,130,1,-3,71,31,12,15,-8,64,22,43,-37,61,34,36,7,-18,6,-76,-6,-58,14,-44,-10,-23,8,20,20,5,74,15,-4,9,14,24,9,14,22,17,9,2,29,28,35,-102,85,-52,120,-2,13,9,7,4,24,45,18,26,20,8,19,-30,26,-38,9,-3,8,-10,-6,-25,9,-35,-10,-46,19,-24,-11,-6,32,-29,-11,-10,-13,-22,16,-23,-9,-34,-39,-1,-39,-13,-11,-2,-36,-43,-19,-11,-40,-23,7,-19,15,-6,18,-24,11,1,55,-34,9,-50,-7,-6,-13,-20,0,-29,-45,8,-7,2,-60]]]},{"geoid":"0600535","name":"Airport","full":"Airport CDP","lsad":"CDP","cdp":true,"polygons":[[[-1209782,376347,35,0,0,-37,69,-2,15,9,0,23,-17,0,0,15,-35,0,0,7,-32,-4,-1,10,-34,10]]]},{"geoid":"0600562","name":"Alameda","full":"Alameda city","lsad":"city","polygons":[[[-1223309,377951,-7,-80,36,-57,102,-17,24,-31,90,-5,65,-38,111,-36,-2,-41,59,-7,37,42,44,-24,31,-42,280,-97,-10,-15,-168,-69,7,-38,50,-39,75,-78,6,5,117,-85,79,78,15,0,3,204,31,-1,-8,148,-24,41,-139,68,-30,26,-13,28,-4,39,-14,17,-47,22,-27,-1,-61,-23,-84,34,-97,52,-78,5,-150,-16,-275,55,-45,16]]]},{"geoid":"0600618","name":"Alamo","full":"Alamo CDP","lsad":"CDP","cdp":true,"polygons":[[[-1220555,378731,25,-74,-11,-14,9,-10,8,-208,90,-5,1,-45,55,2,21,-7,98,-91,35,13,8,-12,12,32,47,45,-33,34,27,12,22,30,-19,16,35,13,33,1,44,-15,78,4,1,-81,101,3,0,72,209,7,-5,-17,9,-1,-4,-5,13,12,41,13,0,116,-44,15,-17,-5,-46,19,-23,23,-156,-5,-8,66,-84,14,12,25,-47,14,13,33,-30,8,-29,20,-35,-19,-18,-33,14,-27,-97,16,-2,-70,-30,-1,0,56,-29,14,-1,-69,-24,-7,-3,21,-66,-1,-15,-21,-18,30,-19,11,-7,-5,-35,36,2,12,-32,14,-29,52,-24,-15,14,-45,-20,-16]]]},{"geoid":"0600674","name":"Albany","full":"Albany city","lsad":"city","polygons":[[[-1223269,378887,107,-38,34,-36,159,22,140,-10,11,25,-9,4,10,29,-7,29,-46,-3,3,76,-32,-9,-63,13,-169,-16,36,-43,-16,-26,-43,18,-115,3,0,-29]]]},{"geoid":"0600716","name":"Albion","full":"Albion CDP","lsad":"CDP","cdpLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"CO","state":"Colorado","fips":"08","features":[{"geoid":"0800320","name":"Acres Green","full":"Acres Green CDP","lsad":"CDP","cdp":true,"polygons":[[[-1049042,395510,154,-2,15,30,13,3,4,9,-8,1,1,31,-32,1,-30,32,-42,6,-2,-9,-30,-1,-23,-10,0,-19,-26,0]]]},{"geoid":"0800620","name":"Aetna Estates","full":"Aetna Estates CDP","lsad":"CDP","cdp":true,"polygons":[[[-1046783,397364,94,0,0,-15,5,0,0,50,-99,-4]]]},{"geoid":"0800760","name":"Aguilar","full":"Aguilar town","lsad":"town","polygons":[[[-1046626,373994,26,3,32,-18,9,17,92,1,-34,28,0,22,-14,0,17,26,-3,22,-76,0,0,-49,-20,1,-5,-16,-24,0]]]},{"geoid":"0800870","name":"Air Force Academy","full":"Air Force Academy CDP","lsad":"CDP","cdp":true,"polygons":[[[-1048985,390014,9,-13,50,-9,9,-15,-5,-20,14,-17,40,6,4,-18,-7,-18,-24,-23,-2,-25,34,-28,8,-26,52,-11,67,-33,5,-43,38,-25,93,-17,24,-21,26,-7,5,-9,36,3,11,11,82,-30,4,-9,16,0,28,-30,13,90,22,71,-21,6,15,54,-15,125,-169,279,-136,-26,-141,16,-101,28,-61,-13,-4,-51,12,-22,-13,-57,-17,-24,-5,-21]]]},{"geoid":"0800925","name":"Akron","full":"Akron town","lsad":"town","polygons":[[[-1032256,401721,122,-20,-96,-5,3,-187,39,0,7,41,44,0,-7,-40,50,-1,-9,-66,-51,1,0,27,-35,0,-2,-75,90,-1,14,177,84,-11,5,64,40,0,-1,-69,29,-1,1,17,15,-6,2,93,-132,1,1,35,-45,0,0,36,-46,-1,0,-15,-119,19]],[[-1032463,401624,189,0,-3,144,-185,1]]]},{"geoid":"0801090","name":"Alamosa","full":"Alamosa city","lsad":"city","polygons":[[[-1059136,374832,357,-156,-7,-11,-42,18,7,10,-208,89,2,-143,90,0,0,47,51,0,0,-19,-32,0,0,-9,26,0,0,-11,18,0,0,-24,52,0,8,-38,14,0,0,17,45,0,-1,-8,-33,0,0,-9,36,-2,-14,-27,14,0,-9,-19,-15,0,-13,-26,38,0,0,-49,-17,2,-10,16,-28,0,-20,-37,49,10,0,-10,28,0,0,-28,187,-4,-1,28,38,-2,0,73,-45,0,-1,27,-45,10,0,26,60,14,37,-30,8,7,31,1,5,-11,30,6,-28,63,15,14,-3,19,-36,-8,-30,10,0,-37,-45,-7,0,61,-35,15,-11,18,0,55,43,4,0,36,-14,0,14,11,-2,65,-88,1,0,71,-44,-1,-1,73,-46,0,-1,73,-183,0,1,-39,27,-21,1,-51,-74,-1,-1,24,-24,-3,-2,-15,-24,-7,0,-20,36,-14,0,-13,-31,-6,0,-14,27,6,9,-6,-9,-20,2,-22,-26,0,0,-37,-27,1,-18,16,-46,-6,-16,7]]]},{"geoid":"0801145","name":"Alamosa East","full":"Alamosa East CDP","lsad":"CDP","cdp":true,"polygons":[[[-1058619,374708,11,-18,35,-15,0,-9,106,-1,258,-25,0,241,-369,-2,2,-65,-14,-11,14,0,0,-36,-43,-4,0,-32]]]},{"geoid":"0801420","name":"Allenspark","full":"Allenspark CDP","lsad":"CDP","cdp":true,"polygons":[[[-1055426,402250,100,1,-3,-56,-24,-1,-4,-21,31,-8,1,-51,-102,-8,-1,-201,11,4,-1,-20,40,-2,3,23,48,20,-6,-220,72,24,37,79,286,-9,0,35,95,-4,0,-35,47,-1,-2,-100,60,32,37,-1,-7,-72,94,-1,8,13,4,45,37,25,-16,8,118,-5,0,90,-50,2,-3,38,-139,2,-1,35,-46,-3,-1,35,-142,-7,-188,29,-195,-4,-2,35,80,1,8,10,-50,91,11,11,45,0,-2,248,101,-3,-7,10,2,138,-93,2,0,34,-46,-1,0,72,-247,5]]],"label":[-105.5230877016129,40.21395]},{"geoid":"0801530","name":"Alma","full":"Alma town","lsad":"town","polygons":[[[-1060774,392893,31,-29,5,-28,24,0,21,-9,4,-9,46,0,0,-18,24,0,0,-9,16,10,19,-13,4,19,-16,15,9,13,-18,9,-13,22,-10,43,-91,-13,10,-42,-30,9,-40,35,42,0,1,27,-46,0,-1,-25,-55,9]]]},{"geoid":"0801640","name":"Alpine","full":"Alpine CDP","lsad":"CDP","cdp":true,"polygons":[[[-1066001,376839,43,0,0,-20,17,-12,32,0,65,31,116,2,2,22,40,-2,-5,46,-37,-24,-16,5,6,25,17,0,-23,10,1,7,-217,-1,1,-33,-22,23,-22,-2]]]},{"geoid":"0801740","name":"Altona","full":"Altona CDP","lsad":"CDP","cdp":true,"polygons":[[[-1053079,401147,11,-22,17,0,1,-29,19,-6,10,5,-23,11,0,69,59,-15,76,5,20,-8,25,-29,43,-8,-2,298,-40,0,-24,-9,-2,-11,-23,-12,2,-36,-7,-24,-21,2,-32,21,-57,-76,-18,-6,-16,-31,-7,-67]]]},{"geoid":"0801915","name":"Amherst","full":"Amherst CDP","lsad":"CDP","cdp":true,"polygons":[[[-1021835,406825,122,-49,126,86,-247,0]]]},{"geoid":"0802355","name":"Antonito","full":"Antonito town","lsad":"town","polygons":[[[-1060158,370752,44,0,0,-32,10,-1,-44,-27,21,-25,50,27,-1,23,18,1,2,109,-94,0]]]},{"geoid":"0802575","name":"Applewood","full":"Applewood CDP","lsad":"CDP","cdp":true,"polygons":[[[-1051829,397573,37,-15,-46,-43,-5,-114,177,1,56,11,-23,36,-21,-11,0,34,89,1,-4,-17,13,-10,26,25,19,2,-12,-14,23,-17,-15,-12,2,-43,-23,-3,7,-41,58,13,1,-28,-29,6,-31,-6,-11,-19,1,-18,163,0,0,72,48,5,0,33,-65,-1,0,-18,-21,-8,0,23,-56,-6,22,6,26,20,-2,165,-6,0,6,37,-28,3,0,18,-20,15,-47,-1,0,62,-33,9,-195,-10,0,-103,-50,6,-7,-13,-45,10]]]},{"geoid":"0802850","name":"Arapahoe","full":"Arapahoe CDP","lsad":"CDP","cdp":true,"polygons":[[[-1021839,388500,43,-2,13,-21,14,10,36,-7,-14,27,1,63,-93,0]]]},{"geoid":"0802905","name":"Arboles","full":"Arboles CDP","lsad":"CDP","cdp":true,"polygons":[[[-1074424,370000,316,0,121,111,10,43,-6,119,-11,22,-48,41,-83,-11,-42,97,9,38,-28,-6,-8,9,-10,-11,-51,-17,-27,-40,-143,2]]]},{"geoid":"0803015","name":"Aristocrat Ranchettes","full":"Aristocrat Ranchettes CDP","lsad":"CDP","cdp":true,"polygons":[[[-1047727,401019,282,2,1,71,-98,0,1,73,-188,-1]]]},{"geoid":"0803235","name":"Arriba","full":"Arriba town","lsad":"town","polygons":[[[-1032812,392808,47,0,-1,-20,91,-1,0,106,-137,0]]]},{"geoid":"0803455","name":"ALine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"CT","state":"Connecticut","fips":"09","features":[{"geoid":"0901150","name":"Ansonia","full":"Ansonia city","lsad":"city","polygons":[[[-731024,413529,69,-137,35,-1,18,-10,21,-30,108,-48,9,-13,43,-21,59,-5,12,-12,93,-13,166,28,-9,57,-19,52,-10,12,-8,35,-26,38,-9,144,-251,1,-84,23,-52,0,-53,-24,-36,2,-57,-67]]]},{"geoid":"0902480","name":"Ball Pond","full":"Ball Pond CDP","lsad":"CDP","cdp":true,"polygons":[[[-735362,414496,76,15,0,17,23,-10,28,10,-2,26,33,4,51,-8,-3,29,-11,13,-7,74,-7,1,1,12,-21,12,-10,37,-63,6,-43,-27,-31,0,-13,-194]]]},{"geoid":"0902550","name":"Baltic","full":"Baltic CDP","lsad":"CDP","cdp":true,"polygons":[[[-721076,416144,147,6,0,-33,33,0,6,-7,10,-72,241,45,-59,17,-27,21,-20,31,-42,17,-1,13,-42,19,-22,45,-33,-5,5,-42,-12,-9,-55,7,-29,36,-38,-28,-4,8,-28,-20,-42,-3]]]},{"geoid":"0902690","name":"Bantam","full":"Bantam borough","lsad":"borough","polygons":[[[-732545,417189,248,-1,-7,117,-237,5,-4,-101]]]},{"geoid":"0904790","name":"Bethel","full":"Bethel CDP","lsad":"CDP","cdp":true,"polygons":[[[-734350,413724,107,-371,182,30,31,110,104,152,24,20,50,-32,-5,38,-48,48,3,15,-10,46,16,12,-8,47,-27,18,-22,-7,-60,16,-62,31,-8,21,10,39,-166,-134,-87,-38,-12,-26]]]},{"geoid":"0904945","name":"Bethlehem Village","full":"Bethlehem Village CDP","lsad":"CDP","cdp":true,"polygons":[[[-732276,416389,36,-85,-38,-76,16,-68,19,-12,10,-23,81,-4,42,27,31,6,13,27,9,3,83,-12,62,-49,165,3,-3,14,-20,15,-33,52,-29,22,-17,24,0,104,-19,43,10,33,15,13,13,30,-7,20,60,2,-6,73,-60,115,-404,-21,24,-22,31,-10,1,-38,-14,-35,6,-71,8,-10,-76,7,-20,-6]]]},{"geoid":"0904965","name":"Bigelow Corners","full":"Bigelow Corners CDP","lsad":"CDP","cdp":true,"polygons":[[[-735161,414654,27,-21,23,-5,86,11,70,24,-8,16,11,19,-10,7,-27,2,-27,26,7,25,-40,38,-15,-4,3,-16,-42,-23,8,-15,-2,-30,-58,-29]]]},{"geoid":"0906050","name":"Blue Hills","full":"Blue Hills CDP","lsad":"CDP","cdp":true,"polygons":[[[-727078,418070,164,6,13,-68,41,-6,-10,264,-16,16,-16,-9,12,-21,-17,-18,-54,-9,7,-43,-136,-8]]]},{"geoid":"0906155","name":"Bogus Hill","full":"Bogus Hill CDP","lsad":"CDP","cdp":true,"polygons":[[[-734672,415081,61,-61,19,16,-1,43,-22,45,-12,4,-20,-26]]]},{"geoid":"0906680","name":"Botsford","full":"Botsford CDP","lsad":"CDP","cdp":true,"polygons":[[[-732786,413571,27,-52,29,0,22,19,4,-11,21,-12,121,59,7,71,20,-3,-39,22,-90,-47,-39,40,-18,1,-27,-37,-19,0,-19,-24]]]},{"geoid":"0907100","name":"Branchville","full":"Branchville CDP","lsad":"CDP","cdp":true,"polygons":[[[-734467,412687,15,-33,-3,-33,73,31,-68,101,-14,-46]]]},{"geoid":"0907345","name":"Branford Center","full":"Branford Center CDP","lsad":"CDP","cdp":true,"polygons":[[[-728310,412659,22,-16,12,6,14,-14,14,2,1,-17,34,-4,5,-19,31,17,65,15,2,10,-18,12,-28,5,-27,22,11,28,35,12,16,27,18,11,31,3,27,-11,11,11,4,10,-11,11,13,17,-16,21,16,2,7,14,-14,6,22,17,22,-7,39,49,12,-4,0,13,14,18,-3,6,-26,3,-129,-21,-112,-31,16,-26,-85,-83,-37,-22,5,-25,-20,-38]]]},{"geoid":"0908000","name":"Bridgeport","full":"Bridgeport city","lsad":"city","polygons":[[[-732191,411978,-13,-99,24,-46,-16,-18,3,-21,-38,-30,-8,-24,-16,-1,-21,-45,39,8,14,-13,-51,-48,-7,-20,-91,-70,0,-14,11,-13,24,-3,8,-45,-19,-36,72,-10,49,53,-11,18,25,12,16,20,26,-5,6,-41,10,-3,61,64,67,33,165,12,18,51,66,23,71,-18,-15,-58,74,-22,60,174,32,34,14,53,-9,70,13,25,-9,9,-81,268,17,44,20,13,15,25,-71,-28,-48,-28,-78,-5,-60,-16,-40,-2,-34,37,75,-5,-13,22,31,12,-69,4,-26,-25,-55,27,-47,6,-3,12,-59,-17,-51,7,-318,-19]]]},{"geoid":"0908140","name":"Bridgewater","full":"Bridgewater CDP","lsad":"CDP","cdp":true,"polygons":[[[-733692,415343,-4,-15,7,-12,35,7,1,-22,22,-12,27,8,-13,25,16,10,4,15,19,20,-40,-6,-8,23,5,10,-14,7,-52,-15,-1,-22,-21,-11]]]},{"geoid":"0908420","name":"Bristol","full":"Bristol city","lsad":"city","polygons":[[[-729831,416395,545,60,8,-48,58,4,-19,44,405,40,-149,737,-1002,-107]]]},{"geoid":"0908770","name":"Broad Brook","full":"Broad Brook CDP","lsad":"CDP","cdp":true,"polygons":[[[-725737,418798,16,23,12,-1,26,19,26,3,0,16,45,7,34,15,60,-21,-18,89,9,24,107,47,34,9,106,-24,128,-9,11,331,-42,-13,-15,14,-22,-14,-3,16,-29,11,-14,-25,15,60,-22,1,-18,-24,-116,-91,-79,7,-16,-8,9,-22,-11,-7,-6,-21,-25,0,7,-14,-33,-22,0,-26,-30,6,9,-29,-44,11,3,-14,-42,-22,-12,-16,-4,-24,-34,-18,-10,-16,19,-7,-23,-28,13,-10,-15,-18,3,-21,-13,-76,-38,2,-9,-9,-1,-26,11,-5,-27,-19]]]},{"geoid":"0909050","name":"Brookfield Center","full":"Brookfield Center CDP","lsad":"CDP","cdp":true,"polygons":[[[-733906,414669,9,-26,-25,-7,16,-48,-22,-25,8,-46,79,49,37,-4,-24,33,-9,68,17,22,-45,35,-65,-31]]]},{"geoid":"0909120","name":"Brooklyn","full":"Brooklyn CDP","lsad":"CDP","cdp":true,"polygons":[[[-719714,417866,21,-32,0,-12,62,1,6,-7,13,7,13,-6,92,-2,19,-9,13,35,51,19,-10,16,-16,2,7,32,18,18,-23,-1,0,9,-35,41,-14,33,-82,1,4,-19,-21,-4,-12,-29,12,-40,-6,-45,-17,-17,-58,31,-30,-21]]]},{"geoid":"0910660","name":"Byram","full":"Byram CDP","lsad":"CDP","cdp":true,"polygons":[[[-736593,409972,-3,-29,24,-34,-18,-20,12,-13,10,1,56,75,24,16,26,34,-2,16,41Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"DC","state":"District of Columbia","fips":"11","features":[{"geoid":"1150000","name":"Washington","full":"Washington city","lsad":"city","polygons":[[[-771179,389324,13,-35,32,-37,71,-61,29,-62,22,-18,110,-69,80,-23,121,-12,19,-9,9,-6,38,-106,52,-85,31,-1,11,-9,30,-39,-2,-20,22,-25,28,6,7,17,-10,19,12,3,48,-40,15,-31,-2,-38,53,-72,23,-66,-28,-100,-40,-12,-57,9,-8,-17,1,-38,14,-15,23,5,20,-15,11,-36,-10,-72,17,-72,-15,-134,-1,-92,1296,1012,-1316,1023,-788,-608]]]}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"DE","state":"Delaware","fips":"10","features":[{"geoid":"1001400","name":"Arden","full":"Arden village","lsad":"village","polygons":[[[-754955,398114,40,-51,114,64,-41,23,-19,-15,-10,14,-26,2,-38,-25]]]},{"geoid":"1001530","name":"Ardencroft","full":"Ardencroft village","lsad":"village","polygons":[[[-754860,398022,39,29,-31,27,-9,-6,-17,14,-22,-14]]]},{"geoid":"1001660","name":"Ardentown","full":"Ardentown village","lsad":"village","polygons":[[[-754798,398035,20,12,47,3,1,11,-55,32,-16,34,-67,-35]]]},{"geoid":"1004130","name":"Bear","full":"Bear CDP","lsad":"CDP","cdp":true,"polygons":[[[-757148,396181,-5,-33,241,-150,73,39,172,25,23,22,206,99,-77,97,-16,40,-3,46,-105,-61,-67,-27,-73,60,-28,38,-17,-37,-16,-6,-46,26,-55,-34,-12,2,-19,-11,4,-11,-21,-1,-33,15,-36,-6,16,-54,13,-9,-15,-12,16,-18,-63,9,-63,-27]]]},{"geoid":"1004650","name":"Bellefonte","full":"Bellefonte town","lsad":"town","polygons":[[[-755041,397689,22,-14,-13,-13,61,-40,16,15,-8,5,11,11,13,-7,20,19,-59,33,-12,14,-42,-18]]]},{"geoid":"1005690","name":"Bethany Beach","full":"Bethany Beach town","lsad":"town","polygons":[[[-750753,385331,64,10,2,-9,56,-11,2,8,15,-8,45,1,2,-7,31,2,-12,141,-98,-13,-18,11,-40,3,-15,-7,-98,-8]]]},{"geoid":"1005820","name":"Bethel","full":"Bethel town","lsad":"town","polygons":[[[-756221,385643,11,7,5,28,14,10,54,-30,9,97,-76,19,-59,-58,6,-2,-9,-17,19,-8,-21,-40]]]},{"geoid":"1006730","name":"Blades","full":"Blades town","lsad":"town","polygons":[[[-756134,386369,40,-86,19,2,25,22,3,-20,58,53,13,-13,28,18,14,26,-28,-9,12,26,-73,-30,-25,13,-20,28,-22,2,-34,-18]]]},{"geoid":"1007250","name":"Bowers","full":"Bowers town","lsad":"town","polygons":[[[-754061,390570,-1,-19,54,0,50,41,-45,64,-21,3,-26,-9,-3,-24,-22,-10,16,-36,-20,2]]]},{"geoid":"1008680","name":"Bridgeville","full":"Bridgeville town","lsad":"town","polygons":[[[-756315,387387,47,-55,23,-5,58,32,39,-51,24,28,1,34,-12,10,-16,-3,57,36,21,-54,22,-10,2,-13,-28,0,-47,-223,22,-18,8,-24,-27,4,-24,-59,40,-32,0,-9,46,11,22,-11,4,-15,23,10,10,-15,109,42,2,14,-12,0,2,10,9,0,-10,7,-13,65,28,4,1,-6,66,20,-4,62,9,10,-83,-2,1,-11,-10,60,27,-5,29,-26,-1,45,13,36,-35,-17,-35,9,9,-27,-33,-4,0,10,24,0,-16,168,-30,-3,-15,40,38,1,1,17,16,-4,-2,19,-67,11,-11,18,-15,-2,8,16,-12,2,-2,-14,-21,-14,-44,84,-67,-26,16,-47,14,-10,30,8,8,-17,15,0,-61,-17,10,-24,-67,-8,-13,-12,-63,-19,-22,1,-30,-20]]]},{"geoid":"1009850","name":"Brookside","full":"Brookside CDP","lsad":"CDP","cdp":true,"polygons":[[[-757320,396487,282,59,69,36,8,115,-26,77,27,15,-21,8,-14,28,6,7,-32,26,-271,-83,9,-42,-26,-2,-53,-84,10,-6,-17,-6]]]},{"geoid":"1010760","name":"Camden","full":"Camden town","lsad":"town","polygons":[[[-755817,390758,41,-30,24,-8,8,14,-7,41,31,-2,11,20,31,3,-8,-69,54,10,4,26,57,0,4,25,48,20,51,126,-23,5,-20,15,52,-2,34,74,-63,35,20,30,60,-36,19,43,33,0,7,32,41,12,-8,29,-18,8,-7,-31,-24,6,40,85,-26,13,-39,-9,-33,-18,-24,17,-36,-11,-9,-15,-13,-1,14,-3,5,-22,-13,-1,-7,-17,-21,5,-11,-44,-58,-55,-31,13,14,26,-26,3,-3,-32,-35,1,-1,-29,-42,-1,-11,-28,-61,-2,-10,-13,21,-60,68,30,37,-73,13,2,0,11,40,4,-17,9,1,22,7,-16,17,-3,3,-14,20,2,-2,27,15,2,23,32,-4,-30,44,-7,-24,-8,4,-12,-16,-9,-32,1,-7,-24,26,1,1,-10,-27,-1,19,-14,4,-12,20,-7,-1,-7,-10,3,-4,-9,-27,7,-12,-29,-27,10,-46,-13,7,54,-30,21,-36,-39,-91,-21]]]},{"geoid":"1014660","name":"Cheswold","full":"Cheswold town","lsad":"town","polygons":[[[-756106,392211,-15,-12,9,-20,34,5,-14,39,43,6,12,-35,108,19,18,-8,-14,-17,43,-92,-37,-15,19,-17,-7,-18,24,-38,10,9,72,1,10,-8,13,22,-34,16,20,18,-19,4,35,23,-33,4,-13,41,-14,-7,-11,26,63,-6,16,5,-3,14,29,14,7,-11,40,4,8,-19,74,33,-39,23,-17,-4,4,-13,-34,-11,-23,7,-21,-8,-13,30,-11,-3,-6,10,6,3,-17,35,6,2,-4,9,-46,-13,-5,6,8,15,31,16,-4,7,-7,-7,-42,32,-21,-13,-34,-2,17,-39,-47,-6,-29,32,-42,-11,2,-6,-102,-19,-19,-14]]]},{"geoid":"1015310","name":"Claymont","full":"Claymont CDP","lsad":"CDP","cdp":true,"polygons":[[[-754847,397970,28,-17,14,-20,38,3,26,-12,13,13,67,-35,33,24,6,-13,105,68,144,151,-126,54,-50,-53,-77,-53,-31,-33,-39,-12,-64,9,-65,-32,-42,-41]]]},{"geoid":"1015440","name":"Clayton","full":"Clayton town","lsad":"town","polygons":[[[-756537,392984,53,-27,-2,-7,65,-47,27,-8,-10,-21,26,4,48,-18,-46,-73,36,4,-6,-12,1,-60,-15,-12,-79,53,-59,-38,93,-78,23,17,27,-18,28,17,-8,21,30,-5,40,10,24,76,-39,64,-27,11,56,72,-16,4,14,29,-30,-4,-14,18,15,10,-27,25,-14,-11,-16,36,-71,-3,-6,7,-88,17,1,-13]]],"label":[-75.6317651065449,39.28485]},{"geoid":"1018950","name":"Dagsboro","full":"Dagsboro town","lsad":"town","polygons":[[[-752653,385423,39,-8,7,3,-4,10,18,10,28,-34,36,4,66,44,8,-26,23,-24,21,-42,36,1,0,-7,27,1,-13,6,14,14,-6,51,54,-5,13,-14,11,35,-22,6,10,13,-35,34,-22,-6,-2,16,-29,9,-6,11,-22,7,-22,-6,-43,23,-16,-6,-39,10,-65,-35,-31,-47,10,-13,-43,-5]]]},{"geoid":"1019730","name":"Delaware City","full":"Delaware City city","lsad":"city","polygons":[[[-756157,395818,160,-61,-13,-11,-16,5,-5,-13,27,-14,11,13,21,-2,15,-14,-61,-55,78,-70,-5,-17,110,16,-13,38,-26,34,14,8,31,-31,21,5,66,47,-249,193,-146,Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"FL","state":"Florida","fips":"12","features":[{"geoid":"1200075","name":"Acacia Villas","full":"Acacia Villas CDP","lsad":"CDP","cdp":true,"polygons":[[[-801126,266434,37,3,6,41,-40,0,-1,-35]]]},{"geoid":"1200375","name":"Alachua","full":"Alachua city","lsad":"city","polygons":[[[-825571,297419,96,0,-2,36,97,-1,27,35,67,1,0,8,-25,0,-1,19,-17,1,45,46,97,0,1,71,84,0,1,-147,43,0,-2,75,125,-2,4,-18,58,0,0,-10,21,0,-1,28,43,1,1,-38,-84,-1,0,-19,-40,-4,12,-12,23,0,-13,-37,21,0,1,-37,-42,0,-27,-65,-2,-21,42,0,-1,-38,-41,0,0,-53,20,0,0,-10,-20,0,0,-65,22,0,0,21,35,0,0,73,58,0,0,32,22,4,1,52,63,0,-2,51,41,0,107,161,126,-49,161,-113,105,0,0,-68,47,0,-6,-37,83,-1,0,37,-70,1,0,31,19,4,8,10,-7,22,86,0,-4,14,13,0,-15,13,104,-5,9,12,25,-10,19,2,21,-13,0,42,-131,145,-68,63,-7,4,-4,-9,-34,11,2,20,41,-14,-1,35,-92,-1,-7,-25,-24,7,0,19,-11,-17,-14,4,-1,14,14,-1,-8,32,-27,8,-16,-24,-25,-3,-44,17,-3,12,9,18,-14,6,13,24,38,-13,41,0,-1,38,-42,-1,-2,37,-43,-1,0,37,-126,-1,-1,75,-19,0,0,35,-68,-12,-3,28,-19,11,-17,-17,0,-10,-13,-1,-94,-72,-69,0,0,13,14,0,-1,101,-32,-2,0,-41,-50,1,10,23,-12,0,0,13,-47,3,0,28,-53,2,8,0,1,52,25,0,7,-9,1,-39,128,-6,13,6,0,12,22,7,3,17,18,12,0,6,-33,8,32,4,-6,115,42,7,-2,37,23,2,0,-9,32,5,29,50,0,19,-76,-4,-21,20,-2,21,-28,0,1,-39,-53,0,1,-34,-98,2,-2,-38,-13,1,0,-37,21,-1,0,-36,-146,11,-6,62,-24,0,0,109,-126,0,1,-37,21,0,0,-36,-20,-1,1,-36,-50,0,-23,34,-31,-1,-1,19,18,0,-6,24,-12,-1,0,15,-22,2,0,17,-43,0,1,-54,41,-1,-1,-54,-55,1,0,-56,44,0,-2,53,42,2,1,-14,41,-1,2,-57,56,-1,31,-54,-8,-37,-39,0,-2,55,-250,1,10,-161,36,-23,-34,0,1,-37,40,1,0,26,26,-26,57,1,42,21,-1,-40,-41,1,0,-19,-28,1,0,14,-15,3,-39,0,1,-18,-41,-1,1,-45,32,0,0,11,15,0,1,-24,-49,-1,1,-12,102,0,0,-27,17,-9,1,72,31,1,0,13,11,0,0,-10,24,-1,7,16,82,-26,7,-24,-75,0,1,7,-32,7,-14,-11,-1,-44,-40,0,1,-36,-120,0,2,-71,-81,4,-1,32,60,0,-1,35,-104,1,-6,-108,15,-110,37,0,0,-18,-76,-2]],[[-824055,297325,162,0,3,68,-164,0]]]},{"geoid":"1200410","name":"Alafaya","full":"Alafaya CDP","lsad":"CDP","cdp":true,"polygons":[[[-812442,285394,9,-43,33,-43,0,-83,20,-93,-20,-79,7,-16,43,-39,63,-165,43,-25,124,1,15,13,1,16,-15,15,0,98,340,7,0,5,82,-7,85,2,0,71,11,12,14,-13,4,-69,66,0,44,11,40,-16,66,-6,34,43,-13,4,0,49,-24,26,9,53,42,45,43,83,11,70,33,47,13,5,-5,31,-68,32,-25,48,-28,26,-23,-5,-29,21,-8,-7,-47,10,-21,27,-2,43,-67,54,-397,1,-301,34,2,-150,-9,-25,-17,1,-39,-21,-142,1]]]},{"geoid":"1200625","name":"Alford","full":"Alford town","lsad":"town","polygons":[[[-854036,306871,41,16,63,10,65,-1,2,148,-168,1]]]},{"geoid":"1200800","name":"Allentown","full":"Allentown CDP","lsad":"CDP","cdp":true,"polygons":[[[-871446,308185,13,-14,-2,-94,9,-5,-11,-285,296,-5,-3,-92,-15,-32,-58,-28,-3,-137,111,3,-3,-151,725,-11,11,7,-37,32,15,-1,42,34,-32,1,1,36,128,-4,4,217,130,-4,4,-14,13,1,15,-35,4,32,28,32,-9,13,-2,-8,-60,29,-52,65,-17,3,-25,35,-16,2,4,19,-37,2,-16,18,9,6,-56,5,-2,8,-23,13,8,18,-20,-8,-133,18,-18,-21,-38,16,-24,-2,-18,22,-18,11,-23,-1,-22,33,-53,24,-23,21,1,16,15,13,-4,7,14,4,-15,31,19,10,-3,8,23,23,16,-1,12,25,-7,-1,7,42,10,10,-26,64,-15,12,-1,28,-12,16,1,33,-31,0,-9,-141,-39,-1,14,-14,-32,-18,-36,-34,-74,-3,-2,-75,-167,3,0,34,-30,6,-34,37,-10,28,-8,-13,-34,-13,-43,20,-34,-4,-14,-14,-36,26,-20,42,-97,-2]]]},{"geoid":"1200950","name":"Altamonte Springs","full":"Altamonte Springs city","lsad":"city","polygons":[[[-814436,286402,471,0,0,36,-115,-2,-8,9,0,28,-63,-4,1,77,41,1,5,44,23,1,5,26,10,-10,121,-5,0,-37,8,-2,-14,-27,12,-27,-6,-23,42,0,0,-49,-18,-9,5,-27,42,0,-6,71,59,0,0,-35,62,0,0,-34,43,0,0,35,8,26,10,0,0,-26,41,1,6,-36,-5,41,51,16,28,-9,8,123,35,1,-3,36,5,0,1,-36,15,0,-1,37,10,1,1,-37,11,0,-1,46,19,10,8,-19,23,0,5,10,-15,14,-80,-1,1,14,-45,5,0,21,22,0,0,8,-20,0,-1,77,-20,0,-11,13,-52,-2,1,-9,-22,0,2,-36,-17,4,0,14,-33,0,28,9,-3,18,-16,5,-17,-5,0,-18,-9,-10,8,-18,-81,1,0,36,-10,0,0,-18,-11,0,0,18,-20,0,-1,-27,-17,-8,-23,17,-61,-1,0,-13,-21,0,7,95,36,20,21,23,-22,-34,21,1,-1,-37,20,0,6,19,5,-5,0,77,-11,6,-48,-27,-42,2,-2,-35,-31,-1,-1,36,-30,3,10,-8,-7,-31,-91,1,13,33,-44,-16,-12,-17,-42,0,-1,-36,-41,0,0,-37,-22,0,1,-36,62,0,0,-54,-6,-20,-35,6,-1,-78,-41,0,0,-18,20,0,0,-18,22,-1,-158,-130]]]},{"geoid":"1201000","name":"Altha","full":"Altha town","lsad":"town","polygons":[[[-851361,305639,211,-1,5,143,-214,3]]]},{"geoid":"1201050","name":"Altoona","full":"Altoona CDP","lsad":"CDP","cdp":true,"polygons":[[[-816542,289676,22,-1,-1,13,34,-10,-31,-75,58,4,17,10,17,41,18,10,-22,16,-10,29,-28,0,10,36,-80,-4,-7,-44]]]},{"geoid":"1201100","name":"Alturas","full":"Alturas CDP","lsad":"CDP","cdp":true,"polygons":[[[-817611,278209,134,-1,11,-14,21,-7,1,-343,363,-3,6,-22,31,-31,11,-55,247,0,0,-29,9,-8,144,1,48,14,82,-8,4,20,138,154,1,210,-24,54,-59,670,5,20,-22,40,12,14,-9,10,7,11,-35,24,-21,0,-84,100,-266,77,-142,28,11,-111,-82,1,0,-19,-41,1,0,44,-81,0,9,37,1,73,82,-7,0,59,-41,1,0,23,41,6,-21,30,-90,0,-130,-64,17,-27,2,-26,-23,-33,-35,-14,-36,-33,-4,-16,22,-61,11,0,10,-28,13,-13,26,0,-6,-31,-47,-48,3,-20,26,-49,2,-20,-161,1,-1,-289,-91,-1]]]},{"geoid":"12Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"GA","state":"Georgia","fips":"13","features":[{"geoid":"1300184","name":"Abbeville","full":"Abbeville city","lsad":"city","polygons":[[[-833227,319879,28,-43,58,-41,50,-13,55,0,43,11,33,17,40,43,19,76,-6,36,-23,48,-29,4,18,6,-46,31,-83,16,-66,-11,-53,-30,-23,-29,-23,-56]]]},{"geoid":"1300408","name":"Acworth","full":"Acworth city","lsad":"city","polygons":[[[-847229,340563,20,12,39,0,16,-19,-16,-5,0,-22,18,0,21,35,70,-22,27,-17,18,-22,-2,-17,12,4,-5,-48,29,0,-1,-25,23,7,8,-19,13,0,-28,-7,0,-25,45,6,-1,12,29,-18,-27,1,1,-18,16,-15,-1,23,29,-14,-6,-20,32,-11,-1,-10,36,25,5,15,122,-23,0,-20,81,-10,8,14,-1,-35,77,-1,23,22,28,-5,1,7,9,-1,0,-8,36,-7,1,47,14,0,4,7,-9,11,-30,-2,1,19,-18,7,4,92,-26,0,0,-36,-25,0,0,18,-44,-9,-21,12,-25,-31,45,0,-9,11,13,3,18,-24,-23,4,1,-30,-22,0,0,16,-19,-5,2,12,-24,3,39,43,-42,38,37,10,25,28,26,0,0,-35,67,-2,2,64,-7,0,0,12,19,-6,1,8,13,-11,17,-2,1,52,9,7,-22,15,-74,1,65,3,0,6,30,-5,-4,-6,6,-1,0,10,27,-1,10,-17,11,-1,0,18,29,2,0,5,-2,25,-25,11,1,13,19,-9,7,20,89,-3,1,37,-54,1,-2,-30,-23,-2,-13,3,0,11,-12,11,-30,11,15,8,-3,30,-52,0,0,-16,20,-17,-53,9,19,25,-103,-4,-33,6,0,-15,-45,5,-1,10,-69,2,0,-24,-21,-4,-6,27,-195,3,5,-11,-17,-7,-13,19,-18,-16,5,-7,-31,14,5,-9,-4,-33,37,7,11,-17,-22,1,3,-13,-69,3,-8,-19,-17,-12,7,-22,17,-3,15,-21,29,-10,7,-16,14,16,21,-3,2,-25,-204,13,-14,19,3,20,-35,4,-21,-14,-23,-35]]]},{"geoid":"1300436","name":"Adairsville","full":"Adairsville city","lsad":"city","polygons":[[[-849707,343628,47,-1,5,-40,-5,-7,45,0,1,-56,42,7,25,13,17,32,-3,121,29,0,3,-23,31,0,17,35,37,-7,-23,-60,37,-39,5,-54,6,32,-6,16,68,-2,31,-13,13,-26,0,-29,28,1,0,-21,49,0,-2,13,20,12,-30,21,-26,1,-4,14,-26,6,-9,20,47,2,5,-8,38,15,12,-4,7,42,10,-11,19,11,0,11,35,0,0,-37,10,0,2,37,12,0,1,-37,17,-3,19,7,1,-70,82,4,-13,-44,-18,11,-43,-1,0,-64,31,-1,12,-37,211,-2,1,-43,90,7,-15,25,-161,154,-93,65,-76,74,-28,45,-1,24,47,-1,-8,-6,1,-48,13,-7,0,19,14,5,0,74,-33,-26,-17,20,40,31,45,17,3,-8,43,0,-5,17,-24,1,19,7,9,-8,24,0,2,112,-56,-2,-7,-29,-14,4,-29,-62,1,27,-8,7,-35,2,0,73,-141,-3,1,-65,-103,-7,3,74,-40,1,1,-13,-20,1,1,-61,-75,3,29,-56,30,0,1,-11,-13,-7,-45,4,-10,9,14,3,-5,18,-16,3,-22,-25,-59,12,-19,-39,-2,-57,-74,1,-15,12,0,38,21,-5,0,21,-31,7,0,-28,-7,-11,-15,0,-6,-34,41,0,2,-49,-16,-3,-5,-26,-27,-21,-6,-23,-10,-7,-21,4]]]},{"geoid":"1300576","name":"Adel","full":"Adel city","lsad":"city","polygons":[[[-834602,311319,38,1,46,37,26,-22,-3,-62,11,-15,40,-6,-9,-4,-3,-18,30,-3,4,10,36,-5,-33,-30,22,-16,-5,-9,97,-78,2,-25,49,-6,-31,52,9,6,69,-128,10,53,1,-69,40,-77,37,2,-2,-66,46,-91,105,-174,-100,285,11,4,-12,40,163,61,-44,24,13,34,-27,20,-3,-34,-73,3,0,24,-76,0,-8,17,-11,1,14,6,-8,21,44,-2,2,36,-5,15,-50,16,-6,49,-11,23,72,7,9,10,-67,86,43,6,-14,54,20,7,-13,9,9,9,-30,8,18,25,-7,36,-54,0,-1,18,22,16,16,23,11,-17,19,6,25,59,35,-1,3,53,65,52,0,19,-163,1,0,-28,24,-3,-2,-11,-29,5,-18,29,-22,1,-1,-33,9,-5,-9,1,-2,-36,18,2,-15,-14,-48,-9,-17,-29,-20,-9,25,0,-1,-16,-44,-7,100,3,-1,-43,-88,7,-14,-11,7,-18,-48,1,-3,12,-12,-13,-32,9,-32,-2,-20,3,-15,43,-10,3,15,-43,-7,-1,-2,-22,-141,-2,24,-12,-5,-51]]]},{"geoid":"1300660","name":"Adrian","full":"Adrian city","lsad":"city","polygons":[[[-826068,325259,10,-16,54,28,43,-41,33,-11,39,-1,44,14,38,35,10,27,-1,33,-14,29,-33,31,-36,11,-51,-1,-51,-23,-26,-39,-4,-20,-64,2]]]},{"geoid":"1300828","name":"Ailey","full":"Ailey city","lsad":"city","polygons":[[[-825766,321761,58,5,6,-19,14,4,-7,18,9,2,14,-21,80,8,-1,36,30,10,-42,16,23,25,5,37,-10,26,-40,31,8,15,40,20,-43,43,-13,4,-163,-33,-36,-140]]]},{"geoid":"1300996","name":"Alamo","full":"Alamo town","lsad":"town","polygons":[[[-827942,321452,20,1,8,-27,32,-43,43,-22,68,-4,43,12,30,20,22,30,12,34,-3,36,-16,33,90,22,-38,24,-37,-11,-27,-21,-60,30,-55,7,-64,-24,-33,-35,-30,20,-52,-48]]]},{"geoid":"1301024","name":"Alapaha","full":"Alapaha town","lsad":"town","polygons":[[[-832320,313757,48,-6,-3,-17,18,23,103,2,2,148,-169,-1]]]},{"geoid":"1301052","name":"Albany","full":"Albany city","lsad":"city","polygons":[[[-842729,315398,591,3,1,59,125,6,-49,-43,2,-26,30,-73,29,1,-36,-41,-55,0,3,-59,171,-2,114,153,33,21,106,4,-7,-87,117,-3,1,81,57,1,1,-52,39,0,1,20,67,0,-7,18,33,14,-8,-44,45,0,2,-12,92,0,7,168,45,-24,23,8,22,145,34,-19,69,-77,86,4,-3,78,218,-17,-2,14,26,-1,0,17,-28,0,3,165,-56,0,2,75,36,0,0,20,-17,2,-1,111,53,42,21,-16,0,11,32,-26,74,17,-21,5,-22,23,-1,22,-42,71,-40,-8,-4,-20,-25,-17,-95,-40,-20,-1,-56,38,-31,47,-55,32,-13,-2,18,-2,54,-39,-65,38,-3,-14,12,-28,-10,-8,22,-23,-1,-18,-19,-7,-20,11,-28,-6,-53,-36,-54,-13,-16,12,8,18,-3,11,-49,13,-59,2,-61,-49,-27,-34,-35,-15,-44,73,-10,12,-16,2,-5,16,-18,5,-29,26,-12,-11,25,-18,-38,-10,-36,39,-10,-2,8,-18,-15,2,4,16,-35,8,-21,15,-17,0,-15,22,-14,1,7,13,-11,8,-11,-21,-12,-3,9,3,8,20,16,3,12,-16,-5,18,-51,27,-874,-3,1,-274,214,-5,0,32,40,-1,4,-18,-23,-61,-1,-40,183,0,-2,-83,-26,-87,-77,1,1,18,-218,16,5,135,-132,0,3,59,-88,0,-3,-47,16,-11,-11,-1,0,-79,72,0,-8,-39,-62,-52,-8,-165]]]},{"geoid":"1301248","name":"Aldora","full":"Aldora town","lsad":"town","polygons":[[Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"GU","state":"Guam","fips":"66","features":[{"geoid":"6600100","name":"Adacao","full":"Adacao CDP","lsad":"CDP","cdp":true,"polygons":[[[1448328,134840,167,0,76,76,-2,80,-83,-20,-48,11,-92,-8,-18,-133]]]},{"geoid":"6600850","name":"Afame","full":"Afame CDP","lsad":"CDP","cdp":true,"polygons":[[[1447609,134564,-11,-21,20,-1,27,-22,12,1,9,44,-5,35,-53,56,-27,-20,-18,10]]]},{"geoid":"6604000","name":"Agana Heights","full":"Agana Heights CDP","lsad":"CDP","cdp":true,"polygons":[[[1447417,134685,5,-30,-14,-33,-21,-8,-3,-20,20,-34,116,11,2,102,-8,30,-41,17,-94,0,-56,34]]]},{"geoid":"6607600","name":"Anao","full":"Anao CDP","lsad":"CDP","cdp":true,"polygons":[[[1448709,134861,60,36,45,13,23,-3,30,15,53,61,-7,11,-1,51,6,18,42,7,11,22,45,32,42,8,20,16,29,8,15,-5,31,11,50,27,32,6,32,25,45,109,10,10,1,50,54,91,-212,88,-35,-129,-28,-16,-12,-38,-20,-22,-46,-86,-70,-38,-184,-241,-60,-36,-2,-39,-18,-18]]]},{"geoid":"6607900","name":"Andersen AFB","full":"Andersen AFB CDP","lsad":"CDP","cdp":true,"polygons":[[[1448992,135634,385,-154,25,46,-18,83,19,72,38,21,30,28,-53,35,-40,-17,-14,29,-178,-78,-63,13,-129,-68]]]},{"geoid":"6609500","name":"Apotgan","full":"Apotgan CDP","lsad":"CDP","cdp":true,"polygons":[[[1447705,134811,27,9,70,-2,54,26,-16,31,-23,101,-119,4,3,-28,44,-10,7,-33,-16,-41,-38,-51]]]},{"geoid":"6609850","name":"Apra Harbor","full":"Apra Harbor CDP","lsad":"CDP","cdp":true,"polygons":[[[1446183,134458,17,-19,8,-24,34,-38,87,-33,56,-51,19,-62,-6,-21,24,-21,56,-11,10,-24,34,1,35,-9,28,-21,28,-42,-1,-38,16,-9,6,-25,110,-2,8,-50,29,27,25,7,5,-24,27,26,30,-12,17,59,-6,10,-49,7,-8,11,-20,-2,-44,38,7,28,19,30,-10,31,-17,22,20,25,-14,40,-93,82,-2,94,-428,90,-59,-70]]]},{"geoid":"6612450","name":"Asan","full":"Asan CDP","lsad":"CDP","cdp":true,"polygons":[[[1447105,134690,22,9,13,-11,17,13,21,-3,49,29,10,19,-3,15,-5,8,-58,-35,-39,3,4,-9,-34,-8]]]},{"geoid":"6615800","name":"Astumbo","full":"Astumbo CDP","lsad":"CDP","cdp":true,"polygons":[[[1448342,135417,201,-2,6,16,1,144,-143,0,-45,-79,-29,-74]]]},{"geoid":"6617000","name":"Barrigada","full":"Barrigada CDP","lsad":"CDP","cdp":true,"polygons":[[[1447896,134576,9,-14,25,-8,11,22,50,5,3,-10,82,0,-2,122,-77,11,-77,26,6,-21,-22,-64,9,-14,-12,-27]]]},{"geoid":"6618300","name":"Barrigada Heights","full":"Barrigada Heights CDP","lsad":"CDP","cdp":true,"polygons":[[[1448182,134843,146,-3,18,157,-95,-5,-16,20,-33,-27,19,-38,-72,-34,18,-48,-29,-31]]]},{"geoid":"6624500","name":"Chaguian","full":"Chaguian CDP","lsad":"CDP","cdp":true,"polygons":[[[1448718,135858,16,-87,25,-15,3,-203,144,-1,20,-31,19,-6,49,129,-30,88,-72,93,-108,29,-56,-2]]]},{"geoid":"6625450","name":"Chalan Pago","full":"Chalan Pago CDP","lsad":"CDP","cdp":true,"polygons":[[[1447623,134328,26,-29,8,-34,34,0,-7,-14,11,-9,39,-10,17,5,-4,-10,18,1,2,-11,15,2,-3,-17,28,7,12,21,20,-14,20,26,69,23,-2,114,-30,20,0,8,-39,2,-7,62,-21,-21,-39,-4,-24,12,-69,-2,-18,-21,-1,-19,-27,-5,-26,-23,5,-19]]]},{"geoid":"6627400","name":"Dededo","full":"Dededo CDP","lsad":"CDP","cdp":true,"polygons":[[[1448348,135155,57,-13,32,1,21,94,24,44,-23,2,0,7,-44,13,-105,1,-7,11,-21,-14,-1,-14,-26,-2,-3,-20,-18,-29]]]},{"geoid":"6629350","name":"Finegayan Station","full":"Finegayan Station CDP","lsad":"CDP","cdp":true,"polygons":[[[1448330,135654,-4,-14,61,26,50,-34,61,116,89,109,46,28,39,2,-37,41,-29,55,-238,-2,-8,-69,-15,-38,-35,-53,8,-35,-11,-23,-20,-1,-52,-46]],[[1448113,135512,212,-111,48,115,-179,117,-22,-50,-52,-36,2,-20]]]},{"geoid":"6634650","name":"Hågat","full":"Hågat CDP","lsad":"CDP","cdp":true,"polygons":[[[1446524,133728,25,-35,138,1,0,36,10,17,-7,37,5,28,-35,100,35,12,-18,14,2,34,-18,9,-19,27,-8,3,-40,-118,-35,-31,-40,-80]]]},{"geoid":"6634750","name":"Hagåtña","full":"Hagåtña CDP","lsad":"CDP","cdp":true,"polygons":[[[1447286,134790,32,-10,-7,-19,16,-12,3,10,52,3,12,-10,58,-2,21,-30,41,-17,8,-30,19,2,22,-29,18,-10,33,30,-18,27,3,15,-15,36,6,24,38,3,48,29,-65,-19,-65,-4,-5,32,-10,1,-23,-33,-70,-1,-51,15,-74,-5,-20,23,-7,-9]]]},{"geoid":"6635000","name":"Harmon Industrial Park","full":"Harmon Industrial Park CDP","lsad":"CDP","cdp":true,"polygons":[[[1447942,134947,40,-31,22,1,77,41,78,30,42,-1,-3,35,15,29,-16,43,-190,-69,-53,-60]]]},{"geoid":"6635500","name":"Inalåhan","full":"Inalåhan CDP","lsad":"CDP","cdp":true,"polygons":[[[1447221,132754,55,-60,-1,-15,52,2,-13,-23,61,-25,36,-1,10,10,-2,19,-38,9,-6,12,55,11,72,34,0,11,-28,28,2,19,23,6,35,-35,10,0,46,38,2,12,-10,26,-41,41,-21,8,-122,16,-21,-31,-2,-77,-19,-3,-8,13,-62,12,-24,-2,-20,19,-16,-65]]]},{"geoid":"6635525","name":"Humåtak","full":"Humåtak CDP","lsad":"CDP","cdp":true,"polygons":[[[1446576,132902,16,-34,32,-19,-2,-12,8,20,11,-1,74,82,-30,38,-6,-6,-13,10,8,18,23,16,-18,1,-40,-17,-56,3,44,-12,2,-13,-54,-47]]]},{"geoid":"6636600","name":"Ipan","full":"Ipan CDP","lsad":"CDP","cdp":true,"polygons":[[[1447611,133523,25,-56,40,-54,46,4,-5,207,-14,33,-16,4,-63,-42,-13,-81]]]},{"geoid":"6641300","name":"Liguan","full":"Liguan CDP","lsad":"CDP","cdp":true,"polyLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"HI","state":"Hawaii","fips":"15","features":[{"geoid":"1500400","name":"Ahuimanu","full":"Ahuimanu CDP","lsad":"CDP","cdp":true,"polygons":[[[-1578627,214343,41,-62,80,-20,43,69,15,-30,88,-50,48,50,-10,20,-2,31,10,10,19,12,61,-45,11,10,7,46,-10,77,-137,99,-2,-14,14,-29,-48,-69,-21,-7,-7,-19,-56,-12,-33,15,-54,-22,-53,-46]]]},{"geoid":"1500550","name":"Aiea","full":"Aiea CDP","lsad":"CDP","cdp":true,"polygons":[[[-1579435,213765,51,-41,37,36,2,8,9,-21,10,-8,4,8,19,18,10,-7,38,23,-23,10,-3,5,8,-6,14,6,13,13,10,3,10,11,19,2,12,6,2,12,20,15,42,6,22,11,20,23,36,17,17,18,36,8,-22,14,-24,25,-36,14,-16,-15,-28,-1,-18,-9,-28,-3,-8,-16,-12,2,-15,-13,-15,-21,-11,1,-43,-10,-34,-16,-10,-25,-16,-15,-7,-4,-6,-9,-14,-5,-6,-13,-26,-14,-23,-1,-31,-20]]]},{"geoid":"1501085","name":"Ainaloa","full":"Ainaloa CDP","lsad":"CDP","cdp":true,"polygons":[[[-1550071,195045,204,142,133,219,-4,11,-11,17,-111,-104,-106,-79,-171,-94]]]},{"geoid":"1502200","name":"Anahola","full":"Anahola CDP","lsad":"CDP","cdp":true,"polygons":[[[-1593387,221441,123,-56,38,10,16,-15,2,-27,-7,-7,-44,6,-21,-7,-6,17,-6,-3,9,-6,-7,-18,173,-15,56,-36,40,-5,-9,40,68,41,2,58,-9,19,22,10,15,-21,-1,17,-31,53,-26,-20,-38,-4,-36,31,14,71,22,26,-37,47,-25,-28,3,-11,-98,14,13,-23,-19,-39,-96,-4,-5,-10,-45,-5,-13,-18,-23,5,0,-42]]]},{"geoid":"1502832","name":"Black Sands","full":"Black Sands CDP","lsad":"CDP","cdp":true,"polygons":[[[-1549683,194120,95,-58,45,63,14,-9,15,56,-11,24,-18,14,-15,1,-7,-21,-20,-9,-20,-24,-22,3,-18,-12,-15,0]]]},{"geoid":"1503850","name":"Captain Cook","full":"Captain Cook CDP","lsad":"CDP","cdp":true,"polygons":[[[-1559499,195023,19,-34,9,-49,23,-33,15,-63,34,-2,20,11,-21,19,41,15,111,-41,20,11,-8,12,21,16,14,-3,34,22,44,-86,46,-12,31,-25,38,-41,19,-38,36,15,0,26,-13,21,6,12,-16,17,26,-2,80,-114,127,-73,18,-23,-20,45,-67,88,-32,58,111,44,-45,110,-68,25,-20,0,41,34,61,27,46,8,23,21,78,3,71,16,89,4,56,-8,32,21,-6,39,47,142,-103,5,-53,-28,-46,-2,-83,-27,-105,-16,-51,-25,-93,-14,-77,-35,-24,-4,-18,12,-27,-6,0,16,-31,-2,-161,-47,-2,29,-33,-11,-24,12,-38,-12,-44,5,-57,-10,-2,-15,14,-26,-97,-14]]]},{"geoid":"1505900","name":"Discovery Harbour","full":"Discovery Harbour CDP","lsad":"CDP","cdp":true,"polygons":[[[-1556402,190337,135,-152,54,107,79,109,59,12,-16,77,-56,123,-47,-19,-61,-41,-44,-5,11,-20,-164,-114]]]},{"geoid":"1506290","name":"East Honolulu","full":"East Honolulu CDP","lsad":"CDP","cdp":true,"polygons":[[[-1577830,212787,21,-8,-6,-17,13,-35,34,-38,13,16,111,47,49,6,8,-18,29,-12,13,33,109,36,90,0,24,23,53,-12,45,6,30,30,67,-35,32,-34,8,-84,-22,-41,-6,-30,22,-23,106,10,72,36,-45,29,0,45,27,13,40,-40,35,68,89,49,31,46,40,1,13,22,105,57,33,47,37,8,21,59,-2,59,-7,1,-17,-21,-25,8,-64,-13,-76,44,-43,47,-60,30,-65,-3,-5,11,-22,6,-35,-14,-40,2,-20,-14,-76,-24,-52,16,-10,-12,-61,1,-21,13,-24,2,-45,55,-64,-14,-48,3,-107,29,-14,13,-35,6,-31,28,-23,7,-107,-165,-9,-55,-26,-33,-49,-19,-34,-31,-32,-178]]]},{"geoid":"1506300","name":"East Kapolei","full":"East Kapolei CDP","lsad":"CDP","cdp":true,"polygons":[[[-1580601,213663,69,-55,23,-27,16,-45,-10,-66,-32,-48,7,-30,89,37,93,85,62,19,-12,51,31,77,4,72,-13,42,-48,61,-88,-49,-93,-39,-70,-60]]]},{"geoid":"1506325","name":"Eden Roc","full":"Eden Roc CDP","lsad":"CDP","cdp":true,"polygons":[[[-1551192,195055,267,-444,219,121,-308,511,-25,23,-55,-102,-63,-71]]]},{"geoid":"1507000","name":"Eleele","full":"Eleele CDP","lsad":"CDP","cdp":true,"polygons":[[[-1595877,218988,42,-6,0,-17,26,-1,-60,42,57,-12,24,64,24,-1,23,51,-17,17,39,30,41,11,15,15,-25,3,-5,19,-21,-26,-55,-2,-58,-23,-23,-38,0,-22,-17,-47,-36,-15]]]},{"geoid":"1507450","name":"Ewa Beach","full":"Ewa Beach CDP","lsad":"CDP","cdp":true,"polygons":[[[-1580213,213071,311,69,-7,14,-124,-27,-13,56,97,104,-45,31,-64,-75,-113,-8,42,-69,-63,-67,-7,6,-25,-27]]]},{"geoid":"1507470","name":"Ewa Gentry","full":"Ewa Gentry CDP","lsad":"CDP","cdp":true,"polygons":[[[-1580482,213319,63,-3,17,-39,26,-15,27,-51,106,41,21,28,32,16,4,-5,67,13,28,-3,29,28,-38,15,-62,49,-41,65,-11,45,-40,-10,-20,4,6,-24,-96,-116,-29,-19,-54,-9]]]},{"geoid":"1507485","name":"Ewa Villages","full":"Ewa Villages CDP","lsad":"CDP","cdp":true,"polygons":[[[-1580487,213318,94,20,29,19,96,116,-16,60,-62,-19,-93,-85,-89,-37]]]},{"geoid":"1507542","name":"Fern Acres","full":"Fern Acres CDP","lsad":"CDP","cdp":true,"polygons":[[[-1551039,195266,25,-23,308,-511,194,96,-387,640,-46,-51,-28,-51,-46,-57]]]},{"geoid":"1507675","name":"Fern Forest","full":"Fern Forest CDP","lsad":"CDP","cdp":true,"polygons":[[[-1551757,194545,117,-195,419,104,271,150,25,7,-267,444,-27,-11,-44,-43,-18,-37,-17,-12,-25,-57,-90,-109,-326,-237]]]},{"geoid":"1508950","name":"Haena","full":"Haena CDP","lsad":"CDP","cdp":true,"polygons":[[[-1595758,222112,16,-11,26,-3,38,17,2,14,19,9,17,29,-1,20,22,-12,20,-42,111,12,39,17,31,-1,-21,56,-51,32,-45,-3,-55,21,-71,-61,-36,28,-76,1]]]},{"geoid":"1509260","name":"Haiku-Pauwela","full":"Haiku-Pauwela CDP","lsad":"CDP","cdp":true,"polygons":[[[-1563392,209358,12,-59,15,1,14,-1Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"IA","state":"Iowa","fips":"19","features":[{"geoid":"1900190","name":"Ackley","full":"Ackley city","lsad":"city","polygons":[[[-930660,425367,196,-4,-1,72,48,-1,0,73,11,0,-1,61,-10,0,1,13,147,42,0,18,-294,-1,-1,18,-48,0,0,-18,-48,0]]]},{"geoid":"1900235","name":"Ackworth","full":"Ackworth city","lsad":"city","polygons":[[[-934814,413642,48,22,0,-96,97,-1,2,145,-99,2,0,-37,-48,1]]]},{"geoid":"1900370","name":"Adair","full":"Adair city","lsad":"city","polygons":[[[-946578,414896,287,0,1,216,-288,1,0,-72]]]},{"geoid":"1900505","name":"Adel","full":"Adel city","lsad":"city","polygons":[[[-940525,416022,48,0,0,-19,157,-3,0,-15,12,2,1,16,177,0,2,-143,52,0,0,-34,48,0,0,35,40,1,-35,61,-34,39,-6,42,-13,26,27,0,40,-14,12,-12,19,1,0,20,-40,14,-36,0,0,10,-29,0,1,33,48,-1,0,13,-30,3,-20,11,0,14,-37,21,39,-1,0,-14,30,-27,72,-3,30,-9,5,-13,28,0,9,9,-1,23,19,3,0,-35,97,0,0,36,97,1,0,-37,96,0,0,67,95,3,-1,42,-142,-3,0,-17,48,1,0,-22,-99,-1,3,39,-49,0,0,-20,-35,5,-35,-6,-30,-16,-46,1,0,45,-11,24,11,3,-96,0,-1,-33,35,-3,0,-11,-164,-15,-8,-10,5,18,36,0,0,54,-57,0,-52,24,-20,-6,-38,22,-25,42,0,-47,-145,1,-1,-161,-51,-16,0,20,8,0,-12,11,0,-32,-48,-12]]]},{"geoid":"1900595","name":"Afton","full":"Afton city","lsad":"city","polygons":[[[-942047,410202,190,1,0,144,-143,0,-28,26,6,-26,-25,0]]]},{"geoid":"1900640","name":"Agency","full":"Agency city","lsad":"city","polygons":[[[-923144,409915,143,0,0,109,-144,1]]]},{"geoid":"1900730","name":"Ainsworth","full":"Ainsworth city","lsad":"city","polygons":[[[-915616,412865,143,-1,0,72,-142,6]]]},{"geoid":"1900775","name":"Akron","full":"Akron city","lsad":"city","polygons":[[[-965682,428157,169,2,-12,19,21,-1,7,16,-37,0,-24,32,87,2,-1,144,-36,2,-13,-12,-19,-1,-22,7,-24,28,-20,-1,-22,-31,-3,-52,-25,-7,-11,13,0,-93,22,1]]]},{"geoid":"1900865","name":"Albert City","full":"Albert City city","lsad":"city","polygons":[[[-949532,427707,49,0,0,61,36,0,-5,11,18,0,0,113,-28,0,-5,-39,-15,18,-21,53,-30,0]]]},{"geoid":"1900910","name":"Albia","full":"Albia city","lsad":"city","polygons":[[[-928231,410245,25,-12,39,30,-8,-21,8,-19,48,-16,0,-32,-23,0,0,-17,-24,0,0,-37,29,0,3,-14,15,0,1,-23,-48,0,0,-27,68,0,-36,-46,64,1,0,37,-21,24,1,48,115,0,-1,33,70,1,0,75,71,1,-1,73,-48,0,-1,73,-64,0,14,73,-94,-1,1,-36,-94,-1,1,-36,-96,0,3,-105]]]},{"geoid":"1900955","name":"Albion","full":"Albion city","lsad":"city","polygons":[[[-929958,421065,145,3,0,117,-146,-2]]]},{"geoid":"1901000","name":"Alburnett","full":"Alburnett city","lsad":"city","polygons":[[[-916287,421495,47,0,-1,-37,17,0,-61,-65,98,-8,-9,37,58,0,3,145,-150,-4]]]},{"geoid":"1901045","name":"Alden","full":"Alden city","lsad":"city","polygons":[[[-934263,424928,34,1,1,15,32,21,18,37,267,27,1,-31,101,-1,-1,38,112,-2,0,27,15,9,36,0,-5,184,-146,0,0,-14,-13,5,1,-36,-19,6,-7,-39,24,-17,-16,-16,19,0,-1,-101,-454,-42]]]},{"geoid":"1901090","name":"Alexander","full":"Alexander city","lsad":"city","polygons":[[[-934987,427894,401,-3,0,304,-401,2]]]},{"geoid":"1901135","name":"Algona","full":"Algona city","lsad":"city","polygons":[[[-942767,430795,0,-59,91,0,13,-8,0,13,26,-9,0,12,-26,0,0,79,7,6,84,-1,0,-12,49,0,0,-15,-49,-1,0,-21,59,-1,0,50,36,-7,10,7,1,-87,18,0,-22,-5,4,-37,-19,-31,-46,-26,-20,-1,5,-8,80,-1,0,6,19,-6,-8,-15,41,-11,7,10,58,-19,38,0,9,-28,31,-1,0,-13,30,-1,0,18,13,1,-25,24,129,-2,1,72,48,-1,0,8,34,4,2,85,16,8,49,2,1,55,-10,0,0,-17,-14,0,-1,34,-25,0,-1,19,-347,0,4,40,-26,0,1,-40,-74,0,0,-21,-18,0,0,11,-29,0,0,-11,-51,0,0,-6,-20,6,-79,0,0,-14,-156,0]]]},{"geoid":"1901180","name":"Alleman","full":"Alleman city","lsad":"city","polygons":[[[-936392,418131,48,0,0,-9,97,-1,0,-63,145,0,1,-73,98,0,0,49,16,0,0,17,32,1,1,224,-243,-1,1,-55,-14,19,-84,0,0,-36,-97,0]]]},{"geoid":"1901270","name":"Allerton","full":"Allerton city","lsad":"city","polygons":[[[-933795,406994,167,0,1,36,48,0,2,108,-26,0,-1,16,-189,2]]]},{"geoid":"1901315","name":"Allison","full":"Allison city","lsad":"city","polygons":[[[-928101,427375,288,0,-2,291,-285,0]]]},{"geoid":"1901495","name":"Alta","full":"Alta city","lsad":"city","polygons":[[[-953139,426658,48,-1,-1,-36,99,0,0,37,50,0,0,91,15,0,0,18,-63,-1,-1,36,-147,1]]]},{"geoid":"1901540","name":"Alta Vista","full":"Alta Vista city","lsad":"city","polygons":[[[-924269,431912,199,0,-1,109,-197,1]]]},{"geoid":"1901585","name":"Alton","full":"Alton city","lsad":"city","polygons":[[[-960184,429894,10,-13,-11,-9,-34,0,-1,-47,45,1,1,-73,199,0,-3,220,-66,1,8,22,-38,-15,0,-7,-73,0,0,8,24,4,0,46,-49,0,20,-58,-20,0,-1,-35,-99,6,-1,-21,-13,-25]]]},{"geoid":"1901630","name":"Altoona","full":"Altoona city","lsad":"city","polygons":[[[-935224,416604,14,-2,-13,-16,24,0,50,-72,35,-35,82,-147,31,-36,70,0,47,84,47,47,-1,-59,74,0,0,-18,19,0,3,-91,-48,0,-1,-72,48,1,0,-109,48,6,0,-6,150,0,-2,72,-48,0,0,37,144,0,0,109,96,0,0,127,-14,0,0,17,-34,0,-7,36,-42,1,0,235,-121,-21,-41,-34,-32,0,0,12,-142,-47,0,70,-48,0,0,36,-12,0,12,7,-2,101,-97,1,0,119,-96,-1,-1,-124,-55,0,2,22,-43,-28,1,-95,-96,0]]]},{"geoid":"1901675","name":"Alvord","full":"Alvord city"Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"ID","state":"Idaho","fips":"16","features":[{"geoid":"1600100","name":"Aberdeen","full":"Aberdeen city","lsad":"city","polygons":[[[-1128487,429369,199,1,0,32,29,1,15,17,-14,20,-15,0,13,-16,-28,-12,1,102,-199,0]]]},{"geoid":"1600280","name":"Acequia","full":"Acequia city","lsad":"city","polygons":[[[-1136040,426634,142,-1,4,109,-146,0,0,-98]]]},{"geoid":"1601000","name":"Albion","full":"Albion city","lsad":"city","polygons":[[[-1135888,424119,39,0,0,-73,34,0,8,-17,23,0,-1,-19,38,0,9,22,-46,0,0,14,33,0,0,35,-28,6,5,22,29,4,0,20,24,12,-28,-5,0,16,-138,0]]]},{"geoid":"1601900","name":"American Falls","full":"American Falls city","lsad":"city","polygons":[[[-1128741,427736,12,-6,138,-1,-10,-23,18,-11,38,34,52,1,0,19,49,-2,-2,17,-49,0,93,93,-9,27,17,8,22,-4,1,16,27,12,-8,10,-66,-33,-5,16,-22,0,0,38,47,0,-4,17,-44,0,-12,20,-24,-11,-10,-25,-26,2,-9,-16,-37,11,-7,-34,-37,-80,-15,-13,-110,-36,0,-23,-10,-13]]]},{"geoid":"1601990","name":"Ammon","full":"Ammon city","lsad":"city","polygons":[[[-1119839,434461,257,0,0,-8,-25,-1,-13,-19,-20,-3,0,-8,18,1,-6,-35,38,1,0,31,20,0,0,-17,-15,0,0,-14,22,-1,34,35,-9,16,4,22,14,0,-3,-17,22,-15,47,-1,-5,-30,17,4,2,38,-11,21,-40,0,0,39,46,6,-37,27,1,44,-27,-10,-2,40,21,30,-5,26,-35,1,-15,9,0,55,98,5,0,-10,13,0,1,-15,-14,-4,0,-33,15,-1,23,38,31,0,-10,13,40,-4,0,-10,34,4,22,-6,0,-13,59,7,0,-8,44,-4,-9,18,10,9,-12,1,1,27,-78,-8,0,33,48,19,46,1,0,-8,50,7,0,-37,25,0,5,-72,-45,0,-1,6,-8,-6,14,-13,-9,-24,2,-36,95,1,1,71,44,-13,0,50,-10,14,38,2,3,13,-13,7,-78,0,16,10,10,62,-37,16,-34,-5,-53,-27,-98,-5,5,-14,-31,-1,-21,27,21,2,0,35,-9,7,-94,-3,0,10,-51,0,0,-45,-21,0,1,60,-54,0,25,12,0,60,-7,13,-58,0,0,-13,12,0,0,-60,-97,1,0,6,-49,-6,0,66,-9,6,7,78,53,0,0,72,-58,-6,0,-10,-17,0,10,17,-19,0,0,-8,-29,-9,-32,0,-21,9,0,8,28,0,4,10,-18,6,0,55,-45,1,-1,-70,29,-2,0,-8,-29,-3,1,-62,96,0,-20,-31,-30,-11,-35,-34,-11,0,-1,-96]]]},{"geoid":"1602935","name":"Arbon Valley","full":"Arbon Valley CDP","lsad":"CDP","cdp":true,"polygons":[[[-1126632,428981,24,-20,23,0,32,-41,57,-33,52,-5,26,-38,12,-3,-3,-25,25,-14,29,-2,0,-9,37,-11,-4,-58,-42,-42,-8,-23,-22,5,-37,-11,-22,-12,-15,-20,-2,-20,12,-16,1,-36,-21,-40,26,-15,-12,-14,8,-59,39,-16,22,-20,14,-32,15,-7,121,1,0,128,88,-1,23,-22,-2,-109,171,53,57,1,123,-25,124,-12,104,14,22,15,12,-2,11,14,18,-29,23,5,48,-24,27,-2,45,16,-6,906,-14,7,-85,4,-4,57,-15,12,-1162,3],[-1126317,429027,172,-1,0,196,42,-1,0,12,26,0,0,39,55,0,1,-29,24,0,0,-18,247,0,-1,52,34,1,-1,17,21,0,0,20,95,-1,0,-107,-95,0,-1,-40,44,21,2,-158,-541,-125,0,12,-50,1,0,36,-76,1]]]},{"geoid":"1603160","name":"Arco","full":"Arco city","lsad":"city","polygons":[[[-1133112,436310,39,0,-9,-5,0,-30,14,-1,0,-36,28,0,0,-9,-22,-1,0,-60,58,0,-5,9,44,14,3,119,50,0,0,36,-50,0,0,36,50,0,0,36,-117,0,-29,21,-14,-4,14,-11,-15,-5,0,-29,-31,0,-7,-8]]]},{"geoid":"1603340","name":"Arimo","full":"Arimo city","lsad":"city","polygons":[[[-1121781,425642,16,-26,-1,-73,25,0,49,-36,25,36,73,-1,0,17,-78,0,-56,129,-17,1,-11,24,-14,0,5,-37,-18,-1]]]},{"geoid":"1603610","name":"Ashton","full":"Ashton city","lsad":"city","polygons":[[[-1114571,440700,12,0,4,-22,148,0,0,35,49,0,0,37,-101,9,1,28,-52,0,0,25,-49,0,-6,-93]]]},{"geoid":"1603700","name":"Athol","full":"Athol city","lsad":"city","polygons":[[[-1167202,479475,27,2,-1,-71,80,-1,-1,-72,54,0,2,72,51,1,1,36,23,0,-6,36,78,1,18,25,0,8,-85,0,-23,36,-4,-30,-133,-4,18,55,-13,36,-27,20,-5,-74,-55,0]]]},{"geoid":"1604105","name":"Avimor","full":"Avimor CDP","lsad":"CDP","cdp":true,"polygons":[[[-1162650,437634,117,8,7,14,-13,0,0,23,-26,15,32,13,-43,29,-42,-6,-1,-9,-28,5,-5,11,-10,-56]]]},{"geoid":"1604420","name":"Bancroft","full":"Bancroft city","lsad":"city","polygons":[[[-1118918,427145,92,4,18,12,1,-43,36,0,0,25,49,0,-1,74,-36,0,-1,53,-11,-18,-147,0]]]},{"geoid":"1604600","name":"Banks","full":"Banks CDP","lsad":"CDP","cdp":true,"polygons":[[[-1161415,440791,31,-76,-20,-8,-3,-14,24,-51,2,-22,49,-18,14,-18,47,-7,46,56,18,50,12,14,6,36,-27,50,5,29,15,14,45,16,-5,1,35,47,-24,17,-12,31,-26,20,-31,10,-12,12,-43,10,1,-63,-61,-39,-27,-35,-10,-27,-36,-16]]]},{"geoid":"1605230","name":"Basalt","full":"Basalt city","lsad":"city","polygons":[[[-1121731,433115,99,0,1,-53,-21,-19,34,0,-3,19,-7,0,-2,53,30,0,1,73,-76,9,-22,-25,-3,-29]]]},{"geoid":"1606220","name":"Bellevue","full":"Bellevue city","lsad":"city","polygons":[[[-1142721,434717,50,-36,0,-56,25,-1,48,-106,42,11,9,-22,31,2,-20,24,14,4,-21,14,-28,47,82,20,0,-24,16,0,0,31,34,6,-11,23,-23,6,0,36,21,0,27,53,22,11,29,32,0,12,16,7,0,16,19,16,13,33,47,70,-12,4,-19,-18,-52,-81,-62,-70,0,-13,-14,0,-44,-46,-9,21,-80,-10,-22,24,16,-34,-46,-11,-82,85,-19,-4,11,-24,-13,-22]]]},{"geoid":"1606670","name":"Bennington","full":"Bennington CDP","lsad":"CDP","cdp":true,"polygons":[[[-1113556,424005,13,-17,4,-22,-5,-38,6,-40,30,-54,36,-47,40,-19,6,-30,10,-4,-3,-18,15,-25,17,-11,-6,-15,19,-7,21,7,54,-39,28,4,25,-10,52,5,77,35,16,-4,11,-17,70,14,60,31,28,33,24,-3,26,26,-20,98,-46,26,-64,19,-62,-3,1,129,-470,2]]]},{"geoid":"1607840","name":"Blackfoot","fLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"IL","state":"Illinois","fips":"17","features":[{"geoid":"1700113","name":"Abingdon","full":"Abingdon city","lsad":"city","polygons":[[[-904141,407981,94,1,0,-37,-95,1,-1,-31,42,16,54,0,0,13,120,1,-8,18,-17,0,1,23,48,1,1,28,42,0,1,37,-48,-1,0,74,-35,8,24,17,-1,12,-30,0,-1,-37,-36,0,0,7,-28,4,0,-10,-104,-1,-2,-33,38,0,0,10,31,2,-5,-18,-42,3,0,-27,-16,0,0,-21,-32,0]]]},{"geoid":"1700178","name":"Adair","full":"Adair CDP","lsad":"CDP","cdp":true,"polygons":[[[-905059,404137,97,-4,-7,37,12,0,16,-27,27,-21,2,120,-106,0,25,-11,-1,-19,-23,-29,-41,0,-1,-10]]]},{"geoid":"1700191","name":"Adams","full":"Adams CDP","lsad":"CDP","cdp":true,"polygons":[[[-912135,398602,283,-3,2,192,-51,-2,-7,11,-25,-1,-12,-11,-17,27,-22,-9,-1,21,-15,-5,-8,9,-29,-14,-93,41]]]},{"geoid":"1700230","name":"Addieville","full":"Addieville village","lsad":"village","polygons":[[[-894951,383842,180,-5,-3,144,-146,4,0,12,-37,-2]]]},{"geoid":"1700243","name":"Addison","full":"Addison village","lsad":"village","polygons":[[[-880518,419283,37,5,4,-6,5,7,0,-14,13,1,-1,-13,-28,0,0,12,-28,-1,-5,-38,97,2,-1,-38,-26,4,0,-18,27,1,9,7,-7,6,99,-26,-1,-40,-12,0,-2,-17,-11,0,-1,-7,26,1,0,-6,-61,-2,-3,-24,63,1,0,-10,30,0,19,12,-1,34,26,2,-1,33,48,-12,-15,16,89,2,-2,37,49,1,4,-95,99,-26,-11,-5,0,-19,61,1,-1,21,15,0,0,20,46,-3,0,6,36,-11,-2,43,49,1,1,-23,14,1,1,-27,35,-5,-2,55,24,0,-23,59,45,0,3,-58,17,-2,-5,-10,13,0,-1,106,-19,3,-14,-32,-45,0,-4,7,-36,-8,1,6,18,1,-6,12,19,-5,23,12,-13,4,0,13,-46,30,9,21,85,-49,0,16,-43,19,41,1,2,-14,26,-9,2,-10,-4,92,-18,0,-4,47,-40,4,-1,32,40,-13,20,24,20,0,8,13,5,-17,25,0,17,31,-70,-3,-42,-11,2,9,-70,-2,11,47,-121,-3,0,-67,-263,123,-75,-31,44,23,0,12,-60,22,-36,-10,-17,-37,18,0,0,-20,-30,-8,-71,30,-29,2,-12,-13,1,14,-91,14,10,-30,-16,-5,17,-5,1,-25,-14,-3,41,2,1,-7,-35,-1,-7,-21,41,1,-1,-82,50,2,-1,-45,-51,7,-14,-19,-34,0,-2,-9]]]},{"geoid":"1700295","name":"Adeline","full":"Adeline village","lsad":"village","polygons":[[[-894969,421375,41,0,-1,-17,31,3,29,14,0,55,-28,16,-72,-3]]]},{"geoid":"1700516","name":"Albany","full":"Albany village","lsad":"village","polygons":[[[-902346,417799,272,-2,0,73,49,1,21,38,-48,-1,0,33,-22,-5,0,14,-46,-6,-128,-54,-50,-39]]]},{"geoid":"1700555","name":"Albers","full":"Albers village","lsad":"village","polygons":[[[-896400,385320,92,1,10,28,3,55,77,25,7,-15,28,0,-1,-19,39,19,-1,-18,39,0,0,-12,-15,0,-1,-68,25,8,5,68,-6,23,23,1,0,11,37,4,12,11,0,26,-23,0,23,16,0,20,-116,2,-20,16,-52,-1,-1,-17,-34,-1,6,-41,40,9,0,-11,-11,-1,-1,-29,-46,1,-12,-9,-11,11,-9,-16,-15,5,0,-22,-22,-7,22,0,1,-36,-92,-2]]]},{"geoid":"1700568","name":"Albion","full":"Albion city","lsad":"city","polygons":[[[-880793,383758,38,4,-1,-51,63,0,0,-37,19,7,-2,41,12,-10,84,0,-60,-21,0,-70,30,18,79,3,0,-37,47,1,6,63,-92,3,7,40,32,-7,0,8,49,1,2,56,22,0,-18,15,10,2,12,-13,0,17,109,6,-1,-80,57,1,1,79,-23,0,-1,26,-45,-10,0,-9,-125,0,0,58,-32,0,0,18,-11,0,1,-18,-24,0,22,36,16,0,-15,9,6,17,-7,-6,-46,0,1,-56,-48,0,0,-15,-44,0,0,-82,-32,5,-4,53,-55,0,0,39,-46,0,-2,-86]]]},{"geoid":"1700594","name":"Alden","full":"Alden CDP","lsad":"CDP","cdp":true,"polygons":[[[-885232,424586,23,-5,0,-30,35,4,8,22,31,1,-2,18,-26,-5,-7,32,-44,-11,1,-25]]]},{"geoid":"1700646","name":"Aledo","full":"Aledo city","lsad":"city","polygons":[[[-907619,411929,74,-1,-2,-19,-15,1,-10,-18,-25,0,-1,-36,170,-4,2,73,96,-2,2,37,48,-1,-1,-37,48,-1,1,52,49,-1,1,21,-49,1,0,18,-70,2,0,22,-24,1,-1,-21,-80,8,-7,6,1,41,-11,0,2,73,-49,1,0,-18,-23,0,0,-18,-21,1,0,25,-11,-23,-42,1,0,-19,-25,0,1,-31,-25,0,0,-60]]]},{"geoid":"1700659","name":"Alexander","full":"Alexander CDP","lsad":"CDP","cdp":true,"polygons":[[[-900488,397241,1,-88,71,-12,49,1,0,36,144,2,-2,71,-123,1,0,58,-70,0,-3,-58,-96,-11]]]},{"geoid":"1700672","name":"Alexis","full":"Alexis village","lsad":"village","polygons":[[[-905633,410611,23,0,-6,-35,109,-5,2,59,32,1,1,32,-33,0,1,8,36,-1,-14,13,-120,-3,-29,-34]]]},{"geoid":"1700685","name":"Algonquin","full":"Algonquin village","lsad":"village","polygons":[[[-883836,421612,48,0,0,-36,78,0,17,-6,-3,-43,24,7,-22,6,101,0,0,18,49,0,-1,-88,41,0,0,-18,73,-6,0,-12,122,0,0,-22,-10,-15,-19,1,2,-32,-20,0,-3,32,-97,2,1,-60,12,-15,14,7,12,-7,111,-1,-1,36,98,-2,-1,13,12,-14,86,0,-1,109,-42,-1,-8,36,175,6,23,11,-1,17,35,-6,1,10,57,-6,51,4,-1,-71,-25,-9,16,-15,21,6,15,-21,23,0,-19,1,-1,20,20,1,0,18,98,1,0,18,99,2,1,160,-44,0,-1,38,-11,0,7,88,-82,-5,-33,18,-31,-22,-10,17,-37,-24,-45,7,-1,56,13,9,59,4,-1,23,25,0,0,-12,43,0,16,16,4,13,-36,13,-2,15,-50,5,-24,12,0,-18,-191,0,15,-45,127,0,0,-27,-75,0,0,-36,-24,0,-1,-36,-15,0,-18,-14,11,-23,-9,-12,-28,14,4,24,-17,-9,-5,7,-14,-23,-12,17,-17,0,-1,-17,-24,0,0,42,-36,3,-145,-9,-1,-18,-12,0,0,18,-415,0,-12,-17,-44,-1,-13,19,-24,-1,0,-72,-25,0]]]},{"geoid":"1700737","name":"Alhambra","full":"Alhambra village","lsad":"village","polygons":[[[-897498,388847,188,-1,-1,-37,46,0,0,37,22,-1,7,73,-261,1]]]},{"geoid":"1700815","name":"Allendale","full":"Allendale village","lsad":"village","polygons":[[[-877159,385239,108,-1,0,34,17,21,-18,19,-108,1]]]Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"IN","state":"Indiana","fips":"18","features":[{"geoid":"1800140","name":"Aberdeen","full":"Aberdeen CDP","lsad":"CDP","cdp":true,"polygons":[[[-871287,414346,241,0,1,52,23,12,-5,22,-22,7,-28,43,-65,30,0,-21,-144,0]]]},{"geoid":"1800154","name":"Abington","full":"Abington CDP","lsad":"CDP","cdp":true,"polygons":[[[-849720,397320,39,6,-14,-58,77,0,11,18,-8,12,28,57,-14,19,-22,-14,-66,0,-18,8]]]},{"geoid":"1800352","name":"Adams","full":"Adams CDP","lsad":"CDP","cdp":true,"polygons":[[[-855646,393799,0,-24,18,0,-9,7,16,14,22,3,0,17,48,-1,0,22,-48,3,0,29,-46,-13,-16,-57]]]},{"geoid":"1800532","name":"Adams Lake","full":"Adams Lake CDP","lsad":"CDP","cdp":true,"polygons":[[[-853425,415480,93,0,17,-14,0,12,70,2,0,28,49,6,19,30,-26,42,-23,18,-36,3,-6,-11,-39,-2,-53,-24,-17,1,0,-18,-48,-1]]]},{"geoid":"1800640","name":"Advance","full":"Advance town","lsad":"town","polygons":[[[-866293,399939,24,0,0,-36,53,-1,0,-18,17,0,0,17,71,1,5,54,16,37,-20,-7,0,24,-141,1,-1,-54,-24,0]]]},{"geoid":"1800748","name":"Akron","full":"Akron town","lsad":"town","polygons":[[[-860330,410361,192,-38,-1,46,-40,5,0,11,20,0,0,35,-75,-2,-23,13,-1,18,-25,0,0,-24,-47,-1]]]},{"geoid":"1800784","name":"Alamo","full":"Alamo town","lsad":"town","polygons":[[[-870577,399817,49,0,2,34,-52,0]]]},{"geoid":"1800802","name":"Albany","full":"Albany town","lsad":"town","polygons":[[[-852562,403004,17,-8,0,-10,42,0,0,-26,224,4,37,9,0,24,42,0,14,-9,0,14,40,-1,-1,35,-40,8,-2,112,-48,0,-1,55,-94,-1,0,-68,47,0,0,-9,-47,2,0,-34,-47,0,-15,-17,-27,-1,0,-19,-47,0,0,-32,-53,0,0,30,-41,0]]]},{"geoid":"1800820","name":"Albion","full":"Albion town","lsad":"town","polygons":[[[-854379,413956,33,0,1,-37,26,-2,-2,-34,80,2,0,-12,26,1,-4,-28,32,0,-1,-66,24,0,1,101,33,0,0,9,44,-2,-2,14,82,-5,0,21,47,1,3,40,-168,0,-9,16,-19,-1,-2,59,53,0,0,73,-137,-1,1,6,-32,0,0,-21,17,0,-1,-42,-33,0,0,-18,34,0,0,-22,-34,0,0,-37,-48,0,-1,24,-45,11]]]},{"geoid":"1800910","name":"Alexandria","full":"Alexandria city","lsad":"city","polygons":[[[-856912,402666,5,-19,-18,-17,22,-8,0,-18,14,-10,18,0,0,20,16,-3,0,-90,50,1,-2,-146,93,1,4,-37,43,0,-15,37,67,0,-1,37,-48,-1,0,51,-50,4,11,1,0,17,38,0,0,11,-37,0,11,27,25,0,-1,35,-17,1,0,21,-30,7,0,10,4,12,14,-5,11,11,-12,6,10,10,20,0,28,54,-28,0,21,12,-16,5,6,39,-31,1,-8,35,-14,0,5,12,-22,1,0,-8,-21,-1,1,22,-19,0,0,-23,-26,1,0,-6,29,1,0,-19,-47,-1,0,18,-47,-1,0,-27,47,0,0,-15,-21,0,0,-30,-40,-1,0,-8,15,-4,-15,-4,14,-5,-107,-1,0,-13,-29,-1]]]},{"geoid":"1800946","name":"Alford","full":"Alford CDP","lsad":"CDP","cdp":true,"polygons":[[[-872493,384877,94,0,8,83,-31,-9,-81,0]]]},{"geoid":"1800964","name":"Alfordsville","full":"Alfordsville town","lsad":"town","polygons":[[[-869490,385581,16,-8,8,6,6,42,-28,13,-18,-29]]]},{"geoid":"1801270","name":"Alton","full":"Alton town","lsad":"town","polygons":[[[-864265,381231,92,-68,37,42,-61,43,-23,-16,-21,18]]]},{"geoid":"1801288","name":"Altona","full":"Altona town","lsad":"town","polygons":[[[-851596,413513,44,-4,-3,-17,49,0,1,12,39,-7,-2,11,13,0,2,8,0,17,-48,-1,0,19,-96,-10]]]},{"geoid":"1801342","name":"Ambia","full":"Ambia town","lsad":"town","polygons":[[[-875193,404862,62,-1,-1,61,-70,0]]]},{"geoid":"1801360","name":"Amboy","full":"Amboy town","lsad":"town","polygons":[[[-859322,405978,18,0,12,-21,22,3,1,18,52,-1,0,31,19,5,-15,-1,0,25,-58,27,-16,-14,-6,35,-27,0,-1,-37,29,4,0,-10,-34,-30]]]},{"geoid":"1801396","name":"Americus","full":"Americus CDP","lsad":"CDP","cdp":true,"polygons":[[[-867681,405260,41,-40,52,26,10,-15,31,20,-6,8,25,1,0,53,-145,-49]]]},{"geoid":"1801432","name":"Amity","full":"Amity CDP","lsad":"CDP","cdp":true,"polygons":[[[-860099,394316,-12,-31,7,-10,-4,-27,14,-14,29,-4,18,-19,-1,-22,44,2,-1,-32,54,3,-59,101,14,2,1,43,27,40,-118,-4,-10,9,-33,-1]]]},{"geoid":"1801450","name":"Amo","full":"Amo town","lsad":"town","polygons":[[[-866192,396842,57,-5,0,-29,82,1,8,68,-43,3,0,28,-17,6,-11,29,22,7,0,37,-61,0,0,-37,-35,1]]]},{"geoid":"1801468","name":"Anderson","full":"Anderson city","lsad":"city","d":5,"polygons":[[[-8576313,4001956,336,376,290,236,279,173,353,171,345,118,1518,426,-63,-144,590,-262,319,395,561,15,40,244,282,86,23,233,325,87,0,573,474,2,-17,-86,560,49,-1,47,2780,17,-2,-208,-472,2,0,-107,467,-9,9,40,236,0,1,-130,804,3,101,84,-13,-164,46,-150,614,3,-301,563,334,6,-4,-117,203,1,-25,-448,231,-1,0,-108,-230,0,2,-526,444,-8,-1,-97,514,4,-4,313,186,1,-3,426,-187,-1,1,650,247,2,2,-92,358,0,80,-22,63,22,1,98,255,3,0,3659,186,0,145,210,240,146,515,207,277,170,-838,-366,-523,-69,1,33,416,47,357,99,-126,174,194,90,-250,263,-588,-116,-2,937,543,10,0,-190,530,1,218,232,-749,2,-542,-42,0,1124,386,-4,0,-75,206,-64,-21,-157,143,-253,1187,7,0,-581,252,-60,-252,-189,0,-92,248,-1,-3,-175,537,366,-93,159,329,2,438,293,1,67,-384,-3,-99,80,0,659,-2178,15,253,335,-952,-5,1,1459,418,0,3,370,156,-1,0,63,189,0,0,366,-479,0,-180,-142,0,-286,-2314,9,191,215,-21,389,-101,99,111,202,244,0,-1,1415,-130,65,-245,4,16,-229,-518,-10,16,-100,94,-20,-65,-36,-124,-4,1,-79,76,-1,1,-34,136,-1,3,-70,-215,4,8,-348Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"KS","state":"Kansas","fips":"20","features":[{"geoid":"2000100","name":"Abbyville","full":"Abbyville city","lsad":"city","polygons":[[[-982087,379671,68,-1,46,9,0,75,-16,1,0,-25,-69,2,-14,10,-14,-21]]]},{"geoid":"2000125","name":"Abilene","full":"Abilene city","lsad":"city","polygons":[[[-972631,389328,25,0,-1,-31,47,1,-1,-73,140,0,0,-36,-35,0,0,-37,12,0,0,9,23,1,0,-62,135,36,0,-5,32,0,-1,-8,-23,-1,-5,-33,8,-10,34,16,0,21,55,1,0,-37,21,-7,-3,-27,47,-3,0,37,28,0,0,10,47,0,0,62,25,0,-7,52,30,0,0,13,-22,0,-8,81,-18,0,-1,85,-46,0,-24,-12,0,37,70,15,0,57,-94,0,1,-36,-26,0,0,-53,-67,-13,1,-56,-29,-60,-160,-1,0,-9,-47,0,0,64,47,0,0,-18,11,10,29,-10,7,18,0,55,-117,-2,0,-34,-25,0,0,33,-69,-18,-21,-20,1,10,-26,-4]],[[-972421,389007,42,-7,17,-36,35,-1,0,117,-11,0,11,43,-49,-13,0,-31,-45,0]]]},{"geoid":"2000225","name":"Ada","full":"Ada CDP","lsad":"CDP","cdp":true,"polygons":[[[-979029,391647,25,-18,17,-38,15,-7,14,-27,-9,-25,4,-27,37,-4,0,-12,55,-11,41,0,90,-18,4,298,-227,-4,-52,-76,-1,-26]]]},{"geoid":"2000325","name":"Admire","full":"Admire city","lsad":"city","polygons":[[[-961072,386367,104,1,-1,85,-103,-2]]]},{"geoid":"2000475","name":"Agenda","full":"Agenda city","lsad":"city","polygons":[[[-974344,396988,17,0,-4,45,34,-1,1,85,-48,0]]]},{"geoid":"2000550","name":"Agra","full":"Agra city","lsad":"city","polygons":[[[-991257,397605,46,-9,0,-31,48,0,0,39,27,-1,0,34,-60,15,0,-19,-15,0,0,18,-31,1,0,-19,-15,1]]]},{"geoid":"2000825","name":"Albert","full":"Albert city","lsad":"city","polygons":[[[-990146,384494,67,0,2,90,-69,1]]]},{"geoid":"2000950","name":"Alden","full":"Alden city","lsad":"city","polygons":[[[-983153,382437,24,-8,0,-82,7,0,1,66,41,9,12,22,-21,29,-10,-11,-68,10]]]},{"geoid":"2001050","name":"Alexander","full":"Alexander city","lsad":"city","polygons":[[[-995577,384658,92,0,0,24,12,1,-12,15,-1,33,-91,0]]]},{"geoid":"2001275","name":"Allen","full":"Allen city","lsad":"city","polygons":[[[-961748,386516,103,0,-1,72,-100,0]]]},{"geoid":"2001350","name":"Alma","full":"Alma city","lsad":"city","polygons":[[[-962979,390170,37,-1,2,-23,7,0,-1,-36,32,-4,-1,-26,15,5,28,-10,49,-1,1,15,-24,20,0,14,17,0,-16,96,-54,-7,0,8,-23,0,-1,-16,-23,0,-9,-22]]]},{"geoid":"2001425","name":"Almena","full":"Almena city","lsad":"city","polygons":[[[-997209,398925,39,-5,9,-62,141,-1,1,112,-95,0,-30,-22,-65,1]]]},{"geoid":"2001550","name":"Altamont","full":"Altamont city","lsad":"city","polygons":[[[-953131,371813,59,1,1,-30,100,0,0,27,21,0,0,-27,43,0,1,72,9,10,16,0,-2,27,22,-1,0,37,47,-1,0,19,-34,0,9,119,74,0,1,9,-92,-1,-5,-127,-22,0,0,19,-63,-1,-3,59,-6,0,-1,-23,-52,0,2,-36,-46,3,-1,-14,-21,0,-1,-24,32,0,1,-51,-25,0,0,-48,-65,-2]]]},{"geoid":"2001575","name":"Alta Vista","full":"Alta Vista city","lsad":"city","polygons":[[[-964945,388614,35,-18,-13,-16,0,-29,48,-1,0,73,46,0,1,37,-32,0,12,35,-49,1,-15,-19]]]},{"geoid":"2001600","name":"Alton","full":"Alton city","lsad":"city","polygons":[[[-989526,394622,61,0,0,25,26,0,0,51,-15,0,1,36,-71,-2]]]},{"geoid":"2001625","name":"Altoona","full":"Altoona city","lsad":"city","polygons":[[[-956650,375227,-6,-72,73,-1,2,26,-16,0,0,34,13,0,0,13,19,0,-3,72,-16,0,-1,57,-35,2,10,-11,-3,-13,-35,0,-13,-106]]]},{"geoid":"2001675","name":"Americus","full":"Americus city","lsad":"city","polygons":[[[-962715,384999,120,-3,1,-30,43,-8,0,22,-23,1,0,14,46,-1,0,73,24,-1,2,36,-24,1,2,37,-187,3,-5,-70]]]},{"geoid":"2001725","name":"Ames","full":"Ames CDP","lsad":"CDP","cdp":true,"polygons":[[[-974630,395665,247,1,1,40,12,3,-258,7]]]},{"geoid":"2001775","name":"Andale","full":"Andale city","lsad":"city","polygons":[[[-976406,377974,73,-58,-22,-5,1,-35,126,2,1,-18,55,0,0,74,-75,-1,-17,24,-31,-1,0,10,-38,-1]]]},{"geoid":"2001800","name":"Andover","full":"Andover city","lsad":"city","polygons":[[[-971530,376722,40,0,-1,-73,-38,0,-1,-146,174,0,1,-199,-14,11,-2,22,-9,0,0,23,-66,-2,0,-72,184,1,0,72,92,0,-1,145,-179,0,-2,144,44,0,0,-36,23,4,1,32,68,0,0,-36,45,0,1,37,-137,1,0,72,92,0,0,60,16,11,7,-8,0,-56,22,0,1,84,-24,0,1,54,24,0,0,73,8,-1,-9,7,0,131,92,-29,0,170,-38,-25,-54,-18,-5,-35,-26,0,0,-9,-14,0,-1,27,-45,0,0,37,-45,0,-1,37,-10,0,-1,24,-35,-5,0,17,92,0,-1,112,-91,0,0,-33,19,0,1,-11,-20,0,0,-19,22,0,0,-24,-22,0,0,12,-45,0,-1,36,-46,1,0,37,-85,0,-1,-356,39,18,1,45,49,1,-3,-25,47,19,0,-6,-133,-62,0,-218]]]},{"geoid":"2001975","name":"Anthony","full":"Anthony city","lsad":"city","polygons":[[[-980438,371521,21,0,0,-8,24,3,1,-80,12,0,1,13,192,6,0,36,-23,0,0,23,49,10,1,45,-22,0,-4,-19,-24,0,1,118,-92,-1,-1,-54,-30,-18,-34,0,11,35,-30,0,-9,-10,0,47,-45,-1]],[[-980575,371702,90,0,1,56,45,5,18,48,-24,0,-3,-17,-36,0,0,44,-26,0,1,45,-12,0,1,54,11,0,0,18,-64,0,0,-16,11,0,10,-23,-10,-34,-12,0,-1,-17,22,-7,25,-70,-14,-42,-34,0]],[[-980878,371588,31,-6,0,-21,28,-1,16,-28,-1,-15,46,0,0,199,-49,0,4,-125,-70,11]],[[-980482,371230,14,0,1,22,39,0,0,-21,7,0,28,71,2,74,-91,-1]]]},{"geoid":"2002175","name":"Arcadia","full":"Arcadia city","lsad":"city","polygons":[[[-946305,376366,130,1,0,73,-24,0,-6,7,0,30,-6,-10,-29,1,-2Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"KY","state":"Kentucky","fips":"21","features":[{"geoid":"2100298","name":"Adairville","full":"Adairville city","lsad":"city","polygons":[[[-868652,366667,19,-3,-4,-56,41,-4,58,14,28,-3,-5,19,26,2,1,129,-13,1,-3,29,-24,27,-45,22,-14,-11,14,-6,-47,-42,13,-37,-37,24,4,-87,-9,0]]]},{"geoid":"2100514","name":"Ages","full":"Ages CDP","lsad":"CDP","cdp":true,"polygons":[[[-832498,368530,156,-43,23,12,-35,26,-40,14,-2,15,-26,1,39,31,7,13,-2,31,-25,34,-14,-18,-30,-3,-18,-15,-16,-41,-29,-2]]]},{"geoid":"2100694","name":"Albany","full":"Albany city","lsad":"city","polygons":[[[-851490,366795,273,0,-1,218,-269,1,-2,-36]]]},{"geoid":"2100802","name":"Alexandria","full":"Alexandria city","lsad":"city","polygons":[[[-844205,389670,9,-20,44,28,-6,6,21,30,38,-17,23,10,2,-15,-11,-7,19,-20,59,39,25,0,19,-27,-57,-20,-19,20,-10,-5,16,-27,-7,-3,7,-15,-7,-35,10,-13,29,0,-41,-27,19,-15,30,18,14,-43,-17,-7,-9,-32,48,28,21,-3,-3,-54,23,4,23,-78,-36,-8,16,-27,-5,-13,17,-45,-37,-9,-1,7,-10,-2,-2,-23,13,6,6,-14,54,21,3,-8,40,4,-15,61,60,13,-23,30,-28,-6,24,21,-12,24,7,3,-13,16,-17,-4,-13,35,-42,-3,-6,47,88,-10,33,4,-2,-22,90,-146,-38,-17,17,-50,63,3,9,-16,24,1,-1,17,13,1,6,16,-18,73,-44,-5,-3,10,-38,-16,-74,128,0,20,32,21,11,-18,-26,-8,78,-67,-17,49,27,9,-3,22,-8,5,56,19,24,-69,6,-1,30,19,-26,41,24,6,-6,15,63,16,-4,9,100,-13,2,-8,31,5,4,-17,12,10,-18,29,-19,13,-24,1,14,15,-20,19,-8,-13,10,-5,-14,-12,-24,9,-13,-16,-35,-10,-7,19,-114,-26,-26,40,31,21,-6,17,-34,26,-11,-7,4,19,-23,19,25,16,28,38,-20,8,-6,21,-31,-7,-2,15,-24,27,-5,32,-16,-17,-16,-3,-45,9,1,23,-38,-11,-119,70,-53,46,-7,17,-46,-36,-8,5,5,18,-14,-4,10,-22,10,0,-9,-15,-38,-29,21,-73,46,4,-31,-7,7,-12,-43,0,41,-33,-21,-16,2,-21,-11,-8,-12,10,5,12,-15,24,-16,-2,-22,25,-4,-28,-32,-24,43,-25,-53,-35]]]},{"geoid":"2100946","name":"Allen","full":"Allen city","lsad":"city","polygons":[[[-827353,376159,25,-22,0,-32,53,-33,21,0,-5,53,11,19,22,12,14,-12,-6,20,-25,-12,-16,-21,-65,32,-6,-7]]]},{"geoid":"2101000","name":"Allensville","full":"Allensville CDP","lsad":"CDP","cdp":true,"polygons":[[[-870779,367096,76,-13,14,-17,43,-4,11,-8,4,35,31,-1,2,161,-49,18,-19,-35,-57,29,-23,-13,-27,-57,-4,-57]]]},{"geoid":"2101504","name":"Anchorage","full":"Anchorage city","lsad":"city","polygons":[[[-855552,382637,32,-54,17,1,8,-17,10,3,11,-24,35,10,16,-39,85,21,3,-7,68,34,15,18,-5,21,20,-11,-1,16,-16,6,-13,41,84,122,-16,38,17,0,6,7,-36,16,-10,23,-245,-69,11,-23,-40,-18,5,-13,-14,-4,8,-19,-17,1,-9,-21,8,-3,-9,-28,-31,7]]]},{"geoid":"2101648","name":"Annville","full":"Annville CDP","lsad":"CDP","cdp":true,"polygons":[[[-839974,373445,9,-63,14,-14,40,-15,4,-23,11,-4,-16,-52,35,-87,13,-16,5,-25,-10,-5,-5,-22,7,-19,67,-31,46,-3,73,5,39,31,-9,-32,7,-8,-13,-98,30,-1,28,16,21,2,45,28,10,30,18,20,57,42,34,19,18,1,72,94,27,-7,-1,42,49,64,-133,-12,43,79,-51,20,-8,16,14,18,-30,27,-37,-2,-26,19,-4,28,-24,3,-40,-33,-33,-45,5,-17,-108,-20,-85,1,-50,31,-47,1,-65,44,-66,2]]]},{"geoid":"2101720","name":"Anthoston","full":"Anthoston CDP","lsad":"CDP","cdp":true,"polygons":[[[-875394,377511,88,-28,19,33,66,-29,-16,49,-49,69,-27,-4,-53,26,-24,-19,-26,19]]]},{"geoid":"2101972","name":"Arjay","full":"Arjay CDP","lsad":"CDP","cdp":true,"polygons":[[[-836503,367984,77,37,38,-14,8,42,33,-8,25,21,-4,31,-38,33,-96,-10,-19,-70,-18,2,-36,-34]]]},{"geoid":"2102044","name":"Arlington","full":"Arlington city","lsad":"city","polygons":[[[-890189,367861,87,11,4,-12,26,2,0,42,13,33,-99,14,0,-29,-56,6]]]},{"geoid":"2102188","name":"Artemus","full":"Artemus CDP","lsad":"CDP","cdp":true,"polygons":[[[-838562,368371,47,-20,61,-97,20,-17,31,-9,63,28,-6,43,43,64,18,12,-10,28,-41,36,-45,12,-8,13,-30,-24,-3,-19,-32,-11,-44,5,-7,24,-39,-23]]]},{"geoid":"2102368","name":"Ashland","full":"Ashland city","lsad":"city","polygons":[[[-826818,384392,35,-16,154,-18,248,71,18,-2,4,-26,19,-43,62,5,65,25,15,-38,19,-6,12,22,-24,30,44,16,10,-33,58,24,16,20,-26,171,-54,101,-86,62,-162,61,-160,87,-48,36,-38,44,-34,-40,31,-18,-23,11,-5,-5,6,-41,46,-46,-5,-9,56,-23,-34,-24,-62,-10,-42,-38,-14,6,-7,-13,18,-3,-12,-89,-6,-7,-25,2,-2,-12,18,-2,-61,-76,-24,-107]]]},{"geoid":"2102638","name":"Auburn","full":"Auburn city","lsad":"city","polygons":[[[-867368,368688,30,2,-6,-39,77,-13,9,2,8,57,55,5,-8,-41,8,-14,-38,-6,-1,-23,-19,0,-2,19,-16,-2,3,-17,-13,-1,1,17,-10,-17,-20,0,2,-31,101,11,10,-5,0,-29,184,-1,-4,139,93,0,149,54,31,-43,51,14,-7,59,-130,-40,-60,-31,-44,-8,-314,-4,-6,10,-40,7,-19,13,-64,-9]]]},{"geoid":"2102656","name":"Audubon Park","full":"Audubon Park city","lsad":"city","polygons":[[[-857340,382013,37,-18,13,15,69,24,30,23,-39,52,-78,-36,10,-15,-55,-24]]]},{"geoid":"2102674","name":"Augusta","full":"Augusta city","lsad":"city","polygons":[[[-840199,387730,72,-68,130,25,7,-34,34,9,-10,-14,3,-25,22,2,-11,-30,46,-9,10,24,-1,21,27,12,-16,32,30,-8,-22,23,7,15,-42,1,-29,11,-12,20,44,7,13,10,-5,-21,-24,-3,12,-17,30,4,-2,9,49,15,25,19,22,-6,28,10,2,10,-25,6,-16,-9,-8,13,-61,-9,-10,15,-36,-23,-43,5,-6,45,-96,-15,-143,-43]]]},{"geoid":"2102764","name":"AuxiLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"LA","state":"Louisiana","fips":"22","features":[{"geoid":"2200100","name":"Abbeville","full":"Abbeville city","lsad":"city","polygons":[[[-921653,299769,49,-7,1,11,119,-17,-2,-10,28,-4,-4,-9,-42,7,-16,-19,91,-13,-82,-38,-31,9,-52,-18,-18,-27,-6,-25,56,-8,5,13,60,-9,-3,-12,30,-5,3,13,65,-10,2,7,31,-5,-4,-24,52,0,0,53,100,0,10,9,0,24,47,-6,-1,31,95,-10,0,25,58,-12,0,29,37,-8,0,-18,9,0,4,15,15,-2,1,-13,27,1,0,7,51,-13,-7,-21,8,-2,8,20,74,-19,-26,56,-1,57,-19,1,-27,58,-9,3,-22,-12,23,-49,-36,0,0,-15,-15,-13,-63,0,-1,44,-45,-1,-1,49,8,0,0,7,-44,52,-28,5,-4,-15,-38,6,3,14,-77,-3,126,-21,-20,-24,-97,14,-11,-13,-1,-5,103,-10,-12,-33,-156,17,6,9,-30,5,-11,-9,-53,9,4,24,22,44,-18,2,19,29,-39,6,7,11,-57,11,-48,-62,-32,5,2,8,-6,-8,-23,3,-10,-14,55,-14,-1,-8,-57,8,0,-14,58,-12,-56,9,-18,-13,26,-73,26,-24,-95,15,7,27,-15,-1,-3,-16,-27,3]]]},{"geoid":"2200240","name":"Abita Springs","full":"Abita Springs town","lsad":"town","polygons":[[[-900488,304632,36,-3,0,-8,12,-2,0,-20,17,0,-7,-3,7,-17,16,-1,0,21,189,-2,0,-33,66,-3,-1,-31,43,0,-1,36,-16,7,-1,52,-24,15,4,157,12,2,-15,65,4,54,12,6,-12,9,0,35,-86,1,0,-35,-128,-1,0,36,-24,0,9,-11,1,-25,-72,-1,-1,11,-10,-112,9,-1,-2,-158,-38,0]]]},{"geoid":"2200415","name":"Addis","full":"Addis town","lsad":"town","polygons":[[[-912902,303529,168,29,14,-32,-132,-29,13,-48,151,47,7,-22,30,12,38,-89,10,4,2,34,23,59,-13,64,111,20,16,80,29,55,-96,37,14,19,-5,9,-167,66,-52,19,-14,-31,-47,17,-18,-27,36,-43,42,-81,-90,14,-7,-15,-9,2,-11,-29,133,-13,6,-19,-123,1,-11,-25,90,2,18,-43,-163,-20]]]},{"geoid":"2200835","name":"Albany","full":"Albany town","lsad":"town","polygons":[[[-905921,304932,98,-1,6,-41,19,-53,4,-29,-19,-20,-64,-18,51,3,28,-20,1,-13,17,-1,7,-64,-7,72,26,26,58,11,-52,0,-42,24,6,5,-28,69,32,4,-4,23,-17,0,-2,23,39,-9,-7,22,-16,7,7,19,-10,21,36,83,-18,13,-12,30,-140,0]]]},{"geoid":"2200975","name":"Alexandria","full":"Alexandria city","lsad":"city","polygons":[[[-925643,313489,3,-63,6,-18,19,12,23,-21,17,9,64,-76,22,-3,3,15,18,-3,10,64,47,-7,-6,-44,32,-26,24,14,2,12,-41,8,6,38,55,70,68,-10,54,-24,8,-12,-6,-37,-34,4,-1,-5,36,-5,3,14,11,-10,37,30,188,-73,-5,-11,16,-9,7,13,40,-16,-17,-22,14,-7,11,25,48,-19,31,-21,-31,-51,61,-7,-8,-38,4,-1,-5,-6,-16,-8,-64,84,-22,12,-13,-20,15,-8,11,19,39,-44,-24,-11,-19,4,6,-12,-58,-23,-7,20,-11,-15,3,-26,18,-17,23,-32,-28,-13,4,-11,31,17,19,-26,-61,-32,0,-9,8,-14,-138,-47,-25,-3,-12,23,-26,-7,-6,15,-18,6,-7,-3,15,-33,-58,2,8,34,16,-1,3,26,-4,17,-55,-12,11,61,-25,4,5,43,-140,7,-25,-3,7,-44,-6,-15,-4,-36,-13,-35,-14,-25,0,-17,25,-1,8,20,15,11,1,-10,16,-23,70,-4,-1,-23,-5,-9,16,-5,7,37,12,0,-6,-35,15,35,100,-2,-3,-15,33,-1,-1,-29,34,-6,-1,-30,-32,-22,26,-11,42,19,21,-1,15,-9,-24,-17,35,-45,15,13,29,14,-24,-31,22,-5,-37,-39,-15,2,-23,10,10,-7,-17,-22,-19,0,-9,-14,10,-41,35,27,24,-2,-9,-35,23,-6,7,14,17,-3,-23,-14,0,-10,-15,-29,-37,7,1,-21,10,-13,18,-7,4,-7,36,53,27,-18,62,-16,-47,-74,91,-42,75,112,25,-7,6,0,7,11,42,-3,13,-12,-3,-15,-15,-18,-2,-19,-45,-63,44,-14,94,120,7,-5,34,40,43,-8,-6,-14,6,-4,-91,-257,34,28,-14,10,42,118,0,-11,54,5,-1,-14,66,24,8,10,39,-26,20,25,-14,11,29,28,25,-25,-24,-35,15,-4,23,20,-2,-23,-18,-10,-16,-1,-48,-24,-26,-9,-12,-20,20,-27,17,-10,34,-8,70,4,16,-6,-29,134,26,-13,20,10,-10,33,35,15,-35,37,48,37,-12,14,29,22,13,-11,-29,-25,12,-13,74,54,-25,21,182,119,15,76,14,42,-3,12,-22,16,-52,-2,-73,15,-81,36,-94,67,-80,64,-100,65,-33,76,2,43,-20,49,-7,37,-12,4,-34,27,-55,13,-52,-13,-39,-31,-7,-15,-26,-29,-61,0,-13,-19,-56,14,-50,32,-58,71,-20,18,18,32,-17,7,-16,-32,-105,21,8,24,-57,10,-6,-15,-54,-1,-26,11,-134,16,-36,15,17,28,120,-45,49,68,-22,5,-56,-23,-41,-5,-75,32,-25,-37,4,-13,-16,-8,-26,-43,-35,9,-71,29,16,38,-43,-74],[-925165,313454,11,9,39,-2,-6,-22,12,21,23,-3,-8,-10,2,-21,21,28,41,-14,25,-27,27,-40,24,-24,22,-14,83,-29,-7,-8,-323,128]]]},{"geoid":"2201780","name":"Ama","full":"Ama CDP","lsad":"CDP","cdp":true,"polygons":[[[-903232,299364,43,-143,434,181,-40,278,-81,-33,-75,-42,-169,-170,-80,-59,-23,-9]]]},{"geoid":"2201815","name":"Amelia","full":"Amelia CDP","lsad":"CDP","cdp":true,"polygons":[[[-911208,296583,68,-105,-62,-33,20,-45,53,-15,39,13,38,32,-1,25,51,109,16,206,-3,112,8,25,29,36,-1,20,-9,6,-32,-10,-84,-43,0,-36,-31,-20,16,-3,7,-37,25,-5,-13,-128,-16,-16,-117,-56]]]},{"geoid":"2201885","name":"Amite City","full":"Amite City town","lsad":"town","polygons":[[[-905356,307263,35,0,-9,-31,1,-45,114,3,10,24,0,34,33,-1,-1,-94,169,0,1,114,19,0,9,21,-3,19,-12,3,10,22,16,-3,-1,14,18,0,1,14,-59,5,0,84,42,0,-6,92,-123,3,-4,-96,-42,0,-3,9,-33,-9,1,-54,-159,0,2,-92,-14,1,0,-13,-13,0]]]},{"geoid":"2201920","name":"Anacoco","full":"Anacoco village","lsad":"village","polygons":[[[-933572,312404,156,1,1,-79,81,1,2,172,40,-1,-3,81,-80,1,-2,114,-43,1,0,70,-121,-2,-2,-291,-28,0]]]},{"geoid":"2202165","name":"Angie","full":"Angie village","lsad":"village","polygons":[[[-898318,309543,234,2,-1,60,36,120,-199,-2,-16,-53,-33,-64,4,-31]]]},{"geoid":"2202550","name":"Arabi","full":"Arabi CDP","lsad":"CDP","cdp":true,"polygons":[[[-899975Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"MA","state":"Massachusetts","fips":"25","features":[{"geoid":"2500135","name":"Abington","full":"Abington CDP","lsad":"CDP","cdp":true,"polygons":[[[-709986,421116,195,25,3,-7,-20,-22,1,-19,26,-15,7,-22,-5,-8,28,-86,-7,-67,4,-20,287,71,308,55,-156,164,-93,349,-614,-241]]]},{"geoid":"2500530","name":"Acushnet Center","full":"Acushnet Center CDP","lsad":"CDP","cdp":true,"polygons":[[[-709190,416818,16,-8,13,-46,33,-17,15,16,63,-5,81,16,76,83,-45,-7,-26,55,-21,21,-49,-3,8,14,-43,5,-43,23,-30,-3,-30,-64,5,-12,-23,-32]]]},{"geoid":"2500590","name":"Adams","full":"Adams CDP","lsad":"CDP","cdp":true,"polygons":[[[-731346,426220,17,-21,87,-36,7,-13,38,-10,7,17,21,8,-9,17,23,4,86,-12,10,-12,32,-3,-4,76,-13,34,18,103,-7,13,-90,12,0,20,-31,9,-47,-52,-25,1,-8,33,10,19,-25,9,-11,14,-49,-71,-59,-155]]]},{"geoid":"2500840","name":"Agawam Town","full":"Agawam Town city","lsad":"city","polygons":[[[-727146,420366,187,2,528,-44,351,-16,0,82,16,88,59,100,87,118,82,85,9,35,-14,54,-44,62,-33,-12,-41,19,-29,-7,-19,-69,-89,23,-39,-11,-74,2,-81,34,-21,41,16,21,-13,29,-25,4,-53,-18,-49,6,-56,-4,-83,-22,-31,-42,-9,-31,6,-27,-7,-20,-24,-1,-24,19,-21,47,-19,20,-29,16,-35,56,-47,2,-8,9,-11,54,-116,-11,-107,-238,-6,-86,-19,-90,0,-90,-29,-57,-21,-109]]]},{"geoid":"2501185","name":"Amesbury","full":"Amesbury city","lsad":"city","polygons":[[[-710046,428632,248,-183,106,-224,77,-26,157,8,55,18,64,55,24,42,63,63,44,25,51,-3,19,-44,20,-18,35,-9,41,3,44,135,-148,392,-162,-20,-357,-156,-309,-47]]]},{"geoid":"2501370","name":"Amherst Town","full":"Amherst Town city","lsad":"city","polygons":[[[-725468,424001,152,14,-39,-361,-10,-3,7,-51,-51,-404,29,5,28,-158,40,-7,32,-21,33,17,33,-10,147,42,60,-12,16,11,55,-40,58,24,47,6,42,17,147,11,-131,687,-126,570,-543,-66,-16,-171]]]},{"geoid":"2501430","name":"Andover","full":"Andover CDP","lsad":"CDP","cdp":true,"polygons":[[[-711665,426605,28,-49,22,-1,101,-78,-12,-16,15,1,7,-25,-27,-19,10,-7,-48,-30,4,-25,25,-1,20,-19,16,-3,22,17,22,-10,10,4,11,45,20,-40,31,37,48,8,25,30,29,74,64,51,34,61,-87,117,-62,-19,-46,2,-20,-25,-26,-3,-2,-8,-19,10,-14,-12,-8,13,5,13,-14,-2,-14,28,-12,-3,-27,-8,-51,-63,-74,-41]]]},{"geoid":"2501640","name":"Arlington","full":"Arlington CDP","lsad":"CDP","cdp":true,"polygons":[[[-712026,424148,83,-14,-10,18,10,1,106,-39,93,-20,105,-37,64,-33,139,-50,32,24,41,13,12,14,6,33,21,32,0,62,-60,3,-45,40,1,24,-32,18,-8,16,-31,105,-324,5,-176,-186]]]},{"geoid":"2502515","name":"Athol","full":"Athol CDP","lsad":"CDP","cdp":true,"polygons":[[[-722671,426008,58,-249,41,-2,17,23,66,36,19,0,9,-8,1,-19,14,-20,19,-63,-12,-27,9,-38,45,0,66,34,130,-7,5,112,42,-1,10,55,27,-1,7,-37,23,1,6,17,14,4,8,-26,13,-5,22,31,-84,48,94,85,6,29,32,14,23,21,21,34,26,13,21,76,-3,29,69,64,-47,1,-106,-20,-2,-19,21,-36,2,-57,-17,-17,-57,-12,-44,6,-45,-22,-20,-32,2,-18,18,-26,-25,-23,-26,-7,-18,4,45,27,-7,55,-40,1,-17,78,-1,52,-87,-4,-16,-17,-21,-5,-69,6,-16,-9,-147,-21,14,-63,-30,-2,-25,-14,-12,-26,-22,-8,-32,7]]]},{"geoid":"2502690","name":"Attleboro","full":"Attleboro city","lsad":"city","polygons":[[[-713817,418932,810,105,782,85,143,-33,-511,653,-94,131,-673,-465,-457,-260,0,-210]]]},{"geoid":"2503040","name":"Ayer","full":"Ayer CDP","lsad":"CDP","cdp":true,"polygons":[[[-716041,425577,50,7,2,-17,38,10,42,-29,36,-13,115,-16,1,-8,112,63,-66,-11,-65,13,50,95,-5,35,-135,7,-29,-36,-27,-14,-33,6,-52,-39,5,-32,-20,-4]]]},{"geoid":"2503355","name":"Baldwinville","full":"Baldwinville CDP","lsad":"CDP","cdp":true,"polygons":[[[-720927,426116,12,-25,-9,-56,14,-26,6,-43,55,-33,-11,-7,18,-10,15,-36,-15,-17,14,-18,-10,-20,87,-5,1,72,22,7,-11,72,-36,58,3,9,15,4,19,18,3,-16,16,-19,55,-30,19,-35,20,4,10,-5,3,-17,30,-12,28,4,-146,117,40,-11,2,18,107,31,-194,153,1,-20,-25,-53,-25,40,-25,-14,8,-19,-66,0,-22,-27,-2,-16,-16,3]]]},{"geoid":"2503690","name":"Barnstable Town","full":"Barnstable Town city","lsad":"city","polygons":[[[-704646,416326,81,24,5,-7,29,6,41,-137,-14,-45,-22,-7,-84,-96,-40,-97,64,-80,56,-24,26,13,2,14,36,12,13,16,27,73,54,62,55,4,49,-10,183,26,79,-9,214,50,93,45,52,78,-2,30,45,44,88,36,135,16,82,-17,83,-41,50,-51,49,20,110,21,5,30,42,19,54,8,93,-1,68,-22,37,-49,33,17,46,-14,13,24,-130,161,150,454,-11,129,-21,25,-25,-4,-10,15,20,30,-42,12,-95,-29,-163,8,-63,-23,-59,18,-133,-6,-76,16,-18,-24,21,-33,-14,-17,-135,-25,-153,11,-56,26,20,37,-17,31,-195,96,-34,-9,-97,45,-8,19,-627,-630,76,-71,45,-66,-51,-78,-15,-8,6,-30,-18,5,-41,-17,-78,-4,-13,-28]],[[-703890,417291,49,19,-12,29,25,8,97,-17,98,0,144,-15,29,6,52,-13,83,10,133,-29,159,-14,70,-22,31,0,11,35,19,7,20,-3,27,-40,37,-28,74,11,5,8,-13,15,-79,32,-52,30,-73,24,-880,54,-96,-100]]]},{"geoid":"2503705","name":"Barre","full":"Barre CDP","lsad":"CDP","cdp":true,"polygons":[[[-721209,424203,24,-26,46,-25,12,6,26,-5,13,13,19,-10,23,-39,27,-7,8,-13,40,-18,13,13,14,29,-7,22,-10,-2,16,76,-93,151,-96,8,-14,-14,21,-54,-14,3,14,-26,-1,-41,-32,-10,-16,18,-62,3]]]},{"geoid":"2504790","name":"Belchertown","full":"Belchertown CDP","lsad":"CDP","cdp":true,"polygons":[[[-7243Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"MD","state":"Maryland","fips":"24","features":[{"geoid":"2400125","name":"Aberdeen","full":"Aberdeen city","lsad":"city","polygons":[[[-762039,395343,26,-10,7,12,56,-22,12,13,16,-2,-39,-33,29,-16,43,-6,6,-33,-57,-4,-41,-35,94,-18,-14,-76,99,4,-41,-49,16,-22,-156,-100,5,-5,-51,-33,23,-11,-31,-20,15,-10,20,8,-10,12,7,9,28,-10,57,22,-13,-31,56,8,-28,-13,76,-38,115,110,57,70,12,4,51,-30,29,58,40,-12,17,-18,2,10,14,-17,19,8,17,33,-26,31,52,41,-15,4,1,27,22,13,24,-3,15,7,-18,33,-72,-44,8,-9,-1,-25,-8,17,-56,39,27,22,67,32,5,-8,16,8,8,15,136,65,-67,-11,-19,75,-29,-7,5,-19,-24,1,20,-18,5,-34,-39,-7,-14,-15,31,-25,-139,-66,-47,32,1,66,-11,34,-43,49,-33,4,-3,-15,-7,18,-32,22,-45,-60,-75,-44,8,40,-8,14,-21,0,-2,11,-39,-4,-6,5,57,41,7,-6,51,12,1,20,-36,5,-16,-8,6,20,46,-7,-16,61,-71,7,0,-12,-26,-1,9,13,-18,9,-11,-11,-4,-29,11,0,0,-31,-21,-8,-18,6,-19,-31,7,-4,-8,1,-64,-51]]]},{"geoid":"2400175","name":"Aberdeen Proving Ground","full":"Aberdeen Proving Ground CDP","lsad":"CDP","cdp":true,"polygons":[[[-761750,394906,201,-72,-27,-27,-45,-19,52,-25,19,-42,-48,-20,-21,-30,154,-64,-70,-29,-14,-14,-11,-34,-42,-31,13,-16,14,1,31,-26,22,-37,25,-16,35,-2,46,10,13,-9,64,0,117,-37,11,8,13,-13,28,26,9,-20,-14,-17,12,-4,54,23,31,-7,12,7,-22,26,20,4,29,-6,-8,-18,10,-16,15,0,3,41,16,31,26,8,17,56,16,102,-5,157,-30,-3,-4,9,-64,27,-74,74,-33,7,8,-33,-38,-48,-2,-15,-14,-6,9,-8,-14,6,11,31,14,3,-11,13,-33,2,-25,17,-30,37,-52,15,-7,25,19,24,-17,11,26,31,-20,23,-130,21,-38,-13,-31,30,-36,11,-29,-58,-51,30,20,-17,-62,-61,-23,9,-4,-5]]]},{"geoid":"2400200","name":"Abingdon","full":"Abingdon CDP","lsad":"CDP","cdp":true,"polygons":[[[-762990,394563,23,-30,10,-8,18,4,30,-12,23,4,12,-5,9,-47,10,0,171,101,79,18,78,38,21,21,-69,65,-28,16,4,22,-21,39,-1,17,-80,-38,-83,-63,-212,-90]]]},{"geoid":"2400225","name":"Accident","full":"Accident town","lsad":"town","polygons":[[[-793262,396241,31,-12,-31,-42,45,-25,38,54,55,57,-41,61,-60,-19,-35,-30,13,-25]]]},{"geoid":"2400250","name":"Accokeek","full":"Accokeek CDP","lsad":"CDP","cdp":true,"polygons":[[[-770579,386397,101,-235,18,26,-2,14,62,58,16,40,97,37,17,13,7,36,39,35,13,43,10,12,91,44,58,7,37,20,29,0,73,24,19,-7,126,23,44,-2,20,20,47,8,72,-24,119,0,70,-43,41,6,49,-11,31,8,87,-14,20,-10,33,4,1,21,-20,44,4,9,-16,30,-82,1,-27,11,-14,42,-26,15,-34,0,-43,79,28,17,148,56,12,31,-2,41,-47,33,-10,17,4,23,25,35,43,24,-17,11,-119,21,-35,0,-25,-12,-6,16,-68,-21,-17,7,-10,-19,-12,-3,-39,24,-32,-9,-28,6,-61,-23,-55,11,6,-23,-43,-10,-18,-17,-4,-25,-25,-12,-56,14,-53,-40,-31,22,-18,-10,-9,8,-75,-10,-250,17,-143,55,-161,-17,-191,-78]]]},{"geoid":"2400350","name":"Adamstown","full":"Adamstown CDP","lsad":"CDP","cdp":true,"polygons":[[[-774865,393034,93,-6,-1,-45,92,1,-15,44,59,1,7,21,79,0,11,60,-16,8,-4,11,-15,-3,1,6,-107,-5,0,18,-12,0,2,19,-50,-38,-28,9,-8,-9,17,-15,-76,-57]]]},{"geoid":"2400400","name":"Adelphi","full":"Adelphi CDP","lsad":"CDP","cdp":true,"polygons":[[[-769798,389993,44,-65,34,-4,37,-26,-45,-57,126,11,16,-20,32,9,32,-4,5,-13,-22,1,1,-8,-10,0,6,-29,117,-30,16,11,30,-5,0,6,-96,41,-11,28,-14,7,-57,8,-10,25,0,35,-42,34,-15,41,-35,39,18,-2,29,23,28,-5,44,68,6,-3,35,112,-143,-31,-75,-4,-119,-163]]],"label":[-76.97112205128205,38.9991]},{"geoid":"2400712","name":"Algonquin","full":"Algonquin CDP","lsad":"CDP","cdp":true,"polygons":[[[-761121,385753,33,-4,-5,33,-23,3,-2,10,8,6,49,-8,-42,14,9,14,64,-12,75,-31,19,-25,47,35,-14,10,-17,-6,-27,20,48,34,41,-30,35,31,-42,12,-24,43,6,25,-18,2,-115,-54,-116,-42]]]},{"geoid":"2400800","name":"Allen","full":"Allen CDP","lsad":"CDP","cdp":true,"polygons":[[[-756965,382883,24,-5,-15,-49,33,-15,33,18,28,35,-10,32,57,31,0,7,-31,-7,-17,35,-17,-11,-33,14,-21,-35,-18,-5]]]},{"geoid":"2401450","name":"Andrews AFB","full":"Andrews AFB CDP","lsad":"CDP","cdp":true,"polygons":[[[-768997,387958,27,-86,51,-34,91,-1,60,-13,122,-1,3,19,30,11,1,6,0,7,-32,18,74,8,3,67,49,22,-20,32,22,55,27,-14,-10,140,8,11,-77,92,-111,-1,-45,-8,-55,-24,-145,-95,-46,-75,-27,-20,-35,-8,16,-20,-21,-27]]]},{"geoid":"2401600","name":"Annapolis","full":"Annapolis city","lsad":"city","polygons":[[[-765397,389775,19,-12,10,-28,14,15,-4,-56,19,-1,-29,-26,35,-17,-8,-9,2,-13,12,0,12,-28,60,58,21,-15,-31,-23,38,-27,-15,-18,20,-6,24,29,26,8,1,-42,34,-22,9,77,25,-18,34,-46,-62,1,-7,-22,10,-12,7,-69,60,-13,6,10,-1,-26,26,2,6,56,58,-17,32,24,12,-18,19,19,21,-11,-13,-13,9,-13,23,10,50,55,53,13,36,19,-13,0,-1,12,-26,3,-7,-25,-14,-11,-22,6,2,11,27,-4,7,14,-24,4,16,6,2,34,22,14,26,-2,-20,61,7,53,-56,45,-40,-3,-21,12,-17,40,-27,5,-35,23,-21,-22,-51,47,15,28,23,19,86,21,-89,88,-57,-44,-27,-48,-23,-8,-15,-15,-60,-9,-30,-13,-45,6,-75,-23,34,-14,-25,6,-66,-58,-8,4,-17,-26]]]},{"geoid":"2401635","name":"Annapolis Neck","full":"Annapolis Neck CDP","lsad":"CDP","cdp":true,"polygons":[[[-765443,389473,381,-261,90,43,77,19,60,-38,27,-34,25,-12,1,-25,24,-20,63,-30,1,-39,91,-5,15,81,-31,88,-13,8,0,37,-51,20,-5,-7,-9,5,27,43,30,-39,15,-3,28,35,94,72,-75,73,-53,2,-82,79,-16,47,-26,2,-22,-14,-2,-34,-16,-6,24,-4,-7,-14,-27,4Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"ME","state":"Maine","fips":"23","features":[{"geoid":"2300695","name":"Alfred","full":"Alfred CDP","lsad":"CDP","cdp":true,"polygons":[[[-707522,434843,71,-22,39,-3,19,-23,-29,-1,2,-18,17,-19,22,-8,5,-19,40,-50,5,-33,73,-36,29,0,-2,-18,28,-19,46,2,43,18,19,34,36,9,31,18,9,-7,28,31,-8,35,4,17,14,-10,-5,22,-18,-5,-9,28,18,16,-3,37,9,19,-37,31,-1,11,-17,11,-4,20,-27,-7,-3,-15,-28,8,-79,55,-12,-27,-46,18,-12,-6,16,-14,-48,-33,-65,20,-54,-51,-31,-9,-39,11]]]},{"geoid":"2301360","name":"Anson","full":"Anson CDP","lsad":"CDP","cdp":true,"polygons":[[[-699083,447854,194,24,-4,25,50,42,-42,26,0,19,21,20,-2,20,-53,80,5,16,-28,20,-23,-2,-14,9,-14,-41]]]},{"geoid":"2301675","name":"Ashland","full":"Ashland CDP","lsad":"CDP","cdp":true,"polygons":[[[-684224,466071,72,-3,28,-6,20,-14,264,2,-16,30,-9,48,-22,32,-56,13,-19,27,140,1,-46,69,231,3,7,14,-7,11,7,52,-24,13,-1,31,-41,54,-29,14,-17,-25,-55,-39,-50,-50,-75,-1,0,47,-211,-2,-7,-22,-32,-26,-13,-24,38,-16,-44,-84,-17,-77,-23,-32]]]},{"geoid":"2302060","name":"Auburn","full":"Auburn city","lsad":"city","polygons":[[[-703142,440926,-13,-5,4,-30,19,-16,-4,-17,17,-15,7,-24,34,-23,54,-8,19,-21,53,-27,20,-50,-1,-40,45,-2,-260,-274,617,-450,239,-183,46,45,48,-28,80,36,409,416,-44,13,-33,22,-55,87,-35,120,-52,37,-66,75,-30,57,-49,24,-9,50,6,85,-15,23,-48,45,-82,51,-18,19,-4,21,19,63,-9,51,16,27,44,35,9,52,36,25,27,32,22,53,54,55,16,61,-49,60,-39,36,-10,22,82,114,6,25,22,34,-74,69,-54,33,-775,205,-31,-54,16,-6,-19,-52,-5,-45,-47,-89,-34,-43,62,-43,42,-42,28,-15,-35,-34,2,-31,-33,-35,0,-21,-53,-55,16,-58,21,-21,-20,-20,-24,-7,23,-15,17,14,7,-38,-16,-18,14,-10,-31,-30,9,-35,-26,-61,9,-8,-33,-36,11,-10,-34,-38,16,-20,-32,-33,-65,24,-17,-40]]]},{"geoid":"2302100","name":"Augusta","full":"Augusta city","lsad":"city","polygons":[[[-698339,443467,22,-4,23,8,9,-6,-53,-144,32,-5,-5,-17,66,-13,-18,-38,-34,5,-27,-66,54,-9,-1,-38,79,-14,5,-13,98,-16,-17,-54,101,-16,-12,-34,221,-37,-4,-5,1062,-173,223,-28,258,868,-1928,299,-36,-110,-15,0,-97,-300]]]},{"geoid":"2302795","name":"Bangor","full":"Bangor city","lsad":"city","polygons":[[[-688364,447775,446,59,106,-51,49,42,43,90,38,46,41,29,82,36,96,65,45,8,178,93,53,12,-37,154,-34,-4,-50,196,99,87,-205,152,-30,13,-1146,-153]]]},{"geoid":"2302830","name":"Bar Harbor","full":"Bar Harbor CDP","lsad":"CDP","cdp":true,"polygons":[[[-682496,444113,36,-10,-3,-9,78,-28,55,-65,47,-33,-4,-41,-14,-25,34,-36,-10,-15,-30,-11,-32,-89,15,27,16,8,23,-3,32,-25,18,-31,14,-1,9,13,-4,20,9,9,17,1,37,-35,112,-51,10,-36,-3,-34,28,-22,24,9,32,39,92,14,13,38,-76,71,-16,-4,-7,18,10,22,-31,-7,-18,10,-5,18,22,-3,8,17,-11,31,-66,60,-55,0,-65,-19,-14,33,-120,74,-17,-10,4,16,-13,14,-80,62,-92,47,-21,-11]]]},{"geoid":"2303355","name":"Bath","full":"Bath city","lsad":"city","polygons":[[[-698747,439536,177,-89,-24,-79,-16,-7,5,-13,-5,-22,-15,-10,123,-61,84,-3,82,-192,22,-14,-46,-49,-27,-74,-31,-48,132,12,-6,-46,-15,-20,8,-17,-27,-50,12,-10,-9,-19,14,-11,-6,-16,-21,-17,19,-15,65,9,20,18,-14,40,3,22,22,17,74,23,42,31,1,141,22,151,-6,64,8,75,-15,56,0,137,-84,79,-87,19,-16,10,-13,21,48,104,-18,42,-1,58,-128,73,-117,-49,-118,-37,-19,-17,-10,-93,-28,-59]]]},{"geoid":"2303950","name":"Belfast","full":"Belfast city","lsad":"city","polygons":[[[-690606,443674,539,232,72,54,34,-29,59,20,27,-19,31,37,29,19,-130,234,-39,34,60,30,54,-30,148,0,57,22,86,7,-83,290,-29,-3,-24,93,-30,-3,-23,88,-12,-1,-35,124,33,16,-30,31,-1371,-622]]]},{"geoid":"2304685","name":"Berwick","full":"Berwick CDP","lsad":"CDP","cdp":true,"polygons":[[[-708828,432732,11,-7,74,-6,17,-17,-1,-16,24,-23,71,-12,18,-20,18,-58,14,-10,31,-11,31,16,88,-25,-3,32,17,31,-64,39,-37,11,3,36,-36,43,-89,78,4,-75,-103,38,-77,76,-19,-4,26,-74,-17,11,-12,-6]]]},{"geoid":"2304790","name":"Bethel","full":"Bethel CDP","lsad":"CDP","cdp":true,"polygons":[[[-707988,444064,36,-42,-23,1,17,-11,9,-31,75,-76,161,67,39,-31,-41,58,22,14,-51,41,-30,10,0,28,-47,35,-20,4,-1,10,-56,-1,-65,24,-22,-8,-14,-20,6,-31,-45,-9]]]},{"geoid":"2304860","name":"Biddeford","full":"Biddeford city","lsad":"city","polygons":[[[-705653,434943,1290,-611,308,-135,101,28,24,-4,1,-31,26,-58,-21,-1,-2,-17,-28,-7,-20,-20,5,-17,-22,-57,-9,-5,-6,-52,14,-15,13,1,69,75,17,42,84,55,3,19,-64,14,38,43,62,17,4,69,61,65,85,50,123,29,59,-19,50,34,14,32,46,-3,-20,51,53,36,22,34,-100,-13,-202,6,-35,-13,-25,-44,-39,2,-48,28,-54,61,-87,10,-62,16,-76,99,-79,9,-29,20,2,25,-62,7,-42,32,-117,15,-9,16,1,28,-16,12,-80,26,-57,4,-29,11,-6,10,14,17,-1,12,-15,14,-112,-9,-48,9,-54,31,-19,-1,-62,-35,-180,-8,-34,7,-16,10,-48,62,-48,23,-39,74,-50,22,-22,19,-29,57,6,15,49,39,43,67,49,28,9,45,-27,5,-431,-443]]]},{"geoid":"2304965","name":"Bingham","full":"Bingham CDP","lsad":"CDP","cdp":true,"polygons":[[[-698892,450592,-11,-19,24,-29,74,-53,50,-19,22,-18,29,-68,36,-21,-6,19,14,-4,11,41,-11,10,-1,25,28,27,19,55,1,47,-13,13,6,20,59,30,11,40,51,31,-73,-5,-393,-64]]]},{"geoid":"2305350","name":"Blaine","full":"Blaine CDP","lsad":"CDP","cdp":true,"polygons":[[[-678985,465024,100,1,-13,-14,4,-54,209,1Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"MI","state":"Michigan","fips":"26","features":[{"geoid":"2600380","name":"Addison","full":"Addison village","lsad":"village","polygons":[[[-843587,419790,192,-1,-1,147,-194,-1]]]},{"geoid":"2600440","name":"Adrian","full":"Adrian city","lsad":"city","polygons":[[[-840891,418983,100,0,0,-19,-13,1,-5,9,-16,-15,-2,-37,8,1,0,-24,17,1,0,-13,-23,0,1,-43,146,18,-1,27,-14,0,0,13,24,1,1,-13,25,0,1,-18,18,7,0,12,91,2,1,-33,-23,-3,1,-38,121,-1,1,-19,-49,3,1,-27,16,0,1,-21,18,-5,181,6,22,35,-40,-1,-2,33,11,0,-2,36,133,4,-1,190,44,0,-1,48,-59,8,42,18,16,18,-1,56,-66,-1,-1,21,42,0,-3,37,-27,-1,-68,-49,2,-9,19,-1,0,-9,-92,2,-2,-53,-29,0,3,15,-26,0,1,-20,-40,5,18,17,-54,46,-29,-5,-44,-27,-25,3,1,-44,-25,1,-1,36,-7,0,1,-36,-89,0,-34,-26,-48,11,-4,-44,-65,-4,-1,38,-10,22,-39,0,1,-70,-26,0,6,-33,-29,-4]]]},{"geoid":"2600480","name":"Advance","full":"Advance CDP","lsad":"CDP","cdp":true,"polygons":[[[-851060,452199,37,2,-16,-46,3,-33,200,0,-2,-74,393,-1,1,34,-182,55,-37,35,-1,21,-14,11,-87,11,-51,35,-45,56,-187,-7,-1,-77,-49,-2]]]},{"geoid":"2600620","name":"Ahmeek","full":"Ahmeek village","lsad":"village","polygons":[[[-883999,472970,22,-11,30,9,-1,36,-50,0]]]},{"geoid":"2600700","name":"Akron","full":"Akron village","lsad":"village","polygons":[[[-835243,435598,199,1,-1,137,-198,-1,-1,-73]]]},{"geoid":"2600860","name":"Alanson","full":"Alanson village","lsad":"village","polygons":[[[-847973,454355,206,-7,0,149,-205,3]]]},{"geoid":"2600900","name":"Alba","full":"Alba CDP","lsad":"CDP","cdp":true,"polygons":[[[-849869,449886,29,3,-1,-67,-9,-23,4,-52,-47,0,0,-73,209,3,194,-7,3,174,-36,33,-18,4,-18,-23,-82,-62,-15,29,-28,1,0,115,-26,10,-53,-3,-105,-27,-31,-26]]]},{"geoid":"2600980","name":"Albion","full":"Albion city","lsad":"city","polygons":[[[-847824,422520,68,-30,6,-169,199,1,-1,-14,48,0,-1,14,148,0,-7,113,34,14,-1,17,-34,-11,-2,127,-15,3,15,6,-13,15,-37,8,-70,0,0,31,-77,-13,-103,6,-93,18,1,-43,23,0,0,-36,-23,-1,-48,16,-1,-17,-17,0]]]},{"geoid":"2601060","name":"Alden","full":"Alden CDP","lsad":"CDP","cdp":true,"polygons":[[[-852810,448740,88,0,58,10,-2,62,-40,17,-16,17,-1,39,-27,-1,5,-14,-16,-47,-14,-9,-5,-28]]]},{"geoid":"2601180","name":"Algonac","full":"Algonac city","lsad":"city","polygons":[[[-825511,426198,68,8,-14,-55,-46,-48,68,-41,30,2,32,11,67,76,123,92,35,86,-220,-17,-3,-69,-144,-10]]]},{"geoid":"2601260","name":"Allegan","full":"Allegan city","lsad":"city","polygons":[[[-858674,425162,12,13,71,1,-6,-37,279,5,21,-11,12,13,-5,151,98,-11,61,8,0,18,-13,-1,-1,19,-98,-12,-1,11,-49,12,-1,95,-71,-3,58,54,0,20,-51,-2,-7,-27,-27,-1,0,-45,-97,0,-2,71,-48,-1,2,-72,-38,0,-11,-11,-37,7,-22,-6,-6,-33,-30,0]]]},{"geoid":"2601300","name":"Allen","full":"Allen village","lsad":"village","polygons":[[[-847777,419547,62,11,4,-9,36,-2,0,17,44,0,22,14,0,14,-48,-6,1,34,-32,16,0,-55,-89,-28]]]},{"geoid":"2601340","name":"Allendale","full":"Allendale CDP","lsad":"CDP","cdp":true,"polygons":[[[-860242,429719,453,3,7,-146,592,2,1,-36,251,0,0,-36,50,0,-1,36,54,7,0,30,74,0,-8,143,20,117,-11,22,-202,179,-241,-23,-124,49,-131,71,-98,16,-254,-37,-191,-59,-33,3,-73,45,-158,134]]]},{"geoid":"2601380","name":"Allen Park","full":"Allen Park city","lsad":"city","polygons":[[[-832298,422275,215,5,15,-9,22,-43,41,6,-1,48,-14,43,-2,134,-6,-2,59,103,122,146,-61,-4,-105,45,171,202,-48,2,-106,59,-11,-5,-34,-45,-14,-44,-161,-191,-38,-23,-65,-3]]]},{"geoid":"2601540","name":"Alma","full":"Alma city","lsad":"city","polygons":[[[-846877,433827,-1,-36,-8,0,13,-14,6,14,60,-1,-4,-63,31,7,17,-10,0,-81,48,0,0,-72,49,-1,0,14,17,-13,34,-1,-1,-58,14,-6,37,-4,0,94,-24,1,-42,46,266,-2,0,73,99,0,1,217,-203,3,0,12,-20,0,0,12,20,0,0,30,-14,0,4,13,-153,6,-19,-11,1,-25,-118,-2,0,-71,-50,-8,0,-18,50,0,0,-12,-124,1,-1,-10,25,0,0,-12,-30,0]]]},{"geoid":"2601660","name":"Almont","full":"Almont village","lsad":"village","polygons":[[[-830544,429325,13,1,35,-32,-12,-1,9,-9,-13,9,-12,-14,-32,-3,9,-146,201,8,4,-26,102,5,-10,50,-43,-4,-1,52,-59,-3,-8,31,-65,-4,-3,48,-44,-2,15,36,-2,34,-97,-4]]]},{"geoid":"2601740","name":"Alpena","full":"Alpena city","lsad":"city","polygons":[[[-834766,450652,51,1,7,-181,225,-1,-15,-6,4,-42,15,26,58,62,86,69,33,3,56,34,39,-14,22,12,4,56,13,1,24,-17,27,2,16,23,-2,11,15,5,34,-21,63,31,34,-8,2,-7,35,0,11,20,-3,25,21,12,5,18,36,5,-150,-1,-3,136,-106,0,3,-68,-309,-2,-1,74,55,1,0,37,47,0,0,74,-46,2,1,29,-55,3,-1,-19,-10,0,1,19,-77,3,-4,-20,-27,4,7,17,-64,-3,-27,-4,-1,-47,31,-20,32,-1,-6,-16,25,-15,-26,-3,-7,-58,10,-11,-14,-2,2,-9,27,1,1,-36,-175,-1,2,-168,-51,2]]]},{"geoid":"2601800","name":"Alpha","full":"Alpha village","lsad":"village","polygons":[[[-883879,460325,154,2,2,182,51,1,1,71,-51,0,-1,-36,-105,-1,1,-144,-52,0]]]},{"geoid":"2601960","name":"Amasa","full":"Amasa CDP","lsad":"CDP","cdp":true,"polygons":[[[-884923,462175,642,3,-50,8,-15,13,-6,264,12,35,-72,-11,-53,3,7,30,-14,-4,-53,-42,-8,-61,13,-5,-11,-16,-75,-8,-36,-23,-14,-40,-263,-1]]]},{"geoid":"2603000","name":"Ann Arbor","full":"Ann Arbor city","lsad":"city","polygons":[[[-837992,42Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"MN","state":"Minnesota","fips":"27","features":[{"geoid":"2700172","name":"Ada","full":"Ada city","lsad":"city","polygons":[[[-965325,472915,43,0,0,9,279,1,0,36,11,0,3,47,-15,0,0,26,-107,0,-18,36,-56,-1,-10,36,-23,0,0,-36,-16,0,0,-34,-91,-1,1,-73]]]},{"geoid":"2700190","name":"Adams","full":"Adams city","lsad":"city","polygons":[[[-927289,435581,199,2,-1,143,-200,0]]]},{"geoid":"2700262","name":"Adrian","full":"Adrian city","lsad":"city","polygons":[[[-959431,436393,0,-86,32,0,-1,-14,-31,-1,0,-58,143,2,-10,20,20,16,48,-1,-11,9,0,78,100,-10,-1,38,-52,21,1,-53,-48,26,-78,9,0,19,23,2,-1,26,-28,3,-6,13,-3,-36,-87,-1,1,6,-33,-14]]]},{"geoid":"2700316","name":"Afton","full":"Afton city","lsad":"city","polygons":[[[-928624,448619,933,1,54,101,3,21,-105,158,-10,28,3,53,15,33,135,65,20,33,20,74,-85,-1,9,-45,-5,-18,-100,-69,-20,4,0,73,-11,21,2,158,-49,2,2,179,-814,2]]]},{"geoid":"2700460","name":"Aitkin","full":"Aitkin city","lsad":"city","polygons":[[[-937343,465289,11,-11,149,27,-1,-45,82,1,0,-19,-33,0,-12,-15,-12,18,-5,-18,-12,-2,24,-1,1,-15,15,0,11,15,0,-37,-22,1,0,-17,-30,0,0,-20,-106,0,1,-37,52,0,1,-37,54,0,-1,37,53,0,-1,28,54,0,0,-25,25,-4,-21,-20,13,-52,194,-1,0,35,-106,2,1,36,-54,2,-1,16,-17,18,11,17,-17,33,21,16,54,7,0,48,23,0,0,-11,24,0,13,38,1,-37,45,0,1,67,-107,-7,0,14,53,8,-2,40,-35,0,0,22,36,0,1,-35,36,0,-1,34,112,3,15,35,-164,0,-3,9,-11,-4,-34,14,12,-27,-17,-14,3,-20,-14,3,-11,-18,-23,1,3,-15,-114,-1,-9,56,-36,-22,-16,-1,0,-48,-54,17,1,-24,-51,0,-1,-14,-53,0]]]},{"geoid":"2700496","name":"Akeley","full":"Akeley city","lsad":"city","polygons":[[[-947428,469917,264,-2,-1,215,-52,2,0,8,-48,-31,0,-10,-52,6,-52,-62,-43,-25,-18,6]]]},{"geoid":"2700622","name":"Albany","full":"Albany city","lsad":"city","polygons":[[[-945939,456313,116,-28,-11,-11,17,0,0,-17,17,0,-4,-28,21,0,0,24,15,0,30,-21,6,10,52,-4,-1,-20,52,0,0,-73,104,1,0,53,83,-14,0,34,-32,0,0,58,-119,9,32,18,1,40,-32,0,-35,47,-2,43,-128,-2,0,-36,-27,0,-5,-37,-38,0,-58,-24,0,-21,-41,4]]]},{"geoid":"2700676","name":"Alberta","full":"Alberta city","lsad":"city","polygons":[[[-960557,455715,66,-1,6,-15,28,4,0,66,18,17,-119,0]]]},{"geoid":"2700694","name":"Albert Lea","full":"Albert Lea city","lsad":"city","polygons":[[[-934186,436571,45,0,4,-74,26,0,-1,-20,106,-4,12,-21,-12,-14,31,-10,-11,-24,-102,0,0,-58,23,-15,78,-21,0,19,22,0,0,-18,28,0,0,8,38,-5,6,17,181,2,-9,-25,19,0,9,-44,-13,1,20,-14,0,-12,23,0,0,-15,77,1,0,-37,-18,0,1,-37,94,0,8,25,25,0,8,-23,26,-13,38,7,46,43,-30,0,0,37,-92,1,25,-21,-21,0,-3,-11,-17,32,-46,49,54,0,0,20,35,-1,0,-8,20,0,1,-18,21,0,0,11,10,-7,1,-18,16,-1,0,10,79,-19,18,-21,1,-62,-12,-5,0,-20,12,-3,0,-16,60,0,-1,18,43,1,96,-34,1,-45,51,0,1,-17,50,0,-1,36,-50,0,0,-17,-49,0,-1,47,-94,31,-47,1,-2,123,-58,1,0,32,-101,1,1,60,-22,13,-29,0,0,125,25,-4,4,18,49,5,1,-17,20,-5,0,20,42,0,7,-27,51,0,-1,27,20,0,15,34,42,1,0,19,35,18,0,-34,-13,0,1,-37,51,0,0,67,22,5,0,-22,26,-1,1,25,-50,0,-1,71,-99,0,-3,-42,-34,75,13,23,-7,17,-29,0,-2,71,-14,0,0,21,-38,3,6,-47,-26,0,0,-17,31,0,-4,-30,-171,2,12,70,18,0,-2,41,-145,24,0,120,31,32,-101,0,0,-16,38,-1,24,-8,-1,-11,-23,0,-10,-21,7,-9,25,0,1,-65,-295,23,1,-120,9,0,0,-9,43,0,-3,-73,-47,20,-2,56,-16,8,-34,-20,-6,-44,-40,0,-7,-10,-21,-4,24,0,0,-23,-42,0,22,-17,-11,0,13,-5,1,-15,18,0,-1,-33,-23,33,-21,1,1,-21,-48,-1,20,-12,-29,0,1,-18,-99,-1],[-933488,436658,99,0,0,71,100,0,-5,-12,-28,0,-20,-14,4,-116,-38,-17,4,16,-35,0,0,-20,-56,-2,3,22,-34,0]]]},{"geoid":"2700730","name":"Albertville","full":"Albertville city","lsad":"city","polygons":[[[-936849,452209,328,-1,1,27,25,8,155,0,2,137,-124,1,0,27,36,0,-4,117,-408,-2,-11,-303]]]},{"geoid":"2700838","name":"Alden","full":"Alden city","lsad":"city","polygons":[[[-935834,436623,199,0,-2,145,-197,0,0,-35]]]},{"geoid":"2700892","name":"Aldrich","full":"Aldrich city","lsad":"city","polygons":[[[-949466,463689,105,-1,0,44,52,-8,0,73,-156,-1,1,-73]]]},{"geoid":"2700928","name":"Alexandria","full":"Alexandria city","lsad":"city","polygons":[[[-954343,458256,23,-6,24,46,26,11,48,5,33,-4,31,-11,17,16,82,-2,0,-33,27,8,-1,25,103,1,0,64,19,17,32,0,0,-9,97,3,0,29,114,-33,1,74,197,0,16,13,-16,21,1,19,19,31,7,61,-24,11,-13,20,-2,16,-13,23,15,41,32,23,42,70,39,18,29,3,36,29,34,6,-3,6,-30,-1,-36,11,-15,19,-14,0,9,12,14,42,28,44,23,11,-17,5,-23,41,-7,36,12,19,28,10,10,11,-12,10,19,25,-9,3,-20,-25,-18,-16,-20,-7,-40,5,-14,19,-19,8,-38,-2,-67,23,-22,-3,-17,10,0,31,13,20,20,18,29,17,-49,-6,-28,11,-17,-24,-28,-13,-48,-12,-48,4,-23,-11,-24,16,21,24,19,38,25,20,28,11,28,1,17,-6,2,14,12,-2,-11,-13,26,-19,-9,42,-52,-6,5,3,-5,3,-46,-4,-13,-18,-11,-5,-25,0,-50,12,27,-17,4,-20,-17,-22,-46,-20,24,-20,13,-17,-2,-20,-9,-13,-31,-16,-18,-3,-10,-11,9,-34,-34,-7,-17,-12,-1,-36,-37,-5,-9,21,-33,-13,2,-23,-37,8,-19,-24,48,-19,74,5,9,-21,-55,1,38,-15,-1,-7,-36,0,-1,-35,-66,0,0,73,-37,1,0,-14,-58,0,25,-7,0,-12,-16,-5,48,-1,-1,-35,-106,0,2,-25,10,-11,94,-1,-1,-15,16,0,-1,-21,37,0,-1,-37,14,0,0,37,18,0,0,6,46,3,0,-9,25,0,1,-72,28,-1,-2,-18,-52,-1,0,-18,-57,-1,-48,-42,-10,11,-37,21,-1,-60,-31,0,-1,Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"st":"MO","state":"Missouri","fips":"29","features":[{"geoid":"2900244","name":"Adrian","full":"Adrian city","lsad":"city","polygons":[[[-943628,383946,20,0,0,-13,78,2,-3,-59,42,0,0,32,101,0,2,-109,102,-1,0,107,73,-1,0,36,-203,3,0,81,-22,45,-9,58,-27,-12,0,-24,-12,0,0,-40,-44,0,-1,-24,-93,-9,-2,-54]]]},{"geoid":"2900262","name":"Advance","full":"Advance city","lsad":"city","polygons":[[[-899277,370944,93,49,1,-19,91,-9,0,13,24,0,0,18,-66,0,0,22,43,-4,20,9,24,-8,0,16,92,-1,0,19,22,0,0,37,-67,0,-3,18,-41,0,0,15,-48,1,0,-32,-91,-1,-5,-36,-88,0]]]},{"geoid":"2900280","name":"Affton","full":"Affton CDP","lsad":"CDP","cdp":true,"polygons":[[[-903578,385629,63,-14,96,-54,-10,-74,-46,-15,33,-66,74,34,38,-7,-5,-34,-42,-34,-35,-14,12,-13,42,-17,112,-23,26,-11,26,-38,79,79,-43,32,-29,2,2,39,49,-4,47,2,1,10,53,-4,34,21,-19,29,-23,16,-55,-1,-9,19,-4,71,87,30,-64,52,-251,28,0,-27,-33,3,4,23,-32,4,-3,-15,-103,2,-1,-15,-38,0,-6,10,-40,-6]]]},{"geoid":"2900298","name":"Agency","full":"Agency village","lsad":"village","polygons":[[[-947877,396897,19,0,-1,-72,-16,-1,2,-71,59,0,1,65,38,0,7,-13,-1,-51,188,-2,-5,-290,139,-1,-1,-55,48,0,0,10,29,8,-14,6,-1,14,25,10,-38,25,0,26,-42,1,0,27,43,0,3,38,25,9,18,18,-22,40,2,27,30,12,-8,31,-18,12,0,25,-73,5,0,-23,-53,0,0,-48,-38,0,5,49,-5,17,-26,25,3,54,-74,1,0,71,-49,1,0,-33,-36,-1,-8,-15,-3,48,-43,2,-2,74,-108,-3]]],"label":[-94.74201290322581,39.66935]},{"geoid":"2900424","name":"Airport Drive","full":"Airport Drive village","lsad":"village","polygons":[[[-945295,371287,228,-6,13,289,-93,3,-6,-94,0,8,-37,6,0,14,-50,0,-2,-76,-46,2]]]},{"geoid":"2900496","name":"Alba","full":"Alba city","lsad":"city","polygons":[[[-944238,372350,28,-9,8,-22,67,0,-1,102,-83,1,-1,-61]]]},{"geoid":"2900514","name":"Albany","full":"Albany city","lsad":"city","polygons":[[[-943515,402488,51,1,0,-108,49,2,0,-37,156,1,-1,34,49,1,3,63,11,11,38,-2,1,110,-47,1,1,12,-113,-1,0,11,-19,-1,0,16,-76,-7,0,-19,-104,0]]]},{"geoid":"2900568","name":"Aldrich","full":"Aldrich village","lsad":"village","polygons":[[[-935545,375475,15,-20,41,3,11,10,2,45,-78,-1]]]},{"geoid":"2900604","name":"Alexandria","full":"Alexandria city","lsad":"city","polygons":[[[-914652,403549,16,-10,42,8,54,25,18,18,-31,70,-26,-18,-70,1]]]},{"geoid":"2900712","name":"Allendale","full":"Allendale village","lsad":"village","polygons":[[[-942961,404799,146,1,-1,108,-140,0]]]},{"geoid":"2900748","name":"Allenville","full":"Allenville village","lsad":"village","polygons":[[[-897574,372188,55,-1,-11,16,11,34,26,25,-45,-18,-9,4,-20,-19,-27,12,-9,-40]]]},{"geoid":"2900802","name":"Alma","full":"Alma city","lsad":"city","polygons":[[[-935513,390911,56,-2,25,25,-17,26,4,58,-66,3]]]},{"geoid":"2900910","name":"Altamont","full":"Altamont village","lsad":"village","polygons":[[[-940924,398826,13,34,110,-1,-17,64,-106,-13,-5,-26]]]},{"geoid":"2900928","name":"Altenburg","full":"Altenburg city","lsad":"city","polygons":[[[-896081,376264,132,2,-1,-53,32,0,0,13,14,0,-2,41,8,-11,90,1,-1,45,139,0,68,87,-37,16,-33,-66,-140,-1,0,27,-44,-1,2,-26,-96,-1,0,-36,-36,0,-8,16,-22,0,0,-19,-26,0,-1,37,-22,0,0,-25,18,-1,-3,-8,-22,0,-10,-13]]]},{"geoid":"2900964","name":"Alton","full":"Alton city","lsad":"city","polygons":[[[-914104,366950,21,0,1,-13,10,0,0,-39,-17,1,-1,-20,42,-1,-16,-9,-38,-48,70,-1,7,16,26,-10,30,20,14,25,34,-29,56,-23,121,-1,2,71,-105,7,-42,20,-21,19,-8,34,-32,1,1,30,15,23,-18,-11,-119,-3,1,-33,-33,-5]]]},{"geoid":"2901018","name":"Amazonia","full":"Amazonia village","lsad":"village","polygons":[[[-949000,398900,48,-34,0,-26,118,0,1,27,-22,8,-50,-1,-4,57,12,18,0,27,-16,-37,-87,-1]]]},{"geoid":"2901054","name":"Amity","full":"Amity town","lsad":"town","polygons":[[[-944386,398644,63,3,5,74,-62,-1,0,-29]]]},{"geoid":"2901072","name":"Amoret","full":"Amoret city","lsad":"city","polygons":[[[-945915,382514,81,-1,1,71,-78,3]]]},{"geoid":"2901090","name":"Amsterdam","full":"Amsterdam city","lsad":"city","polygons":[[[-945940,383420,97,-2,7,148,-104,2]]]},{"geoid":"2901198","name":"Anderson","full":"Anderson city","lsad":"city","polygons":[[[-944805,366475,122,-12,26,8,39,-2,-2,-11,46,-3,3,-45,21,0,6,16,17,16,37,1,23,33,19,10,50,-2,-13,-9,17,-37,-33,0,0,-15,9,-19,61,-12,6,20,-16,27,27,-2,-13,22,0,17,13,7,56,-57,47,-105,42,-62,-3,-16,35,-33,11,4,-19,74,-40,1,-23,34,-39,92,-91,98,15,-5,22,14,17,-7,21,15,17,-13,1,-28,64,-4,3,19,-55,5,-28,23,-8,67,-8,14,-43,0,-3,-32,-43,1,2,91,-15,0,3,28,-27,1,2,-41,-6,-12,-174,5,-4,-48,-26,3,23,-55,48,0,-2,-16,21,-2,-2,-21,15,-9,1,-23,-84,-6,-1,-6,-47,2,-18,-8,-117,11]]]},{"geoid":"2901252","name":"Annada","full":"Annada village","lsad":"village","polygons":[[[-908313,392614,30,0,2,-15,32,0,-29,35,-19,13,-16,1]]]},{"geoid":"2901270","name":"Annapolis","full":"Annapolis city","lsad":"city","polygons":[[[-907043,373566,16,-3,16,12,71,-8,19,6,14,12,-1,19,46,-11,0,25,-90,0,0,16,-30,20,-25,1,-53,-50]]]},{"geoid":"2901288","name":"Anniston","full":"Anniston town","lsad":"town","polygons":[[[-893334,368196,95,0,-1,37,48,0,0,54,-102,0,0,-18,-37Line truncated
|
||||
Loaded 100 of 1087 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user