mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge branch 'main' into feat/cctv-model-spec-enrichment
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
a4888d7846
24 files changed
+623
-14
No files matched your search
@@ -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
|
||||
|
||||
+2
-1
@@ -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" |
|
||||
|
||||
+36
-3
@@ -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 |
|
||||
|
||||
+13
@@ -4,7 +4,20 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>God's Eye View</title>
|
||||
<!-- Raster fallbacks: Safari ignores an SVG apple-touch-icon and falls back to a
|
||||
screenshot of the page, so an installed app needs a real PNG. Regenerate
|
||||
these from the logo with `node scripts/generate-icons.mjs`. -->
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/icon-32.png" />
|
||||
<!-- Listed after the PNG: browsers that use the last usable icon keep the SVG
|
||||
for the tab. -->
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#0a0a0f" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
<meta name="apple-mobile-web-app-title" content="God's Eye" />
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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)`);
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
/<link[^>]+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 <link rel="manifest"> 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(
|
||||
/<link[^>]+rel="apple-touch-icon"[^>]+href="([^"]+)"/,
|
||||
)?.[1];
|
||||
assert.ok(href, 'no <link rel="apple-touch-icon"> 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(
|
||||
/<link[^>]+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'),
|
||||
`<!doctype html><html><head>${tags.join('')}</head><body></body></html>`,
|
||||
);
|
||||
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(/<link[^>]+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(/<link[^>]+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))),
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user