From 4a1d89923430a4bee8e0c35a4f6a4bcb99e46806 Mon Sep 17 00:00:00 2001 From: Nick Budak Date: Wed, 12 Aug 2026 13:25:00 -0700 Subject: [PATCH] Say so when a COG's tiles can't be drawn Every other preview reports a failure through 's alert. A COG drawn by deck.gl had one path there - a file that refused to be opened, which rejects preview() - and nothing for the failures that arrive after that. Tiles come in one at a time, and deck.gl reports one that couldn't be built by calling onTileError rather than by rejecting anything. Left unhandled, its own handler logs the tile and the map stays empty: a record zoomed to the right place with no layer on it and no reason given. That is what #158 looks like from the outside. The COG in it is stored band- separate, which the version of @developmentseed/deck.gl-geotiff we build against refuses outright, so every tile of it fails the same way. The fix for that file is upstream (developmentseed/deck.gl-raster#635); this is about the viewer having nothing to say when a COG can't be drawn, whatever the reason. MapPreviewer gains an onError, which is where a failure that arrives after preview() has resolved goes. Nothing MapLibre draws needs it - it fires those on the map itself, and has listened to that since alerts existed - so this is for the previews that paint with their own WebGL and have no such channel. binds it to the same reportError() a failed load takes, which already dedupes to one alert per load attempt, so a viewport's worth of failing tiles reports once rather than forty times. It is bound to the previewer it came from rather than to whichever is current, because a tile of the record the user just left would otherwise report against the one that replaced it. Only the first failure of a COG that has drawn nothing reaches that alert. A COG can be sparse by design, and the alert covers the map completely - so a tile that failed among tiles that didn't would replace a preview the user can see with an error about a hole in it. Those are logged and left alone. Aborted reads are dropped rather than logged: deck.gl discards a cancelled tile before calling back, so a pan that abandons its reads doesn't arrive here at all, but a decoder that notices the abort itself can still throw one. The message the user sees is deck.gl's own, through referenceError, which is cryptic for the band-separate case in particular. Naming it here would mean matching on an upstream string that the upstream fix removes, so it stays as it is. Co-Authored-By: Claude Opus 5 --- src/components/ogm-map/ogm-map.tsx | 10 +++++ src/lib/previewers/cog-deck.test.ts | 65 +++++++++++++++++++++++++++++ src/lib/previewers/cog-deck.ts | 30 +++++++++++++ src/lib/previewers/map.ts | 8 ++++ 4 files changed, 113 insertions(+) diff --git a/src/components/ogm-map/ogm-map.tsx b/src/components/ogm-map/ogm-map.tsx index 3ca9a80..848e282 100644 --- a/src/components/ogm-map/ogm-map.tsx +++ b/src/components/ogm-map/ogm-map.tsx @@ -165,6 +165,16 @@ export class OgmMap { // that can change without the style document being rebuilt this.applyViewConstraints(); + // A preview that paints with its own WebGL - deck.gl's COG overlay - has no MapLibre source to + // report on, and only finds out it can't be drawn once its tiles start arriving, after preview() + // below has resolved. Give it the same alert a failed load gets. Bound to the previewer it came + // from rather than to whichever is current, so a tile of the record we just left can't report + // against the one that replaced it. + const previewer = this.previewer; + previewer.onError = error => { + if (this.previewer === previewer) this.reportError(error); + }; + try { // The style is only known now: it comes out of the theme, and the theme can change under a // preview that is already on screen diff --git a/src/lib/previewers/cog-deck.test.ts b/src/lib/previewers/cog-deck.test.ts index f324464..39df700 100644 --- a/src/lib/previewers/cog-deck.test.ts +++ b/src/lib/previewers/cog-deck.test.ts @@ -234,6 +234,71 @@ describe('DeckCogPreviewer', () => { }); }); + // deck.gl only finds out a COG can't be drawn once its tiles start arriving, which is after + // preview() has resolved - so before this the map stayed empty and the reason only reached the + // console. See https://github.com/OpenGeoMetadata/ogm-viewer/issues/158, where a band-separate COG + // failed every tile this way. + describe('a tile that fails', () => { + // One error per prop call, since deck.gl reports each failed tile separately + const failTile = (previewer: TestDeckCogPreviewer, error: unknown = new Error('Band-separate images not yet implemented.')) => + previewer.overlay.lastLayers[0].props.onTileError(error); + + const drawTile = (previewer: TestDeckCogPreviewer) => previewer.overlay.lastLayers[0].props.onTileLoad({}); + + it('fails the preview when nothing has been drawn', async () => { + const { previewer } = previewFor(); + const reported: unknown[] = []; + previewer.onError = error => reported.push(error); + await previewer.preview(); + + failTile(previewer); + + expect(reported).toEqual([new Error('Band-separate images not yet implemented.')]); + }); + + // A COG can be sparse by design, and a hole in a preview the user can see is not worth replacing + // that preview with an error + it('is left alone once some of the COG is on screen', async () => { + const { previewer } = previewFor(); + const reported: unknown[] = []; + previewer.onError = error => reported.push(error); + await previewer.preview(); + + drawTile(previewer); + failTile(previewer); + + expect(reported).toEqual([]); + }); + + // deck.gl drops a cancelled tile before calling back, but a decoder that notices the abort itself + // can still throw one - and a pan that abandons its reads is not a failed preview + it('ignores an aborted read', async () => { + const { previewer } = previewFor(); + const reported: unknown[] = []; + previewer.onError = error => reported.push(error); + await previewer.preview(); + + failTile(previewer, new DOMException('The user aborted a request.', 'AbortError')); + + expect(reported).toEqual([]); + }); + + // A fresh load attempt starts over: the tiles of the last one are gone from the overlay + it('fails again after the preview is drawn a second time', async () => { + const { map, previewer } = previewFor(); + const reported: unknown[] = []; + previewer.onError = error => reported.push(error); + await previewer.preview(); + drawTile(previewer); + + previewer.attach(map as unknown as maplibregl.Map, style); + await previewer.preview(); + failTile(previewer); + + expect(reported).toHaveLength(1); + }); + }); + it('takes its layer off the overlay when cleared', async () => { const { previewer } = previewFor(); await previewer.preview(); diff --git a/src/lib/previewers/cog-deck.ts b/src/lib/previewers/cog-deck.ts index 3a70695..14d2735 100644 --- a/src/lib/previewers/cog-deck.ts +++ b/src/lib/previewers/cog-deck.ts @@ -48,12 +48,18 @@ export default class DeckCogPreviewer extends MapPreviewer { // open file instead of reading its header again. protected geotiff: GeoTIFF | undefined; + // Whether any tile of this COG has been drawn, which is what tells a COG that can't be drawn at + // all from one tile of it that couldn't. Reset per attach, alongside everything else a fresh load + // attempt starts over. See reportTileError. + protected anyTileDrawn = false; + attach(map: maplibregl.Map, style: MapLibreStyle): this { super.attach(map, style); this.deckOverlay = this.getDeckOverlay(); this.decoderPool ??= this.createDecoderPool(); this.drawnState = { visible: true, opacity: style.opacity }; this.geotiffBoundsLoaded = new Promise(resolve => (this.resolveGeotiffBounds = resolve)); + this.anyTileDrawn = false; return this; } @@ -130,11 +136,35 @@ export default class DeckCogPreviewer extends MapPreviewer { [east, north], ]); }, + onTileLoad: () => (this.anyTileDrawn = true), + onTileError: (error: unknown) => this.reportTileError(error), parameters: { depthCompare: 'always', cullMode: 'back' }, pool: this.decoderPool, }); } + // A COG can only fail once its tiles start arriving, which is after preview() has resolved - so a + // file deck.gl refuses to draw used to leave the map empty with the reason only in the console. + // deck.gl reports each such tile here; overriding it also takes over from its own handler, which + // logs every one of them. + // + // Only the first failure of a COG that has drawn nothing is worth an alert. A COG can be sparse by + // design, and a tile that failed among tiles that didn't means a hole in a preview the user can + // see rather than a preview that isn't there - not worth replacing with an error. deck.gl drops a + // cancelled tile before calling back, so a pan that abandons its reads never arrives here at all, + // but a decoder that notices the abort itself can still throw one. + protected reportTileError(error: unknown) { + if ((error as { name?: unknown } | null)?.name === 'AbortError') return; + + if (this.anyTileDrawn) { + console.warn(`Could not draw a tile of ${this.url}:`, error); + return; + } + + console.error(`Error drawing ${this.url}:`, error); + this.onError?.(error); + } + // Disable the web worker decoder pool; it appears to error because it can't find /worker.js. // See: https://developmentseed.org/deck.gl-raster/api/geotiff/type-aliases/DecoderPoolOptions/ // See also: https://github.com/developmentseed/deck.gl-raster/issues/364 diff --git a/src/lib/previewers/map.ts b/src/lib/previewers/map.ts index 7916f76..e08b1d6 100644 --- a/src/lib/previewers/map.ts +++ b/src/lib/previewers/map.ts @@ -42,6 +42,14 @@ export default abstract class MapPreviewer extends Previewer { protected style: MapLibreStyle; protected map: maplibregl.Map; + // Where a failure that arrives after preview() has already resolved goes. Everything MapLibre + // draws itself reports one on the map, which ogm-map is already listening to; a preview that + // paints with its own WebGL has no such channel, so it is handed one. Set by whoever draws this + // preview, and only worth reporting for the failures that mean the preview isn't there - see + // DeckCogPreviewer, which has tiles arriving one at a time and most of a viewport's worth of + // failures to say nothing about. + onError?: (error: unknown) => void; + // Stored state for added MapLibre sources and layers to allow for cleanup sourceIds: string[] = []; layerIds: string[] = [];