From 547b706f673e2bd89a5c876520142d4028feda44 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 25 Sep 2026 18:08:43 +0100 Subject: [PATCH] refactor(mapillary): remove the plain-English query feature Street Level is imagery only from here on: coverage, sequences and the embedded viewer. The Claude planner, map-feature and traffic-sign tiles, the NDJSON feature stream, sprites, detection outlines and analyst records go, along with @anthropic-ai/sdk, zod and the Anthropic key entry. Co-Authored-By: Claude Fable 5.1 --- .env.example | 17 +- package-lock.json | 84 +- package.json | 4 +- scripts/format-scope.json | 13 - scripts/package-boundaries.json | 23 +- scripts/qa-mapillary.mjs | 54 +- scripts/setup-doctor.mjs | 1 - server/providers/mapillary.js | 10 +- server/providers/mapillary/constants.js | 53 +- server/providers/mapillary/decode.js | 60 - server/providers/mapillary/features.js | 165 --- server/providers/mapillary/planner.js | 322 ----- server/providers/mapillary/routes.js | 34 +- server/providers/mapillary/sprites.js | 103 -- src/app/constructCatalog.js | 5 - src/keySetupCore.mjs | 11 +- src/layers/mapillary/coverage.js | 18 +- src/layers/mapillary/decode.js | 46 - src/layers/mapillary/decode.test.mjs | 25 - src/layers/mapillary/features.js | 313 ----- src/layers/mapillary/glyphs.js | 85 +- src/layers/mapillary/index.js | 187 +-- src/layers/mapillary/ndjson.js | 61 - src/layers/mapillary/ndjson.test.mjs | 30 - src/layers/mapillary/policy.js | 11 - src/layers/mapillary/query.js | 516 -------- src/layers/mapillary/query.test.mjs | 114 -- src/layers/mapillary/selection.js | 7 +- src/layers/mapillary/source.js | 92 +- src/layers/mapillary/source.test.mjs | 42 +- src/layers/mapillary/state.js | 39 - src/layers/mapillary/taxonomy.js | 1605 ----------------------- src/layers/mapillary/values.js | 162 --- src/layers/mapillary/values.test.mjs | 96 -- src/layers/mapillary/viewerBridge.js | 72 - src/mapillaryMarkup.test.mjs | 22 +- src/setupDoctor.test.mjs | 3 - src/tooling/mapillaryProvider.test.mjs | 271 +--- src/tooling/mapillaryQa.test.mjs | 9 - src/ui/mapillaryControls.js | 140 +- src/ui/mapillaryPresentation.js | 133 +- src/ui/mapillaryPresentation.test.mjs | 155 +-- src/ui/styles/mapillary.css | 222 +--- src/ui/templates/layer-panels.html | 26 - 44 files changed, 143 insertions(+), 5318 deletions(-) delete mode 100644 server/providers/mapillary/decode.js delete mode 100644 server/providers/mapillary/features.js delete mode 100644 server/providers/mapillary/planner.js delete mode 100644 server/providers/mapillary/sprites.js delete mode 100644 src/layers/mapillary/decode.test.mjs delete mode 100644 src/layers/mapillary/features.js delete mode 100644 src/layers/mapillary/ndjson.js delete mode 100644 src/layers/mapillary/ndjson.test.mjs delete mode 100644 src/layers/mapillary/query.js delete mode 100644 src/layers/mapillary/query.test.mjs delete mode 100644 src/layers/mapillary/taxonomy.js delete mode 100644 src/layers/mapillary/values.js delete mode 100644 src/layers/mapillary/values.test.mjs diff --git a/.env.example b/.env.example index 188614654..1930d7bfe 100644 --- a/.env.example +++ b/.env.example @@ -75,25 +75,14 @@ OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano # provider-side budget: OpenAI platform -> Settings -> Limits (usage limits). # GEV_RATELIMIT_OPENAI_PER_MIN=30 -# Optional: Mapillary street-level imagery — coverage lines, detected objects -# and traffic signs, the embedded MapillaryJS viewer and the plain-English query box. +# Optional: Mapillary street-level imagery — coverage lines on the globe and +# the embedded MapillaryJS viewer in the Street Level layer. # Free client token: https://www.mapillary.com/dashboard/developers (register an # application, copy the "Client Token"). CLIENT-EXPOSED by design: MapillaryJS # fetches imagery straight from Mapillary in the browser, so this token is in # the bundle like GOOGLE_MAPS_API_KEY. Mapillary client tokens are meant to be -# public; the dev server also uses it to fetch and cache vector tiles. +# public; the dev server also uses it to fetch and cache coverage tiles. MAPILLARY_CLIENT_TOKEN= -# Optional: Anthropic key for the Street Level query box ("show me all fire hydrants in -# Sacramento" -> a query plan drawn on the globe). SERVER-SIDE ONLY: the -# browser posts the sentence to /api/mapillary/plan and never sees the key. -# The SDK also honours an `ant auth login` profile when this is unset. -ANTHROPIC_API_KEY= -# Planner model. Defaults to claude-opus-5 at low effort; model ids drift, so -# override here rather than editing server/providers/mapillary/constants.js. -# ANTHROPIC_PLANNER_MODEL=claude-opus-5 -# Optional: opt-in per-IP rate limit for the planner endpoint (requests/min). -# DEFAULT IS UNLIMITED. Recommended whenever HOST is not localhost. -# GEV_RATELIMIT_ANTHROPIC_PER_MIN=20 # Optional: OpenSky auth mode (oauth | basic | auto | anon) # Default: oauth — requires client credentials below diff --git a/package-lock.json b/package-lock.json index 199429d93..55ac13f26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "0.1.1", "license": "MIT", "dependencies": { - "@anthropic-ai/sdk": "^0.126.0", "@jtarrio/signals": "^0.10.0", "@jtarrio/webrtlsdr": "^3.0.6", "@mapbox/vector-tile": "^3.0.0", @@ -20,8 +19,7 @@ "mapillary-js": "^4.1.2", "mgrs": "^2.1.0", "pbf": "^5.1.2", - "satellite.js": "^6.0.2", - "zod": "^4.6.5" + "satellite.js": "^6.0.2" }, "devDependencies": { "prettier": "3.9.6", @@ -35,36 +33,6 @@ "node": ">=24.14.0 <25 || >=26 <27" } }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.126.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.126.0.tgz", - "integrity": "sha512-VhiZl6rA/8uC+MgDaOhEcAWaIZ2tPnIY885jlZqxrGrutUW2nqCtophBtlsX0tk5kChBUjV/1NVjL3y0M4OwUg==", - "license": "MIT", - "dependencies": { - "json-schema-to-ts": "^3.1.1", - "standardwebhooks": "^1.0.0" - }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", - "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@cesium/engine": { "version": "22.3.0", "resolved": "https://registry.npmjs.org/@cesium/engine/-/engine-22.3.0.tgz", @@ -1563,12 +1531,6 @@ "pnpm": ">=8" } }, - "node_modules/@stablelib/base64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", - "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", - "license": "MIT" - }, "node_modules/@tweenjs/tween.js": { "version": "25.0.0", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", @@ -2022,12 +1984,6 @@ "individual": "^3.0.0" } }, - "node_modules/fast-sha256": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", - "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", - "license": "Unlicense" - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2220,19 +2176,6 @@ "node": ">= 10.16.0" } }, - "node_modules/json-schema-to-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "ts-algebra": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", @@ -2912,16 +2855,6 @@ "integrity": "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==", "license": "MIT" }, - "node_modules/standardwebhooks": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", - "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", - "license": "MIT", - "dependencies": { - "@stablelib/base64": "^1.0.0", - "fast-sha256": "^1.3.0" - } - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3023,12 +2956,6 @@ "topoquantize": "bin/topoquantize" } }, - "node_modules/ts-algebra": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", - "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT" - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3325,15 +3252,6 @@ "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" } - }, - "node_modules/zod": { - "version": "4.6.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", - "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } } } } diff --git a/package.json b/package.json index 3e9f916cb..ffd687376 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,6 @@ "test:track": "node scripts/track-regression.mjs" }, "dependencies": { - "@anthropic-ai/sdk": "^0.126.0", "@jtarrio/signals": "^0.10.0", "@jtarrio/webrtlsdr": "^3.0.6", "@mapbox/vector-tile": "^3.0.0", @@ -59,8 +58,7 @@ "mapillary-js": "^4.1.2", "mgrs": "^2.1.0", "pbf": "^5.1.2", - "satellite.js": "^6.0.2", - "zod": "^4.6.5" + "satellite.js": "^6.0.2" }, "devDependencies": { "prettier": "3.9.6", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index db6340548..c8af86387 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -852,14 +852,9 @@ "src/layers/mapillary/policy.js", "src/layers/mapillary/coverage.js", "src/layers/mapillary/sequences.js", - "src/layers/mapillary/features.js", "src/layers/mapillary/glyphs.js", "src/layers/mapillary/decode.js", "src/layers/mapillary/tileMath.js", - "src/layers/mapillary/values.js", - "src/layers/mapillary/taxonomy.js", - "src/layers/mapillary/ndjson.js", - "src/layers/mapillary/query.js", "src/layers/mapillary/selection.js", "src/layers/mapillary/viewerBridge.js", "src/layers/mapillary/source.js", @@ -868,15 +863,7 @@ "server/providers/mapillary/constants.js", "server/providers/mapillary/tiles.js", "server/providers/mapillary/trim.js", - "server/providers/mapillary/decode.js", - "server/providers/mapillary/features.js", - "server/providers/mapillary/planner.js", - "server/providers/mapillary/sprites.js", - "src/layers/mapillary/decode.test.mjs", - "src/layers/mapillary/ndjson.test.mjs", - "src/layers/mapillary/query.test.mjs", "src/layers/mapillary/tileMath.test.mjs", - "src/layers/mapillary/values.test.mjs", "scripts/qa-mapillary.mjs", "src/tooling/mapillaryQa.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ccee551f0..0b06dab85 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -182,18 +182,12 @@ "server/providers/mapillary/constants.js", "server/providers/mapillary/tiles.js", "server/providers/mapillary/trim.js", - "server/providers/mapillary/decode.js", - "server/providers/mapillary/features.js", - "server/providers/mapillary/planner.js", - "server/providers/mapillary/sprites.js", "src/layers/mapillary/tileMath.js", - "src/layers/mapillary/values.js", - "src/layers/mapillary/taxonomy.js", "server/providers/common/request.js", "src/sources/rateLimit.js", "server/providers/common/rate-limit.js" ], - "external": ["@anthropic-ai/sdk", "zod", "pbf", "@mapbox/vector-tile"] + "external": ["pbf"] }, "radio-provider": { "runtime": "node", @@ -1128,14 +1122,9 @@ "src/layers/mapillary/policy.js", "src/layers/mapillary/coverage.js", "src/layers/mapillary/sequences.js", - "src/layers/mapillary/features.js", "src/layers/mapillary/glyphs.js", "src/layers/mapillary/decode.js", "src/layers/mapillary/tileMath.js", - "src/layers/mapillary/values.js", - "src/layers/mapillary/taxonomy.js", - "src/layers/mapillary/ndjson.js", - "src/layers/mapillary/query.js", "src/layers/mapillary/selection.js", "src/layers/mapillary/viewerBridge.js", "src/layers/mapillary/source.js", @@ -2010,14 +1999,9 @@ "src/layers/mapillary/policy.js", "src/layers/mapillary/coverage.js", "src/layers/mapillary/sequences.js", - "src/layers/mapillary/features.js", "src/layers/mapillary/glyphs.js", "src/layers/mapillary/decode.js", "src/layers/mapillary/tileMath.js", - "src/layers/mapillary/values.js", - "src/layers/mapillary/taxonomy.js", - "src/layers/mapillary/ndjson.js", - "src/layers/mapillary/query.js", "src/layers/mapillary/selection.js", "src/layers/mapillary/viewerBridge.js", "src/layers/mapillary/source.js", @@ -2488,14 +2472,9 @@ "src/layers/mapillary/policy.js", "src/layers/mapillary/coverage.js", "src/layers/mapillary/sequences.js", - "src/layers/mapillary/features.js", "src/layers/mapillary/glyphs.js", "src/layers/mapillary/decode.js", "src/layers/mapillary/tileMath.js", - "src/layers/mapillary/values.js", - "src/layers/mapillary/taxonomy.js", - "src/layers/mapillary/ndjson.js", - "src/layers/mapillary/query.js", "src/layers/mapillary/selection.js", "src/layers/mapillary/viewerBridge.js", "src/layers/mapillary/source.js", diff --git a/scripts/qa-mapillary.mjs b/scripts/qa-mapillary.mjs index a862e3bd1..4b00edfbc 100644 --- a/scripts/qa-mapillary.mjs +++ b/scripts/qa-mapillary.mjs @@ -2,8 +2,7 @@ /** * Browser QA for the Street Level (Mapillary) layer against a running dev * server: the panel's place in the right rail, the keyless gate, coverage, - * a ready-made feature plan (no LLM), the embedded viewer and its expanded - * dialog. Run with `npm run qa:mapillary -- --url http://localhost:4173`. + * the embedded viewer and its expanded dialog. Run with `npm run qa:mapillary -- --url http://localhost:4173`. * Without MAPILLARY_CLIENT_TOKEN on the server only the keyless steps run. */ import assert from 'node:assert/strict'; @@ -15,23 +14,6 @@ export const VIEWPORTS = Object.freeze([ { width: 1280, height: 800 }, ]); -/** A feature plan the executor can run without the planner. */ -export function hydrantPlan() { - return { - intent: 'map_features', - layer: 'points', - place: null, - use_current_view: true, - values: ['object--fire-hydrant'], - seen_after: null, - seen_before: null, - prefer_pano: false, - visualise: 'icons', - title: 'Fire hydrants · current view', - answer: 'Showing fire hydrants in the current view.', - }; -} - /** Expected right-rail order once the layout controller has run. */ export const RAIL_ORDER = Object.freeze([ 'pp-toggles', @@ -219,40 +201,6 @@ async function main() { ); }, ); - await step( - 'a ready-made plan streams results and fills the chips', - async () => { - // Fire and forget: the fan-out can outlive a CDP call, so poll instead. - await page.evaluate((plan) => { - void window.__godsEyeView.dataManager.layers - .get('mapillary') - .module.runPlan(plan, { prompt: 'fire hydrants here' }); - }, hydrantPlan()); - await sleep(500); - await page.waitForFunction( - () => - !window.__godsEyeView.dataManager.layers - .get('mapillary') - .module.getUIState().query.busy, - { timeout: 120_000 }, - ); - const state = await page.evaluate(() => { - const u = window.__godsEyeView.dataManager.layers - .get('mapillary') - .module.getUIState(); - return { - total: u.features.total, - answer: u.query.answer, - chips: document.querySelectorAll( - '#mly-result-chips .mly-value-chip', - ).length, - }; - }); - assert.ok(state.total > 0, 'hydrants found in view'); - assert.match(state.answer, /fire hydrants?/i); - assert.ok(state.chips >= 1); - }, - ); await step( 'opening the nearest image shows the viewer with a caption', async () => { diff --git a/scripts/setup-doctor.mjs b/scripts/setup-doctor.mjs index a4554c4d3..162cb173c 100644 --- a/scripts/setup-doctor.mjs +++ b/scripts/setup-doctor.mjs @@ -35,7 +35,6 @@ export const CREDENTIALS = Object.freeze([ }, { name: 'LL2_API_TOKEN', label: 'Launch Library 2', keychain: [] }, { name: 'MAPILLARY_CLIENT_TOKEN', label: 'Mapillary street level', keychain: [['mapillary', 'client-token'], ['mapillary', 'token']] }, - { name: 'ANTHROPIC_API_KEY', label: 'Anthropic (street-level planner)', keychain: [['anthropic', 'api-key']] }, ]); export function isConfiguredValue(value) { diff --git a/server/providers/mapillary.js b/server/providers/mapillary.js index 51f9512ed..c9a870114 100644 --- a/server/providers/mapillary.js +++ b/server/providers/mapillary.js @@ -1,13 +1,11 @@ import { installMapillaryRoutes } from './mapillary/routes.js'; /** - * Vite plugin: Mapillary street-level data. + * Vite plugin: Mapillary street-level coverage. * - * Serves cached vector tiles (coverage, point features, traffic signs) with - * the Mapillary token added server-side, fans a city-sized bbox out over - * z14 feature tiles as an NDJSON stream, turns natural-language requests - * into query plans with Claude (ANTHROPIC_API_KEY stays on this machine), - * and proxies the MIT-licensed Mapillary sprite icons. + * Serves cached coverage vector tiles with the Mapillary token added + * server-side (and the unused z14 image point layer stripped in transit), + * plus a status route that says whether a token is configured. */ function mapillaryProxy() { return { diff --git a/server/providers/mapillary/constants.js b/server/providers/mapillary/constants.js index 3444622a1..2c968004f 100644 --- a/server/providers/mapillary/constants.js +++ b/server/providers/mapillary/constants.js @@ -14,23 +14,8 @@ export const TILE_LAYERS = Object.freeze({ maxZoom: 14, dropLayers: Object.freeze(['image']), }), - // Point map features such as fire hydrants, benches and utility poles. - points: Object.freeze({ - upstream: 'mly_map_feature_point', - minZoom: 14, - maxZoom: 14, - }), - // Classified traffic-sign detections. - signs: Object.freeze({ - upstream: 'mly_map_feature_traffic_sign', - minZoom: 14, - maxZoom: 14, - }), }); -/** Zoom at which Mapillary publishes map-feature tiles. */ -export const FEATURE_TILE_ZOOM = 14; - /** Disk cache root, alongside the other providers' caches. */ export const MAPILLARY_CACHE_DIR = path.join( process.cwd(), @@ -38,56 +23,22 @@ export const MAPILLARY_CACHE_DIR = path.join( 'mapillary', ); export const TILE_DISK_DIR = path.join(MAPILLARY_CACHE_DIR, 'tiles'); -export const SPRITE_DISK_DIR = path.join(MAPILLARY_CACHE_DIR, 'sprites'); /** * Tiles change when new imagery is processed, which is far slower than the - * 15-minute upstream Cache-Control. A day keeps a city-wide query from - * re-downloading hundreds of megabytes on every run. + * 15-minute upstream Cache-Control. A day keeps a session over one city from + * re-downloading its tiles on every camera move. */ export const TILE_DISK_TTL_MS = 24 * 60 * 60 * 1000; -export const SPRITE_DISK_TTL_MS = 30 * 24 * 60 * 60 * 1000; /** A z14 image tile over a dense city is ~11 MB; anything past this is wrong. */ export const TILE_MAX_BYTES = 48 * 1024 * 1024; -export const SPRITE_MAX_BYTES = 512 * 1024; /** In-memory tile cache budget (bytes) and upstream fetch timeout. */ export const TILE_MEMORY_BUDGET_BYTES = 96 * 1024 * 1024; export const TILE_FETCH_TIMEOUT_MS = 60_000; -/** Executor limits for one natural-language query. */ -export const QUERY_MAX_TILES = 600; -export const QUERY_MAX_FEATURES = 120_000; -export const QUERY_TILE_CONCURRENCY = 6; -export const QUERY_MAX_BODY_BYTES = 32 * 1024; - -/** Sprite SVG source (MIT) — one file per taxonomy value. */ -export const SPRITE_SOURCE_ROOT = - 'https://raw.githubusercontent.com/mapillary/mapillary_sprite_source/master'; - -/** Planner defaults; the model id is external and may be overridden by env. */ -export const PLANNER_MODEL_DEFAULT = 'claude-opus-5'; -export const PLANNER_MAX_TOKENS = 1500; -export const PLANNER_MAX_BODY_BYTES = 32 * 1024; -export const PLANNER_MAX_QUERY_CHARS = 600; - /** The client token lives in the browser by design; the server adds it to tile URLs too. */ export function mapillaryToken() { return String(process.env.MAPILLARY_CLIENT_TOKEN || '').trim(); } - -/** Anthropic credentials are server-side only. */ -export function anthropicConfigured() { - return Boolean( - String(process.env.ANTHROPIC_API_KEY || '').trim() || - String(process.env.ANTHROPIC_AUTH_TOKEN || '').trim(), - ); -} - -export function plannerModel() { - return ( - String(process.env.ANTHROPIC_PLANNER_MODEL || '').trim() || - PLANNER_MODEL_DEFAULT - ); -} diff --git a/server/providers/mapillary/decode.js b/server/providers/mapillary/decode.js deleted file mode 100644 index 51a211705..000000000 --- a/server/providers/mapillary/decode.js +++ /dev/null @@ -1,60 +0,0 @@ -import { PbfReader } from 'pbf'; -import { VectorTile } from '@mapbox/vector-tile'; -import { tileLocalToLonLat } from '../../../src/layers/mapillary/tileMath.js'; - -/** Vector-tile layer name inside each Mapillary map-feature tile. */ -const FEATURE_LAYER_NAMES = Object.freeze({ - points: 'point', - signs: 'traffic_sign', -}); - -/** - * Decode one map-feature tile into compact rows, keeping only features whose - * value passes `matcher` and whose sighting window overlaps [since, until]. - * Rows are positional arrays to keep the streamed payload small: - * [lon, lat, id, value, firstSeenMs, lastSeenMs]. - * @param {Buffer|Uint8Array} bytes - * @param {{x:number,y:number,z:number,layer:'points'|'signs'}} address - * @param {{matcher?: (value:string)=>boolean, since?: number|null, until?: number|null}} [filter] - * @returns {Array<[number, number, string, string, number, number]>} - */ -export function decodeFeatureRows(bytes, address, filter = {}) { - if (!bytes || !bytes.length) return []; - const layerName = FEATURE_LAYER_NAMES[address.layer]; - const tile = new VectorTile(new PbfReader(bytes)); - const layer = layerName ? tile.layers[layerName] : null; - if (!layer) return []; - const matcher = filter.matcher || (() => true); - const since = Number.isFinite(filter.since) ? filter.since : null; - const until = Number.isFinite(filter.until) ? filter.until : null; - const rows = []; - for (let i = 0; i < layer.length; i++) { - const feature = layer.feature(i); - const value = feature.properties?.value; - if (typeof value !== 'string' || !matcher(value)) continue; - const first = Number(feature.properties.first_seen_at) || 0; - const last = Number(feature.properties.last_seen_at) || first; - if (since !== null && last < since) continue; - if (until !== null && first > until) continue; - const geometry = feature.loadGeometry(); - const point = geometry?.[0]?.[0]; - if (!point) continue; - const [lon, lat] = tileLocalToLonLat( - point.x, - point.y, - layer.extent, - address.x, - address.y, - address.z, - ); - rows.push([ - Number(lon.toFixed(6)), - Number(lat.toFixed(6)), - String(feature.properties.id ?? feature.id ?? ''), - value, - first, - last, - ]); - } - return rows; -} diff --git a/server/providers/mapillary/features.js b/server/providers/mapillary/features.js deleted file mode 100644 index 40f907d52..000000000 --- a/server/providers/mapillary/features.js +++ /dev/null @@ -1,165 +0,0 @@ -import { readRequestBody } from '../common/request.js'; -import { - FEATURE_TILE_ZOOM, - QUERY_MAX_BODY_BYTES, - QUERY_MAX_FEATURES, - QUERY_MAX_TILES, - QUERY_TILE_CONCURRENCY, - mapillaryToken, -} from './constants.js'; -import { fetchTile, TileRequestError } from './tiles.js'; -import { decodeFeatureRows } from './decode.js'; -import { - normalizeBbox, - tilesForBbox, -} from '../../../src/layers/mapillary/tileMath.js'; -import { - createValueMatcher, - normalizeValuePattern, -} from '../../../src/layers/mapillary/values.js'; - -function sendJson(res, status, payload) { - res.statusCode = status; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.setHeader('Cache-Control', 'no-store'); - res.end(JSON.stringify(payload)); -} - -/** Parse an ISO date (or epoch ms) into epoch ms, or null when absent/invalid. */ -function parseEpochMs(value) { - if (value === null || value === undefined || value === '') return null; - if (typeof value === 'number') return Number.isFinite(value) ? value : null; - const parsed = Date.parse(String(value)); - return Number.isFinite(parsed) ? parsed : null; -} - -/** - * Validate a feature query body into an executable request. - * @returns {{ok:true, request:object}|{ok:false, status:number, error:string, detail?:object}} - */ -export function normalizeFeatureQuery(body) { - const bbox = normalizeBbox(body?.bbox); - if (!bbox) - return { - ok: false, - status: 400, - error: 'A bbox [west,south,east,north] is required', - }; - const layer = body?.layer === 'signs' ? 'signs' : 'points'; - const values = Array.isArray(body?.values) - ? body.values.map(normalizeValuePattern).filter(Boolean).slice(0, 64) - : []; - const since = parseEpochMs(body?.seenAfter); - const until = parseEpochMs(body?.seenBefore); - const { tiles, truncated, total } = tilesForBbox(bbox, FEATURE_TILE_ZOOM, { - limit: QUERY_MAX_TILES, - }); - if (truncated) - return { - ok: false, - status: 413, - error: 'area_too_large', - detail: { tiles: total, limit: QUERY_MAX_TILES }, - }; - return { ok: true, request: { bbox, layer, values, since, until, tiles } }; -} - -/** - * POST /api/mapillary/features — fan a bbox out over z14 map-feature tiles - * and stream the matching features back as NDJSON, one line per tile, so - * the globe fills in progressively while the rest of the city downloads. - */ -export async function handleFeatureQuery(req, res) { - if (req.method !== 'POST') - return sendJson(res, 405, { error: 'Method not allowed' }); - if (!mapillaryToken()) - return sendJson(res, 503, { error: 'no_key', keyRequired: true }); - let body; - try { - body = JSON.parse( - (await readRequestBody(req, QUERY_MAX_BODY_BYTES)) || '{}', - ); - } catch (error) { - return sendJson(res, 400, { error: error?.message || 'Invalid JSON body' }); - } - const normalized = normalizeFeatureQuery(body); - if (!normalized.ok) - return sendJson(res, normalized.status, { - error: normalized.error, - ...normalized.detail, - }); - const { layer, values, since, until, tiles, bbox } = normalized.request; - const matcher = createValueMatcher(values); - - const abandoned = new AbortController(); - req.on?.('aborted', () => abandoned.abort()); - res.on?.('close', () => abandoned.abort()); - - res.statusCode = 200; - res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8'); - res.setHeader('Cache-Control', 'no-store'); - res.setHeader('X-Accel-Buffering', 'no'); - const write = (line) => { - if (res.writableEnded || abandoned.signal.aborted) return false; - res.write(`${JSON.stringify(line)}\n`); - return true; - }; - write({ - type: 'start', - tiles: tiles.length, - zoom: FEATURE_TILE_ZOOM, - layer, - values, - bbox, - }); - - let total = 0; - let failed = 0; - let truncated = false; - let cursor = 0; - const worker = async () => { - while (cursor < tiles.length && !abandoned.signal.aborted) { - if (total >= QUERY_MAX_FEATURES) { - truncated = true; - return; - } - const index = cursor++; - const tile = tiles[index]; - try { - const { bytes, source } = await fetchTile( - { layer, z: tile.z, x: tile.x, y: tile.y }, - { signal: abandoned.signal }, - ); - const rows = decodeFeatureRows( - bytes, - { ...tile, layer }, - { matcher, since, until }, - ); - total += rows.length; - write({ type: 'tile', i: index, x: tile.x, y: tile.y, source, rows }); - } catch (error) { - if (abandoned.signal.aborted) return; - failed++; - write({ - type: 'tile', - i: index, - x: tile.x, - y: tile.y, - rows: [], - error: - error instanceof TileRequestError - ? error.message - : `tile unavailable (${error?.status || error?.message || 'error'})`, - }); - } - } - }; - await Promise.all( - Array.from( - { length: Math.min(QUERY_TILE_CONCURRENCY, tiles.length) }, - worker, - ), - ); - write({ type: 'done', total, failed, truncated, tiles: tiles.length }); - if (!res.writableEnded) res.end(); -} diff --git a/server/providers/mapillary/planner.js b/server/providers/mapillary/planner.js deleted file mode 100644 index 0160a72ce..000000000 --- a/server/providers/mapillary/planner.js +++ /dev/null @@ -1,322 +0,0 @@ -import Anthropic from '@anthropic-ai/sdk'; -import { z } from 'zod'; -import { readRequestBody } from '../common/request.js'; -import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js'; -import { - PLANNER_MAX_BODY_BYTES, - PLANNER_MAX_QUERY_CHARS, - PLANNER_MAX_TOKENS, - anthropicConfigured, - mapillaryToken, - plannerModel, -} from './constants.js'; -import { - OBJECT_VALUES, - SIGN_FAMILIES, - SIGN_VALUES, -} from '../../../src/layers/mapillary/taxonomy.js'; -import { - createValueMatcher, - isSignValue, - normalizeValuePattern, -} from '../../../src/layers/mapillary/values.js'; - -/** - * The plan the model returns. Every field is required (structured outputs - * reject optional keys); "unknown" is spelled with null. - */ -const PlanSchema = z.object({ - intent: z.enum([ - 'map_features', - 'traffic_signs', - 'coverage', - 'nearest_image', - 'unsupported', - ]), - place: z.string().nullable(), - use_current_view: z.boolean(), - values: z.array(z.string()), - seen_after: z.string().nullable(), - seen_before: z.string().nullable(), - prefer_pano: z.boolean(), - visualise: z.enum(['icons', 'points', 'count']), - title: z.string(), - answer: z.string(), -}); - -/** - * Frozen system prompt. Nothing volatile lives here so the block can be - * prompt-cached across every query; the date and view context ride in the - * user turn instead. - */ -export const PLANNER_SYSTEM_PROMPT = [ - "You translate natural-language requests about street-level map data into a query plan for God's Eye View, a 3D globe that can draw Mapillary map features (objects detected in street imagery) and traffic signs, show Mapillary coverage, or open the nearest street-level image.", - '', - 'Return ONLY a plan object. Field rules:', - '- intent: "map_features" for objects (hydrants, benches, poles, manholes, traffic lights, road markings…); "traffic_signs" for signs (stop, speed limit, no parking, pedestrian crossing…); "coverage" when the user asks where imagery exists or how recent it is; "nearest_image" when they want to see/open/look at the street view of a place; "unsupported" when the request is not about Mapillary data (then explain in `answer`).', - '- place: the place to search, as a geocodable string ("Sacramento, California"; "Detroit, Michigan"; "Shinjuku, Tokyo"). null when the user means the current view ("here", "around me", "in this area") or gives no place at all.', - '- use_current_view: true when place is null and the current view should be used.', - '- values: Mapillary taxonomy values for the requested things. Use exact values from the lists below. A wildcard `*` matches a run of characters: use `regulatory--stop--*` to cover every regional variant of a sign family, `object--traffic-light--*` for all traffic lights, `marking--discrete--arrow--*` for all arrow markings. Include several values when the request is broad ("street furniture" → benches, trash cans, bike racks, mailboxes). Leave empty only for coverage or nearest_image intents.', - '- seen_after / seen_before: ISO dates (YYYY-MM-DD) when the user constrains time ("since 2024", "before 2022", "in the last two years" relative to today); otherwise null.', - '- prefer_pano: true when the user asks for 360°, panoramic or immersive imagery (nearest_image and coverage intents); otherwise false.', - '- visualise: "icons" by default; "points" when the user asks for density or a very broad class; "count" when they only ask how many.', - '- title: a short HUD title, e.g. "Fire hydrants · Sacramento".', - '- answer: one plain sentence confirming what will be shown, mentioning any interpretation you made. Never claim counts; the data has not been fetched yet.', - '', - 'Follow-ups: when a previous plan is supplied and the new request refines the same subject ("only the ones seen after 2024", "just the panoramas", "same thing in Detroit"), start from the previous plan and change only what the request changes. A request that names a new subject or a new place ("stop signs in downtown Detroit", "now benches here") starts fresh: keep nothing from the previous plan unless the user says "same", "also", "too" or "keep".', - '', - 'Point object values (layer map_features):', - OBJECT_VALUES.join(', '), - '', - 'Traffic-sign families (layer traffic_signs). Each family exists as several regional variants suffixed --g1, --g2…; always request a family with the wildcard form `--*`:', - SIGN_FAMILIES.join(', '), -].join('\n'); - -const KNOWN_VALUES = new Set([...OBJECT_VALUES, ...SIGN_VALUES]); - -/** - * Keep only values that exist in the taxonomy (exact) or match at least one - * taxonomy value (wildcard). Returns the surviving patterns plus what was - * dropped so the UI can say so. - */ -export function reconcilePlanValues(values) { - const kept = []; - const dropped = []; - for (const raw of values || []) { - const pattern = normalizeValuePattern(raw); - if (!pattern) continue; - if (pattern.includes('*')) { - const matches = createValueMatcher([pattern]); - if ([...KNOWN_VALUES].some(matches)) kept.push(pattern); - else dropped.push(pattern); - } else if (KNOWN_VALUES.has(pattern)) kept.push(pattern); - else if (KNOWN_VALUES.has(`${pattern}--g1`)) kept.push(`${pattern}--*`); - else dropped.push(pattern); - } - return { values: [...new Set(kept)], dropped }; -} - -/** - * Turn the model's plan into the executable plan the browser runs: values - * reconciled against the taxonomy, the tile layer derived from the values, - * and an honest downgrade to "unsupported" when nothing usable survived. - */ -export function finalizePlan(plan) { - const { values, dropped } = reconcilePlanValues(plan.values); - let intent = plan.intent; - let layer = null; - if (intent === 'map_features' || intent === 'traffic_signs') { - if (!values.length) { - return { - ...plan, - intent: 'unsupported', - values: [], - dropped, - layer: null, - answer: dropped.length - ? `I could not map "${dropped.join('", "')}" onto a Mapillary object or sign class.` - : plan.answer, - }; - } - const signs = values.filter(isSignValue).length; - layer = signs && signs === values.length ? 'signs' : 'points'; - intent = layer === 'signs' ? 'traffic_signs' : 'map_features'; - if (signs && signs !== values.length) { - // Mixed request: signs live in a different tile set. Keep the object - // half and say so rather than silently dropping either. - return { - ...plan, - intent, - layer, - values: values.filter((value) => !isSignValue(value)), - dropped: [...dropped, ...values.filter(isSignValue)], - answer: `${plan.answer} Traffic signs and objects come from different layers, so this run shows the objects only.`, - }; - } - } - return { ...plan, intent, layer, values, dropped }; -} - -let _client; -function client() { - return (_client ||= new Anthropic()); -} - -let _limiter; -function limiter() { - if (_limiter === undefined) - _limiter = makeOptInRateLimiter( - process.env.GEV_RATELIMIT_ANTHROPIC_PER_MIN, - ); - return _limiter; -} - -function sendJson(res, status, payload) { - res.statusCode = status; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.setHeader('Cache-Control', 'no-store'); - res.end(JSON.stringify(payload)); -} - -/** Build the volatile user turn: the request plus whatever the globe knows. */ -export function buildPlannerUserMessage({ query, context = {}, today }) { - const lines = [`Today is ${today}.`]; - const view = context.view; - if (view && Number.isFinite(view.lat) && Number.isFinite(view.lon)) { - lines.push( - `Current view: ${view.lat.toFixed(4)}, ${view.lon.toFixed(4)}` + - (Number.isFinite(view.heightM) - ? `, camera ${Math.round(view.heightM)} m above ground` - : '') + - (view.label ? `, near ${String(view.label).slice(0, 120)}` : '') + - '.', - ); - } - if (context.previousPlan) { - lines.push( - `Previous plan: ${JSON.stringify(context.previousPlan).slice(0, 1500)}`, - ); - } - lines.push('', `Request: ${query}`); - return lines.join('\n'); -} - -/** - * Structured-output format for the plan: the Zod schema as JSON Schema. Kept - * free of the SDK's zod helper so the provider only depends on package roots. - */ -function planOutputFormat() { - const { $schema: _schema, ...schema } = z.toJSONSchema(PlanSchema, { - reused: 'ref', - }); - return { type: 'json_schema', schema }; -} - -/** The plan object from a structured-output response, or null. */ -function parsePlanResponse(response) { - if (response?.parsed_output) return response.parsed_output; - const text = (response?.content || []) - .filter((block) => block?.type === 'text') - .map((block) => block.text) - .join(''); - if (!text.trim()) return null; - let raw; - try { - raw = JSON.parse(text); - } catch { - return null; - } - const result = PlanSchema.safeParse(raw); - return result.success ? result.data : null; -} - -/** - * Ask Claude for a plan. Exported for tests with an injectable client. - * @returns {Promise<{plan: object, usage: object|null, model: string}>} - */ -export async function planQuery( - { query, context }, - { anthropic = client(), model = plannerModel(), now = new Date() } = {}, -) { - const response = await anthropic.messages.create({ - model, - max_tokens: PLANNER_MAX_TOKENS, - system: [ - { - type: 'text', - text: PLANNER_SYSTEM_PROMPT, - cache_control: { type: 'ephemeral' }, - }, - ], - messages: [ - { - role: 'user', - content: buildPlannerUserMessage({ - query, - context, - today: now.toISOString().slice(0, 10), - }), - }, - ], - output_config: { format: planOutputFormat(), effort: 'low' }, - }); - if (response.stop_reason === 'refusal') { - return { - plan: finalizePlan({ - intent: 'unsupported', - place: null, - use_current_view: false, - values: [], - seen_after: null, - seen_before: null, - prefer_pano: false, - visualise: 'icons', - title: 'Query declined', - answer: 'The planner declined this request.', - }), - usage: response.usage ?? null, - model, - }; - } - const parsed = parsePlanResponse(response); - if (!parsed) - throw Object.assign(new Error('Planner returned no plan'), { status: 502 }); - return { - plan: finalizePlan(parsed), - usage: response.usage ?? null, - model, - }; -} - -function statusForError(error) { - if (error instanceof Anthropic.AuthenticationError) return 503; - if (error instanceof Anthropic.RateLimitError) return 429; - if (error instanceof Anthropic.APIConnectionError) return 502; - if (error instanceof Anthropic.APIStatusError) return error.status || 502; - return error?.status || 500; -} - -/** POST /api/mapillary/plan — natural language in, executable plan out. */ -export async function handlePlan(req, res) { - if (req.method !== 'POST') - return sendJson(res, 405, { error: 'Method not allowed' }); - if (!mapillaryToken()) - return sendJson(res, 503, { error: 'no_key', keyRequired: 'mapillary' }); - if (!anthropicConfigured()) - return sendJson(res, 503, { error: 'no_key', keyRequired: 'anthropic' }); - const allow = limiter(); - if (allow && !allow(clientKey(req))) { - res.setHeader('Retry-After', '5'); - return sendJson(res, 429, { error: 'Rate limit exceeded' }); - } - let body; - try { - body = JSON.parse( - (await readRequestBody(req, PLANNER_MAX_BODY_BYTES)) || '{}', - ); - } catch (error) { - return sendJson(res, 400, { error: error?.message || 'Invalid JSON body' }); - } - const query = String(body?.query || '').trim(); - if (!query || query.length > PLANNER_MAX_QUERY_CHARS) - return sendJson(res, 400, { - error: `A query of 1–${PLANNER_MAX_QUERY_CHARS} characters is required`, - }); - try { - const result = await planQuery({ - query, - context: - body?.context && typeof body.context === 'object' ? body.context : {}, - }); - sendJson(res, 200, result); - } catch (error) { - const status = statusForError(error); - console.warn('[Mapillary Planner]', error?.message || error); - sendJson(res, status, { - error: - status === 503 - ? 'Anthropic authentication failed' - : error?.message || 'Planner request failed', - }); - } -} diff --git a/server/providers/mapillary/routes.js b/server/providers/mapillary/routes.js index 34347d31d..68ef672e5 100644 --- a/server/providers/mapillary/routes.js +++ b/server/providers/mapillary/routes.js @@ -1,14 +1,5 @@ import { fetchTile, TileRequestError, TileUpstreamError } from './tiles.js'; -import { handleFeatureQuery } from './features.js'; -import { handlePlan } from './planner.js'; -import { handleSprite } from './sprites.js'; -import { - anthropicConfigured, - mapillaryToken, - plannerModel, - QUERY_MAX_TILES, - FEATURE_TILE_ZOOM, -} from './constants.js'; +import { mapillaryToken } from './constants.js'; function sendJson(res, status, payload) { res.statusCode = status; @@ -17,29 +8,23 @@ function sendJson(res, status, payload) { res.end(JSON.stringify(payload)); } -/** GET /api/mapillary/status — what this install can do, never a value. */ +/** GET /api/mapillary/status — whether a token is configured, never its value. */ function handleStatus(req, res) { if (req.method !== 'GET') return sendJson(res, 405, { error: 'Method not allowed' }); - sendJson(res, 200, { - configured: Boolean(mapillaryToken()), - planner: anthropicConfigured(), - plannerModel: anthropicConfigured() ? plannerModel() : null, - limits: { maxTiles: QUERY_MAX_TILES, featureZoom: FEATURE_TILE_ZOOM }, - }); + sendJson(res, 200, { configured: Boolean(mapillaryToken()) }); } -/** GET /api/mapillary/tiles/{layer}/{z}/{x}/{y} — cached protobuf tile. */ +/** GET /api/mapillary/tiles/coverage/{z}/{x}/{y} — cached protobuf tile. */ async function handleTile(req, res) { if (req.method !== 'GET') return sendJson(res, 405, { error: 'Method not allowed' }); - const match = - /^\/(coverage|points|signs)\/(\d{1,2})\/(\d{1,6})\/(\d{1,6})$/.exec( - (req.url || '').split('?')[0], - ); + const match = /^\/(coverage)\/(\d{1,2})\/(\d{1,6})\/(\d{1,6})$/.exec( + (req.url || '').split('?')[0], + ); if (!match) return sendJson(res, 400, { - error: 'Tile path must be /{layer}/{z}/{x}/{y}', + error: 'Tile path must be /coverage/{z}/{x}/{y}', }); if (!mapillaryToken()) return sendJson(res, 503, { error: 'no_key', keyRequired: true }); @@ -73,7 +58,4 @@ async function handleTile(req, res) { export function installMapillaryRoutes(middlewares) { middlewares.use('/api/mapillary/status', handleStatus); middlewares.use('/api/mapillary/tiles', handleTile); - middlewares.use('/api/mapillary/features', handleFeatureQuery); - middlewares.use('/api/mapillary/plan', handlePlan); - middlewares.use('/api/mapillary/sprite', handleSprite); } diff --git a/server/providers/mapillary/sprites.js b/server/providers/mapillary/sprites.js deleted file mode 100644 index 15cf2ef15..000000000 --- a/server/providers/mapillary/sprites.js +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Sprite proxy. Icons come from github.com/mapillary/mapillary_sprite_source - * (MIT, © Mapillary), fetched on demand and cached on disk. - */ -import path from 'node:path'; -import { promises as fsp } from 'node:fs'; -import { - SPRITE_DISK_DIR, - SPRITE_DISK_TTL_MS, - SPRITE_MAX_BYTES, - SPRITE_SOURCE_ROOT, -} from './constants.js'; -import { isSignValue } from '../../../src/layers/mapillary/values.js'; - -/** A taxonomy value: lower-case segments joined by `--`, words by `-`. */ -const VALUE_GRAMMAR = - /^[a-z0-9]+(?:-[a-z0-9]+)*(?:--[a-z0-9]+(?:-[a-z0-9]+)*)*$/; - -/** @type {Map>} */ -const _inFlight = new Map(); - -function spritePath(value) { - return path.join(SPRITE_DISK_DIR, `${value}.svg`); -} - -async function readDisk(value) { - try { - const file = spritePath(value); - const stat = await fsp.stat(file); - if (Date.now() - stat.mtimeMs > SPRITE_DISK_TTL_MS) return null; - return await fsp.readFile(file); - } catch { - return null; - } -} - -async function fetchSprite(value) { - const pkg = isSignValue(value) ? 'package_signs' : 'package_objects'; - const response = await fetch(`${SPRITE_SOURCE_ROOT}/${pkg}/${value}.svg`, { - signal: AbortSignal.timeout(20_000), - }); - if (response.status === 404) { - await response.body?.cancel().catch(() => {}); - return null; - } - if (!response.ok) { - await response.body?.cancel().catch(() => {}); - throw new Error(`sprite source HTTP ${response.status}`); - } - const bytes = Buffer.from(await response.arrayBuffer()); - if ( - bytes.length > SPRITE_MAX_BYTES || - !bytes.subarray(0, 512).toString().includes(' fsp.writeFile(file, bytes)) - .catch(() => {}); - return bytes; -} - -/** - * GET /api/mapillary/sprite/.svg — the Mapillary icon for a taxonomy - * value, fetched once from the MIT sprite source and cached on disk. - */ -export async function handleSprite(req, res) { - const match = /^\/([a-z0-9-]+)\.svg$/.exec((req.url || '').split('?')[0]); - const value = match?.[1]; - if (!value || !VALUE_GRAMMAR.test(value)) { - res.statusCode = 400; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ error: 'Invalid sprite value' })); - return; - } - try { - let bytes = await readDisk(value); - if (!bytes) { - let pending = _inFlight.get(value); - if (!pending) { - pending = fetchSprite(value).finally(() => _inFlight.delete(value)); - _inFlight.set(value, pending); - } - bytes = await pending; - } - if (!bytes) { - res.statusCode = 404; - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Cache-Control', 'public, max-age=3600'); - res.end(JSON.stringify({ error: 'No sprite for value' })); - return; - } - res.statusCode = 200; - res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8'); - res.setHeader('Cache-Control', 'public, max-age=86400'); - res.end(bytes); - } catch (error) { - res.statusCode = 502; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ error: error?.message || 'Sprite unavailable' })); - } -} diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index a76fc3cbf..00e17ff46 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -59,14 +59,9 @@ const SOURCE_METHODS = Object.freeze({ mapillary: [ 'getStatus', 'getTile', - 'queryFeatures', - 'plan', - 'geocode', 'getImage', 'getSequenceImages', 'nearestImages', - 'getMapFeature', - 'spriteUrl', ], }); diff --git a/src/keySetupCore.mjs b/src/keySetupCore.mjs index f28953167..8d3f30c45 100644 --- a/src/keySetupCore.mjs +++ b/src/keySetupCore.mjs @@ -102,21 +102,12 @@ export const KEY_SETUP_KEYS = Object.freeze([ id: 'mapillary', title: 'MAPILLARY', unlocks: - 'Street-level imagery, coverage, detected objects and traffic signs. Free: register an app in the Mapillary developer dashboard and paste its Client Token', + 'Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token', getUrl: 'https://www.mapillary.com/dashboard/developers', envVars: Object.freeze(['MAPILLARY_CLIENT_TOKEN']), tier: 'free', clientExposed: true, }), - Object.freeze({ - id: 'anthropic', - title: 'ANTHROPIC', - unlocks: - 'Street Level plain-English questions — type a request, get it drawn on the globe', - getUrl: 'https://console.anthropic.com/settings/keys', - envVars: Object.freeze(['ANTHROPIC_API_KEY']), - tier: 'metered', - }), Object.freeze({ id: 'launch-library', title: 'LAUNCH LIBRARY', diff --git a/src/layers/mapillary/coverage.js b/src/layers/mapillary/coverage.js index 8cfab5490..a66ed683e 100644 --- a/src/layers/mapillary/coverage.js +++ b/src/layers/mapillary/coverage.js @@ -228,16 +228,11 @@ export function createCoverage({ state, source }) { const { primitives, count } = buildSequencePrimitives(entry.sequenceList); entry.primitives = primitives; entry.count = count; - for (const primitive of primitives) { - primitive.show = !state.coverage.resting; - scene.groundPrimitives.add(primitive); - } + for (const primitive of primitives) scene.groundPrimitives.add(primitive); } else { const { collection, count } = buildOverviewCollection(entry.points); entry.primitive = collection; entry.count = count; - // Orbit-level points never rest: results are invisible at that scale. - collection.show = true; scene.primitives.add(collection); } } @@ -494,16 +489,6 @@ export function createCoverage({ state, source }) { requestRender(); } - /** Rest (hide) the street-level sequence web while results are shown; orbit points stay. */ - function setResting(resting) { - state.coverage.resting = resting === true; - for (const entry of state.coverage.tiles.values()) - if (entry.kind === 'sequence') - for (const primitive of entry.primitives || []) - primitive.show = !state.coverage.resting; - requestRender(); - } - function sequenceCount() { let count = 0; for (const entry of state.coverage.tiles.values()) count += entry.count; @@ -518,7 +503,6 @@ export function createCoverage({ state, source }) { findSequence, recolorSequence, sequenceCount, - setResting, setFilter, }; } diff --git a/src/layers/mapillary/decode.js b/src/layers/mapillary/decode.js index a4c305650..8a2a9b847 100644 --- a/src/layers/mapillary/decode.js +++ b/src/layers/mapillary/decode.js @@ -97,49 +97,3 @@ export function decodeCoverageTile(bytes, address, { images = false } = {}) { } return result; } - -/** Decode a base64 string into bytes without Node's Buffer. */ -function base64ToBytes(base64) { - if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') - return new Uint8Array(Buffer.from(base64, 'base64')); - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return bytes; -} - -/** - * Decode a Mapillary detection `geometry` (a base64 vector tile whose single - * layer holds the segmentation polygon in a 4096 grid) into polygons of - * image-normalized [x, y] points in [0, 1], the coordinate space the - * MapillaryJS tag component expects. - * @param {string} base64 - * @returns {Array>} One ring per polygon. - */ -export function decodeDetectionPolygons(base64) { - if (!base64) return []; - let tile; - try { - tile = new VectorTile(new PbfReader(base64ToBytes(base64))); - } catch { - return []; - } - const polygons = []; - for (const layer of Object.values(tile.layers)) { - for (let i = 0; i < layer.length; i++) { - const feature = layer.feature(i); - for (const ring of feature.loadGeometry()) { - if (ring.length < 3) continue; - const points = ring.map((point) => [ - Math.min(1, Math.max(0, point.x / layer.extent)), - Math.min(1, Math.max(0, point.y / layer.extent)), - ]); - const [fx, fy] = points[0]; - const [lx, ly] = points[points.length - 1]; - if (fx !== lx || fy !== ly) points.push([fx, fy]); - polygons.push(points); - } - } - } - return polygons; -} diff --git a/src/layers/mapillary/decode.test.mjs b/src/layers/mapillary/decode.test.mjs deleted file mode 100644 index 0392c0df2..000000000 --- a/src/layers/mapillary/decode.test.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { decodeDetectionPolygons } from './decode.js'; - -// A real detection geometry from graph.mapillary.com (construction--barrier--curb). -const SAMPLE = - 'Gjp4AgoGbXB5LW9yKIAgEisYAwgBIiUJzi+gKHoACAMAAwADAAMAAwADAAMAAAcEAAQABAAEAAQABAAP'; - -test('a detection geometry decodes into a closed, normalized polygon', () => { - const polygons = decodeDetectionPolygons(SAMPLE); - assert.equal(polygons.length, 1); - const ring = polygons[0]; - assert.ok(ring.length >= 4); - for (const [x, y] of ring) { - assert.ok(x >= 0 && x <= 1, `x in range: ${x}`); - assert.ok(y >= 0 && y <= 1, `y in range: ${y}`); - } - assert.deepEqual(ring[0], ring[ring.length - 1]); - assert.ok(Math.abs(ring[0][0] - 3047 / 4096) < 1e-9); -}); - -test('garbage input yields no polygons instead of throwing', () => { - assert.deepEqual(decodeDetectionPolygons('not base64!!'), []); - assert.deepEqual(decodeDetectionPolygons(''), []); -}); diff --git a/src/layers/mapillary/features.js b/src/layers/mapillary/features.js deleted file mode 100644 index d89efcbb6..000000000 --- a/src/layers/mapillary/features.js +++ /dev/null @@ -1,313 +0,0 @@ -import * as Cesium from 'cesium'; -import { - featureDotGlyph, - rasterizeSprite, - selectionRingGlyph, -} from './glyphs.js'; -import { - COLORS, - FEATURE_ICON_LIMIT, - FEATURE_ICON_MAX, - FEATURE_ICON_SIZE_PX, - FEATURE_POINT_SIZE_PX, - PICK_PREFIX, -} from './policy.js'; -import { colorForValue } from './values.js'; - -/** - * Query-result rendering: fast points while a query streams, Mapillary - * sprite icons once it completes (clamped to the ground for modest sets, - * height-sampled for city-sized ones), plus a selection ring. - */ -export function createFeatures({ state, source }) { - const { render, sprites } = state.services; - /** @type {Map>} */ - const spriteImages = new Map(); - - function requestRender() { - render?.governorRequestRender?.('mapillary-features'); - } - - function ensureCollections(viewer) { - const scene = viewer.scene; - if (!state.features.points) { - state.features.points = new Cesium.PointPrimitiveCollection({ - blendOption: Cesium.BlendOption.TRANSLUCENT, - }); - scene.primitives.add(state.features.points); - sprites?.registerSpriteCollection?.('mapillary', state.features.points); - } - if (!state.features.collection) { - state.features.collection = new Cesium.BillboardCollection({ scene }); - scene.primitives.add(state.features.collection); - sprites?.registerSpriteCollection?.( - 'mapillary', - state.features.collection, - ); - } - if (!state.features.highlight) { - state.features.highlight = new Cesium.BillboardCollection({ scene }); - scene.primitives.add(state.features.highlight); - sprites?.registerSpriteCollection?.( - 'mapillary', - state.features.highlight, - ); - } - } - - function groundPosition(lon, lat, lift = 1) { - const carto = Cesium.Cartographic.fromDegrees(lon, lat); - const height = state.viewer?.scene?.globe?.getHeight?.(carto); - return Cesium.Cartesian3.fromDegrees( - lon, - lat, - Number.isFinite(height) ? height + lift : lift, - ); - } - - const colorCache = new Map(); - function cesiumColor(value) { - let color = colorCache.get(value); - if (!color) { - color = Cesium.Color.fromCssColorString(colorForValue(value)); - colorCache.set(value, color); - } - return color; - } - - /** Sprite canvas for a value, cached; falls back to a coloured dot. */ - function spriteImage(value) { - let pending = spriteImages.get(value); - if (!pending) { - pending = fetch(source.spriteUrl(value)) - .then((response) => { - if (!response.ok) throw new Error(`sprite ${response.status}`); - return response.text(); - }) - .then((svg) => rasterizeSprite(svg, { size: 60 })) - .catch(() => - featureDotGlyph({ size: 60, color: colorForValue(value) }), - ); - spriteImages.set(value, pending); - } - return pending; - } - - /** Raw sprite SVG text for a value (3D sign faces), or null. */ - async function spriteSvg(value) { - try { - const response = await fetch(source.spriteUrl(value)); - return response.ok ? await response.text() : null; - } catch { - return null; - } - } - - /** Reset the result set and its primitives. */ - function reset() { - state.features.points?.removeAll(); - state.features.collection?.removeAll(); - state.features.highlight?.removeAll(); - Object.assign(state.features, { - rows: [], - byId: new Map(), - billboardById: new Map(), - counts: new Map(), - earliest: null, - latest: null, - total: 0, - truncated: false, - failed: 0, - progress: { done: 0, tiles: 0 }, - iconMode: false, - selectedId: null, - iconsHidden: false, - }); - if (state.features.points) state.features.points.show = true; - if (state.features.collection) state.features.collection.show = true; - requestRender(); - } - - /** Append streamed rows: [lon, lat, id, value, first, last]. */ - function appendRows(rows) { - const points = state.features.points; - if (!points) return; - const features = state.features; - for (const row of rows) { - const [lon, lat, id, value, first, last] = row; - if (features.byId.has(id)) continue; - features.byId.set(id, row); - features.rows.push(row); - features.counts.set(value, (features.counts.get(value) || 0) + 1); - if (first) - features.earliest = features.earliest - ? Math.min(features.earliest, first) - : first; - if (last) - features.latest = features.latest - ? Math.max(features.latest, last) - : last; - points.add({ - id: `${PICK_PREFIX.feature}${id}`, - position: groundPosition(lon, lat), - color: cesiumColor(value), - outlineColor: Cesium.Color.fromBytes(255, 255, 255, 200), - outlineWidth: 1.5, - pixelSize: FEATURE_POINT_SIZE_PX, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - scaleByDistance: new Cesium.NearFarScalar(500, 1.2, 40_000, 0.5), - }); - } - features.total = features.rows.length; - requestRender(); - } - - /** - * Once a query completes, swap the dots for Mapillary icons. Sets up to - * FEATURE_ICON_LIMIT clamp to terrain and 3D tiles; larger sets sit on the - * sampled globe height (clamping tens of thousands of billboards stalls the - * frame). Beyond FEATURE_ICON_MAX the points stay. - */ - async function upgradeToIcons() { - const features = state.features; - if (!features.collection || features.rows.length > FEATURE_ICON_MAX) return; - const values = [...features.counts.keys()]; - const images = new Map( - await Promise.all( - values.map(async (value) => [value, await spriteImage(value)]), - ), - ); - if (features.loading || !state.viewer) return; - const clamp = features.rows.length <= FEATURE_ICON_LIMIT; - features.collection.removeAll(); - features.billboardById = new Map(); - for (const [lon, lat, id, value] of features.rows) { - const billboard = features.collection.add({ - id: `${PICK_PREFIX.feature}${id}`, - position: clamp - ? Cesium.Cartesian3.fromDegrees(lon, lat) - : groundPosition(lon, lat, 0.5), - image: images.get(value), - imageId: `mly-sprite:${value}`, - width: FEATURE_ICON_SIZE_PX, - height: FEATURE_ICON_SIZE_PX, - ...(clamp - ? { heightReference: Cesium.HeightReference.CLAMP_TO_GROUND } - : {}), - disableDepthTestDistance: Number.POSITIVE_INFINITY, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - scaleByDistance: new Cesium.NearFarScalar(300, 1.1, 20_000, 0.35), - }); - features.billboardById.set(id, billboard); - } - features.points.show = false; - features.collection.show = !features.iconsHidden; - features.iconMode = true; - if (features.selectedId) select(features.selectedId); - requestRender(); - } - - /** Highlight one result: scale its icon and draw a ring behind it. */ - function select(id) { - const features = state.features; - clearSelection({ keepId: true }); - const row = features.byId.get(id); - if (!row) { - features.selectedId = null; - return null; - } - features.selectedId = id; - const billboard = features.billboardById?.get(id); - if (billboard) billboard.scale = 1.45; - const [lon, lat] = row; - const clamp = features.rows.length <= FEATURE_ICON_LIMIT; - features.highlight?.add({ - id: `${PICK_PREFIX.feature}${id}`, - position: clamp - ? Cesium.Cartesian3.fromDegrees(lon, lat) - : groundPosition(lon, lat, 0.5), - image: selectionRingGlyph({ size: 64, color: COLORS.selected }), - imageId: 'mly-selection-ring', - width: 58, - height: 58, - ...(clamp - ? { heightReference: Cesium.HeightReference.CLAMP_TO_GROUND } - : {}), - disableDepthTestDistance: Number.POSITIVE_INFINITY, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - }); - requestRender(); - return row; - } - - function clearSelection({ keepId = false } = {}) { - const features = state.features; - if (features.selectedId) { - const previous = features.billboardById?.get(features.selectedId); - if (previous) previous.scale = 1; - } - features.highlight?.removeAll(); - if (!keepId) features.selectedId = null; - requestRender(); - } - - function setVisible(visible) { - const features = state.features; - if (features.points) features.points.show = visible && !features.iconMode; - if (features.collection) - features.collection.show = - visible && features.iconMode && !features.iconsHidden; - if (features.highlight) features.highlight.show = visible; - requestRender(); - } - - /** Hide the flat icons while 3D objects stand in for them. */ - function setIconsHidden(hidden) { - state.features.iconsHidden = hidden === true; - if (state.features.collection) - state.features.collection.show = - state.features.iconMode && !state.features.iconsHidden; - requestRender(); - } - - /** Fly the camera to frame the query bbox. */ - function frame(bbox = state.features.bbox, { duration = 2.2 } = {}) { - if (!bbox || !state.viewer) return; - const [west, south, east, north] = bbox; - state.viewer.camera.flyTo({ - destination: Cesium.Rectangle.fromDegrees(west, south, east, north), - duration, - }); - } - - function findRow(id) { - return state.features.byId.get(id) || null; - } - - function destroy(viewer) { - for (const key of ['points', 'collection', 'highlight']) { - const collection = state.features[key]; - if (collection) { - sprites?.unregisterSpriteCollection?.('mapillary', collection); - viewer?.scene?.primitives?.remove(collection); - state.features[key] = null; - } - } - } - - return { - ensureCollections, - reset, - appendRows, - upgradeToIcons, - setVisible, - setIconsHidden, - frame, - findRow, - select, - clearSelection, - spriteImage, - spriteSvg, - destroy, - }; -} diff --git a/src/layers/mapillary/glyphs.js b/src/layers/mapillary/glyphs.js index b649a6942..22416e4f5 100644 --- a/src/layers/mapillary/glyphs.js +++ b/src/layers/mapillary/glyphs.js @@ -1,8 +1,7 @@ /** * Canvas glyphs for billboards: a camera cone for images, a ring for - * panoramas, a position marker for the street-level viewer, and a fallback - * dot for map features whose sprite is unavailable. Pure canvas drawing so - * it can run without Cesium. + * panoramas and a position marker for the street-level viewer. Pure canvas + * drawing so it can run without Cesium. */ const _cache = new Map(); @@ -85,83 +84,3 @@ export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) { _cache.set(key, element); return element; } - -/** Fallback feature dot when no sprite exists for a value. */ -export function featureDotGlyph({ size = 30, color = '#00d4ff' } = {}) { - const key = `dot:${size}:${color}`; - if (_cache.has(key)) return _cache.get(key); - const element = canvas(size); - const ctx = element.getContext('2d'); - const c = size / 2; - ctx.beginPath(); - ctx.arc(c, c, size * 0.42, 0, Math.PI * 2); - ctx.fillStyle = 'rgba(10,10,15,0.82)'; - ctx.fill(); - ctx.beginPath(); - ctx.arc(c, c, size * 0.26, 0, Math.PI * 2); - ctx.fillStyle = color; - ctx.fill(); - _cache.set(key, element); - return element; -} - -/** - * Wrap a Mapillary sprite SVG (viewBox only, no intrinsic size) into a fixed - * square canvas with a dark backing disc so it reads over imagery. - * @param {string} svgText - * @param {{size?: number}} [options] - * @returns {Promise} - */ -export function rasterizeSprite(svgText, { size = 60 } = {}) { - const sized = svgText.replace( - /]*)>/i, - (match, attributes) => - ``, - ); - const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(sized)}`; - return new Promise((resolve, reject) => { - const image = new Image(); - image.onload = () => { - const element = canvas(size); - const ctx = element.getContext('2d'); - const c = size / 2; - ctx.beginPath(); - ctx.arc(c, c, size * 0.5, 0, Math.PI * 2); - ctx.fillStyle = 'rgba(10,10,15,0.78)'; - ctx.fill(); - ctx.lineWidth = 1.5; - ctx.strokeStyle = 'rgba(255,255,255,0.35)'; - ctx.stroke(); - const inset = size * 0.14; - ctx.drawImage(image, inset, inset, size - inset * 2, size - inset * 2); - resolve(element); - }; - image.onerror = () => reject(new Error('sprite rasterization failed')); - image.src = url; - }); -} - -/** Selection ring drawn behind the picked map feature. */ -export function selectionRingGlyph({ size = 64, color = '#00d4ff' } = {}) { - const key = `ring:${size}:${color}`; - if (_cache.has(key)) return _cache.get(key); - const element = canvas(size); - const ctx = element.getContext('2d'); - const c = size / 2; - ctx.beginPath(); - ctx.arc(c, c, size * 0.46, 0, Math.PI * 2); - ctx.strokeStyle = color; - ctx.lineWidth = Math.max(2, size * 0.06); - ctx.globalAlpha = 0.95; - ctx.stroke(); - ctx.beginPath(); - ctx.arc(c, c, size * 0.34, 0, Math.PI * 2); - ctx.lineWidth = Math.max(1, size * 0.03); - ctx.globalAlpha = 0.5; - ctx.stroke(); - ctx.globalAlpha = 1; - _cache.set(key, element); - return element; -} diff --git a/src/layers/mapillary/index.js b/src/layers/mapillary/index.js index 3e84c05bf..565cdf623 100644 --- a/src/layers/mapillary/index.js +++ b/src/layers/mapillary/index.js @@ -1,9 +1,7 @@ import { createState } from './state.js'; -import { createCoverage } from './coverage.js'; +import { createCoverage, visibleBbox } from './coverage.js'; import { createSequences } from './sequences.js'; -import { createFeatures } from './features.js'; import { createViewerBridge } from './viewerBridge.js'; -import { createQuery } from './query.js'; import { createSelection } from './selection.js'; import * as Cesium from 'cesium'; import { @@ -12,29 +10,25 @@ import { MAPILLARY_CREDIT_HTML, MAPILLARY_KEY_ID, MAPILLARY_LAYER_ID, + NEAREST_LIMIT, + NEAREST_RADIUS_M, } from './policy.js'; -import { groupCountsByLabel, humanizeValue } from './values.js'; export { MAPILLARY_LAYER_ID } from './policy.js'; const SOURCE_METHODS = [ 'getStatus', 'getTile', - 'queryFeatures', - 'plan', - 'geocode', 'getImage', 'getSequenceImages', 'nearestImages', - 'getMapFeature', - 'spriteUrl', ]; /** * Construct the Mapillary street-level layer: camera-driven coverage, - * per-sequence image cones, the embedded viewer, and the natural-language - * feature query. Application services are injected; nothing here reads the - * DOM except the viewer host the UI hands over. + * per-sequence image cones and the embedded viewer. Application services + * are injected; nothing here reads the DOM except the viewer host the UI + * hands over. */ export function createMapillaryLayer({ source, services = {} }) { if (!SOURCE_METHODS.every((method) => typeof source?.[method] === 'function')) @@ -44,67 +38,20 @@ export function createMapillaryLayer({ source, services = {} }) { const context = { state, source, parts }; parts.coverage = createCoverage(context); parts.sequences = createSequences(context); - parts.features = createFeatures(context); parts.viewer = createViewerBridge(context); - parts.query = createQuery(context); parts.selection = createSelection(context); - // Street-level actions shared by clicks, the query engine and the UI. + // Street-level actions shared by clicks and the UI. parts.street = { - async openImage(imageId, { keepFeature = false } = {}) { + async openImage(imageId) { const host = state.street.host; if (!host) { - state.street.error = 'Open the Mapillary panel to view imagery'; + state.street.error = 'Open the Street Level panel to view imagery'; notify(); return; } - if (!keepFeature) { - parts.features.clearSelection(); - state.street.feature = null; - } await parts.viewer.open(imageId, host); }, - async openFeature(featureId) { - const row = parts.features.findRow(featureId); - parts.features.select(featureId); - state.street.loading = true; - state.street.error = null; - state.street.feature = row - ? { - id: featureId, - value: row[3], - label: humanizeValue(row[3]), - firstSeen: row[4], - lastSeen: row[5], - } - : { id: featureId }; - notify(); - try { - const detail = await source.getMapFeature(featureId); - const images = detail?.images?.data || []; - state.street.feature = { - ...state.street.feature, - value: detail.object_value, - label: humanizeValue(detail.object_value), - firstSeen: - Date.parse(detail.first_seen_at) || state.street.feature.firstSeen, - lastSeen: - Date.parse(detail.last_seen_at) || state.street.feature.lastSeen, - imageCount: images.length, - }; - if (!images.length) throw new Error('No image detected this feature'); - await parts.street.openImage(images[0].id, { keepFeature: true }); - await parts.viewer.highlightDetections( - images[0].id, - detail.object_value, - humanizeValue(detail.object_value), - ); - } catch (error) { - state.street.error = error?.message || 'Feature detail unavailable'; - state.street.loading = false; - notify(); - } - }, }; /** Run low-priority work when the browser is idle (or soon, headless). */ @@ -176,13 +123,24 @@ export function createMapillaryLayer({ source, services = {} }) { state.credit = null; } + /** Centre of the visible ground, or the camera's own footprint. */ + function viewCentre() { + const viewer = state.viewer; + if (!viewer) return null; + const bbox = visibleBbox(viewer); + if (bbox) + return { lat: (bbox[1] + bbox[3]) / 2, lon: (bbox[0] + bbox[2]) / 2 }; + const carto = viewer.camera.positionCartographic; + return { + lat: Cesium.Math.toDegrees(carto.latitude), + lon: Cesium.Math.toDegrees(carto.longitude), + }; + } + function getUIState() { return { enabled: state.enabled, keyRequired: state.keyRequired, - // null until the status call answers (the layer initialises lazily). - planner: state.status ? state.status.planner === true : null, - plannerModel: state.status?.plannerModel || null, coverage: { zoom: state.coverage.zoom, kind: state.coverage.kind, @@ -198,19 +156,6 @@ export function createMapillaryLayer({ source, services = {} }) { images: state.sequence.images.length, loading: state.sequence.loading, }, - features: { - total: state.features.total, - title: state.features.title, - loading: state.features.loading, - progress: { ...state.features.progress }, - counts: groupCountsByLabel(state.features.counts.entries()), - truncated: state.features.truncated, - failed: state.features.failed, - iconMode: state.features.iconMode, - hasBbox: Boolean(state.features.bbox), - selectedId: state.features.selectedId, - bbox: state.features.bbox ? [...state.features.bbox] : null, - }, street: { open: state.street.open, follow: state.street.follow, @@ -224,24 +169,6 @@ export function createMapillaryLayer({ source, services = {} }) { sequenceId: state.street.sequenceId, creator: state.street.creator || null, renderMode: state.street.renderMode, - feature: state.street.feature ? { ...state.street.feature } : null, - highlight: state.street.highlight - ? { ...state.street.highlight } - : null, - }, - query: { - busy: state.query.busy, - stage: state.query.stage, - prompt: state.query.prompt, - place: state.query.place, - answer: state.query.answer, - error: state.query.error, - title: state.query.title || '', - usage: state.query.usage, - history: state.query.history.map(({ prompt, total }) => ({ - prompt, - total, - })), }, }; } @@ -261,9 +188,7 @@ export function createMapillaryLayer({ source, services = {} }) { state.viewer = viewer; state.initialized = true; parts.sequences.ensureCollections(viewer); - parts.features.ensureCollections(viewer); parts.sequences.setVisible(false); - parts.features.setVisible(false); refreshStatus(); console.log('[Data:Mapillary] Initialized'); }, @@ -272,7 +197,6 @@ export function createMapillaryLayer({ source, services = {} }) { state.enabled = true; presentCredit(viewer); parts.sequences.setVisible(true); - parts.features.setVisible(true); parts.selection.install(viewer); parts.coverage.attach(viewer); if (!state.status) refreshStatus(); @@ -283,16 +207,12 @@ export function createMapillaryLayer({ source, services = {} }) { disable() { state.enabled = false; hideCredit(state.viewer); - parts.query.abort(); - parts.features.clearSelection(); - parts.coverage.setResting(false); parts.coverage.detach(); parts.coverage.clear(); parts.sequences.clearSelection(); parts.selection.uninstall(); parts.viewer.destroy(); parts.sequences.setVisible(false); - parts.features.setVisible(false); notify(); }, @@ -302,7 +222,6 @@ export function createMapillaryLayer({ source, services = {} }) { destroy(viewer = state.viewer) { layer.disable(); - parts.features.destroy(viewer); parts.sequences.destroy(viewer); state.listeners.clear(); state.viewer = null; @@ -311,34 +230,22 @@ export function createMapillaryLayer({ source, services = {} }) { }, getStats() { - const loading = - state.coverage.loading > 0 || - state.features.loading || - state.query.busy; let loadingLabel = ''; if (state.keyRequired) loadingLabel = 'KEY REQUIRED'; - else if (state.query.busy) loadingLabel = `AI · ${state.query.stage}`; - else if (state.features.loading) - loadingLabel = `tiles ${state.features.progress.done}/${state.features.progress.tiles}`; else if (state.coverage.loading > 0) loadingLabel = 'loading coverage...'; - else if (state.features.total) - loadingLabel = `${state.features.total.toLocaleString()} features`; else if (state.coverage.hint && state.enabled) loadingLabel = state.coverage.hint; return { - count: state.features.total || parts.coverage.sequenceCount(), + count: parts.coverage.sequenceCount(), sequences: parts.coverage.sequenceCount(), - features: state.features.total, - loading, + loading: state.coverage.loading > 0, keyRequired: state.keyRequired, - error: state.keyRequired - ? 'KEY REQUIRED' - : state.coverage.lastError || state.query.error, + error: state.keyRequired ? 'KEY REQUIRED' : state.coverage.lastError, loadingLabel, }; }, - // ── Public surface used by the panel and the voice/analyst seams ───── + // ── Public surface used by the panel ──────────────────────────────── subscribe(listener) { if (typeof listener !== 'function') return () => {}; state.listeners.add(listener); @@ -352,25 +259,15 @@ export function createMapillaryLayer({ source, services = {} }) { if (element && state.enabled) scheduleIdle(() => parts.viewer.prewarm(element)); }, - runQuery: (prompt) => parts.query.run(prompt), - /** Stop a running query (planning, geocoding or streaming). */ - abortQuery: () => parts.query.abort(), /** Imagery filter for coverage, cones and nearest-image lookups. */ setCoverageFilter(next) { parts.coverage.setFilter(next); parts.sequences.rerender(); }, getCoverageFilter: () => ({ ...state.coverage.filter }), - selectFeature: (id) => parts.features.select(id), - clearFeatureSelection: () => parts.features.clearSelection(), - /** Execute a ready-made plan (voice agent, tests) without the planner. */ - runPlan: (plan, options) => parts.query.runPlan(plan, options), - clearQuery: () => parts.query.clear(), - frameResults: () => parts.features.frame(), openImage: (imageId) => parts.street.openImage(imageId), - openFeature: (featureId) => parts.street.openFeature(featureId), async openNearest(point) { - const view = point || parts.query.viewContext(); + const view = point || viewCentre(); if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon)) return false; state.street.loading = true; @@ -380,12 +277,12 @@ export function createMapillaryLayer({ source, services = {} }) { const images = await source.nearestImages({ lat: view.lat, lon: view.lon, - radius: 50, - limit: 5, + radius: NEAREST_RADIUS_M, + limit: NEAREST_LIMIT, }); if (!images.length) throw new Error( - 'No Mapillary imagery within 50 m of the view centre', + `No Mapillary imagery within ${NEAREST_RADIUS_M} m of the view centre`, ); await parts.street.openImage(images[0].id); return true; @@ -398,11 +295,10 @@ export function createMapillaryLayer({ source, services = {} }) { }, /** * Close the image and deselect it everywhere on the map: position - * marker, highlighted sequence and its cones, result ring and outline. + * marker, highlighted sequence and its cones. */ closeViewer() { parts.viewer.close(); - parts.features.clearSelection(); parts.sequences.clearSelection(); }, setViewerRenderMode: (mode) => parts.viewer.setRenderMode(mode), @@ -412,25 +308,6 @@ export function createMapillaryLayer({ source, services = {} }) { selectSequence: (id) => parts.sequences.select(id), clearSequence: () => parts.sequences.clearSelection(), refreshCoverage: () => parts.coverage.refresh(), - - /** Result rows for the analyst query engine: plain JSON-safe objects. */ - getAnalystRecords(maxCount = 2000) { - const limit = Number.isFinite(maxCount) - ? Math.max(1, Math.floor(maxCount)) - : 2000; - return state.features.rows - .slice(0, limit) - .map(([lon, lat, id, value, first, last]) => ({ - id, - type: 'mapillary-feature', - value, - label: humanizeValue(value), - lat, - lon, - firstSeen: first ? new Date(first).toISOString() : null, - lastSeen: last ? new Date(last).toISOString() : null, - })); - }, }; return layer; } diff --git a/src/layers/mapillary/ndjson.js b/src/layers/mapillary/ndjson.js deleted file mode 100644 index 76bc6049b..000000000 --- a/src/layers/mapillary/ndjson.js +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Incremental NDJSON parsing for the streamed feature query. The server - * writes one JSON object per line; chunks arrive split at arbitrary byte - * boundaries, so a parser keeps the unfinished tail between pushes. - */ -export function createNdjsonParser(onRecord, onError = () => {}) { - let tail = ''; - const emitLine = (line) => { - const text = line.trim(); - if (!text) return; - try { - onRecord(JSON.parse(text)); - } catch (error) { - onError(error, text); - } - }; - return { - /** Feed one decoded text chunk. */ - push(chunk) { - tail += chunk; - let index; - while ((index = tail.indexOf('\n')) >= 0) { - emitLine(tail.slice(0, index)); - tail = tail.slice(index + 1); - } - }, - /** Flush a final line that had no trailing newline. */ - end() { - if (tail) emitLine(tail); - tail = ''; - }, - }; -} - -/** - * Read a fetch Response body as NDJSON, invoking `onRecord` per object. - * Resolves when the stream ends; rejects on network failure or abort. - */ -export async function readNdjsonResponse(response, onRecord, { signal } = {}) { - const parser = createNdjsonParser(onRecord); - const decoder = new TextDecoder(); - const reader = response.body?.getReader?.(); - if (!reader) { - parser.push(await response.text()); - parser.end(); - return; - } - const abort = () => reader.cancel().catch(() => {}); - signal?.addEventListener('abort', abort, { once: true }); - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - parser.push(decoder.decode(value, { stream: true })); - } - parser.push(decoder.decode()); - parser.end(); - } finally { - signal?.removeEventListener('abort', abort); - } -} diff --git a/src/layers/mapillary/ndjson.test.mjs b/src/layers/mapillary/ndjson.test.mjs deleted file mode 100644 index f5c81dd58..000000000 --- a/src/layers/mapillary/ndjson.test.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { createNdjsonParser } from './ndjson.js'; - -test('records split across chunks are reassembled in order', () => { - const seen = []; - const parser = createNdjsonParser((record) => seen.push(record)); - parser.push('{"type":"start","tiles":2}\n{"type":"ti'); - parser.push('le","i":0,"rows":[[1,2,"a","v",0,0]]}\n'); - parser.push('{"type":"done"}'); - parser.end(); - assert.deepEqual( - seen.map((record) => record.type), - ['start', 'tile', 'done'], - ); - assert.deepEqual(seen[1].rows, [[1, 2, 'a', 'v', 0, 0]]); -}); - -test('a malformed line is reported and skipped', () => { - const seen = []; - const errors = []; - const parser = createNdjsonParser( - (record) => seen.push(record), - (error, line) => errors.push(line), - ); - parser.push('not json\n{"ok":true}\n\n'); - parser.end(); - assert.deepEqual(seen, [{ ok: true }]); - assert.deepEqual(errors, ['not json']); -}); diff --git a/src/layers/mapillary/policy.js b/src/layers/mapillary/policy.js index 746bbc0c8..57a69ca21 100644 --- a/src/layers/mapillary/policy.js +++ b/src/layers/mapillary/policy.js @@ -7,8 +7,6 @@ export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com'; export const PICK_PREFIX = Object.freeze({ sequence: 'mly:seq:', image: 'mly:img:', - feature: 'mly:feat:', - object: 'mly:obj:', position: 'mly:pos', }); @@ -42,13 +40,6 @@ export const IMAGE_CONE_MIN_SPACING_M = 3; export const NEAREST_RADIUS_M = 50; export const NEAREST_LIMIT = 8; -/** Query-result rendering: clamped icons up to this many features, unclamped - * icons up to FEATURE_ICON_MAX, coloured points beyond. */ -export const FEATURE_ICON_LIMIT = 6000; -export const FEATURE_ICON_MAX = 80_000; -export const FEATURE_ICON_SIZE_PX = 30; -export const FEATURE_POINT_SIZE_PX = 9; - /** * On-globe credit shown while the layer is enabled. Mapillary imagery and * derived data are CC BY-SA 4.0 and require visible attribution. @@ -61,5 +52,3 @@ export const IMAGE_FIELDS = 'id,captured_at,compass_angle,computed_compass_angle,geometry,computed_geometry,computed_altitude,is_pano,sequence,thumb_256_url,creator,quality_score'; export const SEQUENCE_IMAGE_FIELDS = 'id,captured_at,compass_angle,geometry,is_pano,computed_altitude'; -export const FEATURE_FIELDS = - 'id,object_value,geometry,first_seen_at,last_seen_at,images,aligned_direction'; diff --git a/src/layers/mapillary/query.js b/src/layers/mapillary/query.js deleted file mode 100644 index 71059752c..000000000 --- a/src/layers/mapillary/query.js +++ /dev/null @@ -1,516 +0,0 @@ -import * as Cesium from 'cesium'; -import { passesImageryFilter, visibleBbox } from './coverage.js'; -import { countTilesForBbox, normalizeBbox } from './tileMath.js'; -import { groupCountsByLabel } from './values.js'; - -/** Fallback half-size (degrees) around a geocoded point with no bounds. */ -const POINT_PLACE_HALF_DEG = 0.02; -/** - * Smallest place box worth querying (degrees, ~1.1 km). Nominatim returns a - * node-sized box for many neighbourhoods and landmarks; a query on that would - * cover one intersection and read as "nothing here". - */ -const MIN_PLACE_SPAN_DEG = 0.01; -/** Radii (metres) of the probe rings used when nothing sits within 50 m. */ -const NEAREST_RING_M = [90, 180, 300]; -/** Cap for a "current view" query when the server does not report one. */ -const DEFAULT_MAX_TILES = 600; -const FEATURE_ZOOM = 14; -/** A current-view query wider than this is the horizon, not an area. */ -const MAX_VIEW_SPAN_DEG = 4; - -/** Shrink a bbox around its centre until it fits the tile budget. */ -export function fitBboxToTileBudget(bbox, maxTiles, zoom = FEATURE_ZOOM) { - let box = normalizeBbox(bbox); - if (!box) return null; - let guard = 0; - while ( - countTilesForBbox([box.west, box.south, box.east, box.north], zoom) > - maxTiles && - guard++ < 40 - ) { - const cx = (box.west + box.east) / 2; - const cy = (box.south + box.north) / 2; - const hw = ((box.east - box.west) / 2) * 0.85; - const hh = ((box.north - box.south) / 2) * 0.85; - box = { west: cx - hw, east: cx + hw, south: cy - hh, north: cy + hh }; - } - return [box.west, box.south, box.east, box.north]; -} - -/** Approximate metres between two lon/lat points (small distances). */ -function metresBetween(a, b) { - const lat = ((a.lat + b.lat) / 2) * (Math.PI / 180); - const dx = (b.lon - a.lon) * 111_320 * Math.cos(lat); - const dy = (b.lat - a.lat) * 110_540; - return Math.hypot(dx, dy); -} - -/** Grow a bbox symmetrically until both sides reach `minSpan` degrees. */ -export function enforceMinimumSpan(bbox, minSpan) { - const [west, south, east, north] = bbox; - if (east - west >= minSpan && north - south >= minSpan) return bbox; - const cx = (west + east) / 2; - const cy = (south + north) / 2; - const hw = Math.max((east - west) / 2, minSpan / 2); - const hh = Math.max((north - south) / 2, minSpan / 2); - return [cx - hw, cy - hh, cx + hw, cy + hh]; -} - -/** Turn a geocode result into a bbox, label and centre. */ -export function bboxFromGeocode(result) { - const geometry = result?.geometry; - const location = geometry?.location; - if ( - !location || - !Number.isFinite(location.lat) || - !Number.isFinite(location.lng) - ) - return null; - const bounds = geometry.bounds || geometry.viewport; - const bbox = bounds - ? [ - bounds.southwest.lng, - bounds.southwest.lat, - bounds.northeast.lng, - bounds.northeast.lat, - ] - : [ - location.lng - POINT_PLACE_HALF_DEG, - location.lat - POINT_PLACE_HALF_DEG, - location.lng + POINT_PLACE_HALF_DEG, - location.lat + POINT_PLACE_HALF_DEG, - ]; - return { - bbox: enforceMinimumSpan(bbox, MIN_PLACE_SPAN_DEG), - label: result.formatted_address || '', - center: { lat: location.lat, lon: location.lng }, - }; -} - -/** "1 tile" / "3 tiles". */ -function plural(count, singular, pluralForm = `${singular}s`) { - return `${count.toLocaleString()} ${count === 1 ? singular : pluralForm}`; -} - -/** Pluralise a humanised class label for a count ("fire hydrant" → "fire hydrants"). */ -export function pluralLabel(label, count) { - const word = String(label || '').toLowerCase(); - if (count === 1 || !word) return word; - if (/(s|x|z|ch|sh)$/.test(word)) return `${word}es`; - if (/[^aeiou]y$/.test(word)) return `${word.slice(0, -1)}ies`; - return `${word}s`; -} - -/** - * Compose the one-line result summary shown under the query box. Returns an - * empty string for zero results so the caller can replace, not append to, - * the planner's optimistic answer. - */ -export function summarizeResults(features) { - const total = features.total || 0; - if (!total) return ''; - const values = groupCountsByLabel(features.counts.entries()); - const classes = values - .slice(0, 3) - .map( - ({ label, count }) => - `${count.toLocaleString()} ${pluralLabel(label, count)}`, - ) - .join(', '); - const more = - values.length > 3 - ? ` and ${plural(values.length - 3, 'more class', 'more classes')}` - : ''; - const years = - features.earliest && features.latest - ? ` · seen ${new Date(features.earliest).getFullYear()}–${new Date(features.latest).getFullYear()}` - : ''; - const truncated = features.truncated ? ' · capped' : ''; - const failed = features.failed - ? ` · ${plural(features.failed, 'tile')} failed` - : ''; - // One class: "834 fire hydrants · seen 2017–2021". Several: lead with the total. - const head = - values.length === 1 - ? classes - : `${plural(total, 'feature')}: ${classes}${more}`; - return `${head}${years}${truncated}${failed}`; -} - -/** What the panel says when a query found nothing where it looked. */ -export function noResultsAnswer(plan, area) { - const subject = (plan?.title || 'matching features') - .replace(/\s*·.*$/, '') - .toLowerCase(); - const where = area?.label ? ` in ${area.label}` : ' here'; - return `No ${subject} mapped${where} yet. Try a larger area, another place, or set SINCE back to any date.`; -} - -/** Natural-language query orchestration: plan → resolve area → stream → draw. */ -export function createQuery({ state, source, parts }) { - function notify() { - state.notify?.(); - } - - function viewContext() { - const viewer = state.viewer; - if (!viewer) return {}; - const carto = viewer.camera.positionCartographic; - const bbox = visibleBbox(viewer); - const center = bbox - ? { lat: (bbox[1] + bbox[3]) / 2, lon: (bbox[0] + bbox[2]) / 2 } - : { - lat: Cesium.Math.toDegrees(carto.latitude), - lon: Cesium.Math.toDegrees(carto.longitude), - }; - const ground = viewer.scene.globe?.getHeight?.(carto); - return { - lat: center.lat, - lon: center.lon, - heightM: carto.height - (Number.isFinite(ground) ? ground : 0), - }; - } - - function maxTiles() { - return state.status?.limits?.maxTiles || DEFAULT_MAX_TILES; - } - - async function resolveArea(plan, signal) { - if (plan.place) { - const results = await source.geocode(plan.place, { signal }); - const resolved = bboxFromGeocode(results[0]); - if (!resolved) - throw new Error(`Could not find "${plan.place}" on the map`); - return { - ...resolved, - bbox: fitBboxToTileBudget(resolved.bbox, maxTiles()), - fromPlace: true, - }; - } - const bbox = visibleBbox(state.viewer); - // The horizon (or a camera still in flight) yields a continent-sized - // rectangle; querying that would silently pick a box in the middle of it. - if ( - !bbox || - bbox[2] - bbox[0] > MAX_VIEW_SPAN_DEG || - bbox[3] - bbox[1] > MAX_VIEW_SPAN_DEG - ) - throw new Error('Zoom in closer to the ground, or name a place'); - const view = viewContext(); - return { - bbox: fitBboxToTileBudget(bbox, maxTiles()), - label: 'current view', - center: { lat: view.lat, lon: view.lon }, - fromPlace: false, - }; - } - - async function runFeatures(plan, area, signal) { - const features = state.features; - parts.features.reset(); - Object.assign(features, { - bbox: area.bbox, - title: plan.title, - layer: plan.layer, - values: plan.values, - loading: true, - }); - state.query.stage = 'fetching'; - notify(); - // Results are the subject now: rest the coverage web until CLEAR so tens - // of thousands of markers are not read against a web of green lines. - parts.coverage.setResting(true); - // A named place is somewhere else: fly there. The current view already - // shows the area, so leave the camera where the user put it. - if (area.fromPlace) parts.features.frame(area.bbox); - let lastNotify = 0; - await source.queryFeatures( - { - bbox: area.bbox, - layer: plan.layer, - values: plan.values, - seenAfter: plan.seen_after, - seenBefore: plan.seen_before, - }, - (record) => { - if (record.type === 'start') - features.progress = { done: 0, tiles: record.tiles }; - else if (record.type === 'tile') { - features.progress.done++; - if (record.rows?.length) parts.features.appendRows(record.rows); - const now = Date.now(); - if (now - lastNotify > 120) { - lastNotify = now; - notify(); - } - } else if (record.type === 'done') { - features.truncated = record.truncated === true; - features.failed = record.failed || 0; - } - }, - { signal }, - ); - features.loading = false; - notify(); - await parts.features.upgradeToIcons(); - const summary = summarizeResults(features); - state.query.answer = summary - ? `${plan.answer} ${summary}`.trim() - : noResultsAnswer(plan, area); - notify(); - } - - async function runNearest(plan, area, signal) { - const found = await nearestImageAround(area.center, signal, { - preferPano: - plan.prefer_pano === true || state.coverage.filter.pano === 'pano', - }); - if (!found) - throw new Error( - `No Mapillary imagery within ${NEAREST_RING_M[NEAREST_RING_M.length - 1] + 50} m of ${area.label || 'that point'}`, - ); - await parts.street.openImage(found.image.id); - state.query.answer = `${plan.answer} Opened the closest image, about ${Math.round(found.distanceM)} m away.`; - } - - /** - * Mapillary's radius search stops at 50 m, which misses a landmark whose - * geocoded centre sits inside a large footprint. Probe the centre first, - * then rings of points around it, and keep the closest hit overall. - */ - async function nearestImageAround( - center, - signal, - { preferPano = false } = {}, - ) { - const rings = [[center]]; - for (const ring of NEAREST_RING_M) { - const probes = []; - for (let k = 0; k < 8; k++) { - const angle = (k / 8) * Math.PI * 2; - probes.push({ - lat: center.lat + (ring * Math.cos(angle)) / 110_540, - lon: - center.lon + - (ring * Math.sin(angle)) / - (111_320 * Math.cos((center.lat * Math.PI) / 180)), - }); - } - rings.push(probes); - } - for (const probes of rings) { - const results = await Promise.all( - probes.map((probe) => - source - .nearestImages( - { lat: probe.lat, lon: probe.lon, radius: 50, limit: 3 }, - { signal }, - ) - .catch(() => []), - ), - ); - let best = null; - let fallback = null; - for (const images of results) - for (const image of images) { - const coordinates = - image?.computed_geometry?.coordinates || - image?.geometry?.coordinates; - if (!coordinates) continue; - const distanceM = metresBetween(center, { - lon: coordinates[0], - lat: coordinates[1], - }); - const record = { - isPano: image.is_pano === true, - capturedAt: Number(image.captured_at) || 0, - }; - // The dock's imagery filter is a hard constraint; a 360° preference - // from the request only breaks ties within the filtered set. - const candidate = { - image, - distanceM, - score: distanceM + (preferPano && !record.isPano ? 1000 : 0), - }; - if (!fallback || distanceM < fallback.distanceM) fallback = candidate; - if (!passesImageryFilter(record, state.coverage.filter)) continue; - if (!best || candidate.score < best.score) best = candidate; - } - if (best) return best; - if ( - fallback && - state.coverage.filter.pano === 'all' && - !state.coverage.filter.sinceMs - ) - return fallback; - } - return null; - } - - /** - * Execute an already-built plan (from the planner, the voice agent, or a - * test). Resolves the area, streams features or opens imagery, and records - * the turn in the history. - */ - async function execute(plan, { prompt = plan?.title || '', signal } = {}) { - if (!plan || typeof plan !== 'object') - throw new Error('A plan is required'); - state.query.lastPlan = plan; - state.query.answer = plan.answer || ''; - state.query.title = plan.title || ''; - notify(); - if (plan.intent === 'unsupported') { - state.query.stage = 'idle'; - } else if (plan.intent === 'coverage') { - state.query.stage = 'resolving'; - notify(); - const area = await resolveArea(plan, signal); - state.query.place = area?.label || plan.place || null; - parts.features.frame(area.bbox, { duration: 2.4 }); - state.query.stage = 'idle'; - } else if (plan.intent === 'nearest_image') { - state.query.stage = 'resolving'; - notify(); - const area = await resolveArea(plan, signal); - state.query.place = area?.label || plan.place || null; - await runNearest(plan, area, signal); - state.query.stage = 'idle'; - } else { - if (!plan.layer) plan = { ...plan, layer: 'points' }; - state.query.stage = 'resolving'; - notify(); - const area = await resolveArea(plan, signal); - state.query.place = area?.label || plan.place || null; - await runFeatures(plan, area, signal); - state.query.stage = 'done'; - } - state.query.history.push({ prompt, plan, total: state.features.total }); - } - - /** Run one natural-language request end to end. */ - async function run(prompt) { - const text = String(prompt || '').trim(); - if (!text) return; - abort(); - const controller = new AbortController(); - state.query.abort = controller; - Object.assign(state.query, { - busy: true, - stage: 'planning', - prompt: text, - answer: '', - error: null, - usage: null, - }); - notify(); - try { - const { plan, usage, model } = await source.plan( - { - query: text, - context: { view: viewContext(), previousPlan: state.query.lastPlan }, - }, - { signal: controller.signal }, - ); - if (controller.signal.aborted) return; - state.query.usage = usage ? { ...usage, model } : null; - await execute(plan, { prompt: text, signal: controller.signal }); - } catch (error) { - if (controller.signal.aborted) return; - state.features.loading = false; - state.query.stage = 'error'; - state.query.error = describeError(error); - } finally { - if (state.query.abort === controller) { - state.query.busy = false; - state.query.abort = null; - } - notify(); - } - } - - /** Execute a supplied plan with the same lifecycle bookkeeping as run(). */ - async function runPlan(plan, { prompt } = {}) { - abort(); - const controller = new AbortController(); - state.query.abort = controller; - Object.assign(state.query, { - busy: true, - stage: 'resolving', - prompt: prompt || plan?.title || '', - answer: '', - error: null, - usage: null, - }); - notify(); - try { - await execute(plan, { prompt, signal: controller.signal }); - } catch (error) { - if (controller.signal.aborted) return; - state.features.loading = false; - state.query.stage = 'error'; - state.query.error = describeError(error); - } finally { - if (state.query.abort === controller) { - state.query.busy = false; - state.query.abort = null; - } - notify(); - } - } - - function describeError(error) { - if ( - error?.keyRequired === 'anthropic' || - error?.payload?.keyRequired === 'anthropic' - ) - return 'Mapillary AI needs an Anthropic key — add one in POWER UP.'; - if ( - error?.keyRequired === 'mapillary' || - error?.payload?.keyRequired === 'mapillary' || - error?.payload?.keyRequired === true - ) - return 'Add a Mapillary client token in POWER UP first.'; - if (error?.payload?.error === 'area_too_large') { - const { tiles, limit } = error.payload.detail || error.payload; - const times = - Number.isFinite(tiles) && Number.isFinite(limit) && limit > 0 - ? ` (about ${Math.max(2, Math.round(tiles / limit))}× what one query covers)` - : ''; - return `That area is too large for one query${times}. Zoom in closer to the ground or name a smaller place.`; - } - return error?.message || 'Query failed'; - } - - function abort() { - state.query.abort?.abort(); - state.query.abort = null; - state.query.busy = false; - state.features.loading = false; - } - - /** Drop the drawn results and the conversational context. */ - function clear() { - abort(); - parts.features.reset(); - parts.coverage.setResting(false); - Object.assign(state.query, { - stage: 'idle', - prompt: '', - answer: '', - error: null, - lastPlan: null, - usage: null, - title: '', - }); - Object.assign(state.features, { - bbox: null, - title: '', - layer: null, - values: [], - }); - notify(); - } - - return { run, runPlan, execute, clear, abort, viewContext }; -} diff --git a/src/layers/mapillary/query.test.mjs b/src/layers/mapillary/query.test.mjs deleted file mode 100644 index 07d7240c8..000000000 --- a/src/layers/mapillary/query.test.mjs +++ /dev/null @@ -1,114 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { - bboxFromGeocode, - enforceMinimumSpan, - fitBboxToTileBudget, - noResultsAnswer, - pluralLabel, - summarizeResults, -} from './query.js'; -import { countTilesForBbox } from './tileMath.js'; - -test('bboxFromGeocode prefers bounds and falls back to a point box', () => { - const withBounds = bboxFromGeocode({ - geometry: { - location: { lat: 38.58, lng: -121.49 }, - bounds: { - southwest: { lat: 38.44, lng: -121.56 }, - northeast: { lat: 38.69, lng: -121.36 }, - }, - }, - formatted_address: 'Sacramento, California', - }); - assert.deepEqual(withBounds.bbox, [-121.56, 38.44, -121.36, 38.69]); - assert.equal(withBounds.label, 'Sacramento, California'); - const point = bboxFromGeocode({ - geometry: { location: { lat: 42.33, lng: -83.05 } }, - }); - assert.ok(point.bbox[0] < -83.05 && point.bbox[2] > -83.05); - assert.equal(bboxFromGeocode(null), null); -}); - -test('fitBboxToTileBudget shrinks an oversized box around its centre', () => { - const california = [-125, 32, -114, 42]; - const fitted = fitBboxToTileBudget(california, 600); - assert.ok(countTilesForBbox(fitted, 14) <= 600); - const cx = (fitted[0] + fitted[2]) / 2; - assert.ok(Math.abs(cx - -119.5) < 1e-6); - const small = [-121.5, 38.5, -121.4, 38.6]; - assert.deepEqual(fitBboxToTileBudget(small, 600), small); -}); - -test('summarizeResults reports totals, top classes and the sighting window', () => { - const text = summarizeResults({ - total: 1234, - counts: new Map([ - ['object--fire-hydrant', 1000], - ['object--bench', 200], - ['object--mailbox', 20], - ['object--trash-can', 14], - ]), - earliest: Date.UTC(2019, 0, 1), - latest: Date.UTC(2026, 5, 1), - truncated: true, - failed: 2, - }); - assert.match( - text, - /^1,234 features: 1,000 fire hydrants, 200 benches, 20 mailboxes and 1 more class/, - ); - assert.match(text, /seen 2019–2026 · capped · 2 tiles failed$/); -}); - -test('summarizeResults leads with the class when there is only one', () => { - const text = summarizeResults({ - total: 834, - counts: new Map([['object--fire-hydrant', 834]]), - earliest: Date.UTC(2017, 0, 1), - latest: Date.UTC(2021, 0, 1), - failed: 1, - }); - assert.equal(text, '834 fire hydrants · seen 2017–2021 · 1 tile failed'); - assert.equal( - summarizeResults({ total: 1, counts: new Map([['object--bench', 1]]) }), - '1 bench', - ); -}); - -test('zero results produce no summary so the answer can be replaced', () => { - assert.equal(summarizeResults({ total: 0, counts: new Map() }), ''); - assert.equal( - noResultsAnswer( - { title: 'Fire hydrants · Springfield' }, - { label: 'Springfield, IL' }, - ), - 'No fire hydrants mapped in Springfield, IL yet. Try a larger area, another place, or set SINCE back to any date.', - ); - assert.match( - noResultsAnswer({}, null), - /^No matching features mapped here yet/, - ); -}); - -test("pluralLabel handles the taxonomy's English labels", () => { - assert.equal(pluralLabel('Fire hydrant', 2), 'fire hydrants'); - assert.equal(pluralLabel('Bench', 3), 'benches'); - assert.equal(pluralLabel('Mailbox', 4), 'mailboxes'); - assert.equal(pluralLabel('Bench', 1), 'bench'); -}); - -test('node-sized geocode bounds are grown to a queryable neighbourhood box', () => { - const tiny = bboxFromGeocode({ - geometry: { - location: { lat: 42.3292, lng: -83.0527 }, - bounds: { - southwest: { lat: 42.329, lng: -83.053 }, - northeast: { lat: 42.3294, lng: -83.0524 }, - }, - }, - }); - assert.ok(tiny.bbox[2] - tiny.bbox[0] >= 0.01 - 1e-9); - assert.ok(tiny.bbox[3] - tiny.bbox[1] >= 0.01 - 1e-9); - assert.deepEqual(enforceMinimumSpan([0, 0, 1, 1], 0.5), [0, 0, 1, 1]); -}); diff --git a/src/layers/mapillary/selection.js b/src/layers/mapillary/selection.js index 1c3ab24a8..5494f8031 100644 --- a/src/layers/mapillary/selection.js +++ b/src/layers/mapillary/selection.js @@ -1,7 +1,7 @@ import * as Cesium from 'cesium'; import { MAPILLARY_LAYER_ID, PICK_PREFIX } from './policy.js'; -/** Click handling for coverage lines, image cones and query results. */ +/** Click handling for coverage lines and image cones. */ export function createSelection({ state, parts }) { const { picking, input } = state.services; @@ -25,11 +25,6 @@ export function createSelection({ state, parts }) { parts.sequences.select(id.slice(PICK_PREFIX.sequence.length)); } else if (id.startsWith(PICK_PREFIX.image)) { parts.street.openImage(id.slice(PICK_PREFIX.image.length)); - } else if (id.startsWith(PICK_PREFIX.feature)) { - parts.street.openFeature(id.slice(PICK_PREFIX.feature.length)); - } else if (id.startsWith(PICK_PREFIX.object)) { - // 3D object entities are id'd `mly:obj::`. - parts.street.openFeature(id.split(':')[2]); } } diff --git a/src/layers/mapillary/source.js b/src/layers/mapillary/source.js index c8043b163..0cc602ded 100644 --- a/src/layers/mapillary/source.js +++ b/src/layers/mapillary/source.js @@ -1,6 +1,4 @@ -import { readNdjsonResponse } from './ndjson.js'; import { - FEATURE_FIELDS, IMAGE_FIELDS, MAPILLARY_GRAPH_HOST, NEAREST_LIMIT, @@ -38,10 +36,10 @@ async function readJsonOrThrow(response, label) { } /** - * Every network path the Mapillary layer uses. Tiles, the feature stream, - * the planner and sprites go through the local dev-server proxy; entity - * lookups go straight to graph.mapillary.com with the client token, exactly - * as the embedded MapillaryJS viewer does. + * Every network path the Mapillary layer uses. Coverage tiles go through the + * local dev-server proxy (which adds the token and caches them); image and + * sequence lookups go straight to graph.mapillary.com with the client token, + * exactly as the embedded MapillaryJS viewer does. * @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options */ export function createMapillarySource({ @@ -52,10 +50,6 @@ export function createMapillarySource({ const urls = { status: '/api/mapillary/status', tiles: '/api/mapillary/tiles', - features: '/api/mapillary/features', - plan: '/api/mapillary/plan', - sprite: '/api/mapillary/sprite', - geocode: '/api/geocode', graph: MAPILLARY_GRAPH_HOST, ...endpoints, }; @@ -114,51 +108,6 @@ export function createMapillarySource({ return new Uint8Array(await response.arrayBuffer()); }, - /** - * Stream a feature query. `onEvent` receives each NDJSON record - * ({type:'start'|'tile'|'done'}); resolves with the done record. - */ - async queryFeatures(body, onEvent, { signal } = {}) { - const response = await fetchImpl(urls.features, { - method: 'POST', - signal, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!response.ok) await readJsonOrThrow(response, 'Feature query'); - let done = null; - await readNdjsonResponse( - response, - (record) => { - if (record?.type === 'done') done = record; - onEvent?.(record); - }, - { signal }, - ); - return done; - }, - - /** Natural language → plan, via the server-side Claude planner. */ - async plan(body, { signal } = {}) { - const response = await fetchImpl(urls.plan, { - method: 'POST', - signal, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - return readJsonOrThrow(response, 'Planner'); - }, - - /** Place name → geocode results through the app's existing Nominatim route. */ - async geocode(query, { signal } = {}) { - const response = await fetchImpl( - `${urls.geocode}?${new URLSearchParams({ q: query })}`, - { signal }, - ); - const payload = await readJsonOrThrow(response, 'Geocode'); - return Array.isArray(payload?.results) ? payload.results : []; - }, - getImage(id, { signal, fields = IMAGE_FIELDS } = {}) { return graph(String(id), { fields }, { signal }); }, @@ -196,38 +145,5 @@ export function createMapillarySource({ ); return Array.isArray(payload?.data) ? payload.data : []; }, - - getMapFeature(id, { signal } = {}) { - return graph(String(id), { fields: FEATURE_FIELDS }, { signal }); - }, - - /** Batched entity lookup (`/?ids=`), up to 50 ids per call. */ - async getMapFeaturesBatch( - ids, - { signal, fields = 'id,object_value,aligned_direction' } = {}, - ) { - const list = [...new Set((ids || []).map(String))].slice(0, 50); - if (!list.length) return {}; - const payload = await graph( - '', - { ids: list.join(','), fields }, - { signal }, - ); - return payload && typeof payload === 'object' ? payload : {}; - }, - - /** Detections (segmentation polygons + values) inside one image. */ - async getImageDetections(imageId, { signal } = {}) { - const payload = await graph( - `${String(imageId)}/detections`, - { fields: 'id,value,geometry,created_at' }, - { signal }, - ); - return Array.isArray(payload?.data) ? payload.data : []; - }, - - spriteUrl(value) { - return `${urls.sprite}/${encodeURIComponent(value)}.svg`; - }, }; } diff --git a/src/layers/mapillary/source.test.mjs b/src/layers/mapillary/source.test.mjs index f76a5d495..ec9f13923 100644 --- a/src/layers/mapillary/source.test.mjs +++ b/src/layers/mapillary/source.test.mjs @@ -96,7 +96,7 @@ test('graph API errors carry the upstream message and status', async () => { ); const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl }); await assert.rejects( - () => source.getMapFeature('999'), + () => source.getImage('999'), (error) => error instanceof MapillarySourceError && error.status === 400 && @@ -104,42 +104,8 @@ test('graph API errors carry the upstream message and status', async () => { ); }); -test('feature queries stream NDJSON records and resolve with the done record', async () => { - const lines = [ - JSON.stringify({ type: 'start', tiles: 2 }), - JSON.stringify({ - type: 'tile', - rows: [[-121.5, 38.5, 'a', 'object--bench', 0, 0]], - }), - JSON.stringify({ type: 'done', total: 1 }), - ].join('\n'); - const { calls, fetchImpl } = fakeFetch( - () => - new Response(lines, { - status: 200, - headers: { 'content-type': 'application/x-ndjson' }, - }), - ); - const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl }); - const seen = []; - const done = await source.queryFeatures( - { - bbox: [-121.5, 38.5, -121.4, 38.6], - layer: 'points', - values: ['object--bench'], - }, - (record) => seen.push(record.type), - ); - assert.deepEqual(seen, ['start', 'tile', 'done']); - assert.equal(done.total, 1); - assert.equal(calls[0].url, '/api/mapillary/features'); - assert.equal(calls[0].init.method, 'POST'); -}); - -test('sprite URLs are proxied per taxonomy value', () => { +test('the source exposes only imagery lookups', () => { const source = createMapillarySource({ token: 'MLY|1|abc' }); - assert.equal( - source.spriteUrl('object--fire-hydrant'), - '/api/mapillary/sprite/object--fire-hydrant.svg', - ); + for (const gone of ['queryFeatures', 'plan', 'geocode', 'spriteUrl']) + assert.equal(source[gone], undefined, gone); }); diff --git a/src/layers/mapillary/state.js b/src/layers/mapillary/state.js index f0115d217..11111f48b 100644 --- a/src/layers/mapillary/state.js +++ b/src/layers/mapillary/state.js @@ -29,7 +29,6 @@ export function createState({ services }) { removeCameraListener: null, terrainReady: null, hint: '', - resting: false, kind: null, filter: { pano: 'all', sinceMs: null }, }, @@ -44,31 +43,6 @@ export function createState({ services }) { abort: null, }, - features: { - collection: null, - points: null, - rows: [], - byId: new Map(), - counts: new Map(), - earliest: null, - latest: null, - bbox: null, - title: '', - layer: null, - values: [], - total: 0, - truncated: false, - failed: 0, - loading: false, - progress: { done: 0, tiles: 0 }, - abort: null, - iconMode: false, - iconsHidden: false, - selectedId: null, - billboardById: new Map(), - highlight: null, - }, - street: { bridge: null, container: null, @@ -89,19 +63,6 @@ export function createState({ services }) { renderMode: 'letterbox', }, - query: { - busy: false, - stage: 'idle', - prompt: '', - place: null, - answer: '', - error: null, - lastPlan: null, - history: [], - abort: null, - usage: null, - }, - clickHandler: null, }; } diff --git a/src/layers/mapillary/taxonomy.js b/src/layers/mapillary/taxonomy.js deleted file mode 100644 index e5fcbd9a9..000000000 --- a/src/layers/mapillary/taxonomy.js +++ /dev/null @@ -1,1605 +0,0 @@ -/** - * Mapillary map-feature taxonomy, derived from the class names in - * mapillary/mapillary_sprite_source (MIT). OBJECT_VALUES is the point-object - * layer (`mly_map_feature_point`, property `value`); SIGN_VALUES is the - * traffic-sign layer (`mly_map_feature_traffic_sign`). Sign values end in a - * regional group suffix (`--g1`, `--g3`); a wildcard such as - * `regulatory--stop--*` matches every regional variant of one sign. - * Regenerate from the sprite repository rather than editing by hand. - */ -export const OBJECT_VALUES = Object.freeze([ - 'construction--barrier--temporary', - 'construction--flat--crosswalk-plain', - 'construction--flat--driveway', - 'construction--flat--flat-driveway', - 'marking--discrete--arrow--left', - 'marking--discrete--arrow--right', - 'marking--discrete--arrow--split-left-or-straight', - 'marking--discrete--arrow--split-right-or-straight', - 'marking--discrete--arrow--straight', - 'marking--discrete--crosswalk-zebra', - 'marking--discrete--give-way-row', - 'marking--discrete--give-way-single', - 'marking--discrete--other-marking', - 'marking--discrete--stop-line', - 'marking--discrete--symbol--bicycle', - 'marking--discrete--text', - 'object--banner', - 'object--bench', - 'object--bike-rack', - 'object--catch-basin', - 'object--cctv-camera', - 'object--fire-hydrant', - 'object--junction-box', - 'object--mailbox', - 'object--manhole', - 'object--parking-meter', - 'object--phone-booth', - 'object--sign--advertisement', - 'object--sign--information', - 'object--sign--store', - 'object--street-light', - 'object--support--pole', - 'object--support--traffic-sign-frame', - 'object--support--utility-pole', - 'object--traffic-cone', - 'object--traffic-light--cyclists', - 'object--traffic-light--general-horizontal', - 'object--traffic-light--general-single', - 'object--traffic-light--general-upright', - 'object--traffic-light--other', - 'object--traffic-light--pedestrians', - 'object--trash-can', - 'object--water-valve', -]); - -export const SIGN_VALUES = Object.freeze([ - 'complementary--accident-area--g1', - 'complementary--accident-area--g2', - 'complementary--accident-area--g3', - 'complementary--accident-area--g4', - 'complementary--advisory-exit-or-ramp-speed--g1', - 'complementary--bicycles--g1', - 'complementary--bicycles-and-pedestrians-detour--g1', - 'complementary--bicycles-or-pedestrians-detour--g1', - 'complementary--bicycles-turn-right--g1', - 'complementary--bike-route--g1', - 'complementary--bike-route--g3', - 'complementary--both-directions--g1', - 'complementary--both-directions--g2', - 'complementary--buses--g1', - 'complementary--buses-and-trucks--g1', - 'complementary--camera--g1', - 'complementary--camera--g2', - 'complementary--camera--g3', - 'complementary--camera--g4', - 'complementary--camera--g5', - 'complementary--caravan-trailers--g2', - 'complementary--caravan-trailers--g3', - 'complementary--caravans--g2', - 'complementary--carts--g1', - 'complementary--chevron-left--g1', - 'complementary--chevron-left--g2', - 'complementary--chevron-left--g3', - 'complementary--chevron-left--g4', - 'complementary--chevron-left--g5', - 'complementary--chevron-right--g1', - 'complementary--chevron-right--g2', - 'complementary--chevron-right--g3', - 'complementary--chevron-right--g4', - 'complementary--chevron-right--g5', - 'complementary--dangerous-or-pollutant-good--g1', - 'complementary--dead-end--g1', - 'complementary--detour--g1', - 'complementary--disabled-persons--g1', - 'complementary--distance--g1', - 'complementary--distance--g2', - 'complementary--distance--g3', - 'complementary--end-of-road-works--g1', - 'complementary--except-bicycles--g1', - 'complementary--except-bicycles--g2', - 'complementary--except-buses--g1', - 'complementary--except-carts--g1', - 'complementary--except-motorcycles--g1', - 'complementary--except-motorcycles--g2', - 'complementary--except-polluting-level-green--g1', - 'complementary--except-polluting-level-green-yellow--g1', - 'complementary--except-polluting-level-green-yellow-red--g1', - 'complementary--except-tractors--g1', - 'complementary--except-tractors--g2', - 'complementary--except-trailers--g1', - 'complementary--except-trailers--g2', - 'complementary--except-trains--g1', - 'complementary--except-trams--g1', - 'complementary--except-trucks--g1', - 'complementary--except-vehicles--g1', - 'complementary--except-vehicles--g2', - 'complementary--go-left--g1', - 'complementary--go-right--g1', - 'complementary--go-straight-or-turn-left--g1', - 'complementary--go-straight-or-turn-right--g1', - 'complementary--height-limit--g1', - 'complementary--height-limit--g2', - 'complementary--including-bicycles-and-motorcycles--g1', - 'complementary--including-buses-vehicles--g1', - 'complementary--keep-left--g1', - 'complementary--keep-right--g1', - 'complementary--lane-control--g1', - 'complementary--lane-control--g2', - 'complementary--lane-control--g3', - 'complementary--maximum-speed-limit-10--g1', - 'complementary--maximum-speed-limit-15--g1', - 'complementary--maximum-speed-limit-20--g1', - 'complementary--maximum-speed-limit-25--g1', - 'complementary--maximum-speed-limit-30--g1', - 'complementary--maximum-speed-limit-35--g1', - 'complementary--maximum-speed-limit-40--g1', - 'complementary--maximum-speed-limit-45--g1', - 'complementary--maximum-speed-limit-50--g1', - 'complementary--maximum-speed-limit-55--g1', - 'complementary--maximum-speed-limit-60--g1', - 'complementary--maximum-speed-limit-65--g1', - 'complementary--maximum-speed-limit-70--g1', - 'complementary--maximum-speed-limit-75--g1', - 'complementary--maximum-speed-limit-80--g1', - 'complementary--maximum-speed-limit-85--g1', - 'complementary--maximum-speed-limit-90--g1', - 'complementary--maximum-speed-limit-95--g1', - 'complementary--motorcycles--g1', - 'complementary--motorcycles--g2', - 'complementary--motorcycles--g3', - 'complementary--motorcycles--g4', - 'complementary--obstacle-delineator--g1', - 'complementary--obstacle-delineator--g2', - 'complementary--obstacle-delineator--g3', - 'complementary--one-direction-left--g1', - 'complementary--one-direction-right--g1', - 'complementary--pass-left--g1', - 'complementary--pass-right--g1', - 'complementary--pedestrians-and-bicycles--g1', - 'complementary--pedestrians-left--g1', - 'complementary--pedestrians-right--g1', - 'complementary--photo-enforced--g1', - 'complementary--playground--g1', - 'complementary--priority-route-at-intersection--g1', - 'complementary--priority-route-at-intersection--g2', - 'complementary--priority-route-at-intersection--g3', - 'complementary--priority-route-at-intersection--g4', - 'complementary--priority-route-at-intersection--g5', - 'complementary--priority-route-at-intersection--g6', - 'complementary--railroad--g1', - 'complementary--railroad--g2', - 'complementary--railroad--g3', - 'complementary--restriction-in-both-directions--g1', - 'complementary--roundabout-go-left--g1', - 'complementary--roundabout-go-right--g1', - 'complementary--roundabout-go-straight--g1', - 'complementary--slippery-for-caravan-trailers--g1', - 'complementary--snow--g2', - 'complementary--snow--g4', - 'complementary--snow--g5', - 'complementary--snowmobiles--g1', - 'complementary--soft-shoulder--g1', - 'complementary--soft-shoulder--g2', - 'complementary--steep-ascent--g1', - 'complementary--steep-descent--g1', - 'complementary--time-restrictions--g1', - 'complementary--time-restrictions--g3', - 'complementary--tow-away-zone--g1', - 'complementary--tow-away-zone--g3', - 'complementary--tractors--g1', - 'complementary--traffic-queues--g1', - 'complementary--trailers--g1', - 'complementary--trailers--g2', - 'complementary--trailers--g3', - 'complementary--trailers--g4', - 'complementary--trains--g1', - 'complementary--trams--g1', - 'complementary--trees--g1', - 'complementary--trucks--g1', - 'complementary--trucks--g2', - 'complementary--trucks--g3', - 'complementary--trucks-and-trailers--g1', - 'complementary--trucks-buses-trailers--g1', - 'complementary--trucks-go-left--g1', - 'complementary--trucks-go-left-ahead--g1', - 'complementary--trucks-go-right--g1', - 'complementary--trucks-go-right-ahead--g1', - 'complementary--trucks-go-straight--g1', - 'complementary--trucks-turn-left--g1', - 'complementary--trucks-turn-right--g1', - 'complementary--turn-left--g1', - 'complementary--turn-left--g2', - 'complementary--turn-right--g1', - 'complementary--turn-right--g2', - 'complementary--two-way-traffic--g1', - 'complementary--two-way-traffic--g2', - 'complementary--two-way-traffic--g3', - 'complementary--two-way-traffic--g4', - 'complementary--two-way-traffic--g5', - 'complementary--vehicles--g1', - 'complementary--vehicles--g2', - 'complementary--vehicles-or-buses--g1', - 'complementary--weekends-or-holidays--g1', - 'complementary--weight-limit--g1', - 'complementary--when-foggy--g1', - 'complementary--when-rainy--g1', - 'complementary--when-rainy--g2', - 'complementary--when-rainy--g3', - 'complementary--when-snowy--g1', - 'complementary--when-snowy--g2', - 'complementary--when-snowy-or-rainy--g1', - 'complementary--when-snowy-or-rainy--g2', - 'complementary--when-wet--g1', - 'complementary--width-limit--g1', - 'complementary--working-days--g1', - 'information--airport--g1', - 'information--airport--g2', - 'information--bicycle-lane--g1', - 'information--bicycles-both-ways--g1', - 'information--bicycles-crossing--g1', - 'information--bicycles-crossing--g2', - 'information--bicycles-crossing--g3', - 'information--bike-route--g1', - 'information--bike-route--g2', - 'information--built-up-area--g1', - 'information--built-up-area--g2', - 'information--bus-lane-straight--g1', - 'information--bus-stop--g1', - 'information--bus-stop--g2', - 'information--camera--g1', - 'information--camera--g2', - 'information--camera--g3', - 'information--camp--g1', - 'information--camp--g2', - 'information--car-pool-lane--g1', - 'information--caravan-parking--g1', - 'information--caravan-trailer-parking--g1', - 'information--cargo-loading-zone--g1', - 'information--central-lane--g1', - 'information--charging-station--g1', - 'information--children--g1', - 'information--children--g2', - 'information--children-crossing--g5', - 'information--cycling-two-abreast-permitted--g1', - 'information--dead-end--g1', - 'information--dead-end--g2', - 'information--dead-end--g3', - 'information--dead-end--g4', - 'information--dead-end-except-bicycles--g1', - 'information--dead-end-except-bicycles-and-pedestrians--g1', - 'information--dead-end-except-bicycles-and-pedestrians--g2', - 'information--dead-end-left--g1', - 'information--dead-end-left--g2', - 'information--dead-end-right--g1', - 'information--dead-end-right--g2', - 'information--dead-end-right--g3', - 'information--directions--g1', - 'information--disabled-persons--g1', - 'information--disabled-persons--g2', - 'information--disabled-persons--g3', - 'information--emergency-facility--g1', - 'information--end-of-advisory-maximum-speed-limit-20--g1', - 'information--end-of-advisory-maximum-speed-limit-40--g1', - 'information--end-of-advisory-maximum-speed-limit-60--g1', - 'information--end-of-advisory-maximum-speed-limit-70--g1', - 'information--end-of-advisory-maximum-speed-limit-80--g1', - 'information--end-of-advisory-maximum-speed-limit-90--g1', - 'information--end-of-bicycle-lane--g1', - 'information--end-of-built-up-area--g1', - 'information--end-of-built-up-area--g2', - 'information--end-of-built-up-area--g3', - 'information--end-of-built-up-area--g4', - 'information--end-of-car-pool-lane--g1', - 'information--end-of-limited-access-road--g1', - 'information--end-of-living-street--g1', - 'information--end-of-living-street--g2', - 'information--end-of-minimum-speed-10--g1', - 'information--end-of-minimum-speed-100--g1', - 'information--end-of-minimum-speed-110--g1', - 'information--end-of-minimum-speed-120--g1', - 'information--end-of-minimum-speed-130--g1', - 'information--end-of-minimum-speed-20--g1', - 'information--end-of-minimum-speed-25--g1', - 'information--end-of-minimum-speed-30--g1', - 'information--end-of-minimum-speed-35--g1', - 'information--end-of-minimum-speed-40--g1', - 'information--end-of-minimum-speed-50--g1', - 'information--end-of-minimum-speed-60--g1', - 'information--end-of-minimum-speed-70--g1', - 'information--end-of-minimum-speed-75--g1', - 'information--end-of-minimum-speed-80--g1', - 'information--end-of-minimum-speed-90--g1', - 'information--end-of-motorway--g1', - 'information--end-of-overtaking-permitted-heavy-good-vehicles--g1', - 'information--end-of-road-works--g1', - 'information--end-of-tunnel--g1', - 'information--end-of-tunnel--g2', - 'information--end-of-two-way-traffic--g1', - 'information--equestrians-permitted--g1', - 'information--exit-ahead--g1', - 'information--exit-ahead--g2', - 'information--exit-ahead--g3', - 'information--flight-port--g1', - 'information--food--g1', - 'information--food--g2', - 'information--gas-station--g1', - 'information--gas-station--g2', - 'information--gas-station--g3', - 'information--general-speed-limit-at-city-border--g1', - 'information--go-left--g1', - 'information--go-right--g1', - 'information--go-straight--g1', - 'information--go-straight-or-left--g1', - 'information--go-straight-or-right--g1', - 'information--go-straight-or-turn-left--g1', - 'information--go-straight-or-turn-right--g1', - 'information--hazardous-goods-vehicles-lane--g1', - 'information--height-limit--g1', - 'information--height-limit--g2', - 'information--highway-directions--g1', - 'information--highway-exit--g1', - 'information--highway-interchange--g1', - 'information--highway-interstate-route--g1', - 'information--highway-interstate-route--g2', - 'information--highway-preferential-lane--g1', - 'information--highway-preferential-lane--g2', - 'information--highway-reference-location--g1', - 'information--highway-reference-location--g2', - 'information--hiking--g1', - 'information--hospital--g1', - 'information--hurricane-evacuation-route--g1', - 'information--interstate-route--g1', - 'information--lane-control-intersections--g1', - 'information--lane-control-left-turn--g1', - 'information--lane-control-multiple-lanes--g1', - 'information--lane-control-multiple-lanes--g2', - 'information--lane-control-right-turn--g1', - 'information--limited-access-road--g1', - 'information--litter-container--g1', - 'information--living-street--g1', - 'information--living-street--g2', - 'information--living-street--g3', - 'information--lodging--g1', - 'information--lodging--g2', - 'information--minimum-speed-10--g1', - 'information--minimum-speed-100--g1', - 'information--minimum-speed-110--g1', - 'information--minimum-speed-120--g1', - 'information--minimum-speed-130--g1', - 'information--minimum-speed-20--g1', - 'information--minimum-speed-25--g1', - 'information--minimum-speed-30--g1', - 'information--minimum-speed-35--g1', - 'information--minimum-speed-40--g1', - 'information--minimum-speed-50--g1', - 'information--minimum-speed-60--g1', - 'information--minimum-speed-70--g1', - 'information--minimum-speed-75--g1', - 'information--minimum-speed-80--g1', - 'information--minimum-speed-90--g1', - 'information--motorway--g1', - 'information--motorway-exit-ahead--g1', - 'information--motorway-exit-ahead--g2', - 'information--motorway-exit-ahead--g3', - 'information--overtaking-allowed-heavy-good-vehicles--g1', - 'information--parallel-parking--g1', - 'information--park-and-ride--g1', - 'information--park-and-ride--g2', - 'information--parking--g1', - 'information--parking--g2', - 'information--parking--g3', - 'information--parking--g4', - 'information--parking--g5', - 'information--parking--g6', - 'information--parking-area--g1', - 'information--parking-with-restrictions--g1', - 'information--pass-on-either-side--g1', - 'information--passenger-loading-zone--g1', - 'information--pedestrians-crossing--g1', - 'information--pedestrians-crossing--g2', - 'information--pedestrians-crossing--g3', - 'information--pedestrians-only--g4', - 'information--pedestrians-permitted--g1', - 'information--perpendicular-parking--g1', - 'information--picnic-site--g1', - 'information--playground--g1', - 'information--recreational-vehicle-sanitary-station--g1', - 'information--recycle-collection-center--g1', - 'information--rest-area--g1', - 'information--road-bump--g1', - 'information--road-skating--g1', - 'information--safety-zone--g1', - 'information--safety-zone--g2', - 'information--safety-zone--g3', - 'information--shared-path-vehicles-and-motorcycles--g1', - 'information--stairs--g1', - 'information--stairs--g2', - 'information--stairs--g3', - 'information--stairs--g4', - 'information--stop-line--g1', - 'information--stop-permitted--g1', - 'information--street-name-one-line--g1', - 'information--street-name-three-lines--g1', - 'information--street-name-two-lines--g1', - 'information--subway--g1', - 'information--telephone--g1', - 'information--telephone--g2', - 'information--telephone-device-for-the-deaf--g1', - 'information--toll-station--g1', - 'information--tourism-information--g1', - 'information--tourist-attraction--g1', - 'information--traffic-merges-left--g1', - 'information--traffic-merges-right--g1', - 'information--trail-crossing--g1', - 'information--trail-crossing--g2', - 'information--trail-crossing--g3', - 'information--trailer-camping--g1', - 'information--train-or-light-rail-station--g1', - 'information--tram-bus-stop--g1', - 'information--tram-bus-stop--g2', - 'information--trams-crossing--g1', - 'information--truck-lane-left--g1', - 'information--truck-parking--g1', - 'information--truck-trailer-lane-right--g1', - 'information--truck-trailer-lane-straight--g1', - 'information--trucks-both-ways--g1', - 'information--trucks-only--g1', - 'information--tsunami-evacuation_route--g1', - 'information--tunnel--g1', - 'information--tunnel--g2', - 'information--tunnel--g3', - 'information--tunnel-ahead--g1', - 'information--turn-left--g1', - 'information--turn-left-ahead--g1', - 'information--turn-right--g1', - 'information--turn-right-ahead--g1', - 'information--urban-area--g1', - 'information--vehicles-on-rails--g1', - 'information--water-protection-zone--g1', - 'information--weight-and-height-limit--g1', - 'information--weight-limit--g1', - 'information--wireless-internet--g1', - 'regulatory--advisory-maximum-speed-limit--g1', - 'regulatory--all-directions-permitted--g1', - 'regulatory--all-way--g1', - 'regulatory--atvs-permitted--g1', - 'regulatory--axle-limit--g1', - 'regulatory--axle-limit--g2', - 'regulatory--bicycle-lane-left--g1', - 'regulatory--bicycle-parking--g1', - 'regulatory--bicycles-and-buses-only--g1', - 'regulatory--bicycles-only--g1', - 'regulatory--bicycles-only--g2', - 'regulatory--bicycles-only--g3', - 'regulatory--bicycles-only--g4', - 'regulatory--bicycles-push-button--g1', - 'regulatory--bicycles-push-button--g2', - 'regulatory--bicycles-stop-on-red--g1', - 'regulatory--bicycles-wrong-way--g1', - 'regulatory--bicycles-yield-or-use-signal--g1', - 'regulatory--bike-route--g1', - 'regulatory--building-direction--g1', - 'regulatory--bus-priority-lane--g1', - 'regulatory--buses-and-taxi-only--g1', - 'regulatory--buses-only--g1', - 'regulatory--buses-only--g2', - 'regulatory--circular-intersection--g1', - 'regulatory--circular-intersection--g2', - 'regulatory--circular-intersection--g3', - 'regulatory--circular-intersection--g4', - 'regulatory--cross-only-on-green--g1', - 'regulatory--cross-only-on-pedestrian-signal--g1', - 'regulatory--crosswalk-stop-on-red--g1', - 'regulatory--cycling-restriction--g1', - 'regulatory--cyclists-dismount-and-walk--g1', - 'regulatory--detour-left--g1', - 'regulatory--detour-right--g1', - 'regulatory--divided-highway-crossing--g1', - 'regulatory--divided-highway-ends--g1', - 'regulatory--divided-highway-starts--g1', - 'regulatory--do-not-block-intersection--g1', - 'regulatory--do-not-pass--g1', - 'regulatory--do-not-stop-on-tracks--g1', - 'regulatory--dual-lanes-all-directions-on-left--g1', - 'regulatory--dual-lanes-all-directions-on-right--g1', - 'regulatory--dual-lanes-bicyclists-and-pedestrians--g1', - 'regulatory--dual-lanes-go-left-or-right--g1', - 'regulatory--dual-lanes-go-straight-on-left--g1', - 'regulatory--dual-lanes-go-straight-on-right--g1', - 'regulatory--dual-lanes-turn-left--g1', - 'regulatory--dual-lanes-turn-left-no-u-turn--g1', - 'regulatory--dual-lanes-turn-left-or-straight--g1', - 'regulatory--dual-lanes-turn-right-or-straight--g1', - 'regulatory--dual-path-bicycles-and-pedestrians--g1', - 'regulatory--dual-path-bicycles-and-pedestrians--g2', - 'regulatory--dual-path-bicycles-and-pedestrians--g3', - 'regulatory--dual-path-equestrians-and-pedestrians--g1', - 'regulatory--dual-path-equestrians-and-pedestrians-bicycles--g1', - 'regulatory--dual-path-pedestrians-and-bicycles--g1', - 'regulatory--dual-path-pedestrians-and-bicycles--g2', - 'regulatory--dual-path-pedestrians-and-equestrians--g1', - 'regulatory--dual-path-pedestrians-bicycles-and-equestrians--g1', - 'regulatory--dual-speed-limits--g1', - 'regulatory--dual-speed-limits--g2', - 'regulatory--end-of-bicycles-only--g1', - 'regulatory--end-of-bicycles-only--g2', - 'regulatory--end-of-bus-and-taxi-only--g1', - 'regulatory--end-of-buses-only--g1', - 'regulatory--end-of-buses-only--g2', - 'regulatory--end-of-cycling-restriction--g1', - 'regulatory--end-of-dual-path-bicycles-and-pedestrians--g1', - 'regulatory--end-of-dual-path-pedestrians-and-bicycles--g1', - 'regulatory--end-of-equestrians-only--g1', - 'regulatory--end-of-low-beam-headlights--g1', - 'regulatory--end-of-maximum-speed-limit--g1', - 'regulatory--end-of-maximum-speed-limit-10--g1', - 'regulatory--end-of-maximum-speed-limit-10--g2', - 'regulatory--end-of-maximum-speed-limit-100--g1', - 'regulatory--end-of-maximum-speed-limit-100--g2', - 'regulatory--end-of-maximum-speed-limit-110--g1', - 'regulatory--end-of-maximum-speed-limit-110--g2', - 'regulatory--end-of-maximum-speed-limit-120--g1', - 'regulatory--end-of-maximum-speed-limit-120--g2', - 'regulatory--end-of-maximum-speed-limit-130--g1', - 'regulatory--end-of-maximum-speed-limit-130--g2', - 'regulatory--end-of-maximum-speed-limit-20--g1', - 'regulatory--end-of-maximum-speed-limit-20--g2', - 'regulatory--end-of-maximum-speed-limit-25--g1', - 'regulatory--end-of-maximum-speed-limit-25--g2', - 'regulatory--end-of-maximum-speed-limit-30--g1', - 'regulatory--end-of-maximum-speed-limit-30--g2', - 'regulatory--end-of-maximum-speed-limit-35--g1', - 'regulatory--end-of-maximum-speed-limit-35--g2', - 'regulatory--end-of-maximum-speed-limit-40--g1', - 'regulatory--end-of-maximum-speed-limit-40--g2', - 'regulatory--end-of-maximum-speed-limit-50--g1', - 'regulatory--end-of-maximum-speed-limit-50--g2', - 'regulatory--end-of-maximum-speed-limit-60--g1', - 'regulatory--end-of-maximum-speed-limit-60--g2', - 'regulatory--end-of-maximum-speed-limit-65--g1', - 'regulatory--end-of-maximum-speed-limit-65--g2', - 'regulatory--end-of-maximum-speed-limit-70--g1', - 'regulatory--end-of-maximum-speed-limit-70--g2', - 'regulatory--end-of-maximum-speed-limit-75--g1', - 'regulatory--end-of-maximum-speed-limit-75--g2', - 'regulatory--end-of-maximum-speed-limit-80--g1', - 'regulatory--end-of-maximum-speed-limit-80--g2', - 'regulatory--end-of-maximum-speed-limit-90--g1', - 'regulatory--end-of-maximum-speed-limit-90--g2', - 'regulatory--end-of-mopeds-and-bicycles-only--g1', - 'regulatory--end-of-no-heavy-goods-vehicles--g1', - 'regulatory--end-of-no-horn--g1', - 'regulatory--end-of-no-overtaking--g1', - 'regulatory--end-of-no-overtaking--g2', - 'regulatory--end-of-no-overtaking--g3', - 'regulatory--end-of-no-overtaking--g4', - 'regulatory--end-of-no-overtaking--g5', - 'regulatory--end-of-no-overtaking-by-heavy-goods-vehicles--g1', - 'regulatory--end-of-no-overtaking-by-heavy-goods-vehicles--g2', - 'regulatory--end-of-no-overtaking-by-motorcycles--g1', - 'regulatory--end-of-no-parking--g1', - 'regulatory--end-of-no-parking--g2', - 'regulatory--end-of-no-parking-or-stopping--g1', - 'regulatory--end-of-one-way-straight--g1', - 'regulatory--end-of-parking-zone--g1', - 'regulatory--end-of-parking-zone--g2', - 'regulatory--end-of-pedestrians-only--g1', - 'regulatory--end-of-pedestrians-only--g2', - 'regulatory--end-of-pedestrians-only--g3', - 'regulatory--end-of-pedestrians-only--g4', - 'regulatory--end-of-priority-road--g1', - 'regulatory--end-of-prohibition--g1', - 'regulatory--end-of-school-zone--g1', - 'regulatory--end-of-shared-path-bicycles-and-pedestrians--g1', - 'regulatory--end-of-shared-path-pedestrians-and-bicycles--g1', - 'regulatory--end-of-snow-chains--g1', - 'regulatory--end-of-snow-chains--g2', - 'regulatory--end-of-snowmobiles-only--g1', - 'regulatory--end-of-speed-limit-zone--g1', - 'regulatory--end-of-speed-limit-zone--g2', - 'regulatory--end-of-speed-limit-zone--g3', - 'regulatory--end-of-tractors-only--g1', - 'regulatory--end-of-trams-and-buses-only--g1', - 'regulatory--end-of-trams-only--g1', - 'regulatory--end-of-trucks-and-buses-only--g1', - 'regulatory--end-of-trucks-only--g1', - 'regulatory--end-of-trucks-only--g2', - 'regulatory--equestrians-only--g1', - 'regulatory--except-railroad-crossing--g1', - 'regulatory--fine-for-littering--g1', - 'regulatory--give-way-to-bicycles--g1', - 'regulatory--give-way-to-oncoming-traffic--g1', - 'regulatory--give-way-to-oncoming-traffic--g2', - 'regulatory--go-left-bicycles--g1', - 'regulatory--go-right-bicycles--g1', - 'regulatory--go-straight--g1', - 'regulatory--go-straight--g3', - 'regulatory--go-straight-bicycles--g1', - 'regulatory--go-straight-or-turn-left--g1', - 'regulatory--go-straight-or-turn-left--g2', - 'regulatory--go-straight-or-turn-left--g3', - 'regulatory--go-straight-or-turn-right--g1', - 'regulatory--go-straight-or-turn-right--g2', - 'regulatory--go-straight-or-turn-right--g3', - 'regulatory--heavy-goods-vehicles-permitted--g1', - 'regulatory--height-limit--g1', - 'regulatory--high-beam-headlights--g1', - 'regulatory--horn--g1', - 'regulatory--in-street-pedestrian-crossing--g1', - 'regulatory--keep-left--g1', - 'regulatory--keep-left--g2', - 'regulatory--keep-left--g3', - 'regulatory--keep-left--g4', - 'regulatory--keep-left--g5', - 'regulatory--keep-left--g6', - 'regulatory--keep-left--g7', - 'regulatory--keep-right--g1', - 'regulatory--keep-right--g2', - 'regulatory--keep-right--g3', - 'regulatory--keep-right--g4', - 'regulatory--keep-right--g5', - 'regulatory--keep-right--g6', - 'regulatory--keep-right--g7', - 'regulatory--keep-right--g8', - 'regulatory--keep-right--g9', - 'regulatory--lane-control--g1', - 'regulatory--left-turn-yield-on-green--g1', - 'regulatory--length-limit--g1', - 'regulatory--length-limit--g2', - 'regulatory--light-rail-divided-highway--g1', - 'regulatory--light-rail-do-not-pass--g1', - 'regulatory--light-rail-only--g1', - 'regulatory--look--g1', - 'regulatory--low-beam-headlights--g1', - 'regulatory--low-beam-headlights--g2', - 'regulatory--low-beam-headlights--g3', - 'regulatory--low-speed-vehicle-permitted--g1', - 'regulatory--maximum-speed-limit-10--g1', - 'regulatory--maximum-speed-limit-10--g3', - 'regulatory--maximum-speed-limit-100--g1', - 'regulatory--maximum-speed-limit-100--g3', - 'regulatory--maximum-speed-limit-110--g1', - 'regulatory--maximum-speed-limit-110--g3', - 'regulatory--maximum-speed-limit-120--g1', - 'regulatory--maximum-speed-limit-120--g3', - 'regulatory--maximum-speed-limit-130--g1', - 'regulatory--maximum-speed-limit-130--g3', - 'regulatory--maximum-speed-limit-15--g1', - 'regulatory--maximum-speed-limit-15--g3', - 'regulatory--maximum-speed-limit-20--g1', - 'regulatory--maximum-speed-limit-20--g3', - 'regulatory--maximum-speed-limit-25--g1', - 'regulatory--maximum-speed-limit-25--g2', - 'regulatory--maximum-speed-limit-30--g1', - 'regulatory--maximum-speed-limit-30--g3', - 'regulatory--maximum-speed-limit-35--g1', - 'regulatory--maximum-speed-limit-35--g2', - 'regulatory--maximum-speed-limit-40--g1', - 'regulatory--maximum-speed-limit-40--g3', - 'regulatory--maximum-speed-limit-45--g1', - 'regulatory--maximum-speed-limit-45--g3', - 'regulatory--maximum-speed-limit-5--g1', - 'regulatory--maximum-speed-limit-5--g3', - 'regulatory--maximum-speed-limit-50--g1', - 'regulatory--maximum-speed-limit-50--g3', - 'regulatory--maximum-speed-limit-55--g2', - 'regulatory--maximum-speed-limit-60--g1', - 'regulatory--maximum-speed-limit-60--g3', - 'regulatory--maximum-speed-limit-65--g1', - 'regulatory--maximum-speed-limit-65--g2', - 'regulatory--maximum-speed-limit-70--g1', - 'regulatory--maximum-speed-limit-70--g3', - 'regulatory--maximum-speed-limit-75--g2', - 'regulatory--maximum-speed-limit-80--g1', - 'regulatory--maximum-speed-limit-80--g3', - 'regulatory--maximum-speed-limit-85--g2', - 'regulatory--maximum-speed-limit-90--g1', - 'regulatory--maximum-speed-limit-90--g3', - 'regulatory--maximum-speed-limit-led-10--g1', - 'regulatory--maximum-speed-limit-led-10--g2', - 'regulatory--maximum-speed-limit-led-10--g3', - 'regulatory--maximum-speed-limit-led-100--g1', - 'regulatory--maximum-speed-limit-led-110--g1', - 'regulatory--maximum-speed-limit-led-120--g1', - 'regulatory--maximum-speed-limit-led-130--g1', - 'regulatory--maximum-speed-limit-led-15--g2', - 'regulatory--maximum-speed-limit-led-15--g3', - 'regulatory--maximum-speed-limit-led-20--g1', - 'regulatory--maximum-speed-limit-led-20--g2', - 'regulatory--maximum-speed-limit-led-20--g3', - 'regulatory--maximum-speed-limit-led-25--g1', - 'regulatory--maximum-speed-limit-led-25--g2', - 'regulatory--maximum-speed-limit-led-25--g3', - 'regulatory--maximum-speed-limit-led-30--g1', - 'regulatory--maximum-speed-limit-led-30--g2', - 'regulatory--maximum-speed-limit-led-30--g3', - 'regulatory--maximum-speed-limit-led-35--g1', - 'regulatory--maximum-speed-limit-led-35--g2', - 'regulatory--maximum-speed-limit-led-35--g3', - 'regulatory--maximum-speed-limit-led-40--g1', - 'regulatory--maximum-speed-limit-led-40--g2', - 'regulatory--maximum-speed-limit-led-40--g3', - 'regulatory--maximum-speed-limit-led-45--g2', - 'regulatory--maximum-speed-limit-led-45--g3', - 'regulatory--maximum-speed-limit-led-5--g2', - 'regulatory--maximum-speed-limit-led-5--g3', - 'regulatory--maximum-speed-limit-led-50--g1', - 'regulatory--maximum-speed-limit-led-50--g2', - 'regulatory--maximum-speed-limit-led-50--g3', - 'regulatory--maximum-speed-limit-led-55--g2', - 'regulatory--maximum-speed-limit-led-55--g3', - 'regulatory--maximum-speed-limit-led-60--g1', - 'regulatory--maximum-speed-limit-led-60--g2', - 'regulatory--maximum-speed-limit-led-60--g3', - 'regulatory--maximum-speed-limit-led-65--g2', - 'regulatory--maximum-speed-limit-led-65--g3', - 'regulatory--maximum-speed-limit-led-70--g1', - 'regulatory--maximum-speed-limit-led-70--g2', - 'regulatory--maximum-speed-limit-led-70--g3', - 'regulatory--maximum-speed-limit-led-75--g1', - 'regulatory--maximum-speed-limit-led-75--g2', - 'regulatory--maximum-speed-limit-led-75--g3', - 'regulatory--maximum-speed-limit-led-80--g1', - 'regulatory--maximum-speed-limit-led-80--g2', - 'regulatory--maximum-speed-limit-led-80--g3', - 'regulatory--maximum-speed-limit-led-85--g2', - 'regulatory--maximum-speed-limit-led-85--g3', - 'regulatory--maximum-speed-limit-led-90--g1', - 'regulatory--minimum-safe-distance--g1', - 'regulatory--minimum-safe-distance--g2', - 'regulatory--mopeds-and-bicycles-only--g1', - 'regulatory--motorcycles-and-bicycles-only--g1', - 'regulatory--motorcycles-only--g1', - 'regulatory--motorcycles-only--g2', - 'regulatory--night-speed-limit-10--g1', - 'regulatory--night-speed-limit-15--g1', - 'regulatory--night-speed-limit-20--g1', - 'regulatory--night-speed-limit-25--g1', - 'regulatory--night-speed-limit-30--g1', - 'regulatory--night-speed-limit-35--g1', - 'regulatory--night-speed-limit-40--g1', - 'regulatory--night-speed-limit-45--g1', - 'regulatory--night-speed-limit-5--g1', - 'regulatory--night-speed-limit-50--g1', - 'regulatory--night-speed-limit-55--g1', - 'regulatory--night-speed-limit-60--g1', - 'regulatory--night-speed-limit-65--g1', - 'regulatory--night-speed-limit-70--g1', - 'regulatory--night-speed-limit-75--g1', - 'regulatory--night-speed-limit-80--g1', - 'regulatory--night-speed-limit-85--g1', - 'regulatory--no-abnormal-vehicles--g1', - 'regulatory--no-atvs--g1', - 'regulatory--no-bicycles--g1', - 'regulatory--no-bicycles--g2', - 'regulatory--no-bicycles--g3', - 'regulatory--no-bicycles-carts-or-hand-carts--g1', - 'regulatory--no-bicycles-mopeds-or-motorcycles--g1', - 'regulatory--no-bicycles-mopeds-or-motorcycles--g2', - 'regulatory--no-bicycles-or-hand-carts--g1', - 'regulatory--no-bicycles-or-motorcycles--g1', - 'regulatory--no-bicycles-tractors-or-carts--g1', - 'regulatory--no-buses--g1', - 'regulatory--no-buses--g2', - 'regulatory--no-buses--g3', - 'regulatory--no-caravan-trailers--g1', - 'regulatory--no-caravans--g1', - 'regulatory--no-caravans-or-caravan-trailers--g1', - 'regulatory--no-cargo-loading--g1', - 'regulatory--no-carts--g1', - 'regulatory--no-carts--g2', - 'regulatory--no-carts--g3', - 'regulatory--no-carts-or-tractors--g1', - 'regulatory--no-construction-vehicles--g1', - 'regulatory--no-entry--g1', - 'regulatory--no-equestrians--g1', - 'regulatory--no-go-straight-or-turn-left--g1', - 'regulatory--no-go-straight-or-turn-right--g1', - 'regulatory--no-good-trailers--g2', - 'regulatory--no-goods-vehicle-trailers--g1', - 'regulatory--no-hand-carts--g1', - 'regulatory--no-hand-carts--g2', - 'regulatory--no-hand-carts-or-bicycles--g1', - 'regulatory--no-hawkers--g1', - 'regulatory--no-heavy-goods-vehicles--g1', - 'regulatory--no-heavy-goods-vehicles--g2', - 'regulatory--no-heavy-goods-vehicles--g3', - 'regulatory--no-heavy-goods-vehicles--g4', - 'regulatory--no-heavy-goods-vehicles--g5', - 'regulatory--no-heavy-goods-vehicles-motorcycles-or-bicycles--g1', - 'regulatory--no-heavy-goods-vehicles-motorcycles-or-bicycles--g2', - 'regulatory--no-heavy-goods-vehicles-or-buses--g1', - 'regulatory--no-heavy-goods-vehicles-or-tractors--g1', - 'regulatory--no-heavy-goods-vehicles-or-trailers--g1', - 'regulatory--no-horizontal-turn--g1', - 'regulatory--no-horn--g1', - 'regulatory--no-horn--g2', - 'regulatory--no-lane-change-to-left--g1', - 'regulatory--no-lane-change-to-right--g1', - 'regulatory--no-learner-drivers--g1', - 'regulatory--no-left-or-u-turn--g1', - 'regulatory--no-left-turn--g1', - 'regulatory--no-left-turn--g2', - 'regulatory--no-left-turn--g3', - 'regulatory--no-left-turn--g4', - 'regulatory--no-left-turn--g5', - 'regulatory--no-low-speed-vehicles--g1', - 'regulatory--no-mopeds-or-bicycles--g1', - 'regulatory--no-motor-vehicle-trailers--g1', - 'regulatory--no-motor-vehicles--g1', - 'regulatory--no-motor-vehicles--g3', - 'regulatory--no-motor-vehicles--g4', - 'regulatory--no-motor-vehicles--g5', - 'regulatory--no-motor-vehicles--g6', - 'regulatory--no-motor-vehicles--g7', - 'regulatory--no-motor-vehicles-except-motorcycles--g1', - 'regulatory--no-motor-vehicles-except-motorcycles--g2', - 'regulatory--no-motor-vehicles-except-motorcycles--g3', - 'regulatory--no-motor-vehicles-or-bicycles--g1', - 'regulatory--no-motor-vehicles-or-buses--g1', - 'regulatory--no-motor-vehicles-or-carts--g1', - 'regulatory--no-motorcycles--g1', - 'regulatory--no-motorcycles--g2', - 'regulatory--no-overtaking--g1', - 'regulatory--no-overtaking--g2', - 'regulatory--no-overtaking--g4', - 'regulatory--no-overtaking--g5', - 'regulatory--no-overtaking--g6', - 'regulatory--no-overtaking--g7', - 'regulatory--no-overtaking-atvs--g1', - 'regulatory--no-overtaking-by-heavy-goods-vehicles--g1', - 'regulatory--no-parking--g1', - 'regulatory--no-parking--g2', - 'regulatory--no-parking--g3', - 'regulatory--no-parking--g4', - 'regulatory--no-parking--g5', - 'regulatory--no-parking--g6', - 'regulatory--no-parking--g7', - 'regulatory--no-parking--g8', - 'regulatory--no-parking--g9', - 'regulatory--no-parking-bicycles-or-motorcycles--g1', - 'regulatory--no-parking-bus-stop--g1', - 'regulatory--no-parking-or-no-stopping--g1', - 'regulatory--no-parking-or-no-stopping--g2', - 'regulatory--no-parking-or-no-stopping--g3', - 'regulatory--no-parking-or-no-stopping--g4', - 'regulatory--no-parking-or-no-stopping--g5', - 'regulatory--no-passenger-loading--g1', - 'regulatory--no-pedestrians--g1', - 'regulatory--no-pedestrians--g2', - 'regulatory--no-pedestrians--g3', - 'regulatory--no-pedestrians--g4', - 'regulatory--no-pedestrians--g5', - 'regulatory--no-pedestrians--g6', - 'regulatory--no-pedestrians-bicycles-animals-or-hand-carts--g1', - 'regulatory--no-pedestrians-or-bicycles--g1', - 'regulatory--no-pedestrians-or-bicycles--g2', - 'regulatory--no-pedestrians-or-bicycles--g3', - 'regulatory--no-rickshaws--g1', - 'regulatory--no-rickshaws--g2', - 'regulatory--no-rickshaws--g3', - 'regulatory--no-right-turn--g1', - 'regulatory--no-right-turn--g2', - 'regulatory--no-right-turn--g3', - 'regulatory--no-right-turn-on-red--g1', - 'regulatory--no-skiing--g1', - 'regulatory--no-snowmobiles--g1', - 'regulatory--no-snowmobiles--g2', - 'regulatory--no-snowmobiles-or-atvs--g1', - 'regulatory--no-stopping--g1', - 'regulatory--no-stopping--g2', - 'regulatory--no-stopping--g3', - 'regulatory--no-stopping--g5', - 'regulatory--no-stopping--g6', - 'regulatory--no-stopping--g7', - 'regulatory--no-stopping-on-pavement--g1', - 'regulatory--no-straight-through--g1', - 'regulatory--no-straight-through--g2', - 'regulatory--no-studded-snow-chains--g1', - 'regulatory--no-through-trucks--g1', - 'regulatory--no-tour-buses--g1', - 'regulatory--no-tractors--g1', - 'regulatory--no-tractors-mopeds-or-bicycles--g1', - 'regulatory--no-tractors-or-carts--g1', - 'regulatory--no-trailers--g1', - 'regulatory--no-tricycles--g1', - 'regulatory--no-tricycles-or-hand-carts--g1', - 'regulatory--no-turn-on-red--g1', - 'regulatory--no-turn-on-red--g2', - 'regulatory--no-turn-on-red--g3', - 'regulatory--no-turns--g1', - 'regulatory--no-turns--g2', - 'regulatory--no-two-stage-right-turn-for-mopeds--g1', - 'regulatory--no-u-turn--g1', - 'regulatory--no-u-turn--g2', - 'regulatory--no-u-turn--g3', - 'regulatory--no-vehicles-carrying-dangerous-goods--g1', - 'regulatory--no-vehicles-carrying-dangerous-goods--g2', - 'regulatory--no-vehicles-carrying-dangerous-goods--g3', - 'regulatory--no-vehicles-carrying-dangerous-goods--g4', - 'regulatory--no-vehicles-carrying-dangerous-water-pollutants--g1', - 'regulatory--no-vehicles-carrying-dangerous-water-pollutants--g2', - 'regulatory--no-vehicles-carrying-explosives--g1', - 'regulatory--no-vehicles-carrying-explosives-or-dangerous-water-pollutants--g1', - 'regulatory--one-way-left--g1', - 'regulatory--one-way-left--g2', - 'regulatory--one-way-left--g3', - 'regulatory--one-way-right--g1', - 'regulatory--one-way-right--g2', - 'regulatory--one-way-right--g3', - 'regulatory--one-way-straight--g1', - 'regulatory--one-way-straight--g3', - 'regulatory--parking-fee-station--g1', - 'regulatory--parking-restrictions--g1', - 'regulatory--parking-restrictions--g2', - 'regulatory--parking-restrictions--g3', - 'regulatory--pass-on-either-side--g1', - 'regulatory--pass-on-either-side--g2', - 'regulatory--pass-on-either-side--g3', - 'regulatory--pass-with-care--g1', - 'regulatory--passing-lane-ahead--g1', - 'regulatory--pedestrians-bicycles-permitted--g1', - 'regulatory--pedestrians-keep-left--g1', - 'regulatory--pedestrians-only--g1', - 'regulatory--pedestrians-only--g2', - 'regulatory--pedestrians-only--g3', - 'regulatory--pedestrians-priority-zone--g1', - 'regulatory--pedestrians-push-button--g1', - 'regulatory--pedestrians-push-button--g2', - 'regulatory--priority-over-oncoming-vehicles--g1', - 'regulatory--priority-over-oncoming-vehicles--g2', - 'regulatory--priority-road--g1', - 'regulatory--priority-road--g2', - 'regulatory--radar-enforced--g1', - 'regulatory--reserved-parking--g1', - 'regulatory--reversible-lanes--g1', - 'regulatory--reversible-lanes--g2', - 'regulatory--road-closed--g1', - 'regulatory--road-closed--g2', - 'regulatory--road-closed-to-vehicles--g1', - 'regulatory--road-closed-to-vehicles--g3', - 'regulatory--roundabout--g1', - 'regulatory--roundabout--g2', - 'regulatory--roundabout--g3', - 'regulatory--shared-path-bicycles-and-pedestrians--g1', - 'regulatory--shared-path-pedestrians-and-bicycles--g1', - 'regulatory--sidewalk-closed--g1', - 'regulatory--slanted-parking--g1', - 'regulatory--snow-chains--g1', - 'regulatory--snow-chains--g2', - 'regulatory--snow-chains--g3', - 'regulatory--snowmobiles-only--g1', - 'regulatory--snowmobiles-permitted--g1', - 'regulatory--speed-limit-zone--g1', - 'regulatory--speeding-fines-increased--g1', - 'regulatory--state-route--g1', - 'regulatory--stay-in-lane--g1', - 'regulatory--stop--g1', - 'regulatory--stop--g10', - 'regulatory--stop--g2', - 'regulatory--stop--g3', - 'regulatory--stop--g4', - 'regulatory--stop--g5', - 'regulatory--stop--g6', - 'regulatory--stop--g7', - 'regulatory--stop--g8', - 'regulatory--stop--g9', - 'regulatory--stop-here-on-red-or-flashing-light--g1', - 'regulatory--stop-here-on-red-or-flashing-light--g2', - 'regulatory--stop-signals--g1', - 'regulatory--stop-signals--g2', - 'regulatory--tanks-only--g1', - 'regulatory--taxi-only--g1', - 'regulatory--text--g1', - 'regulatory--text--g2', - 'regulatory--toll-pass-only--g1', - 'regulatory--tractors-only--g1', - 'regulatory--traffic-signal-photo-enforced--g1', - 'regulatory--trams-and-buses-only--g1', - 'regulatory--trams-only--g1', - 'regulatory--triple-lanes--g1', - 'regulatory--triple-lanes-go-straight-center-lane--g1', - 'regulatory--triple-lanes-turn-left-center-lane--g1', - 'regulatory--triple-lanes-turn-right-center-lane--g1', - 'regulatory--truck-route--g1', - 'regulatory--truck-speed-limit-10--g1', - 'regulatory--truck-speed-limit-15--g1', - 'regulatory--truck-speed-limit-20--g1', - 'regulatory--truck-speed-limit-25--g1', - 'regulatory--truck-speed-limit-30--g1', - 'regulatory--truck-speed-limit-35--g1', - 'regulatory--truck-speed-limit-40--g1', - 'regulatory--truck-speed-limit-45--g1', - 'regulatory--truck-speed-limit-5--g1', - 'regulatory--truck-speed-limit-50--g1', - 'regulatory--truck-speed-limit-55--g1', - 'regulatory--truck-speed-limit-60--g1', - 'regulatory--truck-speed-limit-65--g1', - 'regulatory--truck-speed-limit-70--g1', - 'regulatory--truck-speed-limit-75--g1', - 'regulatory--truck-speed-limit-80--g1', - 'regulatory--truck-speed-limit-85--g1', - 'regulatory--trucks-and-buses-only--g1', - 'regulatory--trucks-on-right--g1', - 'regulatory--trucks-only--g1', - 'regulatory--turn-left--g1', - 'regulatory--turn-left--g2', - 'regulatory--turn-left--g3', - 'regulatory--turn-left-ahead--g1', - 'regulatory--turn-left-ahead--g2', - 'regulatory--turn-left-or-right--g1', - 'regulatory--turn-left-or-right--g2', - 'regulatory--turn-left-or-right--g3', - 'regulatory--turn-left-or-u-turn--g1', - 'regulatory--turn-right--g1', - 'regulatory--turn-right--g2', - 'regulatory--turn-right--g3', - 'regulatory--turn-right-ahead--g1', - 'regulatory--turn-right-ahead--g2', - 'regulatory--turning-vehicles-yield-to-pedestrians--g1', - 'regulatory--two-stage-right-turn-for-mopeds--g1', - 'regulatory--two-way--g1', - 'regulatory--u-turn--g1', - 'regulatory--u-turn--g2', - 'regulatory--u-turn--g3', - 'regulatory--use-crosswalk--g1', - 'regulatory--vehicles-carrying-dangerous-goods-only--g1', - 'regulatory--vehicles-carrying-dangerous-goods-permitted--g1', - 'regulatory--vehicles-carrying-explosives-only--g1', - 'regulatory--vehicles-carrying-hazardous-goods-permitted--g1', - 'regulatory--vehicles-only--g1', - 'regulatory--wear-seat-belt--g1', - 'regulatory--wear-seat-belt--g2', - 'regulatory--wear-seat-belt--g3', - 'regulatory--weight-limit--g1', - 'regulatory--weight-limit--g2', - 'regulatory--weight-limit--g3', - 'regulatory--weight-limit--g4', - 'regulatory--weight-limit--g5', - 'regulatory--weight-limit--g6', - 'regulatory--weight-limit--g7', - 'regulatory--weight-limit-per-axle--g1', - 'regulatory--weight-limit-per-tandem-axle--g1', - 'regulatory--weight-limit-with-trucks--g1', - 'regulatory--width-limit--g1', - 'regulatory--wrong-way--g1', - 'regulatory--yield--g1', - 'regulatory--yield-or-stop-for-pedestrians--g1', - 'regulatory--your-speed--g1', - 'warning--accident-area--g1', - 'warning--accident-area--g2', - 'warning--accident-area--g3', - 'warning--accident-area--g4', - 'warning--accident-area--g5', - 'warning--accident-area--g6', - 'warning--accident-area--g7', - 'warning--accident-area--g8', - 'warning--added-lane-from-entering-roadway--g1', - 'warning--added-lane-from-entering-roadway--g2', - 'warning--added-lane-left--g1', - 'warning--added-lane-right--g1', - 'warning--animal-drawn-vehicles--g1', - 'warning--arch-bridge--g1', - 'warning--atv-and-snowmobiles--g1', - 'warning--atv-crossing--g1', - 'warning--atv-crossing--g2', - 'warning--axle-restriction--g1', - 'warning--bear-crossing--g1', - 'warning--bear-crossing--g2', - 'warning--bicycles-and-others--g1', - 'warning--bicycles-caution-on-rail-tracks--g1', - 'warning--bicycles-crossing--g1', - 'warning--bicycles-crossing--g2', - 'warning--bicycles-crossing--g3', - 'warning--bicycles-crossing--g4', - 'warning--bollard--g1', - 'warning--bridge--g1', - 'warning--bridge--g2', - 'warning--bus-stop-ahead--g1', - 'warning--bus-stop-ahead--g2', - 'warning--bus-stop-ahead--g3', - 'warning--camel-crossing--g1', - 'warning--camel-crossing--g2', - 'warning--camera--g1', - 'warning--camera--g2', - 'warning--carts--g1', - 'warning--carts--g2', - 'warning--checkpoint--g1', - 'warning--children--g1', - 'warning--children--g2', - 'warning--children--g3', - 'warning--children--g4', - 'warning--children--g6', - 'warning--cliff--g1', - 'warning--cliff--g2', - 'warning--closed-lane-in-triple-lanes--g1', - 'warning--closed-lane-in-triple-lanes--g2', - 'warning--construction-ahead--g1', - 'warning--crossroads--g1', - 'warning--crossroads--g2', - 'warning--crossroads--g3', - 'warning--crossroads--g4', - 'warning--crossroads--g5', - 'warning--crossroads--g6', - 'warning--crossroads-with-priority-to-the-right--g1', - 'warning--curve-left--g1', - 'warning--curve-left--g2', - 'warning--curve-left--g3', - 'warning--curve-left-with-junction--g1', - 'warning--curve-out-intersection-left--g1', - 'warning--curve-out-intersection-right--g1', - 'warning--curve-right--g1', - 'warning--curve-right--g2', - 'warning--curve-right--g3', - 'warning--curve-right-with-junction--g1', - 'warning--dangerous-crosswinds-left--g1', - 'warning--dangerous-crosswinds-left--g2', - 'warning--dangerous-crosswinds-left--g4', - 'warning--dangerous-crosswinds-right--g1', - 'warning--dangerous-crosswinds-right--g2', - 'warning--dangerous-crosswinds-right--g3', - 'warning--dangerous-crosswinds-right--g4', - 'warning--dead-end--g1', - 'warning--dead-end--g2', - 'warning--dead-end--g3', - 'warning--dead-end-go-left--g1', - 'warning--dead-end-go-right--g1', - 'warning--descent-or-climbing-lanes-in-triple-lanes--g1', - 'warning--detour-ahead--g1', - 'warning--detour-or-construction-ahead--g1', - 'warning--dip--g1', - 'warning--dip--g2', - 'warning--disabled-persons-crossing--g1', - 'warning--disabled-persons-crossing--g2', - 'warning--divided-highway--g1', - 'warning--divided-highway--g2', - 'warning--divided-highway--g3', - 'warning--divided-highway--g4', - 'warning--divided-highway--g5', - 'warning--divided-highway--g6', - 'warning--divided-highway--g7', - 'warning--divided-highway--g8', - 'warning--divided-highway--g9', - 'warning--divided-highway-ends--g1', - 'warning--divided-highway-ends--g2', - 'warning--divided-highway-ends--g3', - 'warning--divided-highway-ends--g4', - 'warning--divided-highway-on-left--g1', - 'warning--divided-highway-on-left--g2', - 'warning--divided-highway-on-right--g1', - 'warning--divided-highway-on-right--g2', - 'warning--divided-highway-to-left--g1', - 'warning--divided-highway-to-right--g1', - 'warning--domestic-animals--g1', - 'warning--domestic-animals--g2', - 'warning--domestic-animals--g3', - 'warning--domestic-animals--g4', - 'warning--domestic-animals--g5', - 'warning--domestic-animals--g6', - 'warning--domestic-animals--g7', - 'warning--domestic-animals--g8', - 'warning--double-curve-first-left--g1', - 'warning--double-curve-first-left--g2', - 'warning--double-curve-first-right--g1', - 'warning--double-curve-first-right--g2', - 'warning--double-descent--g1', - 'warning--double-reverse-curve-left--g1', - 'warning--double-reverse-curve-left--g2', - 'warning--double-reverse-curve-right--g1', - 'warning--double-reverse-curve-right--g2', - 'warning--double-side-roads-left--g1', - 'warning--double-side-roads-left--g3', - 'warning--double-side-roads-right--g1', - 'warning--double-side-roads-right--g3', - 'warning--double-turn-first-left--g1', - 'warning--double-turn-first-right--g1', - 'warning--dual-lanes-all-directions-on-left--g1', - 'warning--dual-lanes-all-directions-on-right--g1', - 'warning--dual-lanes-go-straight-or-turn-left--g1', - 'warning--dual-lanes-go-straight-or-turn-right--g1', - 'warning--dual-lanes-left-turn--g1', - 'warning--dual-lanes-left-turn-or-go-straight--g1', - 'warning--dual-lanes-right-turn--g1', - 'warning--dual-lanes-right-turn-or-go-straight--g1', - 'warning--dual-lanes-turn-left--g1', - 'warning--dual-lanes-turn-left-or-right--g1', - 'warning--dual-lanes-turn-left-or-right--g2', - 'warning--dual-lanes-turn-left-or-right--g3', - 'warning--dual-lanes-turn-left-or-right--g4', - 'warning--dual-lanes-turn-right--g1', - 'warning--dual-path-cyclists-and-pedestrians--g1', - 'warning--electricity--g1', - 'warning--electricity--g2', - 'warning--elephant-crossing--g1', - 'warning--emergency-vehicles--g1', - 'warning--emu-crossing--g1', - 'warning--emu-crossing--g2', - 'warning--entering-roadway-merge--g1', - 'warning--entering-roadway-merge--g2', - 'warning--equestrians-crossing--g1', - 'warning--equestrians-crossing--g2', - 'warning--expressway--g1', - 'warning--falling-rocks-or-debris-left--g1', - 'warning--falling-rocks-or-debris-left--g2', - 'warning--falling-rocks-or-debris-left--g3', - 'warning--falling-rocks-or-debris-left--g4', - 'warning--falling-rocks-or-debris-right--g1', - 'warning--falling-rocks-or-debris-right--g2', - 'warning--falling-rocks-or-debris-right--g3', - 'warning--falling-rocks-or-debris-right--g4', - 'warning--ferry--g1', - 'warning--flaggers-in-road--g1', - 'warning--flaggers-in-road--g2', - 'warning--foggy-road--g1', - 'warning--foggy-road--g2', - 'warning--ford--g1', - 'warning--forest--g1', - 'warning--fresh-oil--g1', - 'warning--frog-crossing--g1', - 'warning--gate--g1', - 'warning--gate--g2', - 'warning--gate-left--g1', - 'warning--gate-right--g1', - 'warning--go-left--g1', - 'warning--go-right--g1', - 'warning--golf-carts-crossing--g1', - 'warning--gravel-road-surface--g1', - 'warning--hairpin-curve-left--g1', - 'warning--hairpin-curve-left--g2', - 'warning--hairpin-curve-left--g3', - 'warning--hairpin-curve-right--g1', - 'warning--hairpin-curve-right--g3', - 'warning--height-restriction--g2', - 'warning--height-restriction--g3', - 'warning--height-restriction--g4', - 'warning--height-restriction--g5', - 'warning--horizontal-alignment-left--g1', - 'warning--horizontal-alignment-left--g3', - 'warning--horizontal-alignment-right--g1', - 'warning--horizontal-alignment-right--g3', - 'warning--horse-crossing--g1', - 'warning--icy-road--g1', - 'warning--junction-with-a-side-road-acute-left--g1', - 'warning--junction-with-a-side-road-acute-left--g2', - 'warning--junction-with-a-side-road-acute-right--g1', - 'warning--junction-with-a-side-road-acute-right--g2', - 'warning--junction-with-a-side-road-perpendicular-left--g1', - 'warning--junction-with-a-side-road-perpendicular-left--g2', - 'warning--junction-with-a-side-road-perpendicular-left--g3', - 'warning--junction-with-a-side-road-perpendicular-left--g4', - 'warning--junction-with-a-side-road-perpendicular-right--g1', - 'warning--junction-with-a-side-road-perpendicular-right--g2', - 'warning--junction-with-a-side-road-perpendicular-right--g3', - 'warning--junction-with-a-side-road-perpendicular-right--g4', - 'warning--junction-with-merge-from-left--g1', - 'warning--junction-with-merge-from-right--g1', - 'warning--junction-with-side-roads--g1', - 'warning--kangaloo-crossing--g1', - 'warning--keep-distance--g1', - 'warning--keep-left--g1', - 'warning--keep-right--g1', - 'warning--kiwi-crossing--g1', - 'warning--kiwi-crossing--g2', - 'warning--koala-crossing--g1', - 'warning--koala-crossing--g2', - 'warning--koala-crossing--g3', - 'warning--koala-crossing--g4', - 'warning--lane-closed-in-dual-lanes-left--g1', - 'warning--lane-closed-in-dual-lanes-left--g2', - 'warning--lane-closed-in-dual-lanes-right--g1', - 'warning--lane-closed-in-dual-lanes-right--g2', - 'warning--length-restriction--g1', - 'warning--length-restriction--g2', - 'warning--light-rail-transit-vehicles--g1', - 'warning--limited-lighting-under-trees--g1', - 'warning--logging-vehicles--g1', - 'warning--loop-270-degree--g1', - 'warning--loop-pretzel--g1', - 'warning--loose-road-surface--g1', - 'warning--loose-road-surface--g2', - 'warning--loose-road-surface--g3', - 'warning--loose-road-surface--g4', - 'warning--low-flying-aircraft--g1', - 'warning--low-flying-aircraft--g2', - 'warning--low-flying-aircraft--g3', - 'warning--low-flying-aircraft--g4', - 'warning--low-flying-aircraft--g5', - 'warning--low-flying-aircraft--g6', - 'warning--low-flying-aircraft--g7', - 'warning--low-flying-aircraft--g8', - 'warning--low-ground-clearance--g1', - 'warning--low-ground-clearance--g2', - 'warning--low-ground-clearance--g3', - 'warning--monkey-crossing--g1', - 'warning--motorcycles-crossing--g1', - 'warning--narrow-bridge--g1', - 'warning--narrow-bridge--g2', - 'warning--narrow-bridge--g3', - 'warning--no-passing-zone--g1', - 'warning--no-passing-zone--g2', - 'warning--occupied-lanes--g1', - 'warning--offset-roads--g1', - 'warning--offset-roads--g2', - 'warning--offset-roads--g3', - 'warning--offset-roads--g4', - 'warning--opening-or-swing-bridge--g1', - 'warning--opening-or-swing-bridge--g2', - 'warning--other-danger--g1', - 'warning--other-danger--g2', - 'warning--other-danger--g3', - 'warning--panda-crossing--g1', - 'warning--pass-left-or-right--g1', - 'warning--pass-left-or-right--g2', - 'warning--pass-left-or-right--g3', - 'warning--pavement-ahead--g1', - 'warning--pavement-ends--g1', - 'warning--pavement-ends--g2', - 'warning--pavement-ends--g3', - 'warning--pavement-ends--g4', - 'warning--pavement-ends--g5', - 'warning--pedestrians-crossing--g1', - 'warning--pedestrians-crossing--g10', - 'warning--pedestrians-crossing--g11', - 'warning--pedestrians-crossing--g12', - 'warning--pedestrians-crossing--g4', - 'warning--pedestrians-crossing--g5', - 'warning--pedestrians-crossing--g6', - 'warning--pedestrians-crossing--g7', - 'warning--pedestrians-crossing--g8', - 'warning--pedestrians-crossing--g9', - 'warning--playground--g1', - 'warning--playground--g3', - 'warning--polar-bear-crossing--g1', - 'warning--quay-or-river-bank--g1', - 'warning--quay-or-river-bank--g2', - 'warning--quay-or-river-bank--g3', - 'warning--quay-or-river-bank--g4', - 'warning--rabbit-crossing--g1', - 'warning--raccoon-crossing--g1', - 'warning--railroad-crossing--g1', - 'warning--railroad-crossing--g2', - 'warning--railroad-crossing--g3', - 'warning--railroad-crossing--g4', - 'warning--railroad-crossing-with-barriers--g1', - 'warning--railroad-crossing-with-barriers--g2', - 'warning--railroad-crossing-with-barriers--g3', - 'warning--railroad-crossing-with-barriers--g4', - 'warning--railroad-crossing-with-barriers--g5', - 'warning--railroad-crossing-with-barriers--g6', - 'warning--railroad-crossing-with-barriers--g7', - 'warning--railroad-crossing-without-barriers--g1', - 'warning--railroad-crossing-without-barriers--g2', - 'warning--railroad-crossing-without-barriers--g3', - 'warning--railroad-crossing-without-barriers--g4', - 'warning--railroad-crossing-without-barriers--g5', - 'warning--railroad-crossing-without-barriers--g6', - 'warning--railroad-intersection--g1', - 'warning--railroad-intersection--g2', - 'warning--railroad-intersection--g3', - 'warning--railroad-intersection--g4', - 'warning--railroad-intersection--g5', - 'warning--railroad-intersection--g6', - 'warning--railroad-intersection--g7', - 'warning--railroad-intersection--g8', - 'warning--railroad-intersection--g9', - 'warning--ramp-closed--g1', - 'warning--reduced-maximum-speed-limit--g1', - 'warning--reserved-lane--g1', - 'warning--restricted-zone--g1', - 'warning--reversible-lanes--g1', - 'warning--reversible-lanes--g2', - 'warning--rickshaws-crossing--g1', - 'warning--road-blocks--g1', - 'warning--road-bump--g1', - 'warning--road-bump--g2', - 'warning--road-bump--g3', - 'warning--road-bump-with-speed-limit--g1', - 'warning--road-closed--g3', - 'warning--road-narrows--g1', - 'warning--road-narrows--g2', - 'warning--road-narrows-left--g1', - 'warning--road-narrows-left--g2', - 'warning--road-narrows-left-ahead--g1', - 'warning--road-narrows-right--g1', - 'warning--road-narrows-right--g2', - 'warning--road-narrows-right-ahead--g1', - 'warning--road-toll-ahead--g1', - 'warning--road-widens--g1', - 'warning--road-widens-left--g1', - 'warning--road-widens-right--g1', - 'warning--roadworks--g1', - 'warning--roadworks--g10', - 'warning--roadworks--g11', - 'warning--roadworks--g2', - 'warning--roadworks--g3', - 'warning--roadworks--g5', - 'warning--roadworks--g6', - 'warning--roadworks--g8', - 'warning--roadworks--g9', - 'warning--roadworks-go-left-or-straight--g1', - 'warning--roadworks-go-right-or-straight--g1', - 'warning--roundabout--g1', - 'warning--roundabout--g2', - 'warning--roundabout--g3', - 'warning--roundabout--g4', - 'warning--roundabout--g5', - 'warning--roundabout--g6', - 'warning--roundabout--g7', - 'warning--ruts--g1', - 'warning--sand--g1', - 'warning--sand-drift--g1', - 'warning--school-zone--g2', - 'warning--severe-weather--g1', - 'warning--shared-lane-motorcycles-bicycles--g1', - 'warning--sharp-turn--g1', - 'warning--single-reverse-curve--g1', - 'warning--skewed-t-roads-left--g1', - 'warning--skewed-t-roads-left--g2', - 'warning--skewed-t-roads-left--g3', - 'warning--skewed-t-roads-right--g1', - 'warning--skewed-t-roads-right--g2', - 'warning--skewed-t-roads-right--g3', - 'warning--skiers--g1', - 'warning--skiers--g2', - 'warning--skiers--g3', - 'warning--slippery-bicycles--g1', - 'warning--slippery-motorcycles--g1', - 'warning--slippery-motorcycles--g2', - 'warning--slippery-road-surface--g1', - 'warning--slippery-road-surface--g2', - 'warning--slow--g1', - 'warning--snow-tractors--g1', - 'warning--snowmobiles--g1', - 'warning--snowmobiles--g2', - 'warning--snowmobiles--g3', - 'warning--snowmobiles-and-others--g1', - 'warning--soft-road-surface--g1', - 'warning--soft-road-surface--g2', - 'warning--soft-shoulder--g1', - 'warning--soft-shoulder--g2', - 'warning--soft-shoulder--g3', - 'warning--soft-shoulder--g4', - 'warning--speed-camera--g1', - 'warning--steep-ascent--g1', - 'warning--steep-ascent--g2', - 'warning--steep-ascent--g3', - 'warning--steep-ascent--g4', - 'warning--steep-ascent-and-descent--g1', - 'warning--steep-descent--g1', - 'warning--steep-descent--g2', - 'warning--steep-descent--g3', - 'warning--steep-descent--g4', - 'warning--steep-descent--g5', - 'warning--steep-descent--g6', - 'warning--stop-ahead--g1', - 'warning--stop-ahead--g3', - 'warning--stop-ahead--g4', - 'warning--stop-ahead--g5', - 'warning--stop-ahead--g6', - 'warning--t-roads--g1', - 'warning--t-roads--g2', - 'warning--tanks-crossing--g1', - 'warning--tanks-crossing--g2', - 'warning--texts--g1', - 'warning--texts--g2', - 'warning--texts--g3', - 'warning--towing--g1', - 'warning--tractors--g1', - 'warning--tractors--g2', - 'warning--tractors--g3', - 'warning--tractors--g4', - 'warning--tractors--g5', - 'warning--tractors--g6', - 'warning--tractors--g7', - 'warning--traffic-merges-at-signalized-intersections--g1', - 'warning--traffic-merges-left--g1', - 'warning--traffic-merges-left--g2', - 'warning--traffic-merges-left--g3', - 'warning--traffic-merges-left--g4', - 'warning--traffic-merges-left-and-right--g1', - 'warning--traffic-merges-left-buses--g1', - 'warning--traffic-merges-right--g1', - 'warning--traffic-merges-right--g2', - 'warning--traffic-merges-right--g3', - 'warning--traffic-merges-right-buses--g1', - 'warning--traffic-queues-likely--g1', - 'warning--traffic-queues-likely--g2', - 'warning--traffic-queues-likely--g3', - 'warning--traffic-queues-likely--g4', - 'warning--traffic-queues-likely--g5', - 'warning--traffic-signals--g1', - 'warning--traffic-signals--g2', - 'warning--traffic-signals--g3', - 'warning--traffic-signals--g4', - 'warning--traffic-signals--g5', - 'warning--traffic-signals--g6', - 'warning--traffic-slow--g1', - 'warning--trail-crossing--g1', - 'warning--trail-crossing--g2', - 'warning--trail-crossing--g3', - 'warning--trail-crossing--g4', - 'warning--trail-crossing--g5', - 'warning--trail-crossing--g6', - 'warning--trams-crossing--g1', - 'warning--trams-crossing--g2', - 'warning--triple-curve-left--g1', - 'warning--triple-curve-right--g1', - 'warning--triple-lanes-left-turn--g1', - 'warning--triple-lanes-left-turn-or-go-straight--g1', - 'warning--triple-lanes-right-turn--g1', - 'warning--triple-lanes-right-turn-or-go-straight--g1', - 'warning--triple-lanes-with-directions--g1', - 'warning--triple-reverse-curve-left--g1', - 'warning--triple-reverse-curve-right--g1', - 'warning--trucks-crossing--g1', - 'warning--trucks-crossing--g2', - 'warning--trucks-rollover--g1', - 'warning--trucks-rollover--g2', - 'warning--trucks-rollover--g3', - 'warning--trucks-rollover--g4', - 'warning--trucks-rollover--g5', - 'warning--tunnel--g1', - 'warning--tunnel--g2', - 'warning--tunnel--g3', - 'warning--tunnel--g4', - 'warning--tunnel--g5', - 'warning--tunnel--g6', - 'warning--tunnel--g7', - 'warning--turn-left--g1', - 'warning--turn-left--g2', - 'warning--turn-left--g3', - 'warning--turn-left-or-right--g1', - 'warning--turn-right--g1', - 'warning--turn-right--g2', - 'warning--turn-right--g3', - 'warning--two-way-traffic--g1', - 'warning--two-way-traffic--g2', - 'warning--two-way-traffic--g3', - 'warning--two-way-traffic--g4', - 'warning--two-way-traffic--g5', - 'warning--two-way-traffic--g6', - 'warning--u-turn--g1', - 'warning--u-turn--g2', - 'warning--uneven-road--g1', - 'warning--uneven-road--g2', - 'warning--uneven-roads-ahead--g1', - 'warning--vehicles-and-others--g1', - 'warning--vehicles-crossing--g1', - 'warning--village--g1', - 'warning--weight-limit--g5', - 'warning--weight-limit-per-tandem-axle--g1', - 'warning--width-restriction--g1', - 'warning--width-restriction--g2', - 'warning--width-restriction--g3', - 'warning--width-restriction--g4', - 'warning--wild-animals--g1', - 'warning--wild-animals--g2', - 'warning--wild-animals--g3', - 'warning--wild-animals--g4', - 'warning--wild-animals--g5', - 'warning--wild-animals--g6', - 'warning--wild-animals--g7', - 'warning--wild-animals--g8', - 'warning--wind--g1', - 'warning--winding-road--g1', - 'warning--winding-road-first-left--g1', - 'warning--winding-road-first-left--g2', - 'warning--winding-road-first-left--g3', - 'warning--winding-road-first-right--g1', - 'warning--winding-road-first-right--g2', - 'warning--winding-road-first-right--g3', - 'warning--winding-road-first-right--g4', - 'warning--winding-road-to-left--g1', - 'warning--winding-road-to-right--g1', - 'warning--wombat-crossing--g1', - 'warning--y-roads--g1', - 'warning--y-roads--g2', - 'warning--yield-ahead--g1', - 'warning--yield-ahead--g3', -]); - -/** Sign values collapsed to their family (regional suffix removed), sorted. */ -export const SIGN_FAMILIES = Object.freeze( - [...new Set(SIGN_VALUES.map((value) => value.replace(/--g\d+$/, '')))].sort(), -); diff --git a/src/layers/mapillary/values.js b/src/layers/mapillary/values.js deleted file mode 100644 index 4082819b5..000000000 --- a/src/layers/mapillary/values.js +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Mapillary map-feature value helpers shared by the browser layer and the - * server query executor. Values look like `object--fire-hydrant` or - * `regulatory--stop--g1`; the segments are separated by `--`. - */ - -const SIGN_PREFIXES = Object.freeze([ - 'regulatory', - 'warning', - 'information', - 'complementary', -]); - -/** Escape a string for use inside a RegExp source. */ -function escapeRegExp(text) { - return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** - * Normalize one user-supplied value pattern: trimmed, lower-cased, spaces - * folded to hyphens, and only the characters a Mapillary value can contain. - * Returns '' when nothing usable remains. - * @param {unknown} pattern - * @returns {string} - */ -export function normalizeValuePattern(pattern) { - return String(pattern ?? '') - .trim() - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/[^a-z0-9*-]/g, '') - .slice(0, 120); -} - -/** - * Build a matcher for a list of value patterns. `*` matches any run of - * characters, so `regulatory--stop--*` covers every regional variant and - * `object--traffic-light--*` every traffic-light subtype. An empty pattern - * list matches everything. - * @param {Iterable} patterns - * @returns {(value: string) => boolean} - */ -export function createValueMatcher(patterns) { - const exact = new Set(); - const wild = []; - for (const raw of patterns || []) { - const pattern = normalizeValuePattern(raw); - if (!pattern) continue; - if (pattern.includes('*')) { - wild.push( - new RegExp(`^${pattern.split('*').map(escapeRegExp).join('.*')}$`), - ); - } else exact.add(pattern); - } - if (!exact.size && !wild.length) return () => true; - return (value) => { - if (typeof value !== 'string') return false; - if (exact.has(value)) return true; - for (const regex of wild) if (regex.test(value)) return true; - return false; - }; -} - -/** - * Whether a value belongs to the traffic-sign layer rather than the point - * object layer. Sign values start with one of four regulatory families. - * @param {string} value - * @returns {boolean} - */ -export function isSignValue(value) { - const head = String(value || '').split('--')[0]; - return SIGN_PREFIXES.includes(head); -} - -/** - * Human label for a value: `object--fire-hydrant` → "Fire hydrant", - * `regulatory--stop--g1` → "Stop (regulatory)", - * `object--traffic-light--general-upright` → "Traffic light · general upright". - * @param {string} value - * @returns {string} - */ -export function humanizeValue(value) { - const parts = String(value || '') - .split('--') - .filter(Boolean); - if (!parts.length) return 'Unknown'; - const [head, ...rest] = parts; - const words = (segment) => - segment.replace(/-/g, ' ').replace(/^\w/, (c) => c.toUpperCase()); - if (SIGN_PREFIXES.includes(head)) { - const body = rest.filter((segment) => !/^g\d+$/.test(segment)); - return `${body.map(words).join(' · ') || words(head)} (${head})`; - } - if (head === 'object' || head === 'marking' || head === 'construction') { - const body = rest.length ? rest : [head]; - return body.map(words).join(' · '); - } - return parts.map(words).join(' · '); -} - -/** Coarse category used for colouring and grouping results. */ -export function categoryOf(value) { - const head = String(value || '').split('--')[0]; - if (SIGN_PREFIXES.includes(head)) return 'sign'; - if (head === 'marking') return 'marking'; - if (head === 'construction') return 'construction'; - return 'object'; -} - -/** - * Stable CSS colour per value, so one class keeps one colour across a query. - * Signs lean warm, objects lean cool; the hue within a category is hashed - * from the value so neighbouring classes differ. - * @param {string} value - * @returns {string} CSS colour. - */ -export function colorForValue(value) { - let hash = 0; - const text = String(value || ''); - for (let i = 0; i < text.length; i++) - hash = (hash * 31 + text.charCodeAt(i)) >>> 0; - const category = categoryOf(text); - // Objects sit in the cyan-blue band so they never blend into the - // Mapillary-green coverage lines; signs lean warm, markings violet. - const base = - category === 'sign' - ? 20 - : category === 'marking' - ? 280 - : category === 'construction' - ? 45 - : 205; - const hue = (base + (hash % 60) - 30 + 360) % 360; - return `hsl(${hue} 90% 60%)`; -} - -/** - * Merge per-value counts whose human labels coincide (regional sign variants - * such as `regulatory--stop--g1` and `--g3`), summing counts and keeping the - * first value's colour. Input and output are sorted by count, descending. - * @param {Iterable<[string, number]>} counts - * @returns {Array<{value: string, values: string[], count: number, label: string, color: string}>} - */ -export function groupCountsByLabel(counts) { - const groups = new Map(); - for (const [value, count] of counts) { - const label = humanizeValue(value); - const group = groups.get(label); - if (group) { - group.count += count; - group.values.push(value); - } else - groups.set(label, { - value, - values: [value], - count, - label, - color: colorForValue(value), - }); - } - return [...groups.values()].sort((a, b) => b.count - a.count); -} diff --git a/src/layers/mapillary/values.test.mjs b/src/layers/mapillary/values.test.mjs deleted file mode 100644 index d2e17c860..000000000 --- a/src/layers/mapillary/values.test.mjs +++ /dev/null @@ -1,96 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { - categoryOf, - groupCountsByLabel, - colorForValue, - createValueMatcher, - humanizeValue, - isSignValue, - normalizeValuePattern, -} from './values.js'; - -test('normalizeValuePattern folds case, spaces and stray characters', () => { - assert.equal( - normalizeValuePattern(' Object--Fire Hydrant '), - 'object--fire-hydrant', - ); - assert.equal( - normalizeValuePattern('regulatory--stop--*'), - 'regulatory--stop--*', - ); - assert.equal(normalizeValuePattern('drop table;'), 'drop-table'); - assert.equal(normalizeValuePattern(null), ''); -}); - -test('createValueMatcher matches exact values and wildcards only', () => { - const matches = createValueMatcher([ - 'object--fire-hydrant', - 'regulatory--stop--*', - ]); - assert.equal(matches('object--fire-hydrant'), true); - assert.equal(matches('regulatory--stop--g1'), true); - assert.equal(matches('regulatory--stop--g3'), true); - assert.equal(matches('regulatory--stop-ahead--g1'), false); - assert.equal(matches('object--bench'), false); - assert.equal(matches(undefined), false); -}); - -test('an empty pattern list matches everything', () => { - const matches = createValueMatcher([]); - assert.equal(matches('anything--at-all'), true); -}); - -test('a wildcard only spans the segment it is placed in', () => { - const matches = createValueMatcher(['object--sign--*']); - assert.equal(matches('object--sign--store'), true); - assert.equal(matches('object--signal'), false); - // Regex metacharacters are stripped by normalization, never interpreted. - assert.equal( - createValueMatcher(['object--sign.*'])('object--sign--store'), - true, - ); -}); - -test('isSignValue and categoryOf split the taxonomy by family', () => { - assert.equal(isSignValue('regulatory--stop--g1'), true); - assert.equal(isSignValue('warning--pedestrians-crossing--g4'), true); - assert.equal(isSignValue('object--fire-hydrant'), false); - assert.equal(categoryOf('object--fire-hydrant'), 'object'); - assert.equal(categoryOf('marking--discrete--stop-line'), 'marking'); - assert.equal(categoryOf('information--general-directions--g1'), 'sign'); -}); - -test('humanizeValue produces readable labels', () => { - assert.equal(humanizeValue('object--fire-hydrant'), 'Fire hydrant'); - assert.equal(humanizeValue('regulatory--stop--g1'), 'Stop (regulatory)'); - assert.equal( - humanizeValue('object--traffic-light--general-upright'), - 'Traffic light · General upright', - ); - assert.equal(humanizeValue(''), 'Unknown'); -}); - -test('colorForValue is stable and category-shifted', () => { - assert.equal(colorForValue('object--bench'), colorForValue('object--bench')); - assert.match(colorForValue('regulatory--stop--g1'), /^hsl\(\d+ 90% 60%\)$/); -}); - -test('groupCountsByLabel merges regional sign variants under one label', () => { - const groups = groupCountsByLabel([ - ['regulatory--stop--g1', 100], - ['object--bench', 30], - ['regulatory--stop--g3', 5], - ]); - assert.deepEqual( - groups.map(({ label, count, values }) => ({ label, count, values })), - [ - { - label: 'Stop (regulatory)', - count: 105, - values: ['regulatory--stop--g1', 'regulatory--stop--g3'], - }, - { label: 'Bench', count: 30, values: ['object--bench'] }, - ], - ); -}); diff --git a/src/layers/mapillary/viewerBridge.js b/src/layers/mapillary/viewerBridge.js index c28e76143..0092fc58f 100644 --- a/src/layers/mapillary/viewerBridge.js +++ b/src/layers/mapillary/viewerBridge.js @@ -1,5 +1,4 @@ import * as Cesium from 'cesium'; -import { decodeDetectionPolygons } from './decode.js'; /** Eye height above the sampled ground when the globe camera follows the viewer. */ const FOLLOW_EYE_HEIGHT_M = 2.4; @@ -109,8 +108,6 @@ export function createViewerBridge({ state, source, parts }) { state.street.bearing = pov?.bearing ?? state.street.bearing; state.street.tilt = pov?.tilt ?? 0; if (image) { - if (state.street.imageId && state.street.imageId !== image.id) - clearHighlight(); state.street.imageId = image.id; state.street.isPano = image.merged ? image.cameraType === 'spherical' @@ -180,7 +177,6 @@ export function createViewerBridge({ state, source, parts }) { bearing: true, zoom: true, attribution: true, - tag: true, }, trackResize: true, renderMode: libraryRenderMode(state.street.renderMode), @@ -221,70 +217,6 @@ export function createViewerBridge({ state, source, parts }) { if (pendingOpen === id && !state.street.error) selectCurrentSequence(); } - function clearHighlight() { - if (!state.street.highlight) return; - state.street.highlight = null; - try { - viewer?.getComponent('tag')?.removeAll(); - } catch { - /* component not ready */ - } - } - - /** - * Outline every detection of `value` inside the current image using the - * MapillaryJS tag component. Detections are per image on the Graph API; - * their geometry is a small vector tile in image-normalized coordinates. - */ - async function highlightDetections(imageId, value, label) { - if (!viewer || !imageId) return 0; - const { OutlineTag, PolygonGeometry } = Library || {}; - let component; - try { - component = viewer.getComponent('tag'); - component.removeAll(); - } catch { - return 0; - } - state.street.highlight = { value, count: 0, loading: true }; - state.notify?.(); - let detections = []; - try { - detections = await source.getImageDetections(imageId); - } catch { - detections = []; - } - if (pendingOpen !== String(imageId) || !viewer) return 0; - const tags = []; - for (const detection of detections) { - if (value && detection.value !== value) continue; - decodeDetectionPolygons(detection.geometry).forEach((polygon, index) => { - try { - tags.push( - new OutlineTag( - `mly-det-${detection.id}-${index}`, - new PolygonGeometry(polygon), - { - lineColor: 0x00d4ff, - lineWidth: 3, - fillColor: 0x00d4ff, - fillOpacity: 0.22, - text: tags.length === 0 ? label || undefined : undefined, - textColor: 0xffffff, - }, - ), - ); - } catch { - /* degenerate polygon */ - } - }); - } - if (tags.length) component.add(tags); - state.street.highlight = { value, count: tags.length, loading: false }; - state.notify?.(); - return tags.length; - } - /** Switch between showing the whole image ('letterbox') and cropping ('fill'). */ function setRenderMode(mode) { state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox'; @@ -326,7 +258,6 @@ export function createViewerBridge({ state, source, parts }) { /** Close the image. The viewer instance is kept warm for the next open. */ function close() { pendingOpen = null; - clearHighlight(); Object.assign(state.street, { open: false, follow: false, @@ -337,8 +268,6 @@ export function createViewerBridge({ state, source, parts }) { tilt: null, loading: false, error: null, - highlight: null, - feature: null, }); parts.sequences.setMarker(null); state.notify?.(); @@ -352,7 +281,6 @@ export function createViewerBridge({ state, source, parts }) { resize, prewarm, lookAtPosition, - highlightDetections, destroy() { close(); destroyViewer(); diff --git a/src/mapillaryMarkup.test.mjs b/src/mapillaryMarkup.test.mjs index 981a4d6ab..1a2a07b03 100644 --- a/src/mapillaryMarkup.test.mjs +++ b/src/mapillaryMarkup.test.mjs @@ -63,9 +63,12 @@ test('panel styles stay inside GEV conventions: no !important, no fixed panel', assert.deepEqual(fixed, ['.mly-viewer-wrap-expanded']); }); -test('keyless and no-planner states gate the controls rather than leave dead buttons', () => { +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, /mly-keyless/); + assert.doesNotMatch( + html, + /mly-keyless|mly-query|mly-results|data-mly-suggestion/, + ); assert.match(html, /
]*role="alert"/); @@ -76,18 +79,13 @@ test('the expanded viewer is a modal dialog that restores focus', () => { assert.match(controls, /setAttribute\('role', 'dialog'\)/); assert.match(controls, /setAttribute\('aria-modal', 'true'\)/); assert.match(controls, /_expandReturnFocus/); - assert.match( - controls, - /if \(event\.key !== 'Escape'\) event\.stopPropagation\(\);/, - ); }); test('labels say what the buttons do', () => { - for (const label of [ - 'OPEN NEAREST PHOTO', - 'CAMERA FOLLOWS VIEW', - 'ZOOM TO RESULTS', - ]) + for (const label of ['OPEN NEAREST PHOTO', 'CAMERA FOLLOWS VIEW']) assert.ok(html.includes(label), label); - assert.doesNotMatch(html, /LOOK HERE|STREET COCKPIT|>FRAMEFRAME<|ZOOM TO RESULTS|ASK IN PLAIN ENGLISH/, + ); }); diff --git a/src/setupDoctor.test.mjs b/src/setupDoctor.test.mjs index f2ad817a3..761a2def9 100644 --- a/src/setupDoctor.test.mjs +++ b/src/setupDoctor.test.mjs @@ -266,7 +266,6 @@ test('doctor describes the credential ladder without exposing values', () => { OPENSKY_CLIENT_SECRET: { configured: false }, LL2_API_TOKEN: { configured: true, source: 'environment' }, MAPILLARY_CLIENT_TOKEN: { configured: false }, - ANTHROPIC_API_KEY: { configured: false }, }; const capabilities = buildCapabilitySummary(credentials); assert.match( @@ -322,7 +321,6 @@ test('doctor sends Keychain-backed reports to dev-fresh and describes OpenSky as 'OPENSKY_CLIENT_SECRET', 'LL2_API_TOKEN', 'MAPILLARY_CLIENT_TOKEN', - 'ANTHROPIC_API_KEY', ].map((name) => [name, { configured: false }]), ); credentials.GOOGLE_MAPS_API_KEY = { @@ -364,7 +362,6 @@ test('doctor never calls a dependency-missing setup ready', () => { 'OPENSKY_CLIENT_SECRET', 'LL2_API_TOKEN', 'MAPILLARY_CLIENT_TOKEN', - 'ANTHROPIC_API_KEY', ].map((name) => [name, { configured: false }]), ); const output = formatSetupReport({ diff --git a/src/tooling/mapillaryProvider.test.mjs b/src/tooling/mapillaryProvider.test.mjs index 653b1ffb7..07e153c92 100644 --- a/src/tooling/mapillaryProvider.test.mjs +++ b/src/tooling/mapillaryProvider.test.mjs @@ -4,14 +4,6 @@ import path from 'node:path'; import test from 'node:test'; import { PbfWriter } from 'pbf'; import { mapillaryProxy } from 'gods-eye-view/server/providers/mapillary'; -import { - buildPlannerUserMessage, - finalizePlan, - planQuery, - PLANNER_SYSTEM_PROMPT, - reconcilePlanValues, -} from '../../server/providers/mapillary/planner.js'; -import { normalizeFeatureQuery } from '../../server/providers/mapillary/features.js'; import { listTileLayers, stripTileLayers, @@ -22,7 +14,6 @@ import { TileRequestError, _resetTileMemoryForTest, } from '../../server/providers/mapillary/tiles.js'; -import { handleSprite } from '../../server/providers/mapillary/sprites.js'; /** Mount the plugin and return a caller keyed by route. */ function install(plugin, mode = 'configureServer') { @@ -78,36 +69,25 @@ function install(plugin, mode = 'configureServer') { const json = (res) => JSON.parse(String(res.body)); -test('the plugin mounts the five Mapillary routes for dev and preview servers', () => { +test('the plugin mounts the status and tile routes for dev and preview servers', () => { for (const mode of ['configureServer', 'configurePreviewServer']) { const { routes } = install(mapillaryProxy(), mode); assert.deepEqual([...routes.keys()].sort(), [ - '/api/mapillary/features', - '/api/mapillary/plan', - '/api/mapillary/sprite', '/api/mapillary/status', '/api/mapillary/tiles', ]); } }); -test('status reports capabilities, never values, and rejects non-GET', async () => { - const saved = { - MAPILLARY_CLIENT_TOKEN: process.env.MAPILLARY_CLIENT_TOKEN, - ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, - }; +test('status reports whether a token exists, never its value, and rejects non-GET', async () => { + const saved = { MAPILLARY_CLIENT_TOKEN: process.env.MAPILLARY_CLIENT_TOKEN }; delete process.env.MAPILLARY_CLIENT_TOKEN; - delete process.env.ANTHROPIC_API_KEY; try { const { call } = install(mapillaryProxy()); const res = await call('/api/mapillary/status'); assert.equal(res.statusCode, 200); - const payload = json(res); - assert.equal(payload.configured, false); - assert.equal(payload.planner, false); - assert.equal(payload.plannerModel, null); - assert.equal(payload.limits.featureZoom, 14); - assert.doesNotMatch(String(res.body), /MLY\||sk-ant/); + assert.deepEqual(json(res), { configured: false }); + assert.doesNotMatch(String(res.body), /MLY\||planner/); const post = await call('/api/mapillary/status', '/', 'POST'); assert.equal(post.statusCode, 405); } finally { @@ -124,6 +104,8 @@ test('tile route validates the path and refuses to proxy without a token', async const { call } = install(mapillaryProxy()); const bad = await call('/api/mapillary/tiles', '/coverage/14/1/x'); assert.equal(bad.statusCode, 400); + const signs = await call('/api/mapillary/tiles', '/signs/14/1/2'); + assert.equal(signs.statusCode, 400, 'only coverage tiles are proxied'); const noKey = await call('/api/mapillary/tiles', '/coverage/14/1/2'); assert.equal(noKey.statusCode, 503); assert.deepEqual(json(noKey), { error: 'no_key', keyRequired: true }); @@ -145,13 +127,10 @@ test('normalizeTileAddress enforces layer names, zoom ranges and tile bounds', ( .dropLayers, ['image'], ); - assert.deepEqual( - normalizeTileAddress({ layer: 'points', z: 14, x: 5, y: 6 }).dropLayers, - [], - ); for (const bad of [ { layer: 'image', z: 14, x: 1, y: 1 }, - { layer: 'points', z: 13, x: 1, y: 1 }, + { layer: 'points', z: 14, x: 1, y: 1 }, + { layer: 'signs', z: 14, x: 1, y: 1 }, { layer: 'coverage', z: 15, x: 1, y: 1 }, { layer: 'coverage', z: 2, x: 4, y: 0 }, { layer: 'coverage', z: 2, x: 1.5, y: 0 }, @@ -223,238 +202,6 @@ test('fetchTile serves from memory after one upstream fetch and strips the image } }); -test('sprite route only accepts taxonomy values, never paths', async () => { - const respond = async (url) => { - const headers = {}; - const res = { - statusCode: 200, - setHeader: (k, v) => { - headers[k.toLowerCase()] = v; - }, - end(body) { - this.body = body; - }, - }; - await handleSprite({ url }, res); - return { status: res.statusCode, headers, body: res.body }; - }; - for (const bad of [ - '/..%2Fetc%2Fpasswd.svg', - '/../x.svg', - '/Object--Fire.svg', - '/a..b.svg', - '/a--.svg', - '/.svg', - '/x.png', - ]) - assert.equal((await respond(bad)).status, 400, bad); - const savedFetch = globalThis.fetch; - globalThis.fetch = async (url) => { - assert.match( - String(url), - /package_objects\/object--fire-hydrant--zz-test\.svg$/, - ); - return new Response('', { - status: 200, - }); - }; - try { - const ok = await respond('/object--fire-hydrant--zz-test.svg'); - assert.equal(ok.status, 200); - assert.match(ok.headers['content-type'], /image\/svg\+xml/); - } finally { - globalThis.fetch = savedFetch; - } -}); - -test('normalizeFeatureQuery caps values, normalises the bbox and rejects oversized areas', () => { - const missing = normalizeFeatureQuery({}); - assert.equal(missing.ok, false); - assert.equal(missing.status, 400); - const ok = normalizeFeatureQuery({ - bbox: [-121.5, 38.55, -121.45, 38.6], - layer: 'points', - values: ['object--fire-hydrant', '', 'object--bench'], - seenAfter: '2020-01-01', - }); - assert.equal(ok.ok, true); - assert.deepEqual(ok.request.values.slice(0, 2), [ - 'object--fire-hydrant', - 'object--bench', - ]); - assert.equal(ok.request.since, Date.parse('2020-01-01')); - assert.ok(ok.request.tiles.length > 0); - const huge = normalizeFeatureQuery({ - bbox: [-125, 32, -114, 42], - layer: 'points', - values: ['object--bench'], - }); - assert.equal(huge.ok, false); - assert.equal(huge.error, 'area_too_large'); - assert.ok(huge.detail.tiles > huge.detail.limit); -}); - -// ── Planner (relocated from src/layers/mapillary/planner.test.mjs) ────────── -const basePlan = { - intent: 'map_features', - place: 'Sacramento, California', - use_current_view: false, - values: ['object--fire-hydrant'], - seen_after: null, - seen_before: null, - prefer_pano: false, - visualise: 'icons', - title: 'Fire hydrants · Sacramento', - answer: 'Showing fire hydrants in Sacramento.', -}; - -test('the system prompt is frozen text carrying the taxonomy', () => { - assert.match(PLANNER_SYSTEM_PROMPT, /object--fire-hydrant/); - assert.match(PLANNER_SYSTEM_PROMPT, /regulatory--stop\b/); - assert.doesNotMatch(PLANNER_SYSTEM_PROMPT, /Today is/); -}); - -test('reconcilePlanValues keeps taxonomy values, expands families, drops junk', () => { - const result = reconcilePlanValues([ - 'object--fire-hydrant', - 'regulatory--stop', - 'regulatory--stop--*', - 'object--unicorn', - 'warning--nothing-like-this--*', - ]); - assert.deepEqual(result.values, [ - 'object--fire-hydrant', - 'regulatory--stop--*', - ]); - assert.deepEqual(result.dropped, [ - 'object--unicorn', - 'warning--nothing-like-this--*', - ]); -}); - -test('finalizePlan derives the tile layer from the values', () => { - assert.equal(finalizePlan(basePlan).layer, 'points'); - const signs = finalizePlan({ - ...basePlan, - intent: 'map_features', - values: ['regulatory--stop--*'], - }); - assert.equal(signs.layer, 'signs'); - assert.equal(signs.intent, 'traffic_signs'); -}); - -test('finalizePlan downgrades to unsupported when nothing usable survives', () => { - const plan = finalizePlan({ ...basePlan, values: ['object--unicorn'] }); - assert.equal(plan.intent, 'unsupported'); - assert.match(plan.answer, /object--unicorn/); -}); - -test('finalizePlan keeps objects and reports signs on a mixed request', () => { - const plan = finalizePlan({ - ...basePlan, - values: ['object--fire-hydrant', 'regulatory--stop--*'], - }); - assert.equal(plan.layer, 'points'); - assert.deepEqual(plan.values, ['object--fire-hydrant']); - assert.deepEqual(plan.dropped, ['regulatory--stop--*']); - assert.match(plan.answer, /different layers/); -}); - -test('buildPlannerUserMessage carries the date, view and previous plan', () => { - const text = buildPlannerUserMessage({ - query: 'only after 2024', - today: '2026-09-15', - context: { - view: { lat: 38.58, lon: -121.49, heightM: 1234.6, label: 'Sacramento' }, - previousPlan: basePlan, - }, - }); - assert.match(text, /Today is 2026-09-15/); - assert.match( - text, - /38\.5800, -121\.4900, camera 1235 m above ground, near Sacramento/, - ); - assert.match(text, /Previous plan: \{"intent":"map_features"/); - assert.match(text, /Request: only after 2024$/); -}); - -test('planQuery sends a cached system block and structured output config', async () => { - const calls = []; - const anthropic = { - messages: { - async create(params) { - calls.push(params); - return { - stop_reason: 'end_turn', - content: [ - { - type: 'text', - text: JSON.stringify({ - ...basePlan, - values: ['object--fire-hydrant', 'bogus'], - }), - }, - ], - usage: { input_tokens: 10, output_tokens: 5 }, - }; - }, - }, - }; - const result = await planQuery( - { query: 'show me all fire hydrants in Sacramento', context: {} }, - { - anthropic, - model: 'claude-opus-5', - now: new Date('2026-09-15T12:00:00Z'), - }, - ); - assert.equal(calls.length, 1); - assert.equal(calls[0].model, 'claude-opus-5'); - assert.deepEqual(calls[0].system[0].cache_control, { type: 'ephemeral' }); - assert.equal(calls[0].output_config.effort, 'low'); - assert.equal(calls[0].output_config.format.type, 'json_schema'); - assert.equal(calls[0].output_config.format.schema.type, 'object'); - assert.ok(!('$schema' in calls[0].output_config.format.schema)); - assert.ok(calls[0].output_config.format.schema.properties.intent); - assert.deepEqual(result.plan.values, ['object--fire-hydrant']); - assert.deepEqual(result.plan.dropped, ['bogus']); - assert.equal(result.plan.layer, 'points'); -}); - -test('planQuery turns a refusal into an unsupported plan instead of throwing', async () => { - const anthropic = { - messages: { - async create() { - return { stop_reason: 'refusal', usage: null }; - }, - }, - }; - const result = await planQuery( - { query: 'x', context: {} }, - { anthropic, model: 'm' }, - ); - assert.equal(result.plan.intent, 'unsupported'); -}); - -test('normalizeFeatureQuery validates bbox, layer, values and the tile cap', () => { - const ok = normalizeFeatureQuery({ - bbox: [-121.56, 38.44, -121.36, 38.69], - layer: 'points', - values: ['Object--Fire-Hydrant', ''], - seenAfter: '2024-01-01', - }); - assert.equal(ok.ok, true); - assert.equal(ok.request.tiles.length, 160); - assert.deepEqual(ok.request.values, ['object--fire-hydrant']); - assert.equal(ok.request.since, Date.parse('2024-01-01')); - assert.equal(ok.request.until, null); - assert.equal(normalizeFeatureQuery({ bbox: 'nope' }).status, 400); - const huge = normalizeFeatureQuery({ bbox: [-125, 32, -114, 42] }); - assert.equal(huge.ok, false); - assert.equal(huge.status, 413); - assert.equal(huge.error, 'area_too_large'); -}); - // ── Tile trimming (relocated from server/providers/mapillary/trim.test.mjs) ── /** Build a minimal MVT: layers with a name, a version and one opaque feature. */ function tile(layers) { diff --git a/src/tooling/mapillaryQa.test.mjs b/src/tooling/mapillaryQa.test.mjs index 001fc8dd4..b97efda0a 100644 --- a/src/tooling/mapillaryQa.test.mjs +++ b/src/tooling/mapillaryQa.test.mjs @@ -2,7 +2,6 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import test from 'node:test'; import { - hydrantPlan, isCollapsed, RAIL_ORDER, VIEWPORTS, @@ -15,14 +14,6 @@ test('the QA harness exercises both review viewports', () => { ); }); -test('the ready-made plan is executable without the planner', () => { - const plan = hydrantPlan(); - assert.equal(plan.intent, 'map_features'); - assert.equal(plan.layer, 'points'); - assert.equal(plan.use_current_view, true); - assert.deepEqual(plan.values, ['object--fire-hydrant']); -}); - test('rail order puts Street Level between CCTV and Context', () => { assert.deepEqual(RAIL_ORDER, [ 'pp-toggles', diff --git a/src/ui/mapillaryControls.js b/src/ui/mapillaryControls.js index 1c3653b22..64a6f512a 100644 --- a/src/ui/mapillaryControls.js +++ b/src/ui/mapillaryControls.js @@ -7,8 +7,8 @@ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; /** - * Own the Street Level panel: the query box, result chips, progress, the - * imagery filters and the embedded MapillaryJS viewer. The panel itself is + * Own the Street Level panel: the imagery filters, the legend and the + * embedded MapillaryJS viewer. The panel itself is * ordinary GEV chrome (collapse button, rail layout, persistence) driven by * the application shell; this class only fills the body and asks the shell * to open the panel when something worth seeing arrives. @@ -25,7 +25,6 @@ export class MapillaryControls { this._view = null; this._wasEnabled = null; this._wasStreetOpen = false; - this._lastStage = null; this._wrapHome = null; this._expandReturnFocus = null; this._elements = this._collect(); @@ -39,20 +38,8 @@ export class MapillaryControls { controls: byId('mly-controls'), enableBtn: byId('mly-enable-btn'), lookBtn: byId('mly-look-btn'), - form: byId('mly-query-form'), - input: byId('mly-query-input'), - runBtn: byId('mly-query-run'), - hint: byId('mly-ai-hint'), - answer: byId('mly-answer'), error: byId('mly-error'), errorText: byId('mly-error-text'), - progress: byId('mly-progress'), - progressFill: byId('mly-progress-fill'), - progressLabel: byId('mly-progress-label'), - results: byId('mly-results'), - chips: byId('mly-result-chips'), - frameBtn: byId('mly-frame-btn'), - clearBtn: byId('mly-clear-btn'), sinceSelect: byId('mly-since'), legend: byId('mly-legend'), followBtn: byId('mly-follow-btn'), @@ -85,28 +72,6 @@ export class MapillaryControls { if (!(await this._ensureEnabled())) return; this.mapillary.openNearest?.(); }); - this.listen(el.form, 'submit', (event) => { - event.preventDefault(); - if (this._view?.query.submitIsStop) this.mapillary.abortQuery?.(); - else this._run(el.input?.value); - }); - for (const button of this.root.querySelectorAll('[data-mly-suggestion]')) { - this.listen(button, 'click', () => { - if (el.input) el.input.value = button.dataset.mlySuggestion || ''; - this._run(button.dataset.mlySuggestion); - }); - } - this.listen(el.frameBtn, 'click', () => this.mapillary.frameResults?.()); - this.listen(el.clearBtn, 'click', () => this.mapillary.clearQuery?.()); - this.listen(el.chips, 'click', (event) => { - const more = event.target.closest?.('[data-mly-more]'); - if (!more) return; - const expanded = el.chips.classList.toggle('is-expanded'); - more.setAttribute('aria-expanded', String(expanded)); - more.textContent = expanded - ? 'FEWER' - : `+${more.dataset.mlyMore} CLASSES`; - }); for (const button of this.root.querySelectorAll('[data-mly-pano]')) { this.listen(button, 'click', () => this.mapillary.setCoverageFilter?.({ pano: button.dataset.mlyPano }), @@ -148,13 +113,6 @@ export class MapillaryControls { } // The expanded viewer is a dialog: Esc closes it, Tab stays inside. this.listen(el.viewerWrap, 'keydown', (event) => this._onDialogKey(event)); - // Keep the app's keyboard shortcuts from firing while typing a query, - // but let Escape through so GEV's panel disclosure can collapse the panel. - const shield = (event) => { - if (event.key !== 'Escape') event.stopPropagation(); - }; - this.listen(el.input, 'keydown', shield); - this.listen(el.input, 'keyup', shield); // MapillaryJS only tracks window resizes; the panel resizes on its own. if (typeof ResizeObserver === 'function' && el.viewer) { let queued = false; @@ -192,13 +150,6 @@ export class MapillaryControls { } } - async _run(prompt) { - const text = String(prompt || '').trim(); - if (!text) return; - if (!(await this._ensureEnabled())) return; - this.mapillary.runQuery?.(text); - } - connect() { this._unsubscribe?.(); this._unsubscribe = null; @@ -304,8 +255,7 @@ export class MapillaryControls { this._view = view; this._renderHeader(view); this._renderGate(view); - this._renderQuery(view); - this._renderResults(view); + this._renderError(view); this._renderFilters(view); this._renderViewer(view, state); this._renderMeta(view); @@ -333,82 +283,11 @@ export class MapillaryControls { if (el.controls) el.controls.disabled = view.controlsDisabled; } - _renderQuery(view) { + _renderError(view) { const el = this._elements; - const { query } = view; - if (el.input) { - el.input.placeholder = query.placeholder; - el.input.disabled = query.inputDisabled; - } - if (el.runBtn) { - el.runBtn.textContent = query.submitLabel; - el.runBtn.classList.toggle('is-stop', query.submitIsStop); - el.runBtn.disabled = query.inputDisabled && !query.submitIsStop; - el.runBtn.setAttribute( - 'aria-label', - query.submitIsStop ? 'Stop the query' : 'Ask', - ); - } - for (const button of this.root.querySelectorAll('[data-mly-suggestion]')) - button.disabled = query.suggestionsDisabled; - if (el.hint) { - el.hint.textContent = query.hint; - el.hint.classList.toggle('is-warn', query.hintWarn); - } - if (el.answer) el.answer.textContent = view.answer; - if (el.error) { - el.error.hidden = !view.error; - if (el.errorText) el.errorText.textContent = view.error || ''; - } - if (el.progress) { - el.progress.hidden = !view.progress.visible; - el.progress.classList.toggle( - 'is-indeterminate', - view.progress.indeterminate, - ); - if (el.progressFill && !view.progress.indeterminate) - el.progressFill.style.width = `${view.progress.percent}%`; - if (el.progressFill && view.progress.indeterminate) - el.progressFill.style.removeProperty('width'); - if (el.progressLabel) el.progressLabel.textContent = view.progress.label; - } - } - - _renderResults(view) { - const el = this._elements; - if (!el.results) return; - el.results.hidden = !view.results.visible; - if (!view.results.visible || !el.chips) return; - const expanded = el.chips.classList.contains('is-expanded'); - const chip = (entry, overflow) => { - const node = document.createElement('span'); - node.className = `mly-value-chip${overflow ? ' is-overflow' : ''}`; - node.title = entry.value; - const dot = document.createElement('i'); - dot.style.background = entry.color; - const text = document.createElement('span'); - text.textContent = entry.label; - const num = document.createElement('b'); - num.textContent = entry.count; - node.append(dot, text, num); - return node; - }; - const nodes = view.results.all.map((entry, index) => - chip(entry, index >= view.results.chips.length), - ); - if (view.results.more) { - const more = document.createElement('button'); - more.type = 'button'; - more.className = 'mly-value-chip'; - more.dataset.mlyMore = String(view.results.more.count); - more.title = view.results.more.title; - more.setAttribute('aria-expanded', String(expanded)); - more.textContent = expanded - ? 'FEWER' - : `+${view.results.more.count} CLASSES`; - nodes.push(more); - } else el.chips.classList.remove('is-expanded'); - el.chips.replaceChildren(...nodes); + if (!el.error) return; + el.error.hidden = !view.error; + if (el.errorText) el.errorText.textContent = view.error || ''; } _renderFilters(view) { @@ -471,11 +350,6 @@ export class MapillaryControls { this._wasEnabled = enabled; if (state.street.open && !this._wasStreetOpen) this.setCollapsed(false); this._wasStreetOpen = state.street.open === true; - if (state.query.stage !== this._lastStage) { - this._lastStage = state.query.stage; - if (state.query.stage === 'done' || state.query.stage === 'error') - this.setCollapsed(false); - } } destroy() { diff --git a/src/ui/mapillaryPresentation.js b/src/ui/mapillaryPresentation.js index a0166c1cf..62ab2e59c 100644 --- a/src/ui/mapillaryPresentation.js +++ b/src/ui/mapillaryPresentation.js @@ -3,15 +3,6 @@ * renders. Pure: no DOM, no layer calls, so every wording decision is testable. */ -const HINT_READY = - 'Objects, traffic signs, coverage or a street view of any place. Follow-ups refine the last answer.'; -const HINT_NO_PLANNER = - 'Plain-English questions need an Anthropic key in POWER UP. Coverage, photos and filters work without it.'; -const IDLE_ANSWER_OFF = - 'Turn on Street Level to see Mapillary coverage on the globe.'; -const IDLE_ANSWER_ON = - 'Click a green line to see its photos, or ask for objects and signs.'; - function formatDate(ms) { if (!Number.isFinite(ms)) return ''; try { @@ -30,84 +21,12 @@ export function mapillaryImageUrl(imageId) { function presentStatus(state) { if (state.keyRequired) return { text: 'KEY REQUIRED', tone: 'warn' }; - if (state.query.busy) - return { text: `AI · ${state.query.stage.toUpperCase()}`, tone: 'busy' }; - if (state.coverage.loading || state.features.loading) - return { text: 'LOADING', tone: 'busy' }; + if (state.coverage.loading) return { text: 'LOADING', tone: 'busy' }; return state.enabled ? { text: 'ON', tone: 'on' } : { text: 'OFF', tone: '' }; } -function presentProgress(state) { - const { stage, busy } = state.query; - const { progress, loading, total } = state.features; - if (loading && progress.tiles > 0) { - const pct = Math.round((progress.done / progress.tiles) * 100); - return { - visible: true, - indeterminate: false, - percent: pct, - label: `${progress.done}/${progress.tiles} tiles · ${total.toLocaleString()} found`, - }; - } - if (busy && stage === 'planning') - return { - visible: true, - indeterminate: true, - percent: 0, - label: 'Planning…', - }; - if (busy && stage === 'resolving') - return { - visible: true, - indeterminate: true, - percent: 0, - label: `Finding ${state.query.place || 'the area'}…`, - }; - if (busy) - return { - visible: true, - indeterminate: true, - percent: 0, - label: 'Working…', - }; - return { visible: false, indeterminate: false, percent: 0, label: '' }; -} - -function presentResults(state, { maxChips = 8 } = {}) { - const counts = state.features.counts || []; - const visible = state.features.total > 0 || state.features.hasBbox === true; - const chips = counts.map(({ value, count, label, color }) => ({ - value, - label, - color, - count: count.toLocaleString(), - })); - const hidden = chips.slice(maxChips); - return { - visible, - chips: chips.slice(0, maxChips), - more: hidden.length - ? { - count: hidden.length, - title: hidden.map((chip) => `${chip.label} ${chip.count}`).join(', '), - } - : null, - all: chips, - }; -} - function presentViewer(state) { const { street } = state; - const left = []; - if (street.feature?.label) - left.push( - `${street.feature.label}${street.feature.imageCount ? ` · ${street.feature.imageCount} sightings` : ''}`, - ); - if (street.highlight?.count) - left.push( - `${street.highlight.count} detection${street.highlight.count === 1 ? '' : 's'} outlined`, - ); - if (street.creator) left.push(`Image by ${street.creator}`); const right = []; if (street.isPano) right.push('360°'); if (Number.isFinite(street.bearing)) @@ -117,7 +36,7 @@ function presentViewer(state) { open: street.open === true, loading: street.loading === true && !street.imageId, renderMode: street.renderMode === 'fill' ? 'fill' : 'letterbox', - captionLeft: left.join(' · '), + captionLeft: street.creator ? `Image by ${street.creator}` : '', captionRight: right.join(' · '), link: street.imageId ? mapillaryImageUrl(street.imageId) : null, follow: { @@ -133,17 +52,9 @@ function presentMeta(state) { return state.sequence.loading ? 'Loading this sequence…' : `${state.sequence.images.toLocaleString()} images in this sequence · Esc clears`; - const bits = []; if (state.coverage.zoom && state.coverage.sequences > 0) - bits.push( - `${state.coverage.sequences.toLocaleString()} sequences in view · click a line for its photos`, - ); - else if (state.coverage.hint) bits.push(state.coverage.hint); - if (state.query.usage?.model) - bits.push( - `${state.query.usage.model} · ${state.query.usage.input_tokens ?? '?'}→${state.query.usage.output_tokens ?? '?'} tok`, - ); - return bits.join(' · '); + return `${state.coverage.sequences.toLocaleString()} sequences in view · click a line for its photos`; + return state.coverage.hint || ''; } /** @@ -153,18 +64,6 @@ function presentMeta(state) { export function presentMapillaryPanel(state) { const enabled = state.enabled === true; const keyRequired = state.keyRequired === true; - // Unknown (status not fetched yet) reads as available: the server answers - // a missing key with its own error, and the panel must not claim one is missing. - const planner = state.planner !== false; - const busy = state.query.busy === true; - const error = state.query.error || state.street.error || null; - let answer = state.query.answer || ''; - if (!answer && !error) { - if (busy) answer = ''; - else if (!enabled) answer = IDLE_ANSWER_OFF; - else if (state.coverage.hint) answer = state.coverage.hint; - else answer = IDLE_ANSWER_ON; - } return { enabled, keyRequired, @@ -174,30 +73,12 @@ export function presentMapillaryPanel(state) { text: enabled ? 'STREET LEVEL ON' : 'STREET LEVEL OFF', pressed: enabled, }, - query: { - placeholder: planner - ? 'Ask about objects, signs or a place' - : 'Anthropic key needed — see POWER UP', - inputDisabled: !planner, - suggestionsDisabled: !planner, - hint: planner ? HINT_READY : HINT_NO_PLANNER, - hintWarn: !planner, - model: state.plannerModel ? `· ${state.plannerModel}` : '', - submitLabel: busy ? 'STOP' : 'ASK', - submitIsStop: busy, - }, - progress: presentProgress(state), - answer, - error, - results: presentResults(state), + error: state.street.error || state.coverage.error || null, filter: state.coverage.filter || { pano: 'all', sinceMs: null }, legend: state.coverage.legend || [], viewer: presentViewer(state), meta: presentMeta(state), - /** The panel opens itself at these moments (a native panel stays put otherwise). */ - wantsOpen: - state.street.open === true || - state.query.stage === 'done' || - state.query.stage === 'error', + /** The panel opens itself when an image opens (a native panel stays put otherwise). */ + wantsOpen: state.street.open === true, }; } diff --git a/src/ui/mapillaryPresentation.test.mjs b/src/ui/mapillaryPresentation.test.mjs index d7cfcbb82..a1c9a7562 100644 --- a/src/ui/mapillaryPresentation.test.mjs +++ b/src/ui/mapillaryPresentation.test.mjs @@ -9,8 +9,6 @@ function snapshot(overrides = {}) { const base = { enabled: false, keyRequired: false, - planner: true, - plannerModel: 'claude-opus-5', coverage: { zoom: null, kind: null, @@ -27,19 +25,6 @@ function snapshot(overrides = {}) { error: null, }, sequence: { selectedId: null, images: 0, loading: false }, - features: { - total: 0, - title: '', - loading: false, - progress: { done: 0, tiles: 0 }, - counts: [], - truncated: false, - failed: 0, - iconMode: false, - hasBbox: false, - selectedId: null, - bbox: null, - }, street: { open: false, follow: false, @@ -53,19 +38,6 @@ function snapshot(overrides = {}) { sequenceId: null, creator: null, renderMode: 'letterbox', - feature: null, - highlight: null, - }, - query: { - busy: false, - stage: 'idle', - prompt: '', - place: null, - answer: '', - error: null, - title: '', - usage: null, - history: [], }, }; return deepMerge(base, overrides); @@ -92,90 +64,36 @@ test('a missing Mapillary token gates every control and flags KEY REQUIRED', () assert.deepEqual(view.status, { text: 'KEY REQUIRED', tone: 'warn' }); }); -test('without an Anthropic key the query box explains itself and stays disabled', () => { - const view = presentMapillaryPanel( - snapshot({ planner: false, enabled: true }), - ); - assert.equal(view.query.inputDisabled, true); - assert.equal(view.query.suggestionsDisabled, true); - assert.equal(view.query.hintWarn, true); - assert.match(view.query.hint, /Anthropic key/); - assert.ok(view.query.placeholder.length < 40, 'placeholder fits the field'); - assert.equal(view.controlsDisabled, false); -}); - -test('before the status call answers the query box does not claim a missing key', () => { - const view = presentMapillaryPanel(snapshot({ planner: null })); - assert.equal(view.query.inputDisabled, false); - assert.equal(view.query.hintWarn, false); - assert.doesNotMatch(view.query.placeholder, /Anthropic/); -}); - -test('a running query turns ASK into STOP and shows indeterminate progress per stage', () => { - const planning = presentMapillaryPanel( - snapshot({ enabled: true, query: { busy: true, stage: 'planning' } }), - ); - assert.equal(planning.query.submitLabel, 'STOP'); - assert.equal(planning.query.submitIsStop, true); - assert.deepEqual(planning.progress, { - visible: true, - indeterminate: true, - percent: 0, - label: 'Planning…', +test('status reads LOADING while coverage streams, then ON or OFF', () => { + assert.deepEqual(presentMapillaryPanel(snapshot()).status, { + text: 'OFF', + tone: '', }); - assert.equal(planning.status.text, 'AI · PLANNING'); - - const resolving = presentMapillaryPanel( - snapshot({ - enabled: true, - query: { busy: true, stage: 'resolving', place: 'Sacramento, CA' }, - }), + assert.deepEqual( + presentMapillaryPanel( + snapshot({ enabled: true, coverage: { loading: true } }), + ).status, + { text: 'LOADING', tone: 'busy' }, ); - assert.equal(resolving.progress.label, 'Finding Sacramento, CA…'); - - const fetching = presentMapillaryPanel( - snapshot({ - enabled: true, - query: { busy: true, stage: 'fetching' }, - features: { loading: true, progress: { done: 3, tiles: 12 }, total: 410 }, - }), - ); - assert.equal(fetching.progress.indeterminate, false); - assert.equal(fetching.progress.percent, 25); - assert.equal(fetching.progress.label, '3/12 tiles · 410 found'); + assert.deepEqual(presentMapillaryPanel(snapshot({ enabled: true })).status, { + text: 'ON', + tone: 'on', + }); }); -test('idle copy depends on whether the layer is on', () => { - assert.match( - presentMapillaryPanel(snapshot()).answer, - /Turn on Street Level/, +test('errors from the viewer or the coverage web surface in one alert', () => { + assert.equal(presentMapillaryPanel(snapshot()).error, null); + assert.equal( + presentMapillaryPanel( + snapshot({ street: { error: 'Image could not be opened' } }), + ).error, + 'Image could not be opened', ); - assert.match( - presentMapillaryPanel(snapshot({ enabled: true })).answer, - /Click a green line/, + assert.equal( + presentMapillaryPanel(snapshot({ coverage: { error: 'Tile HTTP 502' } })) + .error, + 'Tile HTTP 502', ); - const view = presentMapillaryPanel( - snapshot({ enabled: true, query: { error: 'Planner unavailable' } }), - ); - assert.equal(view.error, 'Planner unavailable'); - assert.equal(view.answer, ''); -}); - -test('result chips cap at eight and describe the overflow', () => { - const counts = Array.from({ length: 11 }, (_, i) => ({ - value: `object--v${i}`, - label: `Class ${i}`, - color: '#fff', - count: 100 - i, - })); - const view = presentMapillaryPanel( - snapshot({ enabled: true, features: { total: 900, counts } }), - ); - assert.equal(view.results.visible, true); - assert.equal(view.results.chips.length, 8); - assert.equal(view.results.more.count, 3); - assert.match(view.results.more.title, /Class 8 92, Class 9 91, Class 10 90/); - assert.equal(view.results.all.length, 11); }); test('legend passes through in the layer’s order', () => { @@ -187,6 +105,7 @@ test('legend passes through in the layer’s order', () => { }); test('the meta line never mixes the visible-sequence count with the selected sequence', () => { + assert.equal(presentMapillaryPanel(snapshot()).meta, ''); const browsing = presentMapillaryPanel( snapshot({ enabled: true, coverage: { zoom: 14, sequences: 812 } }), ); @@ -200,6 +119,13 @@ test('the meta line never mixes the visible-sequence count with the selected seq ); assert.equal(selected.meta, '33 images in this sequence · Esc clears'); assert.doesNotMatch(selected.meta, /sequences in view/); + const hinted = presentMapillaryPanel( + snapshot({ + enabled: true, + coverage: { hint: 'Point the camera at the globe' }, + }), + ); + assert.equal(hinted.meta, 'Point the camera at the globe'); }); test('viewer caption reads "Image by" left and date right, with a deep link', () => { @@ -213,15 +139,10 @@ test('viewer caption reads "Image by" left and date right, with a deep link', () capturedAt: Date.UTC(2023, 9, 8), bearing: 93.4, isPano: true, - feature: { label: 'Fire hydrant', imageCount: 5 }, - highlight: { count: 2 }, }, }), ); - assert.equal( - view.viewer.captionLeft, - 'Fire hydrant · 5 sightings · 2 detections outlined · Image by mapfool', - ); + assert.equal(view.viewer.captionLeft, 'Image by mapfool'); assert.equal(view.viewer.captionRight, '360° · 93° · 2023-10-08'); assert.equal( view.viewer.link, @@ -231,6 +152,16 @@ test('viewer caption reads "Image by" left and date right, with a deep link', () assert.equal(view.wantsOpen, true); }); +test('an image without a creator name leaves the left caption empty', () => { + const view = presentMapillaryPanel( + snapshot({ + street: { open: true, imageId: '1', capturedAt: Date.UTC(2024, 0, 2) }, + }), + ); + assert.equal(view.viewer.captionLeft, ''); + assert.equal(view.viewer.captionRight, '2024-01-02'); +}); + test('image deep links match the mapillary.com share format', () => { assert.equal( mapillaryImageUrl(1814275685699406), diff --git a/src/ui/styles/mapillary.css b/src/ui/styles/mapillary.css index 57a3ce7ab..5ddd5b4e6 100644 --- a/src/ui/styles/mapillary.css +++ b/src/ui/styles/mapillary.css @@ -110,126 +110,7 @@ pointer-events: none; } -/* ── Query ───────────────────────────────────────────────────────────────── */ -.mly-query { - display: flex; - flex-direction: column; - gap: 6px; -} - -.mly-query-row { - display: flex; - gap: 6px; -} - -#mly-query-input { - flex: 1; - min-width: 0; - padding: 9px 11px; - border-radius: var(--btn-radius); - border: 1px solid rgba(0, 212, 255, 0.45); - background: rgba(0, 212, 255, 0.06); - color: var(--text-primary); - font: inherit; - font-family: var(--font-mono); - font-size: 0.74rem; -} - -#mly-query-input::placeholder { - color: rgba(232, 234, 237, 0.55); -} - -#mly-query-input:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 2px var(--accent-dim); -} - -#mly-query-input:disabled { - border-color: var(--glass-border); - background: rgba(255, 255, 255, 0.03); -} - -.mly-ask { - flex: 0 0 auto; - min-width: 64px; - color: #0a0a0f; - background: var(--accent); - border-color: var(--accent); - font-weight: 600; -} - -.mly-ask:hover:not(:disabled) { - color: #0a0a0f; - background: var(--accent); - box-shadow: 0 0 16px rgba(0, 212, 255, 0.5); -} - -.mly-ask.is-stop { - color: #ff8a80; - background: rgba(255, 88, 88, 0.15); - border-color: #ff7272; -} - -.mly-ask.is-stop:hover:not(:disabled) { - color: #ff8a80; - background: rgba(255, 88, 88, 0.25); - border-color: #ff7272; -} - -.mly-hint { - margin: 0; - font-family: var(--font-sans); - font-size: 0.68rem; - line-height: 1.35; - color: var(--text-secondary); -} - -.mly-hint.is-warn { - color: #ffb300; -} - -.mly-suggestions { - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -.mly-suggestion { - font-family: var(--font-mono); - font-size: var(--mly-text-small); - letter-spacing: 0.06em; - padding: 3px 8px; - border-radius: 999px; - border: 1px solid var(--glass-border); - background: rgba(255, 255, 255, 0.03); - color: var(--text-secondary); - cursor: pointer; -} - -.mly-suggestion:hover:not(:disabled) { - color: var(--text-primary); - border-color: var(--glass-border-hover); -} - -.mly-suggestion:disabled { - opacity: 0.5; - cursor: default; -} - -/* ── Answer, error, progress ─────────────────────────────────────────────── */ -.mly-answer { - font-family: var(--font-sans); - font-size: 0.74rem; - line-height: 1.4; - color: var(--text-primary); - min-height: 1.2em; -} - -.mly-answer:empty { - display: none; -} - +/* ── Errors ──────────────────────────────────────────────────────────────── */ .mly-error { font-family: var(--font-sans); font-size: 0.72rem; @@ -237,102 +118,6 @@ color: #ff8a80; } -.mly-progress { - position: relative; - height: 14px; - border-radius: 999px; - overflow: hidden; - background: rgba(255, 255, 255, 0.06); -} - -.mly-progress-fill { - height: 100%; - width: 0%; - background: linear-gradient(90deg, var(--mly-green), var(--accent)); - transition: width 160ms linear; -} - -.mly-progress.is-indeterminate .mly-progress-fill { - width: 40%; - animation: mly-indeterminate 1.4s ease-in-out infinite; -} - -@keyframes mly-indeterminate { - 0% { - transform: translateX(-100%); - } - 100% { - transform: translateX(250%); - } -} - -.mly-progress-label { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - font-family: var(--font-mono); - font-size: var(--mly-text-small); - letter-spacing: 0.1em; - color: var(--text-primary); - text-shadow: 0 0 4px rgba(0, 0, 0, 0.8); -} - -/* ── Results ─────────────────────────────────────────────────────────────── */ -.mly-results { - display: flex; - flex-direction: column; - gap: 8px; -} - -.mly-chips { - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -.mly-chips.is-expanded .mly-value-chip.is-overflow { - display: inline-flex; -} - -.mly-value-chip { - display: inline-flex; - align-items: center; - gap: 6px; - font-family: var(--font-mono); - font-size: var(--mly-text-small); - padding: 3px 8px; - border-radius: 999px; - border: 1px solid var(--glass-border); - background: transparent; - color: var(--text-primary); -} - -.mly-value-chip.is-overflow { - display: none; -} - -.mly-value-chip i { - width: 8px; - height: 8px; - border-radius: 50%; - display: inline-block; -} - -.mly-value-chip b { - color: var(--text-secondary); - font-weight: 500; -} - -button.mly-value-chip { - cursor: pointer; -} - -button.mly-value-chip:hover { - border-color: var(--glass-border-hover); -} - /* ── Filters and legend ──────────────────────────────────────────────────── */ .mly-filters { display: flex; @@ -648,11 +433,6 @@ button.mly-value-chip:hover { gap: 6px; } - #mapillary-panel .mly-hint, - #mapillary-panel .mly-suggestions { - display: none; - } - .mly-viewer { min-height: 150px; } diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index 5b146bd7c..0aea7270c 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -92,33 +92,7 @@ -
- -
- - -
-

Objects, traffic signs, coverage or a street view of any place. Follow-ups refine the last answer.

-
- - - - -
-
-
- -
IMAGERY