mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
refactor(street-level): trim QA, tests, styles and docs
The browser gate is one pass over the remaining flows: rail strip and expand, coverage with fixtures, the ON/OFF pill, 360° and SINCE filters, KEY REJECTED, nearest photo with caption, × close, phone fit, keyless KEY REQUIRED on a second page and no page errors. Panel controls are pressed through the DOM; pick-point search, line/cone clicks, the framing-cancel, reload/share and status-route probes, the second viewport and pointer-settling retries are gone (unit tests cover them). Tests that pinned constants, copy, CSS or source text, or repeated another test, are removed or merged. CSS loses an unused variable and duplicate rules. Docs describe the slimmed feature; unused exports and the layer's dead setCoverageFilter/refreshCoverage surface are removed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b5a78826f8
commit
ad58a0bdc4
23 files changed
+534
-1693
No files matched your search
+9
-11
@@ -3,17 +3,15 @@
|
||||
## [Unreleased]
|
||||
|
||||
- Street Level: Mapillary street-level imagery modelled on the iD editor's
|
||||
photo overlay (free client token, CC BY-SA 4.0 imagery). One right-rail panel
|
||||
holds an ON/OFF pill, 360°/flat and captured-since filters, a legend, and an
|
||||
embedded viewer with full-screen EXPAND and FIT/FILL. Coverage lines (below
|
||||
60 km; from orbit the panel says to zoom in) and image cones are draped on
|
||||
the terrain and on Google 3D tiles; MapillaryJS loads on the first photo,
|
||||
and opening one frames it with one camera flight.
|
||||
Share links carry the Mapillary switch, the filters and the panel's collapsed
|
||||
state. A server proxy adds the token to Mapillary coverage tiles, drops
|
||||
their unused image layer and keeps them in memory (24 h); it refuses
|
||||
cross-site requests and rate-limits per IP, and the panel stops asking when
|
||||
Mapillary rejects the token or rate-limits.
|
||||
photo overlay (free client token, CC BY-SA 4.0 imagery). A docked right-rail
|
||||
panel holds an ON/OFF pill, 360°/flat and captured-since filters, a legend
|
||||
and an embedded viewer with full-screen EXPAND and FIT/FILL. Coverage lines
|
||||
(below 60 km) and image cones drape on the terrain and Google 3D tiles;
|
||||
opening a photo frames it with one camera flight. Share links carry the
|
||||
switch, the filters and the panel's collapsed state. A server proxy keeps
|
||||
the token server-side, trims coverage tiles and caches them in memory for
|
||||
24 h; it refuses cross-site requests and rate-limits per IP. The panel reads
|
||||
KEY REQUIRED without a token and KEY REJECTED when Mapillary refuses it.
|
||||
|
||||
- Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera
|
||||
list is one keyless GeoJSON request to the agency's OGC API view of its
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ How to read this:
|
||||
| **NOAA nowCOAST observed weather** (`nowcoast.noaa.gov/geoserver/observations/{weather_radar,satellite}/ows`, WMS 1.1.1) | Rain radar (MRMS base reflectivity, contiguous US, about 4-minute updates) and Satellite clouds (GOES-19/18 Band 14 regional infrared, about 5-minute updates; global infrared mosaic, hourly). Keyless; layer metadata refreshed every 2 minutes, exact-time images cached for up to 24 hours | [NOAA disclaimer](https://oceanservice.noaa.gov/disclaimer.html) | "NOAA nowCOAST · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners" |
|
||||
| **NOAA nowCOAST lightning density** (`nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows`) | Lightning density: 15-minute accumulated strike density on an approximately 8 km grid, Americas and Pacific. Keyless; layer metadata refreshed every 10 minutes, exact-time images cached for up to 24 hours | NOAA Level-5 derived product from Vaisala NLDN/GLD360; the [product description](https://ocean.weather.gov/lightning/lightning_pdd.php) permits public distribution of this density product, not of raw Vaisala detections | "NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360" |
|
||||
| **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" |
|
||||
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
|
||||
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles kept in server memory for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the layer is on |
|
||||
| **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors |
|
||||
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
|
||||
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
|
||||
|
||||
@@ -296,7 +296,7 @@ Twenty layers and map sources. **Seventeen have a keyless path.** Some offer add
|
||||
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
|
||||
| 📹 **CCTV Mesh** | ~3,900 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
||||
| 📷 **Street Level** | Street-level photos behind one panel: coverage on the streets, image cones and an embedded viewer with full-screen **EXPAND**. Mapillary first; Panoramax, KartaView and Google Street View planned | Mapillary | 🟡 Mapillary client token |
|
||||
| 📷 **Street Level** | Mapillary street-level photos in one panel: coverage on the streets, image cones and an embedded viewer with full-screen **EXPAND** | Mapillary | 🟡 Mapillary client token |
|
||||
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
|
||||
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 |
|
||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||
|
||||
+31
-45
@@ -2934,58 +2934,44 @@ its criteria cannot be silently ignored.
|
||||
|
||||
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
|
||||
|
||||
Street Level is a collapsible right-rail panel (`#street-level-panel`, layer
|
||||
token `0`, option owner `street-level`, panel `ui` token `t`) that starts
|
||||
Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
|
||||
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts
|
||||
collapsed. It opens itself only when the user, voice or a tool switches the
|
||||
layer on or a photo opens, never on a restore, and those opens are not stored.
|
||||
Like the iD editor's photo overlay, it shows Mapillary with 360°/flat and
|
||||
Like the iD editor's photo overlay it shows Mapillary only: 360°/flat and
|
||||
captured-since filters (relative days, so a link keeps its meaning), one
|
||||
viewer host and an on-globe credit while the layer is on. Lines and cones are
|
||||
Mapillary green; 360° cones are rings and the selected sequence is GEV cyan.
|
||||
Share options: `m` (Mapillary on/off; off draws nothing), `p` (`a`/`p`/`f`
|
||||
panoramas), `s` (since, days). Without `MAPILLARY_CLIENT_TOKEN` the panel
|
||||
reads KEY REQUIRED, its error line says which key to add, and the filters are
|
||||
disabled. Coverage is z11–14 sequence lines below 60 km; above that nothing is
|
||||
drawn and the panel says to zoom in. Tiles come from the visible ground within
|
||||
range of the camera (rays meet the terrain height, not the bare ellipsoid),
|
||||
nearest the camera first, at most 9. The proxy serves only z11–14, strips the
|
||||
unused `image` layer from z14 tiles (12 MB → ~80 KB) and shares one upstream
|
||||
fetch between concurrent requests for a tile. A 429 pauses tile requests for
|
||||
its Retry-After and then refreshes once; a rejected key stops them and reads
|
||||
KEY REJECTED until the layer goes off.
|
||||
embedded viewer and an on-globe credit while the layer is on. Share options:
|
||||
`m` (Mapillary on/off; off draws nothing), `p` (`a`/`p`/`f` panoramas), `s`
|
||||
(since, days). The header pill is the only layer switch and also turns a
|
||||
switched-off `m` back on.
|
||||
|
||||
Sequence lines are `GroundPolylinePrimitive`s with
|
||||
`ClassificationType.BOTH`, so they drape on the terrain globe and on Google 3D
|
||||
tiles alike (on 3D tiles they can land on roofs and tree tops). Cones and the
|
||||
photo marker are billboards clamped to the ground; they skip the depth test, so
|
||||
a horizon cull hides the ones behind the globe. Roads on elevated decks are
|
||||
drawn at ground level.
|
||||
Coverage is z11–14 sequence lines below 60 km; above that nothing is drawn and
|
||||
the panel says to zoom in. Tiles come from the visible ground within range of
|
||||
the camera, nearest first, at most 9. Lines are draped
|
||||
`GroundPolylinePrimitive`s (`ClassificationType.BOTH`), so they follow terrain
|
||||
and Google 3D tiles alike; cones and the photo marker are ground-clamped
|
||||
billboards with a horizon cull. Without `MAPILLARY_CLIENT_TOKEN` the panel
|
||||
reads KEY REQUIRED, says which key to add and disables the filters; a rejected
|
||||
key reads KEY REJECTED and stops tile requests until the layer goes off; a 429
|
||||
pauses them for its Retry-After.
|
||||
|
||||
The header pill is the only layer switch; switching on also turns Mapillary
|
||||
back on if a share link or tool switched it off. The viewer sits under the header
|
||||
with EXPAND, FIT/FILL and close above the image. EXPAND is the browser's
|
||||
Fullscreen API on the viewer element, which stays in the panel (Esc or SHRINK
|
||||
leaves; the button is hidden where the API is missing). MapillaryJS loads on the
|
||||
first photo (no prewarm). Opening a photo flies the globe camera once to frame
|
||||
it; there is no camera follow. The flight goes
|
||||
through the application's deferred navigation (`attachNavigation`: `begin` /
|
||||
`reassert`), which releases aircraft and satellite tracking and is refused in
|
||||
the cockpit; a photo takes its ticket when it starts opening and frames only
|
||||
if nothing newer took the camera while it loaded. SINCE is a stepped slider whose readout names the
|
||||
cut-off date. At phone width (≤720 px) the viewer height is derived from the
|
||||
rail band so the whole photo fits.
|
||||
The server proxy (`server/providers/mapillary.js`) keeps the token server-side,
|
||||
serves only z11–14 coverage, strips the unused `image` layer, coalesces
|
||||
concurrent requests and keeps tiles in a 24 h memory cache.
|
||||
|
||||
The layer (`src/layers/streetLevel/index.js`) owns the Mapillary parts
|
||||
directly: `providers/mapillary/` holds the source, tile decoding, coverage,
|
||||
sequence cones, nearest-image lookup and the MapillaryJS viewer.
|
||||
MapillaryJS loads on the first photo. Opening one frames it with a single
|
||||
camera flight through the application's deferred navigation (`begin` /
|
||||
`reassert`): it releases tracking, is refused in the cockpit, and is skipped
|
||||
if newer navigation took the camera while the photo loaded; closing the photo
|
||||
cancels it. EXPAND is the browser's Fullscreen API on the viewer element
|
||||
(hidden where the API is missing). At phone width the viewer height comes from
|
||||
the rail band so the whole photo fits.
|
||||
|
||||
Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
|
||||
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
|
||||
qa:street-level:fixtures -- --url <server>` answers every Mapillary request
|
||||
from fixtures (`providers/mapillary/coverageFixture.mjs`,
|
||||
`scripts/fixtures/street-level/`), so it needs no network and covers the whole
|
||||
photo flow. CI runs the fixture gate against a production build served by
|
||||
`vite preview` with a dummy token.
|
||||
Browser gates: `npm run qa:street-level -- --url <server>` runs against real
|
||||
Mapillary with a token; `npm run qa:street-level:fixtures` answers every
|
||||
Mapillary request from fixtures (`providers/mapillary/coverageFixture.mjs`,
|
||||
`scripts/fixtures/street-level/`). CI runs the fixture gate with `--strict`
|
||||
against a production build served by `vite preview` with a dummy token.
|
||||
|
||||
Directions is a keyless front end to the routing the voice agent already
|
||||
uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the
|
||||
|
||||
@@ -21,7 +21,7 @@ export const PHOTO_LINE = Object.freeze({
|
||||
north: 38.591,
|
||||
});
|
||||
/** Metres between consecutive photos on the line (≥ the 3 m cone thinning). */
|
||||
export const PHOTO_SPACING_M = 30;
|
||||
const PHOTO_SPACING_M = 30;
|
||||
/** Fixture thumbnail host (answered, never reached). */
|
||||
export const THUMB_HOST = 'qa-fixture.mapillary.com';
|
||||
|
||||
@@ -117,7 +117,7 @@ const json = (status, body) => ({
|
||||
});
|
||||
|
||||
let photoBytes = null;
|
||||
export function photoJpeg() {
|
||||
function photoJpeg() {
|
||||
photoBytes ??= readFileSync(new URL('./street-640x320.jpg', import.meta.url));
|
||||
return photoBytes;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
/** Console messages and failed requests kept per page. */
|
||||
export const EVIDENCE_LIMIT = 200;
|
||||
const EVIDENCE_LIMIT = 200;
|
||||
|
||||
/** Cesium's console line when the render loop throws (CesiumWidget). */
|
||||
export const RENDER_ERROR_PATTERN = /An error occurred while rendering/i;
|
||||
const RENDER_ERROR_PATTERN = /An error occurred while rendering/i;
|
||||
|
||||
/** Mask tokens in a URL or message before it is printed or saved. */
|
||||
export function redact(text) {
|
||||
|
||||
+426
-1190
File diff suppressed because it is too large.
Load diff
@@ -318,8 +318,6 @@ export function createStreetLevelLayer({
|
||||
attachViewerHost(element) {
|
||||
parts.viewerHost.attach(element);
|
||||
},
|
||||
/** Imagery filter for coverage, cones and nearest-image lookups. */
|
||||
setCoverageFilter,
|
||||
openImage,
|
||||
/** Open the nearest image around a point (default: the view centre). */
|
||||
async openNearest(point) {
|
||||
@@ -366,7 +364,6 @@ export function createStreetLevelLayer({
|
||||
resizeViewer: () => parts.viewerHost.resize(),
|
||||
selectSequence: (sequenceId) => parts.sequences.select(sequenceId),
|
||||
clearSequence: () => parts.sequences.clearSelection(),
|
||||
refreshCoverage: () => parts.coverage.refresh(),
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
@@ -143,18 +143,6 @@ function slowLookups() {
|
||||
return { lookups, source: fakeMapillarySource({ nearestImages }) };
|
||||
}
|
||||
|
||||
test('the layer is Mapillary under the codec keys it has always had', () => {
|
||||
const layer = createStreetLevelLayer({ source: fakeMapillarySource() });
|
||||
assert.equal(layer.id, 'street-level');
|
||||
assert.equal(layer.requiresKeyId, 'mapillary');
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
mapillary: true,
|
||||
pano: 'all',
|
||||
sinceDays: 0,
|
||||
});
|
||||
assert.throws(() => createStreetLevelLayer({ source: {} }), /source/);
|
||||
});
|
||||
|
||||
test('the CC BY-SA credit shows while Mapillary is on and goes with it', async (t) => {
|
||||
const { layer, viewer } = await startLayer(t);
|
||||
const shown = () => viewer.credits.map((credit) => credit.html);
|
||||
@@ -284,7 +272,7 @@ test('a sequence error is withdrawn with the sequence, never the viewer’s own'
|
||||
assert.match(layer.getUIState().street.error, /Open the Street Level panel/);
|
||||
});
|
||||
|
||||
test('closing the photo stops the globe flying to it', async (t) => {
|
||||
test('closing the photo, switching the layer off or destroying it stops the framing flight', async (t) => {
|
||||
const { layer, viewer } = await startLayer(t, {
|
||||
photoViewer: posingViewer(),
|
||||
});
|
||||
@@ -292,35 +280,16 @@ test('closing the photo stops the globe flying to it', async (t) => {
|
||||
assert.equal(await layer.openImage('img1'), true);
|
||||
assert.equal(viewer.flights.started, 1, 'the photo is framed');
|
||||
layer.closeViewer();
|
||||
assert.equal(viewer.flights.cancelled, 1);
|
||||
});
|
||||
assert.equal(viewer.flights.cancelled, 1, 'closed');
|
||||
|
||||
test('switching the layer off or destroying it stops the framing flight', async (t) => {
|
||||
const { layer, viewer } = await startLayer(t, {
|
||||
photoViewer: posingViewer(),
|
||||
});
|
||||
layer.attachViewerHost({});
|
||||
await layer.openImage('img1');
|
||||
await layer.openImage('img2');
|
||||
layer.disable();
|
||||
assert.equal(viewer.flights.cancelled, 1, 'layer off');
|
||||
assert.equal(viewer.flights.cancelled, 2, 'layer off');
|
||||
|
||||
layer.enable(viewer);
|
||||
await layer.openImage('img2');
|
||||
await layer.openImage('img3');
|
||||
layer.destroy();
|
||||
assert.equal(viewer.flights.cancelled, 2, 'destroyed');
|
||||
});
|
||||
|
||||
test('closing the photo leaves a newer navigation flight alone', async (t) => {
|
||||
const { layer, viewer } = await startLayer(t, {
|
||||
photoViewer: posingViewer(),
|
||||
});
|
||||
layer.attachViewerHost({});
|
||||
await layer.openImage('img1');
|
||||
assert.equal(viewer.flights.started, 1, 'the photo is being framed');
|
||||
// A search result flies the globe elsewhere before the framing lands.
|
||||
viewer.camera.flyToBoundingSphere(null, {});
|
||||
layer.closeViewer();
|
||||
assert.equal(viewer.flights.cancelled, 0, 'the search flight keeps going');
|
||||
assert.equal(viewer.flights.cancelled, 3, 'destroyed');
|
||||
});
|
||||
|
||||
test('an older nearest lookup that answers late cannot replace a newer one', async (t) => {
|
||||
@@ -383,7 +352,13 @@ test('closing the viewer or switching the layer off retires a nearest lookup', a
|
||||
});
|
||||
|
||||
test('setParams takes "any date" (0 days) and the switch over the current values (share-link defaults)', async (t) => {
|
||||
assert.throws(() => createStreetLevelLayer({ source: {} }), /source/);
|
||||
const { layer } = await startLayer(t);
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
mapillary: true,
|
||||
pano: 'all',
|
||||
sinceDays: 0,
|
||||
});
|
||||
layer.setParams({ pano: 'flat', sinceDays: 365 });
|
||||
assert.equal(layer.getParams().sinceDays, 365);
|
||||
layer.setParams({ sinceDays: 0 });
|
||||
|
||||
@@ -57,7 +57,7 @@ export function sequenceIdFromPick(pickId) {
|
||||
/** Hint above the street-zoom ceiling, where no coverage is drawn. */
|
||||
export const ZOOM_IN_HINT = 'Zoom in to see street-level coverage';
|
||||
/** Hint when no ground is in view within range (looking at the sky). */
|
||||
export const NO_GROUND_HINT =
|
||||
const NO_GROUND_HINT =
|
||||
'Point the camera at the ground for street-level coverage';
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PbfWriter } from 'pbf';
|
||||
const zigzag = (value) => (value << 1) ^ (value >> 31);
|
||||
|
||||
/** Tile-local [x, y] (0..extent) of a lon/lat inside tile (x, y, z). */
|
||||
export function lonLatToTileLocal([lon, lat], { x, y, z }, extent = 4096) {
|
||||
function lonLatToTileLocal([lon, lat], { x, y, z }, extent = 4096) {
|
||||
const n = 2 ** z;
|
||||
const rad = (lat * Math.PI) / 180;
|
||||
const tx = ((lon + 180) / 360) * n;
|
||||
|
||||
@@ -19,7 +19,7 @@ function linePart(points) {
|
||||
|
||||
/**
|
||||
* A minimal mly1_public-shaped tile: one sequence line, plus one point in the
|
||||
* low-zoom `overview` layer the layer never draws.
|
||||
* z14 `image` layer the decoder skips.
|
||||
*/
|
||||
function sampleTile({ geometry = null } = {}) {
|
||||
const writer = new PbfWriter();
|
||||
@@ -73,7 +73,7 @@ function sampleTile({ geometry = null } = {}) {
|
||||
3,
|
||||
(_, layer) => {
|
||||
layer.writeVarintField(15, 2);
|
||||
layer.writeStringField(1, 'overview');
|
||||
layer.writeStringField(1, 'image');
|
||||
layer.writeVarintField(5, 4096);
|
||||
layer.writeStringField(3, 'is_pano');
|
||||
layer.writeMessage(
|
||||
@@ -97,7 +97,7 @@ function sampleTile({ geometry = null } = {}) {
|
||||
return new Uint8Array(writer.finish());
|
||||
}
|
||||
|
||||
test('sequences decode into lon/lat records; overview points are ignored', () => {
|
||||
test('sequences decode into lon/lat records; image points are skipped', () => {
|
||||
const decoded = decodeCoverageTile(sampleTile(), { x: 0, y: 0, z: 1 });
|
||||
assert.equal(decoded.sequences.length, 1);
|
||||
const [sequence] = decoded.sequences;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { nearestImageId } from './nearest.js';
|
||||
import { COLORS, mapillaryImageUrl } from './policy.js';
|
||||
import { mapillaryImageUrl } from './policy.js';
|
||||
import { sequenceIdFromPick } from './coverage.js';
|
||||
import { thinImages } from './sequences.js';
|
||||
import { resolveFilter } from '../../filter.js';
|
||||
@@ -27,8 +27,7 @@ const at = (id, lon, lat) => ({
|
||||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||
});
|
||||
|
||||
test('Mapillary draws in its green; image deep links match the mapillary.com share format', () => {
|
||||
assert.equal(COLORS.coverage, '#05cb63');
|
||||
test('image deep links match the mapillary.com share format', () => {
|
||||
assert.equal(
|
||||
mapillaryImageUrl(1814275685699406),
|
||||
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
|
||||
|
||||
@@ -15,7 +15,7 @@ const SPRITE_ID = 'street-level:mapillary-cones';
|
||||
const SEQUENCE_CACHE_SIZE = 40;
|
||||
|
||||
/** Normalize a graph image record into the shape the cones use. */
|
||||
export function normalizeSequenceImage(record) {
|
||||
function normalizeSequenceImage(record) {
|
||||
const coordinates = record?.geometry?.coordinates;
|
||||
if (!Array.isArray(coordinates) || coordinates.length < 2) return null;
|
||||
return {
|
||||
|
||||
@@ -104,12 +104,6 @@ test('graph API errors carry the upstream message and status', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('the source exposes only imagery lookups', () => {
|
||||
const source = createMapillarySource({ token: 'MLY|1|abc' });
|
||||
for (const gone of ['queryFeatures', 'plan', 'geocode', 'spriteUrl'])
|
||||
assert.equal(source[gone], undefined, gone);
|
||||
});
|
||||
|
||||
test('a rejected key and a rate limit are told apart from other tile errors', async () => {
|
||||
const rejectedSource = createMapillarySource({
|
||||
token: 'MLY|1|abc',
|
||||
|
||||
@@ -41,13 +41,6 @@ test('normalizeBbox orders and clamps coordinates', () => {
|
||||
assert.equal(normalizeBbox([1, 2, Number.NaN, 3]), null);
|
||||
});
|
||||
|
||||
test('city-scale boxes span many z14 tiles', () => {
|
||||
const sacramento = [-121.56, 38.44, -121.36, 38.69];
|
||||
assert.equal(tilesForBbox(sacramento, 14).total, 160);
|
||||
const detroit = [-83.29, 42.25, -82.91, 42.45];
|
||||
assert.equal(tilesForBbox(detroit, 14).total, 234);
|
||||
});
|
||||
|
||||
test('tilesForBbox orders from the centre outwards and honours the cap', () => {
|
||||
const result = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14, {
|
||||
limit: 5,
|
||||
|
||||
@@ -154,7 +154,7 @@ export function cameraNadir(viewer) {
|
||||
* Horizon occluder depth below WGS84 (m): ground can lie below the ellipsoid
|
||||
* (NYC −22 m), and an occluder at 0 m would hide the cones around it.
|
||||
*/
|
||||
export const HORIZON_CULL_DEPTH_M = 1000;
|
||||
const HORIZON_CULL_DEPTH_M = 1000;
|
||||
const HORIZON_CULL_ELLIPSOID = new Cesium.Ellipsoid(
|
||||
Cesium.Ellipsoid.WGS84.radii.x - HORIZON_CULL_DEPTH_M,
|
||||
Cesium.Ellipsoid.WGS84.radii.y - HORIZON_CULL_DEPTH_M,
|
||||
|
||||
@@ -189,24 +189,6 @@ function deferred() {
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
test('switching the layer off and on during a cold first open keeps the shared viewer', async () => {
|
||||
const gate = deferred();
|
||||
const adapter = fakeAdapter({ gate });
|
||||
const { state, host } = harness(adapter);
|
||||
const first = host.open('a'); // the library is still loading
|
||||
host.unmount(); // layer off …
|
||||
const second = host.open('b'); // … on again, and a new click
|
||||
gate.resolve();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(adapter.calls.unmount, 1, 'only the layer going off');
|
||||
assert.equal(adapter.calls.listeners, 1);
|
||||
assert.equal(state.street.error, null);
|
||||
assert.equal(state.street.imageId, 'b');
|
||||
await host.open('c');
|
||||
assert.equal(state.street.error, null, 'later opens are not stuck');
|
||||
assert.equal(state.street.imageId, 'c');
|
||||
});
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test('every pose the viewer reports moves the marker; only the open frames the camera', async () => {
|
||||
@@ -393,15 +375,6 @@ test('a Mapillary viewer unmounted mid-download is never built; the next mount b
|
||||
assert.equal(viewers.live, 0, 'nothing left holding a WebGL context');
|
||||
});
|
||||
|
||||
test('the Mapillary viewer leaves resizing to the panel, so a hidden one never asks for z=NaN', async () => {
|
||||
const { gate, viewers } = fakeLibrary();
|
||||
gate.resolve();
|
||||
const adapter = createMapillaryViewer({ source: { token: 't' } });
|
||||
await adapter.mount({});
|
||||
assert.equal(viewers.instances[0].options.trackResize, false);
|
||||
adapter.unmount();
|
||||
});
|
||||
|
||||
test('closing the photo stops sequence playback in the hidden viewer', async () => {
|
||||
const { gate, viewers } = fakeLibrary();
|
||||
gate.resolve();
|
||||
@@ -415,18 +388,6 @@ test('closing the photo stops sequence playback in the hidden viewer', async ()
|
||||
assert.equal(viewers.live, 1, 'the viewer itself stays warm');
|
||||
});
|
||||
|
||||
test('closing the photo or switching the layer off stops its framing flight', async () => {
|
||||
const adapter = fakeAdapter();
|
||||
const { framing, host } = harness(adapter);
|
||||
await host.open('a');
|
||||
assert.equal(framing.started, 1, 'the open framed the photo');
|
||||
host.close();
|
||||
assert.equal(framing.cancelled, 1, 'closed: the globe stops flying to it');
|
||||
await host.open('b');
|
||||
host.unmount();
|
||||
assert.equal(framing.cancelled, 2, 'layer off: likewise');
|
||||
});
|
||||
|
||||
/** The real Mapillary adapter, mounted on a ready library, and its poses. */
|
||||
async function mountedMapillary() {
|
||||
const library = fakeLibrary();
|
||||
|
||||
@@ -13,10 +13,8 @@ const html = expandApplicationHtml(read('index.html'));
|
||||
const css = readStylesheet(path.join(ROOT, 'style.css'));
|
||||
const panelChrome = read('src/ui/panelChrome.js');
|
||||
const layoutController = read('src/ui/panelLayoutController.js');
|
||||
const panelCss = read('src/ui/styles/street-level.css');
|
||||
const controls = read('src/ui/streetLevelControls.js');
|
||||
|
||||
test('Street Level is an ordinary collapsible GEV panel that starts collapsed', () => {
|
||||
test('Street Level is a collapsible right-rail panel that starts collapsed', () => {
|
||||
assert.match(
|
||||
html,
|
||||
/<div id="street-level-panel" class="panel-collapsible collapsed" data-panel-id="street-level-panel">/,
|
||||
@@ -25,20 +23,6 @@ test('Street Level is an ordinary collapsible GEV panel that starts collapsed',
|
||||
html,
|
||||
/<button class="panel-collapse-btn" data-collapse-target="street-level-panel"/,
|
||||
);
|
||||
assert.match(html, /<span class="panel-title">STREET LEVEL<\/span>/);
|
||||
// Provider-neutral header: no vendor mark; the inner restores its scroll.
|
||||
assert.doesNotMatch(html, /sl-mark|mly-mark/);
|
||||
assert.match(
|
||||
html,
|
||||
/<div class="street-level-panel-inner" data-rail-scroller>/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
/mapillary-dock|sl-minimized|sl-3d-btn|sl-photoreal-btn/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the panel is registered with panel chrome, cockpit entry and the right rail', () => {
|
||||
assert.match(panelChrome, /\{ id: 'street-level-panel' \}/);
|
||||
assert.match(
|
||||
panelChrome,
|
||||
@@ -48,106 +32,24 @@ test('the panel is registered with panel chrome, cockpit entry and the right rai
|
||||
panelChrome,
|
||||
/const isRightRail = \[[\s\S]*'street-level-panel'/,
|
||||
);
|
||||
// The layout controller moves every rail panel in one loop; ours is listed.
|
||||
assert.match(
|
||||
layoutController,
|
||||
/for \(const panel of \[[\s\S]*?this\._streetLevelPanel,[\s\S]*?\]\) \{[\s\S]*?stack\.insertBefore\(panel, globalContextPanel\)/,
|
||||
);
|
||||
for (const rule of [
|
||||
'#right-context-rail > #street-level-panel',
|
||||
'#right-context-rail #street-level-panel.collapsed',
|
||||
])
|
||||
assert.ok(css.includes(rule), `layers.css names ${rule}`);
|
||||
assert.match(
|
||||
css,
|
||||
/#right-context-rail\.layout-focus\s*>\s*#street-level-panel:not\(\.collapsed\)/,
|
||||
);
|
||||
assert.ok(css.includes('#right-context-rail > #street-level-panel'));
|
||||
});
|
||||
|
||||
test('panel styles stay inside GEV conventions: no !important, no fixed panel', () => {
|
||||
assert.equal((panelCss.match(/!important/g) || []).length, 0);
|
||||
assert.doesNotMatch(panelCss, /position:\s*fixed/);
|
||||
});
|
||||
|
||||
test('the keyless state gates the controls rather than leaving dead buttons', () => {
|
||||
// The key requirement is documented (README, .env.example), not repeated in the panel.
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
/sl-keyless|sl-query|sl-results|data-sl-suggestion/,
|
||||
);
|
||||
assert.match(html, /<fieldset id="sl-controls"/);
|
||||
// The gate holds the filters; the error line above it says how to add the key.
|
||||
const gate = html.slice(
|
||||
html.indexOf('<fieldset id="sl-controls"'),
|
||||
html.indexOf('</fieldset>', html.indexOf('<fieldset id="sl-controls"')),
|
||||
);
|
||||
assert.ok(
|
||||
html.indexOf('id="sl-error"') < html.indexOf('<fieldset id="sl-controls"'),
|
||||
);
|
||||
test('the key gate holds every filter, with the error line outside it', () => {
|
||||
const start = html.indexOf('<fieldset id="sl-controls"');
|
||||
assert.ok(start > 0);
|
||||
const gate = html.slice(start, html.indexOf('</fieldset>', start));
|
||||
assert.match(gate, /data-sl-pano="all"/);
|
||||
assert.match(gate, /id="sl-since"/);
|
||||
assert.match(
|
||||
html,
|
||||
/<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1"/,
|
||||
);
|
||||
assert.match(html, /<output id="sl-since-label"/);
|
||||
assert.doesNotMatch(html, /<select id="sl-since"|value="year:/);
|
||||
assert.match(html, /<ul id="sl-legend"/);
|
||||
assert.ok(html.indexOf('id="sl-error"') < start);
|
||||
assert.match(html, /id="sl-error"[^>]*role="alert"/);
|
||||
// The header pill is the layer's on/off switch, not just a readout.
|
||||
assert.match(
|
||||
html,
|
||||
/<button id="sl-status" class="sl-status" type="button" aria-pressed="false"/,
|
||||
);
|
||||
assert.match(
|
||||
controls,
|
||||
/this\.listen\(el\.status, 'click', \(\) => this\._toggleEnabled\(\)\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('labels say what the buttons do', () => {
|
||||
for (const label of ['>EXPAND<', '>FIT<', '>FILL<', 'SINCE'])
|
||||
assert.ok(html.includes(label), label);
|
||||
// The header pill is the only on/off switch: no provider chips, no FOLLOW.
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
/sl-provider-chips|sl-follow-btn|OPEN NEAREST PHOTO|STREET LEVEL OFF|sl-enable-btn|sl-look-btn|LOOK HERE|STREET COCKPIT|>FRAME<|ZOOM TO RESULTS|ASK IN PLAIN ENGLISH/,
|
||||
);
|
||||
});
|
||||
|
||||
test('on phones the viewer is sized from the rail band, not its aspect ratio', () => {
|
||||
assert.match(
|
||||
panelCss,
|
||||
/@media \(max-width: 720px\) \{\s*\.sl-viewer \{[^}]*height: clamp\(/,
|
||||
);
|
||||
// The full-screen viewer must not inherit the phone height.
|
||||
assert.match(
|
||||
panelCss,
|
||||
/\.sl-viewer-wrap:fullscreen \.sl-viewer \{[^}]*height: auto/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the viewer comes first, with EXPAND in its toolbar', () => {
|
||||
const controlsBlock = html.slice(html.indexOf('<div class="sl-main">'));
|
||||
assert.ok(
|
||||
controlsBlock.indexOf('id="sl-viewer-wrap"') <
|
||||
controlsBlock.indexOf('class="sl-settings"'),
|
||||
'imagery sits above the settings, visible without scrolling',
|
||||
);
|
||||
assert.match(
|
||||
controlsBlock.slice(0, controlsBlock.indexOf('id="sl-viewer"')),
|
||||
/id="sl-viewer-expand"/,
|
||||
'EXPAND lives in the viewer toolbar',
|
||||
);
|
||||
});
|
||||
|
||||
test('radiogroups are one tab stop and their focus ring is not clipped (P3)', () => {
|
||||
const seg = /\.sl-seg \{([^}]*)\}/.exec(panelCss)[1];
|
||||
assert.doesNotMatch(seg, /overflow/);
|
||||
assert.match(
|
||||
panelCss,
|
||||
/\.sl-seg-btn:focus-visible \{[^}]*outline: 1px solid/,
|
||||
);
|
||||
test('each radiogroup is one tab stop, on its checked button', () => {
|
||||
for (const [group, checked] of [
|
||||
['data-sl-render', 'letterbox'],
|
||||
['data-sl-pano', 'all'],
|
||||
@@ -166,17 +68,3 @@ test('radiogroups are one tab stop and their focus ring is not clipped (P3)', ()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the Cyber theme frames the Street Level panel like its rail peers (P3)', () => {
|
||||
const cyber = read('src/ui/styles/cyber.css');
|
||||
const inner = (cyber.match(/\.recent-imagery-panel-inner,/g) || []).length;
|
||||
const panel = (cyber.match(/#recent-imagery-panel,/g) || []).length;
|
||||
assert.ok(inner > 0 && panel > 0);
|
||||
assert.equal(
|
||||
(cyber.match(/\.street-level-panel-inner,/g) || []).length,
|
||||
inner,
|
||||
);
|
||||
assert.equal((cyber.match(/#street-level-panel,/g) || []).length, panel);
|
||||
// A theme header rule must be able to outrank the panel's own.
|
||||
assert.doesNotMatch(panelCss, /#street-level-panel \.panel-header/);
|
||||
});
|
||||
@@ -1,15 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { createApplicationStreetLevel } from '../app/layers/streetLevel.js';
|
||||
import {
|
||||
fixtureTile,
|
||||
LEGEND_SWATCHES,
|
||||
isCollapsed,
|
||||
PARK,
|
||||
STRICT_ALLOWED_SKIPS,
|
||||
strictViolations,
|
||||
} from '../../scripts/qa-street-level.mjs';
|
||||
import { fixtureTile } from '../../scripts/qa-street-level.mjs';
|
||||
import {
|
||||
answerMapillaryRequest,
|
||||
describeCall,
|
||||
@@ -21,37 +12,6 @@ import {
|
||||
} from '../../scripts/fixtures/street-level/mapillaryGraph.mjs';
|
||||
import { redact } from '../../scripts/qa-browserEvidence.mjs';
|
||||
|
||||
// The press helpers wait for paint; Node has no frames, so a tick stands in.
|
||||
globalThis.requestAnimationFrame ??= (callback) =>
|
||||
setTimeout(() => callback(Date.now()), 0);
|
||||
|
||||
test('the harness expects the legend the app layer draws', () => {
|
||||
// Any method the layer asks its source for is a no-op: only the legend matters.
|
||||
const source = new Proxy({}, { get: () => () => {} });
|
||||
const layer = createApplicationStreetLevel({
|
||||
sources: { mapillary: source },
|
||||
});
|
||||
assert.equal(layer.getUIState().legend.length, LEGEND_SWATCHES);
|
||||
});
|
||||
|
||||
test('a panel reads as collapsed only with the collapsed class', () => {
|
||||
assert.equal(isCollapsed(['panel-collapsible', 'collapsed']), true);
|
||||
assert.equal(isCollapsed(['panel-collapsible']), false);
|
||||
});
|
||||
|
||||
test('the harness only runs its browser flow when executed directly', () => {
|
||||
const source = fs.readFileSync(
|
||||
new URL('../../scripts/qa-street-level.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/import\.meta\.url === pathToFileURL\(process\.argv\[1\]\)\.href/,
|
||||
);
|
||||
assert.match(source, /PUPPETEER_EXECUTABLE_PATH/);
|
||||
assert.match(source, /--url/);
|
||||
});
|
||||
|
||||
test('fixture tiles give the hermetic gate something real to filter', async () => {
|
||||
const { decodeCoverageTile } =
|
||||
await import('../layers/streetLevel/providers/mapillary/decode.js');
|
||||
@@ -72,52 +32,6 @@ test('fixture tiles give the hermetic gate something real to filter', async () =
|
||||
assert.equal(fixtureTile(8, 1, 1, now).length, 0, 'nor in between');
|
||||
});
|
||||
|
||||
/* ── The real status route probe ─────────────────────────────────────── */
|
||||
|
||||
test('the gate requires the server’s real Mapillary status route', async () => {
|
||||
const { assertRealStatusRoute } =
|
||||
await import('../../scripts/qa-street-level.mjs');
|
||||
const asked = [];
|
||||
const answer = (body, init) => async (url) => {
|
||||
asked.push(url);
|
||||
return typeof body === 'string'
|
||||
? new Response(body, init)
|
||||
: Response.json(body, init);
|
||||
};
|
||||
assert.deepEqual(
|
||||
await assertRealStatusRoute(
|
||||
'http://localhost:4173',
|
||||
answer({ configured: false }),
|
||||
),
|
||||
{ configured: false },
|
||||
);
|
||||
assert.deepEqual(asked, ['http://localhost:4173/api/mapillary/status']);
|
||||
for (const [why, fetchImpl] of [
|
||||
[
|
||||
'an unregistered route (the API 404)',
|
||||
answer({ error: 'Unknown API route' }, { status: 404 }),
|
||||
],
|
||||
[
|
||||
'the SPA fallback',
|
||||
answer('<!doctype html><title>GEV</title>', {
|
||||
headers: { 'content-type': 'text/html' },
|
||||
}),
|
||||
],
|
||||
['a malformed status', answer({ configured: 'yes' })],
|
||||
[
|
||||
'an unreachable server',
|
||||
async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
},
|
||||
],
|
||||
])
|
||||
await assert.rejects(
|
||||
assertRealStatusRoute('http://localhost:4173', fetchImpl),
|
||||
/api\/mapillary\/status/,
|
||||
why,
|
||||
);
|
||||
});
|
||||
|
||||
/* ── Hermetic photo flow: the photo line and the Graph fixtures ────────── */
|
||||
|
||||
test('the photo sequence runs through the parked view, clipped to each tile it crosses', async () => {
|
||||
@@ -157,9 +71,6 @@ test('the photo sequence runs through the parked view, clipped to each tile it c
|
||||
elsewhere.sequences.some((s) => s.id === PHOTO_SEQUENCE_ID),
|
||||
false,
|
||||
);
|
||||
// The parked camera looks down onto the line.
|
||||
assert.equal(PARK.lon, PHOTO_LINE.lon);
|
||||
assert.ok(PARK.lat > PHOTO_LINE.south && PARK.lat < PHOTO_LINE.north);
|
||||
});
|
||||
|
||||
test('the fixture photos: one sequence, 360° and flat, close enough for any nearest lookup on the line', () => {
|
||||
@@ -322,54 +233,3 @@ test('evidence never carries a Mapillary token', () => {
|
||||
'loaded with AIza…REDACTED inline',
|
||||
);
|
||||
});
|
||||
|
||||
test('--strict accepts only the documented skips', () => {
|
||||
assert.deepEqual([...STRICT_ALLOWED_SKIPS], ['fixtures only']);
|
||||
assert.deepEqual(
|
||||
strictViolations([
|
||||
{ reason: 'fixtures only', label: 'keyless page' },
|
||||
{ reason: 'no Mapillary key', label: 'the keyed steps' },
|
||||
]),
|
||||
[{ reason: 'no Mapillary key', label: 'the keyed steps' }],
|
||||
);
|
||||
});
|
||||
|
||||
test('the gate saves failure evidence and fails on render-loop errors', () => {
|
||||
const source = fs.readFileSync(
|
||||
new URL('../../scripts/qa-street-level.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /saveFailureArtifacts\(/);
|
||||
assert.match(source, /hookRenderErrors\(/);
|
||||
assert.match(source, /readRenderErrors\(/);
|
||||
});
|
||||
|
||||
test('CI runs the gate hermetically against a production build, strict, with evidence', () => {
|
||||
const ci = fs.readFileSync(
|
||||
new URL('../../.github/workflows/ci.yml', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const job = ci.slice(ci.indexOf('street-level-browser:'));
|
||||
const gate = job.slice(0, job.indexOf('\n windows-onboarding:'));
|
||||
// The dummy token is baked into the bundle at build time and the preview
|
||||
// server's status route reads it at run time: both steps carry it.
|
||||
assert.match(
|
||||
gate,
|
||||
/MAPILLARY_CLIENT_TOKEN: 'MLY\|0\|qa-fixture'\s+run: npm run build/,
|
||||
);
|
||||
assert.match(
|
||||
gate,
|
||||
/MAPILLARY_CLIENT_TOKEN: 'MLY\|0\|qa-fixture'\s+run: \|\s+npx vite preview --port 4173 --strictPort/,
|
||||
);
|
||||
assert.doesNotMatch(gate, /npx vite --port/);
|
||||
assert.equal(
|
||||
(gate.match(/MAPILLARY_CLIENT_TOKEN: 'MLY\|0\|qa-fixture'/g) || []).length,
|
||||
2,
|
||||
);
|
||||
assert.match(gate, /qa:street-level:fixtures -- --strict/);
|
||||
assert.match(
|
||||
gate,
|
||||
/if: failure\(\)[\s\S]*actions\/upload-artifact@[0-9a-f]{40}/,
|
||||
);
|
||||
assert.match(gate, /path: qa-artifacts\//);
|
||||
});
|
||||
@@ -435,25 +435,6 @@ test('under KEY REQUIRED the filters are gated and the error line says how to ad
|
||||
controls.destroy();
|
||||
}));
|
||||
|
||||
test('the legend rebuilds when a swatch changes, even at the same count', () =>
|
||||
withDom(async (dom) => {
|
||||
const { layer, controls } = stubPanel(dom, uiState());
|
||||
const legend = dom.root.querySelector('#sl-legend');
|
||||
const swatch = () => legend.children[0].children[0].style.background;
|
||||
assert.equal(swatch(), '#05cb63');
|
||||
layer.publish(
|
||||
uiState({
|
||||
legend: [
|
||||
{ key: 'mapillary', label: 'Mapillary', color: '#a66bff' },
|
||||
{ key: 'selected', label: 'Selected', color: '#00d4ff' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.equal(legend.childElementCount, 2);
|
||||
assert.equal(swatch(), '#a66bff');
|
||||
controls.destroy();
|
||||
}));
|
||||
|
||||
test('the viewer is resized once when it opens, not on every render', () =>
|
||||
withDom(async (dom) => {
|
||||
const { layer, controls } = stubPanel(dom, uiState());
|
||||
|
||||
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
presentStreetLevelPanel,
|
||||
SINCE_STOPS,
|
||||
sinceStopIndex,
|
||||
} from './streetLevelPresentation.js';
|
||||
function snapshot(overrides = {}) {
|
||||
@@ -51,14 +50,7 @@ function deepMerge(target, source) {
|
||||
return out;
|
||||
}
|
||||
|
||||
test('a key-gated layer disables every control and flags KEY REQUIRED', () => {
|
||||
const view = presentStreetLevelPanel(snapshot({ keyRequired: true }));
|
||||
assert.equal(view.controlsDisabled, true);
|
||||
assert.equal(view.status.text, 'KEY REQUIRED');
|
||||
assert.equal(view.status.tone, 'warn');
|
||||
});
|
||||
|
||||
test('KEY REQUIRED says how to add the key instead of a raw error code', () => {
|
||||
test('KEY REQUIRED disables every control and says how to add the key', () => {
|
||||
const view = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
@@ -66,7 +58,9 @@ test('KEY REQUIRED says how to add the key instead of a raw error code', () => {
|
||||
coverage: { error: 'no_key' },
|
||||
}),
|
||||
);
|
||||
assert.equal(view.controlsDisabled, true);
|
||||
assert.equal(view.status.text, 'KEY REQUIRED');
|
||||
assert.equal(view.status.tone, 'warn');
|
||||
assert.match(view.error, /^Mapillary: Needs MAPILLARY_CLIENT_TOKEN/);
|
||||
});
|
||||
|
||||
@@ -149,18 +143,10 @@ test('errors from the viewer or the coverage web surface in one alert', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('the SINCE slider runs from any date on the left to the last month on the right', () => {
|
||||
assert.deepEqual(
|
||||
SINCE_STOPS.map((stop) => stop.days),
|
||||
[0, 3652, 1826, 1095, 730, 365, 182, 91, 30],
|
||||
);
|
||||
test('a SINCE window from a link lands on the nearest slider stop', () => {
|
||||
assert.equal(sinceStopIndex(0), 0);
|
||||
assert.equal(sinceStopIndex(365), 5);
|
||||
assert.equal(
|
||||
sinceStopIndex(400),
|
||||
5,
|
||||
'an off-stop link lands on the nearest stop',
|
||||
);
|
||||
assert.equal(sinceStopIndex(400), 5);
|
||||
assert.equal(sinceStopIndex(-4), 0);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
The rail owns position and width (layers.css). */
|
||||
#street-level-panel {
|
||||
--sl-green: #05cb63;
|
||||
--sl-text: 0.62rem;
|
||||
--sl-text-small: 0.6rem;
|
||||
--sl-text: 0.6rem;
|
||||
}
|
||||
|
||||
#street-level-panel .panel-glow {
|
||||
@@ -44,30 +43,24 @@
|
||||
}
|
||||
|
||||
.sl-status {
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--sl-text-small);
|
||||
font-size: var(--sl-text);
|
||||
letter-spacing: 0.1em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--glass-border-hover);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button.sl-status {
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--sl-text-small);
|
||||
letter-spacing: 0.1em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
button.sl-status:hover,
|
||||
button.sl-status:focus-visible {
|
||||
.sl-status:hover,
|
||||
.sl-status:focus-visible {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
@@ -94,24 +87,25 @@ button.sl-status:focus-visible {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sl-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sl-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sl-body,
|
||||
.sl-main,
|
||||
.sl-settings,
|
||||
.sl-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sl-body,
|
||||
.sl-settings {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sl-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sl-controls {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -123,13 +117,6 @@ button.sl-status:focus-visible {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sl-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Errors ──────────────────────────────────────────────────────────────── */
|
||||
.sl-error {
|
||||
font-family: var(--font-sans);
|
||||
@@ -156,7 +143,7 @@ button.sl-status:focus-visible {
|
||||
|
||||
.sl-seg-btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--sl-text-small);
|
||||
font-size: var(--sl-text);
|
||||
letter-spacing: 0.08em;
|
||||
padding: 4px 9px;
|
||||
border: 0;
|
||||
@@ -211,7 +198,7 @@ button.sl-status:focus-visible {
|
||||
.sl-since-label {
|
||||
grid-column: 2;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--sl-text-small);
|
||||
font-size: var(--sl-text);
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -230,7 +217,7 @@ button.sl-status:focus-visible {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--sl-text-small);
|
||||
font-size: var(--sl-text);
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -322,7 +309,7 @@ button.sl-status:focus-visible {
|
||||
|
||||
.sl-viewer-tools .scene-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: var(--sl-text-small);
|
||||
font-size: var(--sl-text);
|
||||
background: rgba(10, 10, 15, 0.75);
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
@@ -372,7 +359,7 @@ button.sl-status:focus-visible {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--sl-text-small);
|
||||
font-size: var(--sl-text);
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
@@ -418,7 +405,7 @@ button.sl-status:focus-visible {
|
||||
|
||||
.sl-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--sl-text-small);
|
||||
font-size: var(--sl-text);
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user