diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 4973bd484..57fe34f7c 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -907,6 +907,21 @@ "src/layers/streetLevel/providers/google/provider.test.mjs", "src/layers/streetLevel/providers/google/viewer.test.mjs", "src/layers/streetLevel/providers/google/mapsLoader.test.mjs", + "src/layers/streetLevel/providers/kartaview/index.js", + "src/layers/streetLevel/providers/kartaview/policy.js", + "src/layers/streetLevel/providers/kartaview/source.js", + "src/layers/streetLevel/providers/kartaview/decode.js", + "src/layers/streetLevel/providers/kartaview/viewer.js", + "src/layers/streetLevel/providers/kartaview/panorama.js", + "src/layers/streetLevel/lineCoverage.js", + "src/layers/streetLevel/sequenceCones.js", + "src/layers/streetLevel/providers/kartaview/policy.test.mjs", + "src/layers/streetLevel/providers/kartaview/decode.test.mjs", + "src/layers/streetLevel/providers/kartaview/source.test.mjs", + "src/layers/streetLevel/providers/kartaview/panorama.test.mjs", + "src/layers/streetLevel/providers/kartaview/viewer.test.mjs", + "src/layers/streetLevel/providers/kartaview/provider.test.mjs", + "src/testSupport/kartaViewFakes.mjs", "src/testSupport/googleStreetViewFakes.mjs", "server/providers/mapillary.js", "server/providers/mapillary/routes.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index fc7308ab5..ba1f0c36a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1229,6 +1229,14 @@ "src/layers/streetLevel/providers/google/policy.js", "src/layers/streetLevel/providers/google/viewer.js", "src/layers/streetLevel/providers/google/mapsLoader.js", + "src/layers/streetLevel/providers/kartaview/index.js", + "src/layers/streetLevel/providers/kartaview/policy.js", + "src/layers/streetLevel/providers/kartaview/source.js", + "src/layers/streetLevel/providers/kartaview/decode.js", + "src/layers/streetLevel/providers/kartaview/viewer.js", + "src/layers/streetLevel/providers/kartaview/panorama.js", + "src/layers/streetLevel/lineCoverage.js", + "src/layers/streetLevel/sequenceCones.js", "src/layers/streetLevel/providers/mapillary/source.js", "scripts/qa-street-level.mjs" ], @@ -2189,6 +2197,14 @@ "src/layers/streetLevel/providers/google/policy.js", "src/layers/streetLevel/providers/google/viewer.js", "src/layers/streetLevel/providers/google/mapsLoader.js", + "src/layers/streetLevel/providers/kartaview/index.js", + "src/layers/streetLevel/providers/kartaview/policy.js", + "src/layers/streetLevel/providers/kartaview/source.js", + "src/layers/streetLevel/providers/kartaview/decode.js", + "src/layers/streetLevel/providers/kartaview/viewer.js", + "src/layers/streetLevel/providers/kartaview/panorama.js", + "src/layers/streetLevel/lineCoverage.js", + "src/layers/streetLevel/sequenceCones.js", "src/layers/streetLevel/providers/mapillary/source.js", "src/app/layers/streetLevel.js", "src/layers/alpr/tileRecords.js", @@ -2720,6 +2736,14 @@ "src/layers/streetLevel/providers/google/policy.js", "src/layers/streetLevel/providers/google/viewer.js", "src/layers/streetLevel/providers/google/mapsLoader.js", + "src/layers/streetLevel/providers/kartaview/index.js", + "src/layers/streetLevel/providers/kartaview/policy.js", + "src/layers/streetLevel/providers/kartaview/source.js", + "src/layers/streetLevel/providers/kartaview/decode.js", + "src/layers/streetLevel/providers/kartaview/viewer.js", + "src/layers/streetLevel/providers/kartaview/panorama.js", + "src/layers/streetLevel/lineCoverage.js", + "src/layers/streetLevel/sequenceCones.js", "src/layers/streetLevel/providers/mapillary/source.js", "src/app/layers/streetLevel.js", "src/layers/alpr/tileRecords.js", diff --git a/src/app/layers/streetLevel.js b/src/app/layers/streetLevel.js index 350e71dea..f176d97de 100644 --- a/src/app/layers/streetLevel.js +++ b/src/app/layers/streetLevel.js @@ -1,6 +1,7 @@ import { createStreetLevelLayer } from '../../layers/streetLevel/index.js'; import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js'; import { createGoogleProvider } from '../../layers/streetLevel/providers/google/index.js'; +import { createKartaViewProvider } from '../../layers/streetLevel/providers/kartaview/index.js'; import * as sprites from '../../data/spriteOrder.js'; import * as picking from '../../data/pickRegistry.js'; import * as input from '../../data/inputOwnership.js'; @@ -10,12 +11,16 @@ import * as render from '../../renderGovernor.js'; /** The browser Maps key the scene was started with (src/app/scene.js). */ const googleApiKey = () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ || null; -/** Mapillary and Google Street View, once the Mapillary source is supplied; else none. */ +/** + * Mapillary, Google Street View and KartaView, once the Mapillary source is + * supplied; else none. + */ function defaultProviders(sources) { if (sources.mapillary == null) return []; return [ createMapillaryProvider({ source: sources.mapillary }), createGoogleProvider({ getApiKey: googleApiKey }), + createKartaViewProvider(), ]; } diff --git a/src/app/layers/streetLevel.test.mjs b/src/app/layers/streetLevel.test.mjs index 8087a2c38..c2c3ebb16 100644 --- a/src/app/layers/streetLevel.test.mjs +++ b/src/app/layers/streetLevel.test.mjs @@ -28,7 +28,7 @@ test('explicit providers replace the default source and an empty list removes it ); }); -test('the defaults are Mapillary (given its source) and Street View, which starts off and travels as a provider switch', async () => { +test('the defaults are Mapillary (given its source), Street View and KartaView, which start off and travel as provider switches', async () => { const { createDefaultLayerState } = await import('../../data/layerState.js'); const mapillary = { getStatus() {}, @@ -37,7 +37,7 @@ test('the defaults are Mapillary (given its source) and Street View, which start nearestImages() {}, }; const layer = createApplicationStreetLevel({ sources: { mapillary } }); - assert.deepEqual(layer.providerIds, ['mapillary', 'google']); + assert.deepEqual(layer.providerIds, ['mapillary', 'google', 'kartaview']); // A fresh page and a restored default agree: Street View is billed, so off. layer.setParams(createDefaultLayerState().options['street-level']); assert.equal(layer.getParams().google, false); @@ -45,7 +45,12 @@ test('the defaults are Mapillary (given its source) and Street View, which start // A link that lit Street View carries it in the shared switch field. layer.setParams({ providerSwitches: 'google-1' }); assert.equal(layer.getParams().google, true); - assert.equal(layer.getParams().providerSwitches, 'google-1'); + assert.equal(layer.getParams().providerSwitches, 'google-1*kartaview-0'); + // KartaView is keyless and free, but one source shows at a time. + layer.setParams({ providerSwitches: 'kartaview-1' }); + assert.equal(layer.getParams().kartaview, true); + assert.equal(layer.getParams().google, false); + assert.equal(layer.getParams().mapillary, false); // Without the application's Street Level source there are none. assert.deepEqual(createApplicationStreetLevel({}).providerIds, []); }); diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index eed4230ab..c7170f05c 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -109,6 +109,12 @@ export function createStreetLevelLayer({ notify, actions: { openImage: (imageId) => openImage(entry.def.id, imageId), + /** This provider's image nearest a point the user picked on the map. */ + openNearest: (point) => + layer.openNearest(point, { + providerIds: [entry.def.id], + frame: false, + }), /** * Show a provider error, or withdraw it (null). A withdrawal clears * only this provider's own error, never a newer one from elsewhere. @@ -407,6 +413,7 @@ export function createStreetLevelLayer({ hint: hintFor(entry, stats), error: stats.error || null, color: entry.def.colors.coverage, + coverageLabel: entry.def.coverageLabel || null, groundClick: entry.def.groundClick === true, filters: [...filtersOf(entry.def)], }; diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js new file mode 100644 index 000000000..85edd97c1 --- /dev/null +++ b/src/layers/streetLevel/lineCoverage.js @@ -0,0 +1,926 @@ +import * as Cesium from 'cesium'; +import { passesImageryFilter } from './filter.js'; +import { + createHorizonCull, + groundUnderCamera, + metresBetween, + viewFocus, + visibleBbox, + whenIdle, +} from './view.js'; +import { densifyLine, MESH_DENSIFY_DEG } from './groundCast.js'; +import { MESH_SAMPLE_RADIUS_M } from './meshSampler.js'; +import { tileBounds, tilesForBbox } from './tileMath.js'; + +/** Draped lines stay at most this long while a tile's cast lines build. */ +export const SWAP_MAX_WAIT_MS = 4000; +/** Tiles touched by new mesh samples are redrawn at most this often. */ +const REMESH_INTERVAL_MS = 1500; +/** Gap between redrawing one dirty tile and the next. */ +const REMESH_STAGGER_MS = 120; +/** Old-zoom tiles are kept at most this long after a zoom change. */ +const STALE_TILE_MAX_MS = 6000; +/** Sequences per primitive: smaller batches build, and show, sooner. */ +const SEQUENCE_PRIMITIVE_BATCH = 120; + +/** Separates a multi-part sequence's part index from its id in pick ids. */ +const PART_SEPARATOR = '~'; + +/** Pick ids for every part of a sequence; the first part is unsuffixed. */ +function partIds(sequence, prefix) { + return sequence.parts.map((_, index) => + index + ? `${prefix}${sequence.id}${PART_SEPARATOR}${index}` + : `${prefix}${sequence.id}`, + ); +} + +/** Sequence id for any part's pick id (`[~]`), else null. */ +export function sequenceIdFromPick(pickId, prefix) { + if (typeof pickId !== 'string' || !prefix || !pickId.startsWith(prefix)) + return null; + const rest = pickId.slice(prefix.length); + const cut = rest.indexOf(PART_SEPARATOR); + return cut === -1 ? rest : rest.slice(0, cut); +} + +/** + * Whether any point of `box` ({west, south, east, north}) is within the mesh + * sampler's range of `centre`, the ground point under the camera. + */ +export function meshBoxInRange(box, centre) { + // Move the camera's longitude to the box's side of the date line first: + // clamping 179.999 into a box at -179.99 would pick its far edge. + const lon = + centre.lon + + 360 * Math.round(((box.west + box.east) / 2 - centre.lon) / 360); + const nearest = { + lon: Math.min(box.east, Math.max(box.west, lon)), + lat: Math.min(box.north, Math.max(box.south, centre.lat)), + }; + return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M; +} + +/** + * @typedef {object} LineCoverageTuning + * @property {number} maxTiles Line tiles drawn at once. + * @property {number} overviewMaxTiles Overview tiles drawn at once. + * @property {number} maxSequences Lines drawn at once, over every tile. + * @property {number} lineWidthPx + * @property {number} overviewPointPx + * @property {number} moveDebounceMs Camera rest before the tile set is recomputed. + * @property {number} viewRangeMinM Street-zoom coverage reaches at least this far… + * @property {number} viewRangePerHeight …or camera height times this. + * @property {number} viewNearM Ground always covered around a tilted camera. + */ + +/** + * Camera-driven coverage from any XYZ line-tile source: overview points from + * orbit, sequence lines near the ground. In terrain surface mode a tile is + * drawn draped first, then swapped for lines cast to the bare earth (draped + * lines would land on roofs), so coverage never waits on terrain heights. + * + * @param {object} options + * @param {object} options.state Provider state: `context`, `services`, `viewer`, `coverage`, `sequence`, key flags. + * @param {(tile: {x: number, y: number, z: number}, init: {signal: AbortSignal}) => Promise<{sequences: Array, overview: Array}>} options.fetchTile + * A decoded tile. Sequences are `{id, parts, capturedAt, isPano}`; overview + * points `{lon, lat, capturedAt, isPano}`. A rejection may carry + * `keyRequired`, `keyRejected` or `retryAfterSec`. + * @param {(heightM: number|null) => {zoom: number, kind: 'sequence'|'overview'}|null} options.zoomFor + * Tile zoom for a camera height above ground; null draws nothing. + * @param {{coverage: string, selected: string}} options.colors + * @param {string} options.pickPrefix Pick-id prefix of the lines. + * @param {string} options.renderKey Render-governor reason. + * @param {LineCoverageTuning} options.tuning + * @param {{keyRejected?: string, rateLimited: string, tooHigh?: string}} options.messages + * @param {(sequence: object, filter: object) => boolean} [options.passes] Whether the imagery filter keeps a line or point. + */ +export function createLineCoverage({ + state, + fetchTile, + zoomFor, + colors, + pickPrefix, + renderKey, + tuning, + messages, + passes = passesImageryFilter, +}) { + const { render } = state.services; + const perTileSequenceCap = Math.floor(tuning.maxSequences / tuning.maxTiles); + + /** Colour for a line: the provider's, GEV cyan while selected. */ + function sequenceColor({ selected = false } = {}) { + return selected + ? Cesium.Color.fromCssColorString(colors.selected) + : Cesium.Color.fromCssColorString(colors.coverage).withAlpha(0.92); + } + + function requestRender() { + render?.governorRequestRender?.(renderKey); + } + + function ensureTerrainReady() { + return (state.coverage.terrainReady ||= + Cesium.GroundPolylinePrimitive.initializeTerrainHeights()); + } + + function tileKey({ x, y, z }) { + return `${z}/${x}/${y}`; + } + + /** The imagery filter, resolved now: "since N days" moves with the clock. */ + function filter() { + return state.context.getFilter(); + } + + function terrainMode() { + return ( + state.context.getSurface?.() === 'terrain' && + Boolean(state.context.groundCaster) + ); + } + + /** + * A tile's sequences, filtered then capped: capping first could leave a + * dense tile of newer flat captures with no 360° lines at all. + */ + function drawnSequences(entry) { + const drawn = []; + const current = filter(); + for (const sequence of entry.sequenceList) { + if (drawn.length >= perTileSequenceCap) break; + if (passes(sequence, current)) drawn.push(sequence); + } + return drawn; + } + + /** + * Batched primitives for a tile's sequence parts: cast polylines where + * terrain heights are cached, draped ground lines otherwise. Each batch + * records the selection its colours were built with. + */ + function buildSequencePrimitives(sequences) { + const ground = terrainMode() ? state.context.groundCaster : null; + const meshAt = ground ? state.context.meshSampler?.meshAt : undefined; + const selectedId = state.sequence.selectedId ?? null; + const draped = []; + const cast = []; + for (const sequence of sequences) { + const color = Cesium.ColorGeometryInstanceAttribute.fromColor( + sequenceColor({ selected: sequence.id === selectedId }), + ); + const ids = partIds(sequence, pickPrefix); + sequence.parts.forEach((coordinates, index) => { + const flat = ground?.castLine(coordinates, { meshAt }); + let positions; + try { + positions = flat + ? Cesium.Cartesian3.fromDegreesArrayHeights(flat) + : Cesium.Cartesian3.fromDegreesArray(coordinates.flat()); + } catch { + return; + } + if (positions.length < 2) return; + const geometry = flat + ? new Cesium.PolylineGeometry({ + positions, + width: tuning.lineWidthPx, + vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT, + arcType: Cesium.ArcType.NONE, + }) + : new Cesium.GroundPolylineGeometry({ + positions, + width: tuning.lineWidthPx, + }); + (flat ? cast : draped).push( + new Cesium.GeometryInstance({ + geometry, + id: ids[index], + attributes: { color }, + }), + ); + }); + } + const primitives = []; + for (let i = 0; i < draped.length; i += SEQUENCE_PRIMITIVE_BATCH) + primitives.push({ + onGround: true, + selectedId, + primitive: new Cesium.GroundPolylinePrimitive({ + geometryInstances: draped.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), + appearance: new Cesium.PolylineColorAppearance(), + classificationType: Cesium.ClassificationType.BOTH, + asynchronous: true, + allowPicking: true, + }), + }); + for (let i = 0; i < cast.length; i += SEQUENCE_PRIMITIVE_BATCH) + primitives.push({ + onGround: false, + selectedId, + primitive: new Cesium.Primitive({ + geometryInstances: cast.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), + appearance: new Cesium.PolylineColorAppearance({ translucent: true }), + asynchronous: true, + allowPicking: true, + }), + }); + return { primitives, draped, cast: cast.length }; + } + + /** + * Fetch the terrain heights a tile's lines need, then redraw it cast. + * `castRequested` stops the redraw from casting again at once; a refresh + * `retry`s a tile whose lines are still draped (a failed or partial cast), + * and it is redrawn only when more of its lines can be cast. + */ + function castTile(entry, { retry = false } = {}) { + if ((entry.castRequested && !retry) || entry.castAbort || !terrainMode()) + return; + entry.castRequested = true; + entry.castAbort = new AbortController(); + const { signal } = entry.castAbort; + const lines = [...entry.sequences.values()].flatMap( + (sequence) => sequence.parts, + ); + const caster = state.context.groundCaster; + caster.prepareLines(lines, { signal }).then(() => { + if (entry.castAbort?.signal === signal) entry.castAbort = null; + const attached = [...state.coverage.tiles.values()].includes(entry); + if (signal.aborted || !attached || !terrainMode()) return; + // Nothing new to cast: keep what is drawn until the next refresh. + // Counted like buildSequencePrimitives: a cast line needs two points. + const castable = lines.filter( + (coords) => caster.castLine(coords)?.length >= 6, + ).length; + if (castable <= (entry.castLines || 0)) { + entry.castRequested = (entry.castLines || 0) > 0; + return; + } + // A remesh may have cleared the flag while this was in flight. + entry.castRequested = true; + redrawTile(entry); + requestMesh(entry); + }); + } + + /** Rebuild a tile's lines; the old ones stay up until the new are ready. */ + function redrawTile(entry) { + const previous = entry.primitives; + entry.primitives = []; + attachPrimitive(entry); + removeWhenReady(entry, previous); + } + + /** Lon/lat under the camera, where mesh range is measured from. */ + function meshCentre() { + const carto = state.viewer?.camera?.positionCartographic; + if (!carto) return null; + return { + lon: Cesium.Math.toDegrees(carto.longitude), + lat: Cesium.Math.toDegrees(carto.latitude), + }; + } + + /** + * A tile's drawn lines with bounding boxes, built once per filter; mesh + * points are densified the first time a line is in range. + */ + function meshParts(entry) { + if (entry.meshParts) return entry.meshParts; + entry.meshParts = []; + for (const sequence of entry.sequences.values()) + for (const part of sequence.parts) { + const box = { + west: Infinity, + south: Infinity, + east: -Infinity, + north: -Infinity, + }; + for (const [lon, lat] of part) { + if (lon < box.west) box.west = lon; + if (lon > box.east) box.east = lon; + if (lat < box.south) box.south = lat; + if (lat > box.north) box.north = lat; + } + entry.meshParts.push({ part, box, points: null }); + } + return entry.meshParts; + } + + /** + * Ask for mesh samples under a cast tile's lines within the sampler's range + * of the camera; the rest are asked for when a camera move brings them in. + */ + function requestMesh(entry) { + const sampler = state.context.meshSampler; + if (!sampler || entry.kind !== 'sequence' || !terrainMode()) return; + const centre = meshCentre(); + if (!centre || !entry.bounds || !meshBoxInRange(entry.bounds, centre)) + return; + const points = []; + for (const line of meshParts(entry)) { + if (!meshBoxInRange(line.box, centre)) continue; + line.points ||= densifyLine(line.part, MESH_DENSIFY_DEG); + for (const point of line.points) points.push(point); + } + if (points.length) sampler.request(points); + } + + /** Redraw the tiles new mesh samples fall in, throttled. */ + function onMeshSampled(batch) { + if (!terrainMode() || !state.context.isActive()) return; + for (const entry of state.coverage.tiles.values()) { + if (entry.kind !== 'sequence' || !entry.bounds) continue; + const { west, south, east, north } = entry.bounds; + if ( + batch.some( + ([lon, lat]) => + lon >= west && lon <= east && lat >= south && lat <= north, + ) + ) + state.coverage.remeshDirty.add(entry); + } + if (!state.coverage.remeshDirty.size || state.coverage.remeshTimer) return; + const wait = Math.max( + 0, + REMESH_INTERVAL_MS - (Date.now() - (state.coverage.remeshAt || 0)), + ); + state.coverage.remeshTimer = setTimeout(remesh, wait); + } + + /** Redraw one dirty tile per idle slice, so a burst of samples never stalls a frame. */ + function remesh() { + state.coverage.remeshTimer = null; + state.coverage.remeshAt = Date.now(); + if (!terrainMode() || !state.context.isActive()) { + state.coverage.remeshDirty.clear(); + return; + } + const attached = new Set(state.coverage.tiles.values()); + const [entry] = state.coverage.remeshDirty; + if (!entry) return; + state.coverage.remeshDirty.delete(entry); + if (attached.has(entry)) { + redrawTile(entry); + requestRender(); + } + if (state.coverage.remeshDirty.size) + state.coverage.remeshTimer = setTimeout( + () => whenIdle(remesh, 500), + REMESH_STAGGER_MS, + ); + } + + state.coverage.remeshDirty = new Set(); + state.context.meshSampler?.onSampled(onMeshSampled); + + /** + * Remove a tile's previous primitives once all its current ones are ready + * (or after a few seconds); detaching the tile removes them at once. + */ + function removeWhenReady(entry, old) { + const scene = state.viewer?.scene; + finishSwap(entry); + if (!scene?.postRender) { + removePrimitives(old); + return; + } + const fresh = entry.primitives; + const started = Date.now(); + const stop = scene.postRender.addEventListener(() => { + const ready = fresh.every( + ({ primitive }) => primitive.ready || primitive.isDestroyed?.(), + ); + if (!ready && Date.now() - started < SWAP_MAX_WAIT_MS) { + requestRender(); + return; + } + finishSwap(entry); + requestRender(); + }); + entry.swap = { old, stop }; + requestRender(); + } + + function finishSwap(entry) { + if (!entry.swap) return; + entry.swap.stop(); + removePrimitives(entry.swap.old); + entry.swap = null; + } + + function buildOverviewCollection(points) { + const collection = new Cesium.PointPrimitiveCollection({ + blendOption: Cesium.BlendOption.TRANSLUCENT, + }); + const tint = Cesium.Color.fromCssColorString(colors.coverage).withAlpha( + 0.85, + ); + const drawn = []; + const current = filter(); + for (const point of points) { + if (!passes(point, current)) continue; + drawn.push( + collection.add({ + position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat), + color: tint, + pixelSize: tuning.overviewPointPx, + // Google 3D terrain and clouds must not hide the near side's dots; + // the horizon cull hides the far side's, which this lets through. + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }), + ); + } + return { collection, points: drawn }; + } + + /** Every overview point on the globe, current zoom and old alike. */ + function* overviewPoints() { + for (const entry of drawnEntries()) + if (entry.overviewPoints) yield* entry.overviewPoints; + } + + // Overview points skip the depth test: hide the far hemisphere's. + const horizon = createHorizonCull({ + getViewer: () => state.viewer, + items: overviewPoints, + onChange: requestRender, + }); + + function attachPrimitive(entry) { + const scene = state.viewer?.scene; + if (!scene) return; + if (entry.kind === 'sequence') { + // Lookup, picking and counts follow what is drawn, not the whole tile. + const sequences = drawnSequences(entry); + entry.sequences = new Map(sequences.map((s) => [s.id, s])); + const { primitives, draped, cast } = buildSequencePrimitives(sequences); + entry.primitives = primitives; + entry.count = sequences.length; + for (const { primitive, onGround } of primitives) + (onGround ? scene.groundPrimitives : scene.primitives).add(primitive); + // Fewer lines cast than last time: the caster has dropped heights this + // tile used (a full cache), so it must be cast again. + if (cast < (entry.castLines || 0)) entry.castRequested = false; + entry.castLines = cast; + entry.drapedLines = draped.length; + if (draped.length) castTile(entry); + watchSelection(); + } else { + const { collection, points } = buildOverviewCollection(entry.points); + entry.primitive = collection; + entry.overviewPoints = points; + entry.count = points.length; + scene.primitives.add(collection); + // Cull the new points now, then all of them whenever the camera moves. + horizon.update(points); + } + } + + function removePrimitives(list) { + const scene = state.viewer?.scene; + for (const { primitive, onGround } of list || []) { + try { + (onGround ? scene?.groundPrimitives : scene?.primitives)?.remove( + primitive, + ); + } catch { + /* already gone */ + } + } + } + + function detachPrimitive(entry) { + const scene = state.viewer?.scene; + finishSwap(entry); + removePrimitives(entry.primitives); + entry.primitives = []; + if (entry.primitive) { + try { + scene?.primitives?.remove(entry.primitive); + } catch { + /* already gone */ + } + entry.primitive = null; + entry.overviewPoints = null; + } + } + + /** Stop a tile's pending terrain lookup (tile dropped or retired). */ + function cancelCast(entry) { + entry.castAbort?.abort(); + entry.castAbort = null; + } + + function removeTile(key) { + const entry = state.coverage.tiles.get(key); + if (!entry) return; + cancelCast(entry); + detachPrimitive(entry); + state.coverage.tiles.delete(key); + } + + async function loadTile(tile, kind) { + const key = tileKey(tile); + if (state.coverage.tiles.has(key) || state.coverage.pending.has(key)) + return; + const controller = new AbortController(); + state.coverage.pending.set(key, controller); + notify(); + try { + // Fetch and the one-time terrain-height table load run side by side, + // both handled from the start so an early tile failure is not unhandled. + const [decoded] = await Promise.all([ + fetchTile(tile, { signal: controller.signal }), + kind === 'sequence' ? ensureTerrainReady() : null, + ]); + // Only an abort, a retire, a clear or a newer request for this tile + // discards the bytes; a refresh that still wants the tile keeps them. + const current = () => + state.coverage.pending.get(key) === controller && + !state.coverage.tiles.has(key); + if ( + controller.signal.aborted || + !current() || + kind !== state.coverage.kind || + tile.z !== state.coverage.zoom + ) + return; + const entry = { kind, primitive: null, primitives: [], count: 0 }; + // Padded: vector tiles carry a small buffer past their edge. + const bounds = tileBounds(tile.x, tile.y, tile.z); + const pad = (bounds.east - bounds.west) * 0.05; + entry.bounds = { + west: bounds.west - pad, + east: bounds.east + pad, + south: bounds.south - pad, + north: bounds.north + pad, + }; + if (kind === 'sequence') { + // Newest first; the per-tile cap applies to what passes the filter. + entry.sequenceList = decoded.sequences.sort( + (a, b) => (b.capturedAt || 0) - (a.capturedAt || 0), + ); + entry.sequences = new Map(); + entry.total = decoded.sequences.length; + } else { + entry.points = decoded.overview; + entry.sequences = new Map(); + entry.total = decoded.overview.length; + } + attachPrimitive(entry); + state.coverage.tiles.set(key, entry); + state.coverage.lastError = null; + requestRender(); + } catch (error) { + if ( + controller.signal.aborted || + state.coverage.pending.get(key) !== controller + ) + return; + state.coverage.lastError = error?.message || 'Coverage tile failed'; + if (error?.keyRequired) state.keyRequired = true; + if (error?.keyRejected) { + // Every other tile would be refused too: stop asking. + state.keyRejected = true; + state.coverage.lastError = + messages.keyRejected || state.coverage.lastError; + } + if (error?.retryAfterSec) holdFor(error.retryAfterSec); + } finally { + // Only the request that still owns the key settles it: a superseded one + // must not drop a newer request's entry (which is what counts as loading). + if (state.coverage.pending.get(key) === controller) { + state.coverage.pending.delete(key); + if (!state.coverage.pending.size) purgeStale(); + } + notify(); + } + } + + /** + * The source is rate-limiting: keep what is drawn, request nothing until the + * wait is over, then refresh once. + */ + function holdFor(seconds) { + clearTimeout(state.coverage.holdTimer); + state.coverage.holdUntil = Date.now() + seconds * 1000; + state.coverage.lastError = messages.rateLimited; + state.coverage.holdTimer = setTimeout(() => { + state.coverage.holdTimer = null; + state.coverage.holdUntil = 0; + if (state.coverage.lastError === messages.rateLimited) + state.coverage.lastError = null; + refresh(); + }, seconds * 1000); + } + + /** Forget refusals when the provider goes off, so the next run asks again. */ + function resetErrors() { + clearTimeout(state.coverage.holdTimer); + state.coverage.holdTimer = null; + state.coverage.holdUntil = 0; + state.coverage.lastError = null; + state.keyRejected = false; + } + + /** Drop the previous zoom's tiles once the new ones are on screen. */ + function purgeStale() { + clearTimeout(state.coverage.staleTimer); + state.coverage.staleTimer = null; + if (!state.coverage.stale.size) return; + for (const entry of state.coverage.stale.values()) detachPrimitive(entry); + state.coverage.stale.clear(); + requestRender(); + } + + /** Move tiles to the stale set: the old zoom shows until the new one loads. */ + function retire() { + for (const controller of state.coverage.pending.values()) + controller.abort(); + state.coverage.pending.clear(); + for (const [key, entry] of state.coverage.tiles) { + cancelCast(entry); + const previous = state.coverage.stale.get(key); + if (previous) detachPrimitive(previous); + state.coverage.stale.set(key, entry); + } + state.coverage.tiles.clear(); + clearTimeout(state.coverage.staleTimer); + state.coverage.staleTimer = setTimeout(purgeStale, STALE_TILE_MAX_MS); + } + + function notify() { + state.context.notify(); + } + + /** + * Whether the screen centre meets the ground further ahead than the camera + * is high (a view tilted above 45°), or misses it. + */ + function isTilted({ nadir, ahead }, height) { + if (!ahead) return true; + return metresBetween(nadir, ahead) > Math.max(1, height); + } + + /** Recompute the tile set for the current camera and reconcile primitives. */ + function refresh() { + const viewer = state.viewer; + if (!viewer || !state.context.isActive() || state.keyRequired) return; + if (state.statusKnown === false) return; + if (state.keyRejected || state.coverage.holdUntil > Date.now()) return; + const ground = groundUnderCamera(viewer, { + groundAt: state.context.groundCaster?.groundAt, + }); + const cameraHeight = viewer.camera?.positionCartographic?.height; + const height = Number.isFinite(cameraHeight) + ? cameraHeight - (ground ?? 0) + : null; + const level = zoomFor(height); + const zoom = level?.zoom ?? null; + const kind = level?.kind ?? null; + // At street zooms the rays meet the ground where it really is (1,600 m up + // in Denver; Google 3D hides the globe) and stop short of the horizon. + const ranged = + kind === 'sequence' + ? { + groundHeight: ground, + maxRange: Math.max( + tuning.viewRangeMinM, + height * tuning.viewRangePerHeight, + ), + } + : {}; + // Tilted street views rank tiles along the line of sight, from the ground + // under the camera to the ground at the centre of the screen. + const focus = kind === 'sequence' ? viewFocus(viewer, ranged) : null; + let bbox = visibleBbox(viewer, { + ...ranged, + // A tilted view's screen rows jump from the first metres to the horizon: + // keep the ground around the camera too. Looking down needs no help. + nearRange: focus && isTilted(focus, height) ? tuning.viewNearM : null, + }); + if (kind === 'overview' && (!bbox || zoom <= 1)) + bbox = [-180, -85, 180, 85]; + if (zoom == null || !bbox) { + state.coverage.hint = + zoom == null && Number.isFinite(height) && messages.tooHigh + ? messages.tooHigh + : 'Point the camera at the globe for street-level coverage'; + clear(); + notify(); + return; + } + state.coverage.hint = ''; + if (zoom !== state.coverage.zoom || kind !== state.coverage.kind) { + retire(); + state.coverage.zoom = zoom; + state.coverage.kind = kind; + } + const { tiles } = tilesForBbox(bbox, zoom, { + limit: kind === 'sequence' ? tuning.maxTiles : tuning.overviewMaxTiles, + focus: focus && { from: focus.nadir, to: focus.ahead }, + }); + const wanted = new Set(tiles.map(tileKey)); + for (const key of [...state.coverage.tiles.keys()]) + if (!wanted.has(key)) removeTile(key); + for (const [key, controller] of [...state.coverage.pending]) + if (!wanted.has(key)) { + // The aborted request no longer owns its key, so its `finally` will + // not settle it: settle it here. + controller.abort(); + state.coverage.pending.delete(key); + } + for (const tile of tiles) loadTile(tile, kind); + if (!state.coverage.pending.size) purgeStale(); + // The camera moved: cells that were out of the sampler's range may not be, + // and lines left draped by a failed, partial or dropped cast get another try. + if (terrainMode()) + for (const entry of state.coverage.tiles.values()) { + if (entry.castRequested) requestMesh(entry); + if (entry.drapedLines) castTile(entry, { retry: true }); + } + notify(); + } + + function scheduleRefresh() { + clearTimeout(state.coverage.debounceTimer); + state.coverage.debounceTimer = setTimeout(refresh, tuning.moveDebounceMs); + } + + function attach(viewer) { + detach(); + const remove = viewer.camera.changed.addEventListener(scheduleRefresh); + const removeEnd = viewer.camera.moveEnd.addEventListener(scheduleRefresh); + state.coverage.removeCameraListener = () => { + remove(); + removeEnd(); + }; + refresh(); + } + + function detach() { + clearTimeout(state.coverage.debounceTimer); + state.coverage.debounceTimer = null; + state.coverage.removeCameraListener?.(); + state.coverage.removeCameraListener = null; + } + + function clear() { + for (const controller of state.coverage.pending.values()) + controller.abort(); + state.coverage.pending.clear(); + for (const key of [...state.coverage.tiles.keys()]) removeTile(key); + purgeStale(); + state.coverage.zoom = null; + state.coverage.kind = null; + clearTimeout(state.coverage.remeshTimer); + state.coverage.remeshTimer = null; + state.coverage.remeshDirty.clear(); + stopSelectionWatch(); + horizon.stop(); + requestRender(); + } + + /** Rebuild every loaded tile from its decoded cache (after a filter change). */ + function rebuild() { + purgeStale(); + for (const entry of state.coverage.tiles.values()) { + cancelCast(entry); + entry.castRequested = false; + entry.meshParts = null; + detachPrimitive(entry); + attachPrimitive(entry); + } + requestRender(); + notify(); + } + + /** + * Redraw tiles draped or cast for the new surface mode, then re-pick the + * zoom, since the ground height under the camera may have changed. + */ + function setSurface() { + if (!state.context.isActive()) return; + rebuild(); + refresh(); + } + + /** Recolour every part of a sequence in one ready primitive. */ + function recolorInstances(primitive, sequence, selected) { + const value = sequenceColor({ selected }); + for (const instanceId of partIds(sequence, pickPrefix)) { + try { + const attributes = primitive.getGeometryInstanceAttributes(instanceId); + if (attributes) + attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue( + value, + attributes.color, + ); + } catch { + /* instance not in this primitive */ + } + } + } + + /** Every tile on the globe: the current zoom's and the stale zoom's. */ + function drawnEntries() { + return [...state.coverage.tiles.values(), ...state.coverage.stale.values()]; + } + + /** A tile's primitives on the globe: its own, and the ones a swap still shows. */ + function drawnRecords(entry) { + const records = entry.primitives || []; + return entry.swap ? [...records, ...entry.swap.old] : records; + } + + /** Recolour one sequence, every part of it, in place (selection highlight). */ + function recolorSequence(id, selected) { + for (const entry of drawnEntries()) { + const sequence = entry.sequences.get(id); + if (!sequence) continue; + for (const record of drawnRecords(entry)) { + // Still building: `syncSelection` catches it up once it is ready. + if (!record.primitive.ready) continue; + recolorInstances(record.primitive, sequence, selected); + if (selected) record.selectedId = id; + else if (record.selectedId === id) record.selectedId = null; + } + } + requestRender(); + } + + /** + * Bring a ready primitive's highlight up to the current selection, when + * it was built with another one (or none). True when it changed. + */ + function syncSelection(entry, record) { + const current = state.sequence.selectedId ?? null; + if (record.selectedId === current || !record.primitive.ready) return false; + const previous = + record.selectedId && entry.sequences.get(record.selectedId); + if (previous) recolorInstances(record.primitive, previous, false); + const next = current && entry.sequences.get(current); + if (next) recolorInstances(record.primitive, next, true); + record.selectedId = current; + return true; + } + + /** + * A primitive cannot be recoloured until it is ready, so while any is + * building, catch each one up with the selection as it becomes ready. + */ + function watchSelection() { + const scene = state.viewer?.scene; + if (state.coverage.stopSelectionWatch || !scene?.postRender) return; + const stop = scene.postRender.addEventListener(() => { + let building = false; + let changed = false; + for (const entry of drawnEntries()) { + for (const record of entry.primitives || []) { + if (!record.primitive.ready) building = true; + else if (syncSelection(entry, record)) changed = true; + } + // Lines a swap still shows were ready (or never show): catch up only. + for (const record of entry.swap?.old || []) + if (syncSelection(entry, record)) changed = true; + } + if (changed) requestRender(); + if (!building) stopSelectionWatch(); + }); + state.coverage.stopSelectionWatch = stop; + } + + function stopSelectionWatch() { + state.coverage.stopSelectionWatch?.(); + state.coverage.stopSelectionWatch = null; + } + + /** + * Sequences (or overview points) drawn. A sequence that crosses a tile + * edge is in both tiles' lists, so sequences count once by id. + */ + function sequenceCount() { + const ids = new Set(); + let points = 0; + for (const entry of state.coverage.tiles.values()) + if (entry.kind === 'sequence') + for (const id of entry.sequences.keys()) ids.add(id); + else points += entry.count; + return ids.size + points; + } + + return { + attach, + detach, + refresh, + clear, + resetErrors, + rebuild, + setSurface, + recolorSequence, + sequenceCount, + }; +} diff --git a/src/layers/streetLevel/policy.js b/src/layers/streetLevel/policy.js index 6f310274f..84f9e2fc7 100644 --- a/src/layers/streetLevel/policy.js +++ b/src/layers/streetLevel/policy.js @@ -8,6 +8,7 @@ export const POSITION_PICK_ID = 'sl:pos'; export const PROVIDER_COLORS = Object.freeze({ mapillary: '#05cb63', google: '#4285f4', + kartaview: '#635bff', }); /** Colours every provider shares: the selection highlight and the marker. */ diff --git a/src/layers/streetLevel/providers/kartaview/decode.js b/src/layers/streetLevel/providers/kartaview/decode.js new file mode 100644 index 000000000..2c8687eba --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/decode.js @@ -0,0 +1,55 @@ +/** + * Decode a KartaView coverage tile (`{lines: [{track: [{lat, lng}]}]}`) into + * line records for the shared coverage. The tile's lines are OSM way + * segments with photos; segments that meet end to start are chained, which + * cuts the line count about fourfold. Lines carry no sequence, date or + * projection, so nothing can filter them. + * @param {object} payload + * @param {{x: number, y: number, z: number}} address + * @returns {{sequences: Array<{id: string, parts: Array>, capturedAt: number, isPano: boolean}>, overview: Array}} + */ +export function decodeCoverageTile(payload, { x, y, z }) { + const segments = []; + for (const line of Array.isArray(payload?.lines) ? payload.lines : []) { + const points = []; + for (const point of Array.isArray(line?.track) ? line.track : []) { + const lon = Number(point?.lng); + const lat = Number(point?.lat); + if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue; + const last = points[points.length - 1]; + if (last && last[0] === lon && last[1] === lat) continue; + points.push([lon, lat]); + } + if (points.length >= 2) segments.push(points); + } + const key = ([lon, lat]) => `${lon},${lat}`; + const startingAt = new Map(); + segments.forEach((segment, index) => { + const at = key(segment[0]); + const list = startingAt.get(at); + if (list) list.push(index); + else startingAt.set(at, [index]); + }); + const used = new Uint8Array(segments.length); + const sequences = []; + segments.forEach((segment, index) => { + if (used[index]) return; + used[index] = 1; + const chain = [...segment]; + for (;;) { + const next = startingAt + .get(key(chain[chain.length - 1])) + ?.find((candidate) => !used[candidate]); + if (next === undefined) break; + used[next] = 1; + chain.push(...segments[next].slice(1)); + } + sequences.push({ + id: `${z}/${x}/${y}/${sequences.length}`, + parts: [chain], + capturedAt: 0, + isPano: false, + }); + }); + return { sequences, overview: [] }; +} diff --git a/src/layers/streetLevel/providers/kartaview/decode.test.mjs b/src/layers/streetLevel/providers/kartaview/decode.test.mjs new file mode 100644 index 000000000..c1ab57f3a --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/decode.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { decodeCoverageTile } from './decode.js'; + +const at = (lng, lat) => ({ lat: String(lat), lng: String(lng) }); +const TILE = { x: 4412, y: 6061, z: 14 }; + +test('segments that meet end to start chain into one line', () => { + const { sequences, overview } = decodeCoverageTile( + { + lines: [ + { track: [at(1, 1), at(2, 1)], matched: '1' }, + { track: [at(5, 5), at(6, 6)], matched: '1' }, + { track: [at(2, 1), at(3, 1), at(4, 2)], matched: '1' }, + ], + }, + TILE, + ); + assert.deepEqual(overview, []); + assert.equal(sequences.length, 2); + assert.deepEqual(sequences[0].parts, [ + [ + [1, 1], + [2, 1], + [3, 1], + [4, 2], + ], + ]); + assert.equal(sequences[0].id, '14/4412/6061/0'); + assert.equal(sequences[1].id, '14/4412/6061/1'); + // Nothing to date or project: every line passes as flat and undated. + assert.equal(sequences[0].capturedAt, 0); + assert.equal(sequences[0].isPano, false); +}); + +test('a junction continues one way only, and every segment is drawn exactly once', () => { + const { sequences } = decodeCoverageTile( + { + lines: [ + { track: [at(0, 0), at(1, 0)] }, + { track: [at(1, 0), at(2, 0)] }, + { track: [at(1, 0), at(1, 1)] }, + // A loop back to its own start must end, not spin. + { track: [at(9, 9), at(9, 8)] }, + { track: [at(9, 8), at(9, 9)] }, + ], + }, + TILE, + ); + const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0); + assert.equal(sequences.length, 3); + // 5 segments of 2 points, 2 joins in chains: 10 - 2 = 8 points. + assert.equal(points, 8); +}); + +test('bad points, repeats and one-point tracks are skipped; an empty tile has no lines', () => { + const { sequences } = decodeCoverageTile( + { + lines: [ + { track: [at(1, 1), at(1, 1), { lat: 'x', lng: '2' }, at(2, 2)] }, + { track: [at(3, 3)] }, + { track: null }, + null, + ], + }, + TILE, + ); + assert.equal(sequences.length, 1); + assert.deepEqual(sequences[0].parts[0], [ + [1, 1], + [2, 2], + ]); + assert.deepEqual(decodeCoverageTile({ lines: [] }, TILE).sequences, []); + assert.deepEqual(decodeCoverageTile(null, TILE).sequences, []); +}); diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js new file mode 100644 index 000000000..c9da4f7c5 --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -0,0 +1,374 @@ +import { createLineCoverage } from '../../lineCoverage.js'; +import { createSequenceCones, thinImages } from '../../sequenceCones.js'; +import { metresBetween } from '../../view.js'; +import { decodeCoverageTile } from './decode.js'; +import { createKartaViewSource } from './source.js'; +import { createKartaViewViewer } from './viewer.js'; +import { + COLORS, + COVERAGE_MAX_HEIGHT_M, + COVERAGE_NOUN, + COVERAGE_STREET_ZOOM_BELOW_M, + COVERAGE_TUNING, + KARTAVIEW_CREDIT_HTML, + KARTAVIEW_LABEL, + KARTAVIEW_NAME, + KARTAVIEW_PROVIDER_ID, + LINE_CLICK_HINT, + NEAREST_RADIUS_M, + PICK_PREFIX, + RATE_LIMITED_MESSAGE, + SEQUENCE_PAGE_SIZE, + TOO_HIGH_HINT, + normalizePhoto, +} from './policy.js'; + +/** Photos kept in memory by id (sequence pages fill it). */ +const PHOTO_CACHE_SIZE = 4000; +/** Sequence pages kept in memory. */ +const PAGE_CACHE_SIZE = 8; + +/** z14 near the street, z13 a little higher, nothing above. */ +export function kartaViewZoomFor(heightM) { + if (!Number.isFinite(heightM) || heightM > COVERAGE_MAX_HEIGHT_M) return null; + return { + zoom: heightM > COVERAGE_STREET_ZOOM_BELOW_M ? 13 : 14, + kind: 'sequence', + }; +} + +/** Least-recently-used map: reading an entry renews it. */ +function createLru(limit) { + const map = new Map(); + return { + get(key) { + if (!map.has(key)) return undefined; + const value = map.get(key); + map.delete(key); + map.set(key, value); + return value; + }, + set(key, value) { + map.delete(key); + map.set(key, value); + while (map.size > limit) map.delete(map.keys().next().value); + }, + delete: (key) => map.delete(key), + clear: () => map.clear(), + }; +} + +/** The 1-based page of a sequence that holds `index`, if no photo is missing. */ +function pageOf(index) { + return Math.floor(Math.max(0, index) / SEQUENCE_PAGE_SIZE) + 1; +} + +/** + * KartaView Street Level provider: keyless coverage lines from KartaView's + * tile API, the photos nearest a clicked line, cones along the photo's + * sequence, and a flat or 360° viewer with ‹ › along the sequence. + * @param {{source?: ReturnType, viewerOptions?: object}} [options] + * @returns {import('../../registry.js').StreetLevelProvider} + */ +export function createKartaViewProvider({ + source = createKartaViewSource(), + viewerOptions = {}, +} = {}) { + return Object.freeze({ + id: KARTAVIEW_PROVIDER_ID, + name: KARTAVIEW_NAME, + label: KARTAVIEW_LABEL, + requiresKeyId: null, + pickPrefix: PICK_PREFIX.root, + colors: COLORS, + credit: Object.freeze({ html: KARTAVIEW_CREDIT_HTML }), + defaultOn: false, + // Coverage lines carry no date or projection: no filter could thin them. + filters: [], + coverageLabel: Object.freeze({ + noun: COVERAGE_NOUN, + action: LINE_CLICK_HINT, + }), + + create(context) { + const state = { + context, + services: context.services, + viewer: null, + keyRequired: false, + keyRejected: false, + statusKnown: true, + coverage: { + zoom: null, + tiles: new Map(), + stale: new Map(), + staleTimer: null, + pending: new Map(), + lastError: null, + holdUntil: 0, + holdTimer: null, + debounceTimer: null, + removeCameraListener: null, + terrainReady: null, + hint: '', + kind: null, + }, + sequence: { + selectedId: null, + images: [], + cache: new Map(), + collection: null, + loading: false, + abort: null, + }, + }; + const photos = createLru(PHOTO_CACHE_SIZE); + const pages = createLru(PAGE_CACHE_SIZE); + /** The photo last opened in each sequence: its cones centre on it. */ + const anchors = new Map(); + /** The sequence page the cones were drawn from. */ + let drawnPage = null; + + function remember(record) { + const photo = normalizePhoto(record); + if (photo) photos.set(photo.id, photo); + return photo; + } + + async function getPhoto(photoId, { signal } = {}) { + const cached = photos.get(photoId); + if (cached) return cached; + const photo = remember(await source.getPhoto(photoId, { signal })); + if (!photo) throw new Error('KartaView has no position for this photo'); + return photo; + } + + /** A page of a sequence's photos, in index order (shared while in flight). */ + function loadPage(sequenceId, page) { + const key = `${sequenceId}:${page}`; + let entry = pages.get(key); + if (!entry) { + entry = source + .getSequencePhotos(sequenceId, { + page, + itemsPerPage: SEQUENCE_PAGE_SIZE, + }) + .then(({ photos: records, hasMore }) => ({ + page, + hasMore, + photos: records + .map(remember) + .filter(Boolean) + .sort( + (a, b) => (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0), + ), + })); + pages.set(key, entry); + entry.catch(() => pages.delete(key)); + } + return entry; + } + + /** The page holding `photo` (an earlier one when photos are missing). */ + async function windowOf(photo) { + let page = pageOf(photo.sequenceIndex ?? 0); + for (let tries = 0; tries < 3; tries++) { + const window = await loadPage(photo.sequenceId, page); + const first = window.photos[0]; + if ( + window.photos.some((p) => p.id === photo.id) || + page === 1 || + !first || + (first.sequenceIndex ?? 0) <= (photo.sequenceIndex ?? 0) + ) + return window; + page--; + } + return loadPage(photo.sequenceId, page); + } + + async function step(photo, delta) { + if (!photo?.sequenceId) return null; + const window = await windowOf(photo); + const list = window.photos; + let at = list.findIndex((p) => p.id === photo.id); + if (at === -1) + at = list.findIndex( + (p) => (p.sequenceIndex ?? 0) >= (photo.sequenceIndex ?? 0), + ); + const next = at === -1 ? -1 : at + delta; + if (next >= 0 && next < list.length) return list[next].id; + if (next < 0 && window.page > 1) { + const before = await loadPage(photo.sequenceId, window.page - 1); + return before.photos.at(-1)?.id ?? null; + } + if (next >= list.length && window.hasMore) { + const after = await loadPage(photo.sequenceId, window.page + 1); + return after.photos[0]?.id ?? null; + } + return null; + } + + const coverage = createLineCoverage({ + state, + fetchTile: async (tile, { signal }) => + decodeCoverageTile(await source.getTile(tile, { signal }), tile), + zoomFor: kartaViewZoomFor, + colors: COLORS, + pickPrefix: PICK_PREFIX.line, + renderKey: 'kartaview-coverage', + tuning: COVERAGE_TUNING, + messages: { rateLimited: RATE_LIMITED_MESSAGE, tooHigh: TOO_HIGH_HINT }, + passes: () => true, + }); + + const cones = createSequenceCones({ + state, + async loadImages(sequenceId, { signal }) { + const anchor = anchors.get(sequenceId); + if (!anchor) return []; + const window = await windowOf(anchor); + signal?.throwIfAborted(); + drawnPage = { sequenceId, page: window.page }; + return thinImages( + window.photos.map((photo) => ({ + id: photo.id, + lon: photo.lon, + lat: photo.lat, + compassAngle: photo.heading ?? 0, + capturedAt: photo.capturedAt ?? 0, + isPano: photo.isPano, + })), + ); + }, + colors: COLORS, + pickPrefix: PICK_PREFIX.image, + spriteId: 'street-level:kartaview-cones', + renderKey: 'kartaview-sequence', + }); + + /** Re-centre the cones when ‹ › walked off the page they show. */ + async function followStep(photoId) { + const photo = photos.get(photoId); + if (!photo?.sequenceId) return; + anchors.set(photo.sequenceId, photo); + if (drawnPage?.sequenceId !== photo.sequenceId) return; + const window = await windowOf(photo).catch(() => null); + if (window && window.page !== drawnPage.page) + cones.select(photo.sequenceId, { force: true }); + } + + const viewer = createKartaViewViewer({ + getPhoto: async (photoId) => { + const photo = await getPhoto(photoId); + if (photo.sequenceId) anchors.set(photo.sequenceId, photo); + return photo; + }, + step, + onStepped: followStep, + render: context.services?.render, + ...viewerOptions, + }); + + return { + async status() { + return { configured: true }; + }, + + init(cesiumViewer) { + state.viewer = cesiumViewer; + cones.ensureCollections(cesiumViewer); + cones.setVisible(false); + }, + + activate(cesiumViewer) { + state.viewer = cesiumViewer; + cones.setVisible(true); + coverage.attach(cesiumViewer); + }, + + deactivate() { + coverage.detach(); + coverage.clear(); + coverage.resetErrors(); + cones.clearSelection(); + cones.setVisible(false); + drawnPage = null; + }, + + destroy(cesiumViewer) { + coverage.detach(); + coverage.clear(); + coverage.resetErrors(); + cones.destroy(cesiumViewer); + }, + + refreshCoverage: () => coverage.refresh(), + + /** No filter applies (see `filters`). */ + setFilter() {}, + + setSurface() { + coverage.setSurface(); + cones.rerender(); + }, + + coverageStats() { + const { hint } = state.coverage; + const count = coverage.sequenceCount(); + return { + count, + zoom: state.coverage.zoom, + kind: state.coverage.kind, + loading: state.coverage.pending.size > 0, + hint: hint || '', + error: state.coverage.lastError, + keyRequired: false, + keyRejected: false, + }; + }, + + /** A line opens the photo nearest the click; a cone opens its photo. */ + handlePick(pickId, { groundPoint } = {}) { + if (pickId.startsWith(PICK_PREFIX.image)) { + context.actions.openImage(pickId.slice(PICK_PREFIX.image.length)); + return true; + } + if (pickId.startsWith(PICK_PREFIX.line)) { + const point = groundPoint?.(); + if (point) context.actions.openNearest(point); + return true; + } + return false; + }, + + selectSequence: (sequenceId) => cones.select(sequenceId), + clearSequence: () => { + cones.clearSelection(); + drawnPage = null; + }, + sequenceStats: () => ({ + selectedId: state.sequence.selectedId, + images: state.sequence.images.length, + loading: state.sequence.loading, + }), + + /** The nearest photo within the search radius, or null. */ + async nearestImage({ lat, lon }, { signal } = {}) { + const records = await source.nearestPhotos({ lat, lon }, { signal }); + let best = null; + for (const record of records) { + const photo = remember(record); + if (!photo) continue; + const metres = metresBetween({ lat, lon }, photo); + if (metres <= NEAREST_RADIUS_M && (!best || metres < best.metres)) + best = { photo, metres }; + } + return best ? best.photo.id : null; + }, + + viewer, + }; + }, + }); +} diff --git a/src/layers/streetLevel/providers/kartaview/panorama.js b/src/layers/streetLevel/providers/kartaview/panorama.js new file mode 100644 index 000000000..a081d1e5e --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/panorama.js @@ -0,0 +1,213 @@ +/** + * A minimal equirectangular panorama renderer on WebGL: one full-screen + * triangle whose fragment shader turns each pixel's view ray into texture + * coordinates. The image centre is the camera's forward direction; yaw turns + * right from it, pitch looks up. + */ + +export const PANO_FOV_DEFAULT_DEG = 80; +export const PANO_FOV_MIN_DEG = 30; +export const PANO_FOV_MAX_DEG = 100; +export const PANO_PITCH_LIMIT_DEG = 85; + +const VERTEX = ` +attribute vec2 a_position; +varying vec2 v_ndc; +void main() { + v_ndc = a_position; + gl_Position = vec4(a_position, 0.0, 1.0); +}`; + +const FRAGMENT = ` +precision highp float; +uniform sampler2D u_image; +uniform float u_yaw; +uniform float u_pitch; +uniform float u_tanHalfFov; +uniform float u_aspect; +varying vec2 v_ndc; +const float PI = 3.141592653589793; +void main() { + vec3 ray = normalize(vec3(v_ndc.x * u_tanHalfFov * u_aspect, v_ndc.y * u_tanHalfFov, -1.0)); + float cp = cos(u_pitch), sp = sin(u_pitch); + ray = vec3(ray.x, ray.y * cp - ray.z * sp, ray.y * sp + ray.z * cp); + float cy = cos(u_yaw), sy = sin(u_yaw); + ray = vec3(ray.x * cy - ray.z * sy, ray.y, ray.x * sy + ray.z * cy); + float lon = atan(ray.x, -ray.z); + float lat = asin(clamp(ray.y, -1.0, 1.0)); + gl_FragColor = texture2D(u_image, vec2(0.5 + lon / (2.0 * PI), 0.5 - lat / PI)); +}`; + +/** Wrap degrees into [0, 360). */ +export function wrapDegrees(value) { + return ((value % 360) + 360) % 360; +} + +export function clampPitch(degrees) { + return Math.max( + -PANO_PITCH_LIMIT_DEG, + Math.min(PANO_PITCH_LIMIT_DEG, degrees), + ); +} + +export function clampFov(degrees) { + return Math.max(PANO_FOV_MIN_DEG, Math.min(PANO_FOV_MAX_DEG, degrees)); +} + +/** + * Texture coordinates the shader samples for a view ray, for tests and + * picking: `yaw`/`pitch` in degrees, the ray in camera space (x right, y up, + * z backward). + */ +export function rayToUv(ray, { yaw = 0, pitch = 0 } = {}) { + const p = (pitch * Math.PI) / 180; + const w = (yaw * Math.PI) / 180; + const length = Math.hypot(ray[0], ray[1], ray[2]) || 1; + let [x, y, z] = ray.map((v) => v / length); + [y, z] = [ + y * Math.cos(p) - z * Math.sin(p), + y * Math.sin(p) + z * Math.cos(p), + ]; + [x, z] = [ + x * Math.cos(w) - z * Math.sin(w), + x * Math.sin(w) + z * Math.cos(w), + ]; + const lon = Math.atan2(x, -z); + const lat = Math.asin(Math.max(-1, Math.min(1, y))); + return [0.5 + lon / (2 * Math.PI), 0.5 - lat / Math.PI]; +} + +function compile(gl, type, source) { + const shader = gl.createShader(type); + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(shader); + gl.deleteShader(shader); + throw new Error(`Panorama shader failed: ${log}`); + } + return shader; +} + +const isPowerOfTwo = (n) => n > 0 && (n & (n - 1)) === 0; + +/** + * A panorama view on `canvas`. Throws when WebGL is unavailable, so the + * caller can fall back to a flat picture. + * @param {HTMLCanvasElement} canvas + * @returns {{setImage: (image: TexImageSource) => void, setView: (view: {yaw?: number, pitch?: number, fov?: number}) => void, getView: () => {yaw: number, pitch: number, fov: number}, resize: () => void, render: () => void, hasImage: () => boolean, destroy: () => void}} + */ +export function createPanoramaRenderer(canvas) { + const gl = + canvas.getContext('webgl', { + antialias: false, + depth: false, + preserveDrawingBuffer: false, + }) || canvas.getContext('experimental-webgl'); + if (!gl) throw new Error('WebGL is unavailable'); + + const program = gl.createProgram(); + gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERTEX)); + gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FRAGMENT)); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) + throw new Error('Panorama program failed to link'); + gl.useProgram(program); + + const buffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + // One triangle covering the screen. + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([-1, -1, 3, -1, -1, 3]), + gl.STATIC_DRAW, + ); + const position = gl.getAttribLocation(program, 'a_position'); + gl.enableVertexAttribArray(position); + gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0); + const uniforms = Object.fromEntries( + ['u_image', 'u_yaw', 'u_pitch', 'u_tanHalfFov', 'u_aspect'].map((name) => [ + name, + gl.getUniformLocation(program, name), + ]), + ); + const texture = gl.createTexture(); + let loaded = false; + let frame = 0; + const view = { yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG }; + + function resize() { + const ratio = Math.min(globalThis.devicePixelRatio || 1, 2); + const width = Math.max(1, Math.round(canvas.clientWidth * ratio)); + const height = Math.max(1, Math.round(canvas.clientHeight * ratio)); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + scheduleRender(); + } + + function draw() { + frame = 0; + if (gl.isContextLost()) return; + gl.viewport(0, 0, canvas.width, canvas.height); + gl.clearColor(0, 0, 0, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + if (!loaded) return; + gl.uniform1i(uniforms.u_image, 0); + gl.uniform1f(uniforms.u_yaw, (view.yaw * Math.PI) / 180); + gl.uniform1f(uniforms.u_pitch, (view.pitch * Math.PI) / 180); + gl.uniform1f(uniforms.u_tanHalfFov, Math.tan((view.fov * Math.PI) / 360)); + gl.uniform1f(uniforms.u_aspect, canvas.width / Math.max(1, canvas.height)); + gl.drawArrays(gl.TRIANGLES, 0, 3); + } + + function scheduleRender() { + if (frame) return; + frame = requestAnimationFrame(draw); + } + + return { + setImage(image) { + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, image); + // Linear only: a mipmapped lookup draws a seam where longitude wraps. + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + const repeat = + isPowerOfTwo(image.width) && isPowerOfTwo(image.height) + ? gl.REPEAT + : gl.CLAMP_TO_EDGE; + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, repeat); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + loaded = true; + resize(); + scheduleRender(); + }, + setView(next = {}) { + if (Number.isFinite(next.yaw)) view.yaw = wrapDegrees(next.yaw); + if (Number.isFinite(next.pitch)) view.pitch = clampPitch(next.pitch); + if (Number.isFinite(next.fov)) view.fov = clampFov(next.fov); + scheduleRender(); + }, + getView: () => ({ ...view }), + resize, + render: scheduleRender, + hasImage: () => loaded, + clear() { + loaded = false; + scheduleRender(); + }, + destroy() { + if (frame) cancelAnimationFrame(frame); + frame = 0; + loaded = false; + gl.deleteTexture(texture); + gl.deleteBuffer(buffer); + gl.deleteProgram(program); + gl.getExtension('WEBGL_lose_context')?.loseContext(); + }, + }; +} diff --git a/src/layers/streetLevel/providers/kartaview/panorama.test.mjs b/src/layers/streetLevel/providers/kartaview/panorama.test.mjs new file mode 100644 index 000000000..eddf7de4b --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/panorama.test.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + PANO_FOV_MAX_DEG, + PANO_FOV_MIN_DEG, + PANO_PITCH_LIMIT_DEG, + clampFov, + clampPitch, + rayToUv, + wrapDegrees, +} from './panorama.js'; + +const close = (actual, expected) => + actual.forEach((value, i) => + assert.ok(Math.abs(value - expected[i]) < 1e-9, `${actual} ≉ ${expected}`), + ); + +test('straight ahead samples the image centre; yaw turns right, pitch looks up', () => { + close(rayToUv([0, 0, -1]), [0.5, 0.5]); + close(rayToUv([0, 0, -1], { yaw: 90 }), [0.75, 0.5]); + close(rayToUv([0, 0, -1], { yaw: -90 }), [0.25, 0.5]); + close(rayToUv([0, 0, -1], { pitch: 45 }), [0.5, 0.25]); + close(rayToUv([0, 0, -1], { pitch: -90 }), [0.5, 1]); + // Pitch first, then yaw: looking up-right stays up. + close(rayToUv([0, 0, -1], { yaw: 90, pitch: 45 }), [0.75, 0.25]); + // A ray to the right of the screen is the same as turning right. + close(rayToUv([1, 0, 0]), [0.75, 0.5]); +}); + +test('view limits: pitch stops short of the poles, field of view stays usable', () => { + assert.equal(clampPitch(120), PANO_PITCH_LIMIT_DEG); + assert.equal(clampPitch(-120), -PANO_PITCH_LIMIT_DEG); + assert.equal(clampFov(5), PANO_FOV_MIN_DEG); + assert.equal(clampFov(170), PANO_FOV_MAX_DEG); + assert.equal(wrapDegrees(-30), 330); + assert.equal(wrapDegrees(725), 5); +}); diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js new file mode 100644 index 000000000..84a36d7d0 --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -0,0 +1,203 @@ +import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js'; + +export { NEAREST_RADIUS_M } from '../../policy.js'; + +/** Identity and tuning for the KartaView street-level provider (keyless). */ +export const KARTAVIEW_PROVIDER_ID = 'kartaview'; +export const KARTAVIEW_NAME = 'KartaView'; +export const KARTAVIEW_LABEL = 'KARTAVIEW'; + +/** + * The public API. Its documented twin, api.openstreetcam.org, still answers + * but serves empty coverage tiles. + */ +export const KARTAVIEW_API_HOST = 'https://api.kartaview.org'; +/** KartaView's image proxy: resizes a photo given its base64url address. */ +export const KARTAVIEW_CDN_HOST = 'https://cdn.kartaview.org'; + +/** Stable id prefixes for picked primitives; every one starts with `kv:`. */ +export const PICK_PREFIX = Object.freeze({ + root: 'kv:', + line: 'kv:line:', + image: 'kv:img:', +}); + +export const COLORS = Object.freeze({ + coverage: PROVIDER_COLORS.kartaview, + selected: SHARED_COLORS.selected, +}); + +/** + * Coverage lines are OSM ways with photos, from z13–14 JSON tiles. Lower + * zooms take seconds per tile (z10 does not answer), so higher up the panel + * asks the user to zoom in. + */ +export const COVERAGE_STREET_ZOOM_BELOW_M = 2_500; +export const COVERAGE_MAX_HEIGHT_M = 6_000; +export const COVERAGE_TUNING = Object.freeze({ + maxTiles: 9, + overviewMaxTiles: 0, + // Chained ways are short: a dense z13 tile holds ~1,300. + maxSequences: 9_000, + lineWidthPx: 2.5, + overviewPointPx: 2.5, + moveDebounceMs: 320, + viewRangeMinM: 2_500, + viewRangePerHeight: 10, + viewNearM: 1_000, +}); +export const TOO_HIGH_HINT = 'zoom in to see KartaView coverage'; +export const RATE_LIMITED_MESSAGE = + 'KartaView is rate-limiting requests — they resume on their own'; + +/** + * The photo API answers 429 to more than about three requests at once, so + * at most two are in flight; a 429 is retried after a short wait. + */ +export const PHOTO_API_CONCURRENCY = 2; +export const PHOTO_API_RETRIES = 3; +export const PHOTO_API_RETRY_BASE_MS = 800; +export const TILE_TIMEOUT_MS = 20_000; +export const PHOTO_API_TIMEOUT_MS = 15_000; +/** A photo download (tens of kilobytes to a few megabytes). */ +export const IMAGE_TIMEOUT_MS = 30_000; + +/** Photos asked for around a point; the API returns them nearest first. */ +export const NEAREST_LIMIT = 10; +/** The photo API's search zoom: 18 searches the few tens of metres around the point. */ +export const NEAREST_SEARCH_ZOOM = 18; + +/** + * Photos of a sequence fetched at once, around the open one. Records are + * ~2 kB each, so a page is ~2 MB; the cones and prev/next use it. + */ +export const SEQUENCE_PAGE_SIZE = 1000; + +/** Equirectangular panorama sizes: a quick preview, then the sharp one. */ +export const PANO_PREVIEW_SIZE = Object.freeze([1024, 512]); +export const PANO_FULL_SIZE = Object.freeze([4096, 2048]); +/** A flat photo's sharp version, after the 1280 px one is on screen. */ +export const FLAT_FULL_SIZE = Object.freeze([2560, 2560]); + +/** On-globe credit: KartaView imagery is CC BY-SA 4.0 and needs attribution. */ +export const KARTAVIEW_CREDIT_HTML = + 'Street Level: imagery © KartaView contributors, CC BY-SA 4.0'; + +/** The cone and coverage noun: a line is a street with photos, not a sequence. */ +export const COVERAGE_NOUN = 'streets'; +export const LINE_CLICK_HINT = 'click a line for its nearest photo'; + +/** Base64url of a string, as the image proxy reads addresses. */ +function base64Url(text) { + const bytes = new TextEncoder().encode(text); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +/** A photo resized by KartaView's image proxy to fit `width`×`height`. */ +export function resizedImageUrl(url, [width, height]) { + if (!url) return null; + return `${KARTAVIEW_CDN_HOST}/pr:sharp/rs:fit:${width}:${height}/${base64Url(url)}`; +} + +/** Epoch ms for a KartaView timestamp ('2016-02-19 15:28:18.000', UTC), or null. */ +export function kartaViewTimeMs(value) { + const match = + /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/.exec( + String(value ?? ''), + ); + if (!match) return null; + const [, y, mo, d, h = '0', mi = '0', s = '0'] = match; + if (Number(y) < 1990) return null; + const ms = Date.UTC(+y, +mo - 1, +d, +h, +mi, +s); + return Number.isFinite(ms) ? ms : null; +} + +/** A coordinate pair as numbers; the road-matched one when KartaView has it. */ +function positionOf(record) { + const matchLat = Number(record.matchLat); + const matchLon = Number(record.matchLng); + if ( + Number.isFinite(matchLat) && + Number.isFinite(matchLon) && + (matchLat !== 0 || matchLon !== 0) + ) + return { lat: matchLat, lon: matchLon }; + return { lat: Number(record.lat), lon: Number(record.lng) }; +} + +const STORAGE_HOST = /^storage\d+$/; + +/** + * A photo file's address. The API's `fileurl*` fields always name storage1, + * but a photo whose `storage` is another host (storage2…) is only there, so + * the address is built from `storage` and the relative `filepath*`. + */ +function fileUrl(record, pathKey, urlKey) { + const path = record[pathKey]; + if (STORAGE_HOST.test(record.storage || '') && path) + return `https://${record.storage}.openstreetcam.org/files/photo/${path}`; + return record[urlKey] || null; +} + +/** A storage URL with its `{{sizeprefix}}` placeholder filled. */ +function sized(record, prefix) { + const url = fileUrl(record, 'filepath', 'fileurl') || ''; + return url.includes('{{sizeprefix}}') + ? url.replace('{{sizeprefix}}', prefix) + : null; +} + +/** + * A photo API record in the shape the provider uses, or null without an id + * or a position. + * @returns {{id: string, sequenceId: string|null, sequenceIndex: number|null, lon: number, lat: number, heading: number|null, isPano: boolean, capturedAt: number|null, urls: {view: string|null, full: string|null, pano: string|null}}|null} + */ +export function normalizePhoto(record) { + if (!record || record.id == null) return null; + const { lat, lon } = positionOf(record); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + const heading = + record.heading === '' || record.heading == null + ? NaN + : Number(record.heading); + const isPano = + record.projection === 'SPHERE' || Number(record.fieldOfView) >= 360; + const full = + fileUrl(record, 'filepathProc', 'fileurlProc') || sized(record, 'proc'); + const index = Number(record.sequenceIndex); + return { + id: String(record.id), + sequenceId: record.sequenceId != null ? String(record.sequenceId) : null, + sequenceIndex: Number.isFinite(index) ? index : null, + lon, + lat, + heading: Number.isFinite(heading) ? heading : null, + isPano, + capturedAt: + kartaViewTimeMs(record.shotDate) ?? kartaViewTimeMs(record.dateAdded), + urls: { + // A 1280 px rendition; for a 360° photo it is the front view only. + view: + fileUrl(record, 'filepathLTh', 'fileurlLTh') || + sized(record, 'lth') || + full, + full, + // A 360° photo's equirectangular image ('wrapped_proc'). + pano: isPano && /wrapped/.test(full || '') ? full : null, + }, + }; +} + +/** + * Where the image link goes. KartaView's photo pages fail for signed-out + * visitors ("Sequence cannot be loaded"), so it is the full-size photo on + * KartaView's own storage. + */ +export function kartaViewPhotoUrl(photo) { + return photo?.urls?.full || photo?.urls?.view || 'https://kartaview.org'; +} diff --git a/src/layers/streetLevel/providers/kartaview/policy.test.mjs b/src/layers/streetLevel/providers/kartaview/policy.test.mjs new file mode 100644 index 000000000..b48e1742f --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/policy.test.mjs @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + kartaViewPhotoUrl, + kartaViewTimeMs, + normalizePhoto, + resizedImageUrl, +} from './policy.js'; + +/** A photo record as /2.0/photo returns it (trimmed to what is read). */ +function record(overrides = {}) { + return { + id: '7102491', + sequenceId: '14010', + sequenceIndex: '950', + lat: '42.335712', + lng: '-83.048301', + matchLat: '42.335731506347656', + matchLng: '-83.04828643798828', + heading: '299.58', + fieldOfView: '0', + projection: 'PLANE', + shotDate: '2016-08-23 20:17:47.000', + dateAdded: '2016-08-24 04:01:07', + storage: 'storage1', + filepath: '2016/8/24/{{sizeprefix}}/14010_ba53c_57bd1d23d50b7.jpg', + filepathLTh: '2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg', + filepathProc: '2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg', + fileurl: + 'https://storage1.openstreetcam.org/files/photo/2016/8/24/{{sizeprefix}}/14010_ba53c_57bd1d23d50b7.jpg', + fileurlLTh: + 'https://storage1.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg', + fileurlProc: + 'https://storage1.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg', + ...overrides, + }; +} + +test('a photo record normalizes to numbers, the road-matched position and UTC capture time', () => { + const photo = normalizePhoto(record()); + assert.equal(photo.id, '7102491'); + assert.equal(photo.sequenceId, '14010'); + assert.equal(photo.sequenceIndex, 950); + assert.equal(photo.lat, 42.335731506347656); + assert.equal(photo.lon, -83.04828643798828); + assert.equal(photo.heading, 299.58); + assert.equal(photo.isPano, false); + assert.equal(photo.capturedAt, Date.UTC(2016, 7, 23, 20, 17, 47)); + assert.equal(photo.urls.pano, null); +}); + +test('photo files come from the storage host the record names, not the one its URLs say', () => { + // The API writes storage1 into every URL; this photo lives on storage2. + const photo = normalizePhoto(record({ storage: 'storage2' })); + assert.equal( + photo.urls.view, + 'https://storage2.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg', + ); + assert.equal( + photo.urls.full, + 'https://storage2.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg', + ); + assert.equal(kartaViewPhotoUrl(photo), photo.urls.full); + // An unexpected storage value falls back to the URLs as given. + const odd = normalizePhoto(record({ storage: 'https://evil.example' })); + assert.match(odd.urls.view, /^https:\/\/storage1\.openstreetcam\.org\//); + // No large thumbnail path: the size prefix fills in. + const bare = normalizePhoto( + record({ filepathLTh: '', fileurlLTh: '', storage: 'storage3' }), + ); + assert.equal( + bare.urls.view, + 'https://storage3.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg', + ); +}); + +test('a 360° photo keeps its equirectangular image; the unmatched position is the fallback', () => { + const photo = normalizePhoto( + record({ + projection: 'SPHERE', + fieldOfView: '360', + matchLat: '0', + matchLng: '0', + storage: 'storage13', + filepathProc: '2022/10/6/wrapped_proc/6187609_626b8.jpg', + }), + ); + assert.equal(photo.isPano, true); + assert.equal(photo.lat, 42.335712); + assert.equal( + photo.urls.pano, + 'https://storage13.openstreetcam.org/files/photo/2022/10/6/wrapped_proc/6187609_626b8.jpg', + ); + // A sphere without a wrapped image has nothing to project. + assert.equal( + normalizePhoto(record({ projection: 'SPHERE', fieldOfView: '360' })).urls + .pano, + null, + ); +}); + +test('records without an id or a position are dropped; a missing heading is null', () => { + assert.equal(normalizePhoto(null), null); + assert.equal(normalizePhoto(record({ id: undefined })), null); + assert.equal( + normalizePhoto( + record({ lat: 'x', lng: 'y', matchLat: undefined, matchLng: undefined }), + ), + null, + ); + assert.equal(normalizePhoto(record({ heading: '' })).heading, null); + assert.equal(normalizePhoto(record({ heading: '0' })).heading, 0); + assert.equal(normalizePhoto(record({ heading: 'n/a' })).heading, null); +}); + +test('timestamps parse as UTC; years before 1990 and garbage are unknown', () => { + assert.equal(kartaViewTimeMs('2022-03-21'), Date.UTC(2022, 2, 21)); + assert.equal( + kartaViewTimeMs('2022-03-21T06:42:59'), + Date.UTC(2022, 2, 21, 6, 42, 59), + ); + assert.equal(kartaViewTimeMs('0000-00-00 00:00:00'), null); + assert.equal(kartaViewTimeMs(''), null); + assert.equal(kartaViewTimeMs(null), null); + const fallback = normalizePhoto(record({ shotDate: null })); + assert.equal(fallback.capturedAt, Date.UTC(2016, 7, 24, 4, 1, 7)); +}); + +test('resized images go through the proxy with a base64url address', () => { + const url = resizedImageUrl( + 'https://storage13.openstreetcam.org/files/photo/a?b=c+d', + [1024, 512], + ); + const [, encoded] = /\/rs:fit:1024:512\/(.+)$/.exec(url); + assert.match(url, /^https:\/\/cdn\.kartaview\.org\/pr:sharp\/rs:fit:/); + assert.doesNotMatch(encoded, /[+/=]/); + const decoded = Buffer.from( + encoded.replace(/-/g, '+').replace(/_/g, '/'), + 'base64', + ).toString(); + assert.equal( + decoded, + 'https://storage13.openstreetcam.org/files/photo/a?b=c+d', + ); + assert.equal(resizedImageUrl(null, [1, 1]), null); +}); diff --git a/src/layers/streetLevel/providers/kartaview/provider.test.mjs b/src/layers/streetLevel/providers/kartaview/provider.test.mjs new file mode 100644 index 000000000..ef626fe09 --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/provider.test.mjs @@ -0,0 +1,231 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createKartaViewProvider, kartaViewZoomFor } from './index.js'; +import { SEQUENCE_PAGE_SIZE } from './policy.js'; +import { validateProviders } from '../../registry.js'; +import { fakeHost } from '../../../../testSupport/kartaViewFakes.mjs'; + +/** A photo record `metres` east of (42.3314, -83.0458), in sequence `seq` at `index`. */ +function photoRecord(id, { metres = 0, seq = '14010', index = 0 } = {}) { + const lon = -83.0458 + metres / 82_300; + return { + id: String(id), + sequenceId: seq, + sequenceIndex: String(index), + lat: '42.3314', + lng: String(lon), + heading: '90', + projection: 'PLANE', + fieldOfView: '0', + shotDate: '2016-08-23 20:17:47.000', + storage: 'storage2', + filepathLTh: `2016/8/24/lth/${id}.jpg`, + filepathProc: `2016/8/24/proc/${id}.jpg`, + }; +} + +/** A source over `sequences` (id → indexes present), recording calls. */ +function fakeSource({ nearest = [], sequences = {} } = {}) { + const calls = { nearest: 0, photo: [], pages: [] }; + return { + calls, + getTile: async () => ({ lines: [] }), + nearestPhotos: async () => { + calls.nearest++; + return nearest; + }, + getPhoto: async (id) => { + calls.photo.push(id); + for (const [seq, indexes] of Object.entries(sequences)) + for (const index of indexes) + if (`${seq}-${index}` === id) return photoRecord(id, { seq, index }); + throw new Error('KartaView has no such photo'); + }, + getSequencePhotos: async (seq, { page, itemsPerPage }) => { + calls.pages.push(`${seq}:${page}`); + const indexes = sequences[seq] || []; + const rows = indexes.slice( + (page - 1) * itemsPerPage, + page * itemsPerPage, + ); + return { + photos: rows.map((index) => + photoRecord(`${seq}-${index}`, { seq, index }), + ), + hasMore: page * itemsPerPage < indexes.length, + }; + }, + }; +} + +function fakeContext() { + const actions = { opened: [], nearest: [] }; + return { + actions, + context: { + services: {}, + getFilter: () => ({ pano: 'all', sinceMs: null }), + isActive: () => true, + groundCaster: null, + meshSampler: null, + getSurface: () => 'draped', + notify() {}, + actions: { + openImage: (id) => actions.opened.push(id), + openNearest: (point) => actions.nearest.push(point), + reportError() {}, + }, + }, + }; +} + +function setup(sourceOptions) { + const source = fakeSource(sourceOptions); + const loads = []; + const definition = createKartaViewProvider({ + source, + viewerOptions: { + load: async (url) => { + loads.push(url); + return { src: url }; + }, + createRenderer: () => { + throw new Error('no WebGL in Node'); + }, + }, + }); + const { context, actions } = fakeContext(); + const instance = definition.create(context); + return { definition, instance, source, actions, loads }; +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +test('KartaView registers keyless, off by default, with no filters and its own wording', () => { + const { definition } = setup(); + assert.equal(definition.id, 'kartaview'); + assert.equal(definition.requiresKeyId, null); + assert.equal(definition.defaultOn, false); + assert.deepEqual(definition.filters, []); + assert.match( + definition.credit.html, + /KartaView<\/a> contributors, CC BY-SA 4\.0/, + ); + assert.equal(definition.colors.coverage, '#635bff'); + assert.deepEqual(definition.coverageLabel, { + noun: 'streets', + action: 'click a line for its nearest photo', + }); + // Its prefix fits beside the others. + validateProviders([ + { ...definition, id: 'mapillary', pickPrefix: 'mly:' }, + { ...definition, id: 'google', pickPrefix: 'gsv:' }, + definition, + ]); +}); + +test('coverage is drawn from z14 near the street and z13 a little higher; never from orbit', () => { + assert.deepEqual(kartaViewZoomFor(300), { zoom: 14, kind: 'sequence' }); + assert.deepEqual(kartaViewZoomFor(2_500), { zoom: 14, kind: 'sequence' }); + assert.deepEqual(kartaViewZoomFor(4_000), { zoom: 13, kind: 'sequence' }); + assert.equal(kartaViewZoomFor(6_001), null); + assert.equal(kartaViewZoomFor(NaN), null); +}); + +test('the nearest photo is the closest within 50 m, and opening it asks nothing more', async () => { + const { instance, source, loads } = setup({ + nearest: [ + photoRecord('far', { metres: 80 }), + photoRecord('mid', { metres: 30 }), + photoRecord('near', { metres: 12 }), + ], + }); + const id = await instance.nearestImage({ lat: 42.3314, lon: -83.0458 }); + assert.equal(id, 'near'); + const host = fakeHost(); + await instance.viewer.mount(host); + await instance.viewer.open(id); + assert.deepEqual(source.calls.photo, [], 'the lookup filled the cache'); + assert.equal( + loads[0], + 'https://storage2.openstreetcam.org/files/photo/2016/8/24/lth/near.jpg', + ); + const none = setup({ nearest: [photoRecord('far', { metres: 80 })] }); + assert.equal( + await none.instance.nearestImage({ lat: 42.3314, lon: -83.0458 }), + null, + ); +}); + +test('a cone opens its photo; a line opens the photo nearest the click', () => { + const { instance, actions } = setup(); + assert.equal(instance.handlePick('kv:img:123'), true); + assert.deepEqual(actions.opened, ['123']); + const point = { lon: -83.0458, lat: 42.3314 }; + assert.equal( + instance.handlePick('kv:line:14/1/2/3', { groundPoint: () => point }), + true, + ); + assert.deepEqual(actions.nearest, [point]); + // Over the sky the line is still ours, but there is nowhere to look. + assert.equal( + instance.handlePick('kv:line:14/1/2/3', { groundPoint: () => null }), + true, + ); + assert.equal(actions.nearest.length, 1); + assert.equal(instance.handlePick('mly:seq:1'), false); +}); + +test('coverage stats report KartaView as configured and key-free', async () => { + const { instance } = setup(); + assert.deepEqual(await instance.status(), { configured: true }); + const stats = instance.coverageStats(); + assert.equal(stats.keyRequired, false); + assert.equal(stats.keyRejected, false); + assert.equal(stats.count, 0); +}); + +test('› walks across sequence pages; ‹ walks back; the ends stop', async () => { + const size = SEQUENCE_PAGE_SIZE; + const indexes = Array.from({ length: size + 5 }, (_, i) => i); + const { instance, source } = setup({ sequences: { s: indexes } }); + const host = fakeHost(); + await instance.viewer.mount(host); + const poses = []; + instance.viewer.onPose((pose) => poses.push(pose.imageId)); + const [, , prev, next] = host.children[0].children; + const press = async (button) => { + button.fire('click'); + for (let i = 0; i < 6; i++) await settle(); + }; + await instance.viewer.open(`s-${size - 1}`); + await press(next); + assert.equal(poses.at(-1), `s-${size}`, 'onto the second page'); + assert.deepEqual(source.calls.pages, ['s:1', 's:2']); + await press(prev); + assert.equal(poses.at(-1), `s-${size - 1}`); + await instance.viewer.open('s-0'); + await press(prev); + assert.equal(poses.at(-1), 's-0', 'nothing before the first photo'); + assert.equal(prev.disabled, true); +}); + +test('a page with missing photos is found by stepping back a page', async () => { + const size = SEQUENCE_PAGE_SIZE; + // Index `size + 100` sits on page 1 when 200 earlier photos are gone. + const indexes = Array.from({ length: size + 300 }, (_, i) => i).filter( + (i) => i < 100 || i >= 300, + ); + const { instance, source } = setup({ sequences: { s: indexes } }); + const host = fakeHost(); + await instance.viewer.mount(host); + const poses = []; + instance.viewer.onPose((pose) => poses.push(pose.imageId)); + const target = size + 100; + await instance.viewer.open(`s-${target}`); + const [, , , next] = host.children[0].children; + next.fire('click'); + for (let i = 0; i < 8; i++) await settle(); + assert.equal(poses.at(-1), `s-${target + 1}`); + assert.deepEqual(source.calls.pages.slice(0, 2), ['s:2', 's:1']); +}); diff --git a/src/layers/streetLevel/providers/kartaview/source.js b/src/layers/streetLevel/providers/kartaview/source.js new file mode 100644 index 000000000..7d68c5f62 --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/source.js @@ -0,0 +1,226 @@ +import { + KARTAVIEW_API_HOST, + NEAREST_LIMIT, + NEAREST_SEARCH_ZOOM, + PHOTO_API_CONCURRENCY, + PHOTO_API_RETRIES, + PHOTO_API_RETRY_BASE_MS, + PHOTO_API_TIMEOUT_MS, + TILE_TIMEOUT_MS, +} from './policy.js'; + +/** An HTTP or API failure; `retryAfterSec` is set while KartaView rate-limits. */ +export class KartaViewSourceError extends Error { + constructor(message, { status = 0, retryAfterSec = null } = {}) { + super(message); + this.name = 'KartaViewSourceError'; + this.status = status; + this.retryAfterSec = retryAfterSec; + } +} + +const NO_ANSWER = 'KartaView did not answer — check the network and try again'; + +/** Resolves after `ms`, or rejects when `signal` aborts first. */ +function wait(ms, signal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(signal.reason); + const timer = setTimeout(done, ms); + const onAbort = () => { + clearTimeout(timer); + reject(signal.reason); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + function done() { + signal?.removeEventListener('abort', onAbort); + resolve(); + } + }); +} + +/** Seconds from a Retry-After header, or null. */ +function retryAfterSeconds(response) { + const header = response.headers?.get?.('retry-after'); + const value = header == null || header === '' ? NaN : Number(header); + return Number.isFinite(value) && value >= 0 ? value : null; +} + +/** A first-in, first-out gate letting at most `limit` tasks run at once. */ +export function createLimiter(limit) { + let running = 0; + const queue = []; + function next() { + while (running < limit && queue.length) { + const { task, resolve, reject, signal } = queue.shift(); + if (signal?.aborted) { + reject(signal.reason); + continue; + } + running++; + Promise.resolve() + .then(task) + .then(resolve, reject) + .finally(() => { + running--; + next(); + }); + } + } + return { + run(task, { signal } = {}) { + return new Promise((resolve, reject) => { + const item = { task, resolve, reject, signal }; + queue.push(item); + signal?.addEventListener( + 'abort', + () => { + const at = queue.indexOf(item); + if (at === -1) return; + queue.splice(at, 1); + reject(signal.reason); + }, + { once: true }, + ); + next(); + }); + }, + get pending() { + return running + queue.length; + }, + }; +} + +/** + * KartaView's public API, browser-side and keyless (it allows any origin). + * Coverage tiles are not rate-limited; photo lookups are, so they queue two + * at a time and retry a 429. + * @param {{fetchImpl?: typeof fetch, apiHost?: string, retryBaseMs?: number}} [options] + */ +export function createKartaViewSource({ + fetchImpl = (...args) => globalThis.fetch(...args), + apiHost = KARTAVIEW_API_HOST, + retryBaseMs = PHOTO_API_RETRY_BASE_MS, +} = {}) { + const limiter = createLimiter(PHOTO_API_CONCURRENCY); + + /** GET JSON within `timeoutMs`; the caller's abort wins over the timeout. */ + async function getJson(url, { signal, timeoutMs }) { + const controller = new AbortController(); + const onAbort = () => controller.abort(signal.reason); + if (signal?.aborted) throw signal.reason; + signal?.addEventListener('abort', onAbort, { once: true }); + const timer = setTimeout( + () => controller.abort(new KartaViewSourceError(NO_ANSWER)), + timeoutMs, + ); + try { + const response = await fetchImpl(url, { signal: controller.signal }); + if (response.status === 429) + throw new KartaViewSourceError('KartaView is rate-limiting requests', { + status: 429, + retryAfterSec: retryAfterSeconds(response) ?? 2, + }); + if (!response.ok) + throw new KartaViewSourceError( + `KartaView answered HTTP ${response.status}`, + { status: response.status }, + ); + return await response.json(); + } catch (error) { + if (signal?.aborted) throw signal.reason; + if (controller.signal.aborted) throw controller.signal.reason; + if (error instanceof KartaViewSourceError) throw error; + throw new KartaViewSourceError(NO_ANSWER); + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + } + } + + /** A photo-API call through the limiter, retrying 429s with backoff. */ + async function photoApi(path, params, { signal } = {}) { + const query = params ? `?${new URLSearchParams(params)}` : ''; + const url = `${apiHost}${path}${query}`; + for (let attempt = 0; ; attempt++) { + try { + const payload = await limiter.run( + () => getJson(url, { signal, timeoutMs: PHOTO_API_TIMEOUT_MS }), + { signal }, + ); + const code = Number(payload?.status?.apiCode); + // 600 ok, 601 empty, 602 partial: anything else is a refusal. + if (Number.isFinite(code) && (code < 600 || code > 603)) + throw new KartaViewSourceError( + String( + payload?.status?.apiMessage || 'KartaView refused the request', + ).trim(), + { status: Number(payload?.status?.httpCode) || 0 }, + ); + return payload?.result ?? {}; + } catch (error) { + if (error?.status !== 429 || attempt >= PHOTO_API_RETRIES) throw error; + await wait( + Math.max( + retryBaseMs * 2 ** attempt, + (error.retryAfterSec ?? 0) * 1000, + ), + signal, + ); + } + } + } + + return { + /** A coverage tile: `{lines: [{track: [{lat, lng}]}]}`. */ + async getTile({ x, y, z }, { signal } = {}) { + return getJson(`${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.json`, { + signal, + timeoutMs: TILE_TIMEOUT_MS, + }); + }, + + /** Photo records around a point, nearest first. */ + async nearestPhotos({ lat, lon }, { signal } = {}) { + const result = await photoApi( + '/2.0/photo/', + { + lat: lat.toFixed(7), + lng: lon.toFixed(7), + zoomLevel: String(NEAREST_SEARCH_ZOOM), + itemsPerPage: String(NEAREST_LIMIT), + }, + { signal }, + ); + return Array.isArray(result.data) ? result.data : []; + }, + + /** One photo record. */ + async getPhoto(photoId, { signal } = {}) { + const result = await photoApi( + `/2.0/photo/${encodeURIComponent(photoId)}`, + null, + { signal }, + ); + if (!result.data || typeof result.data !== 'object') + throw new KartaViewSourceError('KartaView has no such photo'); + return result.data; + }, + + /** A page of a sequence's photos in capture order (1-based `page`). */ + async getSequencePhotos( + sequenceId, + { page, itemsPerPage }, + { signal } = {}, + ) { + const result = await photoApi( + `/2.0/sequence/${encodeURIComponent(sequenceId)}/photos`, + { page: String(page), itemsPerPage: String(itemsPerPage) }, + { signal }, + ); + return { + photos: Array.isArray(result.data) ? result.data : [], + hasMore: result.hasMoreData === true, + }; + }, + }; +} diff --git a/src/layers/streetLevel/providers/kartaview/source.test.mjs b/src/layers/streetLevel/providers/kartaview/source.test.mjs new file mode 100644 index 000000000..8d8bca033 --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/source.test.mjs @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + KartaViewSourceError, + createKartaViewSource, + createLimiter, +} from './source.js'; +import { PHOTO_API_CONCURRENCY, PHOTO_API_RETRIES } from './policy.js'; + +const ok = (body) => ({ + ok: true, + status: 200, + headers: new Map(), + json: async () => body, +}); +const status = (code, headers = new Map()) => ({ + ok: false, + status: code, + headers, + json: async () => ({}), +}); +const photoPayload = (data) => ({ + status: { apiCode: 600, httpCode: 200 }, + result: { data, hasMoreData: false }, +}); + +test('coverage tiles come from api.kartaview.org as JSON', async () => { + const urls = []; + const source = createKartaViewSource({ + fetchImpl: async (url) => { + urls.push(url); + return ok({ lines: [] }); + }, + }); + assert.deepEqual(await source.getTile({ x: 4412, y: 6061, z: 14 }), { + lines: [], + }); + assert.deepEqual(urls, [ + 'https://api.kartaview.org/2.0/sequence/tiles/4412/6061/14.json', + ]); +}); + +test('photo lookups run at most two at a time', async () => { + let running = 0; + let peak = 0; + const source = createKartaViewSource({ + fetchImpl: async () => { + running++; + peak = Math.max(peak, running); + await new Promise((resolve) => setTimeout(resolve, 5)); + running--; + return ok(photoPayload({ id: '1' })); + }, + }); + await Promise.all( + Array.from({ length: 7 }, (_, i) => source.getPhoto(String(i))), + ); + assert.equal(peak, PHOTO_API_CONCURRENCY); +}); + +test('a 429 is retried after a wait, then answered', async () => { + let calls = 0; + const source = createKartaViewSource({ + retryBaseMs: 1, + fetchImpl: async () => { + calls++; + return calls < 3 + ? status(429, new Map([['retry-after', '0']])) + : ok(photoPayload([{ id: '9' }])); + }, + }); + const records = await source.nearestPhotos({ lat: 42.3, lon: -83.04 }); + assert.deepEqual(records, [{ id: '9' }]); + assert.equal(calls, 3); +}); + +test('rate-limiting that outlasts the retries is reported with a wait', async () => { + let calls = 0; + const source = createKartaViewSource({ + retryBaseMs: 1, + fetchImpl: async () => { + calls++; + return status(429, new Map([['retry-after', '0.001']])); + }, + }); + await assert.rejects(source.getPhoto('1'), (error) => { + assert.ok(error instanceof KartaViewSourceError); + assert.equal(error.status, 429); + assert.ok(error.retryAfterSec > 0); + return true; + }); + assert.equal(calls, PHOTO_API_RETRIES + 1); +}); + +test('an API refusal inside a 200 is an error with KartaView’s message; 601 is just empty', async () => { + const refusing = createKartaViewSource({ + fetchImpl: async () => + ok({ + status: { apiCode: 612, apiMessage: ' out of range ', httpCode: 400 }, + result: {}, + }), + }); + await assert.rejects(refusing.getPhoto('1'), /out of range/); + const empty = createKartaViewSource({ + fetchImpl: async () => + ok({ status: { apiCode: 601 }, result: { data: [] } }), + }); + assert.deepEqual(await empty.nearestPhotos({ lat: 0, lon: 0 }), []); + const page = await empty.getSequencePhotos('2008', { + page: 2, + itemsPerPage: 1000, + }); + assert.deepEqual(page, { photos: [], hasMore: false }); +}); + +test('HTTP errors, network failures and non-JSON become readable errors', async () => { + const http = createKartaViewSource({ fetchImpl: async () => status(500) }); + await assert.rejects(http.getTile({ x: 0, y: 0, z: 14 }), /HTTP 500/); + const offline = createKartaViewSource({ + fetchImpl: async () => { + throw new TypeError('Failed to fetch'); + }, + }); + await assert.rejects(offline.getPhoto('1'), /did not answer/); + const garbled = createKartaViewSource({ + fetchImpl: async () => ({ + ok: true, + status: 200, + headers: new Map(), + json: async () => { + throw new SyntaxError('Unexpected token <'); + }, + }), + }); + await assert.rejects( + garbled.getTile({ x: 0, y: 0, z: 14 }), + /did not answer/, + ); +}); + +test('an abort stops a queued lookup without fetching, and wins over a retry wait', async () => { + let calls = 0; + let release; + const gate = new Promise((resolve) => (release = resolve)); + const source = createKartaViewSource({ + retryBaseMs: 10_000, + fetchImpl: async (url) => { + calls++; + if (url.includes('/slow')) await gate; + return status(429); + }, + }); + // Two slow lookups fill the queue; the third waits for a slot. + const first = new AbortController(); + const slow = [ + source.getPhoto('slow', { signal: first.signal }), + source.getPhoto('slow', { signal: first.signal }), + ]; + const controller = new AbortController(); + const queued = source.getPhoto('queued', { signal: controller.signal }); + controller.abort(new Error('overtaken')); + await assert.rejects(queued, /overtaken/); + assert.equal(calls, 2, 'the queued lookup never fetched'); + release(); + // Each slow one is now waiting 10 s to retry; nothing else is asked. + const retrying = new AbortController(); + const waiting = source.getPhoto('slow', { signal: retrying.signal }); + await new Promise((resolve) => setTimeout(resolve, 20)); + retrying.abort(new Error('closed')); + await assert.rejects(waiting, /closed/); + // The two first lookups are still waiting out their backoff: drop them. + first.abort(new Error('done')); + for (const promise of slow) await assert.rejects(promise, /done/); +}); + +test('the limiter runs tasks in order and keeps going after a failure', async () => { + const limiter = createLimiter(1); + const order = []; + const failing = limiter.run(async () => { + order.push('a'); + throw new Error('a failed'); + }); + const second = limiter.run(async () => { + order.push('b'); + return 'b'; + }); + await assert.rejects(failing, /a failed/); + assert.equal(await second, 'b'); + assert.deepEqual(order, ['a', 'b']); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(limiter.pending, 0); +}); diff --git a/src/layers/streetLevel/providers/kartaview/viewer.js b/src/layers/streetLevel/providers/kartaview/viewer.js new file mode 100644 index 000000000..27c5bee6b --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/viewer.js @@ -0,0 +1,422 @@ +import { + createPanoramaRenderer, + PANO_FOV_DEFAULT_DEG, + wrapDegrees, +} from './panorama.js'; +import { + FLAT_FULL_SIZE, + IMAGE_TIMEOUT_MS, + KARTAVIEW_PROVIDER_ID, + PANO_FULL_SIZE, + PANO_PREVIEW_SIZE, + kartaViewPhotoUrl, + resizedImageUrl, +} from './policy.js'; + +const LOAD_FAILED = 'The KartaView photo could not be loaded'; + +/** Load an image (CORS-clean, for WebGL) within the timeout. */ +export function loadImage( + url, + { crossOrigin = true, timeoutMs = IMAGE_TIMEOUT_MS } = {}, +) { + return new Promise((resolve, reject) => { + if (!url) return reject(new Error(LOAD_FAILED)); + const image = new Image(); + if (crossOrigin) image.crossOrigin = 'anonymous'; + image.decoding = 'async'; + const timer = setTimeout(() => { + image.src = ''; + reject(new Error(LOAD_FAILED)); + }, timeoutMs); + image.onload = () => { + clearTimeout(timer); + resolve(image); + }; + image.onerror = () => { + clearTimeout(timer); + reject(new Error(LOAD_FAILED)); + }; + image.src = url; + }); +} + +/** + * Viewer adapter for KartaView photos: a flat photo in an , a 360° + * photo on a WebGL panorama (drag to look, wheel to zoom), and ‹ › to step + * along the sequence (also ← →). Poses follow the view direction. + * + * @param {object} options + * @param {(photoId: string) => Promise} options.getPhoto A normalized photo (policy.js normalizePhoto). + * @param {(photo: object, delta: -1|1) => Promise} options.step The photo `delta` along the sequence, or null at its end. + * @param {(imageId: string) => void} [options.onStepped] A photo opened from ‹ ›, after its pose. + * @param {object} [options.render] Render governor. + * @param {(url: string, options?: object) => Promise} [options.load] + * @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer] + * @returns {import('../../registry.js').ViewerAdapter} + */ +export function createKartaViewViewer({ + getPhoto, + step, + onStepped = () => {}, + render, + load = loadImage, + createRenderer = createPanoramaRenderer, +}) { + const listeners = new Set(); + let root = null; + let picture = null; + let canvas = null; + let prevButton = null; + let nextButton = null; + let renderer = null; + /** WebGL failed once: 360° photos show their front view instead. */ + let rendererFailed = false; + let photo = null; + /** What shows: 'flat', 'pano' or null. */ + let showing = null; + let openSeq = 0; + let renderMode = 'letterbox'; + let drag = null; + let poseFrame = 0; + let removeListeners = []; + + function requestRender() { + render?.governorRequestRender?.('kartaview-viewer'); + } + + function emit() { + if (!photo || !showing) return; + const view = showing === 'pano' ? renderer.getView() : null; + const heading = photo.heading; + const pose = { + providerId: KARTAVIEW_PROVIDER_ID, + imageId: photo.id, + position: { lon: photo.lon, lat: photo.lat }, + bearing: + heading === null + ? null + : view + ? wrapDegrees(heading + view.yaw) + : heading, + tilt: view ? view.pitch : 0, + altitude: null, + isPano: showing === 'pano', + capturedAt: photo.capturedAt, + capturedAtPrecision: 'day', + // KartaView's only place name is the sequence's, where the drive began. + title: null, + creator: null, + sequenceId: photo.sequenceId, + externalUrl: kartaViewPhotoUrl(photo), + }; + for (const listener of [...listeners]) { + try { + listener(pose); + } catch { + /* listener errors are the core's to log */ + } + } + requestRender(); + } + + /** Re-emit on the next frame: a drag moves the view many times a frame. */ + function emitSoon() { + if (poseFrame) return; + poseFrame = requestAnimationFrame(() => { + poseFrame = 0; + emit(); + }); + } + + function syncButtons() { + const enabled = Boolean(photo?.sequenceId) && showing !== null; + if (prevButton) prevButton.disabled = !enabled; + if (nextButton) nextButton.disabled = !enabled; + } + + function show(kind) { + showing = kind; + if (picture) picture.hidden = kind !== 'flat'; + if (canvas) canvas.hidden = kind !== 'pano'; + if (root) root.dataset.kind = kind || ''; + syncButtons(); + } + + function applyRenderMode() { + if (picture) + picture.style.objectFit = renderMode === 'fill' ? 'cover' : 'contain'; + } + + function ensureRenderer() { + if (renderer || rendererFailed || !canvas) return renderer; + try { + renderer = createRenderer(canvas); + } catch { + rendererFailed = true; + renderer = null; + } + return renderer; + } + + /** Step along the sequence from the open photo. */ + async function stepBy(delta) { + const from = photo; + if (!from?.sequenceId || !showing) return; + const seq = openSeq; + let nextId = null; + try { + nextId = await step(from, delta); + } catch { + nextId = null; + } + if (seq !== openSeq || photo !== from) return; + if (!nextId) { + const button = delta < 0 ? prevButton : nextButton; + if (button) button.disabled = true; + return; + } + try { + await adapter.open(nextId, { keepView: true }); + } catch { + return; + } + if (photo?.id === nextId) onStepped(nextId); + } + + function onPointerDown(event) { + if (showing !== 'pano' || event.button !== 0) return; + drag = { + id: event.pointerId, + x: event.clientX, + y: event.clientY, + view: renderer.getView(), + }; + canvas.setPointerCapture?.(event.pointerId); + canvas.classList.add('sl-kv-dragging'); + } + + function onPointerMove(event) { + if (!drag || event.pointerId !== drag.id) return; + const { fov } = drag.view; + const width = Math.max(1, canvas.clientWidth); + const height = Math.max(1, canvas.clientHeight); + const vertical = fov; + const horizontal = fov * (width / height); + renderer.setView({ + yaw: drag.view.yaw - ((event.clientX - drag.x) / width) * horizontal, + pitch: drag.view.pitch + ((event.clientY - drag.y) / height) * vertical, + }); + emitSoon(); + } + + function onPointerUp(event) { + if (!drag || event.pointerId !== drag.id) return; + drag = null; + canvas.classList.remove('sl-kv-dragging'); + } + + function onWheel(event) { + if (showing !== 'pano') return; + event.preventDefault(); + const { fov } = renderer.getView(); + renderer.setView({ fov: fov * Math.exp(event.deltaY * 0.001) }); + } + + function onKeyDown(event) { + if ( + event.defaultPrevented || + event.altKey || + event.ctrlKey || + event.metaKey + ) + return; + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + event.preventDefault(); + stepBy(event.key === 'ArrowLeft' ? -1 : 1); + } + } + + function listen(target, type, handler, options) { + target.addEventListener(type, handler, options); + removeListeners.push(() => + target.removeEventListener(type, handler, options), + ); + } + + function build(host) { + const doc = host.ownerDocument; + root = doc.createElement('div'); + root.className = 'sl-kv-viewer'; + root.tabIndex = 0; + root.setAttribute('role', 'group'); + root.setAttribute( + 'aria-label', + 'KartaView photo. Drag to look around a 360° photo; ← and → step along the sequence', + ); + picture = doc.createElement('img'); + picture.className = 'sl-kv-photo'; + picture.alt = 'KartaView street-level photo'; + picture.draggable = false; + picture.hidden = true; + canvas = doc.createElement('canvas'); + canvas.className = 'sl-kv-pano'; + canvas.hidden = true; + const nav = (label, glyph, delta) => { + const button = doc.createElement('button'); + button.type = 'button'; + button.className = `sl-kv-step sl-kv-step-${delta < 0 ? 'prev' : 'next'}`; + button.textContent = glyph; + button.title = label; + button.setAttribute('aria-label', label); + button.disabled = true; + listen(button, 'click', (event) => { + event.stopPropagation(); + stepBy(delta); + }); + return button; + }; + prevButton = nav('Previous photo in this sequence (←)', '‹', -1); + nextButton = nav('Next photo in this sequence (→)', '›', 1); + root.append(picture, canvas, prevButton, nextButton); + host.append(root); + listen(canvas, 'pointerdown', onPointerDown); + listen(canvas, 'pointermove', onPointerMove); + listen(canvas, 'pointerup', onPointerUp); + listen(canvas, 'pointercancel', onPointerUp); + listen(canvas, 'wheel', onWheel, { passive: false }); + listen(root, 'keydown', onKeyDown); + applyRenderMode(); + } + + function teardown() { + for (const remove of removeListeners) remove(); + removeListeners = []; + if (poseFrame) cancelAnimationFrame(poseFrame); + poseFrame = 0; + drag = null; + renderer?.destroy(); + renderer = null; + root?.remove(); + root = picture = canvas = prevButton = nextButton = null; + showing = null; + photo = null; + } + + /** A sharper version once the first is on screen, if nothing replaced it. */ + async function upgrade(target, seq, kind) { + // A 360° photo shown flat (no WebGL) keeps its front view. + if (kind === 'flat' && target.isPano) return; + const url = + kind === 'pano' + ? resizedImageUrl(target.urls.pano, PANO_FULL_SIZE) + : resizedImageUrl(target.urls.full, FLAT_FULL_SIZE); + if (!url) return; + try { + const image = await load(url, { crossOrigin: kind === 'pano' }); + if (seq !== openSeq || photo !== target || showing !== kind) return; + if (kind === 'pano') renderer.setImage(image); + else picture.src = image.src; + requestRender(); + } catch { + /* the first version stays */ + } + } + + async function showPano(target, seq, keepView) { + const preview = resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE); + let image; + try { + image = await load(preview, { crossOrigin: true }); + } catch { + image = await load(resizedImageUrl(target.urls.pano, PANO_FULL_SIZE), { + crossOrigin: true, + }); + } + if (seq !== openSeq) return false; + if (!keepView || showing !== 'pano') + renderer.setView({ yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG }); + renderer.setImage(image); + photo = target; + show('pano'); + return true; + } + + async function showFlat(target, seq) { + let image; + try { + image = await load(target.urls.view, { crossOrigin: false }); + } catch (error) { + // No 1280 px rendition: the proxy can shrink the full-size photo. + const fallback = resizedImageUrl(target.urls.full, FLAT_FULL_SIZE); + if (target.isPano || !fallback || seq !== openSeq) throw error; + image = await load(fallback, { crossOrigin: false }); + } + if (seq !== openSeq) return false; + picture.src = image.src; + photo = target; + show('flat'); + return true; + } + + const adapter = { + async mount(host) { + if (!host) throw new Error('Street Level viewer has no host element'); + if (root && root.parentNode === host) return; + teardown(); + build(host); + }, + + /** Resolves once the photo is on screen and its pose was emitted. */ + async open(imageId, { keepView = false } = {}) { + if (!root) throw new Error('KartaView viewer is not mounted'); + const seq = ++openSeq; + const target = await getPhoto(String(imageId)); + if (seq !== openSeq) return; + let shown = false; + if (target.isPano && target.urls.pano && ensureRenderer()) { + try { + shown = await showPano(target, seq, keepView); + } catch { + // The panorama failed: its front view is better than nothing. + if (seq !== openSeq) return; + shown = await showFlat(target, seq); + } + } else shown = await showFlat(target, seq); + if (!shown) return; + emit(); + upgrade(target, seq, showing); + }, + + close() { + openSeq++; + photo = null; + show(null); + if (picture) picture.removeAttribute('src'); + renderer?.clear?.(); + }, + + unmount() { + openSeq++; + teardown(); + }, + + resize() { + renderer?.resize(); + }, + + /** FIT / FILL frame a flat photo; a panorama has no frame to fit. */ + supportsRenderModes: () => showing !== 'pano', + + setRenderMode(mode) { + renderMode = mode === 'fill' ? 'fill' : 'letterbox'; + applyRenderMode(); + }, + + onPose(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + return adapter; +} diff --git a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs new file mode 100644 index 000000000..f15c6f3fc --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs @@ -0,0 +1,270 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createKartaViewViewer } from './viewer.js'; +import { fakeHost } from '../../../../testSupport/kartaViewFakes.mjs'; + +const PHOTOS = { + flat1: { + id: 'flat1', + sequenceId: 's1', + sequenceIndex: 1, + lon: -83.04, + lat: 42.33, + heading: 90, + isPano: false, + capturedAt: Date.UTC(2016, 7, 23), + urls: { + view: 'https://storage2.openstreetcam.org/lth/a.jpg', + full: 'https://storage2.openstreetcam.org/proc/a.jpg', + pano: null, + }, + }, + flat2: { + id: 'flat2', + sequenceId: 's1', + sequenceIndex: 2, + lon: -83.0401, + lat: 42.33, + heading: 92, + isPano: false, + capturedAt: Date.UTC(2016, 7, 23), + urls: { + view: 'https://storage2.openstreetcam.org/lth/b.jpg', + full: 'https://storage2.openstreetcam.org/proc/b.jpg', + pano: null, + }, + }, + pano1: { + id: 'pano1', + sequenceId: 's2', + sequenceIndex: 7, + lon: 106.84, + lat: -6.19, + heading: 150, + isPano: true, + capturedAt: Date.UTC(2022, 2, 21), + urls: { + view: 'https://storage13.openstreetcam.org/lth/p.jpg', + full: 'https://storage13.openstreetcam.org/wrapped_proc/p.jpg', + pano: 'https://storage13.openstreetcam.org/wrapped_proc/p.jpg', + }, + }, +}; + +function fakeRenderer() { + const view = { yaw: 0, pitch: 0, fov: 80 }; + return { + images: [], + setImage(image) { + this.images.push(image.src); + }, + setView(next) { + Object.assign(view, next); + }, + getView: () => ({ ...view }), + resize() {}, + clear() {}, + destroy() { + this.destroyed = true; + }, + }; +} + +function setup({ failLoads = [], rendererThrows = false, steps = {} } = {}) { + const loads = []; + const renderers = []; + const stepped = []; + const viewer = createKartaViewViewer({ + getPhoto: async (id) => { + if (!PHOTOS[id]) throw new Error('no such photo'); + return PHOTOS[id]; + }, + step: async (photo, delta) => steps[`${photo.id}:${delta}`] ?? null, + onStepped: (id) => stepped.push(id), + load: async (url, options) => { + loads.push({ url, crossOrigin: options?.crossOrigin }); + if (failLoads.some((part) => url.includes(part))) + throw new Error('The KartaView photo could not be loaded'); + return { src: url }; + }, + createRenderer: () => { + if (rendererThrows) throw new Error('WebGL is unavailable'); + const renderer = fakeRenderer(); + renderers.push(renderer); + return renderer; + }, + }); + const poses = []; + viewer.onPose((pose) => poses.push(pose)); + const host = fakeHost(); + return { viewer, poses, host, loads, renderers, stepped }; +} + +const parts = (host) => { + const root = host.children[0]; + const [picture, canvas, prev, next] = root.children; + return { root, picture, canvas, prev, next }; +}; +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +test('a flat photo shows its 1280 px version, then the sharp one, with a pose facing its heading', async () => { + const { viewer, poses, host, loads } = setup(); + await viewer.mount(host); + const { picture, canvas, prev, next } = parts(host); + assert.equal(prev.disabled, true, 'nothing to step through yet'); + await viewer.open('flat1'); + assert.equal(picture.hidden, false); + assert.equal(canvas.hidden, true); + assert.equal(prev.disabled, false); + assert.equal(next.disabled, false); + const pose = poses.at(-1); + assert.deepEqual( + { + providerId: pose.providerId, + imageId: pose.imageId, + position: pose.position, + bearing: pose.bearing, + tilt: pose.tilt, + isPano: pose.isPano, + sequenceId: pose.sequenceId, + capturedAt: pose.capturedAt, + title: pose.title, + }, + { + providerId: 'kartaview', + imageId: 'flat1', + position: { lon: -83.04, lat: 42.33 }, + bearing: 90, + tilt: 0, + isPano: false, + sequenceId: 's1', + capturedAt: Date.UTC(2016, 7, 23), + title: null, + }, + ); + assert.equal(pose.externalUrl, PHOTOS.flat1.urls.full); + assert.equal(loads[0].url, PHOTOS.flat1.urls.view); + assert.equal(loads[0].crossOrigin, false, 'an needs no CORS'); + await settle(); + assert.match( + loads[1].url, + /^https:\/\/cdn\.kartaview\.org\/.*rs:fit:2560:2560/, + ); + assert.equal(picture.src, loads[1].url, 'upgraded to the sharp version'); + assert.equal(viewer.supportsRenderModes(), true); + viewer.setRenderMode('fill'); + assert.equal(picture.style.objectFit, 'cover'); +}); + +test('a missing 1280 px rendition falls back to the proxy-shrunk full photo', async () => { + const { viewer, host, loads } = setup({ failLoads: ['/lth/'] }); + await viewer.mount(host); + await viewer.open('flat1'); + assert.match(loads[1].url, /rs:fit:2560:2560/); + assert.equal(parts(host).picture.hidden, false); +}); + +test('a 360° photo shows on the panorama, preview first; dragging turns the bearing', async () => { + const { viewer, poses, host, loads, renderers } = setup(); + await viewer.mount(host); + await viewer.open('pano1'); + const { picture, canvas } = parts(host); + assert.equal(canvas.hidden, false); + assert.equal(picture.hidden, true); + assert.match(loads[0].url, /rs:fit:1024:512/); + assert.equal(loads[0].crossOrigin, true, 'WebGL textures need CORS'); + assert.equal(poses.at(-1).isPano, true); + assert.equal(poses.at(-1).bearing, 150); + assert.equal(viewer.supportsRenderModes(), false, 'no frame to fit'); + await settle(); + assert.match(renderers[0].images.at(-1), /rs:fit:4096:2048/); + // Drag left by a quarter of the width: the view turns right. + const frames = []; + globalThis.requestAnimationFrame = (callback) => { + frames.push(callback); + return frames.length; + }; + try { + canvas.fire('pointerdown', { + button: 0, + pointerId: 1, + clientX: 200, + clientY: 100, + }); + canvas.fire('pointermove', { pointerId: 1, clientX: 100, clientY: 100 }); + canvas.fire('pointerup', { pointerId: 1 }); + frames.forEach((callback) => callback()); + } finally { + delete globalThis.requestAnimationFrame; + } + const turned = poses.at(-1).bearing; + assert.ok(turned > 150 && turned < 360, `bearing ${turned} turned right`); +}); + +test('without WebGL, or when the panorama fails, a 360° photo shows its front view', async () => { + const noGl = setup({ rendererThrows: true }); + await noGl.viewer.mount(noGl.host); + await noGl.viewer.open('pano1'); + assert.equal(parts(noGl.host).picture.hidden, false); + assert.equal(noGl.loads[0].url, PHOTOS.pano1.urls.view); + assert.equal(noGl.poses.at(-1).isPano, false); + await settle(); + assert.equal( + noGl.loads.length, + 1, + 'the equirectangular image never replaces it', + ); + + const broken = setup({ failLoads: ['cdn.kartaview.org'] }); + await broken.viewer.mount(broken.host); + await broken.viewer.open('pano1'); + assert.equal(parts(broken.host).picture.hidden, false); + assert.equal(broken.poses.at(-1).imageId, 'pano1'); +}); + +test('‹ › and the arrow keys step along the sequence; the end disables the button', async () => { + const { viewer, poses, host, stepped } = setup({ + steps: { 'flat1:1': 'flat2' }, + }); + await viewer.mount(host); + await viewer.open('flat1'); + const { root, prev, next } = parts(host); + next.fire('click'); + await settle(); + await settle(); + assert.equal(poses.at(-1).imageId, 'flat2'); + assert.deepEqual(stepped, ['flat2']); + root.fire('keydown', { key: 'ArrowRight' }); + await settle(); + await settle(); + assert.equal(next.disabled, true, 'flat2 is the last photo'); + root.fire('keydown', { key: 'ArrowLeft', metaKey: true }); + await settle(); + assert.equal(poses.at(-1).imageId, 'flat2', 'modified arrows are not ours'); + assert.equal(prev.disabled, false); +}); + +test('a newer open wins; close hides the photo; unmount tears down the panorama', async () => { + const { viewer, poses, host, renderers } = setup(); + await viewer.mount(host); + const first = viewer.open('pano1'); + const second = viewer.open('flat1'); + await Promise.all([first, second]); + assert.deepEqual( + poses.map((pose) => pose.imageId), + ['flat1'], + 'the overtaken panorama never posed', + ); + assert.equal(renderers.length, 0, 'nor built a WebGL context'); + await viewer.open('pano1'); + viewer.close(); + const { picture, canvas, root } = parts(host); + assert.equal(canvas.hidden, true); + assert.equal(picture.hidden, true); + assert.equal(root.dataset.kind, ''); + await assert.rejects(viewer.open('nope'), /no such photo/); + viewer.unmount(); + assert.equal(host.children.length, 0); + assert.equal(renderers[0].destroyed, true); + await assert.rejects(viewer.open('flat1'), /not mounted/); +}); diff --git a/src/layers/streetLevel/providers/mapillary/coverage.js b/src/layers/streetLevel/providers/mapillary/coverage.js index 5b9b5246c..e08a578e4 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.js +++ b/src/layers/streetLevel/providers/mapillary/coverage.js @@ -1,21 +1,11 @@ -import * as Cesium from 'cesium'; -import { decodeCoverageTile } from './decode.js'; -import { passesImageryFilter } from '../../filter.js'; import { - createHorizonCull, - groundUnderCamera, - metresBetween, - viewFocus, - visibleBbox, - whenIdle, -} from '../../view.js'; -import { densifyLine, MESH_DENSIFY_DEG } from '../../groundCast.js'; -import { MESH_SAMPLE_RADIUS_M } from '../../meshSampler.js'; + createLineCoverage, + sequenceIdFromPick as lineSequenceIdFromPick, +} from '../../lineCoverage.js'; +import { decodeCoverageTile } from './decode.js'; import { coverageZoomForHeight, overviewZoomForHeight, - tileBounds, - tilesForBbox, } from '../../tileMath.js'; import { COLORS, @@ -33,885 +23,48 @@ import { SEQUENCE_VIEW_RANGE_PER_HEIGHT, } from './policy.js'; -/** Draped lines stay at most this long while a tile's cast lines build. */ -export const SWAP_MAX_WAIT_MS = 4000; -/** Tiles touched by new mesh samples are redrawn at most this often. */ -const REMESH_INTERVAL_MS = 1500; -/** Gap between redrawing one dirty tile and the next. */ -const REMESH_STAGGER_MS = 120; -/** Old-zoom tiles are kept at most this long after a zoom change. */ -const STALE_TILE_MAX_MS = 6000; -/** Sequences per primitive: smaller batches build, and show, sooner. */ -const SEQUENCE_PRIMITIVE_BATCH = 120; +export { SWAP_MAX_WAIT_MS, meshBoxInRange } from '../../lineCoverage.js'; -const PER_TILE_SEQUENCE_CAP = Math.floor( - COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES, -); - -/** Colour for a sequence: Mapillary green, GEV cyan while selected. */ -function sequenceColor({ selected = false } = {}) { - return selected - ? Cesium.Color.fromCssColorString(COLORS.selected) - : Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92); -} - -/** Separates a multi-part sequence's part index from its id in pick ids. */ -const PART_SEPARATOR = '~'; - -/** Pick ids for every part of a sequence; the first part is unsuffixed. */ -function partIds(sequence) { - return sequence.parts.map((_, index) => - index - ? `${PICK_PREFIX.sequence}${sequence.id}${PART_SEPARATOR}${index}` - : `${PICK_PREFIX.sequence}${sequence.id}`, - ); +/** Overview points (z0–5) from orbit, sequence lines (z11–14) below 60 km. */ +export function mapillaryZoomFor(heightM) { + const sequence = coverageZoomForHeight(heightM); + if (sequence != null) return { zoom: sequence, kind: 'sequence' }; + const overview = overviewZoomForHeight(heightM); + return overview != null ? { zoom: overview, kind: 'overview' } : null; } /** Sequence id for any part's pick id (`mly:seq:[~]`), else null. */ export function sequenceIdFromPick(pickId) { - if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX.sequence)) - return null; - const rest = pickId.slice(PICK_PREFIX.sequence.length); - const cut = rest.indexOf(PART_SEPARATOR); - return cut === -1 ? rest : rest.slice(0, cut); + return lineSequenceIdFromPick(pickId, PICK_PREFIX.sequence); } -/** - * Whether any point of `box` ({west, south, east, north}) is within the mesh - * sampler's range of `centre`, the ground point under the camera. - */ -export function meshBoxInRange(box, centre) { - // Move the camera's longitude to the box's side of the date line first: - // clamping 179.999 into a box at -179.99 would pick its far edge. - const lon = - centre.lon + - 360 * Math.round(((box.west + box.east) / 2 - centre.lon) / 360); - const nearest = { - lon: Math.min(box.east, Math.max(box.west, lon)), - lat: Math.min(box.north, Math.max(box.south, centre.lat)), - }; - return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M; -} - -/** - * Camera-driven coverage: overview points (z0–5) from orbit, sequence lines - * (z11–14) near the ground. In terrain surface mode a tile is drawn draped - * first, then swapped for lines cast to the bare earth (draped lines would - * land on roofs), so coverage never waits on terrain heights. - */ +/** Mapillary's vector-tile coverage through the server proxy. */ export function createCoverage({ state, source }) { - const { render } = state.services; - - function requestRender() { - render?.governorRequestRender?.('mapillary-coverage'); - } - - function ensureTerrainReady() { - return (state.coverage.terrainReady ||= - Cesium.GroundPolylinePrimitive.initializeTerrainHeights()); - } - - function tileKey({ x, y, z }) { - return `${z}/${x}/${y}`; - } - - /** The imagery filter, resolved now: "since N days" moves with the clock. */ - function filter() { - return state.context.getFilter(); - } - - function terrainMode() { - return ( - state.context.getSurface?.() === 'terrain' && - Boolean(state.context.groundCaster) - ); - } - - /** - * A tile's sequences, filtered then capped: capping first could leave a - * dense tile of newer flat captures with no 360° lines at all. - */ - function drawnSequences(entry) { - const drawn = []; - const current = filter(); - for (const sequence of entry.sequenceList) { - if (drawn.length >= PER_TILE_SEQUENCE_CAP) break; - if (passesImageryFilter(sequence, current)) drawn.push(sequence); - } - return drawn; - } - - /** - * Batched primitives for a tile's sequence parts: cast polylines where - * terrain heights are cached, draped ground lines otherwise. Each batch - * records the selection its colours were built with. - */ - function buildSequencePrimitives(sequences) { - const ground = terrainMode() ? state.context.groundCaster : null; - const meshAt = ground ? state.context.meshSampler?.meshAt : undefined; - const selectedId = state.sequence.selectedId ?? null; - const draped = []; - const cast = []; - for (const sequence of sequences) { - const color = Cesium.ColorGeometryInstanceAttribute.fromColor( - sequenceColor({ selected: sequence.id === selectedId }), - ); - const ids = partIds(sequence); - sequence.parts.forEach((coordinates, index) => { - const flat = ground?.castLine(coordinates, { meshAt }); - let positions; - try { - positions = flat - ? Cesium.Cartesian3.fromDegreesArrayHeights(flat) - : Cesium.Cartesian3.fromDegreesArray(coordinates.flat()); - } catch { - return; - } - if (positions.length < 2) return; - const geometry = flat - ? new Cesium.PolylineGeometry({ - positions, - width: COVERAGE_LINE_WIDTH_PX, - vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT, - arcType: Cesium.ArcType.NONE, - }) - : new Cesium.GroundPolylineGeometry({ - positions, - width: COVERAGE_LINE_WIDTH_PX, - }); - (flat ? cast : draped).push( - new Cesium.GeometryInstance({ - geometry, - id: ids[index], - attributes: { color }, - }), - ); - }); - } - const primitives = []; - for (let i = 0; i < draped.length; i += SEQUENCE_PRIMITIVE_BATCH) - primitives.push({ - onGround: true, - selectedId, - primitive: new Cesium.GroundPolylinePrimitive({ - geometryInstances: draped.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), - appearance: new Cesium.PolylineColorAppearance(), - classificationType: Cesium.ClassificationType.BOTH, - asynchronous: true, - allowPicking: true, - }), - }); - for (let i = 0; i < cast.length; i += SEQUENCE_PRIMITIVE_BATCH) - primitives.push({ - onGround: false, - selectedId, - primitive: new Cesium.Primitive({ - geometryInstances: cast.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), - appearance: new Cesium.PolylineColorAppearance({ translucent: true }), - asynchronous: true, - allowPicking: true, - }), - }); - return { primitives, draped, cast: cast.length }; - } - - /** - * Fetch the terrain heights a tile's lines need, then redraw it cast. - * `castRequested` stops the redraw from casting again at once; a refresh - * `retry`s a tile whose lines are still draped (a failed or partial cast), - * and it is redrawn only when more of its lines can be cast. - */ - function castTile(entry, { retry = false } = {}) { - if ((entry.castRequested && !retry) || entry.castAbort || !terrainMode()) - return; - entry.castRequested = true; - entry.castAbort = new AbortController(); - const { signal } = entry.castAbort; - const lines = [...entry.sequences.values()].flatMap( - (sequence) => sequence.parts, - ); - const caster = state.context.groundCaster; - caster.prepareLines(lines, { signal }).then(() => { - if (entry.castAbort?.signal === signal) entry.castAbort = null; - const attached = [...state.coverage.tiles.values()].includes(entry); - if (signal.aborted || !attached || !terrainMode()) return; - // Nothing new to cast: keep what is drawn until the next refresh. - // Counted like buildSequencePrimitives: a cast line needs two points. - const castable = lines.filter( - (coords) => caster.castLine(coords)?.length >= 6, - ).length; - if (castable <= (entry.castLines || 0)) { - entry.castRequested = (entry.castLines || 0) > 0; - return; - } - // A remesh may have cleared the flag while this was in flight. - entry.castRequested = true; - redrawTile(entry); - requestMesh(entry); - }); - } - - /** Rebuild a tile's lines; the old ones stay up until the new are ready. */ - function redrawTile(entry) { - const previous = entry.primitives; - entry.primitives = []; - attachPrimitive(entry); - removeWhenReady(entry, previous); - } - - /** Lon/lat under the camera, where mesh range is measured from. */ - function meshCentre() { - const carto = state.viewer?.camera?.positionCartographic; - if (!carto) return null; - return { - lon: Cesium.Math.toDegrees(carto.longitude), - lat: Cesium.Math.toDegrees(carto.latitude), - }; - } - - /** - * A tile's drawn lines with bounding boxes, built once per filter; mesh - * points are densified the first time a line is in range. - */ - function meshParts(entry) { - if (entry.meshParts) return entry.meshParts; - entry.meshParts = []; - for (const sequence of entry.sequences.values()) - for (const part of sequence.parts) { - const box = { - west: Infinity, - south: Infinity, - east: -Infinity, - north: -Infinity, - }; - for (const [lon, lat] of part) { - if (lon < box.west) box.west = lon; - if (lon > box.east) box.east = lon; - if (lat < box.south) box.south = lat; - if (lat > box.north) box.north = lat; - } - entry.meshParts.push({ part, box, points: null }); - } - return entry.meshParts; - } - - /** - * Ask for mesh samples under a cast tile's lines within the sampler's range - * of the camera; the rest are asked for when a camera move brings them in. - */ - function requestMesh(entry) { - const sampler = state.context.meshSampler; - if (!sampler || entry.kind !== 'sequence' || !terrainMode()) return; - const centre = meshCentre(); - if (!centre || !entry.bounds || !meshBoxInRange(entry.bounds, centre)) - return; - const points = []; - for (const line of meshParts(entry)) { - if (!meshBoxInRange(line.box, centre)) continue; - line.points ||= densifyLine(line.part, MESH_DENSIFY_DEG); - for (const point of line.points) points.push(point); - } - if (points.length) sampler.request(points); - } - - /** Redraw the tiles new mesh samples fall in, throttled. */ - function onMeshSampled(batch) { - if (!terrainMode() || !state.context.isActive()) return; - for (const entry of state.coverage.tiles.values()) { - if (entry.kind !== 'sequence' || !entry.bounds) continue; - const { west, south, east, north } = entry.bounds; - if ( - batch.some( - ([lon, lat]) => - lon >= west && lon <= east && lat >= south && lat <= north, - ) - ) - state.coverage.remeshDirty.add(entry); - } - if (!state.coverage.remeshDirty.size || state.coverage.remeshTimer) return; - const wait = Math.max( - 0, - REMESH_INTERVAL_MS - (Date.now() - (state.coverage.remeshAt || 0)), - ); - state.coverage.remeshTimer = setTimeout(remesh, wait); - } - - /** Redraw one dirty tile per idle slice, so a burst of samples never stalls a frame. */ - function remesh() { - state.coverage.remeshTimer = null; - state.coverage.remeshAt = Date.now(); - if (!terrainMode() || !state.context.isActive()) { - state.coverage.remeshDirty.clear(); - return; - } - const attached = new Set(state.coverage.tiles.values()); - const [entry] = state.coverage.remeshDirty; - if (!entry) return; - state.coverage.remeshDirty.delete(entry); - if (attached.has(entry)) { - redrawTile(entry); - requestRender(); - } - if (state.coverage.remeshDirty.size) - state.coverage.remeshTimer = setTimeout( - () => whenIdle(remesh, 500), - REMESH_STAGGER_MS, - ); - } - - state.coverage.remeshDirty = new Set(); - state.context.meshSampler?.onSampled(onMeshSampled); - - /** - * Remove a tile's previous primitives once all its current ones are ready - * (or after a few seconds); detaching the tile removes them at once. - */ - function removeWhenReady(entry, old) { - const scene = state.viewer?.scene; - finishSwap(entry); - if (!scene?.postRender) { - removePrimitives(old); - return; - } - const fresh = entry.primitives; - const started = Date.now(); - const stop = scene.postRender.addEventListener(() => { - const ready = fresh.every( - ({ primitive }) => primitive.ready || primitive.isDestroyed?.(), - ); - if (!ready && Date.now() - started < SWAP_MAX_WAIT_MS) { - requestRender(); - return; - } - finishSwap(entry); - requestRender(); - }); - entry.swap = { old, stop }; - requestRender(); - } - - function finishSwap(entry) { - if (!entry.swap) return; - entry.swap.stop(); - removePrimitives(entry.swap.old); - entry.swap = null; - } - - function buildOverviewCollection(points) { - const collection = new Cesium.PointPrimitiveCollection({ - blendOption: Cesium.BlendOption.TRANSLUCENT, - }); - const green = Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha( - 0.85, - ); - const drawn = []; - const current = filter(); - for (const point of points) { - if (!passesImageryFilter(point, current)) continue; - drawn.push( - collection.add({ - position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat), - color: green, - pixelSize: COVERAGE_OVERVIEW_POINT_PX, - // Google 3D terrain and clouds must not hide the near side's dots; - // the horizon cull hides the far side's, which this lets through. - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }), - ); - } - return { collection, points: drawn }; - } - - /** Every overview point on the globe, current zoom and old alike. */ - function* overviewPoints() { - for (const entry of drawnEntries()) - if (entry.overviewPoints) yield* entry.overviewPoints; - } - - // Overview points skip the depth test: hide the far hemisphere's. - const horizon = createHorizonCull({ - getViewer: () => state.viewer, - items: overviewPoints, - onChange: requestRender, + return createLineCoverage({ + state, + fetchTile: async (tile, { signal }) => + decodeCoverageTile( + await source.getTile('coverage', tile.z, tile.x, tile.y, { signal }), + tile, + ), + zoomFor: mapillaryZoomFor, + colors: COLORS, + pickPrefix: PICK_PREFIX.sequence, + renderKey: 'mapillary-coverage', + tuning: { + maxTiles: COVERAGE_MAX_TILES, + overviewMaxTiles: COVERAGE_OVERVIEW_MAX_TILES, + maxSequences: COVERAGE_MAX_SEQUENCES, + lineWidthPx: COVERAGE_LINE_WIDTH_PX, + overviewPointPx: COVERAGE_OVERVIEW_POINT_PX, + moveDebounceMs: COVERAGE_MOVE_DEBOUNCE_MS, + viewRangeMinM: SEQUENCE_VIEW_RANGE_MIN_M, + viewRangePerHeight: SEQUENCE_VIEW_RANGE_PER_HEIGHT, + viewNearM: SEQUENCE_VIEW_NEAR_M, + }, + messages: { + keyRejected: KEY_REJECTED_MESSAGE, + rateLimited: RATE_LIMITED_MESSAGE, + }, }); - - function attachPrimitive(entry) { - const scene = state.viewer?.scene; - if (!scene) return; - if (entry.kind === 'sequence') { - // Lookup, picking and counts follow what is drawn, not the whole tile. - const sequences = drawnSequences(entry); - entry.sequences = new Map(sequences.map((s) => [s.id, s])); - const { primitives, draped, cast } = buildSequencePrimitives(sequences); - entry.primitives = primitives; - entry.count = sequences.length; - for (const { primitive, onGround } of primitives) - (onGround ? scene.groundPrimitives : scene.primitives).add(primitive); - // Fewer lines cast than last time: the caster has dropped heights this - // tile used (a full cache), so it must be cast again. - if (cast < (entry.castLines || 0)) entry.castRequested = false; - entry.castLines = cast; - entry.drapedLines = draped.length; - if (draped.length) castTile(entry); - watchSelection(); - } else { - const { collection, points } = buildOverviewCollection(entry.points); - entry.primitive = collection; - entry.overviewPoints = points; - entry.count = points.length; - scene.primitives.add(collection); - // Cull the new points now, then all of them whenever the camera moves. - horizon.update(points); - } - } - - function removePrimitives(list) { - const scene = state.viewer?.scene; - for (const { primitive, onGround } of list || []) { - try { - (onGround ? scene?.groundPrimitives : scene?.primitives)?.remove( - primitive, - ); - } catch { - /* already gone */ - } - } - } - - function detachPrimitive(entry) { - const scene = state.viewer?.scene; - finishSwap(entry); - removePrimitives(entry.primitives); - entry.primitives = []; - if (entry.primitive) { - try { - scene?.primitives?.remove(entry.primitive); - } catch { - /* already gone */ - } - entry.primitive = null; - entry.overviewPoints = null; - } - } - - /** Stop a tile's pending terrain lookup (tile dropped or retired). */ - function cancelCast(entry) { - entry.castAbort?.abort(); - entry.castAbort = null; - } - - function removeTile(key) { - const entry = state.coverage.tiles.get(key); - if (!entry) return; - cancelCast(entry); - detachPrimitive(entry); - state.coverage.tiles.delete(key); - } - - async function loadTile(tile, kind) { - const key = tileKey(tile); - if (state.coverage.tiles.has(key) || state.coverage.pending.has(key)) - return; - const controller = new AbortController(); - state.coverage.pending.set(key, controller); - notify(); - try { - // Fetch and the one-time terrain-height table load run side by side, - // both handled from the start so an early tile failure is not unhandled. - const [bytes] = await Promise.all([ - source.getTile('coverage', tile.z, tile.x, tile.y, { - signal: controller.signal, - }), - kind === 'sequence' ? ensureTerrainReady() : null, - ]); - // Only an abort, a retire, a clear or a newer request for this tile - // discards the bytes; a refresh that still wants the tile keeps them. - const current = () => - state.coverage.pending.get(key) === controller && - !state.coverage.tiles.has(key); - if ( - controller.signal.aborted || - !current() || - kind !== state.coverage.kind || - tile.z !== state.coverage.zoom - ) - return; - const decoded = decodeCoverageTile(bytes, tile); - const entry = { kind, primitive: null, primitives: [], count: 0 }; - // Padded: vector tiles carry a small buffer past their edge. - const bounds = tileBounds(tile.x, tile.y, tile.z); - const pad = (bounds.east - bounds.west) * 0.05; - entry.bounds = { - west: bounds.west - pad, - east: bounds.east + pad, - south: bounds.south - pad, - north: bounds.north + pad, - }; - if (kind === 'sequence') { - // Newest first; the per-tile cap applies to what passes the filter. - entry.sequenceList = decoded.sequences.sort( - (a, b) => (b.capturedAt || 0) - (a.capturedAt || 0), - ); - entry.sequences = new Map(); - entry.total = decoded.sequences.length; - } else { - entry.points = decoded.overview; - entry.sequences = new Map(); - entry.total = decoded.overview.length; - } - attachPrimitive(entry); - state.coverage.tiles.set(key, entry); - state.coverage.lastError = null; - requestRender(); - } catch (error) { - if ( - controller.signal.aborted || - state.coverage.pending.get(key) !== controller - ) - return; - state.coverage.lastError = error?.message || 'Coverage tile failed'; - if (error?.keyRequired) state.keyRequired = true; - if (error?.keyRejected) { - // Every other tile would be refused too: stop asking. - state.keyRejected = true; - state.coverage.lastError = KEY_REJECTED_MESSAGE; - } - if (error?.retryAfterSec) holdFor(error.retryAfterSec); - } finally { - // Only the request that still owns the key settles it: a superseded one - // must not drop a newer request's entry (which is what counts as loading). - if (state.coverage.pending.get(key) === controller) { - state.coverage.pending.delete(key); - if (!state.coverage.pending.size) purgeStale(); - } - notify(); - } - } - - /** - * Mapillary is rate-limiting: keep what is drawn, request nothing until the - * wait is over, then refresh once. - */ - function holdFor(seconds) { - clearTimeout(state.coverage.holdTimer); - state.coverage.holdUntil = Date.now() + seconds * 1000; - state.coverage.lastError = RATE_LIMITED_MESSAGE; - state.coverage.holdTimer = setTimeout(() => { - state.coverage.holdTimer = null; - state.coverage.holdUntil = 0; - if (state.coverage.lastError === RATE_LIMITED_MESSAGE) - state.coverage.lastError = null; - refresh(); - }, seconds * 1000); - } - - /** Forget refusals when the provider goes off, so the next run asks again. */ - function resetErrors() { - clearTimeout(state.coverage.holdTimer); - state.coverage.holdTimer = null; - state.coverage.holdUntil = 0; - state.coverage.lastError = null; - state.keyRejected = false; - } - - /** Drop the previous zoom's tiles once the new ones are on screen. */ - function purgeStale() { - clearTimeout(state.coverage.staleTimer); - state.coverage.staleTimer = null; - if (!state.coverage.stale.size) return; - for (const entry of state.coverage.stale.values()) detachPrimitive(entry); - state.coverage.stale.clear(); - requestRender(); - } - - /** Move tiles to the stale set: the old zoom shows until the new one loads. */ - function retire() { - for (const controller of state.coverage.pending.values()) - controller.abort(); - state.coverage.pending.clear(); - for (const [key, entry] of state.coverage.tiles) { - cancelCast(entry); - const previous = state.coverage.stale.get(key); - if (previous) detachPrimitive(previous); - state.coverage.stale.set(key, entry); - } - state.coverage.tiles.clear(); - clearTimeout(state.coverage.staleTimer); - state.coverage.staleTimer = setTimeout(purgeStale, STALE_TILE_MAX_MS); - } - - function notify() { - state.context.notify(); - } - - /** - * Whether the screen centre meets the ground further ahead than the camera - * is high (a view tilted above 45°), or misses it. - */ - function isTilted({ nadir, ahead }, height) { - if (!ahead) return true; - return metresBetween(nadir, ahead) > Math.max(1, height); - } - - /** Recompute the tile set for the current camera and reconcile primitives. */ - function refresh() { - const viewer = state.viewer; - if (!viewer || !state.context.isActive() || state.keyRequired) return; - if (state.statusKnown === false) return; - if (state.keyRejected || state.coverage.holdUntil > Date.now()) return; - const ground = groundUnderCamera(viewer, { - groundAt: state.context.groundCaster?.groundAt, - }); - const cameraHeight = viewer.camera?.positionCartographic?.height; - const height = Number.isFinite(cameraHeight) - ? cameraHeight - (ground ?? 0) - : null; - const sequenceZoom = coverageZoomForHeight(height); - const overviewZoom = sequenceZoom ? null : overviewZoomForHeight(height); - const zoom = sequenceZoom ?? overviewZoom; - const kind = sequenceZoom ? 'sequence' : 'overview'; - // At street zooms the rays meet the ground where it really is (1,600 m up - // in Denver; Google 3D hides the globe) and stop short of the horizon. - const ranged = - kind === 'sequence' - ? { - groundHeight: ground, - maxRange: Math.max( - SEQUENCE_VIEW_RANGE_MIN_M, - height * SEQUENCE_VIEW_RANGE_PER_HEIGHT, - ), - } - : {}; - // Tilted street views rank tiles along the line of sight, from the ground - // under the camera to the ground at the centre of the screen. - const focus = kind === 'sequence' ? viewFocus(viewer, ranged) : null; - let bbox = visibleBbox(viewer, { - ...ranged, - // A tilted view's screen rows jump from the first metres to the horizon: - // keep the ground around the camera too. Looking down needs no help. - nearRange: focus && isTilted(focus, height) ? SEQUENCE_VIEW_NEAR_M : null, - }); - if (kind === 'overview' && (!bbox || zoom <= 1)) - bbox = [-180, -85, 180, 85]; - if (zoom == null || !bbox) { - state.coverage.hint = - 'Point the camera at the globe for street-level coverage'; - clear(); - notify(); - return; - } - state.coverage.hint = ''; - if (zoom !== state.coverage.zoom || kind !== state.coverage.kind) { - retire(); - state.coverage.zoom = zoom; - state.coverage.kind = kind; - } - const { tiles } = tilesForBbox(bbox, zoom, { - limit: - kind === 'sequence' ? COVERAGE_MAX_TILES : COVERAGE_OVERVIEW_MAX_TILES, - focus: focus && { from: focus.nadir, to: focus.ahead }, - }); - const wanted = new Set(tiles.map(tileKey)); - for (const key of [...state.coverage.tiles.keys()]) - if (!wanted.has(key)) removeTile(key); - for (const [key, controller] of [...state.coverage.pending]) - if (!wanted.has(key)) { - // The aborted request no longer owns its key, so its `finally` will - // not settle it: settle it here. - controller.abort(); - state.coverage.pending.delete(key); - } - for (const tile of tiles) loadTile(tile, kind); - if (!state.coverage.pending.size) purgeStale(); - // The camera moved: cells that were out of the sampler's range may not be, - // and lines left draped by a failed, partial or dropped cast get another try. - if (terrainMode()) - for (const entry of state.coverage.tiles.values()) { - if (entry.castRequested) requestMesh(entry); - if (entry.drapedLines) castTile(entry, { retry: true }); - } - notify(); - } - - function scheduleRefresh() { - clearTimeout(state.coverage.debounceTimer); - state.coverage.debounceTimer = setTimeout( - refresh, - COVERAGE_MOVE_DEBOUNCE_MS, - ); - } - - function attach(viewer) { - detach(); - const remove = viewer.camera.changed.addEventListener(scheduleRefresh); - const removeEnd = viewer.camera.moveEnd.addEventListener(scheduleRefresh); - state.coverage.removeCameraListener = () => { - remove(); - removeEnd(); - }; - refresh(); - } - - function detach() { - clearTimeout(state.coverage.debounceTimer); - state.coverage.debounceTimer = null; - state.coverage.removeCameraListener?.(); - state.coverage.removeCameraListener = null; - } - - function clear() { - for (const controller of state.coverage.pending.values()) - controller.abort(); - state.coverage.pending.clear(); - for (const key of [...state.coverage.tiles.keys()]) removeTile(key); - purgeStale(); - state.coverage.zoom = null; - state.coverage.kind = null; - clearTimeout(state.coverage.remeshTimer); - state.coverage.remeshTimer = null; - state.coverage.remeshDirty.clear(); - stopSelectionWatch(); - horizon.stop(); - requestRender(); - } - - /** Rebuild every loaded tile from its decoded cache (after a filter change). */ - function rebuild() { - purgeStale(); - for (const entry of state.coverage.tiles.values()) { - cancelCast(entry); - entry.castRequested = false; - entry.meshParts = null; - detachPrimitive(entry); - attachPrimitive(entry); - } - requestRender(); - notify(); - } - - /** - * Redraw tiles draped or cast for the new surface mode, then re-pick the - * zoom, since the ground height under the camera may have changed. - */ - function setSurface() { - if (!state.context.isActive()) return; - rebuild(); - refresh(); - } - - /** Recolour every part of a sequence in one ready primitive. */ - function recolorInstances(primitive, sequence, selected) { - const value = sequenceColor({ selected }); - for (const instanceId of partIds(sequence)) { - try { - const attributes = primitive.getGeometryInstanceAttributes(instanceId); - if (attributes) - attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue( - value, - attributes.color, - ); - } catch { - /* instance not in this primitive */ - } - } - } - - /** Every tile on the globe: the current zoom's and the stale zoom's. */ - function drawnEntries() { - return [...state.coverage.tiles.values(), ...state.coverage.stale.values()]; - } - - /** A tile's primitives on the globe: its own, and the ones a swap still shows. */ - function drawnRecords(entry) { - const records = entry.primitives || []; - return entry.swap ? [...records, ...entry.swap.old] : records; - } - - /** Recolour one sequence, every part of it, in place (selection highlight). */ - function recolorSequence(id, selected) { - for (const entry of drawnEntries()) { - const sequence = entry.sequences.get(id); - if (!sequence) continue; - for (const record of drawnRecords(entry)) { - // Still building: `syncSelection` catches it up once it is ready. - if (!record.primitive.ready) continue; - recolorInstances(record.primitive, sequence, selected); - if (selected) record.selectedId = id; - else if (record.selectedId === id) record.selectedId = null; - } - } - requestRender(); - } - - /** - * Bring a ready primitive's highlight up to the current selection, when - * it was built with another one (or none). True when it changed. - */ - function syncSelection(entry, record) { - const current = state.sequence.selectedId ?? null; - if (record.selectedId === current || !record.primitive.ready) return false; - const previous = - record.selectedId && entry.sequences.get(record.selectedId); - if (previous) recolorInstances(record.primitive, previous, false); - const next = current && entry.sequences.get(current); - if (next) recolorInstances(record.primitive, next, true); - record.selectedId = current; - return true; - } - - /** - * A primitive cannot be recoloured until it is ready, so while any is - * building, catch each one up with the selection as it becomes ready. - */ - function watchSelection() { - const scene = state.viewer?.scene; - if (state.coverage.stopSelectionWatch || !scene?.postRender) return; - const stop = scene.postRender.addEventListener(() => { - let building = false; - let changed = false; - for (const entry of drawnEntries()) { - for (const record of entry.primitives || []) { - if (!record.primitive.ready) building = true; - else if (syncSelection(entry, record)) changed = true; - } - // Lines a swap still shows were ready (or never show): catch up only. - for (const record of entry.swap?.old || []) - if (syncSelection(entry, record)) changed = true; - } - if (changed) requestRender(); - if (!building) stopSelectionWatch(); - }); - state.coverage.stopSelectionWatch = stop; - } - - function stopSelectionWatch() { - state.coverage.stopSelectionWatch?.(); - state.coverage.stopSelectionWatch = null; - } - - /** - * Sequences (or overview points) drawn. A sequence that crosses a tile - * edge is in both tiles' lists, so sequences count once by id. - */ - function sequenceCount() { - const ids = new Set(); - let points = 0; - for (const entry of state.coverage.tiles.values()) - if (entry.kind === 'sequence') - for (const id of entry.sequences.keys()) ids.add(id); - else points += entry.count; - return ids.size + points; - } - - return { - attach, - detach, - refresh, - clear, - resetErrors, - rebuild, - setSurface, - recolorSequence, - sequenceCount, - }; } diff --git a/src/layers/streetLevel/providers/mapillary/policy.js b/src/layers/streetLevel/providers/mapillary/policy.js index 6c038566e..c55c0d26e 100644 --- a/src/layers/streetLevel/providers/mapillary/policy.js +++ b/src/layers/streetLevel/providers/mapillary/policy.js @@ -55,8 +55,6 @@ export const COVERAGE_LINE_WIDTH_PX = 2.5; /** Per-sequence image cones after a sequence is selected. */ export const SEQUENCE_IMAGES_LIMIT = 2000; -export const IMAGE_CONE_SIZE_PX = 26; -export const IMAGE_CONE_MIN_SPACING_M = 3; /** Images asked for in a nearest-image search (radius: NEAREST_RADIUS_M). */ export const NEAREST_LIMIT = 8; diff --git a/src/layers/streetLevel/providers/mapillary/sequences.js b/src/layers/streetLevel/providers/mapillary/sequences.js index 4be1f54a2..b10902a2b 100644 --- a/src/layers/streetLevel/providers/mapillary/sequences.js +++ b/src/layers/streetLevel/providers/mapillary/sequences.js @@ -1,20 +1,7 @@ -import * as Cesium from 'cesium'; -import { imageConeGlyph } from '../../glyphs.js'; -import { passesImageryFilter } from '../../filter.js'; -import { refineHeights } from '../../groundCast.js'; -import { createHorizonCull, metresBetween } from '../../view.js'; -import { meshCellKey } from '../../meshSampler.js'; -import { - COLORS, - IMAGE_CONE_MIN_SPACING_M, - IMAGE_CONE_SIZE_PX, - PICK_PREFIX, -} from './policy.js'; +import { createSequenceCones, thinImages } from '../../sequenceCones.js'; +import { COLORS, PICK_PREFIX } from './policy.js'; -const SPRITE_ID = 'street-level:mapillary-cones'; - -/** How many recently viewed sequences keep their image list in memory. */ -const SEQUENCE_CACHE_SIZE = 40; +export { thinImages } from '../../sequenceCones.js'; /** Normalize a graph image record into the shape the cones use. */ export function normalizeSequenceImage(record) { @@ -33,290 +20,24 @@ export function normalizeSequenceImage(record) { }; } -/** Drop images closer than the spacing to the previous kept one. */ -export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) { - const kept = []; - let last = null; - for (const image of images) { - if (!last || metresBetween(last, image) >= spacingM) { - kept.push(image); - last = image; - } - } - return kept; -} - -/** Image cones for one selected sequence. */ +/** Image cones for the selected Mapillary sequence, its line highlighted. */ export function createSequences({ state, source, parts }) { - const { render, sprites } = state.services; - - function requestRender() { - render?.governorRequestRender?.('mapillary-sequence'); - } - - function notify() { - state.context.notify(); - } - - const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() }); - /** Drawn cones, plus the mesh cells under them, so a sample moves only those. */ - let drawn = nothingDrawn(); - - // The cones skip the depth test: hide the ones behind the globe. - const horizon = createHorizonCull({ - getViewer: () => state.viewer, - items: () => drawn.cones.map((cone) => cone.billboard), - onChange: requestRender, - }); - - function ensureCollections(viewer) { - if (state.sequence.collection) return; - state.sequence.collection = new Cesium.BillboardCollection({ - scene: viewer.scene, - }); - viewer.scene.primitives.add(state.sequence.collection); - sprites?.registerSpriteCollection?.(SPRITE_ID, state.sequence.collection); - } - - function clearCones() { - state.sequence.collection?.removeAll(); - state.sequence.images = []; - drawn = nothingDrawn(); - horizon.stop(); - } - - /** Where a cone stands: at `height`, or clamped to the ground when null. */ - function placement(image, height) { - return { - position: Cesium.Cartesian3.fromDegrees( - image.lon, - image.lat, - height ?? 0, - ), - heightReference: - height === null - ? Cesium.HeightReference.CLAMP_TO_GROUND - : Cesium.HeightReference.NONE, - }; - } - - /** - * Terrain-mode cone heights on the street, not on roofs: bare earth refined - * by mesh samples. Null when draped or while terrain heights are fetched. - */ - function coneHeights(images, { request = true } = {}) { - const ground = state.context.groundCaster; - if (state.context.getSurface?.() !== 'terrain' || !ground) return null; - const dems = images.map((image) => ground.groundAt(image.lon, image.lat)); - if (dems.includes(null)) { - ground - .prepare(images.map((image) => [image.lon, image.lat])) - .then((ready) => { - if (ready && state.sequence.images === images) renderCones(images); - }); - return null; - } - const sampler = state.context.meshSampler; - if (request) - sampler?.request(images.map((image) => [image.lon, image.lat])); - return refineHeights( - images.map((image, i) => ({ - dem: dems[i], - mesh: sampler?.meshAt(image.lon, image.lat), - })), - ); - } - - function renderCones(images) { - const collection = state.sequence.collection; - if (!collection) return; - collection.removeAll(); - drawn = { ...nothingDrawn(), images }; - const heights = coneHeights(images); - // One colour per source; 360° images keep the ring shape. - const cone = imageConeGlyph({ size: 32, color: COLORS.coverage }); - const ring = imageConeGlyph({ - size: 32, - color: COLORS.coverage, - pano: true, - }); - // Resolved now, so a "since N days" window keeps up with the clock. - const filter = state.context.getFilter(); - images.forEach((image, index) => { - if (!passesImageryFilter(image, filter)) return; - const height = heights?.[index] ?? null; - const billboard = collection.add({ - id: `${PICK_PREFIX.image}${image.id}`, - ...placement(image, height), - image: image.isPano ? ring : cone, - imageId: image.isPano ? 'mly-cone-pano' : 'mly-cone', - width: IMAGE_CONE_SIZE_PX, - height: IMAGE_CONE_SIZE_PX, - rotation: -Cesium.Math.toRadians(image.compassAngle), - alignedAxis: Cesium.Cartesian3.UNIT_Z, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35), - }); - drawn.cones.push({ index, billboard, height }); - drawn.cells.add(meshCellKey(image.lon, image.lat)); - }); - horizon.update(); - requestRender(); - } - - /** - * Re-place cones whose height new mesh samples changed. A sample can move - * a neighbour too, so every height is recomputed but only moved cones touched. - */ - function onMeshSampled(batch) { - if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length) - return; - if (!batch.some(([lon, lat]) => drawn.cells.has(meshCellKey(lon, lat)))) - return; - const { images, cones } = drawn; - // Already asked for when drawn; a missing terrain cell redraws them all. - const heights = coneHeights(images, { request: false }); - if (!heights) return; - const moved = []; - for (const cone of cones) { - const height = heights[cone.index] ?? null; - if (height === cone.height) continue; - cone.height = height; - const { position, heightReference } = placement( - images[cone.index], - height, - ); - cone.billboard.heightReference = heightReference; - cone.billboard.position = position; - moved.push(cone.billboard); - } - if (!moved.length) return; - horizon.update(moved); - requestRender(); - } - - function remember(sequenceId, images) { - const { cache } = state.sequence; - cache.delete(sequenceId); - cache.set(sequenceId, images); - while (cache.size > SEQUENCE_CACHE_SIZE) - cache.delete(cache.keys().next().value); - } - - /** Select a sequence: highlight its line and load its image cones. */ - async function select(sequenceId) { - if (!sequenceId || !state.viewer) return; - // Already on screen, or already on its way. - if ( - state.sequence.selectedId === sequenceId && - (state.sequence.images.length || state.sequence.abort) - ) - return; - if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId) - parts.coverage.recolorSequence(state.sequence.selectedId, false); - state.sequence.abort?.abort(); - state.sequence.selectedId = sequenceId; - parts.coverage.recolorSequence(sequenceId, true); - const cached = state.sequence.cache.get(sequenceId); - if (cached) { - state.sequence.abort = null; - state.sequence.loading = false; - state.sequence.images = cached; - renderCones(cached); - state.context.actions.reportError(null); - notify(); - return; - } - // The previous sequence's cones must not stay clickable under the new - // highlight while this one loads, nor after its load fails. - clearCones(); - requestRender(); - const controller = new AbortController(); - state.sequence.abort = controller; - state.sequence.loading = true; - notify(); - try { - const records = await source.getSequenceImages(sequenceId, { - signal: controller.signal, - }); - if (controller.signal.aborted) return; - const images = thinImages( + return createSequenceCones({ + state, + async loadImages(sequenceId, { signal }) { + const records = await source.getSequenceImages(sequenceId, { signal }); + return thinImages( records .map(normalizeSequenceImage) .filter(Boolean) .sort((a, b) => a.capturedAt - b.capturedAt), ); - remember(sequenceId, images); - state.sequence.images = images; - renderCones(images); - state.context.actions.reportError(null); - } catch (error) { - if (!controller.signal.aborted) { - // Nothing to show: drop the highlight, so a click asks again. - parts.coverage.recolorSequence(sequenceId, false); - state.sequence.selectedId = null; - state.context.actions.reportError( - error?.message || 'Sequence images unavailable', - ); - } - } finally { - if (state.sequence.abort === controller) { - state.sequence.loading = false; - state.sequence.abort = null; - } - notify(); - } - } - - function clearSelection() { - state.sequence.abort?.abort(); - state.sequence.abort = null; - if (state.sequence.selectedId) - parts.coverage.recolorSequence(state.sequence.selectedId, false); - state.sequence.selectedId = null; - state.sequence.loading = false; - clearCones(); - // An error about the sequence goes with it. - state.context.actions.reportError(null); - requestRender(); - notify(); - } - - /** Re-draw the current sequence's cones (after an imagery filter change). */ - function rerender() { - if (state.sequence.images.length) renderCones(state.sequence.images); - } - - // New mesh samples may move the cones onto (or off) the road surface. - const stopMeshListener = state.context.meshSampler?.onSampled(onMeshSampled); - - function setVisible(visible) { - if (state.sequence.collection) state.sequence.collection.show = visible; - requestRender(); - } - - function destroy(viewer) { - state.sequence.abort?.abort(); - horizon.stop(); - drawn = nothingDrawn(); - stopMeshListener?.(); - const collection = state.sequence.collection; - if (collection) { - sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection); - viewer?.scene?.primitives?.remove(collection); - state.sequence.collection = null; - } - state.sequence.images = []; - state.sequence.selectedId = null; - } - - return { - ensureCollections, - select, - clearSelection, - rerender, - setVisible, - destroy, - }; + }, + colors: COLORS, + pickPrefix: PICK_PREFIX.image, + spriteId: 'street-level:mapillary-cones', + renderKey: 'mapillary-sequence', + onHighlight: (sequenceId, selected) => + parts.coverage.recolorSequence(sequenceId, selected), + }); } diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index a9ed18331..1e83ee7d4 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -22,6 +22,7 @@ export function filtersOf(provider) { * @property {boolean} [defaultOn] Default true. * @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin. * @property {Array<'pano'|'since'>} [filters] Filters it honours (default both). + * @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos"). * @property {(context: ProviderContext) => ProviderInstance} create * * @typedef {object} ProviderContext Handed to `create()` once by the core. @@ -32,7 +33,7 @@ export function filtersOf(provider) { * @property {GroundCaster|null} groundCaster Bare-earth heights for terrain mode (groundCast.js); null without a terrain service. * @property {{meshAt: (lon: number, lat: number) => number|undefined, request: (points: Array<[number, number]>) => void, onSampled: (listener: (cells: Array<[number, number]>) => void) => () => void}|null} meshSampler Sampled Google 3D surface heights (meshSampler.js); pass `meshAt` to `castLine`. * @property {() => void} notify Ask the core to publish a new UI snapshot. - * @property {{openImage: (imageId: string) => Promise, reportError: (message: string|null) => void}} actions + * @property {{openImage: (imageId: string) => Promise, openNearest: (point: {lon: number, lat: number}) => Promise, reportError: (message: string|null) => void}} actions * * @typedef {object} ProviderInstance * @property {() => Promise<{configured: boolean}>} status @@ -44,7 +45,7 @@ export function filtersOf(provider) { * @property {(filter: {pano: string, sinceMs: number|null}) => void} setFilter The filter changed: redraw (reading `getFilter()`). * @property {(mode: 'draped'|'terrain') => void} [setSurface] Redraw coverage and cones for the new surface mode. * @property {() => {count: number, zoom: number|null, kind: string|null, loading: boolean, hint: string, error: string|null, keyRequired: boolean}} coverageStats - * @property {(pickId: string) => boolean} handlePick The id carries the provider's own prefix. + * @property {(pickId: string, click?: {groundPoint: () => {lon: number, lat: number}|null}) => boolean} handlePick The id carries the provider's own prefix; `groundPoint` is where the click met the ground. * @property {(sequenceId: string) => Promise} [selectSequence] * @property {() => void} [clearSequence] * @property {() => {selectedId: string|null, images: number, loading: boolean}} [sequenceStats] diff --git a/src/layers/streetLevel/selection.js b/src/layers/streetLevel/selection.js index 429608449..e3710dd8e 100644 --- a/src/layers/streetLevel/selection.js +++ b/src/layers/streetLevel/selection.js @@ -28,7 +28,24 @@ export function createSelection({ state, parts }) { } const entry = state.providers.get(route.providerId); if (!entry?.on) return; - route.instance.handlePick(route.id); + route.instance.handlePick(route.id, { + groundPoint: () => groundPointAt(viewer, click.position), + }); + } + + /** Lon/lat of the ground under a screen point, or null (sky, no picker). */ + function groundPointAt(viewer, screenPosition) { + const position = state.services.scenePick?.pickGroundPosition?.( + viewer, + screenPosition, + ); + const carto = position && Cesium.Cartographic.fromCartesian(position); + return carto + ? { + lon: Cesium.Math.toDegrees(carto.longitude), + lat: Cesium.Math.toDegrees(carto.latitude), + } + : null; } /** diff --git a/src/layers/streetLevel/selection.test.mjs b/src/layers/streetLevel/selection.test.mjs index f35cd4361..72e9e6e0c 100644 --- a/src/layers/streetLevel/selection.test.mjs +++ b/src/layers/streetLevel/selection.test.mjs @@ -8,7 +8,12 @@ import { createSelection } from './selection.js'; * `frame()` runs them; timers and Date.now() are mocked so a burst of moves * has a known timeline. */ -function harness({ selected = false } = {}) { +function harness({ + selected = false, + router = null, + providers = new Map(), + services = {}, +} = {}) { const saved = { document: globalThis.document, requestAnimationFrame: globalThis.requestAnimationFrame, @@ -60,12 +65,16 @@ function harness({ selected = false } = {}) { viewer, enabled: true, clickHandler: null, - services: { picking: null, input: null }, + providers, + services: { picking: null, input: null, ...services }, }; const selection = createSelection({ state, parts: { - router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null }, + router: router || { + ownsPick: (id) => id === 'mly:seq:1', + resolve: () => null, + }, hasSelectedSequence: () => sequences.selected, openAtGround: (position, picked) => groundClicks.push({ position, picked }), @@ -282,3 +291,43 @@ test('a click on nothing of ours goes to the ground handler with the pick, which h.restore(); } }); + +test('a click on a provider line hands it the ground under the click, measured only if asked', () => { + const handled = []; + const instance = { + handlePick: (id, click) => handled.push({ id, click }), + }; + const groundPicks = []; + const h = harness({ + router: { + ownsPick: () => true, + resolve: (id) => ({ providerId: 'kartaview', instance, id }), + }, + providers: new Map([['kartaview', { on: true }]]), + services: { + scenePick: { + pickGroundPosition: (viewer, position) => { + groundPicks.push(position); + return Cesium.Cartesian3.fromDegrees(-83.0458, 42.3314, 190); + }, + }, + }, + }); + try { + h.scene.under = 'kv:line:14/1/2/3'; + h.click(40); + assert.equal(handled.length, 1); + assert.equal(handled[0].id, 'kv:line:14/1/2/3'); + assert.equal( + groundPicks.length, + 0, + 'no depth read until the provider asks', + ); + const point = handled[0].click.groundPoint(); + assert.ok(Math.abs(point.lon + 83.0458) < 1e-9); + assert.ok(Math.abs(point.lat - 42.3314) < 1e-9); + assert.deepEqual(groundPicks, [{ x: 40, y: 10 }]); + } finally { + h.restore(); + } +}); diff --git a/src/layers/streetLevel/sequenceCones.js b/src/layers/streetLevel/sequenceCones.js new file mode 100644 index 000000000..a2aeca1ce --- /dev/null +++ b/src/layers/streetLevel/sequenceCones.js @@ -0,0 +1,331 @@ +import * as Cesium from 'cesium'; +import { imageConeGlyph } from './glyphs.js'; +import { passesImageryFilter } from './filter.js'; +import { refineHeights } from './groundCast.js'; +import { createHorizonCull, metresBetween } from './view.js'; +import { meshCellKey } from './meshSampler.js'; + +/** How many recently viewed sequences keep their image list in memory. */ +const SEQUENCE_CACHE_SIZE = 40; + +/** Cones closer than this to the previous kept one are dropped. */ +export const IMAGE_CONE_MIN_SPACING_M = 3; +export const IMAGE_CONE_SIZE_PX = 26; + +/** + * @typedef {object} ConeImage One image of a sequence, as the cones draw it. + * @property {string} id + * @property {number} lon + * @property {number} lat + * @property {number} compassAngle Degrees clockwise from north. + * @property {number} capturedAt Epoch ms (0 when unknown). + * @property {boolean} isPano + */ + +/** Drop images closer than the spacing to the previous kept one. */ +export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) { + const kept = []; + let last = null; + for (const image of images) { + if (!last || metresBetween(last, image) >= spacingM) { + kept.push(image); + last = image; + } + } + return kept; +} + +/** + * Image cones for one selected sequence, any provider's. A cone's pick id is + * `pickPrefix` + the image id; 360° images draw as rings. + * + * @param {object} options + * @param {object} options.state Provider state: `context`, `services`, `viewer`, `sequence`. + * @param {(sequenceId: string, init: {signal: AbortSignal}) => Promise>} options.loadImages + * The sequence's images in capture order, thinned (see `thinImages`). + * @param {{coverage: string}} options.colors + * @param {string} options.pickPrefix Pick-id prefix of the cones. + * @param {string} options.spriteId Sprite-order id of the cone collection. + * @param {string} options.renderKey Render-governor reason. + * @param {(sequenceId: string, selected: boolean) => void} [options.onHighlight] + * Highlight (or stop highlighting) the sequence's coverage line. + */ +export function createSequenceCones({ + state, + loadImages, + colors, + pickPrefix, + spriteId, + renderKey, + onHighlight = () => {}, +}) { + const { render, sprites } = state.services; + + function requestRender() { + render?.governorRequestRender?.(renderKey); + } + + function notify() { + state.context.notify(); + } + + const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() }); + /** Drawn cones, plus the mesh cells under them, so a sample moves only those. */ + let drawn = nothingDrawn(); + + // The cones skip the depth test: hide the ones behind the globe. + const horizon = createHorizonCull({ + getViewer: () => state.viewer, + items: () => drawn.cones.map((cone) => cone.billboard), + onChange: requestRender, + }); + + function ensureCollections(viewer) { + if (state.sequence.collection) return; + state.sequence.collection = new Cesium.BillboardCollection({ + scene: viewer.scene, + }); + viewer.scene.primitives.add(state.sequence.collection); + sprites?.registerSpriteCollection?.(spriteId, state.sequence.collection); + } + + function clearCones() { + state.sequence.collection?.removeAll(); + state.sequence.images = []; + drawn = nothingDrawn(); + horizon.stop(); + } + + /** Where a cone stands: at `height`, or clamped to the ground when null. */ + function placement(image, height) { + return { + position: Cesium.Cartesian3.fromDegrees( + image.lon, + image.lat, + height ?? 0, + ), + heightReference: + height === null + ? Cesium.HeightReference.CLAMP_TO_GROUND + : Cesium.HeightReference.NONE, + }; + } + + /** + * Terrain-mode cone heights on the street, not on roofs: bare earth refined + * by mesh samples. Null when draped or while terrain heights are fetched. + */ + function coneHeights(images, { request = true } = {}) { + const ground = state.context.groundCaster; + if (state.context.getSurface?.() !== 'terrain' || !ground) return null; + const dems = images.map((image) => ground.groundAt(image.lon, image.lat)); + if (dems.includes(null)) { + ground + .prepare(images.map((image) => [image.lon, image.lat])) + .then((ready) => { + if (ready && state.sequence.images === images) renderCones(images); + }); + return null; + } + const sampler = state.context.meshSampler; + if (request) + sampler?.request(images.map((image) => [image.lon, image.lat])); + return refineHeights( + images.map((image, i) => ({ + dem: dems[i], + mesh: sampler?.meshAt(image.lon, image.lat), + })), + ); + } + + function renderCones(images) { + const collection = state.sequence.collection; + if (!collection) return; + collection.removeAll(); + drawn = { ...nothingDrawn(), images }; + const heights = coneHeights(images); + // One colour per source; 360° images keep the ring shape. + const cone = imageConeGlyph({ size: 32, color: colors.coverage }); + const ring = imageConeGlyph({ + size: 32, + color: colors.coverage, + pano: true, + }); + // Resolved now, so a "since N days" window keeps up with the clock. + const filter = state.context.getFilter(); + images.forEach((image, index) => { + if (!passesImageryFilter(image, filter)) return; + const height = heights?.[index] ?? null; + const billboard = collection.add({ + id: `${pickPrefix}${image.id}`, + ...placement(image, height), + image: image.isPano ? ring : cone, + imageId: image.isPano ? `${spriteId}:pano` : `${spriteId}:flat`, + width: IMAGE_CONE_SIZE_PX, + height: IMAGE_CONE_SIZE_PX, + rotation: -Cesium.Math.toRadians(image.compassAngle), + alignedAxis: Cesium.Cartesian3.UNIT_Z, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35), + }); + drawn.cones.push({ index, billboard, height }); + drawn.cells.add(meshCellKey(image.lon, image.lat)); + }); + horizon.update(); + requestRender(); + } + + /** + * Re-place cones whose height new mesh samples changed. A sample can move + * a neighbour too, so every height is recomputed but only moved cones touched. + */ + function onMeshSampled(batch) { + if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length) + return; + if (!batch.some(([lon, lat]) => drawn.cells.has(meshCellKey(lon, lat)))) + return; + const { images, cones } = drawn; + // Already asked for when drawn; a missing terrain cell redraws them all. + const heights = coneHeights(images, { request: false }); + if (!heights) return; + const moved = []; + for (const cone of cones) { + const height = heights[cone.index] ?? null; + if (height === cone.height) continue; + cone.height = height; + const { position, heightReference } = placement( + images[cone.index], + height, + ); + cone.billboard.heightReference = heightReference; + cone.billboard.position = position; + moved.push(cone.billboard); + } + if (!moved.length) return; + horizon.update(moved); + requestRender(); + } + + function remember(sequenceId, images) { + const { cache } = state.sequence; + cache.delete(sequenceId); + cache.set(sequenceId, images); + while (cache.size > SEQUENCE_CACHE_SIZE) + cache.delete(cache.keys().next().value); + } + + /** + * Select a sequence: highlight its line and load its image cones. `force` + * reloads a selected one (a provider that loads part of a sequence moved on). + */ + async function select(sequenceId, { force = false } = {}) { + if (!sequenceId || !state.viewer) return; + // Already on screen, or already on its way. + if ( + !force && + state.sequence.selectedId === sequenceId && + (state.sequence.images.length || state.sequence.abort) + ) + return; + if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId) + onHighlight(state.sequence.selectedId, false); + state.sequence.abort?.abort(); + state.sequence.selectedId = sequenceId; + onHighlight(sequenceId, true); + const cached = force ? null : state.sequence.cache.get(sequenceId); + if (cached) { + state.sequence.abort = null; + state.sequence.loading = false; + state.sequence.images = cached; + renderCones(cached); + state.context.actions.reportError(null); + notify(); + return; + } + // The previous sequence's cones must not stay clickable under the new + // highlight while this one loads, nor after its load fails. + clearCones(); + requestRender(); + const controller = new AbortController(); + state.sequence.abort = controller; + state.sequence.loading = true; + notify(); + try { + const images = await loadImages(sequenceId, { + signal: controller.signal, + }); + if (controller.signal.aborted) return; + remember(sequenceId, images); + state.sequence.images = images; + renderCones(images); + state.context.actions.reportError(null); + } catch (error) { + if (!controller.signal.aborted) { + // Nothing to show: drop the highlight, so a click asks again. + onHighlight(sequenceId, false); + state.sequence.selectedId = null; + state.context.actions.reportError( + error?.message || 'Sequence images unavailable', + ); + } + } finally { + if (state.sequence.abort === controller) { + state.sequence.loading = false; + state.sequence.abort = null; + } + notify(); + } + } + + function clearSelection() { + state.sequence.abort?.abort(); + state.sequence.abort = null; + if (state.sequence.selectedId) + onHighlight(state.sequence.selectedId, false); + state.sequence.selectedId = null; + state.sequence.loading = false; + clearCones(); + // An error about the sequence goes with it. + state.context.actions.reportError(null); + requestRender(); + notify(); + } + + /** Re-draw the current sequence's cones (after an imagery filter change). */ + function rerender() { + if (state.sequence.images.length) renderCones(state.sequence.images); + } + + // New mesh samples may move the cones onto (or off) the road surface. + const stopMeshListener = state.context.meshSampler?.onSampled(onMeshSampled); + + function setVisible(visible) { + if (state.sequence.collection) state.sequence.collection.show = visible; + requestRender(); + } + + function destroy(viewer) { + state.sequence.abort?.abort(); + horizon.stop(); + drawn = nothingDrawn(); + stopMeshListener?.(); + const collection = state.sequence.collection; + if (collection) { + sprites?.unregisterSpriteCollection?.(spriteId, collection); + viewer?.scene?.primitives?.remove(collection); + state.sequence.collection = null; + } + state.sequence.images = []; + state.sequence.selectedId = null; + } + + return { + ensureCollections, + select, + clearSelection, + rerender, + setVisible, + destroy, + }; +} diff --git a/src/layers/streetLevel/uiState.js b/src/layers/streetLevel/uiState.js index 4163d473f..77c6bf173 100644 --- a/src/layers/streetLevel/uiState.js +++ b/src/layers/streetLevel/uiState.js @@ -17,6 +17,7 @@ import { COLORS } from './policy.js'; * @property {string} color The source's one colour. * @property {boolean} [groundClick] * @property {Array<'pano'|'since'>} [filters] + * @property {{noun: string, action: string}|null} [coverageLabel] */ /** @@ -34,6 +35,8 @@ export function summarizeCoverage(providers) { ? 'No street-level imagery providers configured' : [...new Set(active.map((p) => p.hint).filter(Boolean))].join(' · '), error: active.find((p) => p.error)?.error || null, + // One source is lit at a time; its wording counts the lines. + label: active.find((p) => p.coverageLabel)?.coverageLabel || null, keyRequired: active.length > 0 && active.every((p) => p.keyRequired === true), keyRejected: diff --git a/src/layers/streetLevel/uiState.test.mjs b/src/layers/streetLevel/uiState.test.mjs index 894075f6f..fe0b19d82 100644 --- a/src/layers/streetLevel/uiState.test.mjs +++ b/src/layers/streetLevel/uiState.test.mjs @@ -154,6 +154,7 @@ test('summarizeCoverage is what getStats reports, without building a snapshot', loading: true, hint: 'Look down', error: 'boom', + label: null, keyRequired: false, keyRejected: false, }, diff --git a/src/layers/streetLevel/viewerHost.js b/src/layers/streetLevel/viewerHost.js index 628633927..3a562c21e 100644 --- a/src/layers/streetLevel/viewerHost.js +++ b/src/layers/streetLevel/viewerHost.js @@ -39,6 +39,10 @@ export function createViewerHost({ state, parts }) { sequenceId: pose.sequenceId ?? null, creator: pose.creator || null, externalUrl: pose.externalUrl || null, + // FIT / FILL, unless this image has no frame to fit (a panorama). + renderModes: + typeof active.adapter.setRenderMode === 'function' && + active.adapter.supportsRenderModes?.() !== false, }); parts.marker.set( state.street.position, diff --git a/src/testSupport/kartaViewFakes.mjs b/src/testSupport/kartaViewFakes.mjs new file mode 100644 index 000000000..4bd601e21 --- /dev/null +++ b/src/testSupport/kartaViewFakes.mjs @@ -0,0 +1,60 @@ +// DOM stand-ins for the KartaView viewer's tests (no browser in Node). + +/** A DOM element with what the viewer touches: children, events, attributes. */ +export function fakeElement(tag) { + const listeners = new Map(); + const element = { + tagName: tag.toUpperCase(), + children: [], + parentNode: null, + style: {}, + dataset: {}, + attributes: {}, + hidden: false, + disabled: false, + clientWidth: 400, + clientHeight: 250, + classList: { + values: new Set(), + add: (name) => element.classList.values.add(name), + remove: (name) => element.classList.values.delete(name), + }, + append(...nodes) { + for (const node of nodes) { + node.parentNode = element; + element.children.push(node); + } + }, + remove() { + const list = element.parentNode?.children; + if (list) list.splice(list.indexOf(element), 1); + element.parentNode = null; + }, + setAttribute: (name, value) => (element.attributes[name] = value), + removeAttribute: (name) => { + delete element.attributes[name]; + if (name === 'src') element.src = ''; + }, + addEventListener: (type, handler) => listeners.set(type, handler), + removeEventListener: (type) => listeners.delete(type), + setPointerCapture() {}, + /** Fire an event at the element's listener. */ + fire(type, event = {}) { + return listeners.get(type)?.({ + preventDefault() { + this.defaultPrevented = true; + }, + stopPropagation() {}, + ...event, + }); + }, + }; + return element; +} + +export function fakeHost() { + const doc = { createElement: (tag) => fakeElement(tag) }; + const host = fakeElement('div'); + host.ownerDocument = doc; + return host; +} diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js index ae5fe5ae3..16e32eec6 100644 --- a/src/tools/globePanel.js +++ b/src/tools/globePanel.js @@ -15,7 +15,8 @@ export const PANEL_WORKER_PRELUDE_PATH = 'cesium/worker-prelude.js'; /** * Sites the app's browser code loads from directly: map imagery, 3D tiles, * terrain, fonts, and Street Level's Mapillary Graph API and image CDN (its - * coverage tiles come through the app's server). Everything from the app's + * coverage tiles come through the app's server), Google Street View and + * KartaView. Everything from the app's * own server comes through the MCP server. */ const PROVIDER_ORIGINS = Object.freeze([ @@ -44,6 +45,10 @@ const PROVIDER_ORIGINS = Object.freeze([ 'https://*.ggpht.com', // Mapillary photos come from regional hosts, e.g. scontent-man2-1.xx.fbcdn.net. 'https://*.xx.fbcdn.net', + // KartaView's API, its image proxy and its photo storage (storage1…storage13). + 'https://api.kartaview.org', + 'https://cdn.kartaview.org', + 'https://*.openstreetcam.org', ]); export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app'; diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index 6955e63b2..9ecfd077e 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -193,7 +193,7 @@ function presentMeta(state) { const hint = state.coverage.hint || ''; if (state.coverage.count > 0) return [ - `${state.coverage.count.toLocaleString()} sequences in view · click a line for its photos`, + `${state.coverage.count.toLocaleString()} ${state.coverage.label?.noun || 'sequences'} in view · ${state.coverage.label?.action || 'click a line for its photos'}`, hint, ] .filter(Boolean) diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index 7f5b83cdc..302129b85 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -613,6 +613,86 @@ button.sl-status:focus-visible { font-size: 1.3rem; } +/* ── KartaView photo viewer ──────────────────────────────────────────────── */ +.sl-kv-viewer { + position: absolute; + inset: 0; + background: #000; + outline: none; + overflow: hidden; +} + +.sl-kv-viewer:focus-visible { + box-shadow: inset 0 0 0 2px var(--sl-kartaview, #635bff); +} + +.sl-kv-photo, +.sl-kv-pano { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + user-select: none; +} + +.sl-kv-photo { + object-fit: contain; +} + +.sl-kv-pano { + cursor: grab; + touch-action: none; +} + +.sl-kv-pano.sl-kv-dragging { + cursor: grabbing; +} + +.sl-kv-photo[hidden], +.sl-kv-pano[hidden] { + display: none; +} + +.sl-kv-step { + position: absolute; + top: 50%; + z-index: 2; + width: 34px; + height: 44px; + margin-top: -22px; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 8px; + background: rgba(10, 10, 15, 0.62); + color: #fff; + font-size: 1.5rem; + line-height: 1; + cursor: pointer; +} + +.sl-kv-step-prev { + left: 6px; +} + +.sl-kv-step-next { + right: 6px; +} + +.sl-kv-step:hover:not(:disabled), +.sl-kv-step:focus-visible { + background: rgba(99, 91, 255, 0.85); + outline: none; +} + +.sl-kv-step:disabled { + opacity: 0.3; + cursor: default; +} + +/* Nothing to step through until a photo shows. */ +.sl-kv-viewer:not([data-kind='flat']):not([data-kind='pano']) .sl-kv-step { + display: none; +} + /* ── Short viewports ─────────────────────────────────────────────────────── */ @media (max-height: 640px) { .street-level-panel-inner {