Merge remote-tracking branch 'upstream/main' into fix/drone-airspace

Resolve the CURRENT-STATE.md layer table by keeping main's realigned
table and adding this layer's row. Main added an Arabic locale, so
translate the 12 layer strings and list ar.json in the drone-airspace
package.
This commit is contained in:
Matt Van Horn committed 2026-11-02 16:10:41 -08:00
commit 351a47ee22
1060 files changed
+69675 -20451

No files matched your search

+46 -34
View File
@@ -111,8 +111,8 @@ const cacheDir = path.resolve(
);
const only = args.includes('--only') ? args[args.indexOf('--only') + 1] : null;
async function fetchPinned(name) {
const { url, sha256 } = SOURCES[name];
export async function fetchPinned(name, source = SOURCES[name]) {
const { url, sha256 } = source;
const file = path.join(cacheDir, path.basename(new URL(url).pathname));
let bytes = await readFile(file).catch(() => null);
if (!bytes) {
@@ -137,7 +137,7 @@ const EARTH_RADIUS_KM = 6371;
const toRad = (d) => (d * Math.PI) / 180;
/** Spherical ring area (km²); matches src/data/naturalEarthRegions.js. */
function ringAreaKm2(ring) {
export function ringAreaKm2(ring) {
const n = ring.length;
if (n < 3) return 0;
let sum = 0;
@@ -151,7 +151,7 @@ function ringAreaKm2(ring) {
}
/** Signed planar (shoelace) area: positive counter-clockwise in lon/lat. */
function signedArea(ring) {
export function signedArea(ring) {
let sum = 0;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++)
sum += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1];
@@ -209,15 +209,16 @@ function douglasPeucker(points, tolerance) {
* Simplify a closed ring to an OPEN rounded ring. The ring is split at its
* farthest vertex from the start so Douglas-Peucker has two anchored halves
* (a closed ring has no chord to measure against). Returns null when fewer
* than four distinct vertices survive.
* than `minVertices` distinct vertices survive (four by default; a pack that
* keeps triangles passes three).
*/
function simplifyRing(ring, tolerance, decimals) {
function simplifyRing(ring, tolerance, decimals, minVertices = 4) {
let open = ring.slice();
const first = open[0];
const last = open[open.length - 1];
if (open.length > 1 && first[0] === last[0] && first[1] === last[1])
open = open.slice(0, -1);
if (open.length < 4) return null;
if (open.length < minVertices) return null;
let far = 1;
let farDist = -1;
for (let i = 1; i < open.length; i++) {
@@ -247,7 +248,7 @@ function simplifyRing(ring, tolerance, decimals) {
out[0][1] === out[out.length - 1][1]
)
out.pop();
return out.length >= 4 ? out : null;
return out.length >= minVertices ? out : null;
}
/**
@@ -255,16 +256,17 @@ function simplifyRing(ring, tolerance, decimals) {
* area floors (the largest part always stays), orient outers
* counter-clockwise and holes clockwise, sort parts by area descending.
*/
function simplifyPolygons(polygons, params, tolerance, decimals) {
export function simplifyPolygons(polygons, params, tolerance, decimals) {
const minVertices = params.minRingVertices ?? 4;
const parts = [];
for (const poly of polygons) {
const outer = simplifyRing(poly[0], tolerance, decimals);
const outer = simplifyRing(poly[0], tolerance, decimals, minVertices);
if (!outer) continue;
const area = ringAreaKm2(outer);
if (signedArea(outer) < 0) outer.reverse();
const holes = [];
for (const hole of poly.slice(1)) {
const ring = simplifyRing(hole, tolerance, decimals);
const ring = simplifyRing(hole, tolerance, decimals, minVertices);
if (!ring || ringAreaKm2(ring) < params.minHoleKm2) continue;
if (signedArea(ring) > 0) ring.reverse();
holes.push(ring);
@@ -287,18 +289,22 @@ function simplifyPolygons(polygons, params, tolerance, decimals) {
* and a fifth of the minimum tolerance, or failing that unsimplified at two
* more decimals; the caller records `d`.
*/
function simplifyUnit(polygons, params) {
export function unitTolerance(polygons, params) {
const sourceArea = polygons.reduce(
(sum, poly) => sum + ringAreaKm2(poly[0]),
0,
);
const tolerance = Math.min(
return Math.min(
params.maxToleranceDeg,
Math.max(
params.minToleranceDeg,
(params.toleranceFactor * Math.sqrt(sourceArea)) / 111.32,
),
);
}
export function simplifyUnit(polygons, params) {
const tolerance = unitTolerance(polygons, params);
const coarse = simplifyPolygons(polygons, params, tolerance, params.decimals);
if (coarse.length) return { polygons: coarse, decimals: params.decimals };
const fine = simplifyPolygons(
@@ -334,7 +340,7 @@ function encodeRing(ring, decimals) {
return out;
}
function encodeUnit({ polygons, decimals }) {
export function encodeUnit({ polygons, decimals }) {
return polygons.map((poly) => poly.map((ring) => encodeRing(ring, decimals)));
}
@@ -362,7 +368,7 @@ function bboxOf(polygons) {
// ── zip / shapefile / dbf ───────────────────────────────────────────────
/** Read named entries from a zip archive (stored or deflated). */
function unzip(buffer) {
export function unzip(buffer) {
let eocd = buffer.length - 22;
while (eocd >= 0 && buffer.readUInt32LE(eocd) !== 0x06054b50) eocd--;
if (eocd < 0) throw new Error('zip: end of central directory not found');
@@ -394,7 +400,7 @@ function unzip(buffer) {
}
/** Polygon records (shape type 5) as arrays of closed [lon, lat] rings. */
function readShp(buffer) {
export function readShp(buffer) {
const shapes = [];
let offset = 100;
while (offset < buffer.length) {
@@ -431,7 +437,7 @@ function readShp(buffer) {
}
/** dBASE records as plain objects (character and numeric fields). */
function readDbf(buffer) {
export function readDbf(buffer) {
const count = buffer.readUInt32LE(4);
const headerLength = buffer.readUInt16LE(8);
const recordLength = buffer.readUInt16LE(10);
@@ -458,7 +464,7 @@ function readDbf(buffer) {
}
/** Group shapefile rings into polygons: clockwise outers, holes assigned by containment. */
function shpPolygons(rings) {
export function shpPolygons(rings) {
const outers = [];
const holes = [];
for (const ring of rings) {
@@ -475,7 +481,7 @@ function shpPolygons(rings) {
return polygons;
}
function pointInRing(ring, x, y) {
export function pointInRing(ring, x, y) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i];
@@ -742,18 +748,24 @@ async function writePack(relative, pack) {
);
}
if (!only || only === 'countries')
await writePack(
'src/data/local_data/natural_earth/countries.json',
await buildCountries(),
);
if (!only || only === 'admin1')
await writePack(
'src/data/local_data/natural_earth/states_provinces.json',
await buildAdmin1(),
);
if (!only || only === 'counties')
await writePack(
'src/data/local_data/us_census_counties/counties.json',
await buildCounties(),
);
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
if (!only || only === 'countries')
await writePack(
'src/data/local_data/natural_earth/countries.json',
await buildCountries(),
);
if (!only || only === 'admin1')
await writePack(
'src/data/local_data/natural_earth/states_provinces.json',
await buildAdmin1(),
);
if (!only || only === 'counties')
await writePack(
'src/data/local_data/us_census_counties/counties.json',
await buildCounties(),
);
}
+418
View File
@@ -0,0 +1,418 @@
#!/usr/bin/env node
/**
* Build the bundled US places pack read by `src/data/placeBoundaries.js`:
*
* src/data/local_data/us_census_places/index.json state list, boxes, meta
* src/data/local_data/us_census_places/<ST>.json one file per state
* src/data/local_data/us_census_places/files.js their URLs, for bundlers
*
* Source: US Census Bureau 2025 cartographic boundary places
* (cb_2025_us_place_500k, public domain), pinned by URL and SHA-256, so a
* rerun reproduces the pack byte for byte. The download is cached outside the
* repository (default: the system temp directory).
*
* Geometry uses the county pack's simplification and encoding
* (scripts/build-admin-packs.mjs) with two differences: triangles are kept,
* and a unit whose simplified rings cross themselves or each other is
* simplified again at half the tolerance (finer precision last). Then
* `scripts/pack_geometry.py repair census` checks every feature as a whole
* MultiPolygon under GEOS and repairs the ones that are still invalid (rings
* touching themselves, overlapping parts), so it needs Python with
* shapely==2.1.2 on GEOS 3.13.1.
*
* Usage:
* node scripts/build-census-places.mjs [--cache <dir>] [--python <exe>]
*/
import { execFileSync } from 'node:child_process';
import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
PARAMS as ADMIN_PARAMS,
encodeUnit,
fetchPinned,
readDbf,
readShp,
shpPolygons,
simplifyPolygons,
unitTolerance,
unzip,
} from './build-admin-packs.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const OUT = path.join(ROOT, 'src/data/local_data/us_census_places');
export const SOURCE = Object.freeze({
url: 'https://www2.census.gov/geo/tiger/GENZ2025/shp/cb_2025_us_place_500k.zip',
sha256: 'ce0e4019ecd4123d03d53aaa936eed0459b82e3e14b89a3dcd4d5e8b3308627d',
});
/** County-pack parameters; triangles are valid rings here. */
export const PARAMS = Object.freeze({
...ADMIN_PARAMS.counties,
minRingVertices: 3,
maxRepairSteps: 8,
});
/** Census LSAD codes for census designated places (unincorporated). */
const CDP_LSAD = new Set(['55', '57', '62']);
// ── validity ─────────────────────────────────────────────────────────────
function orient(ax, ay, bx, by, cx, cy) {
const v = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
return v > 0 ? 1 : v < 0 ? -1 : 0;
}
function onSegment(ax, ay, bx, by, px, py) {
return (
Math.min(ax, bx) <= px &&
px <= Math.max(ax, bx) &&
Math.min(ay, by) <= py &&
py <= Math.max(ay, by)
);
}
/** Whether segments ab and cd share any point (touching counts). */
export function segmentsIntersect(a, b, c, d) {
const o1 = orient(a[0], a[1], b[0], b[1], c[0], c[1]);
const o2 = orient(a[0], a[1], b[0], b[1], d[0], d[1]);
const o3 = orient(c[0], c[1], d[0], d[1], a[0], a[1]);
const o4 = orient(c[0], c[1], d[0], d[1], b[0], b[1]);
if (o1 !== o2 && o3 !== o4) return true;
if (o1 === 0 && onSegment(a[0], a[1], b[0], b[1], c[0], c[1])) return true;
if (o2 === 0 && onSegment(a[0], a[1], b[0], b[1], d[0], d[1])) return true;
if (o3 === 0 && onSegment(c[0], c[1], d[0], d[1], a[0], a[1])) return true;
if (o4 === 0 && onSegment(c[0], c[1], d[0], d[1], b[0], b[1])) return true;
return false;
}
/**
* Whether segments of two different rings cross or overlap. Rings may touch
* at a point (a hole meeting its outer ring at a vertex is valid).
*/
function segmentsCross(s, t) {
const [a, b, c, d] = [s.a, s.b, t.a, t.b];
const o1 = orient(a[0], a[1], b[0], b[1], c[0], c[1]);
const o2 = orient(a[0], a[1], b[0], b[1], d[0], d[1]);
const o3 = orient(c[0], c[1], d[0], d[1], a[0], a[1]);
const o4 = orient(c[0], c[1], d[0], d[1], b[0], b[1]);
if (o1 && o2 && o3 && o4) return o1 !== o2 && o3 !== o4;
if (o1 === 0 && o2 === 0) {
// Collinear: overlapping by more than a point.
const key = a[0] !== b[0] ? 0 : 1;
const lo = Math.max(Math.min(a[key], b[key]), Math.min(c[key], d[key]));
const hi = Math.min(Math.max(a[key], b[key]), Math.max(c[key], d[key]));
return hi > lo;
}
// One endpoint on the other segment: a crossing only when the rings pass
// through each other there; a touch at a point is allowed.
return false;
}
/**
* Whether the rings of one part ([outer, ...holes], open, integer
* coordinates) are simple: no ring crosses or touches itself away from its
* shared vertices, no two rings cross or overlap (they may touch at a point),
* and no ring is flat.
*/
export function partIsValid(rings) {
const segments = [];
for (let r = 0; r < rings.length; r++) {
const ring = rings[r];
if (ring.length < 3) return false;
let twice = 0;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++)
twice += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1];
if (twice === 0) return false;
for (let i = 0; i < ring.length; i++) {
const a = ring[i];
const b = ring[(i + 1) % ring.length];
segments.push({
r,
i,
n: ring.length,
a,
b,
minX: Math.min(a[0], b[0]),
maxX: Math.max(a[0], b[0]),
});
}
}
segments.sort((s, t) => s.minX - t.minX);
for (let x = 0; x < segments.length; x++) {
const s = segments[x];
for (let y = x + 1; y < segments.length; y++) {
const t = segments[y];
if (t.minX > s.maxX) break;
if (s.r === t.r) {
const gap = Math.abs(s.i - t.i);
// Neighbours share exactly one vertex; that is not a crossing — unless
// they fold back over each other.
if (gap === 1 || gap === s.n - 1) {
if (s.n > 3 && collinearOverlap(s, t)) return false;
continue;
}
}
if (segmentsCross(s, t)) return false;
}
}
return true;
}
function collinearOverlap(s, t) {
const shared =
s.a === t.a || s.a === t.b ? s.a : s.b === t.a || s.b === t.b ? s.b : null;
if (!shared) return false;
const p = s.a === shared ? s.b : s.a;
const q = t.a === shared ? t.b : t.a;
if (orient(shared[0], shared[1], p[0], p[1], q[0], q[1]) !== 0) return false;
// Collinear and pointing the same way from the shared vertex: a spike.
return (
(p[0] - shared[0]) * (q[0] - shared[0]) +
(p[1] - shared[1]) * (q[1] - shared[1]) >
0
);
}
function toIntegers(polygons, decimals) {
const f = 10 ** decimals;
return polygons.map((poly) =>
poly.map((ring) =>
ring.map(([lon, lat]) => [Math.round(lon * f), Math.round(lat * f)]),
),
);
}
export function polygonsValid(polygons, decimals) {
return toIntegers(polygons, decimals).every(partIsValid);
}
/**
* Simplify a place like `simplifyUnit`, then halve the tolerance until every
* part is valid; then one more decimal; then the source rings at two more.
* @returns {{polygons: Array, decimals: number, repaired: boolean, valid: boolean}}
*/
export function simplifyPlace(source, params = PARAMS) {
let tolerance = unitTolerance(source, params);
const attempts = [];
for (let step = 0; step <= params.maxRepairSteps; step++) {
attempts.push([tolerance, params.decimals]);
tolerance /= 2;
}
attempts.push([params.minToleranceDeg / 5, params.decimals + 1]);
attempts.push([0, params.decimals + 1]);
attempts.push([0, params.decimals + 2]);
let first = null;
for (let i = 0; i < attempts.length; i++) {
const [tol, decimals] = attempts[i];
const polygons = simplifyPolygons(source, params, tol, decimals);
if (!polygons.length) continue;
first ||= { polygons, decimals };
if (polygonsValid(polygons, decimals))
return { polygons, decimals, repaired: i > 0, valid: true };
}
return first
? { ...first, repaired: false, valid: false }
: {
polygons: [],
decimals: params.decimals,
repaired: false,
valid: false,
};
}
// ── names ────────────────────────────────────────────────────────────────
const GOVERNMENT =
/\s+(?:metropolitan government|unified government|consolidated government|metro government|city and county|urban county)\b.*$/i;
/**
* Everyday names for records whose Census name is formal: "Nashville-Davidson
* metropolitan government (balance)" is also "Nashville"; "Urban Honolulu"
* is "Honolulu".
*/
export function placeAliases(name) {
const out = new Set();
let base = String(name).replace(/\s*\(balance\)\s*$/i, '');
if (base !== name) out.add(base);
const stripped = base.replace(GOVERNMENT, '');
if (stripped !== base) {
out.add(stripped);
base = stripped;
}
if (base !== name && /[-/]/.test(base)) out.add(base.split(/[-/]/)[0].trim());
const urban = /^Urban (.+)$/.exec(name);
if (urban) out.add(urban[1]);
out.delete(name);
return [...out].filter(Boolean).sort();
}
// ── pack ─────────────────────────────────────────────────────────────────
function bboxOf(polygons) {
let w = Infinity;
let s = Infinity;
let e = -Infinity;
let n = -Infinity;
for (const poly of polygons)
for (const [lon, lat] of poly[0]) {
if (lon < w) w = lon;
if (lon > e) e = lon;
if (lat < s) s = lat;
if (lat > n) n = lat;
}
return [w, s, e, n];
}
function union(boxes) {
return [
Math.min(...boxes.map((b) => b[0])),
Math.min(...boxes.map((b) => b[1])),
Math.max(...boxes.map((b) => b[2])),
Math.max(...boxes.map((b) => b[3])),
];
}
/**
* The box of a state's places. Alaska crosses the antimeridian: its box is
* taken with western longitudes shifted past 180 when that is narrower.
*/
function unionBox(boxes) {
const plain = union(boxes);
const shifted = union(
boxes.map(([w, s, e, n]) =>
e < 0 ? [w + 360, s, e + 360, n] : [w, s, e, n],
),
);
return shifted[2] - shifted[0] < plain[2] - plain[0] ? shifted : plain;
}
async function build() {
const params = PARAMS;
const source = await fetchPinned('places', SOURCE);
const files = unzip(source.bytes);
const base = path.basename(new URL(SOURCE.url).pathname, '.zip');
const shapes = readShp(files.get(`${base}.shp`));
const records = readDbf(files.get(`${base}.dbf`));
if (shapes.length !== records.length)
throw new Error('places: shp/dbf record counts differ');
const states = new Map();
const stats = { records: records.length, repaired: [], invalid: [] };
records.forEach((record, index) => {
const unit = simplifyPlace(shpPolygons(shapes[index]), params);
if (!unit.polygons.length) throw new Error(`no geometry: ${record.GEOID}`);
if (unit.repaired) stats.repaired.push(record.GEOID);
if (!unit.valid) stats.invalid.push(record.GEOID);
const alt = placeAliases(record.NAME);
const lsad = record.NAMELSAD.startsWith(record.NAME)
? record.NAMELSAD.slice(record.NAME.length).trim()
: '';
let state = states.get(record.STUSPS);
if (!state) {
state = {
st: record.STUSPS,
fips: record.STATEFP,
name: record.STATE_NAME,
features: [],
boxes: [],
};
states.set(record.STUSPS, state);
}
state.boxes.push(bboxOf(unit.polygons));
state.features.push({
geoid: record.GEOID,
name: record.NAME,
...(alt.length ? { alt } : {}),
full: record.NAMELSAD,
...(lsad ? { lsad } : {}),
...(CDP_LSAD.has(record.LSAD) ? { cdp: true } : {}),
...(unit.decimals !== params.decimals ? { d: unit.decimals } : {}),
polygons: encodeUnit(unit),
});
});
if (stats.invalid.length)
throw new Error(`places still invalid: ${stats.invalid.join(', ')}`);
await mkdir(OUT, { recursive: true });
for (const file of await readdir(OUT))
if (/^[A-Z]{2}\.json$/.test(file)) await rm(path.join(OUT, file));
const list = [...states.values()].sort((a, b) => a.st.localeCompare(b.st));
let bytes = 0;
for (const state of list) {
state.features.sort((a, b) => a.geoid.localeCompare(b.geoid));
const text =
JSON.stringify({
st: state.st,
state: state.name,
fips: state.fips,
features: state.features,
}) + '\n';
bytes += Buffer.byteLength(text);
await writeFile(path.join(OUT, `${state.st}.json`), text);
}
const round = (v) => Math.round(v * 1e4) / 1e4;
const index = {
meta: {
title: 'US Census Bureau cartographic boundary places',
source: base,
url: SOURCE.url,
sha256: source.digest,
license: 'Public domain (U.S. Government work, 17 U.S.C. § 105)',
schema:
'states[]: st (USPS), fips, name, count, bbox [w, s, e, n] (e > 180 across the antimeridian). <st>.json: st, state, fips, features[]: geoid, name (NAME), alt?[] (everyday names for formal records), full (NAMELSAD), lsad? (city, town, CDP, …), cdp? (census designated place), d? (decimals when not meta.decimals), label? [lon, lat] (verified filled anchor in degrees when the outer centroid is unfilled), polygons[][ring] (first ring outer, rest holes; each ring open and encoded as integers in 10^-d degrees, first vertex absolute then [dLon, dLat] deltas)',
decimals: params.decimals,
curation: params,
script: 'scripts/build-census-places.mjs',
},
states: list.map((s) => ({
st: s.st,
fips: s.fips,
name: s.name,
count: s.features.length,
bbox: unionBox(s.boxes).map(round),
})),
};
await writeFile(
path.join(OUT, 'index.json'),
JSON.stringify(index, null, 1) + '\n',
);
await writeFile(path.join(OUT, 'files.js'), filesModule(list));
const count = list.reduce((n, s) => n + s.features.length, 0);
console.log(
`us_census_places: ${count} places in ${list.length} files, ${bytes} bytes; ` +
`${stats.repaired.length} re-simplified to stay valid`,
);
const args = process.argv.slice(2);
const python = args.includes('--python')
? args[args.indexOf('--python') + 1]
: 'python3';
execFileSync(
python,
[path.join(ROOT, 'scripts/pack_geometry.py'), 'repair', 'census'],
{ stdio: 'inherit', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } },
);
}
/**
* The file list as literal URLs, so a bundler emits every state file as an
* asset and the loader can fetch one by its code.
*/
function filesModule(list) {
const lines = [
'// Generated by scripts/build-census-places.mjs; do not edit.',
"export const INDEX_URL = () => new URL('./index.json', import.meta.url);",
'export const STATE_URLS = {',
...list.map(
(s) => ` ${s.st}: () => new URL('./${s.st}.json', import.meta.url),`,
),
'};',
];
return lines.join('\n') + '\n';
}
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) await build();
+434
View File
@@ -0,0 +1,434 @@
#!/usr/bin/env python3
"""Maintainer-only Who's On First neighborhood pack build (Shapely 2.1).
Reads the pinned Who's On First neighbourhood, macrohood and microhood
archives and the WOF sources registry, keeps current polygon records whose
geometry source matches the reviewed open-licence allowlist, simplifies them
to about 15 m, then applies pinned source/ID exclusions after partitioning, and
writes src/data/local_data/wof_neighborhoods/:
index.json format, licences per source, tile list with boxes
<tile>.json records whose box centre falls in that quadtree tile
files.js their URLs, for bundlers
ATTRIBUTION.md per-source credits
Usage:
python scripts/build-wof-neighborhoods.py --cache /tmp/wof-neighborhoods [--verify]
Requires shapely==2.1.2 on GEOS 3.13.1 (checked). --verify rebuilds in memory
and fails unless every shipped file matches byte for byte.
Downloads go to --cache and are checked against the pinned SHA-256. The
neighbourhood archive is about 400 MB.
"""
import argparse
import collections
import hashlib
import json
import math
import subprocess
import tarfile
from pathlib import Path
from shapely import make_valid
from shapely.affinity import scale
from shapely.geometry import MultiPolygon, shape
import pack_geometry
ROOT = Path(__file__).resolve().parent.parent
PACK = ROOT / 'src/data/local_data/wof_neighborhoods'
# The legacy distribution is frozen (last published 2025-10-14) and only
# offers `-latest` names; the SHA-256 pins make a changed file fail loudly.
DIST = 'https://data.geocode.earth/wof/dist/legacy'
ARCHIVES = {
'neighbourhood': {
'url': DIST + '/whosonfirst-data-neighbourhood-latest.tar.bz2',
'sha256': '8e5e413aacf2244e4e4167fbd9be860bc407ae6de8b733e47f13abe6d3d10455',
},
'macrohood': {
'url': DIST + '/whosonfirst-data-macrohood-latest.tar.bz2',
'sha256': 'f5fbfabcb8229b83242dbd38530c27e61867e8b3b097e3393d9c841962a99679',
},
'microhood': {
'url': DIST + '/whosonfirst-data-microhood-latest.tar.bz2',
'sha256': 'e8197fa576e8831be8f091ac7e219d29224d89404ab0c56d377339e42a8f29ac',
},
}
# The registry at a fixed commit.
SOURCES = {
'url': 'https://raw.githubusercontent.com/whosonfirst/whosonfirst-sources/'
'e17af153c144fe6e08aa8db2607b4bf8aecd448d/data/sources-spec-latest.json',
'sha256': 'd8a5dd873d26eee11583de4438185c046dec33848d5bde27047ca4bff44d9c39',
}
TOLERANCE_M = 15
PRECISION = 5
TILE_MAX_BYTES = 200_000
TILE_MAX_DEPTH = 16
LICENSE_POLICY = json.loads((ROOT / 'scripts/wof-open-licenses.json').read_text())
ALLOWED_LICENSES = frozenset(LICENSE_POLICY['licenses'])
REVIEWED_SOURCES = LICENSE_POLICY.get('reviewedSources', {})
RECORD_EXCLUSIONS = LICENSE_POLICY['recordExclusions']
FIELDS = ['id', 'name', 'country', 'type', 'bbox', 'aliases', 'source', 'rings', 'label']
def fetch(cache, name, url, sha256):
path = cache / name
if not path.exists():
print('Downloading', url, flush=True)
# Plain urllib requests are refused by the WOF host; curl is not.
subprocess.run(['curl', '--fail', '-L', '-o', str(path), url], check=True)
digest = hashlib.sha256()
with open(path, 'rb') as handle:
for chunk in iter(lambda: handle.read(1 << 20), b''):
digest.update(chunk)
if digest.hexdigest() != sha256:
raise ValueError(f'{name}: SHA-256 {digest.hexdigest()} is not the pinned {sha256}')
return path
# ── geometry ────────────────────────────────────────────────────────────
def simplify(geometry):
"""Simplify at TOLERANCE_M in local metres, snap to the pack grid, keep it
valid, and order parts largest first. Returns (rings, label) where the
label is a point inside the largest part, or ([], None)."""
geometry = MultiPolygon(pack_geometry.polygon_parts(make_valid(geometry) if not geometry.is_valid else geometry))
if geometry.is_empty:
return [], None
lat = geometry.centroid.y
sx = 111320 * max(0.001, math.cos(math.radians(lat)))
sy = 110574
metric = scale(geometry, xfact=sx, yfact=sy, origin=(0, 0))
metric = metric.simplify(TOLERANCE_M, preserve_topology=True)
geometry = scale(metric, xfact=1 / sx, yfact=1 / sy, origin=(0, 0))
# To whole pack units (10^-PRECISION degrees), where GEOS is exact.
units = scale(geometry, xfact=10 ** PRECISION, yfact=10 ** PRECISION, origin=(0, 0))
parts = pack_geometry.ordered_parts(pack_geometry.settle(units))
if not parts:
return [], None
rings = [
[pack_geometry.encode_polyline(pack_geometry.open_ring(p.exterior.coords))]
+ [pack_geometry.encode_polyline(pack_geometry.open_ring(r.coords)) for r in p.interiors]
for p in parts
]
# Check what ships: the geometry decoded from the encoded strings.
decoded = [[pack_geometry.decode_polyline(r) for r in poly] for poly in rings]
why = pack_geometry.problems(decoded)
if why:
raise ValueError(f'encoded geometry invalid: {why}')
point = parts[0].representative_point()
point = shape({'type': 'Point', 'coordinates': [round(point.x) / 10 ** PRECISION, round(point.y) / 10 ** PRECISION]})
return rings, [point.x, point.y]
# ── records ─────────────────────────────────────────────────────────────
def read_archive(path, records):
with tarfile.open(path, 'r|bz2') as archive:
for member in archive:
if not member.name.endswith('.geojson') or '-alt-' in member.name:
continue
feature = json.load(archive.extractfile(member))
if feature['geometry']['type'] not in ('Polygon', 'MultiPolygon'):
records.pop(feature['properties']['wof:id'], None)
continue
records[feature['properties']['wof:id']] = feature
def keep(properties):
deprecated = properties.get('edtf:deprecated', 'uuuu') not in ('uuuu', '', None)
superseded = bool(properties.get('wof:superseded_by', []))
return properties.get('mz:is_current', -1) != 0 and not deprecated and not superseded
def source_table(spec):
lookup = {}
for source in spec.values():
for key in (source.get('name'), source.get('prefix')):
if key:
lookup[key] = source
return lookup
def verify_source_reviews(lookup):
"""Pin exceptional source decisions to the exact reviewed registry evidence."""
registry_commit = SOURCES['url'].split('/')[-3]
for key, review in REVIEWED_SOURCES.items():
source = lookup.get(key)
if not source:
raise ValueError(f'{key}: reviewed source is absent from the pinned registry')
if review.get('registryCommit') != registry_commit:
raise ValueError(f'{key}: review does not match the pinned registry commit')
if review.get('registrySha256') != SOURCES['sha256']:
raise ValueError(f'{key}: review does not match the pinned registry SHA-256')
if review.get('rawLicenseType') != source.get('license_type'):
raise ValueError(f'{key}: reviewed raw licence no longer matches the registry')
text = source.get('license_text') or ''
if review.get('licenseTextSha256') != hashlib.sha256(text.encode()).hexdigest():
raise ValueError(f'{key}: reviewed licence text no longer matches the registry')
if review['rawLicenseType'] not in ALLOWED_LICENSES:
raise ValueError(f'{key}: reviewed raw licence is not admitted by policy')
def licence_allowed(value):
"""Only exact raw registry strings reviewed in the policy are accepted."""
return isinstance(value, str) and value in ALLOWED_LICENSES
def source_allowed(key, source):
return (key != 'unknown' and bool(source) and
licence_allowed(source.get('license_type')))
def build_records(archives, lookup):
raw = {}
for kind in ('neighbourhood', 'macrohood', 'microhood'):
read_archive(archives[kind], raw)
print(kind, len(raw), flush=True)
features = []
excluded = collections.Counter()
for wof_id in sorted(raw):
properties = raw[wof_id]['properties']
if not keep(properties):
continue
key = properties.get('src:geom', 'unknown')
if not source_allowed(key, lookup.get(key)):
excluded[key] += 1
continue
name = properties['wof:name']
aliases = sorted({
alias
for k, values in properties.items()
if k.startswith('name:') and isinstance(values, list)
for alias in values
if isinstance(alias, str) and alias != name
})
if not name.strip() and not aliases:
continue # nothing to ask for it by
geometry = shape(raw[wof_id]['geometry'])
if geometry.is_empty:
continue
rings, label = simplify(geometry)
if not rings:
continue
country = properties.get('iso:country', properties.get('wof:country'))
features.append([
wof_id, name, country, properties['wof:placetype'],
[round(v, PRECISION) for v in geometry.bounds], aliases, key, rings, label,
])
return features, excluded
# ── tiles ───────────────────────────────────────────────────────────────
def compact(value):
return json.dumps(value, ensure_ascii=False, separators=(',', ':'))
def tiles(features, key='', box=(-180.0, -90.0, 180.0, 90.0)):
size = sum(len(compact(f).encode()) for f in features)
if size <= TILE_MAX_BYTES or len(key) >= TILE_MAX_DEPTH:
yield key or 'world', features
return
west, south, east, north = box
mx, my = (west + east) / 2, (south + north) / 2
quads = [[], [], [], []]
for f in features:
x = (f[4][0] + f[4][2]) / 2
y = (f[4][1] + f[4][3]) / 2
quads[(1 if x >= mx else 0) + (2 if y >= my else 0)].append(f)
boxes = [(west, south, mx, my), (mx, south, east, my), (west, my, mx, north), (mx, my, east, north)]
for i, quad in enumerate(quads):
if quad:
yield from tiles(quad, key + str(i), boxes[i])
def union_box(features):
return [
min(f[4][0] for f in features), min(f[4][1] for f in features),
max(f[4][2] for f in features), max(f[4][3] for f in features),
]
def exclude_records(partition):
"""Filter approved source+IDs AFTER the original byte-sized partition."""
if RECORD_EXCLUSIONS['archives'] != {k: a['sha256'] for k, a in ARCHIVES.items()}:
raise ValueError('record exclusions do not match the pinned archives')
if RECORD_EXCLUSIONS['registrySha256'] != SOURCES['sha256']:
raise ValueError('record exclusions do not match the pinned registry')
expected = {}
for source, rule in RECORD_EXCLUSIONS['sources'].items():
if rule['count'] != len(rule['ids']) or len(set(rule['ids'])) != len(rule['ids']):
raise ValueError(f'{source}: invalid exclusion membership')
for record_id in rule['ids']:
if record_id in expected:
raise ValueError(f'{record_id}: duplicate exclusion')
expected[record_id] = source
seen = set()
kept = []
for key, members in partition:
retained = []
for row in members:
record_id, source = row[0], row[6]
if record_id in expected:
if source != expected[record_id] or record_id in seen:
raise ValueError(f'{record_id}: exclusion source or membership changed')
seen.add(record_id)
elif source in RECORD_EXCLUSIONS['sources']:
raise ValueError(f'{record_id}: unreviewed exclusion-source member')
else:
retained.append(row)
if not retained:
raise ValueError(f'{key}: exclusion would change the tile layout')
kept.append((key, retained))
if seen != set(expected):
raise ValueError(f'missing excluded records: {sorted(set(expected) - seen)}')
return kept
# ── credits ─────────────────────────────────────────────────────────────
def licence_label(key, source):
licence = source['license_type']
if key == 'karmashapes':
return 'CC BY 4.0'
return {
'CC-BY': 'CC BY', 'Attribution 3.0 Unported': 'CC BY 3.0', 'Public domain': 'Public Domain',
'Open Government License - British Columbia, v2.0': 'Open Government Licence - British Columbia, v2.0',
}.get(licence, licence)
def exclusion_note():
groups = ', '.join(f'{rule["count"]} `{key}` records' for key, rule in RECORD_EXCLUSIONS['sources'].items())
lead = f'This derived pack excludes {groups}. ' if groups else ''
return f'{lead}{RECORD_EXCLUSIONS["reason"]} Original upstream notices remain unchanged.'
def attribution(counts, lookup):
lines = [
'# Neighborhood boundary credits',
'',
"Neighborhood boundaries from [Who's On First](https://whosonfirst.org/docs/licenses/) "
'and contributors, used under the licences below. Modified: boundaries simplified to about 15 m, repaired and re-encoded.',
'',
'| Source | Records | Licence | Terms |',
'|---|---:|---|---|',
]
for key, count in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])):
source = lookup[key]
name = source.get('fullname') or key
terms = source.get('license') or ''
link = f'[terms]({terms})' if terms.startswith('http') else terms
lines.append(f'| {name} (`{key}`) | {count:,} | {licence_label(key, source)} | {link} |')
via = [(key, lookup[key]) for key in sorted(counts) if lookup[key].get('src:via')]
if via:
lines += ['', '## Upstream sources', '']
for key, source in via:
lines += [f'### {source.get("fullname") or key}', '']
for v in source['src:via']:
note = f' ({v["source_note"]})' if v.get('source_note') else ''
context = f'{v["context"]}: ' if v.get('context') else ''
lines.append(f'- {context}{v.get("source_name", "")}{note} {v.get("source_link", "")}'.rstrip())
lines.append('')
while lines[-1] == '':
lines.pop()
lines += ['', '## Source-specific exclusions', '', exclusion_note()]
return '\n'.join(lines).rstrip() + '\n'
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--cache', type=Path, required=True)
parser.add_argument('--verify', action='store_true', help='rebuild in memory and compare with the shipped pack')
args = parser.parse_args()
pack_geometry.require_pinned_tooling()
args.cache.mkdir(parents=True, exist_ok=True)
archives = {
kind: fetch(args.cache, Path(a['url']).name, a['url'], a['sha256'])
for kind, a in ARCHIVES.items()
}
spec = json.loads(fetch(args.cache, 'sources-spec-latest.json', SOURCES['url'], SOURCES['sha256']).read_text())
lookup = source_table(spec)
verify_source_reviews(lookup)
features, excluded = build_records(archives, lookup)
if len({f[0] for f in features}) != len(features):
raise ValueError('duplicate WOF id')
out = {}
tile_list = []
# Partition the full pinned input first: early filtering can collapse tiles.
partition = exclude_records(list(tiles(features)))
features = [row for _, members in partition for row in members]
for key, members in partition:
members.sort(key=lambda f: f[0])
out[f'{key}.json'] = compact({'tile': key, 'features': members}) + '\n'
tile_list.append({'key': key, 'count': len(members), 'bbox': union_box(members)})
counts = collections.Counter(f[6] for f in features)
index = {
'meta': {
'title': "Who's On First neighbourhood, macrohood and microhood polygons",
'archives': ARCHIVES,
'sourcesRegistry': SOURCES,
'recordExclusions': {key: rule['count'] for key, rule in RECORD_EXCLUSIONS['sources'].items()},
'recordExclusionReason': RECORD_EXCLUSIONS['reason'],
'license': 'Per source; see sources and ATTRIBUTION.md',
'format': 'neighborhood-polylines-v1',
'fields': FIELDS,
'precision': PRECISION,
'coordinateOrder': 'lon,lat',
'toleranceM': TOLERANCE_M,
'schema': (
'tiles[]: key, count, bbox [w, s, e, n] of its records. <key>.json: tile, features[] as '
'fields: WOF id, name, ISO country, placetype, bbox, aliases[] (other-language names), '
'geometry source key, rings (polygons, largest first, each [outer, ...holes]; each ring '
'an encoded polyline string: zigzag varint deltas of lon then lat at 10^-precision '
'degrees), label [lon, lat] (a point inside the largest polygon)'
),
'sources': {
key: {
'name': lookup[key].get('fullname') or key,
'license': licence_label(key, lookup[key]),
'terms': lookup[key].get('license') or '',
'records': counts[key],
}
for key in sorted(counts)
},
'script': 'scripts/build-wof-neighborhoods.py',
},
'tiles': tile_list,
}
out['index.json'] = json.dumps(index, ensure_ascii=False, indent=1) + '\n'
out['ATTRIBUTION.md'] = attribution(counts, lookup)
# Literal URLs, so a bundler emits every tile as an asset.
out['files.js'] = '\n'.join([
'// Generated by scripts/build-wof-neighborhoods.py; do not edit.',
"export const INDEX_URL = () => new URL('./index.json', import.meta.url);",
'export const TILE_URLS = {',
*[f" t{t['key']}: () => new URL('./{t['key']}.json', import.meta.url)," for t in tile_list],
'};',
]) + '\n'
if args.verify:
# Reproducibility: a rebuild must match what ships, byte for byte.
shipped = {p.name for p in PACK.glob('*.json')} | {'ATTRIBUTION.md', 'files.js'}
differ = sorted(
name for name in shipped | set(out)
if not (PACK / name).exists() or name not in out
or (PACK / name).read_bytes() != out[name].encode('utf-8')
)
if differ:
raise SystemExit(f'rebuild differs from the shipped pack: {differ[:10]}')
print(f'wof_neighborhoods: rebuild matches all {len(out)} shipped files')
return
PACK.mkdir(parents=True, exist_ok=True)
for old in PACK.glob('*.json'):
old.unlink()
for name, text in out.items():
(PACK / name).write_text(text, encoding='utf-8')
print(f'wof_neighborhoods: {len(features)} records in {len(tile_list)} tiles; '
f'{sum(rule["count"] for rule in RECORD_EXCLUSIONS["sources"].values())} source-specific record exclusions; '
f'excluded {sum(excluded.values())} with an unapproved licence: {dict(excluded)}', flush=True)
if __name__ == '__main__':
main()
+10 -3
View File
@@ -1,6 +1,8 @@
import { readFile, realpath } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import vue from '@vitejs/plugin-vue';
import { VUE_PLUGIN_OPTIONS } from '../build/vueOptions.js';
import { build, normalizePath } from 'vite';
/** Build each browser or Node export group and reject imports outside its declared ownership. */
@@ -99,15 +101,20 @@ export async function checkPackageBoundaries(root) {
ssr: node ? { noExternal: true } : undefined,
logLevel: 'silent',
plugins: [
vue(VUE_PLUGIN_OPTIONS),
{
name: 'check-package-ownership',
moduleParsed(info) {
if (!allowed.has(info.id)) {
// Plugin helpers are virtual; a single-file component's style and
// template blocks belong to the component file itself.
if (info.id.startsWith('\0')) return;
const id = info.id.split('?')[0];
if (!allowed.has(id)) {
throw new Error(
`Package boundary ${name} imports an unowned module: ${path.relative(root, info.id)}`,
`Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`,
);
}
seen.add(info.id);
seen.add(id);
},
},
],
+91 -6
View File
@@ -29,6 +29,8 @@
"src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js",
"src/data/inputOwnership.js",
"src/annotations/drawMarks.js",
"src/annotations/drawMarks.test.mjs",
"src/annotations/drawMode.js",
"src/annotations/drawMode.test.mjs",
"src/annotations/drawTool.js",
@@ -68,6 +70,7 @@
"server/standalone/vite.config.js",
"vite.config.js",
"src/tooling/viteBuild.test.mjs",
"src/tooling/vueLoader.test.mjs",
"src/tooling/allowedHosts.test.mjs",
"server/providers/common/http.js",
"server/providers/common/query.js",
@@ -180,6 +183,8 @@
"src/ui/panelDisclosure.js",
"src/ui/imagerySplit.js",
"src/ui/imagerySplit.test.mjs",
"src/ui/imagerySplitView.test.mjs",
"src/ui/stores/imagerySplit.js",
"src/ui/surfaceKeyboard.js",
"src/ui/surfaceKeyboard.test.mjs",
"src/ui/panelRails.js",
@@ -197,9 +202,7 @@
"scripts/qa-scope-hud.mjs",
"scripts/qa-segmented-controls.mjs",
"src/ui/displayControls.js",
"src/ui/segmentedControl.test.mjs",
"src/ui/styles/segmented-control.css",
"src/ui/displayControls.test.mjs",
"src/ui/cameraOrientationControls.js",
"src/ui/cameraOrientationControls.test.mjs",
"src/ui/effects.js",
@@ -297,6 +300,8 @@
"src/ui/styles.js",
"src/ui/shellLifecycle.test.mjs",
"src/testSupport/readStylesheet.mjs",
"src/testSupport/voiceUi.mjs",
"src/testSupport/voiceView.mjs",
"src/testSupport/streetLevelFakes.mjs",
"src/splitFlap.js",
"style.css",
@@ -534,6 +539,7 @@
"scripts/qa-traffic-navigation.mjs",
"src/ui/frameRateMonitor.js",
"src/ui/frameRateMonitor.test.mjs",
"src/ui/frameRateView.test.mjs",
"src/layers/cctv/lifecycle.js",
"src/layers/cctv/controls.js",
"src/layers/cctv/geometry.js",
@@ -658,7 +664,6 @@
"src/voice/realtimeBackend.test.mjs",
"src/agent/worldPacket.test.mjs",
"src/voice/layerSummary.js",
"build/application-html.js",
"src/app/sources.js",
"src/ui/composition.js",
"src/services/application.js",
@@ -749,6 +754,8 @@
"server/providers/openai/toolDescriptions.js",
"src/voice/session.js",
"src/voice/sessionCommands.js",
"src/ui/stores/pointerReticle.js",
"src/voice/pointerReticleView.test.mjs",
"src/voice/realtimeSession.js",
"src/voice/realtimeEvents.js",
"src/voice/session.test.mjs",
@@ -908,6 +915,7 @@
"scripts/qa-weather-swap.mjs",
"scripts/qa-weather-teardown.mjs",
"src/layers/weather/imageryHost.test.mjs",
"src/layers/weather/display.test.mjs",
"src/sdr/adsbDecoder.test.mjs",
"src/sdr/controller.test.mjs",
"src/sdr/decoderDiagnostics.test.mjs",
@@ -920,6 +928,8 @@
"src/sources/adsbRecords.test.mjs",
"src/layers/localAdsb/index.test.mjs",
"src/ui/localSdrControls.test.mjs",
"src/ui/sdrCardActions.test.mjs",
"src/ui/radioPanelActions.test.mjs",
"src/ui/localSdrMarkup.test.mjs",
"src/ui/localSdrPresentation.test.mjs",
"src/data/localReceiversProxy.test.mjs",
@@ -1105,7 +1115,6 @@
"server/providers/overpass/requestAdmission.test.mjs",
"src/tooling/trackArrivalObservation.test.mjs",
"src/ui/styles/status-badge.css",
"src/ui/statusBadge.test.mjs",
"src/ui/cctvBadges.test.mjs",
"scripts/qa-status-badges.mjs",
"src/dockerTrustedPeers.test.mjs",
@@ -1115,12 +1124,13 @@
"src/hudSummaryGate.test.mjs",
"src/voice/hudPlaceLabels.test.mjs",
"src/voice/panel/preferences.test.mjs",
"src/voice/panel/preferencesView.test.mjs",
"src/voice/voiceControlView.test.mjs",
"src/voice/panel/host.test.mjs",
"src/voice/panel/manualPanel.test.mjs",
"src/voice/panel/nativeActivity.test.mjs",
"src/voice/panel/inactivity.test.mjs",
"src/voice/panel/actionObserver.test.mjs",
"src/voice/panel/testDom.mjs",
"src/voice/realtimeCostPanel.test.mjs",
"docs/SHARE-LINK-FORMAT.md",
"src/share/wire.test.mjs",
@@ -1129,5 +1139,80 @@
"src/testSupport/shareViewFixtures.mjs",
"src/ui/shareLinks.test.mjs",
"src/ui/shareButton.test.mjs",
"src/share/shareLinkPath.test.mjs"
"src/share/shareLinkPath.test.mjs",
"src/ui/kit/UiRail.test.mjs",
"src/ui/kit/UiSegmented.vue",
"src/ui/kit/UiSegmented.test.mjs",
"src/ui/kit/UiSlider.js",
"src/ui/kit/UiSlider.test.mjs",
"src/ui/kit/UiButton.vue",
"src/ui/kit/UiButton.test.mjs",
"src/ui/kit/UiChip.vue",
"src/ui/kit/UiChip.test.mjs",
"src/ui/globeActions.test.mjs",
"src/ui/safeFrameView.test.mjs",
"src/ui/kit/UiSelect.js",
"src/ui/kit/UiSelect.test.mjs",
"src/ui/kit/UiBadge.vue",
"src/ui/kit/UiBadge.test.mjs",
"src/ui/kit/UiEmpty.vue",
"src/ui/rail/RailChips.vue",
"src/ui/displayPanelActions.test.mjs",
"src/testSupport/vueDom.mjs",
"build/vueLoader.js",
"build/registerVue.js",
"src/ui/panelNavigation.test.mjs",
"src/ui/stores/owners.js",
"src/ui/stores/owners.test.mjs",
"src/ui/stores/panels.test.mjs",
"src/ui/stores/status.js",
"src/ui/statusChipsView.test.mjs",
"src/ui/statusChipOwners.test.mjs",
"src/ui/toast.js",
"src/ui/toastView.test.mjs",
"src/ui/stores/sceneInteractions.js",
"src/scenes/dataPacks/presentationView.test.mjs",
"src/ui/stores/dataPacks.js",
"src/scenes/interactionsView.test.mjs",
"src/layers/wind/inspectionMarker.js",
"src/layers/wind/inspectionMarker.test.mjs",
"src/ui/stores/windInspection.js",
"src/ui/stores/missionReplay.js",
"src/layers/launches/replayOverlay.test.mjs",
"src/ui/stores/bhoteKoshiPanel.js",
"src/data/bhoteKoshiPanelView.test.mjs",
"src/ui/stores/celestialRing.js",
"src/celestialRingView.test.mjs",
"src/ui/stores/screenAnnotations.js",
"src/ui/stores/worldOverlayActions.js",
"src/overlays/worldOverlayActionsView.test.mjs",
"src/ui/stores/awarenessDirection.js",
"src/layers/awareness/directionOverlay.test.mjs",
"src/hudReadouts.js",
"src/ui/stores/hud.js",
"src/ui/views/intelHudView.test.mjs",
"src/testSupport/hudReadouts.mjs",
"src/ui/stores/firstRun.js",
"src/firstRunLauncherView.test.mjs",
"src/firstRunCopy.js",
"src/data/headingDegrees.test.mjs",
"src/data/distanceFormat.test.mjs",
"src/sources/adsbFields.test.mjs",
"src/sources/phaseTiming.test.mjs",
"scripts/build-census-places.mjs",
"src/data/placeBoundaries.test.mjs",
"src/data/boundaryLabels.test.mjs",
"src/data/packGeometry.test.mjs",
"src/annotations/placeOutline.test.mjs",
"src/annotations/outlineIdentity.test.mjs",
"src/annotations/outlineRungs.test.mjs",
"src/annotations/outlineRungsFallback.test.mjs",
"src/search/photonSetting.test.mjs",
"src/sources/nominatimOutlines.test.mjs",
"src/sources/openFreeMapOutlines.test.mjs",
"src/sources/openFreeMapOutlinesEdges.test.mjs",
"src/standalone/layerSources.test.mjs",
"src/tooling/nominatimGuardLifecycle.test.mjs",
"src/tooling/nominatimLockOwnership.test.mjs",
"src/tooling/nominatimOutlineRoute.test.mjs"
]
+1 -1
View File
@@ -39,7 +39,7 @@ export async function discoverRuntimeFormatFiles(root) {
const files = [];
for (const name of new Set(candidates)) {
if (
!/\.[cm]?js$/.test(name) ||
!/\.([cm]?js|vue)$/.test(name) ||
/\.test\.[cm]?js$/.test(name) ||
!existsSync(path.join(root, name))
)
+61 -6
View File
@@ -8,7 +8,7 @@
// lists it.
//
// Messages are the English literals passed to t(), tn() and msg() in src/,
// plus the markup opted in with data-i18n* in src/ui/templates and index.html.
// plus the markup opted in with data-i18n* in index.html and the Vue views.
// It also lists UI text that is not marked at all (see findUnmarked).
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
@@ -119,10 +119,10 @@ export function extractMessages() {
}
}
const markup = trackedFiles('index.html', 'src/ui/templates/*.html');
const markup = markupFiles();
const TAG = /<([a-zA-Z][\w-]*)(\s[^>]*?)\/?>([^<]*)/gs;
for (const file of markup) {
const html = readFileSync(path.join(ROOT, file), 'utf8');
const html = readMarkup(file);
for (const [, tag, attrs, text] of html.matchAll(TAG)) {
const where = `${file} <${tag}>`;
if (/\sdata-i18n(\s|=|$)/.test(attrs)) {
@@ -156,7 +156,7 @@ const SKIP_TAGS = new Set(['script', 'style', 'svg', 'code', 'pre', 'title']);
/**
* User-visible markup text without data-i18n*. Skipped, with everything
* inside: translate="no" (text that stays as is, as in HTML),
* data-i18n-runtime (code sets this text through t()), and icons
* data-i18n-runtime (page markup whose text code sets through t()), and icons
* (aria-hidden, Material Symbols).
*/
export function unmarkedMarkup(file, html) {
@@ -404,9 +404,12 @@ export function unmarkedScript(file, original) {
*/
export function findUnmarked() {
const found = [];
for (const file of trackedFiles('index.html', 'src/ui/templates/*.html'))
for (const file of markupFiles()) {
found.push(...unmarkedMarkup(file, readMarkup(file)));
}
for (const file of trackedFiles('src/**/*.vue'))
found.push(
...unmarkedMarkup(file, readFileSync(path.join(ROOT, file), 'utf8')),
...markedTemplate(file, readFileSync(path.join(ROOT, file), 'utf8')),
);
for (const file of sourceFiles().filter(
(name) => !NOT_UI.some((pattern) => pattern.test(name)),
@@ -423,6 +426,57 @@ export function findUnmarked() {
});
}
/**
* data-i18n* in a Vue template: views translate with t() as they render, so
* their text follows the locale; the attributes are for static page markup.
* Reads the whole single-file component, so lines are the file's own.
*/
export function markedTemplate(file, source) {
const start = source.indexOf('<template>');
if (start === -1) return [];
const end = source.lastIndexOf('</template>');
// Comments and attribute values are blanked, keeping every offset.
const blank = (text) => text.replace(/[^\n]/g, ' ');
const template = source
.slice(0, end === -1 ? undefined : end)
.replace(/<!--[\s\S]*?-->/g, blank);
const found = [];
const TAG = /<[a-zA-Z][\w-]*((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
TAG.lastIndex = start + '<template>'.length;
for (let tag; (tag = TAG.exec(template));) {
const attrs = tag[1].replace(/"[^"]*"|'[^']*'/g, blank);
const offset = tag.index + tag[0].length - 1 - tag[1].length;
for (const match of attrs.matchAll(
/(?<=\s)(?::|v-bind:)?(data-i18n[\w-]*)(?=[\s=/]|$)/g,
))
found.push({
where: `${file}:${lineAt(source, offset + match.index)}`,
text: `${match[1]}: use t() in a Vue template`,
});
}
return found;
}
/** Markup checked for unmarked text: the page and its views. */
function markupFiles() {
return trackedFiles('index.html', 'src/ui/views/*.vue');
}
/**
* A file's markup; for a single-file component, its template block without
* interpolations, which are code: their t() calls are read with the sources.
*/
function readMarkup(file) {
const source = readFileSync(path.join(ROOT, file), 'utf8');
if (!file.endsWith('.vue')) return source;
const start = source.indexOf('<template>');
const end = source.lastIndexOf('</template>');
if (start === -1) return '';
return source
.slice(start + '<template>'.length, end)
.replace(/\{\{[\s\S]*?\}\}/g, '');
}
// Text that never reaches the page as UI: tool results and descriptions for
// AI clients (MCP and the voice model).
const NOT_UI = [/^src\/tools\//];
@@ -433,6 +487,7 @@ function sourceFiles() {
'src/*.mjs',
'src/**/*.js',
'src/**/*.mjs',
'src/**/*.vue',
).filter(
(file) =>
!file.startsWith('src/i18n/') &&
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""Maintainer-only topology check and repair for the bundled outline packs.
python scripts/pack_geometry.py check census|wof every shipped feature,
decoded as a whole MultiPolygon, is valid under GEOS, keeps at least
three distinct vertices per ring, and lists its largest part first
python scripts/pack_geometry.py repair census rewrite the Census place
files so every feature passes `check` (run by build-census-places.mjs)
Geometry is checked and repaired in the packs' own integer units (10^-d
degrees), where GEOS arithmetic is exact: a vertex that lies on another edge
after rounding is seen as the touch it is.
Requires Shapely 2.1.2 on GEOS 3.13.1, pinned so repairs are reproducible.
"""
import argparse
import json
import math
import sys
from pathlib import Path
import shapely
from shapely import make_valid, set_precision
from shapely.geometry import MultiPolygon, Polygon
from shapely.geometry.polygon import orient
ROOT = Path(__file__).resolve().parent.parent
DATA = ROOT / 'src/data/local_data'
SHAPELY_VERSION = '2.1.2'
GEOS_VERSION = (3, 13, 1)
def require_pinned_tooling():
if shapely.__version__ != SHAPELY_VERSION or shapely.geos_version != GEOS_VERSION:
raise RuntimeError(
f'Use shapely=={SHAPELY_VERSION} on GEOS {".".join(map(str, GEOS_VERSION))}; '
f'found {shapely.__version__} on GEOS {shapely.geos_version}'
)
def compact(value):
return json.dumps(value, ensure_ascii=False, separators=(',', ':'))
# ── encodings ────────────────────────────────────────────────────────────
def decode_deltas(encoded):
"""[x0, y0, dx1, dy1, …] → [(x, y), …] in pack units (Census/admin packs)."""
x = y = 0
ring = []
for i in range(0, len(encoded) - 1, 2):
x += encoded[i]
y += encoded[i + 1]
ring.append((x, y))
return ring
def encode_deltas(coords):
out = []
px = py = 0
for fx, fy in coords:
x, y = round(fx), round(fy)
out += [x - px, y - py]
px, py = x, y
return out
def decode_polyline(encoded):
"""Zigzag varint deltas, 5-bit groups offset by 63, lon then lat; pack units."""
values = []
last = [0, 0]
i = 0
while i < len(encoded):
n = shift = 0
while True:
b = ord(encoded[i]) - 63
i += 1
n |= (b & 31) << shift
shift += 5
if b < 32:
break
axis = len(values) % 2
last[axis] += ~(n >> 1) if n & 1 else n >> 1
values.append(last[axis])
return list(zip(values[::2], values[1::2]))
def encode_polyline(coords):
out = []
last = [0, 0]
for x, y in coords:
for i, value in enumerate([round(x), round(y)]):
n = value - last[i]
last[i] = value
n = (n << 1) ^ (n >> 63)
while n >= 32:
out.append(chr((32 | (n & 31)) + 63))
n >>= 5
out.append(chr(n + 63))
return ''.join(out)
# ── geometry ─────────────────────────────────────────────────────────────
def polygon_parts(geometry):
if geometry.geom_type == 'Polygon':
return [] if geometry.is_empty else [geometry]
if hasattr(geometry, 'geoms'):
return [p for g in geometry.geoms for p in polygon_parts(g)]
return []
def open_ring(coords):
coords = [tuple(c[:2]) for c in coords]
return coords[:-1] if len(coords) > 1 and coords[0] == coords[-1] else coords
def ordered_parts(geometry):
"""Polygon parts oriented (outer counter-clockwise), largest area first."""
parts = [orient(p, 1.0) for p in polygon_parts(geometry)]
return sorted(parts, key=lambda p: (-p.area, p.bounds))
def on_grid(geometry):
"""Snap to whole pack units keeping the result valid; polygon parts only."""
snapped = set_precision(geometry, 1, mode='valid_output')
return MultiPolygon(polygon_parts(snapped))
def settle(geometry):
"""A valid MultiPolygon of `geometry` (in pack units) on whole units."""
if not geometry.is_valid:
geometry = make_valid(geometry)
geometry = on_grid(MultiPolygon(polygon_parts(geometry)))
for _ in range(3):
if geometry.is_valid:
break
geometry = on_grid(make_valid(geometry))
return geometry
def rings_ok(polygons):
return all(len(set(ring)) >= 3 for poly in polygons for ring in poly)
def problems(polygons):
"""Why decoded `polygons` ([[outer, *holes], …]) fail the pack contract."""
if not polygons or not rings_ok(polygons):
return 'ring with fewer than three distinct vertices'
try:
geometry = MultiPolygon([Polygon(p[0], p[1:]) for p in polygons])
except Exception as error: # noqa: BLE001 - malformed input is a finding
return f'unbuildable: {error}'
if not geometry.is_valid:
return shapely.is_valid_reason(geometry)
areas = [part.area for part in geometry.geoms]
if max(areas) > areas[0]:
return 'largest part is not first'
return None
# ── packs ────────────────────────────────────────────────────────────────
def census_features():
folder = DATA / 'us_census_places'
decimals = json.loads((folder / 'index.json').read_text())['meta']['decimals']
for path in sorted(folder.glob('[A-Z][A-Z].json')):
pack = json.loads(path.read_text(encoding='utf-8'))
yield path, pack, decimals
def census_polygons(feature):
return [[decode_deltas(r) for r in poly] for poly in feature['polygons']]
def census_degrees(feature, decimals):
factor = 10 ** feature.get('d', decimals)
return [[[(x / factor, y / factor) for x, y in ring]
for ring in poly] for poly in census_polygons(feature)]
def ring_contains(ring, lat, lon):
"""Match adminBoundaries.pointInRing on final degree-decoded vertices."""
inside = False
for i in range(len(ring)):
xi, yi = ring[i]
xj, yj = ring[i - 1]
if (yi > lat) != (yj > lat) and lon < (xj - xi) * (lat - yi) / (yj - yi) + xi:
inside = not inside
return inside
def label_is_filled(polygons, label):
if not isinstance(label, (list, tuple)) or len(label) != 2:
return False
lon, lat = label
if not all(isinstance(n, (int, float)) and math.isfinite(n) for n in label):
return False
return any(ring_contains(poly[0], lat, lon)
and not any(ring_contains(hole, lat, lon) for hole in poly[1:])
for poly in polygons)
def census_label(feature, polygons):
"""The effective runtime label, including its area-weighted fallback."""
if isinstance(feature.get('label'), list):
return feature['label']
ring = polygons[0][0]
area = cx = cy = 0
for i in range(len(ring)):
x0, y0 = ring[i - 1]
x1, y1 = ring[i]
cross = x0 * y1 - x1 * y0
area += cross
cx += (x0 + x1) * cross
cy += (y0 + y1) * cross
return [cx / (3 * area), cy / (3 * area)] if area else list(ring[0])
def wof_features():
folder = DATA / 'wof_neighborhoods'
index = json.loads((folder / 'index.json').read_text(encoding='utf-8'))
for tile in index['tiles']:
pack = json.loads((folder / f'{tile["key"]}.json').read_text(encoding='utf-8'))
for feature in pack['features']:
yield feature, index['meta']['precision']
def check(pack):
bad = []
total = 0
if pack == 'census':
for _, data, decimals in census_features():
for feature in data['features']:
total += 1
why = problems(census_polygons(feature))
if not why:
polygons = census_degrees(feature, decimals)
if not label_is_filled(polygons, census_label(feature, polygons)):
why = 'effective label is outside filled geometry'
if why:
bad.append((feature['geoid'], feature['name'], why))
else:
for feature, precision in wof_features():
total += 1
polygons = [[decode_polyline(r) for r in poly] for poly in feature[7]]
why = problems(polygons)
if why:
bad.append((feature[0], feature[1], why))
for row in bad[:20]:
print('invalid', *row)
print(f'{pack}: {total} features, {len(bad)} failing')
return not bad
def repair_census():
repaired = 0
labelled = 0
for path, data, decimals in census_features():
changed = False
for feature in data['features']:
polygons = census_polygons(feature)
if problems(polygons):
geometry = MultiPolygon([Polygon(p[0], p[1:]) for p in polygons if len(set(p[0])) >= 3])
parts = ordered_parts(settle(geometry))
feature['polygons'] = [
[encode_deltas(open_ring(p.exterior.coords))]
+ [encode_deltas(open_ring(r.coords)) for r in p.interiors]
for p in parts
]
feature.pop('label', None)
polygons = census_polygons(feature)
why = problems(polygons)
if why:
raise ValueError(f'{feature["geoid"]} {feature["name"]}: still {why}')
repaired += 1
changed = True
decoded = census_degrees(feature, decimals)
if label_is_filled(decoded, census_label(feature, decoded)):
continue
factor = 10 ** feature.get('d', decimals)
for poly in polygons:
point = Polygon(poly[0], poly[1:]).representative_point()
label = [point.x / factor, point.y / factor]
if label_is_filled(decoded, label):
feature['label'] = label
labelled += 1
changed = True
break
else:
raise ValueError(f'{feature["geoid"]} {feature["name"]}: no verified filled label')
if changed:
path.write_text(compact(data) + '\n', encoding='utf-8')
print(f'us_census_places: {repaired} topology repairs, {labelled} verified interior labels added')
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('command', choices=['check', 'repair'])
parser.add_argument('pack', choices=['census', 'wof'])
args = parser.parse_args()
require_pinned_tooling()
if args.command == 'repair':
if args.pack != 'census':
parser.error('the WOF build repairs its own geometry')
repair_census()
sys.exit(0 if check('census') else 1)
sys.exit(0 if check(args.pack) else 1)
if __name__ == '__main__':
main()
File diff suppressed because it is too large. Load diff
+27 -16
View File
@@ -1720,6 +1720,9 @@ try {
const priorSignalMaxHeight = signal.style.maxHeight;
signal.style.maxHeight = '150px';
manager._setCockpitDisclosure(kind, true);
// Let Vue render the disclosure, but not the scheduled layout frame: the
// first syncSignalLayout below must be the first pass.
await Promise.resolve();
// One pass must be enough. Record the result of the FIRST layout call, then
// confirm a second changes nothing, so a convergence regression cannot hide
// behind a repeated call.
@@ -1796,7 +1799,7 @@ try {
manager._setCockpitDisplayPortalActive(false);
document.body.classList.remove('cockpit-mode');
await waitFrames();
standard.classList.remove('collapsed');
manager._presentPanel(standard, { collapsed: false });
standard.style.height = '120px';
standard.style.maxHeight = '120px';
standard.style.overflowY = 'auto';
@@ -1831,7 +1834,7 @@ try {
cockpit.style.height = priorCockpitHeight;
cockpit.style.maxHeight = priorCockpitMaxHeight;
cockpit.style.overflowY = priorCockpitOverflow;
standard.classList.toggle('collapsed', standardWasCollapsed);
manager._presentPanel(standard, { collapsed: standardWasCollapsed });
return {
standardBefore,
standardAfter,
@@ -1851,7 +1854,7 @@ try {
JSON.stringify(portalScroll),
);
const boundary = await page.evaluate(() => {
const boundary = await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const hud = document.getElementById('cockpit-hud');
const signal = document.getElementById('cockpit-signal-stream');
@@ -1866,6 +1869,7 @@ try {
signal.hidden = false;
manager._setCockpitDisclosure('radio', false);
manager._setCockpitDisclosure('display', true);
await new Promise(requestAnimationFrame);
const priorSignalMaxHeight = signal.style.maxHeight;
const priorSignalHeight = signal.style.height;
// The strip is pinned to the viewport ceiling once the briefing card grows
@@ -2083,7 +2087,7 @@ try {
}
await page.evaluate(() => window.__godsEyeView.styleManager._setHudVariant('tactical'));
const signalTransition = await page.evaluate(() => {
const signalTransition = await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const cockpit = manager.cockpitView;
const signalToggle = document.getElementById('cockpit-signal-toggle');
@@ -2110,6 +2114,8 @@ try {
manager._setCockpitDisclosure('radio', true);
manager._setCockpitDisclosure('radio', false);
const manualCollapseRetained = cockpit.signalCollapsed && cockpit.signalUserCollapsed;
// The toggle renders the published collapse on Vue's next flush.
await new Promise((resolve) => requestAnimationFrame(resolve));
return {
afterExpansion,
afterRepeatedExpansion,
@@ -2136,7 +2142,7 @@ try {
&& signalTransition.signalAriaExpanded === 'false',
JSON.stringify(signalTransition),
);
const contextTransition = await page.evaluate(() => {
const contextTransition = await page.evaluate(async () => {
const cockpit = window.__godsEyeView.styleManager.cockpitView;
const priorCollapsed = cockpit.contextCollapsed;
const contextToggle = document.getElementById('cockpit-context-toggle');
@@ -2148,6 +2154,8 @@ try {
cockpit.setContextCollapsed(false);
const afterExpansion = expandedEvents;
cockpit.setContextCollapsed(false);
// The toggle renders the published collapse on Vue's next flush.
await new Promise((resolve) => requestAnimationFrame(resolve));
return {
afterExpansion,
afterRepeatedExpansion: expandedEvents,
@@ -2168,33 +2176,34 @@ try {
JSON.stringify(contextTransition),
);
// Use the real connected DOM to catch focus loss caused by moving live rows.
const signalFocus = await page.evaluate(() => {
const signalFocus = await page.evaluate(async () => {
const cockpit = window.__godsEyeView.styleManager.cockpitView;
// The rows render on Vue's next update; a frame is past it.
const show = async (items) => {
cockpit.signalItems = items;
cockpit.publishCockpitSignals();
await new Promise(requestAnimationFrame);
};
const originalItems = cockpit.signalItems;
const wasCollapsed = cockpit.signalCollapsed;
cockpit.setSignalCollapsed(false);
const item = (id) => ({ key: `qa-${id}`, tone: 'info', timestamp: Date.now(),
title: `QA ${id}`, detail: 'Keyboard refresh fixture', target: { layerId: 'flights', id } });
try {
cockpit.signalItems = [item('focus-a'), item('focus-b')];
cockpit.renderCockpitSignals();
await show([item('focus-a'), item('focus-b')]);
const button = cockpit.signalList.querySelector('[data-signal-id="focus-a"]');
button.focus();
const acquired = document.activeElement === button;
cockpit.signalItems = [item('focus-c'), item('focus-b'), item('focus-a')];
cockpit.renderCockpitSignals();
await show([item('focus-c'), item('focus-b'), item('focus-a')]);
const retained = document.activeElement === button && button.isConnected;
const order = [...cockpit.signalList.querySelectorAll('[data-signal-id]')].map((node) => node.dataset.signalId);
cockpit.signalItems = [item('focus-b')];
cockpit.renderCockpitSignals();
await show([item('focus-b')]);
const continuation = cockpit.briefTabs[cockpit.briefPageIndex] || cockpit.signalToggle;
const continued = document.activeElement === continuation;
cockpit.signalItems = [item('focus-a'), item('focus-b')];
cockpit.renderCockpitSignals();
await show([item('focus-a'), item('focus-b')]);
return { acquired, retained, order, continued, notReclaimed: document.activeElement === continuation };
} finally {
cockpit.signalItems = originalItems;
cockpit.renderCockpitSignals();
await show(originalItems);
cockpit.setSignalCollapsed(wasCollapsed);
}
});
@@ -2260,12 +2269,14 @@ try {
signal.hidden = false;
manager._setCockpitDisclosure('display', false);
manager._setCockpitDisclosure('radio', false);
await new Promise(requestAnimationFrame);
manager.cockpitView.syncSignalLayout();
const collapsed = {
display: displayControl.getBoundingClientRect().toJSON(),
radio: radioControl.getBoundingClientRect().toJSON(),
};
display.click();
await new Promise(requestAnimationFrame);
manager.cockpitView.syncSignalLayout();
return {
collapsed,
+12 -2
View File
@@ -474,12 +474,22 @@ async function contacts() {
// SEARCH NEARBY SITES, as the user presses it.
await page.evaluate(() => {
document.getElementById('toast').textContent = '';
const toast = document.getElementById('toast');
const before = toast.firstChild;
document.getElementById('installations-search-btn').click();
window.__qaToastBefore = before;
});
// Every toast show writes a new text node, even for a repeated message.
await page
.waitForFunction(
() => document.getElementById('toast').textContent.trim().length > 0,
() => {
const toast = document.getElementById('toast');
return (
toast.classList.contains('visible') &&
toast.firstChild !== window.__qaToastBefore &&
toast.textContent.trim().length > 0
);
},
{ timeout: 20_000 },
)
.catch(() => {});
+12 -10
View File
@@ -107,7 +107,7 @@ const layerState = () =>
const manager = gev.dataManager;
const module = manager.layers.get('directions').module;
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
'#utilities-toggles [data-layer-id="directions"]',
);
const controls = module.getRowControls();
const viewer = gev.viewer;
@@ -152,7 +152,7 @@ const layerState = () =>
const clickChip = async (chipId) => {
const clicked = await page.evaluate((id) => {
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
'#utilities-toggles [data-layer-id="directions"]',
);
const button = row?.querySelector(`button[data-chip-id="${id}"]`);
if (!button || button.disabled) return false;
@@ -232,24 +232,26 @@ try {
});
await sleep(3000);
// ── 1. Open the DATA LAYERS panel, then toggle the row on. ──────────────
// ── 1. Open the UTILITIES panel, then toggle the row on. ────────────────
const opened = await page.evaluate(() => {
const panel = document.getElementById('data-panel');
const panel = document.getElementById('utilities-panel');
if (!panel) return false;
if (panel.classList.contains('collapsed')) {
panel
.querySelector('.panel-collapse-btn[data-collapse-target="data-panel"]')
.querySelector(
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
)
?.click();
}
return !document
.getElementById('data-panel')
.getElementById('utilities-panel')
.classList.contains('collapsed');
});
await sleep(700);
check('the DATA LAYERS panel opens from its own header control', opened);
check('the UTILITIES panel opens from its own header control', opened);
const toggled = await page.evaluate(() => {
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
'#utilities-toggles [data-layer-id="directions"]',
);
const button = row?.querySelector('.data-toggle-btn');
if (!button) return false;
@@ -397,7 +399,7 @@ try {
);
const focused = await page.evaluate(() => {
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
'#utilities-toggles [data-layer-id="directions"]',
);
const items = row.querySelectorAll('.data-row-list-item');
items[2]?.focus();
@@ -776,7 +778,7 @@ try {
}
}
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
'#utilities-toggles [data-layer-id="directions"]',
);
return {
entities: left.length,
+3
View File
@@ -427,6 +427,9 @@ try {
shot.interactions = [];
fields[0].value = JSON.stringify(scene);
fields[1].value = JSON.stringify(shot);
// Typing fires input; the dialog reads its fields from those events.
for (const field of fields)
field.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.click('[data-director-apply-details]');
await page.waitForFunction(
+198 -25
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
/** Rendered acceptance for DISPLAY ▸ Draw: real clicks, real keys, real teardown. */
/** Rendered acceptance for UTILITIES ▸ Draw: real clicks, real keys, real teardown. */
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
@@ -58,6 +58,47 @@ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** A clip of the globe canvas, clear of the panels on every side. */
const CANVAS_CLIP = { x: 340, y: 200, width: 560, height: 380 };
// Clear of the open UTILITIES panel (x 100–380), whose own amber swatch
// dims when its row's shape is hidden.
const LINE_CLIP = { x: 390, y: 200, width: 510, height: 380 };
/** The canvas clip as a PNG, for comparing two moments of the same view. */
const canvasShot = () =>
page.screenshot({ clip: LINE_CLIP, type: 'png', encoding: 'base64' });
/**
* Amber pixels in one shot that are not amber in another of the same view:
* a mark's own pixels, apart from imagery and panels that both shots share.
*/
function amberOnlyIn(shotA, shotB) {
return page.evaluate(
async (a, b) => {
const pixels = async (base64) => {
const bitmap = await createImageBitmap(
await (await fetch(`data:image/png;base64,${base64}`)).blob(),
);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const context = canvas.getContext('2d');
context.drawImage(bitmap, 0, 0);
return context.getImageData(0, 0, bitmap.width, bitmap.height).data;
};
const amber = (d, i) =>
d[i] > 150 &&
d[i + 1] > 90 &&
d[i + 1] < 220 &&
d[i + 2] < 130 &&
d[i] - d[i + 2] > 70 &&
d[i] - d[i + 1] > 20;
const [da, db] = [await pixels(a), await pixels(b)];
let only = 0;
for (let i = 0; i < da.length; i += 4)
if (amber(da, i) && !amber(db, i)) only += 1;
return only;
},
shotA,
shotB,
);
}
/**
* How many amber pixels the clip contains. The drawn area's stroke and fill are
* the only amber in this scene, so the count going UP after a shape is finished
@@ -196,12 +237,14 @@ try {
);
check('visible tile content settles before drawing', settled);
// The DISPLAY rail starts collapsed on a first run: open it the way a user does.
// UTILITIES starts collapsed on a first run: open it the way a user does.
await page.evaluate(() => {
const rail = document.getElementById('pp-toggles');
if (rail?.classList.contains('collapsed'))
const panel = document.getElementById('utilities-panel');
if (panel?.classList.contains('collapsed'))
document
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
.querySelector(
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
)
?.click();
});
await page.waitForFunction(
@@ -210,7 +253,7 @@ try {
{ timeout: 10_000 },
);
check(
'the Draw control is on the DISPLAY rail and starts off',
'the Draw control is in the UTILITIES panel and starts off',
await page.evaluate(() => {
const toggle = document.getElementById('draw-toggle');
return (
@@ -298,8 +341,6 @@ try {
// ── a pin, finished with Enter ──────────────────────────────────────────
await page.click('#draw-toggle');
await page.click('.pp-mode-btn[data-shape="pin"]');
await page.click('#draw-label-input');
await page.keyboard.type('Pin one');
await clickWorld(520, 330);
check(
'the pointer is claimed while a session is open',
@@ -313,7 +354,8 @@ try {
// ── a line along a road, with Backspace ─────────────────────────────────
await page.click('.pp-mode-btn[data-shape="line"]');
await clickWorld(360, 300);
// Clear of the open UTILITIES panel, which sits beside the left rail.
await clickWorld(420, 300);
await clickWorld(470, 380);
await clickWorld(600, 470);
const beforeBackspace = (await drawState()).vertices;
@@ -326,16 +368,9 @@ try {
`${beforeBackspace} → ${afterBackspace}`,
);
await clickWorld(640, 500);
await page.click('#draw-label-input');
await page.keyboard.type('Market St');
await page.keyboard.press('Enter');
check(
'a line finished from the label field lands on the board',
await waitForMarks(2),
);
const lineMark = (await marks()).find((mark) =>
mark.label?.startsWith('Market St'),
);
check('a line finished with Enter lands on the board', await waitForMarks(2));
const lineMark = (await marks()).find((mark) => mark.type === 'route');
check(
'a drawn line reports its length and never a travel time',
Boolean(lineMark) &&
@@ -347,10 +382,8 @@ try {
// ── an area, coloured and labelled, finished with a double-click ────────
await page.click('.pp-mode-btn[data-shape="area"]');
await page.select('#draw-color-select', 'amber');
await page.click('.draw-color[data-color="amber"]');
const amberBefore = await amberPixels();
await page.click('#draw-label-input');
await page.keyboard.type('Civic Center block');
await clickWorld(400, 280);
await clickWorld(700, 290);
await clickWorld(720, 520);
@@ -373,14 +406,34 @@ try {
afterDoubleClick === 3,
`${afterDoubleClick} marks`,
);
// ── each drawn shape gets a row: named, recoloured, hidden from there ──
const rowIds = () =>
page.evaluate(() =>
[...document.querySelectorAll('#draw-mark-list [data-mark-id]')].map(
(row) => row.dataset.markId,
),
);
const ids = await rowIds();
check('every drawn shape has a row under Draw', ids.length === 3, ids);
const areaId = ids[2];
const rowControl = (id, control) =>
`#draw-mark-list [data-mark-id="${id}"] [data-control="${control}"]`;
await page.click(rowControl(areaId, 'name'));
await page.keyboard.type('Civic Center block');
await page.keyboard.press('Enter');
await wait(300);
const areaMark = (await marks()).find(
(mark) => mark.label === 'Civic Center block',
);
check(
'the label and colour chosen in the panel are what is drawn',
'the name typed in its row and the colour picked before drawing are what is drawn',
areaMark?.color === 'amber',
JSON.stringify(areaMark),
);
check(
'naming a shape keeps Draw on and the shape in progress untouched',
(await drawState()).active === true,
);
await wait(1200);
const amberAfter = await amberPixels();
check(
@@ -389,6 +442,118 @@ try {
`${amberBefore} → ${amberAfter} amber pixels (+${amberAfter - amberBefore})`,
);
await shot('01-area-with-label');
// ── the row's own controls: recolour, hide and show, fly to ─────────────
const markState = (id) =>
page.evaluate((id) => {
const mark = window.__gevAnnotations.list().find((m) => m.id === id);
return (
mark && { color: mark.color, hidden: mark.hidden, alpha: mark.alpha }
);
}, id);
// Wait for a mark to reach a state rather than for a fixed time.
const markReaches = (id, want) =>
page
.waitForFunction(
(id, want) => {
const mark = window.__gevAnnotations.list().find((m) => m.id === id);
return (
mark &&
Object.entries(want).every(([key, value]) => mark[key] === value)
);
},
{ timeout: 10_000 },
id,
want,
)
.then(
() => true,
() => false,
);
await page.click(rowControl(areaId, 'swatch'));
await page.click(rowControl(areaId, 'color-green'));
await markReaches(areaId, { color: 'green' });
check(
'a colour picked in the row recolours that shape',
(await markState(areaId))?.color === 'green',
);
await page.click(rowControl(areaId, 'toggle'));
await markReaches(areaId, { hidden: true, alpha: 0 });
const hidden = await markState(areaId);
check(
'hiding a shape keeps it on the board but draws it at zero alpha',
hidden?.hidden === true && hidden.alpha === 0,
JSON.stringify(hidden),
);
await page.click(rowControl(areaId, 'toggle'));
check(
'showing it again brings it straight back',
await markReaches(areaId, { hidden: false, alpha: 1 }),
);
// Fly-to frames the line by its whole path: once the flight settles, the
// middle of the line sits near the middle of the view.
await page.click(rowControl(ids[1], 'fly'));
await page
.waitForFunction(() => !window.__godsEyeView.viewer.camera._currentFlight, {
timeout: 15_000,
})
.catch(() => {});
await wait(300);
const framed = await page.evaluate((id) => {
const { viewer } = window.__godsEyeView;
// No Cesium global in the page: borrow Cartesian3 from a live object.
const Cartesian3 = viewer.camera.positionWC.constructor;
const mark = window.__gevAnnotations.list().find((m) => m.id === id);
const lons = mark.path.map((p) => p.lon);
const lats = mark.path.map((p) => p.lat);
const middle = Cartesian3.fromDegrees(
(Math.min(...lons) + Math.max(...lons)) / 2,
(Math.min(...lats) + Math.max(...lats)) / 2,
);
const at = viewer.scene.cartesianToCanvasCoordinates(middle);
const canvas = viewer.scene.canvas;
return (
at && {
dx: Math.abs(at.x - canvas.clientWidth / 2) / canvas.clientWidth,
dy: Math.abs(at.y - canvas.clientHeight / 2) / canvas.clientHeight,
}
);
}, ids[1]);
check(
'fly to frames that shape: its middle is near the middle of the view',
Boolean(framed) && framed.dx < 0.2 && framed.dy < 0.2,
JSON.stringify(framed),
);
// ── a hidden Line leaves the globe: its flowing stroke keeps its own
// colour, so the check is the pixels, not the mark's state ─────────────
const lineId = ids[1];
await page.click(rowControl(lineId, 'swatch'));
await page.click(rowControl(lineId, 'color-amber'));
await markReaches(lineId, { color: 'amber', alpha: 1 });
await wait(800);
const shown = await canvasShot();
await page.click(rowControl(lineId, 'toggle'));
await markReaches(lineId, { hidden: true, alpha: 0 });
await wait(800);
const hiddenShot = await canvasShot();
// Amber the shown line had and the hidden one lost; amber that appears
// only once it is hidden is noise, and must stay small beside it.
const gone = await amberOnlyIn(shown, hiddenShot);
const noise = await amberOnlyIn(hiddenShot, shown);
check(
'a hidden Line is no longer painted',
gone > 40 && noise < gone * 0.2,
`${gone} amber pixels went with the line, ${noise} appeared`,
);
await page.click(rowControl(lineId, 'toggle'));
await wait(1200);
const back = await amberOnlyIn(await canvasShot(), hiddenShot);
check(
'and paints again when shown',
back > gone * 0.5,
`${back} of ${gone} line pixels back`,
);
await shot('03-pins-and-shapes');
// ── Escape cancels the shape, a second Escape leaves draw mode ──────────
@@ -419,6 +584,12 @@ try {
afterSecondEscape.marks === 3,
`${afterSecondEscape.marks} marks`,
);
await page.click(rowControl(ids[0], 'remove'));
await waitForMarks(2);
check(
'remove in a row takes that one shape off the board and out of the list',
(await rowIds()).join() === ids.slice(1).join(),
);
// ── a click that would select something selects nothing while drawing ───
const target = await findClickableTarget();
@@ -480,10 +651,12 @@ try {
// ── Clear wipes the board ───────────────────────────────────────────────
await page.evaluate(() => {
const rail = document.getElementById('pp-toggles');
if (rail?.classList.contains('collapsed'))
const panel = document.getElementById('utilities-panel');
if (panel?.classList.contains('collapsed'))
document
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
.querySelector(
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
)
?.click();
});
await page.waitForFunction(
+96 -55
View File
@@ -25,12 +25,18 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const TESTS = ['src/firstRunExperience.test.mjs', 'src/standalone/startupChrome.test.mjs'];
const TESTS = [
'src/firstRunExperience.test.mjs',
'src/standalone/startupChrome.test.mjs',
'src/firstRunLauncherView.test.mjs',
];
const FILES = {
keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'),
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
html: path.join(ROOT, 'src', 'ui', 'templates', 'welcome.html'),
copy: path.join(ROOT, 'src', 'firstRunCopy.js'),
surfaces: path.join(ROOT, 'src', 'exclusiveSurfaces.js'),
html: path.join(ROOT, 'src', 'ui', 'views', 'WelcomeView.vue'),
css: path.join(ROOT, 'src', 'ui', 'styles', 'first-run.css'),
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
voiceDescriptions: path.join(
@@ -53,7 +59,8 @@ const MUTATIONS = [
to: "writeStored('local', sessionStorageRef, FIRST_RUN_STORAGE_KEY, 'suppressed');",
},
{
defect: 'the checkbox is ignored, so nothing can ever suppress the launcher',
defect:
'the checkbox is ignored, so nothing can ever suppress the launcher',
file: 'module',
from: " ? writeStored('local', storage, FIRST_RUN_STORAGE_KEY, 'suppressed')",
to: ' ? false',
@@ -77,7 +84,8 @@ const MUTATIONS = [
to: ' // durable check removed',
},
{
defect: '?welcome=1 no longer outranks suppression, so demos cannot replay it',
defect:
'?welcome=1 no longer outranks suppression, so demos cannot replay it',
file: 'module',
from: "if (params.get('welcome') === '1') return true;",
to: '// replay hatch removed',
@@ -139,8 +147,9 @@ const MUTATIONS = [
to: ' /* observer never attached */',
},
{
defect: 'cockpit drops off the exclusive list and can stack with the launcher',
file: 'module',
defect:
'cockpit drops off the exclusive list and can stack with the launcher',
file: 'surfaces',
from: "export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([\n 'cockpit-mode',",
to: 'export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([',
},
@@ -157,19 +166,22 @@ const MUTATIONS = [
to: 'dismiss();',
},
{
defect: 'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
defect:
'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
file: 'module',
from: ' root.getClientRects().length > 0 &&\n !coveredByOverlay();',
to: ' root.getClientRects().length > 0;',
},
{
defect: 'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
defect:
'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
file: 'module',
from: ' return Boolean(hit) && !root.contains(hit);',
to: ' return !root.contains(hit);',
},
{
defect: 'the launcher stops honouring a key another surface already claimed',
defect:
'the launcher stops honouring a key another surface already claimed',
file: 'keyboard',
from: ' || event.defaultPrevented',
to: ' /* belt removed */',
@@ -177,25 +189,29 @@ const MUTATIONS = [
{
// Anchored past the call so it lands on the Cockpit utility disclosure's
// Escape claim and nowhere else.
defect: 'a Cockpit utility disclosure returns to stopPropagation, so later document listeners also act on its ESC',
defect:
'a Cockpit utility disclosure returns to stopPropagation, so later document listeners also act on its ESC',
file: 'radio',
from: " event.stopImmediatePropagation();\n const kind = displayOpen ? 'display' : 'radio';",
to: " event.stopPropagation();\n const kind = displayOpen ? 'display' : 'radio';",
},
{
defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind',
defect:
'the "no timer" decision is deleted, so the next editor re-litigates it blind',
file: 'module',
from: ' * ACCEPTED, DELIBERATELY NOT TIMED OUT: a surface class that never clears',
to: ' * (note removed)',
},
{
defect: 'a bounded reveal timer punches the card through a recording in progress',
defect:
'a bounded reveal timer punches the card through a recording in progress',
file: 'module',
from: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;',
to: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;\n globalThis.setTimeout?.(reveal, 45000);',
},
{
defect: 'the accepted no-show vanishes from CURRENT-STATE and reads as a bug',
defect:
'the accepted no-show vanishes from CURRENT-STATE and reads as a bug',
file: 'docs',
from: 'a surface class that never clears means no launcher for that page',
to: 'the launcher always turns up eventually for that page',
@@ -203,10 +219,11 @@ const MUTATIONS = [
// ── The checkbox may not promise what storage refused ─────────────────────
{
defect: 'a refused write leaves the box ticked, promising a suppression nobody stored',
defect:
'a refused write leaves the box ticked, promising a suppression nobody stored',
file: 'module',
from: ' if (setFirstRunSuppressed(wanted, storage)) return;',
to: ' setFirstRunSuppressed(wanted, storage);\n return;',
from: ' if (setFirstRunSuppressed(Boolean(wanted), storage)) return true;',
to: ' setFirstRunSuppressed(Boolean(wanted), storage);\n return true;',
},
{
defect: 'a missing storage area reports the write as saved',
@@ -215,14 +232,16 @@ const MUTATIONS = [
to: ' store?.setItem?.(key, value);\n return true;',
},
{
defect: 'the scroll fade promises more list on a card where everything fits',
defect:
'the scroll fade promises more list on a card where everything fits',
file: 'module',
from: 'choiceList.dataset.scrollable = String(overflows);',
to: "choiceList.dataset.scrollable = 'true'; void overflows;",
from: 'present({ scrollable: String(overflows) });',
to: "present({ scrollable: 'true' }); void overflows;",
},
{
defect: 'the DISPLAY rail opens on first run again, stealing the impression',
defect:
'the DISPLAY rail opens on first run again, stealing the impression',
file: 'ui',
from: "if (panelId === 'pp-toggles' && stored === null) collapsed = true;",
to: "if (panelId === 'pp-toggles' && stored === null) collapsed = false;",
@@ -236,13 +255,15 @@ const MUTATIONS = [
// ── Mission definitions ────────────────────────────────────────────────────
{
defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
defect:
'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
file: 'module',
from: ' environmental: Object.freeze({',
to: " infrastructure: Object.freeze({\n kind: 'globe',\n layerIds: Object.freeze(['local-datacenters', 'local-dams', 'telegeography-submarine-cables']),\n busyText: 'Mapping global infrastructure…',\n }),\n environmental: Object.freeze({",
},
{
defect: 'ENVIRONMENTAL drops the keyless earthquakes that carry it without a key',
defect:
'ENVIRONMENTAL drops the keyless earthquakes that carry it without a key',
file: 'module',
from: "layerIds: Object.freeze(['earthquakes', 'local-firms']),",
to: "layerIds: Object.freeze(['local-firms']),",
@@ -256,11 +277,11 @@ const MUTATIONS = [
{
defect: 'the tile stops promising the fires it actually turns on',
file: 'html',
from: '<small>Live earthquakes and active fires, from USGS and NASA</small>',
to: '<small>Live earthquakes worldwide, straight from USGS</small>',
from: " 'Live earthquakes and active fires, from USGS and NASA',",
to: " 'Live earthquakes worldwide, straight from USGS',",
},
{
defect: "the owner-authored first-run line is quietly rewritten",
defect: 'the owner-authored first-run line is quietly rewritten',
file: 'html',
from: 'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.',
to: "It feels like a forbidden cockpit. It isn't — every feed is public, and every contact is live.",
@@ -302,21 +323,24 @@ const MUTATIONS = [
to: 'if (!mission) return { ok: true, choice };',
},
{
defect: 'the fires/quakes tile name stops being switchable from one constant',
file: 'module',
from: 'return ENVIRONMENTAL_LABELS[choice] || ENVIRONMENTAL_LABELS.ENVIRONMENTAL;',
to: 'return ENVIRONMENTAL_LABELS.ENVIRONMENTAL;',
defect:
'the fires/quakes tile name stops being switchable from one constant',
file: 'copy',
from: 'ENVIRONMENTAL_LABEL_KEYS[choice] || ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;',
to: 'ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;',
},
// ── Defaults interplay (the ruling this whole integration turns on) ────────
{
defect: "mission layer enables stop persisting, unlike the clicks they stand for",
defect:
'mission layer enables stop persisting, unlike the clicks they stand for',
file: 'module',
from: "dataManager.setEnabled(layerId, true, { origin: 'user' })",
to: 'dataManager.setEnabled(layerId, true)',
},
{
defect: 'a mission hand-edits detection and kills the style auto-preset contract',
defect:
'a mission hand-edits detection and kills the style auto-preset contract',
file: 'module',
from: ' flyToGlobe: () => styleManager.resetToGlobeView(),',
to: ' flyToGlobe: () => { styleManager._detectionUserOverridden = true; return styleManager.resetToGlobeView(); },',
@@ -328,13 +352,15 @@ const MUTATIONS = [
to: ' styleManager._setModels3dEnabled?.(true);\n const result = await styleManager.setContextMode(mode);',
},
{
defect: 'the Context panel is left collapsed, hiding the mode that just started',
defect:
'the Context panel is left collapsed, hiding the mode that just started',
file: 'module',
from: " styleManager.setPanelCollapsed?.('global-context-panel', false, {\n explicit: true,\n });",
to: ' // panel reveal removed',
},
{
defect: 'the decision table is deleted, so the next editor re-litigates it blind',
defect:
'the decision table is deleted, so the next editor re-litigates it blind',
file: 'module',
from: ' * MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST',
to: ' * (notes removed)',
@@ -342,10 +368,11 @@ const MUTATIONS = [
// ── Accessibility and the transplant defects ──────────────────────────────
{
defect: 'disabling the focused tile drops the keyboard to <body> mid-flight',
file: 'module',
from: " for (const button of buttons)\n button.setAttribute('aria-disabled', String(next));",
to: ' for (const button of buttons) button.disabled = next;',
defect:
'disabling the focused tile drops the keyboard to <body> mid-flight',
file: 'html',
from: ':aria-disabled="launcher?.busy ?? undefined" @click="controls?.choose(\'contacts\')"',
to: ':disabled="launcher?.busy === \'true\'" @click="controls?.choose(\'contacts\')"',
},
{
defect: 'Tab escapes into an app the visitor has not seen yet',
@@ -384,19 +411,22 @@ const MUTATIONS = [
to: 'body.never-matches-anything #first-run-launcher,',
},
{
defect: 'the flex card defeats [hidden] and sits in the a11y tree pre-reveal',
defect:
'the flex card defeats [hidden] and sits in the a11y tree pre-reveal',
file: 'css',
from: '#first-run-launcher[hidden] {\n display: none;\n}',
to: '/* [hidden] rule removed */',
},
{
defect: 'the card loses its viewport cap and clips on a short landscape phone',
defect:
'the card loses its viewport cap and clips on a short landscape phone',
file: 'css',
from: ' max-height: calc(100dvh - 1.5rem);',
to: ' /* cap removed */',
},
{
defect: 'the mission list stops scrolling, so tiles overflow the capped card',
defect:
'the mission list stops scrolling, so tiles overflow the capped card',
file: 'css',
from: ' min-height: 0;\n overflow-y: auto;',
to: ' /* scroll removed */',
@@ -415,19 +445,19 @@ const MUTATIONS = [
{
defect: 'a mission tile is dropped from the menu',
file: 'html',
from: ' <button type="button" data-first-run-choice="space-missions">',
to: ' <button type="button" data-first-run-choice-disabled="space-missions">',
from: ' <button type="button" data-first-run-choice="space-missions"',
to: ' <button type="button" data-first-run-choice-disabled="space-missions"',
},
{
defect: 'the menu order stops matching the owner\'s',
defect: "the menu order stops matching the owner's",
file: 'html',
from: '<button type="button" data-first-run-choice="environmental">',
to: '<button type="button" data-first-run-choice="zzz-environmental">',
from: '<button type="button" data-first-run-choice="environmental"',
to: '<button type="button" data-first-run-choice="zzz-environmental"',
},
{
defect: 'the "don\'t show again" checkbox is removed',
file: 'html',
from: '<input type="checkbox" data-first-run-suppress />',
from: '<input type="checkbox" data-first-run-suppress @change="onSuppress" />',
to: '<span data-first-run-suppress-removed></span>',
},
{
@@ -439,8 +469,8 @@ const MUTATIONS = [
{
defect: 'the launcher is no longer announced as a dialog',
file: 'html',
from: '<aside id="first-run-launcher" role="dialog"',
to: '<aside id="first-run-launcher"',
from: 'id="first-run-launcher" role="dialog"',
to: 'id="first-run-launcher"',
},
{
defect: 'the launcher is revealed before the loading cover yields',
@@ -449,7 +479,8 @@ const MUTATIONS = [
to: " revealFirstRun();\n loadingScreen.classList.add('hidden');",
},
{
defect: 'the globe missions lose their DataManager and cannot enable layers',
defect:
'the globe missions lose their DataManager and cannot enable layers',
file: 'main',
from: 'firstRun = initializeWelcome?.({ styleManager, dataManager });',
to: 'firstRun = initializeWelcome?.({ styleManager });',
@@ -463,7 +494,8 @@ const MUTATIONS = [
to: "'Counts, lists, superlatives, and attribute filters over records loaded by ENABLED layers",
},
{
defect: 'the instruction mapping is dropped, so voice cannot reach the modes',
defect:
'the instruction mapping is dropped, so voice cannot reach the modes',
file: 'voiceInstructions',
from: " 'NAMED VIEWS are shorthand",
to: " // 'NAMED VIEWS are shorthand",
@@ -471,21 +503,25 @@ const MUTATIONS = [
];
const originals = new Map();
for (const [key, file] of Object.entries(FILES)) originals.set(key, fs.readFileSync(file, 'utf8'));
for (const [key, file] of Object.entries(FILES))
originals.set(key, fs.readFileSync(file, 'utf8'));
/** Write only on a real change: a no-op write still wakes every file watcher. */
const write = (file, next) => {
if (fs.readFileSync(file, 'utf8') !== next) fs.writeFileSync(file, next);
};
const restoreAll = () => {
for (const [key, file] of Object.entries(FILES)) write(file, originals.get(key));
for (const [key, file] of Object.entries(FILES))
write(file, originals.get(key));
};
process.on('exit', restoreAll);
let caught = 0;
const missed = [];
console.log(`\nFirst-run launcher pin strength — ${MUTATIONS.length} individual reverts\n`);
console.log(
`\nFirst-run launcher pin strength — ${MUTATIONS.length} individual reverts\n`,
);
for (const { defect, file, from, to } of MUTATIONS) {
const original = originals.get(file);
if (!original.includes(from)) {
@@ -497,10 +533,15 @@ for (const { defect, file, from, to } of MUTATIONS) {
let red = false;
let by = '';
try {
execFileSync('node', ['--test', ...TESTS], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
execFileSync(
'node',
['--import=./build/registerVue.js', '--test', ...TESTS],
{ cwd: ROOT, encoding: 'utf8', stdio: 'pipe' },
);
} catch (error) {
red = true;
const failed = String(error.stdout || '').split('\n')
const failed = String(error.stdout || '')
.split('\n')
.filter((line) => line.trim().startsWith('✖') && line.includes('('))
.map((line) => line.trim().slice(2).split(' (')[0]);
by = [...new Set(failed)].slice(0, 2).join('; ');
+3 -24
View File
@@ -39,14 +39,13 @@ try {
'scene-select',
'cctv-camera-select',
'hud-layout-select',
'draw-color-select',
'radio-filter',
'sdr-gain-select',
].map((id) => document.getElementById(id));
return {
ids: selects.map((node) => node.id),
shared:
selects.length === 6 &&
selects.length === 5 &&
selects.every((node) => node.classList.contains('gev-field')),
native: selects.every(
(node) =>
@@ -58,7 +57,7 @@ try {
};
});
check(
'all six panel dropdowns retain native select semantics and shared surface',
'all five panel dropdowns retain native select semantics and shared surface',
inventory.shared && inventory.native,
inventory,
);
@@ -282,13 +281,6 @@ try {
syncShare: false,
});
});
if (
await page.$eval(
'#draw-toggle',
(node) => node.getAttribute('aria-pressed') !== 'true',
)
)
await page.click('#draw-toggle');
for (const lang of ['en']) {
await page.evaluate((lang) => {
document.documentElement.lang = lang;
@@ -296,12 +288,7 @@ try {
for (const width of [1440, 620, 390]) {
await page.setViewport({ width, height: 900 });
const layout = await page.evaluate(() => {
const ids = [
'scene-select',
'cctv-camera-select',
'hud-layout-select',
'draw-color-select',
];
const ids = ['scene-select', 'cctv-camera-select', 'hud-layout-select'];
return ids.map((id) => {
const node = document.getElementById(id),
rect = node.getBoundingClientRect(),
@@ -432,19 +419,11 @@ try {
syncShare: false,
});
});
if (
await page.$eval(
'#draw-toggle',
(node) => node.getAttribute('aria-pressed') !== 'true',
)
)
await page.click('#draw-toggle');
const translated = await page.evaluate(() => {
const nodes = [
'scene-select',
'cctv-camera-select',
'hud-layout-select',
'draw-color-select',
].map((id) => document.getElementById(id));
return (
document.documentElement.lang === 'ru' &&
+7 -5
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* Rendered acceptance for DISPLAY ▸ Import: real files through the real file
* Rendered acceptance for UTILITIES ▸ Import: real files through the real file
* input and a real drop, parsed by Cesium in the page.
*
* Proves: GeoJSON, KML, KMZ (with a packed icon and ground overlay) and GPX
@@ -269,10 +269,12 @@ try {
}, VIEW);
await wait(4000);
await page.evaluate(() => {
const rail = document.getElementById('pp-toggles');
if (rail?.classList.contains('collapsed'))
const panel = document.getElementById('utilities-panel');
if (panel?.classList.contains('collapsed'))
document
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
.querySelector(
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
)
?.click();
});
await page.waitForFunction(
@@ -283,7 +285,7 @@ try {
);
const before = await state();
check(
'Import is on the DISPLAY rail with an empty, hidden list',
'Import is in the UTILITIES panel with an empty, hidden list',
before.rows === 0 &&
!(await page.evaluate(() =>
document
+15 -3
View File
@@ -87,17 +87,21 @@ try {
row().querySelector('.data-toggle-btn').dataset.feedState === 'stale',
]);
const ui = window.__godsEyeView.styleManager;
ui._clearSelectedLayersBtn.click();
const clearButton = document.getElementById('clear-selected-layers');
clearButton.click();
// The bar renders the busy state on Vue's next flush.
await Promise.resolve();
result.push([
'native clear activation presents busy state',
ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'true' &&
clearButton.getAttribute('aria-busy') === 'true' &&
!!ui._contextControls._clearSelectedLayersPromise,
]);
await ui._contextControls._clearSelectedLayersPromise;
await Promise.resolve();
result.push([
'clear settles through the existing layer transaction',
!manager.isEnabled(id) &&
ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'false',
clearButton.getAttribute('aria-busy') === 'false',
]);
await manager.setEnabled(id, true);
const final = row().querySelector('.data-toggle-btn');
@@ -122,6 +126,14 @@ try {
'ordinary layer rows return after reassembly',
container.querySelectorAll('.data-toggle-row').length > 10,
]);
result.push([
'Directions returns to the Utilities list after reassembly',
Boolean(
document.querySelector(
'#utilities-toggles [data-layer-id="directions"]',
) && !container.querySelector('[data-layer-id="directions"]'),
),
]);
return result;
});
for (const [name, passed] of results) check(name, passed);
+25 -10
View File
@@ -62,13 +62,17 @@ try {
);
check(
'orbit controls and indicator agree',
await page.evaluate(() => {
await page.evaluate(async () => {
const ui = window.__godsEyeView.styleManager;
// Vue renders on a microtask; a task later the indicator shows it.
const rendered = () => new Promise((resolve) => setTimeout(resolve));
ui.setOrbit(true);
await rendered();
const active =
ui.orbitController.active &&
document.getElementById('orbit-indicator').classList.contains('active');
ui.setOrbit(false);
await rendered();
return (
active &&
!ui.orbitController.active &&
@@ -120,8 +124,11 @@ try {
const ui = window.__godsEyeView.styleManager;
return (
ui._searchedLocationLabel === '39.7392° N, 104.9903° W' &&
ui._locationMiniCity.textContent.includes('39.7392° N') &&
ui._locationMiniPoi.textContent === '104.9903° W'
document
.getElementById('location-mini-city')
.textContent.includes('39.7392° N') &&
document.getElementById('location-mini-poi').textContent ===
'104.9903° W'
);
}),
);
@@ -194,8 +201,11 @@ try {
const ui = window.__godsEyeView.styleManager;
return (
ui._searchedLocationLabel === 'Second landmark, Test city' &&
ui._locationMiniCity.textContent.includes('Second landmark') &&
ui._locationMiniPoi.textContent === 'Test city' &&
document
.getElementById('location-mini-city')
.textContent.includes('Second landmark') &&
document.getElementById('location-mini-poi').textContent ===
'Test city' &&
ui.activeStyle === 'normal' &&
!ui._locationSearch.classList.contains('searching')
);
@@ -227,8 +237,10 @@ try {
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
return (
!ui._poiRow.classList.contains('expanded') &&
!ui._locationBarDivider.classList.contains('visible')
!document.getElementById('poi-row').classList.contains('expanded') &&
!document
.getElementById('location-bar-divider')
.classList.contains('visible')
);
}, cityId),
);
@@ -284,7 +296,7 @@ try {
'native reset returns through the existing camera handoff',
await page.evaluate(async () => {
const ui = window.__godsEyeView.styleManager;
ui._resetGlobeBtn.click();
document.getElementById('reset-globe-view').click();
const pending = ui._globeResetPromise;
if (!pending) return false;
await pending;
@@ -301,8 +313,11 @@ try {
ui._locationControls.destroy();
ui._locationLookup.destroy();
const before = ui._navigationGeneration;
ui._locationPills.querySelector('button').click();
ui._resetGlobeBtn.click();
document
.getElementById('location-pills')
.querySelector('button')
?.click();
document.getElementById('reset-globe-view').click();
ui._locationSearch.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
+24 -14
View File
@@ -69,9 +69,10 @@ try {
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
return (
manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset
.stackId === 'osm' &&
manager._mapStackStatus.textContent ===
document
.getElementById('map-stack-chips')
.querySelector('[aria-pressed="true"]')?.dataset.stackId === 'osm' &&
document.getElementById('map-stack-status').textContent ===
manager.mapStackController.getState().activeStack.shortLabel
);
}),
@@ -81,32 +82,38 @@ try {
await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const result = await manager.setMapStack('esri-imagery');
// The dock renders the published row on Vue's next flush.
await new Promise((resolve) => setTimeout(resolve, 0));
const active = manager.mapStackController.getActiveId();
return (
window.__qaMapSelections.at(-1) === 'esri-imagery' &&
manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset
.stackId === active &&
document
.getElementById('map-stack-chips')
.querySelector('[aria-pressed="true"]')?.dataset.stackId === active &&
(result.ok || !!manager.mapStackController.getState().lastError)
);
}),
);
check(
'refresh revokes retained old chips',
await page.evaluate(() => {
'refresh re-presents the chips without duplicating them',
await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const old = manager._mapStackChips.querySelector('[data-stack-id="osm"]');
manager._mapSourceControls.refresh();
const before = window.__qaMapSelections.length;
old.click();
return window.__qaMapSelections.length === before;
await new Promise((resolve) => setTimeout(resolve, 0));
return (
document.querySelectorAll('#map-stack-chips [data-stack-id="osm"]')
.length === 1
);
}),
);
check(
'refreshed chips still select once',
await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const before = window.__qaMapSelections.length;
manager._mapStackChips.querySelector('[data-stack-id="osm"]').click();
document
.getElementById('map-stack-chips')
.querySelector('[data-stack-id="osm"]')
.click();
await Promise.resolve();
return window.__qaMapSelections.length === before + 1;
}),
@@ -188,7 +195,10 @@ try {
const manager = window.__godsEyeView.styleManager;
const before = window.__qaMapSelections.length;
manager._mapSourceControls.destroy();
manager._mapStackChips.querySelector('[data-stack-id="osm"]').click();
document
.getElementById('map-stack-chips')
.querySelector('[data-stack-id="osm"]')
.click();
const result = await manager._mapSourceControls.select('esri-imagery');
return result === null && window.__qaMapSelections.length === before;
}),
+4
View File
@@ -871,6 +871,9 @@ try {
return originalSetStack(stackId);
};
const switchPromise = styleManager._setMapStack('osm', { syncShare: false });
// The dock renders the published row on Vue's next flush.
const rendered = () => new Promise((resolve) => setTimeout(resolve, 0));
await rendered();
const during = {
status: document.getElementById('map-stack-status').textContent,
active: [...document.querySelectorAll('.map-stack-chip')]
@@ -879,6 +882,7 @@ try {
};
release();
await switchPromise;
await rendered();
const after = {
status: document.getElementById('map-stack-status').textContent,
active: [...document.querySelectorAll('.map-stack-chip')]
+853
View File
@@ -0,0 +1,853 @@
#!/usr/bin/env node
/**
* Traffic-warm OpenFreeMap outline acceptance.
*
* Each sample opens a fresh application page, loads the Texas Capitol z14 tile
* through Street Traffic, then circles a building from that same tile. It
* records application fetches separately from browser transfer bytes,
* body-read/decode/extraction/install measures, and Long Tasks. A second ask
* on the page proves the outline source's own consumer-cache path. A separate
* page holds traffic's target response while the outline consumer subscribes,
* proving that overlap still produces one application fetch and one decode.
*
* Acceptance records actual owned synchronous segments (16.7 ms each), full
* eight-probe completion within 2 s, and separate inclusive latency. No sample may create a
* >=50 ms Long Task, and neither the first nor second outline ask may fetch or
* decode the traffic-warm tile again. The first explicit cold ask's target
* projection must fit one frame; Traffic does no outline projection and the
* warm ask reuses it. Traffic activation Long Tasks are reported separately.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import puppeteer from 'puppeteer';
const args = process.argv.slice(2);
const valueAfter = (flag, fallback) => {
const index = args.indexOf(flag);
return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
};
if (args.includes('--help') || args.includes('-h')) {
console.log(
'Usage: node scripts/qa-openfreemap-same-tile.mjs [--url http://localhost:4173] [--runs 5] [--headful] [--out qa-shots/openfreemap-same-tile.json]',
);
process.exit(0);
}
const APP_URL = valueAfter('--url', 'http://localhost:4173');
const RUNS = Number(valueAfter('--runs', '5'));
const OUTPUT = path.resolve(
valueAfter('--out', 'qa-shots/openfreemap-same-tile.json'),
);
const FRAME_BUDGET_MS = 16.7;
const LONG_TASK_MS = 50;
const TARGET = Object.freeze({
latitude: 30.27472,
longitude: -97.74035,
z: 14,
x: 3743,
y: 6745,
});
const TARGET_PATH = `/${TARGET.z}/${TARGET.x}/${TARGET.y}.pbf`;
if (!Number.isInteger(RUNS) || RUNS < 3 || RUNS > 20)
throw new Error('--runs must be an integer from 3 through 20');
const targetUrl = new URL(APP_URL);
if (!['http:', 'https:'].includes(targetUrl.protocol))
throw new Error('--url must be HTTP(S)');
const percentile = (values, p) => {
if (!values.length) return null;
const sorted = values.slice().sort((a, b) => a - b);
return sorted[Math.ceil(p * sorted.length) - 1];
};
const round = (value) =>
value == null ? null : Number(Number(value).toFixed(3));
const measures = (entries, name) =>
entries.filter((entry) => entry.name === name).map((entry) => entry.ms);
const sum = (values) => values.reduce((total, value) => total + value, 0);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Numeric DEV evidence from the actual owners; waited frames are latency, not CPU.
function workEvidence(entries, label) {
const work = entries.filter(
(entry) => entry.name === 'outlines:install-work',
);
assert.equal(work.length, 1, `${label}: one completed owned-work trace`);
const trace = work[0].detail;
assert.equal(
trace?.status,
'installed',
`${label}: processed install required`,
);
assert.equal(trace.samplerStatus, 'installed');
assert.equal(trace.rendererStatus, 'installed');
assert.equal(trace.dropped, 0, `${label}: no lost work intervals`);
assert.equal(trace.probes, 8, `${label}: all original probes required`);
assert.ok(Number.isFinite(trace.timeOrigin));
assert.ok(
Number.isFinite(trace.samplerStart) && Number.isFinite(trace.completedAt),
);
assert.ok(
Number.isFinite(trace.callerStart) &&
trace.callerStart <= trace.samplerStart,
);
const phases = {
'engine-schedule': 1,
'ground-step': 8,
'hybrid-complete': 1,
'engine-process': 1,
'engine-finalize': 1,
'engine-settle': 1,
};
assert.equal(
trace.slices?.length,
13,
`${label}: complete disjoint owner intervals`,
);
for (const [phase, count] of Object.entries(phases))
assert.equal(
trace.slices.filter((slice) => slice.phase === phase).length,
count,
`${label}: ${phase}`,
);
let end = -Infinity;
const durations = trace.slices.map((slice) => {
assert.ok(
Number.isFinite(slice.start) &&
Number.isFinite(slice.end) &&
slice.end >= slice.start,
);
assert.ok(
slice.start >= end,
`${label}: no nested or double-counted owner intervals`,
);
end = slice.end;
const duration = slice.end - slice.start;
assert.ok(
duration <= FRAME_BUDGET_MS,
`${label}: ${slice.phase} work slice ${duration} exceeds ${FRAME_BUDGET_MS} ms`,
);
return duration;
});
const completionMs = trace.completedAt - trace.samplerStart;
assert.ok(
completionMs >= 0 && completionMs <= 2000,
`${label}: processed completion ${completionMs} ms exceeds 2000 ms`,
);
// This sum describes owned tail work, never the complete browser task/frame.
const tailStart = trace.slices
.filter((slice) => slice.phase === 'ground-step')
.at(-1).start;
return {
timeOrigin: trace.timeOrigin,
slices: trace.slices,
maxSliceMs: Math.max(...durations),
ownedTailSumMs: trace.slices
.filter((slice) => slice.start >= tailStart)
.reduce((total, slice) => total + slice.end - slice.start, 0),
completionMs,
callerCompletionMs: trace.completedAt - trace.callerStart,
probes: trace.probes,
dropped: trace.dropped,
};
}
await fs.mkdir(path.dirname(OUTPUT), { recursive: true });
const browser = await puppeteer.launch({
headless: args.includes('--headful') ? false : 'new',
protocolTimeout: 240_000,
...(process.env.PUPPETEER_EXECUTABLE_PATH
? { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH }
: {}),
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--use-gl=angle',
...(args.includes('--headful')
? []
: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader']),
'--disable-dev-shm-usage',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1440,1000',
],
});
const report = {
appUrl: APP_URL,
target: TARGET,
targetPath: TARGET_PATH,
acceptance: {
maxOwnedSliceMs: FRAME_BUDGET_MS,
samplerCompletionMaxMs: 2000,
legacyInclusive: 'reported-not-gated',
maxLongTaskMs: LONG_TASK_MS,
coldProjectionMaxMs: FRAME_BUDGET_MS,
coldApplicationFetches: 0,
coldOutlineTransferBytes: 0,
coldDecodes: 0,
warmApplicationFetches: 0,
warmDecodes: 0,
overlapApplicationFetches: 1,
overlapDecodes: 1,
},
renderer: null,
samples: [],
errors: [],
};
function relevantMeasures(raw) {
return raw
.filter(
(entry) =>
entry.name.startsWith('roads:') || entry.name.startsWith('outlines:'),
)
.map((entry) => ({
name: entry.name,
ms: entry.duration,
at: entry.startTime,
detail: entry.detail || {},
}));
}
async function beginOutline(page, label) {
await page.evaluate(
({ label, target }) => {
performance.clearMeasures();
window.__qaOutlineLongTasks.length = 0;
window.__qaOutlineStarted = performance.now();
window.__qaOutlinePromise = Promise.resolve(
window.__gevVoiceCommands.runner('annotate_map', {
annotations: [
{
type: 'area',
target: 'this building',
label,
entityKind: 'building',
latitude: target.latitude,
longitude: target.longitude,
},
],
}),
);
},
{ label, target: TARGET },
);
}
async function settleOutline(page, label) {
return page.evaluate(async (label) => {
const result = await window.__qaOutlinePromise;
let annotation = null;
const deadline = performance.now() + 30_000;
while (performance.now() < deadline) {
annotation = (window.__gevAnnotations?.list?.() || []).find(
(item) => item.label === label,
);
if (annotation && !annotation.pendingOutline) break;
await new Promise((resolve) => setTimeout(resolve, 25));
}
if (!annotation || annotation.pendingOutline)
throw new Error(`${label}: outline did not settle`);
const { scene } = window.__godsEyeView.viewer;
await new Promise((resolve) => {
const timer = setTimeout(resolve, 1500);
const remove = scene.postRender.addEventListener(() => {
clearTimeout(timer);
remove();
resolve();
});
scene.requestRender();
});
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
return {
commandToRenderMs: performance.now() - window.__qaOutlineStarted,
result,
annotation: {
pending: Boolean(annotation.pendingOutline),
unavailable: Boolean(annotation.outlineUnavailable),
synthesized: Boolean(annotation.synthesized),
footprintKind: annotation.footprintKind || null,
ringPoints: annotation.ring?.length || 0,
parts: annotation.polygons?.length || (annotation.ring ? 1 : 0),
},
measures: performance.getEntriesByType('measure').map((entry) => ({
name: entry.name,
duration: entry.duration,
startTime: entry.startTime,
detail: entry.detail,
})),
longTasks: window.__qaOutlineLongTasks.slice(),
};
}, label);
}
async function driveOutline(page, label) {
await beginOutline(page, label);
return settleOutline(page, label);
}
try {
for (let run = 0; run <= RUNS; run += 1) {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 1000 });
let overlapMode = false;
let overlapApplicationRequests = 0;
let heldTargetRequest = null;
let resolveHeldTarget;
const heldTarget = new Promise((resolve) => {
resolveHeldTarget = resolve;
});
if (run === 0) {
await page.setRequestInterception(true);
page.on('request', (request) => {
let isTarget = false;
try {
isTarget = new URL(request.url()).pathname.endsWith(TARGET_PATH);
} catch {
// Invalid URLs are passed through for the browser to handle.
}
if (overlapMode && isTarget) {
overlapApplicationRequests += 1;
if (!heldTargetRequest) {
heldTargetRequest = request;
resolveHeldTarget();
return;
}
}
void request.continue();
});
}
await page.evaluateOnNewDocument(() => {
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed');
window.__qaOutlineLongTasks = [];
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries())
window.__qaOutlineLongTasks.push({
at: entry.startTime,
ms: entry.duration,
});
}).observe({ type: 'longtask', buffered: false });
window.__qaLongTaskSupported = true;
} catch {
window.__qaLongTaskSupported = false;
}
});
let phase = 'startup';
const network = [];
const pending = new Map();
const client = await page.createCDPSession();
await client.send('Network.enable');
client.on('Network.requestWillBeSent', (event) => {
let url;
try {
url = new URL(event.request.url);
} catch {
return;
}
if (
url.hostname !== 'tiles.openfreemap.org' ||
!url.pathname.endsWith('.pbf')
)
return;
const entry = {
requestId: event.requestId,
phase,
url: url.origin + url.pathname,
targetTile: url.pathname.endsWith(TARGET_PATH),
cached: false,
transferBytes: 0,
responseBytes: 0,
finished: false,
};
pending.set(event.requestId, entry);
network.push(entry);
});
client.on('Network.requestServedFromCache', ({ requestId }) => {
const entry = pending.get(requestId);
if (entry) entry.cached = true;
});
client.on('Network.responseReceived', ({ requestId, response }) => {
const entry = pending.get(requestId);
if (!entry) return;
entry.status = response.status;
entry.cached ||= Boolean(
response.fromDiskCache ||
response.fromServiceWorker ||
response.fromPrefetchCache,
);
});
client.on('Network.dataReceived', ({ requestId, dataLength }) => {
const entry = pending.get(requestId);
if (entry) entry.responseBytes += dataLength;
});
client.on('Network.loadingFinished', ({ requestId, encodedDataLength }) => {
const entry = pending.get(requestId);
if (!entry) return;
entry.transferBytes = entry.cached ? 0 : encodedDataLength;
entry.finished = true;
pending.delete(requestId);
});
client.on('Network.loadingFailed', ({ requestId, errorText }) => {
const entry = pending.get(requestId);
if (!entry) return;
entry.failure = errorText;
entry.finished = true;
pending.delete(requestId);
});
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(String(error.message)));
const navigation = new URL(APP_URL);
navigation.searchParams.set('welcome', '0');
navigation.searchParams.set('trafficDebug', '1');
navigation.searchParams.set('trafficRoads', 'osm');
await page.goto(navigation.href, {
waitUntil: 'domcontentloaded',
timeout: 90_000,
});
await page.waitForFunction(
() =>
window.__godsEyeView?.viewer &&
window.__godsEyeView?.dataManager &&
window.__gevVoiceCommands?.runner &&
window.__gevAnnotations &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 120_000, polling: 250 },
);
await page.evaluate(
() => window.__godsEyeView.styleManager.initialRestorePromise,
);
await page.evaluate(() =>
window.__godsEyeView.styleManager.setDetection({ enabled: false }),
);
await page.evaluate(async (target) => {
const { viewer, dataManager } = window.__godsEyeView;
await dataManager.setEnabled('traffic', false);
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: window.__CESIUM__.Cartesian3.fromDegrees(
target.longitude,
target.latitude,
700,
),
orientation: {
heading: 0,
pitch: (-65 * Math.PI) / 180,
roll: 0,
},
});
viewer.camera.moveEnd.raiseEvent();
window.__gevAnnotations.clear();
performance.clearMeasures();
}, TARGET);
await sleep(750);
if (run === 0) {
phase = 'overlap';
overlapMode = true;
await page.evaluate(() => {
window.__qaOutlineLongTasks.length = 0;
performance.clearMeasures();
});
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('traffic', true),
);
await Promise.race([
heldTarget,
sleep(30_000).then(() => {
throw new Error('overlap phase did not intercept the target tile');
}),
]);
await beginOutline(page, 'QA Capitol overlap');
await sleep(100);
await heldTargetRequest.continue();
const overlap = await settleOutline(page, 'QA Capitol overlap');
await sleep(750);
const trafficStats = await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('traffic')
.module.getStats(),
);
assert.equal(trafficStats.error, null, 'overlap traffic source error');
overlap.measures = relevantMeasures(overlap.measures);
overlap.work = workEvidence(overlap.measures, 'overlap');
overlap.network = network.filter(
(entry) => entry.phase === 'overlap' && entry.targetTile,
);
const overlapDecodes = overlap.measures
.filter(
(entry) =>
entry.name === 'roads:decode' &&
String(entry.detail?.key).endsWith(
`:${TARGET.z}/${TARGET.x}/${TARGET.y}`,
),
)
.map((entry) => entry.ms);
assert.equal(
overlapApplicationRequests,
1,
'overlapping traffic and outline consumers issue one application request',
);
assert.equal(
overlap.network.length,
1,
'overlapping traffic and outline consumers emit one target network request',
);
assert.equal(
overlapDecodes.length,
1,
'overlapping traffic and outline consumers decode once',
);
assert.equal(overlap.annotation.unavailable, false);
assert.equal(overlap.annotation.footprintKind, 'building');
report.overlap = {
applicationFetches: overlapApplicationRequests,
targetNetworkRequests: overlap.network.length,
transferBytes: sum(overlap.network.map((entry) => entry.transferBytes)),
decodes: overlapDecodes.length,
decodeMs: round(sum(overlapDecodes)),
longTasks: overlap.longTasks.length,
longTaskEntries: overlap.longTasks,
work: overlap.work,
};
await page.close();
continue;
}
phase = 'traffic';
await page.evaluate(() => {
window.__qaOutlineLongTasks.length = 0;
performance.clearMeasures();
});
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('traffic', true),
);
await page.waitForFunction(
() => {
const stats = window.__godsEyeView.dataManager.layers
.get('traffic')
.module.getStats();
return stats.count > 0 && !stats.loading && !stats.error;
},
{ timeout: 180_000, polling: 250 },
);
await sleep(1500);
const trafficMeasures = relevantMeasures(
await page.evaluate(() =>
performance.getEntriesByType('measure').map((entry) => ({
name: entry.name,
duration: entry.duration,
startTime: entry.startTime,
detail: entry.detail,
})),
),
);
const trafficLongTasks = await page.evaluate(() =>
window.__qaOutlineLongTasks.slice(),
);
const trafficTarget = network.filter(
(entry) => entry.phase === 'traffic' && entry.targetTile,
);
const targetTile = `${TARGET.z}/${TARGET.x}/${TARGET.y}`;
const targetProjectionMeasures = trafficMeasures.filter(
(entry) =>
entry.detail?.tile === targetTile &&
['outlines:street-extract', 'outlines:polygon-extract'].includes(
entry.name,
),
);
const targetProjectionMs = sum(
targetProjectionMeasures.map((entry) => entry.ms),
);
assert.ok(
trafficTarget.length >= 1,
`sample ${run}: traffic did not load ${TARGET_PATH}`,
);
assert.equal(
trafficMeasures.filter((entry) =>
['outlines:street-extract', 'outlines:polygon-extract'].includes(
entry.name,
),
).length,
0,
`sample ${run}: traffic must leave outline projection to the explicit ask`,
);
phase = 'outline-cold';
const cold = await driveOutline(page, `QA Capitol cold ${run}`);
await sleep(750);
cold.measures = relevantMeasures(cold.measures);
cold.work = workEvidence(cold.measures, 'cold');
cold.network = network.filter(
(entry) => entry.phase === 'outline-cold' && entry.targetTile,
);
cold.longTasks = cold.longTasks.map((entry) => ({
at: round(entry.at),
ms: round(entry.ms),
}));
await page.evaluate(() => window.__gevAnnotations.clear());
phase = 'outline-warm';
const warm = await driveOutline(page, `QA Capitol warm ${run}`);
await sleep(750);
warm.measures = relevantMeasures(warm.measures);
warm.work = workEvidence(warm.measures, 'warm');
warm.network = network.filter(
(entry) => entry.phase === 'outline-warm' && entry.targetTile,
);
warm.longTasks = warm.longTasks.map((entry) => ({
at: round(entry.at),
ms: round(entry.ms),
}));
const supported = await page.evaluate(() => window.__qaLongTaskSupported);
assert.equal(supported, true, `sample ${run}: Long Tasks unsupported`);
assert.deepEqual(pageErrors, [], `sample ${run}: browser errors`);
assert.equal(cold.annotation.unavailable, false);
assert.equal(cold.annotation.synthesized, false);
assert.equal(cold.annotation.footprintKind, 'building');
assert.ok(cold.annotation.ringPoints >= 4);
assert.equal(warm.annotation.footprintKind, 'building');
assert.ok(warm.annotation.ringPoints >= 4);
const coldDecodes = measures(cold.measures, 'roads:decode');
const coldInstalls = measures(cold.measures, 'outlines:install');
const warmDecodes = measures(warm.measures, 'roads:decode');
const coldProjectionMeasures = cold.measures.filter(
(entry) =>
entry.detail?.tile === targetTile &&
['outlines:street-extract', 'outlines:polygon-extract'].includes(
entry.name,
),
);
cold.targetProjectionMs = sum(
coldProjectionMeasures.map((entry) => entry.ms),
);
assert.equal(
cold.network.length,
0,
'shared decoded tile performs no second application fetch',
);
assert.equal(sum(cold.network.map((entry) => entry.transferBytes)), 0);
assert.equal(
coldDecodes.length,
0,
'shared decoded tile is not parsed again',
);
assert.equal(
coldProjectionMeasures.filter(
(entry) => entry.name === 'outlines:street-extract',
).length,
1,
'the first explicit cold ask owns target street extraction exactly once',
);
assert.equal(
coldProjectionMeasures.filter(
(entry) => entry.name === 'outlines:polygon-extract',
).length,
1,
'the first explicit cold ask owns target polygon extraction exactly once',
);
assert.ok(
cold.targetProjectionMs <= FRAME_BUDGET_MS,
`sample ${run}: cold target projection ${cold.targetProjectionMs} ms exceeds ${FRAME_BUDGET_MS} ms`,
);
assert.equal(
measures(warm.measures, 'outlines:street-extract').length,
0,
'warm reuse performs no street extraction',
);
assert.equal(
measures(warm.measures, 'outlines:polygon-extract').length,
0,
'warm reuse performs no polygon extraction',
);
assert.ok(coldInstalls.length >= 1, 'cold outline installs geometry');
assert.equal(warm.network.length, 0, 'decoded-cache hit performs no fetch');
assert.equal(warmDecodes.length, 0, 'decoded-cache hit performs no decode');
report.renderer ||= await page.evaluate(() => {
const gl = window.__godsEyeView.viewer.scene.context._gl;
const debug = gl.getExtension('WEBGL_debug_renderer_info');
return debug
? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL)
: gl.getParameter(gl.RENDERER);
});
if (args.includes('--headful')) {
assert.doesNotMatch(
String(report.renderer),
/swiftshader|software rasterizer|llvmpipe/i,
`sample ${run}: headful evidence requires a hardware renderer`,
);
}
report.samples.push({
run,
pageErrors,
traffic: {
applicationFetches: trafficTarget.length,
transferBytes: sum(trafficTarget.map((entry) => entry.transferBytes)),
decodes: measures(trafficMeasures, 'roads:decode').length,
targetProjectionMs: round(targetProjectionMs),
longTasks: trafficLongTasks.map((entry) => ({
at: round(entry.at),
ms: round(entry.ms),
})),
measures: trafficMeasures,
network: trafficTarget,
},
cold: {
...cold,
applicationFetches: cold.network.length,
transferBytes: sum(cold.network.map((entry) => entry.transferBytes)),
decodeMs: round(sum(coldDecodes)),
bodyReadMs: round(sum(measures(cold.measures, 'roads:tile-body-read'))),
streetExtractMs: round(
sum(measures(cold.measures, 'outlines:street-extract')),
),
polygonExtractMs: round(
sum(measures(cold.measures, 'outlines:polygon-extract')),
),
installMs: round(sum(coldInstalls)),
legacyInclusiveMs: round(
sum(coldDecodes) +
sum(measures(cold.measures, 'outlines:street-extract')) +
sum(measures(cold.measures, 'outlines:polygon-extract')) +
sum(coldInstalls),
),
},
warm: {
...warm,
applicationFetches: warm.network.length,
transferBytes: sum(warm.network.map((entry) => entry.transferBytes)),
decodeMs: round(sum(warmDecodes)),
installMs: round(sum(measures(warm.measures, 'outlines:install'))),
},
});
await page.close();
}
const coldInclusive = report.samples.map(
(sample) => sample.cold.legacyInclusiveMs,
);
const outlineLongTasks = report.samples.flatMap((sample) => [
...sample.cold.longTasks,
...sample.warm.longTasks,
]);
const trafficLongTasks = report.samples.flatMap(
(sample) => sample.traffic.longTasks,
);
report.summary = {
samples: report.samples.length,
overlapApplicationFetches: report.overlap?.applicationFetches ?? null,
overlapDecodes: report.overlap?.decodes ?? null,
legacyInclusiveP50Ms: round(percentile(coldInclusive, 0.5)),
legacyInclusiveP95Ms: round(percentile(coldInclusive, 0.95)),
legacyInclusiveMaxMs: round(Math.max(...coldInclusive)),
coldCommandToRenderP95Ms: round(
percentile(
report.samples.map((sample) => sample.cold.commandToRenderMs),
0.95,
),
),
coldApplicationFetches: sum(
report.samples.map((sample) => sample.cold.applicationFetches),
),
coldTransferBytes: sum(
report.samples.map((sample) => sample.cold.transferBytes),
),
coldDecodes: sum(
report.samples.map(
(sample) => measures(sample.cold.measures, 'roads:decode').length,
),
),
warmApplicationFetches: sum(
report.samples.map((sample) => sample.warm.applicationFetches),
),
warmDecodes: sum(
report.samples.map(
(sample) => measures(sample.warm.measures, 'roads:decode').length,
),
),
coldProjectionMaxMs: Math.max(
...report.samples.map((sample) => sample.cold.targetProjectionMs),
),
trafficBaselineLongTasks: trafficLongTasks.length,
trafficBaselineMaxLongTaskMs: round(
trafficLongTasks.length
? Math.max(...trafficLongTasks.map((entry) => entry.ms))
: 0,
),
longTasks: outlineLongTasks.length,
maxLongTaskMs: round(
outlineLongTasks.length
? Math.max(...outlineLongTasks.map((entry) => entry.ms))
: 0,
),
};
const coldSlices = report.samples.map(
(sample) => sample.cold.work.maxSliceMs,
);
report.summary.coldOwnedSliceP95Ms = round(percentile(coldSlices, 0.95));
report.summary.maxOwnedSliceMs = Math.max(
report.overlap.work.maxSliceMs,
...report.samples.flatMap((sample) => [
sample.cold.work.maxSliceMs,
sample.warm.work.maxSliceMs,
]),
);
report.summary.maxSamplerCompletionMs = Math.max(
report.overlap.work.completionMs,
...report.samples.flatMap((sample) => [
sample.cold.work.completionMs,
sample.warm.work.completionMs,
]),
);
assert.ok(report.summary.maxOwnedSliceMs <= FRAME_BUDGET_MS);
assert.ok(report.summary.maxSamplerCompletionMs <= 2000);
assert.ok(
report.summary.coldProjectionMaxMs <= FRAME_BUDGET_MS,
'cold outline projection exceeded one frame',
);
assert.equal(report.summary.longTasks, 0, 'outline ask created a Long Task');
assert.equal(report.summary.coldTransferBytes, 0);
assert.equal(report.summary.coldApplicationFetches, 0);
assert.equal(report.summary.coldDecodes, 0);
assert.equal(report.summary.warmApplicationFetches, 0);
assert.equal(report.summary.warmDecodes, 0);
assert.equal(report.summary.overlapApplicationFetches, 1);
assert.equal(report.summary.overlapDecodes, 1);
report.passed = true;
console.table(
report.samples.map((sample) => ({
run: sample.run,
coldProjectionMs: sample.cold.targetProjectionMs,
appFetches: sample.cold.applicationFetches,
transferBytes: sample.cold.transferBytes,
bodyReadMs: sample.cold.bodyReadMs,
decodeMs: sample.cold.decodeMs,
streetMs: sample.cold.streetExtractMs,
polygonMs: sample.cold.polygonExtractMs,
installMs: sample.cold.installMs,
legacyInclusiveMs: sample.cold.legacyInclusiveMs,
maxOwnedSliceMs: sample.cold.work.maxSliceMs,
renderMs: round(sample.cold.commandToRenderMs),
longTasks: sample.cold.longTasks.length,
})),
);
console.log('OpenFreeMap same-tile acceptance passed:', report.summary);
} catch (error) {
report.passed = false;
report.failure = String(error?.stack || error);
console.error(error);
process.exitCode = 1;
} finally {
await fs.writeFile(OUTPUT, `${JSON.stringify(report, null, 2)}\n`);
await browser.close();
}
+1 -1
View File
@@ -310,7 +310,7 @@ try {
countFrames(2_500),
page.evaluate(() => new Promise((resolve) => setTimeout(() => {
// Drive the actual sharpen slider: input event → handler →
// _applySharpenIntensity → governorRequestRender. Proves the wiring,
// _setSharpenIntensity → governorRequestRender. Proves the wiring,
// not just the facade.
const slider = document.getElementById('sharpen-intensity-slider');
if (!slider) { resolve({ ok: false }); return; }
+44 -12
View File
@@ -1100,7 +1100,7 @@ async function main() {
document.getElementById('installations-search-btn').click();
await new Promise((resolve) => setTimeout(resolve, 50));
const installationFalseMessage = document.getElementById('toast').textContent;
const installationFalseReleased = !document.getElementById('installations-search-btn').disabled;
const installationFalseReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
dataManager.setEnabled = async (layerId, shouldEnable) => {
if (layerId === 'military-installations' && shouldEnable) {
@@ -1111,7 +1111,7 @@ async function main() {
document.getElementById('installations-search-btn').click();
await new Promise((resolve) => setTimeout(resolve, 50));
const installationEnableRejectionMessage = document.getElementById('toast').textContent;
const installationEnableRejectionReleased = !document.getElementById('installations-search-btn').disabled;
const installationEnableRejectionReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
dataManager.setEnabled = async (layerId, shouldEnable) => {
if (layerId === 'military-installations' && shouldEnable) enabled.add(layerId);
@@ -1123,13 +1123,13 @@ async function main() {
document.getElementById('installations-search-btn').click();
await new Promise((resolve) => setTimeout(resolve, 50));
const installationRejectionMessage = document.getElementById('toast').textContent;
const installationRejectionReleased = !document.getElementById('installations-search-btn').disabled;
const installationRejectionReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
if (installations) installations.searchNearby = async () => false;
document.getElementById('installations-search-btn').click();
await new Promise((resolve) => setTimeout(resolve, 50));
const installationSearchFalseMessage = document.getElementById('toast').textContent;
const installationSearchFalseReleased = !document.getElementById('installations-search-btn').disabled;
const installationSearchFalseReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
await new Promise((resolve) => setTimeout(resolve, 0));
return {
semanticFalseMessage,
@@ -1716,7 +1716,14 @@ async function main() {
else panel.setAttribute('style', state.style);
if (state.ariaHidden === null) panel.removeAttribute('aria-hidden');
else panel.setAttribute('aria-hidden', state.ariaHidden);
manager._syncPanelCollapseButton(panel);
// The restored classes are the panel's state again.
const has = (name) => panel.classList.contains(name);
manager._presentPanel(panel, {
collapsed: has('collapsed'),
pinned: has('dock-pinned'),
pinnedTop: has('dock-pinned-top'),
auto: has('layout-auto-collapsed') ? 'layout' : has('accordion-collapsed') ? 'accordion' : null,
});
}
for (const [id, state] of Object.entries(prior.stacks)) {
const stack = document.getElementById(id);
@@ -1974,7 +1981,7 @@ async function main() {
manager._setCockpitDisplayPortalActive(true);
hud.hidden = false;
signal.hidden = false;
dataPanel.classList.remove('collapsed');
manager._presentPanel(dataPanel, { collapsed: false });
window.__godsEyeView.styleManager._syncLeftPanelAdaptiveLayout();
window.__godsEyeView.styleManager.cockpitView.syncSignalLayout();
if (display.getAttribute('aria-expanded') === 'true') display.click();
@@ -2059,8 +2066,7 @@ async function main() {
contextCard.hidden = false;
// Expand the panel the same way this block already does at entry, so the
// portaled Display groups measured further down keep their DOM home.
dataPanel.classList.remove('collapsed', 'layout-auto-collapsed');
manager._syncPanelCollapseButton(dataPanel);
manager._presentPanel(dataPanel, { collapsed: false, auto: null });
manager._syncLeftPanelAdaptiveLayout();
await waitForLayout();
const panelRect = dataPanel.getBoundingClientRect();
@@ -2154,16 +2160,20 @@ async function main() {
.every((control) => control.closest('.pp-toggle-group')?.parentElement
?.closest('#cockpit-display-panel'));
display.click();
await waitForLayout();
manager.cockpitView.syncSignalLayout();
radio.click();
await waitForLayout();
const radioOpened = radio.getAttribute('aria-expanded') === 'true'
&& !document.getElementById('cockpit-radio-panel').hidden;
const mutuallyExclusive = display.getAttribute('aria-expanded') === 'false';
radio.click();
await waitForLayout();
manager.cockpitView.syncSignalLayout();
radio.click();
await waitForLayout();
const radioNavigationAvailable = [
document.getElementById('cockpit-radio-prev-btn'),
@@ -2283,7 +2293,14 @@ async function main() {
else panel.setAttribute('style', state.style);
if (state.ariaHidden === null) panel.removeAttribute('aria-hidden');
else panel.setAttribute('aria-hidden', state.ariaHidden);
manager._syncPanelCollapseButton(panel);
// The restored classes are the panel's state again.
const has = (name) => panel.classList.contains(name);
manager._presentPanel(panel, {
collapsed: has('collapsed'),
pinned: has('dock-pinned'),
pinnedTop: has('dock-pinned-top'),
auto: has('layout-auto-collapsed') ? 'layout' : has('accordion-collapsed') ? 'accordion' : null,
});
}
manager._panelLayout._leftStackPreferredPanelId = prior.preferredLeftPanelId;
localStorage.clear();
@@ -2700,14 +2717,20 @@ async function main() {
};
const playsBefore = window.__qaRadioPlayCalls.length;
const frozenSignature = style._radioControls._radioTunerBandSignature;
// The tuner renders from the Radio store; let Vue patch before reading.
const rendered = () => Promise.resolve();
pointer('pointerdown', xFor(0));
await rendered();
const left = snapshot();
const centerCoordinate = (count - 1) / 2;
pointer('pointermove', xFor(centerCoordinate));
await rendered();
const center = snapshot();
pointer('pointermove', xFor(centerCoordinate + 2));
await rendered();
const shifted = snapshot();
pointer('pointermove', xFor(count - 1));
await rendered();
const right = snapshot();
const shiftedByIndex = new Map(shifted.ticks.map((tick) => [tick.index, tick.x]));
const sharedTick = center.ticks.find((tick) => shiftedByIndex.has(tick.index));
@@ -2764,7 +2787,7 @@ async function main() {
tapeDelta: absoluteTuner.tapeDelta,
}),
);
const tunerCancelRestore = await page.evaluate(() => {
const tunerCancelRestore = await page.evaluate(async () => {
const gev = window.__godsEyeView;
const style = gev.styleManager;
const radio = gev.dataManager.layers.get('radio').module;
@@ -2797,11 +2820,14 @@ async function main() {
const targetIndex = before.slot < beforeStations.length - 2 ? before.slot + 2 : before.slot - 2;
pointer('pointerdown', before.slot);
pointer('pointermove', targetIndex);
// The tuner renders from the Radio store; let Vue patch before reading.
await Promise.resolve();
const shiftedSlot = Number(slider.value);
const previewBeforeCancel = gev.viewer.entities.values
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null;
const flyToCallsBeforeCancel = flyToCalls.length;
pointer('pointercancel', targetIndex);
await Promise.resolve();
const afterStations = style._radioControls._radioTunerStations;
const afterState = radio.getUIState();
const after = {
@@ -2957,7 +2983,7 @@ async function main() {
await page.evaluate(async () => {
await window.__godsEyeView.dataManager.layers.get('radio').module.update();
});
const tunerRefreshRelease = await page.evaluate((target) => {
const tunerRefreshRelease = await page.evaluate(async (target) => {
const gev = window.__godsEyeView;
const slider = document.getElementById('radio-tuner-slider');
const displayedBeforeRelease = document.getElementById('radio-tuner-station').textContent;
@@ -2970,6 +2996,10 @@ async function main() {
clientY: target.clientY,
}));
const state = gev.dataManager.layers.get('radio').module.getUIState();
// The playback line renders from the Radio store once Vue flushes.
const message = await Promise.resolve().then(
() => document.getElementById('radio-playback-state').textContent,
);
return {
displayedBeforeRelease,
frozenGeneration,
@@ -2978,7 +3008,7 @@ async function main() {
plays: window.__qaRadioPlayCalls.length,
value: document.getElementById('radio-tuner-value').textContent,
stationText: document.getElementById('radio-tuner-station').textContent,
message: document.getElementById('radio-playback-state').textContent,
message,
targetId: target.id,
};
}, tunerRefreshTarget);
@@ -3025,6 +3055,8 @@ async function main() {
pointer('pointermove', targetIndex);
const previewPlays = window.__qaRadioPlayCalls.length;
pointer('pointerup', targetIndex);
// The tuner renders from the Radio store; sample once Vue has patched.
await Promise.resolve();
const samples = [needle.getBoundingClientRect().left];
await new Promise((resolve) => requestAnimationFrame(resolve));
samples.push(needle.getBoundingClientRect().left);
+8 -5
View File
@@ -442,12 +442,12 @@ try {
return '{"scenes":[]}';
},
});
// A shot row held across disposal must not act once it is revoked.
const retainedLabel = document.querySelector('.scene-shot-label');
const stopping = director.destroy();
const notificationsAtStop = window.__qaSceneState.length;
const stoppedSynchronously =
controls.destroyed &&
controls.removers.length === 0 &&
controls.rowRemovers.length === 0;
const stoppedSynchronously = controls.destroyed;
retainedLabel?.click();
document.getElementById('scene-new-btn').click();
controls.run('capture');
finish();
@@ -456,6 +456,7 @@ try {
await director.destroy();
return {
stoppedSynchronously,
heldRowAction: Boolean(retainedLabel),
stateStopped: window.__qaSceneState.length === notificationsAtStop,
sameProject: director._project === project,
noLateStatus:
@@ -465,7 +466,9 @@ try {
});
check(
'Disposal revokes fixed and row actions before waiting for scene work',
teardown.stoppedSynchronously && teardown.rowsGone,
teardown.stoppedSynchronously &&
teardown.heldRowAction &&
teardown.rowsGone,
);
check(
'Late import completion cannot replace the disposed project or status',
+29 -25
View File
@@ -4,7 +4,7 @@
* file dropped from the desktop, proved in a real browser.
*
* The dialog draws a translated row over the native file input
* (src/ui/sceneSharing.js, fileChooser). The input must still cover the row,
* (src/ui/views/panels/SceneDialog.vue). The input must still cover the row,
* or drops miss it. Drops a file on the row's centre via CDP and expects
* `input.files` to hold it.
*
@@ -15,31 +15,34 @@
* drop selects the file, 1 otherwise.
*/
import http from 'node:http';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import puppeteer from 'puppeteer';
import { createServer } from 'vite';
import { VUE_PLUGIN_OPTIONS } from '../build/vueOptions.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const TYPES = {
'.js': 'text/javascript',
'.mjs': 'text/javascript',
'.json': 'application/json',
'.html': 'text/html',
};
// The scene panels view draws the dialog; Vite compiles it for the page.
const PAGE = `<!doctype html><html lang="en"><body>
<div id="view"></div>
<script type="module">
import { createApp, nextTick } from 'vue';
import { applicationPinia } from '/src/ui/vueRuntime.js';
import LayerPanelsView from '/src/ui/views/LayerPanelsView.vue';
import { createSceneDialog } from '/src/ui/sceneSharing.js';
window.openDialog = () => {
createApp(LayerPanelsView).use(applicationPinia()).mount('#view');
window.openDialog = async () => {
const dialog = createSceneDialog('Share selected scene', () => {});
const files = dialog.input('Choose data-pack files', '', { type: 'file' });
files.multiple = true;
window.filesInput = files;
// Centre of the visible row (the wrapper's last child).
const row = files.parentElement.lastElementChild;
const r = row.getBoundingClientRect();
dialog.input('Choose data-pack files', '', { type: 'file', multiple: true });
await nextTick();
const input = document.querySelector('[data-director-dialog] input[type=file]');
window.filesInput = input;
// Centre of the visible row (the label's last child).
const r = input.parentElement.lastElementChild.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
};
window.ready = true;
@@ -56,21 +59,21 @@ async function main() {
const dir = await mkdtemp(path.join(tmpdir(), 'qa-scene-dialog-drop-'));
const pack = path.join(dir, 'pack.json');
await writeFile(pack, '{}');
const server = http.createServer(async (req, res) => {
const vite = await createServer({
root: ROOT,
configFile: false,
logLevel: 'error',
plugins: [vue(VUE_PLUGIN_OPTIONS)],
server: { middlewareMode: true, hmr: false },
appType: 'custom',
});
const server = http.createServer((req, res) => {
if (req.url === '/') {
res.writeHead(200, { 'content-type': 'text/html' });
res.end(PAGE);
return;
}
try {
const body = await readFile(path.join(ROOT, req.url));
res.writeHead(200, {
'content-type': TYPES[path.extname(req.url)] || 'text/plain',
});
res.end(body);
} catch {
res.writeHead(404).end();
}
vite.middlewares(req, res, () => res.writeHead(404).end());
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const url = `http://127.0.0.1:${server.address().port}/`;
@@ -99,6 +102,7 @@ async function main() {
} finally {
await browser.close();
server.close();
await vite.close();
await rm(dir, { recursive: true, force: true });
}
}
+38 -7
View File
@@ -35,15 +35,21 @@ try {
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.evaluate(() =>
window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, {
persist: false,
syncShare: false,
}),
);
// DISPLAY holds most segmented controls; Draw's shape group is in UTILITIES.
await page.evaluate(() => {
for (const panel of ['pp-toggles', 'utilities-panel'])
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
persist: false,
syncShare: false,
});
});
const surface = await page.evaluate(() => {
const groups = [...document.querySelectorAll('[role="radiogroup"]')];
// Colour swatches are one-of-many too, but chips, not segments: they are
// checked on their own below.
const groups = [
...document.querySelectorAll('[role="radiogroup"]:not(.draw-colors)'),
];
const buttons = groups.flatMap((group) => [
...group.querySelectorAll('[role="radio"]'),
]);
@@ -69,6 +75,31 @@ try {
JSON.stringify(surface),
);
const swatches = await page.evaluate(() => {
const group = document.getElementById('draw-colors');
const chips = [...(group?.querySelectorAll('[role="radio"]') || [])];
return {
count: chips.length,
round: chips.every(
(chip) => getComputedStyle(chip).borderRadius === '50%',
),
colours: new Set(
chips.map((chip) => getComputedStyle(chip).backgroundColor),
).size,
checked: chips.filter(
(chip) => chip.getAttribute('aria-checked') === 'true',
).length,
};
});
check(
"Draw's colour swatches are five round chips, one checked",
swatches.count === 5 &&
swatches.round &&
swatches.colours === 5 &&
swatches.checked === 1,
JSON.stringify(swatches),
);
await page.evaluate(() =>
document.getElementById('models3d-mode-all').click(),
);
+14 -12
View File
@@ -69,8 +69,6 @@ try {
calDirty: false,
},
});
window.__qaBadgeText =
document.getElementById('cctv-source-badge').textContent;
});
await page.waitForSelector('.weather-coverage.gev-status-badge');
const inventory = await page.evaluate(() => {
@@ -120,20 +118,22 @@ try {
);
for (const state of ['loading', 'error', 'retained', 'ready', 'idle']) {
const result = await page.evaluate((state) => {
const result = await page.evaluate(async (state) => {
const owner = window.__godsEyeView.styleManager._cctvControls;
owner._cctvFrame.dataset.loading = state === 'loading' ? 'true' : '';
owner._cctvFrame.dataset.error = ['error', 'retained'].includes(state)
? 'true'
: '';
owner._cctvFrameWrap.classList.toggle(
'has-frame',
state === 'retained',
);
const frame = owner._frame;
owner._frame = {
...frame,
loading: state === 'loading',
error: ['error', 'retained'].includes(state),
hasFrame: state === 'retained',
};
owner._syncCctvSourceBadge(
owner._cctvState.activeCamera,
state !== 'idle',
);
owner._frame = frame;
// The panel view renders the badge on Vue's next flush.
await new Promise((resolve) => requestAnimationFrame(resolve));
const badge = document.getElementById('cctv-source-badge');
return {
state: badge.dataset.frameState,
@@ -164,7 +164,7 @@ try {
['curated', true, 'rgb(255, 208, 138)'],
]) {
const result = await page.evaluate(
({ calBadge, dirty }) => {
async ({ calBadge, dirty }) => {
const owner = window.__godsEyeView.styleManager._cctvControls;
document.getElementById('cctv-next-btn').focus();
owner._renderCctvState({
@@ -175,6 +175,8 @@ try {
calDirty: dirty,
},
});
// The panel view renders the chip on Vue's next flush.
await new Promise((resolve) => requestAnimationFrame(resolve));
const badge = document.getElementById('cctv-quality-chip');
return {
state: badge.dataset.calBadge,
+17 -4
View File
@@ -40,6 +40,8 @@ try {
cancelable: true,
}),
);
// The title bar renders the readout on Vue's next flush.
await Promise.resolve();
const fpsReadout = document.querySelector('.frame-rate-readout');
const fpsWasVisible = fpsReadout && !fpsReadout.hidden;
const watchObserver = (name) => {
@@ -118,10 +120,18 @@ try {
if (type === 'resize' && callback === resizeHandler) counts.resize++;
return removeEventListener.call(this, type, callback, options);
};
const { setSplitFlapText } = await import('/src/splitFlap.js');
// Through the owner, so its copy of the label and the store agree.
const feedbackLabel = document.getElementById('global-loading-label');
setSplitFlapText(feedbackLabel, 'CHECKING LIVE DATA');
setSplitFlapText(feedbackLabel, 'LOAD COMPLETE');
for (const label of ['CHECKING LIVE DATA', 'LOAD COMPLETE']) {
ui._feedback._publishLoading({
hidden: false,
state: 'loading',
label,
detail: '',
});
// Vue renders on a microtask; a task later the label shows it.
await new Promise((resolve) => setTimeout(resolve));
}
const permanentText =
feedbackLabel.querySelector('.gev-flap-text')?.firstChild;
let stateNotifications = 0;
@@ -202,6 +212,7 @@ try {
feedbackLabel.querySelector('.gev-flap-text')?.firstChild ===
permanentText &&
permanentText.data === 'LOAD COMPLETE' &&
document.getElementById('global-loading-status').hidden &&
!feedbackLabel.classList.contains('gev-flap-active') &&
feedbackLabel.querySelector('.gev-flap-cells')?.childNodes.length ===
0,
@@ -211,7 +222,9 @@ try {
ui._lifetime.timers.size === 0 &&
ui._lifetime.removers.size === 0,
frameRateReleased:
fpsWasVisible && !fpsReadout.isConnected && fpsReadout.hidden,
fpsWasVisible &&
!fpsReadout.isConnected &&
!document.querySelector('.frame-rate-readout'),
stateStopped: stateNotifications === 0,
idempotent: once === JSON.stringify(counts),
};
+5 -3
View File
@@ -57,7 +57,7 @@ try {
persist: false,
syncShare: false,
});
manager._sliderPanel.classList.remove('collapsed');
manager._presentPanel(manager._sliderPanel, { collapsed: false });
const slider = manager._sliderContainer.querySelector('input');
window.__qaOldSlider = slider;
const name = 'pixelation';
@@ -140,9 +140,11 @@ try {
hudBefore,
),
);
const displayBefore = await page.evaluate(() => {
const displayBefore = await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
manager._setBloomEnabled(true);
// The Display view shows the slider row on its next render.
await new Promise((resolve) => requestAnimationFrame(resolve));
manager._bloomSlider.value = '80';
manager._bloomSlider.dispatchEvent(new Event('input', { bubbles: true }));
manager._bloomSlider.focus();
@@ -263,7 +265,7 @@ try {
'destroyed Display controls cannot change settings',
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager._displayControls.destroy();
manager._displayBindings._detachDisplayControls();
const before = manager.bloomEnabled;
const contrast = manager._bloomStage.uniforms.contrast;
manager._bloomBtn.click();
+6 -2
View File
@@ -12,10 +12,14 @@ import { after, before, test } from 'node:test';
import puppeteer from 'puppeteer';
const root = new URL('../', import.meta.url);
const template = await readFile(
new URL('src/ui/templates/provider-settings.html', root),
const view = await readFile(
new URL('src/ui/views/ProviderSettingsView.vue', root),
'utf8',
);
const template = view.slice(
view.indexOf('<template>') + '<template>'.length,
view.lastIndexOf('</template>'),
);
const assets = new Set([
'/src/keySetup.js',
'/src/voice/cloudVoiceAuth.js',
+303 -2
View File
@@ -23,6 +23,9 @@
* node scripts/qa-voice-routing.mjs # both layers, :4415
* node scripts/qa-voice-routing.mjs --layer routing --budget 120
* node scripts/qa-voice-routing.mjs --layer behavior --url http://localhost:4173
* node scripts/qa-voice-routing.mjs --layer behavior --behavior outlines
* node scripts/qa-voice-routing.mjs --layer behavior --behavior acceptance [--headful] [--shots qa-shots/outlines]
* # operator-only: may spend the configured guarded Nominatim allowance
* node scripts/qa-voice-routing.mjs --layer behavior --deixis-only --headful --shots qa-shots/point-and-ask
* node scripts/qa-voice-routing.mjs --layer deixis --budget 12 # pointer_context routing (model turns)
* node scripts/qa-voice-routing.mjs --layer deixis-live --url ... # in-app text turns (model turns)
@@ -72,6 +75,7 @@ const LAYER = getOpt('--layer', 'all'); // routing | behavior | all | deixis | d
const TURN_BUDGET = Number(getOpt('--budget', '120'));
const PHRASES_PER_SESSION = Number(getOpt('--batch', '6'));
const ONLY = getOpt('--only', null); // substring filter on phrase text
const BEHAVIOR = getOpt('--behavior', 'all'); // all | outlines | acceptance
const DEIXIS_ONLY = process.argv.includes('--deixis-only'); // behavior: point-and-ask block only
const HEADFUL = process.argv.includes('--headful'); // real GPU, visible window
const SHOT_DIR = getOpt('--shots', null); // point-and-ask evidence screenshots
@@ -1023,7 +1027,7 @@ async function runDeixisBehavior(page, run, settle) {
};
const beginAt = (x, y, said = null) =>
page.evaluate(
(px, py, text) => {
async (px, py, text) => {
const voice = window.__gevVoiceCommands;
if (text) {
// What the session emits for a spoken turn: a new turn, then the caption.
@@ -1043,6 +1047,8 @@ async function runDeixisBehavior(page, run, settle) {
voice.pointer.hold('keydown');
const snapshot = voice.beginPointerTurn('keydown');
voice.pointer.releaseHold();
// The voice card and the reticle render on Vue's next flush.
await Promise.resolve();
const chip = document.getElementById('gev-voice-card-pointer');
const reticle = document.querySelector('.gev-pointer-reticle');
return {
@@ -1323,7 +1329,10 @@ async function runBehaviorLayer() {
// Overpass queries through the app and any direct request to an Overpass
// host (the status probe is not a query).
const overpassRequests = [];
const packTimes = [];
const requestStarted = new Map();
page.on('request', (request) => {
requestStarted.set(request, Date.now());
const target = new URL(request.url());
const viaApp =
target.origin === new URL(APP_URL).origin &&
@@ -1334,12 +1343,26 @@ async function runBehaviorLayer() {
`${request.method()} ${target.origin}${target.pathname}`,
);
});
page.on('requestfinished', (request) => {
if (/us_census_places|wof_neighborhoods/.test(request.url()))
packTimes.push({
file: request.url().split('/').pop().split('?')[0],
ms: Date.now() - (requestStarted.get(request) ?? Date.now()),
});
requestStarted.delete(request);
});
const resolverLog = [];
page.on('console', (message) => {
const text = message.text();
if (text.startsWith('[Resolver]')) resolverLog.push(text);
});
try {
// The point-and-ask block skips the first-run launcher so its evidence
// screenshots show the map; the full run keeps the historical URL.
const behaviorUrl = new URL(APP_URL);
if (DEIXIS_ONLY) behaviorUrl.searchParams.set('welcome', '0');
if (DEIXIS_ONLY || BEHAVIOR === 'acceptance')
behaviorUrl.searchParams.set('welcome', '0');
await page.goto(behaviorUrl.href, {
waitUntil: 'domcontentloaded',
timeout: 60000,
@@ -1382,6 +1405,22 @@ async function runBehaviorLayer() {
});
const settle = (ms) => new Promise((r) => setTimeout(r, ms));
if (BEHAVIOR === 'outlines') {
await runBundledOutlineScenarios({
page,
run,
settle,
overpassRequests,
resolverLog,
packTimes,
});
return;
}
if (BEHAVIOR === 'acceptance') {
await runOutlineAcceptance({ page, run, settle, overpassRequests });
return;
}
// (1) fly_to_location lands near the target. Annotations follow IMMEDIATELY
// while the camera is local — the resolver's proximity gate rejects far
// targets BY DESIGN (annotating Austin from over the Alps must fail).
@@ -2293,6 +2332,15 @@ async function runBehaviorLayer() {
`ok=${gated?.ok} error=${String(gated?.error || '').slice(0, 90)} active=${cockpitAfterGate}`,
);
await runBundledOutlineScenarios({
page,
run,
settle,
overpassRequests,
resolverLog,
packTimes,
});
const shotDir = path.join(ROOT, 'qa-shots');
fs.mkdirSync(shotDir, { recursive: true });
await page.screenshot({
@@ -2316,6 +2364,259 @@ async function runBehaviorLayer() {
}
}
/**
* Area asks that the bundled packs answer with no boundary lookup: a US
* Census place, the San Francisco DataSF pack, and Who's On First
* neighborhoods. Each teleports the camera over the place, draws the area,
* and checks the outline, the pack that answered, and that no request went
* to Overpass.
*/
const BUNDLED_OUTLINES = [
{ target: 'Austin', lat: 30.2672, lon: -97.7431, pack: 'us-census' },
{ target: 'the Mission District', lat: 37.7599, lon: -122.4148, pack: 'datasf', entityKind: 'district' },
{ target: 'Notting Hill', lat: 51.5118, lon: -0.2046, pack: 'wof' },
{ target: 'Williamsburg', lat: 40.7081, lon: -73.9571, pack: 'wof' },
{ target: 'Le Marais', lat: 48.8575, lon: 2.358, pack: 'wof' },
];
async function runBundledOutlineScenarios({
page,
run,
settle,
overpassRequests,
resolverLog,
packTimes,
}) {
const timings = [];
for (const scenario of BUNDLED_OUTLINES) {
const label = `behavior: "outline ${scenario.target}" draws the bundled ${scenario.pack} outline`;
await page.evaluate(({ lat, lon }) => {
const { viewer } = window.__godsEyeView;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (lon * Math.PI) / 180,
latitude: (lat * Math.PI) / 180,
height: 9000,
}),
});
}, scenario);
await run('clear_annotations', {});
await settle(1500);
const overpassBefore = overpassRequests.length;
const logBefore = resolverLog.length;
const packsBefore = packTimes.length;
const started = Date.now();
const r = await run('annotate_map', {
annotations: [{
type: 'area',
target: scenario.target,
label: scenario.target,
...(scenario.entityKind ? { entityKind: scenario.entityKind } : {}),
}],
});
let ringPoints = 0;
for (let i = 0; i < 40 && ringPoints < 4; i += 1) {
ringPoints = await page.evaluate((target) => {
const items = window.__gevAnnotations?.list?.() || [];
const item = items.find((it) => it.label === target);
return Array.isArray(item?.ring) ? item.ring.length : 0;
}, scenario.target);
if (ringPoints < 4) await settle(250);
}
const ms = Date.now() - started;
const line = resolverLog.slice(logBefore).find((l) => l.includes('bundled')) || '';
const pack = /\((us-census|datasf|wof|natural-earth)[,)]/.exec(line)?.[1] || 'none';
const packFiles = packTimes.slice(packsBefore);
const overpass = overpassRequests.slice(overpassBefore);
timings.push({ target: scenario.target, ms, pack, overpass: overpass.length });
report((r?.drawn ?? 0) >= 1 && ringPoints >= 4 && pack === scenario.pack && overpass.length === 0,
label,
`drawn=${r?.drawn} ring=${ringPoints} pack=${pack} outlineMs=${ms} overpass=${overpass.length} ` +
`packFiles=${packFiles.map((f) => `${f.file}:${f.ms}ms`).join(',') || 'cached'}`);
}
await run('clear_annotations', {});
report(
overpassRequests.length === 0,
'behavior: no request reached Overpass during the run',
`overpass=${overpassRequests.length} ${overpassRequests.slice(0, 2).join(' ')}`,
);
console.log(` bundled outline timings: ${JSON.stringify(timings)}`);
console.log(` pack files loaded: ${packTimes.map((f) => `${f.file}:${f.ms}ms`).join(' ')}`);
}
/**
* Outline acceptance across six countries: each ask teleports the camera
* over the place, draws the area once cold and once warm (after clearing),
* and records the rung that answered, the parts drawn, the latency, and how
* many public Nominatim requests the server spent (its daily count in
* .gev-cache/nominatim/state.json). Nothing may reach Overpass.
*/
const OUTLINE_ASKS = [
// group, target, camera lat/lon/height(m), extra annotation args, shot name
['country', 'Switzerland', 46.8, 8.2, 1_200_000],
['country', 'Iran', 32.4, 53.7, 3_500_000],
['state', 'Texas', 31.0, -99.0, 2_000_000],
['state', 'Bavaria', 48.9, 11.4, 900_000],
['city', 'Austin', 30.2672, -97.7431, 60_000, {}, 'austin-city'],
['city', 'Paris', 48.8566, 2.3522, 40_000],
['city', 'Tokyo', 35.6762, 139.6503, 90_000],
['city', 'São Paulo', -23.5505, -46.6333, 90_000],
['city', 'Mumbai', 19.076, 72.8777, 60_000],
['city', 'Lagos', 6.5244, 3.3792, 60_000],
['neighborhood', 'the Mission District', 37.7599, -122.4148, 8000, { entityKind: 'district' }],
['neighborhood', 'the Marina', 37.8037, -122.4368, 8000, { entityKind: 'district' }],
['neighborhood', 'Notting Hill', 51.5118, -0.2046, 8000, { entityKind: 'district' }, 'notting-hill'],
['neighborhood', 'Williamsburg', 40.7081, -73.9571, 8000, { entityKind: 'district' }],
['neighborhood', 'SoHo', 40.7233, -74.003, 8000, { entityKind: 'district' }],
['neighborhood', 'Le Marais', 48.8575, 2.358, 8000, { entityKind: 'district' }],
['neighborhood', 'Shibuya', 35.6595, 139.7005, 8000, { entityKind: 'district' }],
['landmark', 'the Texas Capitol grounds', 30.2747, -97.7404, 3000, { entityKind: 'compound' }],
['landmark', 'Central Park', 40.7829, -73.9654, 6000, {}, 'central-park'],
['landmark', 'the Eiffel Tower', 48.8584, 2.2945, 3000],
['landmark', 'Stanford', 37.4275, -122.1697, 8000],
['landmark', 'Stanford University', 37.4275, -122.1697, 8000],
['landmark', 'Lady Bird Lake', 30.2638, -97.7526, 6000],
['street', 'Congress Avenue', 30.2672, -97.7425, 3000, { entityKind: 'street' }, 'congress-avenue'],
['street', 'Lombard Street', 37.8021, -122.4187, 3000, { entityKind: 'street' }],
['building', 'this building', 30.27472, -97.74035, 1200,
{ entityKind: 'building', latitude: 30.27472, longitude: -97.74035 }, 'building'],
].map(([group, target, lat, lon, height, extra = {}, shot = null]) => ({ group, target, lat, lon, height, extra, shot }));
const NOMINATIM_STATE = path.join(ROOT, '.gev-cache', 'nominatim', 'state.json');
function nominatimCount() {
try {
return JSON.parse(fs.readFileSync(NOMINATIM_STATE, 'utf8')).count || 0;
} catch {
return 0;
}
}
async function runOutlineAcceptance({
page,
run,
settle,
overpassRequests,
}) {
const trace = [];
page.on('console', (msg) => {
const text = msg.text();
if (/^\[(Outline|Resolver)\]/.test(text)) trace.push(text);
});
const nominatimStart = nominatimCount();
const overpassStart = overpassRequests.length;
if (SHOT_DIR) fs.mkdirSync(path.resolve(ROOT, SHOT_DIR), { recursive: true });
const teleport = (ask) => page.evaluate(({ lat, lon, height }) => {
const { viewer } = window.__godsEyeView;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (lon * Math.PI) / 180,
latitude: (lat * Math.PI) / 180,
height,
}),
});
}, ask);
const rungOf = (lines, state) => {
for (const line of lines) {
const m = /→ (openfreemap|nominatim)\b/.exec(line) ||
/bundled → ([a-z-]+)/.exec(line) ||
/\((us-census|datasf|wof|natural-earth),/.exec(line);
if (m) return m[1];
}
if (state?.synthesized) return 'approximate';
return state?.ring >= 4 ? 'other' : 'point';
};
const once = async (ask) => {
await run('clear_annotations', {});
await teleport(ask);
await settle(1200);
trace.length = 0;
const before = nominatimCount();
const label = `QA ${ask.target}`;
const started = Date.now();
const r = await run('annotate_map', {
annotations: [{ type: 'area', target: ask.target, label, ...ask.extra }],
});
let state = null;
for (let i = 0; i < 120; i += 1) {
state = await page.evaluate((l) => {
const it = (window.__gevAnnotations?.list?.() || []).find((a) => a.label === l);
if (!it) return null;
return {
ring: Array.isArray(it.ring) ? it.ring.length : 0,
parts: Array.isArray(it.polygons) && it.polygons.length ? it.polygons.length : (it.ring ? 1 : 0),
synthesized: Boolean(it.synthesized),
unavailable: Boolean(it.outlineUnavailable),
pending: Boolean(it.pendingOutline),
};
}, label);
if (state && !state.pending) break;
if (!state && r?.drawn === 0 && i > 8) break;
await settle(250);
}
const ms = Date.now() - started;
return {
ms,
drawn: r?.drawn ?? 0,
state,
rung: rungOf(trace, state),
nominatim: nominatimCount() - before,
error: r?.error || r?.results?.find?.((x) => x?.error)?.error || null,
};
};
const table = [];
// --only "Paris|Stanford" reruns a few rows.
const only = ONLY ? ONLY.split('|') : null;
for (const ask of OUTLINE_ASKS.filter((a) => !only || only.some((o) => a.target.includes(o)))) {
const cold = await once(ask);
if (SHOT_DIR && ask.shot) {
await settle(HEADFUL ? 6000 : 2500);
await page.screenshot({ path: path.resolve(ROOT, SHOT_DIR, `${ask.shot}.png`) });
}
const warm = await once(ask);
const rendered = Boolean(cold.state?.ring >= 4) && !cold.state?.synthesized;
const row = {
group: ask.group,
ask: ask.target,
rung: cold.rung,
rendered,
approximate: Boolean(cold.state?.synthesized),
parts: cold.state?.parts ?? 0,
coldMs: cold.ms,
warmMs: warm.ms,
warmRung: warm.rung,
nominatim: cold.nominatim + warm.nominatim,
};
table.push(row);
const detail = `rung=${row.rung} parts=${row.parts} cold=${row.coldMs}ms warm=${row.warmMs}ms ` +
`nominatim=${row.nominatim}${row.approximate ? ' approximate' : ''}` +
(rendered ? '' : ` trace=${trace.slice(-2).join(' | ').slice(0, 300)}`);
if (rendered) report(true, `outlines: ${ask.group} "${ask.target}"`, detail);
else skipped(`outlines: ${ask.group} "${ask.target}" (no exact outline)`, detail);
}
await run('clear_annotations', {});
const overpass = overpassRequests.slice(overpassStart);
report(overpass.length === 0, 'outlines: no request reached Overpass during the run',
`overpass=${overpass.length} ${overpass.slice(0, 2).join(' ')}`);
const used = nominatimCount() - nominatimStart;
report(used <= 30, 'outlines: public Nominatim requests within the run budget (30)', `used=${used}`);
console.log('\n | Group | Ask | Rung | Outline | Parts | Cold ms | Warm ms | Nominatim |');
console.log(' |---|---|---|---|---|---|---|---|');
for (const row of table) {
const outline = row.rendered ? 'yes' : row.approximate ? 'approximate' : 'no (point)';
console.log(` | ${row.group} | ${row.ask} | ${row.rung} | ${outline} | ${row.parts} | ${row.coldMs} | ${row.warmMs} | ${row.nominatim} |`);
}
if (SHOT_DIR)
fs.writeFileSync(path.resolve(ROOT, SHOT_DIR, 'outline-table.json'), JSON.stringify(table, null, 2));
}
// ── Main ────────────────────────────────────────────────────
console.log('\nVoice-surface QA harness');
console.log(` App URL : ${APP_URL}`);
+1 -1
View File
@@ -261,7 +261,7 @@ try {
for (const [id, entry] of gev.dataManager.layers)
if (entry.enabled)
await gev.dataManager.setEnabled(id, false, { origin: 'user' });
document.querySelector('#data-panel')?.classList.remove('collapsed');
gev.styleManager._presentPanel('data-panel', { collapsed: false });
});
// 1. Earth view, clean.
+7 -1
View File
@@ -71,8 +71,14 @@ export function allocationTestArgs(file) {
return ['--expose-gc', '--test', '--test-concurrency=1', file];
}
/** Node flags every unit-test process receives. */
export const UNIT_TEST_NODE_FLAGS = Object.freeze([
// Single-file components compile on import.
'--import=./build/registerVue.js',
]);
function runTests(args) {
const result = spawnSync(process.execPath, args, {
const result = spawnSync(process.execPath, [...UNIT_TEST_NODE_FLAGS, ...args], {
cwd: process.cwd(),
stdio: 'inherit',
env: process.env,
+1 -1
View File
@@ -57,7 +57,7 @@ export function classifyNodeVersion(version = process.versions.node) {
// NEWER than this release has verified is a warning, never a refusal: a
// future Node must not brick a no-terminal install with advice its user
// cannot follow. Too-old stays an error above — old runtimes genuinely fail.
return { level: 'warn', summary: 'newer than this release has verified; Node 24.14.x or 26.x is the tested path' };
return { level: 'warn', summary: 'newer than this release has verified; Node 24.22.x or 26.x is the tested path' };
}
/** Verify that every direct package declared by this checkout is present. */
+1
View File
@@ -612,6 +612,7 @@ async function main() {
if (models3dButton) {
if (!models3dButton.classList.contains('active')) models3dButton.click();
models3dButton.click(); // → OFF: both layers must follow the one control
await new Promise((resolve) => setTimeout(resolve)); // the view renders
models3dOff.button = models3dButton.classList.contains('active');
models3dOff.fl = fl.getParams().models3d;
models3dOff.mil = mil.getParams().models3d;
+51
View File
@@ -0,0 +1,51 @@
{
"reviewedSources": {
"zs": {
"name": "Zetashapes",
"registryCommit": "e17af153c144fe6e08aa8db2607b4bf8aecd448d",
"registrySha256": "d8a5dd873d26eee11583de4438185c046dec33848d5bde27047ca4bff44d9c39",
"rawLicenseType": "Public domain",
"licenseTextSha256": "91757f96f74f6dfb4a7206486b78586da0d039e9611e224fc793c7167dcfe615",
"decision": "Approved from the pinned WOF registry text: no added restrictions beyond public-domain US TIGER/Line data and Flickr-derived data; retain both upstream credits."
}
},
"licenses": [
"Attribution 3.0 Unported",
"CC BY",
"CC BY 2.0",
"CC BY 3.0",
"CC BY 3.0 US",
"CC BY 4.0",
"CC-BY",
"CC0",
"CC0 1.0 Universal",
"ODC-By, v1.0",
"ODC-By v1.0",
"OGL - Canada",
"OGL - City of New Westminster",
"OGL - City of Surrey",
"Open Government Licence v3.0",
"Open Government Licence - British Columbia, v2.0",
"Open Government Licence - Canada",
"Open Government Licence - City of Calgary",
"Open Government Licence - City of Regina",
"Open Government Licence - Edmonton",
"Open Government Licence - Ontario, v1.0",
"Open Government License - British Columbia, v2.0",
"Open Government License - Winnipeg",
"PDDL 1.0",
"Public domain",
"Public Domain",
"Singapore Open Data Licence v1"
],
"recordExclusions": {
"reason": "Sources whose pinned registry licence is only an \"equivalent\" designation (for example \"CC BY (equivalent)\" or \"CC0 (equivalent)\"), including can-ons, can-saskodp, can-gatsudd and esp-aytomad, are not admitted: their redistribution terms have not been established for this derived pack.",
"archives": {
"neighbourhood": "8e5e413aacf2244e4e4167fbd9be860bc407ae6de8b733e47f13abe6d3d10455",
"macrohood": "f5fbfabcb8229b83242dbd38530c27e61867e8b3b097e3393d9c841962a99679",
"microhood": "e8197fa576e8831be8f091ac7e219d29224d89404ab0c56d377339e42a8f29ac"
},
"registrySha256": "d8a5dd873d26eee11583de4438185c046dec33848d5bde27047ca4bff44d9c39",
"sources": {}
}
}