Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions assets/records.csv
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
"Millionth Map (Index Map, Overlapping Polygons)","https://raw.githubusercontent.com/geoblacklight/geoblacklight/be395f5d12a75c7f643baf550ace66efd387f602/spec/fixtures/solr_documents/index-map-v1-complex.json"
"Dalian (Index Map, Polygons, Mixed Availability)","https://raw.githubusercontent.com/OpenGeoMetadata/edu.stanford.purl/8f9ce62bdc4b60f821c80199aa6061d5bc4793fd/metadata-aardvark/zz/075/gp/2099/geoblacklight.json"
"Hopkins (Index Map, Points)","https://gist.githubusercontent.com/thatbudakguy/dcf881807aea33739a8cbb8fcefa42d0/raw/67b80876bdd17c21d654024bc77a1e4ece8a7fb6/hopkins.json"
"CA 1:24k Topos (Index Map of COGs)", "https://raw.githubusercontent.com/OpenGeoMetadata/gov.usgs.htmc/refs/heads/main/metadata-aardvark/htmc/index-maps/usgs-htmc-index-ca-24000.json"
"3DEP 1 Arc-Second DEMs (Index Map of DEM COGs)", "https://raw.githubusercontent.com/OpenGeoMetadata/gov.usgs.tnm/refs/heads/main/metadata-aardvark/dem1/usgs-tnm-index-dem1.json"
"Isles (IIIF v2 Manifest, Complex Geometry)","https://raw.githubusercontent.com/geoblacklight/geoblacklight/9f7dbb4c36dc0b8e88fb9be5282d7b1663858829/spec/fixtures/solr_documents/complex-geom.json"
"Siberia (IIIF v3 Manifest, Multi-image)","https://raw.githubusercontent.com/OpenGeoMetadata/edu.stanford.purl/8f9ce62bdc4b60f821c80199aa6061d5bc4793fd/metadata-aardvark/hj/948/rn/6493/geoblacklight.json"
"NY & NJ (IIIF Image)","https://raw.githubusercontent.com/geoblacklight/geoblacklight/refs/heads/main/spec/fixtures/solr_documents/public_iiif_princeton.json"
Expand Down
61 changes: 61 additions & 0 deletions src/components/ogm-map/ogm-map.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const drawablePreviewer = () => ({
inspectable: true,
minZoom: undefined as number | undefined,
onNotice: undefined as ((notice: string | undefined) => void) | undefined,
onLoading: undefined as ((loading: boolean) => void) | undefined,
url: 'http://example.com/data.json',
sourceIds: [] as string[],
previewLayers: [],
Expand Down Expand Up @@ -654,6 +655,66 @@ describe('ogm-map', () => {
expect(noticeVariants(el)).toEqual(['brand']);
});

// The sheets of a mosaic, opened and drawn by deck.gl after a pan, which MapLibre's own tile events
// never hear about - so the preview says when it is still drawing, and the spinner goes by that too
describe('while a preview that draws itself is still drawing', () => {
const drawingPreview = async () => {
const { el } = await renderMap();
const map = loadingMap();
const previewer = drawablePreviewer();
Object.assign(el, { map, layersControl: fakeLayersControl(), previewer });
await styleLoads(el, map);

// Only what's said after the preview's own first load
const said: string[] = [];
el.addEventListener('mapLoading', () => said.push('start'));
el.addEventListener('mapIdle', () => said.push('stop'));
return { el, map, previewer, said };
};

it('says the map is loading until the preview says it is done', async () => {
const { previewer, said } = await drawingPreview();

previewer.onLoading?.(true);
expect(said).toEqual(['start']);

previewer.onLoading?.(false);
expect(said).toEqual(['start', 'stop']);
});

// <ogm-viewer> counts the events, so a preview saying it twice mustn't leave the spinner up
it('says each once, however often the preview repeats itself', async () => {
const { previewer, said } = await drawingPreview();

previewer.onLoading?.(true);
previewer.onLoading?.(true);
previewer.onLoading?.(false);
previewer.onLoading?.(false);

expect(said).toEqual(['start', 'stop']);
});

it('settles a wait the last preview left unfinished when the next one loads, and stops listening to it', async () => {
const { el, map, previewer, said } = await drawingPreview();
previewer.onLoading?.(true);

Object.assign(el, { previewer: drawablePreviewer() });
await styleLoads(el, map);
previewer.onLoading?.(true);

expect(said.filter(event => event === 'start').length).toEqual(said.filter(event => event === 'stop').length);
});

it('settles on the way out, so the count it was added to does not keep the spinner up', async () => {
const { el, previewer, said } = await drawingPreview();
previewer.onLoading?.(true);

(el as unknown as { disconnectedCallback: () => void }).disconnectedCallback();

expect(said).toEqual(['start', 'stop']);
});
});

// A WMTS layer's <LegendURL>s ride on its row in the layers panel, the way a COG's ramp does
describe('with a layer whose service publishes a picture of its key', () => {
const pictured = (legendImages: LegendImage[]) => ({
Expand Down
14 changes: 14 additions & 0 deletions src/components/ogm-map/ogm-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export class OgmMap {
// tiles because it drives mapLoading/mapIdle, which <ogm-viewer> counts: every time this is set,
// exactly one clear has to follow, or the spinner is stuck on or turned off early.
private tilesLoading: boolean = false;
private previewLoading: boolean = false;

// Resolves once the current attempt has an answer - the preview's first tile arrived, or the
// deadline gave up on it - which is what the spinner is held up by. Starts resolved, since there
Expand Down Expand Up @@ -230,6 +231,7 @@ export class OgmMap {

// Nothing is going to arrive now, and the count this was added to outlives us
this.settleTileLoading();
this.setPreviewLoading(false);
if (this.map) this.map.remove();
}

Expand Down Expand Up @@ -287,6 +289,7 @@ export class OgmMap {
this.clearLoadDeadline();
this.previewDrawn = false;
this.notice = undefined;
this.setPreviewLoading(false);

// Indicate loading state so we can show the spinner
this.mapLoading.emit();
Expand Down Expand Up @@ -321,6 +324,9 @@ export class OgmMap {
previewer.onNotice = notice => {
if (this.previewer === previewer) this.notice = notice;
};
previewer.onLoading = loading => {
if (this.previewer === previewer) this.setPreviewLoading(loading);
};
// A preview whose layers can still change after they are drawn - a georeferenced scan works its
// background colour out from a thumbnail it has to fetch, and only then has a toggle to offer.
// Bound the same way, so a colour that arrives for the record we just left cannot rebuild the
Expand Down Expand Up @@ -516,6 +522,14 @@ export class OgmMap {
this.mapIdle.emit();
}

// Emit the appropriate events if the preview loading state changed
private setPreviewLoading(loading: boolean) {
if (loading === this.previewLoading) return;
this.previewLoading = loading;
if (loading) this.mapLoading.emit();
else this.mapIdle.emit();
}

// Something of the current preview is on the map. Reached from the map's own tile events above for
// everything MapLibre draws, and from the onDrawn hook for the two previews that draw themselves.
private markPreviewDrawn() {
Expand Down
178 changes: 178 additions & 0 deletions src/lib/clipped-cog-layer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { describe, it, expect, vi, afterEach } from '@stencil/vitest';
import { COGLayer } from '@developmentseed/deck.gl-geotiff';
import { Texture } from '@luma.gl/core';

import ClippedCOGLayer, { cutlineUniforms, PreciseCutline, type Cutline, type CutlineUniforms } from './clipped-cog-layer';

// A pipeline of its own to clip, standing in for whichever one the layer would have drawn with. Handed
// over as a prop so the layer never has to read a GeoTIFF to work out one of its own.
const OWN_MODULE = { module: { name: 'own' } };
const renderTile = vi.fn(() => ({ renderPipeline: [OWN_MODULE] }));

// Only a GlobeViewport has a resolution, which is how deck.gl-raster tells the two apart
const GLOBE = { resolution: 0 };
const FLAT = {};

// Tibet's Ngari, say: far enough from the origin in both directions that a float32 can't hold either
// edge exactly, in degrees or in common space
const CUTLINE: Cutline = [80.123456789, 31.987654321, 81.123456789, 32.987654321];

// What WebMercatorViewport.projectPosition makes of a longitude and a latitude: deck.gl's common
// space, 512 units across the world, with the equator at 256 and north upwards
const commonX = (lng: number) => ((lng + 180) / 360) * 512;
const commonY = (lat: number) => 256 + (256 / Math.PI) * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360));

// The box the shader is handed, put back together: where its corner is and how far it reaches
const reassemble = ({ originHigh, originLow, size }: CutlineUniforms) => {
const minX = originHigh[0] + originLow[0];
const minY = originHigh[1] + originLow[1];
return [minX, minY, minX + size[0], minY + size[1]];
};

// The layer's render callback as RasterTileLayer would call it, in a view of the given kind
const layerIn = (viewport: object, cutline?: Cutline) => {
const layer = new ClippedCOGLayer({ id: 'sheet', geotiff: 'https://example.com/sheet.tif', getTileData: vi.fn(), renderTile, ...(cutline && { cutline }) } as never);
Object.assign(layer, { context: { viewport } });
return layer as unknown as {
_renderTileCallback(): ((data: unknown) => { renderPipeline: { module: unknown; props?: CutlineUniforms }[] }) | undefined;
context: { viewport: object };
};
};

const DATA = { width: 512, height: 512 };

describe('ClippedCOGLayer', () => {
it('draws exactly what it would have without a cutline', () => {
expect(layerIn(FLAT)._renderTileCallback()).toBe(renderTile);
});

it('clips the pipeline it would have drawn with, after the rest of it', () => {
const pipeline = layerIn(GLOBE, CUTLINE)._renderTileCallback()!(DATA).renderPipeline;

expect(pipeline.map(({ module }) => module)).toEqual([OWN_MODULE.module, PreciseCutline]);
});

// RasterTileLayer builds a globe's mesh in longitude and latitude, and a flat map's in common space
it('clips in whichever space the mesh is in', () => {
const globe = layerIn(GLOBE, CUTLINE)._renderTileCallback()!(DATA).renderPipeline[1].props!;
const flat = layerIn(FLAT, CUTLINE)._renderTileCallback()!(DATA).renderPipeline[1].props!;

expect(globe).toBe(cutlineUniforms(CUTLINE, true));
expect(flat).toBe(cutlineUniforms(CUTLINE, false));
});

// deck.gl's GlobeView hands over a WebMercatorViewport past zoom 12, and the callback outlives that
it('asks which kind of view it is in for each tile, not once for the layer', () => {
const layer = layerIn(GLOBE, CUTLINE);
const render = layer._renderTileCallback()!;

layer.context.viewport = FLAT;

expect(render(DATA).renderPipeline[1].props).toBe(cutlineUniforms(CUTLINE, false));
});
});

describe('cutlineUniforms', () => {
it('hands a globe the box in degrees, let out by a few millionths of one', () => {
const [west, south, east, north] = reassemble(cutlineUniforms(CUTLINE, true));

expect(west).toBeLessThan(CUTLINE[0]);
expect(west).toBeCloseTo(CUTLINE[0], 4);
expect(south).toBeLessThan(CUTLINE[1]);
expect(south).toBeCloseTo(CUTLINE[1], 4);
expect(east).toBeGreaterThan(CUTLINE[2]);
expect(east).toBeCloseTo(CUTLINE[2], 4);
expect(north).toBeGreaterThan(CUTLINE[3]);
expect(north).toBeCloseTo(CUTLINE[3], 4);
});

it('hands a flat map the box in common space, let out the same way', () => {
const [minX, minY, maxX, maxY] = reassemble(cutlineUniforms(CUTLINE, false));

expect(minX).toBeLessThan(commonX(CUTLINE[0]));
expect(minX).toBeCloseTo(commonX(CUTLINE[0]), 4);
expect(minY).toBeLessThan(commonY(CUTLINE[1]));
expect(minY).toBeCloseTo(commonY(CUTLINE[1]), 4);
expect(maxX).toBeGreaterThan(commonX(CUTLINE[2]));
expect(maxX).toBeCloseTo(commonX(CUTLINE[2]), 4);
expect(maxY).toBeGreaterThan(commonY(CUTLINE[3]));
expect(maxY).toBeCloseTo(commonY(CUTLINE[3]), 4);
});

// The whole point: a float32 of the corner alone is off by up to half its last place, which in
// common space is pixels at zoom 18
it('splits the corner into a float32 and the remainder that float32 cannot hold', () => {
for (const globe of [true, false]) {
const { originHigh, originLow } = cutlineUniforms(CUTLINE, globe);

originHigh.forEach(high => expect(Math.fround(high)).toBe(high));
originLow.forEach((low, axis) => {
expect(low).not.toBe(0);
expect(Math.abs(low)).toBeLessThan(Math.abs(originHigh[axis]) * 2 ** -23);
});
}
});

it('works each box out once', () => {
expect(cutlineUniforms(CUTLINE, false)).toBe(cutlineUniforms(CUTLINE, false));
});
});

// What deck.gl leaves behind when a sheet of a mosaic leaves the view: the layer is finalized with its
// tiles still loaded, and its TileLayer empties its tileset without unloading any of them
describe('ClippedCOGLayer going away', () => {
afterEach(() => vi.restoreAllMocks());

// A layer whose tile layer holds these tiles, as RasterTileLayer's does when the layer is removed
const layerHolding = (tiles: { content: unknown }[], props: object) => {
const layer = new ClippedCOGLayer({ id: 'sheet', geotiff: 'https://example.com/sheet.tif', ...props } as never);
vi.spyOn(layer, 'getSubLayers').mockReturnValue([{ state: { tileset: { tiles } } }] as never);
return layer as unknown as { finalizeState(context: unknown): void };
};

// A texture as COGLayer's own pipeline uploads one, which it frees with destroy()
const texture = () => Object.assign(Object.create(Texture.prototype), { destroy: vi.fn() }) as Texture;

it('unloads every tile still holding something, through a pipeline handed in', () => {
vi.spyOn(COGLayer.prototype, 'finalizeState').mockImplementation(() => {});
const onTileUnload = vi.fn();
const held = [{ content: { texture: 'a' } }, { content: null }, { content: { texture: 'b' } }];

layerHolding(held, { getTileData: vi.fn(), renderTile, onTileUnload }).finalizeState({});

expect(onTileUnload.mock.calls.map(([tile]) => tile)).toEqual([held[0], held[2]]);
});

it('frees the textures of the pipeline COGLayer works out for itself', () => {
vi.spyOn(COGLayer.prototype, 'finalizeState').mockImplementation(() => {});
const data = { texture: texture(), mask: texture() };

layerHolding([{ content: data }], {}).finalizeState({});

expect(data.texture.destroy).toHaveBeenCalledTimes(1);
expect(data.mask.destroy).toHaveBeenCalledTimes(1);
});

// COGLayer's own finalize aborts what's still loading, and RasterTileLayer's lets go of the rest
it('unloads them before COGLayer finalizes anything of its own', () => {
const onTileUnload = vi.fn();
let unloadedByThen = -1;
vi.spyOn(COGLayer.prototype, 'finalizeState').mockImplementation(() => {
unloadedByThen = onTileUnload.mock.calls.length;
});

layerHolding([{ content: {} }, { content: {} }], { getTileData: vi.fn(), renderTile, onTileUnload }).finalizeState({});

expect(unloadedByThen).toEqual(2);
});

it('has nothing to unload for a layer that never drew a tile', () => {
vi.spyOn(COGLayer.prototype, 'finalizeState').mockImplementation(() => {});
const onTileUnload = vi.fn();
const layer = new ClippedCOGLayer({ id: 'sheet', geotiff: 'https://example.com/sheet.tif', getTileData: vi.fn(), renderTile, onTileUnload } as never);

(layer as unknown as { finalizeState(context: unknown): void }).finalizeState({});

expect(onTileUnload).not.toHaveBeenCalled();
});
});
Loading
Loading