Skip to content

Render COG colormaps client-side - #353

Open
slesaad wants to merge 5 commits into
developmentfrom
fix/296-client-side-colormaps
Open

Render COG colormaps client-side#353
slesaad wants to merge 5 commits into
developmentfrom
fix/296-client-side-colormaps

Conversation

@slesaad

@slesaad slesaad commented Aug 27, 2026

Copy link
Copy Markdown
Member

Closes #296.

Why

The ramp picker asked a tile server both for the list of ramps and for every swatch's colours. Two consequences:

  • A layer rendered client-side could not offer a colormap at all without a tile server running — the one layer type that needs no server was the one blocked by its absence. On a deployment without TiTiler the control read "Color ramps are unavailable".
  • When a service did answer, the picker offered ramps that layer cannot paint. Picking one rendered the fallback ramp instead, silently, because normalizeColormapName resolves an unknown name to viridis.

What changed

localColormaps becomes the single source of ramps the app can paint itself. colormapLUT's name resolution moves there and it re-imports it, so the GPU lookup table is unchanged; what is new is being able to ask whether a ramp exists rather than only what it falls back to. Swatch and render now derive from one evaluator, so they cannot disagree.

A layer's ramps come from whatever paints it. cogCapabilitiesFor already worked out which renderer that is and dropped the answer; it now returns it, and the adapter hands the local table only to a layer this renderer paints — for which it is then the whole ramp vocabulary. A tile-server rendered layer is withheld it and keeps resolving both list and colours from its service, exactly as before.

So nothing changes for tile-server layers. The diff is: client-side layers stop needing a server.

The ramps reach the picker as data on CogData, alongside the existing titilerUrl, so LayerManager/lib keeps importing nothing but React and d3. Presence of the table is what says which renderer owns the layer, so that is decided in one place rather than carried as a separate flag through the UI.

Why not use the local ramps for tile-server layers too

An earlier revision did, on the reasoning that both sides derive from matplotlib. Measured against a live service they do agree — every continuous ramp within 1/255 per channel, balance and dark2 byte-identical, qualitative ramps matching band-for-band bar 2–4 boundary pixels of 256.

But agreeing today is not being the same source. A deployment can register a ramp under a standard name, and a version bump can move one; either way the swatch would misrepresent the tiles while looking correct. Keeping each layer's ramps sourced from its own renderer makes that class of drift impossible rather than merely unlikely.

Two fixes that fell out

  • Rescale inputs populate. Min/Max rendered blank on a client-side layer because the popover failed to initialise when the colormap request died. Fixing the request fixed the inputs.
  • Sampling matches the render. Qualitative ramps (Accent, Set1, the Paired family) were being emitted at their palette length, which a CSS gradient blends smoothly between — while both the GPU lookup table and a tiling service hold each colour for an equal run across 256 entries. All ramps now sample the lookup table's 256 points, and the spec asserts a swatch equals the LUT sample for sample.

Verification

93 test files / 1449 tests pass (up from 92 / 1425); tsc --noEmit clean; LayerManager/lib still imports only react, react-dom, d3.

Verified live against a deck.gl mission whose COG layer has no titilerUrl configured at all: legend swatch renders, the full ramp list renders with swatches, switching a ramp applies instantly, and the network panel records zero /colorMaps requests — only the COG's own range GETs.

🤖 Generated with Claude Code

The GPU render path and the legend UI each needed to answer "what colour
is this ramp at this value", and only the render path could. The UI asked
a tile server, so a swatch could advertise colours the render would never
produce.

Put both on one evaluator. colormapLUT's name resolution moves here
unchanged and it re-imports it, so the LUT behaviour is identical; what
is new is the ability to ask whether a ramp exists at all, rather than
only what it falls back to.

A qualitative ramp emits exactly its own palette: evaluate_cmap buckets
one into colors.length bands, so sampling at the interpolated resolution
would repeat entries at uneven widths and paint the wrong bands.
cogCapabilitiesFor already worked out whether the client-side renderer
handles a layer, then dropped the answer on the floor. Return it.

A picker offering ramps has to know: the client-side renderer paints only
the ramps bundled with the app, while a tile server may serve ramps of
its own. Without this the two are indistinguishable and a picker can
offer a ramp that silently renders as the fallback.
The ramp picker asked a tile server for the list and for every swatch,
so a client-side rendered layer could not offer a colormap without one —
and when the service answered, it offered ramps that layer cannot paint,
which rendered as the fallback instead of the ramp that was picked.

Ramps the app can paint itself now resolve locally, and the list follows
the renderer: a client-side layer is offered exactly those, a tile-server
layer is offered them plus whatever its service registers. The ramps
travel to the picker as data, so lib keeps importing nothing but React.

Local ramps are also the floor — an unreachable service now degrades the
list rather than emptying it.
The local ramps were resolving for tile-server layers too, on the
reasoning that both sides derive from matplotlib and agree. Measured
against a live service they do agree — within 1/255 per channel on every
continuous ramp, and byte-identical on some — but agreeing today is not
the same as being the same source. A service can define a ramp under a
standard name, and a version bump can move one; either way the swatch
would misrepresent the tiles while looking right.

So the local ramps now travel only for a layer this renderer paints, and
are then its whole vocabulary. A tile-server layer keeps asking its
service for both the list and the colours, exactly as before: that
service defines the ramps and paints the pixels a swatch stands for.

This drops the union list and the local floor with it. Presence of the
table now says which renderer owns the layer, so it is decided in one
place — the adapter — and the separate flag through the UI is gone.

Also fixes sampling. Qualitative ramps were emitted at their palette
length, which a CSS gradient blends smoothly between; both the GPU
lookup table and a tiling service instead hold each colour for an equal
run of 256. Sampling all ramps at the lookup table's 256 points makes a
swatch match the pixels it stands for, and the spec now asserts exactly
that.
@slesaad
slesaad force-pushed the fix/296-client-side-colormaps branch from 330b3e7 to d313c75 Compare August 28, 2026 16:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make the legend reflect what a client-side COG layer actually renders

1 participant