Skip to content

Commit 8aa5845

Browse files
authored
feat(legend): derive discrete swatch legends from a match recolor (#334) (#339)
1 parent 1de6121 commit 8aa5845

5 files changed

Lines changed: 342 additions & 22 deletions

File tree

app/legend-helpers.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,62 @@ export function primaryColorValue(paint) {
3131
return undefined;
3232
}
3333

34+
/**
35+
* Derive a discrete legend (one swatch per category) from a vector layer's
36+
* paint, by parsing a data-driven `match` color expression.
37+
*
38+
* The categorical counterpart to {@link deriveContinuousLegend}. An agent that
39+
* codes categories as integers in SQL and recolors with `match` produces a map
40+
* whose colors are discrete, and a continuous colorbar over the code range
41+
* describes it wrongly (#334). The value→color pairs are already in the paint;
42+
* this reads them back out so the legend mirrors the map.
43+
*
44+
* Only `match` qualifies. `step` is a numeric ramp binned into classes and is
45+
* already handled as continuous, and `case` carries arbitrary predicates with
46+
* no value to label a swatch with.
47+
*
48+
* @param {Object} paint - A MapLibre paint object.
49+
* @returns {{ classes: Array<{ value: *, color: string }> } | null}
50+
* One entry per `match` label, in expression order. Returns null when the
51+
* primary color is not a flat `match` over scalar labels — including the
52+
* `case`-wrapped per-resolution expression hex layers register with, whose
53+
* `match` arms are nested expressions rather than colors.
54+
*/
55+
export function deriveCategoricalLegend(paint) {
56+
if (!paint || typeof paint !== 'object') return null;
57+
58+
let expr = null;
59+
for (const key of COLOR_PAINT_KEYS) {
60+
if (Array.isArray(paint[key])) { expr = paint[key]; break; }
61+
}
62+
if (!expr || expr[0] !== 'match') return null;
63+
64+
// ["match", <input>, label0, color0, label1, color1, ..., <fallback>]
65+
// Pairs start at index 2; the trailing lone element is the fallback color,
66+
// which the loop bound excludes. The fallback gets no swatch on purpose —
67+
// it stands for "everything not enumerated", which commonly matches no
68+
// feature at all, and a legend row for it would claim a category the map
69+
// may not contain.
70+
const classes = [];
71+
const isScalar = v => typeof v === 'number' || typeof v === 'string';
72+
for (let i = 2; i + 1 < expr.length; i += 2) {
73+
const value = expr[i];
74+
const color = expr[i + 1];
75+
// Bail on the whole expression rather than skipping arms: a partial
76+
// swatch list is a legend that silently omits colors that are on screen.
77+
if (typeof color !== 'string') return null;
78+
// A label may be one value or an array of values sharing a color.
79+
if (Array.isArray(value) ? value.length > 0 && value.every(isScalar) : isScalar(value)) {
80+
classes.push({ value, color });
81+
} else {
82+
return null;
83+
}
84+
}
85+
if (classes.length === 0) return null;
86+
87+
return { classes };
88+
}
89+
3490
/**
3591
* Derive a continuous legend (gradient + value range) from a vector layer's
3692
* paint, by parsing a data-driven `interpolate` or `step` color expression.

app/map-manager.js

Lines changed: 76 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
*/
1414

1515
import { extractHashFromUrl, buildFillColorExpression, buildFlatFillColorExpression, rewriteValueColumn, PALETTES, buildHeightExpression, buildFlatHeightExpression, defaultExtrusionMaxHeight } from './hex-layer-helpers.js';
16-
import { deriveContinuousLegend, primaryColorValue } from './legend-helpers.js';
16+
import { deriveCategoricalLegend, deriveContinuousLegend, primaryColorValue } from './legend-helpers.js';
1717

1818
const BASEMAPS = {
1919
natgeo: {
@@ -1758,20 +1758,30 @@ export class MapManager {
17581758
}
17591759

17601760
/**
1761-
* Give a vector layer restyled into a graduated choropleth a colorbar even
1762-
* when its config never declared `legend_type` — the agent can build such a
1763-
* ramp with `set_style` on any layer, and without this it renders with
1764-
* nothing explaining it (#333). Flagged `legendTypeAuto` so resetStyle and
1765-
* switchVersion can undo it; layers whose config *does* declare a legend
1766-
* type are left alone, as are hex layers (`legendType: 'hex'`).
1761+
* Give a vector layer restyled into a choropleth a legend even when its
1762+
* config never declared `legend_type` — the agent can build one with
1763+
* `set_style` on any layer, and without this it renders with nothing
1764+
* explaining it (#333). A `match` recolor yields discrete swatches, a
1765+
* graduated ramp a colorbar (#334). Flagged `legendTypeAuto` so resetStyle
1766+
* and switchVersion can undo it; layers whose config *does* declare a
1767+
* legend type are left alone, as are hex layers (`legendType: 'hex'`) —
1768+
* for those the legend still follows the paint, via `_categoricalLegend`
1769+
* and `_hexLegend`, without the declared type being rewritten.
17671770
*/
17681771
_promoteLegendType(state) {
17691772
if (state.type !== 'vector') return;
17701773
if (state.legendType && !state.legendTypeAuto) return;
1771-
const derivable = this._colorPaintChanged(state)
1772-
&& !!deriveContinuousLegend(this._effectivePaint(state));
1773-
if (derivable) {
1774-
state.legendType = 'continuous';
1774+
1775+
let promoted = null;
1776+
if (this._colorPaintChanged(state)) {
1777+
const paint = this._effectivePaint(state);
1778+
// Mutually exclusive by operator; categorical first as the narrower match.
1779+
if (deriveCategoricalLegend(paint)) promoted = 'categorical';
1780+
else if (deriveContinuousLegend(paint)) promoted = 'continuous';
1781+
}
1782+
1783+
if (promoted) {
1784+
state.legendType = promoted;
17751785
state.legendTypeAuto = true;
17761786
} else if (state.legendTypeAuto) {
17771787
state.legendType = null;
@@ -1820,17 +1830,60 @@ export class MapManager {
18201830

18211831
/**
18221832
* Whether a layer contributes a legend entry: continuous rasters (colorbar),
1823-
* any layer with a categorical class list, and continuous vector layers
1824-
* (graduated choropleths) whose colorbar can be sourced from config or
1825-
* derived from their paint expression (#258).
1833+
* any layer resolving to a categorical class list (config or derived from a
1834+
* `match` recolor, #334), and continuous vector layers (graduated
1835+
* choropleths) whose colorbar can be sourced from config or derived from
1836+
* their paint expression (#258).
1837+
*
1838+
* The categorical check runs before the type-gated ones because a `match`
1839+
* restyle can turn *any* vector layer discrete — including one whose config
1840+
* declared `continuous` or which is a hex layer.
18261841
*/
18271842
_hasLegend(state) {
18281843
return state.type === 'raster'
1829-
|| (state.legendType === 'categorical' && state.legendClasses?.length > 0)
1844+
|| !!this._categoricalLegend(state)
18301845
|| (state.legendType === 'continuous' && !!this._continuousVectorLegend(state))
18311846
|| (state.legendType === 'hex' && !!this._hexLegend(state));
18321847
}
18331848

1849+
/**
1850+
* Resolve a layer's discrete swatch rows, or null when it isn't a
1851+
* categorical legend.
1852+
*
1853+
* Once a restyle has replaced the color expression, a `match` in the new
1854+
* paint is the only truthful source: config `legend_classes` described the
1855+
* colors the layer shipped with, and a restyle that recolors past them
1856+
* leaves them describing nothing on screen (#334). So a changed paint means
1857+
* derived-or-nothing — config classes are never shown over a recolor.
1858+
*
1859+
* @returns {{ classes: Array<Object>, derived: boolean } | null} `classes`
1860+
* are in the shape `_showLegend` renders (`name` + `color-hint`).
1861+
*/
1862+
_categoricalLegend(state) {
1863+
// Derivation is vector-only — a raster's colors come from its TiTiler
1864+
// colormap, never a paint expression — so a categorical raster always
1865+
// takes the config path below (STAC `classification:classes`).
1866+
if (state.type === 'vector' && this._colorPaintChanged(state)) {
1867+
const derived = deriveCategoricalLegend(this._effectivePaint(state));
1868+
if (!derived) return null;
1869+
const classes = derived.classes.map(c => ({
1870+
value: c.value,
1871+
// No name exists to show: the tiles carry the code, and what it
1872+
// means lives in the SQL that produced it. Label with the value
1873+
// itself — honest and discrete, and `set_legend` can name it.
1874+
name: Array.isArray(c.value)
1875+
? c.value.map(v => this._fmtLegendValue(v)).join(', ')
1876+
: this._fmtLegendValue(c.value),
1877+
'color-hint': c.color,
1878+
}));
1879+
return { classes, derived: true };
1880+
}
1881+
1882+
return (state.legendType === 'categorical' && state.legendClasses?.length > 0)
1883+
? { classes: state.legendClasses, derived: false }
1884+
: null;
1885+
}
1886+
18341887
/**
18351888
* Determine which H3 resolution a hex layer is currently rendering, so its
18361889
* legend can show the matching value domain. The server pyramid serves one
@@ -2030,12 +2083,16 @@ export class MapManager {
20302083
const item = document.createElement('div');
20312084
item.className = 'legend-section';
20322085

2086+
const categorical = this._categoricalLegend(state);
2087+
20332088
// A single-class categorical layer would render a redundant heading plus
20342089
// one identically-labelled swatch row (the class label usually restates
20352090
// the display name). Drop the per-layer heading in that case — the lone
20362091
// swatch row (and, when grouped, the group heading) already labels it (#328).
2037-
const singleCategorical = state.legendType === 'categorical'
2038-
&& state.legendClasses?.length === 1;
2092+
// Only for config classes: a derived row is labelled with a bare value,
2093+
// which restates nothing, so its layer still needs its heading (#334).
2094+
const singleCategorical = categorical && !categorical.derived
2095+
&& categorical.classes.length === 1;
20392096

20402097
// Display name, class names, and color hints come from STAC metadata
20412098
// (untrusted) — build the legend via textContent and validate colors
@@ -2050,8 +2107,8 @@ export class MapManager {
20502107
? this._continuousVectorLegend(state)
20512108
: null;
20522109

2053-
if (state.legendType === 'categorical' && state.legendClasses?.length) {
2054-
for (const cls of state.legendClasses) {
2110+
if (categorical) {
2111+
for (const cls of categorical.classes) {
20552112
const row = document.createElement('div');
20562113
row.className = 'legend-item';
20572114
const swatch = document.createElement('span');

docs/guide/configuration.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,18 @@ A vector layer styled with a graduated `default_style` — an `interpolate` or `
126126

127127
Use `legend_range` and/or `legend_gradient` only to override the derived values (e.g. when the paint expression doesn't cleanly map to the labels you want, or the color stops aren't plain hex). If neither config nor a parseable color expression is present, the layer shows no legend.
128128

129+
### Legends follow runtime restyles
130+
131+
The config above describes the layer as it loads. The agent can recolor any layer at runtime with `set_style`, and the legend follows the paint that is actually on the map rather than the paint the layer was registered with:
132+
133+
- An `interpolate` or `step` recolor renders a **colorbar** over the new stops. This overrides `legend_range` / `legend_gradient`, which described the original ramp.
134+
- A `match` recolor renders **discrete swatches**, one per match arm, using the colors from the expression. This applies even to a layer configured as `continuous` or to a dynamic hex layer, and it overrides `legend_classes`.
135+
- A recolor with no describable structure (a flat color, a `case` expression) removes the legend rather than leaving a stale one behind.
136+
137+
A layer with no `legend_type` at all gains a legend when the agent recolors it into a choropleth, and `reset_style` removes it again along with the restyle.
138+
139+
Swatches derived from a `match` are labelled with the matched value itself (`1`, `2`, …), because the vector tiles carry only the code — what the code *means* usually lives in the SQL that produced the layer. Author-supplied `legend_classes` labels are used whenever the layer has not been recolored past them.
140+
129141
## Asset config — raster (COG)
130142

131143
| Field | Type | Description |

test/legend-helpers.test.js

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { deriveContinuousLegend, primaryColorValue } from '../app/legend-helpers.js';
2+
import { deriveCategoricalLegend, deriveContinuousLegend, primaryColorValue } from '../app/legend-helpers.js';
33

44
describe('deriveContinuousLegend', () => {
55
it('derives gradient + range from an interpolate fill-color expression', () => {
@@ -97,3 +97,76 @@ describe('primaryColorValue', () => {
9797
expect(primaryColorValue(null)).toBeUndefined();
9898
});
9999
});
100+
101+
describe('deriveCategoricalLegend', () => {
102+
it('derives value+color pairs from a match fill-color expression', () => {
103+
// The expression from the session that motivated #334: taxon codes 1-5.
104+
const paint = {
105+
'fill-color': ['match', ['get', 'dominant_taxon'],
106+
1, '#1f77b4', 2, '#ff7f0e', 3, '#2ca02c', 4, '#9467bd', 5, '#8c564b', '#cccccc'],
107+
'fill-opacity': 0.7,
108+
};
109+
expect(deriveCategoricalLegend(paint)).toEqual({
110+
classes: [
111+
{ value: 1, color: '#1f77b4' },
112+
{ value: 2, color: '#ff7f0e' },
113+
{ value: 3, color: '#2ca02c' },
114+
{ value: 4, color: '#9467bd' },
115+
{ value: 5, color: '#8c564b' },
116+
],
117+
});
118+
});
119+
120+
it('excludes the trailing fallback color', () => {
121+
const paint = { 'fill-color': ['match', ['get', 'g'], 'a', '#111', '#fallback'] };
122+
expect(deriveCategoricalLegend(paint).classes).toEqual([{ value: 'a', color: '#111' }]);
123+
});
124+
125+
it('keeps a multi-value match arm as one entry', () => {
126+
const paint = { 'fill-color': ['match', ['get', 'g'], [1, 2], '#111', 3, '#222', '#ccc'] };
127+
expect(deriveCategoricalLegend(paint).classes).toEqual([
128+
{ value: [1, 2], color: '#111' },
129+
{ value: 3, color: '#222' },
130+
]);
131+
});
132+
133+
it('reads other layer-type color keys', () => {
134+
expect(deriveCategoricalLegend({ 'circle-color': ['match', ['get', 'g'], 1, '#111', '#ccc'] }).classes)
135+
.toEqual([{ value: 1, color: '#111' }]);
136+
expect(deriveCategoricalLegend({ 'fill-extrusion-color': ['match', ['get', 'g'], 1, '#111', '#ccc'] }).classes)
137+
.toEqual([{ value: 1, color: '#111' }]);
138+
});
139+
140+
it('returns null for expressions that are not a flat match', () => {
141+
// step and interpolate are continuous ramps, handled by the other helper.
142+
expect(deriveCategoricalLegend({ 'fill-color': ['step', ['get', 'v'], '#a', 10, '#b'] })).toBeNull();
143+
expect(deriveCategoricalLegend({ 'fill-color': ['interpolate', ['linear'], ['get', 'v'], 0, '#a', 1, '#b'] })).toBeNull();
144+
expect(deriveCategoricalLegend({ 'fill-color': '#2E7D32' })).toBeNull();
145+
expect(deriveCategoricalLegend({ 'fill-opacity': 0.5 })).toBeNull();
146+
expect(deriveCategoricalLegend(null)).toBeNull();
147+
});
148+
149+
it('returns null for the case-wrapped per-resolution expression hex layers register with', () => {
150+
// Its `match` arms are nested interpolate expressions, not colors — deriving
151+
// from it would caption a hex layer with H3 resolutions as if categories.
152+
const ramp = ['interpolate', ['linear'], ['get', 'v'], 0, '#eee', 100, '#333'];
153+
const paint = {
154+
'fill-color': ['case', ['==', ['get', 'v'], null], 'rgba(0,0,0,0)',
155+
['match', ['get', 'res'], 5, ramp, 6, ramp, 'rgba(0,0,0,0)']],
156+
};
157+
expect(deriveCategoricalLegend(paint)).toBeNull();
158+
});
159+
160+
it('rejects the whole expression when any arm carries a non-color', () => {
161+
// A partial swatch list silently omits colors that are on screen.
162+
const paint = {
163+
'fill-color': ['match', ['get', 'g'],
164+
1, '#111', 2, ['interpolate', ['linear'], ['get', 'v'], 0, '#a', 1, '#b'], '#ccc'],
165+
};
166+
expect(deriveCategoricalLegend(paint)).toBeNull();
167+
});
168+
169+
it('returns null for a match with no arms', () => {
170+
expect(deriveCategoricalLegend({ 'fill-color': ['match', ['get', 'g'], '#ccc'] })).toBeNull();
171+
});
172+
});

0 commit comments

Comments
 (0)