mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
fix(street-level): address gekh's review of #768
P0 - tileMath: lonToTileX(180) wrapped to column 0, so a whole-world view at z1 loaded only the western column; 180 is now the last column. - coverage.loadTile: a superseded request (after a retire or clear) still deleted the newer request's pending entry and loading count in `finally`, letting a duplicate load attach lines that nothing could remove. Only the request that still owns the key attaches its tile or settles the key. P1 - view/tileMath: a view across the date line read as a ~358 deg box and loaded tiles on the far side of the globe. visibleBbox now returns west > east for such views and tilesForBbox splits them at +/-180; viewCentre averages across the line. - decode: the parts of a multi-line sequence were joined into one line, drawing straight segments over capture gaps. Each part is now its own line (`parts`); later parts carry `~n` in their pick id, and picking or recolouring any part acts on the whole sequence. - viewerHost: a prewarmed MapillaryJS viewer was never destroyed, leaking its WebGL context. Prewarmed adapters are tracked and released on unmount, at once when the layer went off mid-load, and per provider when only that provider is switched off. - nearestImage took the first record the API returned; it now takes the closest one that passes the filter. P2 - groundCast: the cache was cleared after a request counted its corners, dropping ones it relied on; it is now cleared first. - streetLevelControls: the viewer was resized on every render (every frame while a panorama is dragged); the ResizeObserver handles size changes and the viewer is resized once when it opens. The legend now rebuilds when its entries change, not only their count. - panelPositionControls: the portable-panel paths this PR added called the CCTV viewport sync directly; they go through onPanelResized, which the shell routes to CCTV. The "Compose camera panel controls" JSDoc is back on _initCctvPanel. New tests: zoom-1 columns, date-line tiles and bbox, multi-part decode and part pick ids, nearest image, prewarmed viewer release, and the cache ordering (fails on the old order). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
514788303d
commit
a1f8d6ca25
17 files changed
+435
-117
No files matched your search
@@ -133,13 +133,14 @@ export function densifyLine(coords, maxStep = GROUND_CAST_DENSIFY_DEG) {
|
||||
|
||||
/**
|
||||
* A bare-earth height cache over the terrain service.
|
||||
* @param {{terrain: {resolveEllipsoidalGround: Function}, step?: number, lift?: number, maxCorners?: number}} options
|
||||
* @param {{terrain: {resolveEllipsoidalGround: Function}, step?: number, lift?: number, maxCorners?: number, cacheMax?: number}} options
|
||||
*/
|
||||
export function createGroundCaster({
|
||||
terrain,
|
||||
step = GROUND_CAST_STEP_DEG,
|
||||
lift = GROUND_CAST_LIFT_M,
|
||||
maxCorners = GROUND_CAST_MAX_CORNERS,
|
||||
cacheMax = GROUND_CAST_CACHE_MAX,
|
||||
}) {
|
||||
if (typeof terrain?.resolveEllipsoidalGround !== 'function')
|
||||
throw new TypeError('Ground casting requires a terrain service');
|
||||
@@ -169,6 +170,9 @@ export function createGroundCaster({
|
||||
|
||||
async function resolve(points, signal) {
|
||||
if (signal?.aborted) return false;
|
||||
// Make room first: clearing after counting would drop corners this
|
||||
// request already treated as cached.
|
||||
if (heights.size + maxCorners > cacheMax) heights.clear();
|
||||
const missing = missingCorners(points);
|
||||
if (!missing.length) return true;
|
||||
if (missing.length > maxCorners) return false;
|
||||
@@ -178,7 +182,6 @@ export function createGroundCaster({
|
||||
lat: Number((j * step).toFixed(6)),
|
||||
})),
|
||||
);
|
||||
if (heights.size + missing.length > GROUND_CAST_CACHE_MAX) heights.clear();
|
||||
let complete = true;
|
||||
missing.forEach(([key], n) => {
|
||||
const result = results?.[n];
|
||||
|
||||
@@ -219,3 +219,22 @@ test('castLine refines heights with sampled mesh and densifies finer', async ()
|
||||
const unsampled = caster.castLine(line, { meshAt: () => undefined });
|
||||
assert.ok(unsampled[2] > 100 && unsampled[2] < 110);
|
||||
});
|
||||
|
||||
test('a full cache is cleared before a request counts its corners, so the request still casts', async () => {
|
||||
const caster = createGroundCaster({
|
||||
terrain: fakeTerrain(),
|
||||
step: 0.001,
|
||||
maxCorners: 8,
|
||||
cacheMax: 5,
|
||||
});
|
||||
// One cell's four corners are cached...
|
||||
assert.equal(await caster.prepare([[10.0005, 50.0005]]), true);
|
||||
// ...then a line over that cell and the next needs two more. Clearing after
|
||||
// counting would have kept only the two new corners and lost the first cell.
|
||||
const line = [
|
||||
[10.0002, 50.0005],
|
||||
[10.0018, 50.0005],
|
||||
];
|
||||
assert.equal(await caster.prepareLines([line]), true);
|
||||
assert.ok(caster.castLine(line), 'the whole line casts');
|
||||
});
|
||||
@@ -221,7 +221,7 @@ export function createStreetLevelLayer({
|
||||
}
|
||||
|
||||
function deactivate(entry) {
|
||||
if (state.street.providerId === entry.def.id) parts.viewerHost.unmount();
|
||||
parts.viewerHost.unmount(entry.def.id);
|
||||
entry.instance.deactivate();
|
||||
parts.credits.hide(state.viewer, entry.def);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,32 @@ function sequenceColor({ selected = false } = {}) {
|
||||
: 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}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequence id for a picked line, whichever part was hit: `mly:seq:<id>` and
|
||||
* `mly:seq:<id>~<part>` both give `<id>`; null for any other pick.
|
||||
* @param {string} pickId
|
||||
* @returns {string|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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera-driven coverage: z0–5 `overview` points from orbit down to 60 km,
|
||||
* then z11–14 sequence polylines clamped to terrain and 3D tiles. Decoded
|
||||
@@ -84,64 +110,55 @@ export function createCoverage({ state, source }) {
|
||||
);
|
||||
}
|
||||
|
||||
function sequenceInstance(sequence, geometry) {
|
||||
return new Cesium.GeometryInstance({
|
||||
geometry,
|
||||
id: `${PICK_PREFIX.sequence}${sequence.id}`,
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
sequenceColor({
|
||||
selected: sequence.id === state.sequence.selectedId,
|
||||
}),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Primitives for a tile's sequences, in draw batches: ground primitives
|
||||
* draped on the globe, plus (in terrain mode) plain polylines at the cast
|
||||
* bare-earth heights for every sequence whose heights are cached.
|
||||
* heights for every part whose heights are cached. Each part of a sequence
|
||||
* with a capture gap is its own line.
|
||||
*/
|
||||
function buildSequencePrimitives(sequences) {
|
||||
const ground = terrainMode() ? state.context.groundCaster : null;
|
||||
const meshAt = ground ? state.context.meshSampler?.meshAt : undefined;
|
||||
const draped = [];
|
||||
const cast = [];
|
||||
let drawn = 0;
|
||||
for (const sequence of sequences) {
|
||||
if (!passesImageryFilter(sequence, filter())) continue;
|
||||
const flat = ground?.castLine(sequence.coordinates, { meshAt });
|
||||
let positions;
|
||||
try {
|
||||
positions = flat
|
||||
? Cesium.Cartesian3.fromDegreesArrayHeights(flat)
|
||||
: Cesium.Cartesian3.fromDegreesArray(sequence.coordinates.flat());
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (positions.length < 2) continue;
|
||||
if (flat)
|
||||
cast.push(
|
||||
sequenceInstance(
|
||||
sequence,
|
||||
new Cesium.PolylineGeometry({
|
||||
drawn++;
|
||||
const color = Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
sequenceColor({ selected: sequence.id === state.sequence.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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
else
|
||||
draped.push(
|
||||
sequenceInstance(
|
||||
sequence,
|
||||
new Cesium.GroundPolylineGeometry({
|
||||
})
|
||||
: 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)
|
||||
@@ -165,7 +182,7 @@ export function createCoverage({ state, source }) {
|
||||
allowPicking: true,
|
||||
}),
|
||||
});
|
||||
return { primitives, count: draped.length + cast.length, draped };
|
||||
return { primitives, count: drawn, draped };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,7 +196,7 @@ export function createCoverage({ state, source }) {
|
||||
const { signal } = entry.castAbort;
|
||||
const lines = entry.sequenceList
|
||||
.filter((sequence) => passesImageryFilter(sequence, filter()))
|
||||
.map((sequence) => sequence.coordinates);
|
||||
.flatMap((sequence) => sequence.parts);
|
||||
const caster = state.context.groundCaster;
|
||||
caster.prepareLines(lines, { signal }).then(() => {
|
||||
const attached = [...state.coverage.tiles.values()].includes(entry);
|
||||
@@ -200,9 +217,8 @@ export function createCoverage({ state, source }) {
|
||||
if (entry.meshCells) return entry.meshCells;
|
||||
const cells = new Map();
|
||||
for (const sequence of entry.sequenceList)
|
||||
for (const [lon, lat] of densifyLine(
|
||||
sequence.coordinates,
|
||||
MESH_DENSIFY_DEG,
|
||||
for (const [lon, lat] of sequence.parts.flatMap((part) =>
|
||||
densifyLine(part, MESH_DENSIFY_DEG),
|
||||
))
|
||||
cells.set(
|
||||
`${Math.round(lon / MESH_CELL_DEG)},${Math.round(lat / MESH_CELL_DEG)}`,
|
||||
@@ -414,8 +430,13 @@ export function createCoverage({ state, source }) {
|
||||
const bytes = await fetching;
|
||||
// A refresh that still wants this tile must not throw the bytes away;
|
||||
// only an abort (tile no longer wanted, or zoom changed) does.
|
||||
// A retire, clear or newer request for this tile owns it now.
|
||||
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
|
||||
)
|
||||
@@ -449,13 +470,21 @@ export function createCoverage({ state, source }) {
|
||||
state.coverage.lastError = null;
|
||||
requestRender();
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
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;
|
||||
} finally {
|
||||
state.coverage.pending.delete(key);
|
||||
state.coverage.loading = Math.max(0, state.coverage.loading - 1);
|
||||
if (!state.coverage.pending.size) purgeStale();
|
||||
// Only the request that still owns the key settles it: a superseded one
|
||||
// must not drop a newer request's entry or its loading count.
|
||||
if (state.coverage.pending.get(key) === controller) {
|
||||
state.coverage.pending.delete(key);
|
||||
state.coverage.loading = Math.max(0, state.coverage.loading - 1);
|
||||
if (!state.coverage.pending.size) purgeStale();
|
||||
}
|
||||
notify();
|
||||
}
|
||||
}
|
||||
@@ -615,26 +644,27 @@ export function createCoverage({ state, source }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Recolour one sequence in place (selection highlight). */
|
||||
/** Recolour one sequence, every part of it, in place (selection highlight). */
|
||||
function recolorSequence(id, selected) {
|
||||
const instanceId = `${PICK_PREFIX.sequence}${id}`;
|
||||
const value = sequenceColor({ selected });
|
||||
for (const entry of state.coverage.tiles.values()) {
|
||||
const sequence = entry.sequences.get(id);
|
||||
if (!sequence) continue;
|
||||
for (const { primitive } of entry.primitives || []) {
|
||||
if (!primitive.ready) continue;
|
||||
try {
|
||||
const attributes =
|
||||
primitive.getGeometryInstanceAttributes(instanceId);
|
||||
if (attributes)
|
||||
attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
sequenceColor({ selected }),
|
||||
attributes.color,
|
||||
);
|
||||
} catch {
|
||||
/* instance not in this primitive */
|
||||
for (const instanceId of partIds(sequence))
|
||||
for (const { primitive } of entry.primitives || []) {
|
||||
if (!primitive.ready) continue;
|
||||
try {
|
||||
const attributes =
|
||||
primitive.getGeometryInstanceAttributes(instanceId);
|
||||
if (attributes)
|
||||
attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
value,
|
||||
attributes.color,
|
||||
);
|
||||
} catch {
|
||||
/* instance not in this primitive */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
requestRender();
|
||||
}
|
||||
|
||||
@@ -21,15 +21,16 @@ export function decodeCoverageTile(bytes, address, { images = false } = {}) {
|
||||
for (let i = 0; i < sequenceLayer.length; i++) {
|
||||
const feature = sequenceLayer.feature(i);
|
||||
const props = feature.properties || {};
|
||||
const coordinates = [];
|
||||
for (const ring of feature.loadGeometry()) {
|
||||
for (const point of ring) {
|
||||
coordinates.push(
|
||||
tileLocalToLonLat(point.x, point.y, sequenceLayer.extent, x, y, z),
|
||||
);
|
||||
}
|
||||
// A sequence with a capture gap is a multi-line: keep each part as its
|
||||
// own line, so no straight segment is drawn across the gap.
|
||||
const parts = [];
|
||||
for (const line of feature.loadGeometry()) {
|
||||
const part = line.map((point) =>
|
||||
tileLocalToLonLat(point.x, point.y, sequenceLayer.extent, x, y, z),
|
||||
);
|
||||
if (part.length >= 2) parts.push(part);
|
||||
}
|
||||
if (coordinates.length < 2) continue;
|
||||
if (!parts.length) continue;
|
||||
result.sequences.push({
|
||||
id: String(props.id ?? feature.id ?? `${x}/${y}/${i}`),
|
||||
imageId: props.image_id != null ? String(props.image_id) : null,
|
||||
@@ -39,7 +40,7 @@ export function decodeCoverageTile(bytes, address, { images = false } = {}) {
|
||||
quality: Number.isFinite(props.quality_score)
|
||||
? props.quality_score
|
||||
: null,
|
||||
coordinates,
|
||||
parts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,20 @@ import { decodeCoverageTile } from './decode.js';
|
||||
|
||||
const zigzag = (value) => (value << 1) ^ (value >> 31);
|
||||
|
||||
/** One line part: MoveTo the first point, LineTo the rest (relative). */
|
||||
function linePart(points) {
|
||||
const out = [9, zigzag(points[0][0]), zigzag(points[0][1])];
|
||||
out.push(((points.length - 1) << 3) | 2);
|
||||
for (let i = 1; i < points.length; i++)
|
||||
out.push(
|
||||
zigzag(points[i][0] - points[i - 1][0]),
|
||||
zigzag(points[i][1] - points[i - 1][1]),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A minimal mly1_public-shaped tile: one sequence line and one overview point. */
|
||||
function sampleTile() {
|
||||
function sampleTile({ geometry = null } = {}) {
|
||||
const writer = new PbfWriter();
|
||||
writer.writeMessage(
|
||||
3,
|
||||
@@ -37,14 +49,17 @@ function sampleTile() {
|
||||
feature.writeVarintField(1, 7);
|
||||
feature.writePackedVarint(2, [0, 0, 1, 1, 2, 2]);
|
||||
feature.writeVarintField(3, 2); // LineString
|
||||
feature.writePackedVarint(4, [
|
||||
9,
|
||||
zigzag(100),
|
||||
zigzag(100), // MoveTo
|
||||
10,
|
||||
zigzag(200),
|
||||
zigzag(200), // LineTo ×1
|
||||
]);
|
||||
feature.writePackedVarint(
|
||||
4,
|
||||
geometry || [
|
||||
9,
|
||||
zigzag(100),
|
||||
zigzag(100), // MoveTo
|
||||
10,
|
||||
zigzag(200),
|
||||
zigzag(200), // LineTo ×1
|
||||
],
|
||||
);
|
||||
},
|
||||
null,
|
||||
);
|
||||
@@ -86,8 +101,9 @@ test('sequences and overview points decode into lon/lat records', () => {
|
||||
assert.equal(sequence.id, 'seq-42');
|
||||
assert.equal(sequence.capturedAt, 1_700_000_000);
|
||||
assert.equal(sequence.isPano, true);
|
||||
assert.equal(sequence.coordinates.length, 2);
|
||||
const [[lon0, lat0], [lon1, lat1]] = sequence.coordinates;
|
||||
assert.equal(sequence.parts.length, 1);
|
||||
assert.equal(sequence.parts[0].length, 2);
|
||||
const [[lon0, lat0], [lon1, lat1]] = sequence.parts[0];
|
||||
assert.ok(
|
||||
lon0 > -180 && lon0 < 0 && lon1 > lon0,
|
||||
'west half of tile 0/0 at z1',
|
||||
@@ -110,3 +126,35 @@ test('an empty tile decodes to empty lists', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a sequence with a capture gap keeps its parts as separate lines', () => {
|
||||
// Two parts with a gap between (300,300) and (1000,1000); the second part's
|
||||
// MoveTo is relative to the end of the first, as in the MVT encoding.
|
||||
const first = [
|
||||
[100, 100],
|
||||
[300, 300],
|
||||
];
|
||||
const second = [
|
||||
[700, 700],
|
||||
[900, 700],
|
||||
];
|
||||
const geometry = [
|
||||
...linePart(first),
|
||||
9,
|
||||
zigzag(second[0][0] - first[1][0]),
|
||||
zigzag(second[0][1] - first[1][1]),
|
||||
((second.length - 1) << 3) | 2,
|
||||
zigzag(second[1][0] - second[0][0]),
|
||||
zigzag(second[1][1] - second[0][1]),
|
||||
];
|
||||
const [sequence] = decodeCoverageTile(sampleTile({ geometry }), {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 1,
|
||||
}).sequences;
|
||||
assert.equal(sequence.parts.length, 2);
|
||||
assert.equal(sequence.parts[0].length, 2);
|
||||
assert.equal(sequence.parts[1].length, 2);
|
||||
// No part ends where the next begins: the gap is not bridged.
|
||||
assert.notDeepEqual(sequence.parts[0].at(-1), sequence.parts[1][0]);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createCoverage } from './coverage.js';
|
||||
import { createCoverage, sequenceIdFromPick } from './coverage.js';
|
||||
import { createSequences } from './sequences.js';
|
||||
import { createMapillaryViewer } from './viewer.js';
|
||||
import { passesImageryFilter } from '../../filter.js';
|
||||
@@ -168,8 +168,9 @@ export function createMapillaryProvider({ source }) {
|
||||
},
|
||||
|
||||
handlePick(pickId) {
|
||||
if (pickId.startsWith(PICK_PREFIX.sequence)) {
|
||||
parts.sequences.select(pickId.slice(PICK_PREFIX.sequence.length));
|
||||
const sequenceId = sequenceIdFromPick(pickId);
|
||||
if (sequenceId) {
|
||||
parts.sequences.select(sequenceId);
|
||||
return true;
|
||||
}
|
||||
if (pickId.startsWith(PICK_PREFIX.image)) {
|
||||
@@ -195,7 +196,17 @@ export function createMapillaryProvider({ source }) {
|
||||
radius: NEAREST_RADIUS_M,
|
||||
limit: NEAREST_LIMIT,
|
||||
});
|
||||
const hit = images.find((record) =>
|
||||
// The API returns the images in the radius in no particular order.
|
||||
const metres = (record) => {
|
||||
const [lon2, lat2] = (record.computed_geometry || record.geometry)
|
||||
?.coordinates || [NaN, NaN];
|
||||
const dx = (lon2 - lon) * 111_320 * Math.cos((lat * Math.PI) / 180);
|
||||
const dy = (lat2 - lat) * 110_540;
|
||||
const d = Math.hypot(dx, dy);
|
||||
return Number.isFinite(d) ? d : Infinity;
|
||||
};
|
||||
const byDistance = [...images].sort((a, b) => metres(a) - metres(b));
|
||||
const hit = byDistance.find((record) =>
|
||||
passesImageryFilter(
|
||||
{
|
||||
isPano: record.is_pano === true,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createMapillaryProvider, mapillaryImageUrl } from './index.js';
|
||||
import { sequenceIdFromPick } from './coverage.js';
|
||||
import { validateProviders } from '../../registry.js';
|
||||
import { PROVIDER_COLORS } from '../../policy.js';
|
||||
|
||||
@@ -92,3 +93,28 @@ test('nearestImage honours the imagery filter and reports key status', async ()
|
||||
assert.deepEqual(await flat.status(), { configured: true });
|
||||
assert.equal(flat.coverageStats().keyRequired, false);
|
||||
});
|
||||
|
||||
test('any part of a multi-part sequence picks the whole sequence', () => {
|
||||
assert.equal(sequenceIdFromPick('mly:seq:abc'), 'abc');
|
||||
assert.equal(sequenceIdFromPick('mly:seq:abc~2'), 'abc');
|
||||
assert.equal(sequenceIdFromPick('mly:img:abc'), null);
|
||||
assert.equal(sequenceIdFromPick(null), null);
|
||||
});
|
||||
|
||||
test('nearestImage picks the closest image, not the first the API returned', async () => {
|
||||
const at = (id, lon, lat) => ({
|
||||
id,
|
||||
is_pano: false,
|
||||
captured_at: 10,
|
||||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||
});
|
||||
const source = fakeSource({
|
||||
nearest: [
|
||||
at('far', 2.0004, 1), // ~45 m east
|
||||
at('near', 2.00005, 1), // ~5 m east
|
||||
at('mid', 2, 1.0002), // ~22 m north
|
||||
],
|
||||
});
|
||||
const instance = createMapillaryProvider({ source }).create(fakeContext());
|
||||
assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), 'near');
|
||||
});
|
||||
@@ -12,6 +12,8 @@ function clampLat(lat) {
|
||||
/** Tile column for a longitude at zoom z. */
|
||||
export function lonToTileX(lon, z) {
|
||||
const n = 2 ** z;
|
||||
// 180° is the east edge of the last column, not the west edge of the first.
|
||||
if (lon >= 180) return n - 1;
|
||||
const wrapped = ((((lon + 180) % 360) + 360) % 360) - 180;
|
||||
return Math.min(n - 1, Math.max(0, Math.floor(((wrapped + 180) / 360) * n)));
|
||||
}
|
||||
@@ -91,13 +93,38 @@ export function countTilesForBbox(bbox, z) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate the tiles at zoom z covering a bbox, ordered from the centre of
|
||||
* Enumerate the tiles at zoom z covering a bbox (west > east means it crosses
|
||||
* the date line), ordered from the centre of
|
||||
* the box outwards so a progressive renderer fills in what the user is most
|
||||
* likely looking at first. `limit` caps the list; the result reports whether
|
||||
* the cap was hit.
|
||||
* @returns {{tiles: Array<{x:number,y:number,z:number}>, truncated: boolean, total: number}}
|
||||
*/
|
||||
export function tilesForBbox(bbox, z, { limit = Infinity } = {}) {
|
||||
// A box across the date line comes as west > east: split it at ±180°.
|
||||
if (Array.isArray(bbox) && Number(bbox[0]) > Number(bbox[2])) {
|
||||
const [west, south, east, north] = bbox.map(Number);
|
||||
const n = 2 ** z;
|
||||
const tiles = [
|
||||
...tilesForBbox([west, south, 180, north], z).tiles,
|
||||
...tilesForBbox([-180, south, east, north], z).tiles,
|
||||
];
|
||||
const x0 = lonToTileX(west, z);
|
||||
const x1 = lonToTileX(east, z) + n;
|
||||
const cx = ((x0 + x1) / 2) % n;
|
||||
const cy = (latToTileY(north, z) + latToTileY(south, z)) / 2;
|
||||
const dx = (x) => Math.min(Math.abs(x - cx), n - Math.abs(x - cx));
|
||||
tiles.sort(
|
||||
(a, b) =>
|
||||
dx(a.x) ** 2 + (a.y - cy) ** 2 - (dx(b.x) ** 2 + (b.y - cy) ** 2),
|
||||
);
|
||||
const truncated = tiles.length > limit;
|
||||
return {
|
||||
tiles: truncated ? tiles.slice(0, limit) : tiles,
|
||||
truncated,
|
||||
total: tiles.length,
|
||||
};
|
||||
}
|
||||
const box = normalizeBbox(bbox);
|
||||
if (!box) return { tiles: [], truncated: false, total: 0 };
|
||||
const x0 = lonToTileX(box.west, z);
|
||||
|
||||
@@ -85,3 +85,27 @@ test('overviewZoomForHeight covers the globe above the sequence ceiling', () =>
|
||||
assert.equal(overviewZoomForHeight(10_000_000), 1);
|
||||
assert.equal(overviewZoomForHeight(25_000_000), 0);
|
||||
});
|
||||
|
||||
test('longitude 180 is the last column, so a world view keeps the eastern hemisphere', () => {
|
||||
assert.equal(lonToTileX(180, 1), 1);
|
||||
assert.equal(lonToTileX(179.99, 1), 1);
|
||||
assert.equal(lonToTileX(-180, 1), 0);
|
||||
const columns = new Set(
|
||||
tilesForBbox([-180, -85, 180, 85], 1).tiles.map((tile) => tile.x),
|
||||
);
|
||||
assert.deepEqual([...columns].sort(), [0, 1]);
|
||||
});
|
||||
|
||||
test('a box across the date line takes tiles on both sides of it, not the far side of the globe', () => {
|
||||
const { tiles } = tilesForBbox([179, -1, -179, 1], 8);
|
||||
const columns = new Set(tiles.map((tile) => tile.x));
|
||||
assert.ok(columns.has(255), 'west of the line');
|
||||
assert.ok(columns.has(0), 'east of the line');
|
||||
assert.ok(
|
||||
[...columns].every((x) => x <= 1 || x >= 254),
|
||||
'nothing from the middle of the map',
|
||||
);
|
||||
// The limit keeps the tiles nearest the line.
|
||||
const limited = tilesForBbox([170, -1, -170, 1], 8, { limit: 4 }).tiles;
|
||||
assert.ok(limited.every((tile) => tile.x <= 8 || tile.x >= 247));
|
||||
});
|
||||
@@ -68,7 +68,24 @@ export function visibleBbox(viewer, { grid = 5 } = {}) {
|
||||
south = Math.min(south, lat);
|
||||
north = Math.max(north, lat);
|
||||
}
|
||||
if (east - west > 0 && north - south > 0) return [west, south, east, north];
|
||||
// Hits on both sides of ±180° read as a box around the whole globe;
|
||||
// measured in 0–360° they are a narrow box across the date line, which
|
||||
// comes back as west > east (the convention tilesForBbox splits).
|
||||
if (east - west > 180) {
|
||||
let west360 = Infinity;
|
||||
let east360 = -Infinity;
|
||||
for (const carto of hits) {
|
||||
const lon = Cesium.Math.toDegrees(carto.longitude);
|
||||
const shifted = lon < 0 ? lon + 360 : lon;
|
||||
west360 = Math.min(west360, shifted);
|
||||
east360 = Math.max(east360, shifted);
|
||||
}
|
||||
if (east360 - west360 < east - west) {
|
||||
west = west360 > 180 ? west360 - 360 : west360;
|
||||
east = east360 > 180 ? east360 - 360 : east360;
|
||||
}
|
||||
}
|
||||
if (west !== east && north - south > 0) return [west, south, east, north];
|
||||
}
|
||||
const rectangle = camera.computeViewRectangle?.(scene.globe?.ellipsoid);
|
||||
if (!rectangle) return null;
|
||||
@@ -84,8 +101,13 @@ export function visibleBbox(viewer, { grid = 5 } = {}) {
|
||||
export function viewCentre(viewer) {
|
||||
if (!viewer) return null;
|
||||
const bbox = visibleBbox(viewer);
|
||||
if (bbox)
|
||||
return { lat: (bbox[1] + bbox[3]) / 2, lon: (bbox[0] + bbox[2]) / 2 };
|
||||
if (bbox) {
|
||||
// Across the date line (west > east) the middle is on the far side of 0°.
|
||||
const span = bbox[2] - bbox[0] + (bbox[0] > bbox[2] ? 360 : 0);
|
||||
let lon = bbox[0] + span / 2;
|
||||
if (lon > 180) lon -= 360;
|
||||
return { lat: (bbox[1] + bbox[3]) / 2, lon };
|
||||
}
|
||||
const carto = viewer.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { cameraHeightAboveGround } from './view.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { cameraHeightAboveGround, viewCentre, visibleBbox } from './view.js';
|
||||
|
||||
const RAD = Math.PI / 180;
|
||||
/** A viewer 300 m above a street 1,600 m up (Denver-like). */
|
||||
@@ -42,3 +43,36 @@ test('with no ground sample the ellipsoidal height is used', () => {
|
||||
assert.equal(cameraHeightAboveGround(hidden, { groundAt: () => null }), 1900);
|
||||
assert.equal(cameraHeightAboveGround({}), null);
|
||||
});
|
||||
|
||||
/** A camera whose screen rays land on a grid of lon/lat points. */
|
||||
function gridViewer(lons, lats) {
|
||||
const points = [];
|
||||
for (const lat of lats)
|
||||
for (const lon of lons)
|
||||
points.push(Cesium.Cartesian3.fromDegrees(lon, lat));
|
||||
let next = 0;
|
||||
return {
|
||||
scene: { canvas: { clientWidth: 100, clientHeight: 100 }, globe: {} },
|
||||
camera: {
|
||||
pickEllipsoid: () => points[next++ % points.length],
|
||||
positionCartographic: { longitude: 0, latitude: 0, height: 1000 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a view across the date line is a narrow box with west > east', () => {
|
||||
const viewer = gridViewer([178, 179, -179, -178], [-1, 0, 1]);
|
||||
const bbox = visibleBbox(viewer);
|
||||
assert.ok(bbox[0] > 177 && bbox[0] < 179, `west ${bbox[0]}`);
|
||||
assert.ok(bbox[2] < -177 && bbox[2] > -179, `east ${bbox[2]}`);
|
||||
const centre = viewCentre(viewer);
|
||||
assert.ok(Math.abs(Math.abs(centre.lon) - 180) < 0.5, `centre ${centre.lon}`);
|
||||
});
|
||||
|
||||
test('an ordinary view keeps west < east', () => {
|
||||
const bbox = visibleBbox(gridViewer([10, 11, 12], [50, 51]));
|
||||
assert.ok(bbox[0] < bbox[2]);
|
||||
assert.ok(
|
||||
Math.abs(viewCentre(gridViewer([10, 11, 12], [50, 51])).lon - 11) < 1e-6,
|
||||
);
|
||||
});
|
||||
@@ -9,6 +9,12 @@ export function createViewerHost({ state, parts }) {
|
||||
/** @type {{id: string, promise: Promise<object>}|null} */
|
||||
let mounting = null;
|
||||
let openSeq = 0;
|
||||
/**
|
||||
* Adapters stood up by `prewarm` that are not the active one: each holds a
|
||||
* live viewer (a WebGL context for MapillaryJS) until `unmount` releases it.
|
||||
* @type {Map<string, object>}
|
||||
*/
|
||||
const warmed = new Map();
|
||||
|
||||
function notify() {
|
||||
state.notify?.();
|
||||
@@ -84,6 +90,7 @@ export function createViewerHost({ state, parts }) {
|
||||
throw new Error('Street-level viewer was closed');
|
||||
}
|
||||
active = { id: entry.def.id, adapter, unsubscribe };
|
||||
warmed.delete(entry.def.id);
|
||||
adapter.setRenderMode?.(state.street.renderMode);
|
||||
return adapter;
|
||||
})();
|
||||
@@ -193,22 +200,44 @@ export function createViewerHost({ state, parts }) {
|
||||
const host = state.street.host;
|
||||
if (!host || !state.enabled) return;
|
||||
for (const entry of entries) {
|
||||
const adapter = entry.instance.viewer;
|
||||
if (!adapter.prewarm || !entry.on) continue;
|
||||
try {
|
||||
await entry.instance.viewer.prewarm?.(host);
|
||||
await adapter.prewarm(host);
|
||||
} catch {
|
||||
/* the real open reports errors */
|
||||
}
|
||||
if (active?.id === entry.def.id) continue;
|
||||
// Switched off while it loaded: release it now rather than keep it.
|
||||
if (!state.enabled || !entry.on) adapter.unmount();
|
||||
else warmed.set(entry.def.id, adapter);
|
||||
}
|
||||
}
|
||||
|
||||
/** Tear the mounted adapter down (layer disabled or destroyed). */
|
||||
function unmount() {
|
||||
close();
|
||||
mounting = null;
|
||||
if (!active) return;
|
||||
active.unsubscribe();
|
||||
active.adapter.unmount();
|
||||
active = null;
|
||||
/**
|
||||
* Tear viewers down: every one (layer disabled or destroyed), or only the
|
||||
* given provider's, active or prewarmed (that provider switched off).
|
||||
* @param {string} [providerId]
|
||||
*/
|
||||
function unmount(providerId) {
|
||||
if (
|
||||
!providerId ||
|
||||
active?.id === providerId ||
|
||||
mounting?.id === providerId
|
||||
) {
|
||||
close();
|
||||
mounting = null;
|
||||
if (active) {
|
||||
active.unsubscribe();
|
||||
active.adapter.unmount();
|
||||
active = null;
|
||||
}
|
||||
}
|
||||
for (const [id, adapter] of warmed) {
|
||||
if (providerId && id !== providerId) continue;
|
||||
warmed.delete(id);
|
||||
adapter.unmount();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -87,3 +87,43 @@ test('unmounting while a mount is in flight does not leave it active', async ()
|
||||
assert.equal(adapter.calls.unmount, 1);
|
||||
assert.equal(state.street.open, false);
|
||||
});
|
||||
|
||||
test('a prewarmed viewer is released when the layer goes off, or at once if it went off mid-load', async () => {
|
||||
const adapter = fakeAdapter();
|
||||
let release = null;
|
||||
adapter.prewarm = () =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const { state, host } = harness(adapter);
|
||||
const entry = state.providers.get('mapillary');
|
||||
entry.on = true;
|
||||
|
||||
// Warmed, then the layer is switched off: unmount releases the warm viewer.
|
||||
const warming = host.prewarm([entry]);
|
||||
release();
|
||||
await warming;
|
||||
assert.equal(adapter.calls.unmount, 0);
|
||||
host.unmount();
|
||||
assert.equal(adapter.calls.unmount, 1, 'the WebGL viewer is destroyed');
|
||||
|
||||
// Switched off while the library was still loading: released on arrival.
|
||||
const late = host.prewarm([entry]);
|
||||
state.enabled = false;
|
||||
release();
|
||||
await late;
|
||||
assert.equal(adapter.calls.unmount, 2);
|
||||
});
|
||||
|
||||
test('switching one provider off releases only its viewer', async () => {
|
||||
const adapter = fakeAdapter();
|
||||
adapter.prewarm = async () => {};
|
||||
const { state, host } = harness(adapter);
|
||||
const entry = state.providers.get('mapillary');
|
||||
entry.on = true;
|
||||
await host.prewarm([entry]);
|
||||
host.unmount('panoramax');
|
||||
assert.equal(adapter.calls.unmount, 0, 'another provider leaves it alone');
|
||||
host.unmount('mapillary');
|
||||
assert.equal(adapter.calls.unmount, 1);
|
||||
});
|
||||
@@ -933,7 +933,7 @@ export class StyleManager extends ShellFacade {
|
||||
return this._runExplicitNavigation('camera', () => focus(cameraId));
|
||||
}
|
||||
|
||||
/** Compose camera panel controls from the existing camera port and application actions. */
|
||||
/** Compose the Street Level panel controls from its layer and application actions. */
|
||||
_initStreetLevelPanel() {
|
||||
const { streetLevelLayer } = this.services;
|
||||
this._streetLevelControls?.destroy();
|
||||
@@ -959,6 +959,7 @@ export class StyleManager extends ShellFacade {
|
||||
this._streetLevelControls.connect();
|
||||
}
|
||||
|
||||
/** Compose camera panel controls from the existing camera port and application actions. */
|
||||
_initCctvPanel() {
|
||||
const { cctvLayer } = this.services;
|
||||
this._cctvControls?.destroy();
|
||||
@@ -1506,6 +1507,7 @@ export class StyleManager extends ShellFacade {
|
||||
* @returns {void}
|
||||
*/
|
||||
_onPanelResized(panelId) {
|
||||
if (panelId === 'cctv-panel') this._syncCctvPanelViewport();
|
||||
if (panelId === 'street-level-panel')
|
||||
this._streetLevelControls?.onPanelResized();
|
||||
}
|
||||
|
||||
@@ -416,9 +416,6 @@ export class PanelPositionControls {
|
||||
// storage unavailable
|
||||
}
|
||||
this._layoutRightPanels();
|
||||
if (panelId === 'cctv-panel') {
|
||||
this._syncCctvPanelViewport();
|
||||
}
|
||||
this._onPanelResized?.(panelId);
|
||||
}
|
||||
|
||||
@@ -629,9 +626,7 @@ export class PanelPositionControls {
|
||||
swallowNextClick = false;
|
||||
}, 0);
|
||||
this._savePanelPosition(panelId, panelEl);
|
||||
if (panelId === 'cctv-panel') {
|
||||
this._syncCctvPanelViewport();
|
||||
}
|
||||
this._onPanelResized?.(panelId);
|
||||
};
|
||||
|
||||
this._cancelDrag = cancel;
|
||||
@@ -716,9 +711,6 @@ export class PanelPositionControls {
|
||||
const onUp = () => {
|
||||
cancel();
|
||||
this._savePanelPosition(panelId, panelEl);
|
||||
if (panelId === 'cctv-panel') {
|
||||
this._syncCctvPanelViewport();
|
||||
}
|
||||
this._onPanelResized?.(panelId);
|
||||
};
|
||||
|
||||
|
||||
@@ -366,7 +366,13 @@ export class StreetLevelControls {
|
||||
el.sinceRange.setAttribute('aria-valuetext', view.since.label);
|
||||
}
|
||||
}
|
||||
if (el.legend && el.legend.childElementCount !== view.legend.length) {
|
||||
// Rebuild when the swatches change, not only their count: one provider
|
||||
// can replace another with the same number of entries.
|
||||
const legendKey = view.legend
|
||||
.map((entry) => `${entry.key}:${entry.color}:${entry.label}`)
|
||||
.join('|');
|
||||
if (el.legend && this._legendKey !== legendKey) {
|
||||
this._legendKey = legendKey;
|
||||
el.legend.replaceChildren(
|
||||
...view.legend.map((entry) => {
|
||||
const item = document.createElement('li');
|
||||
@@ -406,7 +412,11 @@ export class StreetLevelControls {
|
||||
if (viewer.link) el.imageLink.href = viewer.link;
|
||||
if (viewer.linkLabel) el.imageLink.textContent = viewer.linkLabel;
|
||||
}
|
||||
if (state.street.open) this.layer.resizeViewer?.();
|
||||
// Size changes reach the viewer through the ResizeObserver; resizing on
|
||||
// every render ran each frame while a panorama was dragged. Opening is the
|
||||
// one moment the element may not have reported a size yet.
|
||||
if (state.street.open && !this._viewerWasOpen) this.layer.resizeViewer?.();
|
||||
this._viewerWasOpen = state.street.open === true;
|
||||
}
|
||||
|
||||
_renderMeta(view) {
|
||||
|
||||
Reference in new issue
Block a user