diff --git a/CHANGELOG.md b/CHANGELOG.md
index 81ff88aa8..5cee11c60 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,24 @@
estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it (bassem chagra,
#452).
+- Ship a web app manifest and raster home-screen icons, so adding the app to a
+ phone home screen or installing it on desktop uses the God's Eye View mark
+ instead of a blank tile. The icons are generated from `public/logo.svg` by
+ `node scripts/generate-icons.mjs` and committed, so a plain `vite build` needs
+ no image toolchain. The existing SVG favicon still serves the browser tab.
+ Manifest paths are relative to the manifest, so a build served under a path
+ prefix installs and launches inside that prefix.
+
+- Cyclone advisories no longer go dark while NHC publishes an advisory ahead
+ of its nominal time: advisory and position times up to 90 minutes ahead of
+ the server clock are accepted (previously 5 minutes), so the whole feed is
+ not rejected for about half an hour each advisory cycle.
+
+- Flights age out vanished aircraft again. OpenSky's routine position-less rows
+ no longer mark every snapshot partial, which had kept vanished and landed
+ aircraft at full opacity for up to five minutes; those rows retain only their
+ own aircraft, and a likely-landed one still leaves on the landed limit.
+
- Add WSDOT highway cameras (Washington State) as a keyless CCTV source pack:
WSDOT's official Travel Center camera layer, with frames registered only on
WSDOT's own image host (partner-hosted cameras such as Oregon DOT and City of
@@ -73,6 +91,11 @@
have wound down — CoGo's feed reports no stations and last moved a bike in
March 2025. The three repointed feeds were verified live.
+- Add seven Brazilian Bike Itaú (Tembici) cities to the GBFS bikeshare registry:
+ São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília and
+ Curitiba (about 1,000 stations in total). These are the layer's first stations
+ in South America.
+
- Add Bike Share Toronto (1,069 stations) to the bikeshare registry. The system
publishes the same PBSC GBFS v2 feeds as the Austin, Honolulu and Chattanooga
entries, so it needs no new plumbing; a registry test now holds every city's
diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md
index d1ecd1423..218505fad 100644
--- a/DATA_SOURCES.md
+++ b/DATA_SOURCES.md
@@ -65,8 +65,9 @@ How to read this:
| **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" |
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
| **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" |
-| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
+| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
| **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" |
+| **Bike Itaú (Tembici)** (`*.publicbikesystem.net`, seven Brazilian cities) | Bikeshare station availability: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília and Curitiba | Operator GBFS feeds. No licence is published: the feeds' `system_information.json` carries no `license_url`, operator or attribution fields. Attribution is given to the operator. | "Bike Itaú (Tembici)" |
| **Oslo / Bergen / Trondheim Bysykkel** (Urban Sharing, `gbfs.urbansharing.com`) | Bikeshare availability, Oslo, Bergen and Trondheim city bikes. Keyless GBFS; the proxy sends the `Client-Identifier` header the operators ask for | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED ([Oslo](https://oslobysykkel.no/en/open-data/realtime), [Bergen](https://bergenbysykkel.no/en/apne-data/sanntid), [Trondheim](https://trondheimbysykkel.no/en/open-data/realtime)) | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel" |
| **Kolumbus / Farte Bysykkel** (Entur Mobility, `api.entur.io/mobility/v2/gbfs`) | Bikeshare availability, Kolumbus Bysykkel (Stavanger/Nord-Jæren) and Farte Bysykkel (Skien/Porsgrunn). Keyless GBFS; the proxy sends the `ET-Client-Name` header Entur requires and is limited to Entur's GBFS paths | [NLOD](https://data.norge.no/nlod/en/2.0) per the [Entur Mobility API docs](https://developer.entur.org/pages-mobility-docs-mobility-v2) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)" |
| **JCDecaux Cyclocity** (`api.cyclocity.fr`) | Bikeshare availability, Bysykkel Lillestrøm. Keyless GBFS; the proxy is limited to JCDecaux contract paths | [Licence Ouverte / Etalab](https://developer.jcdecaux.com/files/Open-Licence-fr.pdf) (the feed's `license_url`) — attribution REQUIRED | "Bysykkel Lillestrøm: JCDecaux, Licence Ouverte" |
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index b2be525c7..580d05e6e 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -339,7 +339,12 @@ Cyclones combine fixed NHC/CPHC status and summary GIS endpoints through a bound
same-origin provider. The status endpoint lacks browser CORS; each installation
fetches keyless public sources at runtime, with no centralized ingestion service.
The five-minute snapshot covers Atlantic and eastern/central North Pacific only.
-Current positions, advisory issue time and position time stay distinct. Tracks,
+Current positions, advisory issue time and position time stay distinct. NHC may
+release an advisory before its nominal time (routinely up to about 30 minutes,
+rarely about an hour), so advisory issue and position times up to and including
+90 minutes ahead of the server clock are accepted; 90 minutes is this app's
+tolerance, not an NHC limit. Later, non-canonical, or more than 12-hour-old
+times still reject the status snapshot. Tracks,
forecast lead-hour points and cones render only when all source parts match the
status advisory; a newer status shows its position with geometry pending rather
than relabeling older geometry. The cone represents forecast center uncertainty,
@@ -872,7 +877,13 @@ addition to the locally accumulated trail, never a promise of complete coverage.
Snapshot coverage, completeness and freshness are separate fields. A partially
admitted aircraft snapshot retains absent contacts for up to five minutes before
-the usual missed-poll eviction. Unknown snapshot times remain unknown in stats. An invalid nonempty
+the usual missed-poll eviction. An OpenSky row with an ICAO24 address but null
+longitude and latitude is a contact without a position, not a malformed row: it
+leaves the snapshot complete and is listed in `retainIds`, so civil flights
+retain that aircraft under the same five-minute bound, unless it is likely
+landed, while other absent aircraft fade and leave on the missed-poll limits. A
+nonempty snapshot with no positioned rows is still malformed. Military readsb
+admission is unchanged. Unknown snapshot times remain unknown in stats. An invalid nonempty
snapshot retains the previous display. Empty vessel refreshes retain the existing
first-connect grace and warm-data behavior. Known source failures have bounded
messages; arbitrary HTTP response bodies are not surfaced as diagnostics.
@@ -1274,6 +1285,28 @@ services, local voice and standalone key setup. Importing them starts no network
acquisition. Browser layer lifecycle, rendering and voice execution stay in their
existing modules.
+## Web app install icons
+
+`public/manifest.webmanifest` declares the installable app: standalone display,
+`#0a0a0f` background and theme, and 192/512 icons plus a 512 maskable variant.
+`index.html` links the manifest, a 180px `apple-touch-icon` and a 32px PNG
+favicon; the existing SVG favicon still serves the browser tab. Safari ignores an
+SVG apple-touch-icon and substitutes a page screenshot, so the raster is what
+makes an installed icon appear at all.
+
+Manifest `start_url`, `scope` and icon paths are relative to the manifest, and
+Vite rewrites the `index.html` links under `base`, so a build served from a path
+prefix installs and launches inside that prefix.
+
+`node scripts/generate-icons.mjs` renders every raster from `public/logo.svg` and
+writes `public/icons/`. Output is committed, so building needs no image
+toolchain; re-run the script after changing the logo. Ordinary icons inset the
+mark to 78% of the canvas and the maskable variant to 54%, keeping it inside the
+circle Android may crop to. `src/tooling/webAppIcons.test.mjs` checks the links,
+the manifest fields and that each committed PNG is square at its declared size.
+It also builds the install links under `base: '/example/'` and asserts every
+emitted link and manifest URL stays inside that base.
+
## Local build preview
After `npm run build`, `npm run preview` serves the built app with the same data
@@ -3232,7 +3265,7 @@ its criteria cannot be silently ignored.
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
-| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto + Urban Sharing, Entur, JCDecaux NO) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
+| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto and Bike Itaú (Brazil) + Urban Sharing, Entur, JCDecaux NO) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
diff --git a/index.html b/index.html
index 78d6e34ec..8a01415b6 100644
--- a/index.html
+++ b/index.html
@@ -4,7 +4,20 @@
God's Eye View
+
+
+
+
+
+
+
+
+
+
diff --git a/public/icons/icon-180.png b/public/icons/icon-180.png
new file mode 100644
index 000000000..5655a9e70
Binary files /dev/null and b/public/icons/icon-180.png differ
diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png
new file mode 100644
index 000000000..7aed57138
Binary files /dev/null and b/public/icons/icon-192.png differ
diff --git a/public/icons/icon-32.png b/public/icons/icon-32.png
new file mode 100644
index 000000000..a19279f06
Binary files /dev/null and b/public/icons/icon-32.png differ
diff --git a/public/icons/icon-512-maskable.png b/public/icons/icon-512-maskable.png
new file mode 100644
index 000000000..0b3064f39
Binary files /dev/null and b/public/icons/icon-512-maskable.png differ
diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png
new file mode 100644
index 000000000..644a5c8ea
Binary files /dev/null and b/public/icons/icon-512.png differ
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 000000000..478cd9019
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,22 @@
+{
+ "id": ".",
+ "name": "God's Eye View",
+ "short_name": "God's Eye",
+ "description": "Live spatial intelligence in the browser.",
+ "start_url": ".",
+ "scope": ".",
+ "display": "standalone",
+ "orientation": "any",
+ "background_color": "#0a0a0f",
+ "theme_color": "#0a0a0f",
+ "icons": [
+ { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
+ { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
+ {
+ "src": "icons/icon-512-maskable.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ]
+}
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index d956d09e8..56dd54da1 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -1050,6 +1050,8 @@
"src/costEndpointThrottle.test.mjs",
"src/sources/gbfsStations.test.mjs",
"scripts/qa-gbfs-names.mjs",
+ "scripts/generate-icons.mjs",
+ "src/tooling/webAppIcons.test.mjs",
"src/ui/flyToDataSource.js",
"src/ui/flyToDataSource.test.mjs"
]
diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs
new file mode 100644
index 000000000..3c16eb46e
--- /dev/null
+++ b/scripts/generate-icons.mjs
@@ -0,0 +1,70 @@
+#!/usr/bin/env node
+// Renders the home-screen and favicon rasters from `public/logo.svg`.
+//
+// Re-run after changing the logo: `node scripts/generate-icons.mjs`. The output
+// is committed, because the install icons have to exist in a plain `vite build`
+// and contributors should not need an image toolchain to ship the app.
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import sharp from 'sharp';
+
+const REPO_ROOT = path.resolve(
+ fileURLToPath(new URL('.', import.meta.url)),
+ '..',
+);
+const LOGO = path.join(REPO_ROOT, 'public', 'logo.svg');
+const OUT_DIR = path.join(REPO_ROOT, 'public', 'icons');
+
+/** `--bg-dark` from src/ui/styles/foundation.css, so the tile matches the app. */
+const BACKGROUND = '#0a0a0f';
+
+/**
+ * Fraction of the canvas the mark is allowed to occupy.
+ *
+ * A maskable icon may be cropped to a circle inscribed in the middle 80% of the
+ * square, so its mark stays well inside that circle; the other icons are shown
+ * as drawn and only need optical breathing room.
+ */
+const INSET = { normal: 0.78, maskable: 0.54 };
+
+const TARGETS = [
+ { file: 'icon-32.png', size: 32, inset: INSET.normal },
+ { file: 'icon-180.png', size: 180, inset: INSET.normal },
+ { file: 'icon-192.png', size: 192, inset: INSET.normal },
+ { file: 'icon-512.png', size: 512, inset: INSET.normal },
+ { file: 'icon-512-maskable.png', size: 512, inset: INSET.maskable },
+];
+
+/** Fit the logo inside `size * inset` and centre it on an opaque square. */
+async function render(svg, { size, inset }) {
+ const box = Math.round(size * inset);
+ // The logo's viewBox is wider than it is tall, so `contain` bounds it by
+ // width and leaves the vertical padding uneven — centring is done by the
+ // composite below rather than by the resize.
+ const mark = await sharp(svg, { density: 384 })
+ .resize(box, box, {
+ fit: 'contain',
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
+ })
+ .png()
+ .toBuffer();
+ return sharp({
+ create: {
+ width: size,
+ height: size,
+ channels: 4,
+ background: BACKGROUND,
+ },
+ })
+ .composite([{ input: mark, gravity: 'centre' }])
+ .png({ compressionLevel: 9 })
+ .toBuffer();
+}
+
+const svg = await readFile(LOGO);
+await mkdir(OUT_DIR, { recursive: true });
+for (const target of TARGETS) {
+ await writeFile(path.join(OUT_DIR, target.file), await render(svg, target));
+ console.log(`icons: wrote ${target.file} (${target.size}px)`);
+}
diff --git a/server/providers/cyclones.js b/server/providers/cyclones.js
index fede1aade..d580357ce 100644
--- a/server/providers/cyclones.js
+++ b/server/providers/cyclones.js
@@ -41,6 +41,9 @@ function number(value, min, max) {
const n = Number(value);
return Number.isFinite(n) && n >= min && n <= max ? n : null;
}
+// NHC stamps an advisory with its nominal time (e.g. 03:00Z) and publishes it
+// up to about half an hour before; allow that, but not a clock far ahead.
+const EARLY_ADVISORY_MS = 90 * 60_000;
function iso(value, now) {
if (
typeof value !== 'string' ||
@@ -50,7 +53,7 @@ function iso(value, now) {
const n = Date.parse(value);
if (
!Number.isFinite(n) ||
- n > now + 5 * 60_000 ||
+ n > now + EARLY_ADVISORY_MS ||
now - n > 12 * HOUR ||
new Date(n).toISOString().replace('.000Z', 'Z') !==
value.replace('.000Z', 'Z')
diff --git a/src/data/cyclonesProxy.test.mjs b/src/data/cyclonesProxy.test.mjs
index 04729d33a..23e84826c 100644
--- a/src/data/cyclonesProxy.test.mjs
+++ b/src/data/cyclonesProxy.test.mjs
@@ -162,6 +162,44 @@ test('malformed, duplicate, oversized, future and expired status is rejected rat
assert.deepEqual(parseCycloneStatus({ activeStorms: [] }, NOW), []);
});
+test('advisories published before their nominal time are accepted, far-future ones are not', () => {
+ // NHC wrote the 03:00Z advisory at 02:34Z.
+ const early = Date.UTC(2026, 8, 16, 2, 34);
+ assert.equal(parseCycloneStatus(status(), early)[0].issuedAt, TIME);
+ assert.throws(
+ () => parseCycloneStatus(status(), Date.UTC(2026, 8, 16, 1, 25)),
+ /invalid_cyclone_data/,
+ );
+});
+
+test('advisory and position times are bounded at 90 minutes ahead and 12 hours old', () => {
+ const MINUTE = 60_000;
+ const at = (offsetMs) => new Date(NOW + offsetMs).toISOString();
+ const fields = {
+ issuedAt: (value) =>
+ status({
+ forecastAdvisory: {
+ advNum: '010',
+ issuance: value,
+ url: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
+ },
+ }),
+ positionAt: (value) => status({ lastUpdate: value }),
+ };
+ for (const [field, payload] of Object.entries(fields)) {
+ for (const offset of [90 * MINUTE, -12 * 60 * MINUTE]) {
+ const [storm] = parseCycloneStatus(payload(at(offset)), NOW);
+ assert.equal(storm[field], at(offset), `${field} ${offset}`);
+ }
+ for (const offset of [90 * MINUTE + 1, -12 * 60 * MINUTE - 1])
+ assert.throws(
+ () => parseCycloneStatus(payload(at(offset)), NOW),
+ /invalid_cyclone_data/,
+ `${field} ${offset}`,
+ );
+ }
+});
+
test('coherent geometry is retained with holes/dateline while older advisory geometry is omitted', () => {
const storms = parseCycloneStatus(status(), NOW);
const parts = collections();
diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js
index 956671757..d6c265ddc 100644
--- a/src/data/dataCredits.js
+++ b/src/data/dataCredits.js
@@ -304,7 +304,7 @@ export const DATA_CREDITS = [
key: 'gbfs',
html:
'Bikeshare availability: GBFS operator feeds ' +
- '(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority)',
+ '(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú, operated by Tembici)',
},
{
key: 'urbansharing-gbfs',
diff --git a/src/layers/bikeshare/brPbscCities.test.mjs b/src/layers/bikeshare/brPbscCities.test.mjs
new file mode 100644
index 000000000..60e12c23b
--- /dev/null
+++ b/src/layers/bikeshare/brPbscCities.test.mjs
@@ -0,0 +1,73 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { CITY_BY_ID } from './registry.js';
+import { createModel } from './model.js';
+import { createViewport } from './viewport.js';
+import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
+
+// Each Bike Itaú city's PBSC host, and the station farthest from its
+// registered centre in the live feed (checked 2026-10-15).
+const CITIES = {
+ 'sao-paulo-bike-itau': {
+ host: 'saopaulo.publicbikesystem.net',
+ stations: [{ name: '260 - P3', lat: -23.568235, lon: -46.740715 }],
+ },
+ 'rio-de-janeiro-bike-itau': {
+ host: 'riodejaneiro.publicbikesystem.net',
+ stations: [{ name: '293 - Praia da Macumba', lat: -23.03135, lon: -43.479584 }],
+ },
+ 'salvador-bike-itau': {
+ host: 'salvador.publicbikesystem.net',
+ stations: [{ name: '49 - Stella Maris', lat: -12.93645, lon: -38.32701 }],
+ },
+ 'recife-bike-itau': {
+ host: 'recife.publicbikesystem.net',
+ stations: [{ name: '69 - Praia de Piedade', lat: -8.186205, lon: -34.918371 }],
+ },
+ 'porto-alegre-bike-itau': {
+ host: 'portoalegre.publicbikesystem.net',
+ stations: [{ name: '72 - Avenida Tramandaí', lat: -30.135915, lon: -51.22684 }],
+ },
+ 'brasilia-bike-itau': {
+ host: 'brasilia.publicbikesystem.net',
+ stations: [{ name: "60 - Parque Olhos d'Água", lat: -15.7431, lon: -47.88667 }],
+ },
+ 'curitiba-bike-itau': {
+ host: 'curitiba.publicbikesystem.net',
+ stations: [{ name: '42 - Terminal Portão', lat: -25.476885, lon: -49.292608 }],
+ },
+};
+
+// The production range check, with no viewer or fetches behind it.
+const parts = { model: createModel({ state: {}, services: {}, parts: {}, source: null }) };
+const { computeInRangeCities } = createViewport({
+ state: {},
+ services: { render: { governorRequestRender() {} } },
+ parts,
+ source: null,
+});
+
+test('the Bike Itaú systems are registered and readable through the proxy', () => {
+ for (const [id, expected] of Object.entries(CITIES)) {
+ const city = CITY_BY_ID.get(id);
+ assert.ok(city, `${id} is registered`);
+ assert.equal(city.provider, 'Bike Itaú');
+ assert.deepEqual(city.hosts, [expected.host]);
+ for (const feed of [city.stationInformationUrl, city.stationStatusUrl]) {
+ const url = new URL(feed);
+ assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`);
+ assert.ok(isAllowedGbfsPath(url.pathname), `${id} path ${url.pathname}`);
+ }
+ }
+});
+
+test('a camera over each outermost station activates its system', () => {
+ for (const [id, expected] of Object.entries(CITIES)) {
+ for (const station of expected.stations) {
+ assert.ok(
+ computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id),
+ `${station.name} activates ${id}`,
+ );
+ }
+ }
+});
diff --git a/src/layers/bikeshare/registry.js b/src/layers/bikeshare/registry.js
index 78ebce9ce..916d50123 100644
--- a/src/layers/bikeshare/registry.js
+++ b/src/layers/bikeshare/registry.js
@@ -246,6 +246,90 @@ const RAW_GBFS_CITY_REGISTRY = [
'https://chat.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Chattanooga',
},
+ {
+ id: 'sao-paulo-bike-itau',
+ city: 'São Paulo, SP',
+ centerLat: -23.5505,
+ centerLon: -46.6333,
+ loadRadiusKm: 100,
+ stationInformationUrl:
+ 'https://saopaulo.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
+ stationStatusUrl:
+ 'https://saopaulo.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
+ provider: 'Bike Itaú',
+ },
+ {
+ id: 'rio-de-janeiro-bike-itau',
+ city: 'Rio de Janeiro, RJ',
+ centerLat: -22.9068,
+ centerLon: -43.1729,
+ loadRadiusKm: 100,
+ stationInformationUrl:
+ 'https://riodejaneiro.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
+ stationStatusUrl:
+ 'https://riodejaneiro.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
+ provider: 'Bike Itaú',
+ },
+ {
+ id: 'salvador-bike-itau',
+ city: 'Salvador, BA',
+ centerLat: -12.9777,
+ centerLon: -38.5016,
+ loadRadiusKm: 90,
+ stationInformationUrl:
+ 'https://salvador.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
+ stationStatusUrl:
+ 'https://salvador.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
+ provider: 'Bike Itaú',
+ },
+ {
+ id: 'recife-bike-itau',
+ city: 'Recife, PE',
+ centerLat: -8.0476,
+ centerLon: -34.877,
+ loadRadiusKm: 90,
+ stationInformationUrl:
+ 'https://recife.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
+ stationStatusUrl:
+ 'https://recife.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
+ provider: 'Bike Itaú',
+ },
+ {
+ id: 'porto-alegre-bike-itau',
+ city: 'Porto Alegre, RS',
+ centerLat: -30.0346,
+ centerLon: -51.2177,
+ loadRadiusKm: 90,
+ stationInformationUrl:
+ 'https://portoalegre.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
+ stationStatusUrl:
+ 'https://portoalegre.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
+ provider: 'Bike Itaú',
+ },
+ {
+ id: 'brasilia-bike-itau',
+ city: 'Brasília, DF',
+ centerLat: -15.7939,
+ centerLon: -47.8828,
+ loadRadiusKm: 90,
+ stationInformationUrl:
+ 'https://brasilia.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
+ stationStatusUrl:
+ 'https://brasilia.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
+ provider: 'Bike Itaú',
+ },
+ {
+ id: 'curitiba-bike-itau',
+ city: 'Curitiba, PR',
+ centerLat: -25.4284,
+ centerLon: -49.2733,
+ loadRadiusKm: 90,
+ stationInformationUrl:
+ 'https://curitiba.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
+ stationStatusUrl:
+ 'https://curitiba.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
+ provider: 'Bike Itaú',
+ },
{
id: 'toronto-bike-share',
city: 'Toronto, ON',
diff --git a/src/layers/flights/records.js b/src/layers/flights/records.js
index 2b6f48dfb..709cd69ed 100644
--- a/src/layers/flights/records.js
+++ b/src/layers/flights/records.js
@@ -275,10 +275,12 @@ export class FlightRecords {
return { icao24, prevMeta, meta, groundFlipped, fixEpochMs };
}
- absence(id, { complete, likelyLanded }) {
- // Partial admissions do not prove absence, but retention remains bounded.
+ absence(id, { complete, retainIds, likelyLanded }) {
+ // Partial admissions and position-less rows do not prove absence, but
+ // retention remains bounded; a likely-landed position-less contact
+ // still takes the landed limit.
if (
- !complete &&
+ (!complete || (retainIds?.has(id) && !likelyLanded)) &&
Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
)
return 'retain';
diff --git a/src/layers/flights/records.test.mjs b/src/layers/flights/records.test.mjs
index fff25b8b9..638c6f843 100644
--- a/src/layers/flights/records.test.mjs
+++ b/src/layers/flights/records.test.mjs
@@ -107,6 +107,30 @@ test('ground transitions preserve last render height while a surface cell is col
assert.equal(next.meta.wasAirborne, true);
});
+test('complete snapshots age out vanished aircraft and retain position-less ones unless landed', () => {
+ const store = records();
+ for (const id of ['abc123', 'def456', 'ghi789'])
+ store.receive(observation({ id, reference: id }), view());
+ // abc123 and ghi789 are still listed without a position; def456 vanished.
+ const absent = (id, likelyLanded = false) =>
+ store.absence(id, {
+ complete: true,
+ retainIds: new Set(['abc123', 'ghi789']),
+ likelyLanded,
+ });
+ assert.deepEqual(
+ [absent('def456'), absent('def456'), absent('def456')],
+ ['stale', 'stale', 'remove'],
+ );
+ assert.equal(absent('abc123'), 'retain');
+ assert.equal(store.missingPolls.has('abc123'), false);
+ // A landed contact that lost its position takes the landed fast-cull.
+ assert.equal(absent('ghi789', true), 'remove');
+ // Position-less retention stays bounded like partial admission.
+ store.data.get('abc123').observedReceiptMs = Date.now() - 300001;
+ assert.equal(absent('abc123'), 'stale');
+});
+
test('civil emergency status follows the latest poll instead of sticking', () => {
const store = records();
const first = store.receive(observation({ squawk: '7700' }), view());
diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js
index f6e74b125..ec6c2933b 100644
--- a/src/layers/flights/snapshotRenderer.js
+++ b/src/layers/flights/snapshotRenderer.js
@@ -243,6 +243,7 @@ export function createFlightSnapshotRenderer({
if (currentIcaos.has(icao24)) continue;
const absence = records.absence(icao24, {
complete: snapshot.complete,
+ retainIds: snapshot.retainIds,
likelyLanded: queries._likelyLanded(icao24),
});
if (absence === 'retain') continue;
diff --git a/src/sources/live/aircraft.js b/src/sources/live/aircraft.js
index ebc27f559..1af1f6b47 100644
--- a/src/sources/live/aircraft.js
+++ b/src/sources/live/aircraft.js
@@ -7,10 +7,12 @@ import {
finite,
} from './contract.js';
+const openSkyId = (row) =>
+ Array.isArray(row) ? cleanText(row[0]).toLowerCase() : '';
+
/** Normalize the OpenSky state-vector route, including its regional fallback. */
export function normalizeOpenSkyAircraft(row) {
- if (!Array.isArray(row)) return null;
- const id = cleanText(row[0]).toLowerCase();
+ const id = openSkyId(row);
if (!id || !coordinates(row[6], row[5])) return null;
return {
id,
@@ -77,6 +79,16 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) {
};
}
+/**
+ * OpenSky allows null longitude/latitude for a contact that is still
+ * broadcasting. Strict nulls also reject rows truncated before the position.
+ */
+function openSkyUnpositionedId(row) {
+ return Array.isArray(row) && row[5] === null && row[6] === null
+ ? openSkyId(row)
+ : '';
+}
+
export function openSkySnapshot(
payload,
{
@@ -96,6 +108,7 @@ export function openSkySnapshot(
return record;
},
'OpenSky',
+ openSkyUnpositionedId,
);
const observedAtMs = epoch(payload?.time, 1000);
const ageMs = observedAtMs == null ? null : Math.max(0, now - observedAtMs);
diff --git a/src/sources/live/contract.js b/src/sources/live/contract.js
index 2415ff43c..d99b55f0d 100644
--- a/src/sources/live/contract.js
+++ b/src/sources/live/contract.js
@@ -53,17 +53,30 @@ export function coordinates(latitude, longitude) {
);
}
-/** Admit a snapshot atomically; an all-invalid nonempty feed is unavailable. */
-export function admitRecords(rows, normalize, label) {
+/**
+ * Admit a snapshot atomically; an all-invalid nonempty feed is unavailable.
+ * `unpositionedId(row)` names a rejected row that is a valid contact without
+ * a position: it is returned in `retainIds` and does not make the snapshot
+ * incomplete.
+ */
+export function admitRecords(rows, normalize, label, unpositionedId) {
if (!Array.isArray(rows))
throw new LiveSourceError('malformed', `Malformed ${label} response`);
const records = [];
const ids = new Set();
+ const retainIds = new Set();
+ let unpositioned = 0;
for (const row of rows) {
const record = normalize(row);
if (record && !ids.has(record.id)) {
records.push(record);
ids.add(record.id);
+ } else if (!record) {
+ const id = unpositionedId?.(row);
+ if (id) {
+ retainIds.add(id);
+ unpositioned += 1;
+ }
}
}
if (rows.length && !records.length) {
@@ -71,7 +84,8 @@ export function admitRecords(rows, normalize, label) {
}
return {
records,
- complete: records.length === rows.length,
+ retainIds,
+ complete: records.length + unpositioned === rows.length,
rejectedCount: rows.length - records.length,
};
}
diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs
index 1bba6e28f..658db1596 100644
--- a/src/sources/live/contract.test.mjs
+++ b/src/sources/live/contract.test.mjs
@@ -67,6 +67,50 @@ test('mixed-invalid and duplicate admissions are explicitly incomplete; all-inva
assert.equal(openSkySnapshot({ states: [] }).complete, true);
});
+test('position-less OpenSky rows are seen, not missing; malformed rows stay incomplete', () => {
+ // OpenSky's spec allows null longitude/latitude; every live /states/all
+ // carries such rows.
+ const unpositioned = ['DEF456', ...aircraft.slice(1)];
+ unpositioned[5] = null;
+ unpositioned[6] = null;
+ const snapshot = openSkySnapshot({ states: [aircraft, unpositioned] });
+ assert.equal(snapshot.records.length, 1);
+ assert.equal(snapshot.complete, true);
+ assert.deepEqual([...snapshot.retainIds], ['def456']);
+ assert.equal(snapshot.rejectedCount, 1);
+ const malformed = [...aircraft];
+ malformed[5] = 181;
+ const garbled = openSkySnapshot({
+ states: [aircraft, unpositioned, malformed],
+ });
+ assert.equal(garbled.complete, false);
+ assert.deepEqual([...garbled.retainIds], ['def456']);
+ // No usable id, only half a position, a truncated row, or no row at all.
+ for (const row of [
+ [' ', ...unpositioned.slice(1)],
+ Object.assign([...unpositioned], { 5: -97 }),
+ ['DEF456'],
+ null,
+ ])
+ assert.equal(openSkySnapshot({ states: [aircraft, row] }).complete, false);
+ // Repeated position-less rows stay complete.
+ const repeated = openSkySnapshot({
+ states: [aircraft, unpositioned, unpositioned],
+ });
+ assert.equal(repeated.complete, true);
+ // A feed with no positions at all still cannot prove absence.
+ assert.throws(() => openSkySnapshot({ states: [unpositioned] }), {
+ code: 'malformed',
+ });
+ // Military readsb admission keeps its existing semantics.
+ const military = readsbSnapshot(
+ { ac: [{ hex: 'abc123', lat: 30, lon: -97 }, { hex: 'def456' }] },
+ { observedAtMs: null },
+ );
+ assert.equal(military.complete, false);
+ assert.equal(military.retainIds.size, 0);
+});
+
test('readsb units and position/contact ages are normalized once against the source epoch', () => {
const row = normalizeReadsbAircraft(
{
diff --git a/src/tooling/webAppIcons.test.mjs b/src/tooling/webAppIcons.test.mjs
new file mode 100644
index 000000000..6a6cdb350
--- /dev/null
+++ b/src/tooling/webAppIcons.test.mjs
@@ -0,0 +1,149 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { rm, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import sharp from 'sharp';
+import { build } from 'vite';
+import { makeFixtureRoot } from './fixtureRoot.mjs';
+
+const REPO_ROOT = path.resolve(
+ fileURLToPath(new URL('.', import.meta.url)),
+ '../..',
+);
+const PUBLIC_ROOT = path.join(REPO_ROOT, 'public');
+const INDEX_HTML = readFileSync(path.join(REPO_ROOT, 'index.html'), 'utf8');
+
+/** `public/` is served at the site root, so an index.html href maps to a file by dropping the leading slash. */
+function publicFile(href) {
+ assert.ok(href.startsWith('/'), `${href} must be a root-relative URL`);
+ return path.join(PUBLIC_ROOT, href.slice(1));
+}
+
+const manifestHref = INDEX_HTML.match(
+ /]+rel="manifest"[^>]+href="([^"]+)"/,
+)?.[1];
+
+/**
+ * Manifest URLs resolve against the manifest, not the page. Keeping them
+ * relative is what lets a build served under a path prefix install inside it.
+ */
+function manifestFile(src) {
+ assert.ok(
+ !src.startsWith('/') && !/^[a-z][a-z\d+.-]*:/i.test(src),
+ `${src} must be relative to the manifest`,
+ );
+ return path.join(path.dirname(publicFile(manifestHref)), src);
+}
+
+test('index.html links a web app manifest', () => {
+ assert.ok(manifestHref, 'no in index.html');
+});
+
+test('iOS gets a raster home-screen icon', () => {
+ // Safari ignores SVG for apple-touch-icon and falls back to a screenshot of
+ // the page, which is the blank tile this whole file exists to prevent. The
+ // existing `rel="icon"` SVG covers the browser tab and nothing else.
+ const href = INDEX_HTML.match(
+ /]+rel="apple-touch-icon"[^>]+href="([^"]+)"/,
+ )?.[1];
+ assert.ok(href, 'no in index.html');
+ assert.match(href, /\.png$/, 'apple-touch-icon must be a PNG');
+ assert.doesNotThrow(() => readFileSync(publicFile(href)));
+});
+
+test('the manifest declares a standalone installable app', () => {
+ const manifest = JSON.parse(readFileSync(publicFile(manifestHref), 'utf8'));
+ assert.equal(manifest.display, 'standalone');
+ assert.equal(manifest.start_url, '.');
+ assert.equal(manifest.scope, '.');
+ for (const field of ['name', 'short_name', 'background_color', 'theme_color'])
+ assert.ok(manifest[field], `manifest is missing ${field}`);
+ // Android's install prompt needs both, and uses the 512 for the splash screen.
+ const sizes = new Set(manifest.icons.map((icon) => icon.sizes));
+ for (const required of ['192x192', '512x512'])
+ assert.ok(sizes.has(required), `manifest declares no ${required} icon`);
+ // Without a maskable icon Android pads the square into a circle and clips
+ // the mark; with one it crops to the safe zone instead.
+ assert.ok(
+ manifest.icons.some((icon) => icon.purpose?.includes('maskable')),
+ 'manifest declares no maskable icon',
+ );
+});
+
+test('every declared icon is a square PNG at its declared size', async () => {
+ const manifest = JSON.parse(readFileSync(publicFile(manifestHref), 'utf8'));
+ for (const icon of manifest.icons) {
+ const meta = await sharp(manifestFile(icon.src)).metadata();
+ const [width, height] = icon.sizes.split('x').map(Number);
+ assert.equal(meta.format, 'png', `${icon.src} is not a PNG`);
+ // A size the manifest overstates is worse than a missing one: the platform
+ // trusts the declaration and scales whatever it finds.
+ assert.equal(meta.width, width, `${icon.src} width`);
+ assert.equal(meta.height, height, `${icon.src} height`);
+ }
+});
+
+test('install assets stay inside a non-root Vite base', async (t) => {
+ // The real index.html pulls the whole Cesium app into a build, so only its
+ // install links are copied, verbatim, into a fixture page.
+ const tags = INDEX_HTML.match(
+ /]+rel="(?:icon|apple-touch-icon|manifest)"[^>]*>/g,
+ );
+ assert.ok(tags?.length >= 3, 'index.html lost its install links');
+ const root = await makeFixtureRoot('gev-webapp-icons-');
+ t.after(() => rm(root, { recursive: true, force: true }));
+ await writeFile(
+ path.join(root, 'index.html'),
+ `${tags.join('')}`,
+ );
+ const base = '/example/';
+ const origin = 'https://host.test';
+ const result = await build({
+ root,
+ configFile: false,
+ envFile: false,
+ logLevel: 'silent',
+ base,
+ publicDir: PUBLIC_ROOT,
+ build: { write: false },
+ });
+ const html = result.output.find(
+ (item) => item.fileName === 'index.html',
+ ).source;
+ const hrefs = [...html.matchAll(/]+href="([^"]+)"/g)].map(
+ (match) => match[1],
+ );
+ assert.equal(hrefs.length, tags.length);
+ for (const href of hrefs) {
+ assert.ok(href.startsWith(base), `${href} escapes ${base}`);
+ assert.doesNotThrow(() =>
+ readFileSync(path.join(PUBLIC_ROOT, href.slice(base.length))),
+ );
+ }
+
+ // Vite copies public/ only when it writes, and copies it verbatim, so the
+ // deployed manifest is the committed one, served at the emitted href.
+ const manifestUrl = new URL(
+ html.match(/]+rel="manifest"[^>]+href="([^"]+)"/)[1],
+ origin,
+ );
+ const manifest = JSON.parse(readFileSync(publicFile(manifestHref), 'utf8'));
+ const inBase = (value) => {
+ const url = new URL(value, manifestUrl);
+ assert.ok(
+ url.href.startsWith(origin + base),
+ `${value} resolves to ${url.href}, outside ${base}`,
+ );
+ return url;
+ };
+ assert.equal(inBase(manifest.start_url).href, origin + base);
+ assert.equal(inBase(manifest.scope).href, origin + base);
+ for (const icon of manifest.icons) {
+ const url = inBase(icon.src);
+ assert.doesNotThrow(() =>
+ readFileSync(path.join(PUBLIC_ROOT, url.pathname.slice(base.length))),
+ );
+ }
+});