mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +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
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user