Skip to content

Feature/categorical rasters - #169

Merged
ckrew merged 5 commits into
mainfrom
feature/categorical-rasters
Aug 15, 2026
Merged

Feature/categorical rasters#169
ckrew merged 5 commits into
mainfrom
feature/categorical-rasters

Conversation

@ckrew

@ckrew ckrew commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Style categorical rasters by class instead of a color ramp

Branch: feature/categorical-rasters → base main
4 commits · 12 files · +880 / −53 · 24 new tests · frontend only

Follow-up to #168, which deliberately left this out.


Why

Some rasters hold labels rather than magnitudes — land cover, hazard class, a
classification code. A color ramp is the wrong instrument for those: a gradient implies class
1 sits halfway between 0 and 2, and that the distance from 0 to 1 means the same
thing as 1 to 2. Neither is true of categories.

Until now a ramp was the only option for a raster layer. The driving case is
guatemala_land_use_clipped.tif, three classes over a value-attribute table, currently
rendered as a turbo gradient across 0–2.

What changed

The Style tab's raster section gains a Continuous / Categorical toggle.

Categorical replaces the ramp picker and Ramp Min/Max with a class table — value, color,
label
— and builds a match on band 1 instead of an interpolate:

["case",
  ["==", ["band", 2], 0],         [0,0,0,0],   // nodata
  ["<=", ["band", 1], maskValue], [0,0,0,0],   // mask_below, checked first
  ["match", ["band", 1], 0,"#aaa", 1,"#bbb", 2,"#ccc", "#999999"]]

New rows are seeded with a color sampled from the last selected ramp, so a usable style
exists before picking anything by hand. The ramp choice is remembered but not shown —
switching back to Continuous restores it.

Values matching no class take an Other values color, or render transparent when it is
unset. legend: "default" emits one swatch per class rather than a colorbar, labelled from
the class label or, failing that, its raw value.

Source shape:

"source": {
  "type": "GeoTIFF",
  "styleMode": "categorical",
  "classes": [
    { "value": "0", "color": "#e5e5e5", "label": "Background" },
    { "value": "1", "color": "#4c78a8", "label": "Residential" },
    { "value": "2", "color": "#e45756", "label": "Commercial" }
  ],
  "fallbackColor": "#999999",
  "rampName": "turbo",          // kept for the Continuous round trip
  "props": { "url": "...", "normalize": false, "interpolate": false }
}

Three decisions worth reviewing

normalize: false is mandatory, not cosmetic. With normalization on, band 1 carries
OpenLayers' 0–255 scaled bytes and would never equal a class value, so every pixel would take
the fallback. It is set at save time and at render time so it holds even when the
render-time resolve never runs.

interpolate: false — nearest-neighbor resampling. OL interpolates by default, which
blends both bands across a nodata boundary: band 1 lands between two class values and matches
nothing (taking the fallback color) while band 2 blends off 0, so the nodata guard stops
firing and the blended pixel is drawn. The result is a one-pixel fringe hugging every nodata
edge. That fringe pre-dated the fallback color — an unmatched blend simply rendered
transparent before — so setting a color revealed it rather than causing it. Interpolating
class labels is meaningless regardless. Visible consequence: class boundaries are now hard
and blocky at high zoom where interpolation used to soften them. Correct for categorical data,
but a change in character worth eyeballing.

Mask Below is checked before the class lookup. So a class at or below the threshold stays
hidden despite having a color. That ordering is deliberate: once a fallback color exists,
leaving a class out of the table no longer hides it, and mask_below becomes the way to drop
one — e.g. the 86% background class of the land-use raster.

A bug the tests caught

The first version of the "is this class usable" filter was
Number.isFinite(Number(entry.value)) && entry.color. But Number("") is 0, and Add
class
creates rows with value: "" — so a freshly added, unfilled row silently became class
0, shadowing a real class 0 or coloring the background. Blank is now rejected before
Number(), and the predicate is exported as isUsableClass so MapLayer and ModuleLoader
share one definition rather than each keeping a copy that could drift.

Smaller things

  • The nodata and mask guards are factored into a shared transparencyGuards() used by both
    the ramp and class builders, so the two cannot diverge on transparency semantics.
  • ColorPickerPopOver gains a hideLabel prop: the class table already has a "Color" column
    header, so Class 1: beside every swatch was repetition. The label still supplies the
    accessible name and tooltip. Existing callers are unaffected (defaults to false).
  • Categorical needs no statistics — the class values are the scale — so the render-time
    resolve skips range fitting and the .aux.xml sidecar request for these layers. It still
    reads the header to settle nodata.

Testing

  • Frontend 2,335 passed / 130 suites · ESLint and Prettier clean
  • No backend changes
  • Every behavioral change was mutation-checked — reverted to confirm the new tests fail,
    and fail for the right reason. Covered: the blank-value filter, normalize: false,
    interpolate: false, hiding the ramp picker, and hideLabel.
  • One test-quality fix along the way: the mode-toggle test picked
    getAllByRole("radio")[1], which only worked by DOM order — RampPicker is itself a
    radiogroup. Now scoped with within() to the mode group.

Try it

https://cog-s3-test-401506828094-us-east-1-an.s3.us-east-1.amazonaws.com/Guatemala_IBF/guatemala_land_use_clipped.tif

Categorical, classes at 0 / 1 / 2, and mask_below = 0 to drop the 86% background
class. Its statistics live only in a .aux.xml sidecar, which categorical mode does not need
at all — a useful contrast with the continuous path.

Not covered by tests: that the match expression paints the right colors in a live WebGL
context, and how hard-edged nearest-neighbor resampling reads at zoom. Both want a real
browser.

Not included

Seeding the class table from the raster's .vat.dbf. A value-attribute table sits next to
the land-use file and often carries class names as well as values, so a "Read classes from
raster" button could fill the table in one click. Deliberately deferred — the table is manual
for now.


Commits

73fb1609 Style categorical rasters by class instead of a ramp
7bc7262b Hide the color ramp picker in Categorical mode
55c5b165 Drop the categorical hint and the per-swatch class labels
81ad50a0 Resample categorical rasters with nearest neighbor

ckrew and others added 5 commits August 14, 2026 17:16
Some rasters hold labels rather than magnitudes -- land cover, hazard class -- and
a color ramp misrepresents them: a gradient implies class 1 sits halfway between
0 and 2. Until now a ramp was the only option for a raster.

Add a Categorical mode to the Color Ramp section. Each class is a value, a color
and an optional label, and the style becomes a `match` on band 1 rather than an
`interpolate`. New rows are seeded with a color sampled from the selected ramp so
a usable style exists before anything is picked by hand, and the ramp choice is
kept so switching back does not lose it.

Values matching no class take an "Other values" color, or render transparent when
it is unset. The existing nodata and mask guards are shared with the ramp path and
still run first, which is how a class gets hidden once a fallback color means
leaving it out of the table is no longer enough.

`legend: "default"` emits one swatch per class rather than a colorbar, labelled
from the class label or its raw value.

Categorical needs no statistics -- the class values are the scale -- so the
render-time resolve skips range fitting and the sidecar request for these layers.
It does still read the header to settle nodata, and it forces `normalize: false`,
without which band 1 would carry OpenLayers' 0-255 scaled bytes and never match a
class value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A gradient has no meaning for discrete classes -- each class carries its own
color -- so offering a ramp to choose alongside the class table was noise. The
section heading follows the mode too, since "Color Ramp" misdescribes a class
table.

The selection is still held on the source, so switching back to Continuous
restores it, and it keeps seeding the color of each new class row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two bits of noise in the class table: an explanatory note under the table, and a
"Class 1:" caption beside every color swatch when the column header already says
what the swatch is.

ColorPickerPopOver gains a `hideLabel` prop for the second one, so the label
still names the control for assistive tech and the tooltip without being drawn.
Existing callers are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Setting an "Other values" color fringed every nodata boundary: cell edges
adjacent to nodata picked up the fallback color.

OpenLayers resamples with linear interpolation by default, which blends both
bands across that boundary. Band 1 lands between two class values and so matches
no class, taking the fallback color, while band 2 blends off 0 and the nodata
guard stops firing -- so the blended pixel is drawn rather than skipped. The
fringe was always there; a fallback color is simply what made it visible.

Interpolating class labels is meaningless anyway -- halfway between class 1 and
class 2 is not a class -- so categorical layers now resample with nearest
neighbor, at render time and at save time both.

Continuous ramps keep interpolating, where blending is wanted and an
intermediate value still lands somewhere sensible on the ramp.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Popup dereferences every entry it is handed, so a null inside a layer's query
result threw "Cannot read properties of null (reading 'layerName')". The hover
path already passed non-objects through unwrapped, then handed them straight to
the popup; the click path had the same gap one line before its own crash.

The throw came from a useEffect, after the React commit, so the uncaught error
landed wherever the event loop happened to be -- which is why it surfaced
against an unrelated hover-debounce test under load, and why it would not
reproduce in isolation.

A test existed that drove exactly this path and papered over the consequence: it
suppressed console.error, swallowed the uncaught error, and asserted the popup
had been positioned -- documenting the crash as expected behavior. It now
asserts the null is dropped instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ckrew
ckrew merged commit b79aaae into main Aug 15, 2026
2 checks passed
@ckrew
ckrew deleted the feature/categorical-rasters branch August 15, 2026 18:52
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.

1 participant