Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
83f7684
Add refreshLayer and a per-layer refresher hook to both engines
slesaad Aug 27, 2026
a73fea1
Derive COG layer props through one function for creation and refresh
slesaad Aug 27, 2026
28e218b
Restore missing explanatory comments in deckCOGProps
slesaad Aug 27, 2026
4695ff3
Register each layer's refresher and engine id at creation
slesaad Aug 27, 2026
9f9863b
Route time and colormap updates through one engine call
slesaad Aug 27, 2026
3a75560
Handle vector layers in the Leaflet adapter's opacity surface
slesaad Aug 27, 2026
14534a4
Read layer opacity from the registry instead of Leaflet options
slesaad Aug 27, 2026
624096b
Ask the engine per layer part instead of branching on layer shape
slesaad Aug 27, 2026
b2be6b4
Match DeckGLAdapter.setLayerOpacity's signature to IMapEngine
slesaad Aug 27, 2026
198bb89
Compile deck tile URLs on the domain side, not in the adapter
slesaad Aug 27, 2026
03574d6
Decline a non-clonable layer in the deck adapter instead of throwing
slesaad Aug 27, 2026
9f84ec5
Document that a Leaflet refresher mutates in place
slesaad Aug 27, 2026
c522745
Answer hasLayer from the map, not the Leaflet registry
slesaad Aug 27, 2026
5a79999
Read the COG refresher's config from the registry on each call
slesaad Aug 27, 2026
284bc38
Warn when a time reload finds no layer to refresh
slesaad Aug 27, 2026
ee19373
Type deckCOGProps against COGLayer's own props
slesaad Aug 27, 2026
04561f5
Tidy the smaller findings from the branch review
slesaad Aug 27, 2026
a2b6bf9
Fix RefreshContext JSDoc: refresher, not adapters, tests ctx.url
slesaad Aug 27, 2026
95958d3
Rewrite layer-engine comments to stand on their own
slesaad Aug 27, 2026
8bd1a8e
Explain the marker CSS pass in terms of the code, not the migration
slesaad Aug 28, 2026
47c1757
Warn when the deck engine holds a layer it did not build
slesaad Aug 28, 2026
099e6b8
Correct registerLayer doc and trim oversized layer-refresh comments
slesaad Aug 28, 2026
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
320 changes: 99 additions & 221 deletions src/essence/Basics/Layers_/Layers_.js

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions src/essence/Basics/Layers_/deckCOGRefresher.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { deckCOGProps } from '../MapEngines/Adapters/DeckCOGLayer'
import { resolveDeckCOGFileUrl } from './tileLayerSource'
import L_ from './Layers_'

/**
* How a client-side COG layer recomputes itself, for
* `IMapEngine.setLayerRefresher`.
*
* Lives on the domain side, not the adapter: it reads mission config and the
* opacity registry, which adapters must not know about. Colormap, rescale,
* opacity and the time-substituted file URL are all re-derived per call, so
* one path covers every kind of change. The returned instance keeps the
* layer's id, so deck.gl diffs it against the old one and cached tiles
* survive.
*
* @param {string} uuid - Layer UUID, also the engine-side layer id.
* @param {object} layerObj - Fallback config used when the registry has no
* `L_.layers.data` entry for `uuid`.
* @returns {(layer: object) => object} A refresher returning the replacement.
*/
export function makeDeckCOGRefresher(uuid, layerObj) {
return (layer) => {
// Looked up per call rather than captured, so this stays a derivation
// of current config: every writer mutates the entry in place, but a
// captured reference would go stale if a future write replaced the
// entry outright instead of mutating it.
const config = L_.layers.data[uuid] ?? layerObj
return layer.clone(
deckCOGProps(uuid, {
rawCogUrl: resolveDeckCOGFileUrl(config),
layerObj: config,
// ?? not ||: an opacity of 0 is a real value, not "default to 1"
opacity: L_.layers.opacity[uuid] ?? 1,
})
)
}
}
67 changes: 46 additions & 21 deletions src/essence/Basics/MapEngines/Adapters/DeckCOGLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ import {
addAlphaChannel,
texture as textureUtils,
} from '@developmentseed/deck.gl-geotiff'
import type { GetTileDataOptions } from '@developmentseed/deck.gl-geotiff'
import type {
COGLayerProps,
GetTileDataOptions,
} from '@developmentseed/deck.gl-geotiff'
import type { GeoTIFF, Overview } from '@developmentseed/geotiff'
import type {
MinimalTileData,
Expand Down Expand Up @@ -395,25 +398,29 @@ function makeRenderTile(opts: {
// ---------------------------------------------------------------------------

/**
* Build the client-side COG layer for the deck.gl engine's makeTileLayer path.
*
* A plain `COGLayer` — the colormap/rescale behaviour rides on the
* `getTileData` and `renderTile` props rather than a subclass, so there is no
* layer-owned GPU state to keep in sync (see `colormapTextures`).
* What creation and refresh both need to derive a COG layer's props.
*
* @param id - Layer id (layer name from layerObj).
* @param options - Raw COG file URL + layer config. `rawCogUrl` is the bare
* `.tif` URL with no TiTiler host or query params
* (resolveTileLayerSource's `fileUrl`).
* `rawCogUrl` is the bare `.tif` URL with no TiTiler host or query params
* (resolveTileLayerSource's `fileUrl`). `layerObj` is the layer's mission
* config, taken loosely because it is parsed from JSON.
*/
export type DeckCOGOptions = {
rawCogUrl: string
layerObj: Record<string, any>
opacity?: number
}

/**
* The complete prop bag for a client-side COG layer, derived from current
* config. Creation and every refresh go through this one function so a
* colormap default can never be computed two different ways — which also
* makes this the one place a typed return is worth having: it is checked
* against COGLayer's own props, so a misspelled prop fails to compile.
*/
export function buildDeckCOGLayer(
export function deckCOGProps(
id: string,
options: {
rawCogUrl: string
layerObj: Record<string, any>
opacity?: number
}
): Layer {
options: DeckCOGOptions
): COGLayerProps<TileData> {
const l = options.layerObj
const colormapName = (l.currentCogColormap ?? l.cogColormap ?? 'viridis') as string
const rescaleMin = Number(l.currentCogMin ?? l.cogMin ?? 0)
Expand All @@ -424,7 +431,7 @@ export function buildDeckCOGLayer(
const minZoom = parseInt(l.minZoom)
const maxZoom = parseInt(l.maxZoom)

return new COGLayer<TileData>({
return {
id,
geotiff: options.rawCogUrl,
opacity: options.opacity ?? 1,
Expand All @@ -434,14 +441,32 @@ export function buildDeckCOGLayer(
// skip its default inferRenderPipeline, which throws for float COGs
// ('non-unsigned integers not yet supported').
// The config nodata (if any) overrides the file's GDAL_NODATA.
getTileData: (image, opts) =>
cogGetTileData(image, { ...opts, noDataOverride: nodata }),
getTileData: (
image: GeoTIFF | Overview,
opts: GetTileDataOptions
) => cogGetTileData(image, { ...opts, noDataOverride: nodata }),
renderTile: makeRenderTile({ colormapName, rescaleMin, rescaleMax }),
updateTriggers: {
// Re-runs renderTile for already-loaded tiles (no refetch) when the
// colormap or rescale range changes. Nodata needs no trigger: it
// comes from the file's GDAL_NODATA tag, fixed for a given URL.
renderTile: [colormapName, rescaleMin, rescaleMax],
},
}) as unknown as Layer
}
}

/**
* Build the client-side COG layer for the deck.gl engine's makeTileLayer path.
*
* A plain `COGLayer` — the colormap/rescale behaviour rides on the
* `getTileData` and `renderTile` props rather than a subclass, so there is no
* layer-owned GPU state to keep in sync (see `colormapTextures`).
*
* @param id - Layer id (layer name from layerObj).
* @param options - Raw COG file URL + layer config, see {@link DeckCOGOptions}.
*/
export function buildDeckCOGLayer(id: string, options: DeckCOGOptions): Layer {
return new COGLayer<TileData>(
deckCOGProps(id, options)
) as unknown as Layer
}
110 changes: 96 additions & 14 deletions src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import type {
MapInitOptions,
BasemapOptions,
} from '../types/view'
import type { LayerOptions, OverlayOptions } from '../types/layers'
import type { LayerOptions, OverlayOptions, RefreshContext } from '../types/layers'
import type {
MapEventHandler,
MapEventOptions,
Expand Down Expand Up @@ -266,6 +266,31 @@ export class DeckGLAdapter implements IMapEngine<Deck, Layer, PickingInfo> {
private _maxBounds: BoundsLike | null = null

private _layers = new Map<string, Layer>()
/**
* Layer ids already reported as not-a-deck-layer, so a slider drag warns
* once rather than on every frame.
*/
private _warnedNonDeckLayers = new Set<string>()

/**
* Whether the engine holds a real deck.gl layer under `id`. Warns once per
* id when it holds something else — see {@link updateLayer}.
*/
private _holdsDeckLayer(id: string, existing: unknown): boolean {
if (typeof (existing as Layer)?.clone === 'function') return true
if (existing != null && !this._warnedNonDeckLayers.has(id)) {
this._warnedNonDeckLayers.add(id)
console.warn(
`DeckGLAdapter: layer "${id}" is not a deck.gl layer, so it cannot be ` +
`updated. It was built with Leaflet because this engine has no builder ` +
`for its type. The update was skipped.`
)
}
return false
}

/** Per-layer refresh hooks, keyed by layer id. */
private _refreshers = new Map<string, (layer: Layer, ctx: RefreshContext) => Layer | void>()
private _layerZIndices = new Map<string, number>()
private _layerIdCounter = 0

Expand Down Expand Up @@ -415,6 +440,8 @@ export class DeckGLAdapter implements IMapEngine<Deck, Layer, PickingInfo> {
this._overlays.clear()

this._layers.clear()
this._warnedNonDeckLayers.clear()
this._refreshers.clear()
this._layerZIndices.clear()
this._eventListeners.clear()
this._featureClickHandler = null
Expand Down Expand Up @@ -918,17 +945,25 @@ export class DeckGLAdapter implements IMapEngine<Deck, Layer, PickingInfo> {
const id = resolveLayerId(layer)
this._layers.delete(id)
this._layerZIndices.delete(id)
this._refreshers.delete(id)
this._syncLayers()
}

/**
* Clone the existing layer with overridden props. deck.gl detects the same
* `id` and updates GPU resources incrementally.
* Clone the existing layer with overridden props. deck.gl detects the
* same `id` and updates GPU resources incrementally.
*
* A held value that is not a deck.gl layer is declined, with a warning,
* rather than applied: this engine should only ever hold layers it
* built, but layer types it has no builder for fall through to being
* built with Leaflet instead. The warning exists so that mis-construction
* surfaces, rather than presenting as an update that quietly did nothing.
*/
updateLayer(layer: Layer | string, options: Partial<LayerOptions>): Layer {
const id = resolveLayerId(layer)
const existing = this._layers.get(id)
if (!existing) return existing as unknown as Layer
if (!this._holdsDeckLayer(id, existing)) return existing
const updated = existing.clone({
...(options.opacity !== undefined ? { opacity: options.opacity } : {}),
...(options.visible !== undefined ? { visible: options.visible } : {}),
Expand All @@ -939,6 +974,45 @@ export class DeckGLAdapter implements IMapEngine<Deck, Layer, PickingInfo> {
return updated
}

registerLayer(id: string, layer: Layer): void {
// For deck.gl, holding a layer is rendering it — there is no
// "registered but not on the map" state. Keyed by the caller's id
// rather than layer.id so the two can never drift apart.
this._layers.set(id, layer)
this._syncLayers()
}

setLayerRefresher(
id: string,
refresh: ((layer: Layer, ctx: RefreshContext) => Layer | void) | null
): void {
if (refresh == null) this._refreshers.delete(id)
else this._refreshers.set(id, refresh)
}

refreshLayer(id: string, ctx: RefreshContext = {}): boolean {
const existing = this._layers.get(id)
if (!existing) return false

// No fallback: how a layer recomputes itself is layer-kind knowledge,
// and this adapter has none. The module that owns the kind registers a
// refresher at creation (see Map_.makeTileLayer); a held layer without
// one has no way to refresh, exactly as in the Leaflet adapter.
const refresh = this._refreshers.get(id)
if (!refresh) return false

const next = refresh(existing, {
url: ctx.url,
tileOptions: ctx.tileOptions,
force: ctx.force,
})

// A refresher with nothing to apply returns nothing; keep what we hold.
if (next) this._layers.set(id, next)
this._syncLayers()
return true
}

/**
* Assign a logical z-index. deck.gl renders layers in array order (index 0 = bottom),
* so this re-sorts the internal map by ascending z-index.
Expand Down Expand Up @@ -979,20 +1053,28 @@ export class DeckGLAdapter implements IMapEngine<Deck, Layer, PickingInfo> {
}

/**
* Set a layer's opacity and return the instance carrying it. deck.gl layers
* are immutable, so the caller must replace its reference with the result.
* Set a layer's opacity. deck.gl layers are immutable, so this replaces
* the instance the engine holds via {@link updateLayer}, which re-syncs
* the render list.
*
* A layer on the map goes through {@link updateLayer}, which re-syncs the
* render list. One that is off the map is cloned instead, so it comes back
* at the requested opacity when it is added. A reference that is neither
* on the map nor a clonable layer instance (an unknown id, or a registry
* value that never became a layer) yields no replacement.
* A no-op when the engine doesn't hold `id`, or holds a native Leaflet
* layer (MMGIS still builds `data`, `image`, `video` and `velocity`
* layers with Leaflet under this engine, and those carry no `id` to be
* found by). Either way the caller has already written
* `L_.layers.opacity[name]`, which layer creation reads, so the opacity
* is picked up next time the layer is built or re-added.
*
* `options.fillOpacity` is accepted but not applied separately — see
* {@link IMapEngine.setLayerOpacity}.
*/
setLayerOpacity(layer: Layer | string, opacity: number): Layer | undefined {
setLayerOpacity(
layer: Layer | string,
opacity: number,
options?: { fillOpacity?: number }
): void {
const id = resolveLayerId(layer)
if (this._layers.has(id)) return this.updateLayer(id, { opacity })
if (typeof (layer as Layer)?.clone !== 'function') return undefined
return (layer as Layer).clone({ opacity }) as Layer
const existing = this._layers.get(id)
if (this._holdsDeckLayer(id, existing)) this.updateLayer(id, { opacity })
}

/**
Expand Down
Loading
Loading