mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(mapillary): street-level layer core — coverage, sequences, viewer bridge, query engine
Camera-driven Mapillary coverage (z0–5 overview points from orbit, z11–14 ground-clamped sequence lines below 60 km), per-sequence image cones, an embedded MapillaryJS viewer bridge with a camera-follow mode, sprite icons for map features, and a query engine that turns a plan into a streamed z14 tile fan-out. Registers an on-globe CC BY-SA 4.0 credit while enabled. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
0d41b6be54
commit
fa5710bcf2
46 files changed
+9935
-32
No files matched your search
@@ -19,6 +19,7 @@ import { aisLiveProxy } from './vessels/ais-live.js';
|
||||
import { trackBackfillProxies } from './aircraft/tracks.js';
|
||||
import { openAiRealtimeProxy } from './openai.js';
|
||||
import { googlePlacesContextProxy } from './places.js';
|
||||
import { mapillaryProxy } from './mapillary.js';
|
||||
import { keySetupEndpoint } from '../standalone/key-setup.js';
|
||||
|
||||
/** Construct the local provider plugins in their established order. */
|
||||
@@ -45,6 +46,7 @@ function localProviderPlugins() {
|
||||
trackBackfillProxies(),
|
||||
openAiRealtimeProxy(),
|
||||
googlePlacesContextProxy(),
|
||||
mapillaryProxy(),
|
||||
keySetupEndpoint(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { installMapillaryRoutes } from './mapillary/routes.js';
|
||||
|
||||
/**
|
||||
* Vite plugin: Mapillary street-level data.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
function mapillaryProxy() {
|
||||
return {
|
||||
name: 'mapillary-proxy',
|
||||
configureServer(server) {
|
||||
installMapillaryRoutes(server.middlewares);
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
installMapillaryRoutes(server.middlewares);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { mapillaryProxy };
|
||||
@@ -0,0 +1,93 @@
|
||||
import path from 'node:path';
|
||||
|
||||
/** Mapillary vector tile root; the path template is /{layer}/2/{z}/{x}/{y}. */
|
||||
export const MAPILLARY_TILE_HOST = 'https://tiles.mapillary.com/maps/vtp';
|
||||
|
||||
/** Public tile layer names this proxy exposes, mapped to Mapillary's ids. */
|
||||
export const TILE_LAYERS = Object.freeze({
|
||||
// Overview points (z0–5), sequences (z6–14) and image points (z14 only).
|
||||
// The z14 `image` point layer is ~98% of a 10 MB tile and unused here:
|
||||
// image positions come from the graph API per sequence. Dropped in transit.
|
||||
coverage: Object.freeze({
|
||||
upstream: 'mly1_public',
|
||||
minZoom: 0,
|
||||
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(),
|
||||
'.gev-cache',
|
||||
'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.
|
||||
*/
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
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. */
|
||||
export 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();
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { z } from 'zod';
|
||||
import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/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.
|
||||
*/
|
||||
export 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 `<family>--*`:',
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.parse({
|
||||
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: zodOutputFormat(PlanSchema), 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,
|
||||
};
|
||||
}
|
||||
if (!response.parsed_output)
|
||||
throw Object.assign(new Error('Planner returned no plan'), { status: 502 });
|
||||
return {
|
||||
plan: finalizePlan(response.parsed_output),
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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';
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/** GET /api/mapillary/status — what this install can do, never a 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 },
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /api/mapillary/tiles/{layer}/{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],
|
||||
);
|
||||
if (!match)
|
||||
return sendJson(res, 400, {
|
||||
error: 'Tile path must be /{layer}/{z}/{x}/{y}',
|
||||
});
|
||||
if (!mapillaryToken())
|
||||
return sendJson(res, 503, { error: 'no_key', keyRequired: true });
|
||||
const abandoned = new AbortController();
|
||||
req.on?.('aborted', () => abandoned.abort());
|
||||
res.on?.('close', () => abandoned.abort());
|
||||
try {
|
||||
const { bytes, source } = await fetchTile(
|
||||
{ layer: match[1], z: match[2], x: match[3], y: match[4] },
|
||||
{ signal: abandoned.signal },
|
||||
);
|
||||
if (res.writableEnded) return;
|
||||
res.statusCode = bytes.length ? 200 : 204;
|
||||
res.setHeader('Content-Type', 'application/x-protobuf');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
res.setHeader('X-Gev-Cache', source);
|
||||
res.end(bytes.length ? bytes : undefined);
|
||||
} catch (error) {
|
||||
if (res.writableEnded || abandoned.signal.aborted) return;
|
||||
if (error instanceof TileRequestError)
|
||||
return sendJson(res, error.status, { error: error.message });
|
||||
if (error instanceof TileUpstreamError)
|
||||
return sendJson(res, error.status >= 500 ? 502 : error.status, {
|
||||
error: error.message,
|
||||
});
|
||||
sendJson(res, 502, { error: error?.message || 'Tile fetch failed' });
|
||||
}
|
||||
}
|
||||
|
||||
/** Attach every Mapillary route to a connect-style middleware stack. */
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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<string, Promise<Buffer|null>>} */
|
||||
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('<svg')
|
||||
)
|
||||
return null;
|
||||
const file = spritePath(value);
|
||||
fsp
|
||||
.mkdir(path.dirname(file), { recursive: true })
|
||||
.then(() => fsp.writeFile(file, bytes))
|
||||
.catch(() => {});
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mapillary/sprite/<value>.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' }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import path from 'node:path';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { stripTileLayers } from './trim.js';
|
||||
import {
|
||||
MAPILLARY_TILE_HOST,
|
||||
TILE_LAYERS,
|
||||
TILE_DISK_DIR,
|
||||
TILE_DISK_TTL_MS,
|
||||
TILE_MAX_BYTES,
|
||||
TILE_MEMORY_BUDGET_BYTES,
|
||||
TILE_FETCH_TIMEOUT_MS,
|
||||
mapillaryToken,
|
||||
} from './constants.js';
|
||||
|
||||
/** Thrown for a request this proxy refuses before contacting Mapillary. */
|
||||
export class TileRequestError extends Error {
|
||||
constructor(message, status = 400) {
|
||||
super(message);
|
||||
this.name = 'TileRequestError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when Mapillary answered with an error status. */
|
||||
export class TileUpstreamError extends Error {
|
||||
constructor(status, message) {
|
||||
super(message || `Mapillary tiles HTTP ${status}`);
|
||||
this.name = 'TileUpstreamError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize a tile address. Layer names are the proxy's public
|
||||
* ids (coverage/points/signs); zoom must be inside the layer's published range.
|
||||
* @returns {{layer:string, upstream:string, z:number, x:number, y:number, key:string}}
|
||||
*/
|
||||
export function normalizeTileAddress({ layer, z, x, y }) {
|
||||
const spec = TILE_LAYERS[layer];
|
||||
if (!spec) throw new TileRequestError(`Unknown tile layer: ${layer}`);
|
||||
const zi = Number(z);
|
||||
const xi = Number(x);
|
||||
const yi = Number(y);
|
||||
if (
|
||||
![zi, xi, yi].every((v) => Number.isInteger(v) && v >= 0) ||
|
||||
zi < spec.minZoom ||
|
||||
zi > spec.maxZoom
|
||||
)
|
||||
throw new TileRequestError(
|
||||
`Tile zoom for ${layer} must be ${spec.minZoom}–${spec.maxZoom}`,
|
||||
);
|
||||
const n = 2 ** zi;
|
||||
if (xi >= n || yi >= n)
|
||||
throw new TileRequestError('Tile address out of range');
|
||||
return {
|
||||
layer,
|
||||
upstream: spec.upstream,
|
||||
dropLayers: spec.dropLayers || [],
|
||||
z: zi,
|
||||
x: xi,
|
||||
y: yi,
|
||||
key: `${layer}/${zi}/${xi}/${yi}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** @type {Map<string, {bytes: Buffer, at: number}>} insertion-ordered LRU */
|
||||
const _memory = new Map();
|
||||
let _memoryBytes = 0;
|
||||
/** @type {Map<string, Promise<Buffer>>} */
|
||||
const _inFlight = new Map();
|
||||
|
||||
function memoryGet(key) {
|
||||
const hit = _memory.get(key);
|
||||
if (!hit) return null;
|
||||
// Re-insert to mark as most recently used.
|
||||
_memory.delete(key);
|
||||
_memory.set(key, hit);
|
||||
return hit.bytes;
|
||||
}
|
||||
|
||||
function memoryPut(key, bytes) {
|
||||
if (bytes.length > TILE_MEMORY_BUDGET_BYTES / 2) return;
|
||||
const existing = _memory.get(key);
|
||||
if (existing) _memoryBytes -= existing.bytes.length;
|
||||
_memory.set(key, { bytes, at: Date.now() });
|
||||
_memoryBytes += bytes.length;
|
||||
while (_memoryBytes > TILE_MEMORY_BUDGET_BYTES && _memory.size) {
|
||||
const [oldest, entry] = _memory.entries().next().value;
|
||||
_memory.delete(oldest);
|
||||
_memoryBytes -= entry.bytes.length;
|
||||
}
|
||||
}
|
||||
|
||||
function diskPath({ layer, z, x, y }) {
|
||||
return path.join(TILE_DISK_DIR, layer, String(z), `${x}-${y}.pbf`);
|
||||
}
|
||||
|
||||
async function readDisk(address) {
|
||||
const file = diskPath(address);
|
||||
try {
|
||||
const stat = await fsp.stat(file);
|
||||
if (Date.now() - stat.mtimeMs > TILE_DISK_TTL_MS) return null;
|
||||
return await fsp.readFile(file);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeDisk(address, bytes) {
|
||||
const file = diskPath(address);
|
||||
fsp
|
||||
.mkdir(path.dirname(file), { recursive: true })
|
||||
.then(() => fsp.writeFile(`${file}.tmp`, bytes))
|
||||
.then(() => fsp.rename(`${file}.tmp`, file))
|
||||
.catch((error) =>
|
||||
console.warn(
|
||||
'[Mapillary Proxy] tile cache write failed:',
|
||||
error?.message || error,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchUpstream(address, signal) {
|
||||
const token = mapillaryToken();
|
||||
if (!token) throw new TileRequestError('Mapillary token not configured', 503);
|
||||
const url = `${MAPILLARY_TILE_HOST}/${address.upstream}/2/${address.z}/${address.x}/${address.y}?access_token=${encodeURIComponent(token)}`;
|
||||
const timeout = AbortSignal.timeout(TILE_FETCH_TIMEOUT_MS);
|
||||
const response = await fetch(url, {
|
||||
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
||||
headers: { Accept: 'application/x-protobuf' },
|
||||
});
|
||||
if (response.status === 404 || response.status === 204) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
throw new TileUpstreamError(response.status);
|
||||
}
|
||||
const declared = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(declared) && declared > TILE_MAX_BYTES) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
throw new TileUpstreamError(502, 'Mapillary tile exceeds size cap');
|
||||
}
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
if (bytes.length > TILE_MAX_BYTES)
|
||||
throw new TileUpstreamError(502, 'Mapillary tile exceeds size cap');
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Strip the layers this proxy never serves for the address's layer spec. */
|
||||
function trim(address, bytes) {
|
||||
if (!address.dropLayers.length || !bytes.length) return bytes;
|
||||
try {
|
||||
return stripTileLayers(bytes, address.dropLayers);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[Mapillary Proxy] tile trim failed, serving raw:',
|
||||
error?.message || error,
|
||||
);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one tile through memory, disk and in-flight coalescing, then
|
||||
* Mapillary. Returns the raw protobuf bytes (empty for a tile with no data).
|
||||
* @param {{layer:string,z:number|string,x:number|string,y:number|string}} request
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<{bytes: Buffer, source: 'memory'|'disk'|'inflight'|'upstream', address: object}>}
|
||||
*/
|
||||
export async function fetchTile(request, { signal } = {}) {
|
||||
const address = normalizeTileAddress(request);
|
||||
const memory = memoryGet(address.key);
|
||||
if (memory) return { bytes: memory, source: 'memory', address };
|
||||
const disk = await readDisk(address);
|
||||
if (disk) {
|
||||
// Older cache files may still hold the untrimmed tile: trim and replace.
|
||||
const bytes = trim(address, disk);
|
||||
if (bytes !== disk) writeDisk(address, bytes);
|
||||
memoryPut(address.key, bytes);
|
||||
return { bytes, source: 'disk', address };
|
||||
}
|
||||
const pending = _inFlight.get(address.key);
|
||||
if (pending) return { bytes: await pending, source: 'inflight', address };
|
||||
const work = fetchUpstream(address, signal)
|
||||
.then((raw) => trim(address, raw))
|
||||
.then((bytes) => {
|
||||
memoryPut(address.key, bytes);
|
||||
writeDisk(address, bytes);
|
||||
return bytes;
|
||||
})
|
||||
.finally(() => _inFlight.delete(address.key));
|
||||
_inFlight.set(address.key, work);
|
||||
return { bytes: await work, source: 'upstream', address };
|
||||
}
|
||||
|
||||
/** Test seam: forget every cached tile held in memory. */
|
||||
export function _resetTileMemoryForTest() {
|
||||
_memory.clear();
|
||||
_memoryBytes = 0;
|
||||
_inFlight.clear();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { PbfReader, PbfWriter } from 'pbf';
|
||||
|
||||
/** Field number of `layers` in a Mapbox Vector Tile, and of `name` in a layer. */
|
||||
const TILE_LAYER_FIELD = 3;
|
||||
const LAYER_NAME_FIELD = 1;
|
||||
|
||||
/** Read a layer's name without touching its features. */
|
||||
function layerName(layerBytes) {
|
||||
const pbf = new PbfReader(layerBytes);
|
||||
let name = '';
|
||||
pbf.readFields((tag, _result, reader) => {
|
||||
if (tag !== LAYER_NAME_FIELD) return;
|
||||
name = reader.readString();
|
||||
reader.pos = reader.length; // the name is all we need
|
||||
}, null);
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop whole layers from a vector tile at the protobuf level. Nothing inside
|
||||
* a kept layer is decoded or re-encoded, so a 12 MB tile whose bulk is an
|
||||
* unused layer trims in a few milliseconds. Returns the input when nothing
|
||||
* was dropped.
|
||||
* @param {Buffer|Uint8Array} bytes
|
||||
* @param {Iterable<string>} dropNames
|
||||
*/
|
||||
export function stripTileLayers(bytes, dropNames) {
|
||||
const drop = new Set(dropNames || []);
|
||||
if (!bytes?.length || !drop.size) return bytes;
|
||||
const reader = new PbfReader(bytes);
|
||||
const writer = new PbfWriter();
|
||||
let dropped = false;
|
||||
reader.readFields((tag, _result, pbf) => {
|
||||
if (tag !== TILE_LAYER_FIELD) return; // pbf skips what we leave alone
|
||||
const end = pbf.readVarint() + pbf.pos;
|
||||
const layerBytes = bytes.subarray(pbf.pos, end);
|
||||
pbf.pos = end;
|
||||
if (drop.has(layerName(layerBytes))) {
|
||||
dropped = true;
|
||||
return;
|
||||
}
|
||||
writer.writeBytesField(TILE_LAYER_FIELD, layerBytes);
|
||||
}, null);
|
||||
return dropped ? Buffer.from(writer.finish()) : bytes;
|
||||
}
|
||||
|
||||
/** Names of the layers inside a vector tile, in order. */
|
||||
export function listTileLayers(bytes) {
|
||||
const names = [];
|
||||
if (!bytes?.length) return names;
|
||||
new PbfReader(bytes).readFields((tag, _result, pbf) => {
|
||||
if (tag !== TILE_LAYER_FIELD) return;
|
||||
const end = pbf.readVarint() + pbf.pos;
|
||||
names.push(layerName(bytes.subarray(pbf.pos, end)));
|
||||
pbf.pos = end;
|
||||
}, null);
|
||||
return names;
|
||||
}
|
||||
Reference in new issue
Block a user