Skip to content

Commit 4f39f3f

Browse files
authored
feat(legend): set_legend / reset_legend, and legend state in get_map_state (#334) (#340)
1 parent 8aa5845 commit 4f39f3f

6 files changed

Lines changed: 554 additions & 14 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Core library for map-based applications with LLM-powered data analysis. Interact
1414
- `main.js` — Bootstrap: loads config, initializes catalog → map → tools → agent → UI
1515
- `dataset-catalog.js` — Fetches STAC collections, builds unified records
1616
- `map-manager.js` — Creates MapLibre map, manages layers/filters/styles
17-
- `map-tools.js`9 local tools the LLM agent can call
17+
- `map-tools.js`the local tools the LLM agent can call (map control, styling, legends, geocoding)
1818
- `tool-registry.js` — Unified dispatch for local + remote (MCP) tools
1919
- `mcp-client.js` — MCP transport wrapper (connect once, lazy reconnect)
2020
- `agent.js` — LLM orchestration loop (agentic tool-use cycle)

app/map-manager.js

Lines changed: 175 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,6 +1088,131 @@ export class MapManager {
10881088
return { success: true, layer: layerId, displayName: state.displayName, tooltipFields: state.tooltipFields };
10891089
}
10901090

1091+
// ---- Legend ----
1092+
1093+
/**
1094+
* Set the parts of a legend that cannot be derived from the map: what the
1095+
* classes are called, what the layer is called, the unit on a colorbar's end
1096+
* values, and whether the section shows at all (#334).
1097+
*
1098+
* Colors and value ranges are deliberately not settable — those come from
1099+
* the paint, so a legend cannot be made to disagree with what's rendered
1100+
* (the staleness #333 removed). What the agent supplies is the semantics it
1101+
* alone knows: it wrote the SQL that turned "Amphibians" into the integer 1,
1102+
* and nothing on the client can recover that mapping from the tiles.
1103+
*
1104+
* @param {string} layerId
1105+
* @param {Object} opts
1106+
* @param {Object<string,string>} [opts.labels] - value → class name, for
1107+
* categorical legends. Merged with any labels already set.
1108+
* @param {string} [opts.title] - Layer heading, in the legend and the layer panel.
1109+
* @param {string} [opts.units] - Unit suffix on colorbar end values.
1110+
* @param {boolean} [opts.visible] - false suppresses the section.
1111+
* @returns {Object} Result, including the legend as it now reads.
1112+
*/
1113+
setLegend(layerId, opts = {}) {
1114+
const state = this.layers.get(layerId);
1115+
if (!state) return { success: false, error: `Unknown layer: ${layerId}` };
1116+
1117+
const { labels, title, units, visible } = opts;
1118+
1119+
if (labels !== undefined) {
1120+
if (labels === null || typeof labels !== 'object' || Array.isArray(labels)) {
1121+
return { success: false, error: 'labels must be an object mapping class values to names' };
1122+
}
1123+
const bad = Object.entries(labels).find(([, v]) => typeof v !== 'string');
1124+
if (bad) return { success: false, error: `label for "${bad[0]}" must be a string` };
1125+
// Merge: labelling classes one call at a time shouldn't drop earlier names.
1126+
state.legendLabels = { ...(state.legendLabels || {}), ...labels };
1127+
}
1128+
1129+
if (title !== undefined) {
1130+
if (typeof title !== 'string' || !title.trim()) {
1131+
return { success: false, error: 'title must be a non-empty string' };
1132+
}
1133+
// Capture once, so reset lands on the name boot rendered rather than
1134+
// on whatever an earlier setLegend left behind.
1135+
if (state.defaultDisplayName === undefined) state.defaultDisplayName = state.displayName;
1136+
state.displayName = title;
1137+
this._renameLayerControl(layerId, title);
1138+
}
1139+
1140+
if (units !== undefined) {
1141+
if (units !== null && typeof units !== 'string') {
1142+
return { success: false, error: 'units must be a string (or null to clear)' };
1143+
}
1144+
if (state.defaultLegendLabel === undefined) state.defaultLegendLabel = state.legendLabel ?? null;
1145+
state.legendLabel = units || null;
1146+
}
1147+
1148+
if (visible !== undefined) {
1149+
if (typeof visible !== 'boolean') return { success: false, error: 'visible must be a boolean' };
1150+
state.legendHidden = !visible;
1151+
}
1152+
1153+
this._refreshLegend(layerId);
1154+
return { success: true, layer: layerId, ...this.describeLegend(layerId) };
1155+
}
1156+
1157+
/**
1158+
* Drop agent-supplied legend labels, title, units, and suppression, leaving
1159+
* the legend as config declared it. Mirrors resetStyle / resetTooltip; does
1160+
* not touch paint, so a legend derived from a restyle stays derived.
1161+
*/
1162+
resetLegend(layerId) {
1163+
const state = this.layers.get(layerId);
1164+
if (!state) return { success: false, error: `Unknown layer: ${layerId}` };
1165+
1166+
state.legendLabels = null;
1167+
state.legendHidden = false;
1168+
if (state.defaultDisplayName !== undefined) {
1169+
state.displayName = state.defaultDisplayName;
1170+
this._renameLayerControl(layerId, state.displayName);
1171+
}
1172+
if (state.defaultLegendLabel !== undefined) state.legendLabel = state.defaultLegendLabel;
1173+
1174+
this._refreshLegend(layerId);
1175+
return { success: true, layer: layerId, ...this.describeLegend(layerId) };
1176+
}
1177+
1178+
/**
1179+
* How a layer's legend currently reads — the resolved type, whether it's on
1180+
* screen, and for a categorical legend the class values with their labels,
1181+
* so the agent can see which are still bare codes.
1182+
*/
1183+
describeLegend(layerId) {
1184+
const state = this.layers.get(layerId);
1185+
if (!state) return { error: `Unknown layer: ${layerId}` };
1186+
1187+
const type = this._resolvedLegendType(state);
1188+
const categorical = type === 'categorical' ? this._categoricalLegend(state) : null;
1189+
return {
1190+
displayName: state.displayName,
1191+
legend: {
1192+
type,
1193+
rendered: !!type && state.visible && !state.legendHidden,
1194+
...(state.legendLabel && { units: state.legendLabel }),
1195+
...(categorical && {
1196+
classes: categorical.classes.map(c => ({
1197+
...(c.value !== undefined && { value: c.value }),
1198+
label: c.name ?? `Class ${c.value}`,
1199+
})),
1200+
}),
1201+
},
1202+
};
1203+
}
1204+
1205+
/** Update a layer's row in the layer panel after a rename. */
1206+
_renameLayerControl(layerId, name) {
1207+
const safeId = layerId.replace(/\//g, '-');
1208+
const row = document.getElementById(`layer-item-${safeId}`);
1209+
if (!row) return;
1210+
const span = row.querySelector('label span');
1211+
if (span) span.textContent = name;
1212+
const removeBtn = row.querySelector('.layer-remove-btn');
1213+
if (removeBtn) removeBtn.setAttribute('aria-label', `Remove ${name}`);
1214+
}
1215+
10911216
// ---- Styling ----
10921217

10931218
/**
@@ -1228,6 +1353,10 @@ export class MapManager {
12281353
// Hex layers color by a single dynamic column; expose it so the
12291354
// agent styles against the real property, not a guess (#259).
12301355
...(state.valueColumn && { valueColumn: state.valueColumn }),
1356+
// What the legend currently says. Without this the agent is
1357+
// blind to the one part of the map it can't see, and reads a
1358+
// "the colors are wrong" complaint as a paint bug (#334).
1359+
legend: this.describeLegend(id).legend,
12311360
};
12321361
}
12331362
return { success: true, layers };
@@ -1840,10 +1969,26 @@ export class MapManager {
18401969
* declared `continuous` or which is a hex layer.
18411970
*/
18421971
_hasLegend(state) {
1843-
return state.type === 'raster'
1844-
|| !!this._categoricalLegend(state)
1845-
|| (state.legendType === 'continuous' && !!this._continuousVectorLegend(state))
1846-
|| (state.legendType === 'hex' && !!this._hexLegend(state));
1972+
return !state.legendHidden && !!this._resolvedLegendType(state);
1973+
}
1974+
1975+
/**
1976+
* What a layer's legend actually renders as *now* — as opposed to
1977+
* `state.legendType`, which is what config declared. They diverge whenever a
1978+
* restyle has recolored the layer past its declared type (a hex layer
1979+
* recolored with `match` resolves 'categorical'), and `get_map_state`
1980+
* reports this one so the agent can see the legend it's being asked about
1981+
* (#334).
1982+
*
1983+
* @returns {'raster'|'categorical'|'continuous'|'hex'|null} null when the
1984+
* layer contributes no legend.
1985+
*/
1986+
_resolvedLegendType(state) {
1987+
if (state.type === 'raster') return 'raster';
1988+
if (this._categoricalLegend(state)) return 'categorical';
1989+
if (state.legendType === 'continuous' && this._continuousVectorLegend(state)) return 'continuous';
1990+
if (state.legendType === 'hex' && this._hexLegend(state)) return 'hex';
1991+
return null;
18471992
}
18481993

18491994
/**
@@ -1868,20 +2013,37 @@ export class MapManager {
18682013
if (!derived) return null;
18692014
const classes = derived.classes.map(c => ({
18702015
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),
2016+
// Nothing on the client knows what the code means — the tiles
2017+
// carry the code, and its meaning lives in the SQL that produced
2018+
// it. Fall back to the value itself, which `set_legend` labels.
2019+
name: this._legendClassLabel(state, c.value)
2020+
?? (Array.isArray(c.value)
2021+
? c.value.map(v => this._fmtLegendValue(v)).join(', ')
2022+
: this._fmtLegendValue(c.value)),
18772023
'color-hint': c.color,
18782024
}));
18792025
return { classes, derived: true };
18802026
}
18812027

1882-
return (state.legendType === 'categorical' && state.legendClasses?.length > 0)
1883-
? { classes: state.legendClasses, derived: false }
1884-
: null;
2028+
if (!(state.legendType === 'categorical' && state.legendClasses?.length > 0)) return null;
2029+
// Author-declared classes, with any agent-supplied label taking priority
2030+
// (the agent is renaming what the user is looking at right now).
2031+
const classes = state.legendClasses.map(cls => {
2032+
const label = this._legendClassLabel(state, cls.value);
2033+
return label == null ? cls : { ...cls, name: label };
2034+
});
2035+
return { classes, derived: false };
2036+
}
2037+
2038+
/**
2039+
* An agent-supplied label for one legend class, or null. Values arrive from
2040+
* the model as strings even when the paint matches numbers, so compare
2041+
* stringified (and join array-valued match arms the way they render).
2042+
*/
2043+
_legendClassLabel(state, value) {
2044+
if (!state.legendLabels) return null;
2045+
const key = Array.isArray(value) ? value.join(',') : String(value);
2046+
return state.legendLabels[key] ?? null;
18852047
}
18862048

18872049
/**

app/map-tools.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,63 @@ ${pickLayerNudge}`,
305305
execute: (args) => JSON.stringify(mapManager.resetTooltip(args.layer_id)),
306306
},
307307

308+
{
309+
name: 'set_legend',
310+
description: `Label a layer's legend. Use after you build a map whose meaning isn't visible from the tiles — above all after styling a layer with a \`match\` expression over codes you invented in SQL.
311+
312+
The legend already mirrors the map's colors automatically: a \`match\` recolor renders one swatch per category, a gradient recolor renders a colorbar. What it cannot know is what your codes MEAN. If your SQL emitted \`CASE WHEN ... THEN 1\` for amphibians, the legend shows a swatch labelled "1" until you name it here.
313+
314+
So: whenever you post a color key in chat, call this instead — that key belongs on the map.
315+
316+
set_legend { "layer_id": "hex-1a66…",
317+
"labels": { "1": "Amphibians", "2": "Reptiles", "3": "Birds", "4": "Mammals", "5": "Plants" } }
318+
319+
Fields (all optional except layer_id):
320+
labels class value → name, for a categorical/swatch legend. Keys are the values in
321+
your \`match\` expression, as strings. Merged with labels already set.
322+
title heading for the layer, in both the legend and the layer panel.
323+
units unit shown after a colorbar's end values (e.g. "observations", "kg/ha").
324+
visible false hides this layer's legend section; true brings it back.
325+
326+
Colors and value ranges are NOT settable — they are read from the layer's paint, so the legend can never disagree with the map. To change the colors, call set_style; the legend follows.
327+
328+
Call get_map_state to see what a legend currently says (its \`legend.type\`, and \`legend.classes\` with the label each class shows now). Use reset_legend to return to the app's own labels.`,
329+
inputSchema: {
330+
type: 'object',
331+
properties: {
332+
layer_id: { type: 'string', description: 'Layer ID whose legend to label' },
333+
labels: {
334+
type: 'object',
335+
description: 'Class value → display name, e.g. {"1": "Amphibians", "2": "Reptiles"}. Keys are the values from the layer\'s `match` expression, as strings.',
336+
additionalProperties: { type: 'string' },
337+
},
338+
title: { type: 'string', description: 'Heading for this layer in the legend and layer panel' },
339+
units: { type: 'string', description: 'Unit suffix for a colorbar\'s end values, e.g. "species"' },
340+
visible: { type: 'boolean', description: 'false hides this layer\'s legend section' },
341+
},
342+
required: ['layer_id'],
343+
},
344+
execute: (args) => JSON.stringify(mapManager.setLegend(args.layer_id, {
345+
labels: args.labels,
346+
title: args.title,
347+
units: args.units,
348+
visible: args.visible,
349+
})),
350+
},
351+
352+
{
353+
name: 'reset_legend',
354+
description: `Discard legend labels, title, units, and hiding you set with set_legend, returning the layer to the app's own legend. Does not change the map's colors — a legend derived from a restyle stays derived. Use when the user asks to "reset the legend" or "put the labels back".`,
355+
inputSchema: {
356+
type: 'object',
357+
properties: {
358+
layer_id: { type: 'string', description: 'Layer ID whose legend to reset' },
359+
},
360+
required: ['layer_id'],
361+
},
362+
execute: (args) => JSON.stringify(mapManager.resetLegend(args.layer_id)),
363+
},
364+
308365
{
309366
name: 'set_style',
310367
description: `Update a layer's paint/style properties. Provide MapLibre paint properties — every property name carries a layer-type prefix (\`fill-\`, \`line-\`, \`circle-\`, \`raster-\`).
@@ -323,6 +380,8 @@ Examples:
323380
Data-driven gradient: { "fill-color": ["interpolate", ["linear"], ["get", "PROP"], 0, "#low", 100, "#high"] }
324381
Stepped: { "fill-color": ["step", ["get", "PROP"], "#c1", 10, "#c2", 50, "#c3"] }
325382
383+
After a \`match\` recolor over codes you defined in SQL, the legend shows one swatch per code labelled with the bare number — call set_legend with \`labels\` to name them, rather than writing the color key out in chat.
384+
326385
For dynamic hex layers (\`hex-…\` ids from add_hex_tile_layer), \`PROP\` is the layer's value column (the \`value_column\` you passed to add_hex_tile_layer, e.g. "species_richness") — NOT "count" unless that is literally the column. If unsure, call get_map_state to read the layer's \`valueColumn\`.
327386
328387
${pickLayerNudge}`,

docs/guide/configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ A layer with no `legend_type` at all gains a legend when the agent recolors it i
138138

139139
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.
140140

141+
The agent closes that last gap with `set_legend`, which names the classes (`{"1": "Amphibians"}`), retitles the layer, adds a colorbar unit, or hides the section. It cannot set colors or value ranges — those stay derived from the paint, so a legend can't be made to contradict the map. `reset_legend` restores the labels configured here. Nothing in this file needs to change to allow it; `set_legend` overrides `legend_label` and `legend_classes` names for the session only.
142+
141143
## Asset config — raster (COG)
142144

143145
| Field | Type | Description |

0 commit comments

Comments
 (0)