Feature/categorical rasters - #169
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Style categorical rasters by class instead of a color ramp
Branch:
feature/categorical-rasters→ basemain4 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
1sits halfway between0and2, and that the distance from0to1means the samething as
1to2. 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, currentlyrendered 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
matchon band 1 instead of aninterpolate: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 fromthe class label or, failing that, its raw value.
Source shape:
Three decisions worth reviewing
normalize: falseis mandatory, not cosmetic. With normalization on, band 1 carriesOpenLayers' 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, whichblends 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 stopsfiring 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_belowbecomes the way to dropone — 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. ButNumber("")is0, and Addclass creates rows with
value: ""— so a freshly added, unfilled row silently became class0, shadowing a real class 0 or coloring the background. Blank is now rejected beforeNumber(), and the predicate is exported asisUsableClasssoMapLayerandModuleLoadershare one definition rather than each keeping a copy that could drift.
Smaller things
transparencyGuards()used by boththe ramp and class builders, so the two cannot diverge on transparency semantics.
ColorPickerPopOvergains ahideLabelprop: the class table already has a "Color" columnheader, so
Class 1:beside every swatch was repetition. The label still supplies theaccessible name and tooltip. Existing callers are unaffected (defaults to
false).resolve skips range fitting and the
.aux.xmlsidecar request for these layers. It stillreads the header to settle nodata.
Testing
and fail for the right reason. Covered: the blank-value filter,
normalize: false,interpolate: false, hiding the ramp picker, andhideLabel.getAllByRole("radio")[1], which only worked by DOM order —RampPickeris itself aradiogroup. Now scoped with
within()to the mode group.Try it
Categorical, classes at
0/1/2, andmask_below = 0to drop the 86% backgroundclass. Its statistics live only in a
.aux.xmlsidecar, which categorical mode does not needat all — a useful contrast with the continuous path.
Not covered by tests: that the
matchexpression paints the right colors in a live WebGLcontext, 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 tothe 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
73fb16097bc7262b55c5b16581ad50a0