Skip to content

Commit bb393ee

Browse files
authored
feat(legend): group legend entries by layer group (#328) (#329)
* feat(legend): group legend entries by layer group (#328) The legend rendered one flat section per visible layer with no grouping, even though the layer panel already groups by the layer `group` property. This forced apps like utah-public-lands to collapse per-era boundary layers into a single match-colored layer (losing per-era toggling) just to get a grouped legend. Cluster visible legend sections under a lazily-created .legend-group heading matching state.group, mirroring generateControls. Ungrouped layers append flat to #legend-content as before (backward compatible), and a group heading is hidden once all its members are hidden and restored when one returns. Keys off the existing, general `group` config field, so any app that groups its layers gets a matching grouped legend. * fix(legend): drop redundant heading for single-class categorical layers A per-era boundary layer is a single-class categorical whose class label restates the display name, so a grouped legend showed the name twice — once as the section <h4> heading and once as the swatch row label. Skip the per-layer heading when a categorical layer has exactly one class: the lone swatch row (and, when grouped, the group heading) already labels it.
1 parent 6f759a9 commit bb393ee

4 files changed

Lines changed: 205 additions & 5 deletions

File tree

app/map-manager.js

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export class MapManager {
6161
this._legendEl = null;
6262
this._legendContent = null;
6363
this._legendItems = new Map(); // layerId → DOM element
64+
this._legendGroups = new Map(); // groupName → group wrapper element (grouped legend sections)
6465
this._colormapCache = new Map(); // colormap name → CSS gradient string
6566
this._hexLegendRefs = new Map(); // layerId → { minSpan, maxSpan, resNote } for zoom-reactive relabeling
6667
this._hexLegendReactive = false; // moveend handler registered lazily on first hex legend
@@ -1867,6 +1868,34 @@ export class MapManager {
18671868
}
18681869
}
18691870

1871+
/**
1872+
* Resolve the element a layer's legend section should append into, mirroring
1873+
* the layer panel's grouping (`generateControls`). Layers with a `group` are
1874+
* clustered under one lazily-created `.legend-group` wrapper (heading = group
1875+
* name); ungrouped layers append straight into `#legend-content` (flat,
1876+
* backward compatible). Also un-hides the wrapper so a re-shown member brings
1877+
* its group heading back.
1878+
* @param {Object} state - entry from this.layers
1879+
* @returns {HTMLElement}
1880+
*/
1881+
_legendParentFor(state) {
1882+
if (!state.group) return this._legendContent;
1883+
let wrapper = this._legendGroups.get(state.group);
1884+
if (!wrapper) {
1885+
wrapper = document.createElement('div');
1886+
wrapper.className = 'legend-group';
1887+
// Group name comes from config (untrusted) — textContent, never HTML.
1888+
const title = document.createElement('h4');
1889+
title.className = 'legend-group-title';
1890+
title.textContent = state.group;
1891+
wrapper.appendChild(title);
1892+
this._legendGroups.set(state.group, wrapper);
1893+
this._legendContent.appendChild(wrapper);
1894+
}
1895+
wrapper.style.display = '';
1896+
return wrapper;
1897+
}
1898+
18701899
async _showLegend(layerId) {
18711900
const state = this.layers.get(layerId);
18721901
if (!state) return;
@@ -1876,18 +1905,28 @@ export class MapManager {
18761905

18771906
if (this._legendItems.has(layerId)) {
18781907
this._legendItems.get(layerId).style.display = '';
1908+
if (state.group) this._legendParentFor(state); // re-show group wrapper
18791909
return;
18801910
}
18811911

18821912
const item = document.createElement('div');
18831913
item.className = 'legend-section';
18841914

1915+
// A single-class categorical layer would render a redundant heading plus
1916+
// one identically-labelled swatch row (the class label usually restates
1917+
// the display name). Drop the per-layer heading in that case — the lone
1918+
// swatch row (and, when grouped, the group heading) already labels it (#328).
1919+
const singleCategorical = state.legendType === 'categorical'
1920+
&& state.legendClasses?.length === 1;
1921+
18851922
// Display name, class names, and color hints come from STAC metadata
18861923
// (untrusted) — build the legend via textContent and validate colors
18871924
// before they reach a style attribute.
1888-
const heading = document.createElement('h4');
1889-
heading.textContent = state.displayName;
1890-
item.appendChild(heading);
1925+
if (!singleCategorical) {
1926+
const heading = document.createElement('h4');
1927+
heading.textContent = state.displayName;
1928+
item.appendChild(heading);
1929+
}
18911930

18921931
const continuousVector = state.legendType === 'continuous'
18931932
? this._continuousVectorLegend(state)
@@ -1972,13 +2011,23 @@ export class MapManager {
19722011
item.appendChild(labels);
19732012
}
19742013

1975-
this._legendContent.appendChild(item);
2014+
this._legendParentFor(state).appendChild(item);
19762015
this._legendItems.set(layerId, item);
19772016
}
19782017

19792018
_hideLegend(layerId) {
19802019
const item = this._legendItems.get(layerId);
19812020
if (item) item.style.display = 'none';
2021+
// Hide a group wrapper once all its member sections are hidden
2022+
const state = this.layers.get(layerId);
2023+
if (state?.group) {
2024+
const wrapper = this._legendGroups.get(state.group);
2025+
if (wrapper) {
2026+
const anyMemberVisible = [...wrapper.querySelectorAll('.legend-section')]
2027+
.some(el => el.style.display !== 'none');
2028+
wrapper.style.display = anyMemberVisible ? '' : 'none';
2029+
}
2030+
}
19822031
// Hide the whole panel when nothing is visible
19832032
if (this._legendEl) {
19842033
const anyVisible = [...this._legendItems.values()].some(el => el.style.display !== 'none');

app/style.css

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,43 @@ details.layer-group:not([open]) .layer-group-title::before {
437437
letter-spacing: 0.5px;
438438
}
439439

440+
/* Grouped legend sections: one heading over its member layers (mirrors the
441+
layer panel). The group title is the prominent heading; per-layer section
442+
headings inside become lighter, indented sub-headings. */
443+
.legend-group {
444+
margin-bottom: 15px;
445+
}
446+
447+
.legend-group:last-child {
448+
margin-bottom: 0;
449+
}
450+
451+
.legend-group-title {
452+
margin: 0 0 8px 0;
453+
font-size: 12px;
454+
font-weight: 600;
455+
color: #555;
456+
text-transform: uppercase;
457+
letter-spacing: 0.5px;
458+
}
459+
460+
.legend-group .legend-section {
461+
margin-bottom: 10px;
462+
padding-left: 8px;
463+
}
464+
465+
.legend-group .legend-section:last-child {
466+
margin-bottom: 0;
467+
}
468+
469+
.legend-group .legend-section h4 {
470+
font-size: 11px;
471+
font-weight: 500;
472+
color: #666;
473+
text-transform: none;
474+
letter-spacing: 0;
475+
}
476+
440477
.legend-colorbar {
441478
height: 12px;
442479
border-radius: 2px;
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// @vitest-environment jsdom
2+
import { describe, it, expect } from 'vitest';
3+
import { MapManager } from '../app/map-manager.js';
4+
5+
/**
6+
* Legend grouping (#328): visible legend sections cluster under a `.legend-group`
7+
* heading matching their layer `group`, mirroring the layer panel. Ungrouped
8+
* layers stay flat (backward compatible), and a group heading disappears once all
9+
* its members are hidden.
10+
*/
11+
12+
function createLegendManager(states) {
13+
const mm = Object.create(MapManager.prototype);
14+
mm.layers = new Map(Object.entries(states));
15+
mm._legendEl = document.createElement('div');
16+
mm._legendContent = document.createElement('div');
17+
mm._legendItems = new Map();
18+
mm._legendGroups = new Map();
19+
mm._ensureLegend = () => {};
20+
return mm;
21+
}
22+
23+
// Mirrors the utah-public-lands per-era boundary layers: a single-class
24+
// categorical whose class label restates the display name.
25+
const categorical = (displayName, group) => ({
26+
displayName,
27+
group,
28+
visible: true,
29+
legendType: 'categorical',
30+
legendClasses: [{ name: displayName, 'color-hint': 'ff0000' }],
31+
});
32+
33+
describe('MapManager legend grouping (#328)', () => {
34+
it('clusters same-group layers under one heading; ungrouped stays flat', async () => {
35+
const mm = createLegendManager({
36+
A: categorical('2021 restored', 'Bears Ears'),
37+
B: categorical('2026 proposed', 'Bears Ears'),
38+
C: categorical('Statewide layer', null),
39+
});
40+
await mm._showLegend('A');
41+
await mm._showLegend('B');
42+
await mm._showLegend('C');
43+
44+
// One group wrapper for "Bears Ears" holding both member sections.
45+
const groups = mm._legendContent.querySelectorAll('.legend-group');
46+
expect(groups).toHaveLength(1);
47+
expect(groups[0].querySelector('.legend-group-title').textContent).toBe('Bears Ears');
48+
expect(groups[0].querySelectorAll('.legend-section')).toHaveLength(2);
49+
50+
// Ungrouped section is a direct child of #legend-content, not inside a group.
51+
const flatSections = [...mm._legendContent.children].filter(el => el.classList.contains('legend-section'));
52+
expect(flatSections).toHaveLength(1);
53+
expect(flatSections[0].textContent).toContain('Statewide layer');
54+
});
55+
56+
it('collapses a single-class categorical to one row, no redundant heading (#328)', async () => {
57+
const mm = createLegendManager({
58+
A: categorical('2021 restored', 'Bears Ears'),
59+
});
60+
await mm._showLegend('A');
61+
const section = mm._legendContent.querySelector('.legend-section');
62+
// No per-layer <h4> heading (it would duplicate the sole class label)...
63+
expect(section.querySelector('h4')).toBeNull();
64+
// ...just one swatch row, labelled once.
65+
const rows = section.querySelectorAll('.legend-item');
66+
expect(rows).toHaveLength(1);
67+
expect(section.textContent.match(/2021 restored/g)).toHaveLength(1);
68+
});
69+
70+
it('renders separate wrappers for distinct groups', async () => {
71+
const mm = createLegendManager({
72+
A: categorical('2021 restored', 'Bears Ears'),
73+
B: categorical('2021 restored', 'Grand Staircase-Escalante'),
74+
});
75+
await mm._showLegend('A');
76+
await mm._showLegend('B');
77+
expect(mm._legendContent.querySelectorAll('.legend-group')).toHaveLength(2);
78+
expect(mm._legendGroups.size).toBe(2);
79+
});
80+
81+
it('hides the group heading only once every member is hidden, and restores it', async () => {
82+
const mm = createLegendManager({
83+
A: categorical('2021 restored', 'Bears Ears'),
84+
B: categorical('2026 proposed', 'Bears Ears'),
85+
});
86+
await mm._showLegend('A');
87+
await mm._showLegend('B');
88+
const wrapper = mm._legendGroups.get('Bears Ears');
89+
90+
mm._hideLegend('A');
91+
expect(wrapper.style.display).not.toBe('none'); // B still visible
92+
93+
mm._hideLegend('B');
94+
expect(wrapper.style.display).toBe('none'); // all hidden
95+
96+
await mm._showLegend('A'); // reuse path re-shows the wrapper
97+
expect(wrapper.style.display).not.toBe('none');
98+
});
99+
100+
it('escapes an untrusted group name (textContent, no HTML injection)', async () => {
101+
const mm = createLegendManager({
102+
A: categorical('2021 restored', '<img src=x onerror=alert(1)>'),
103+
});
104+
await mm._showLegend('A');
105+
const wrapper = mm._legendContent.querySelector('.legend-group');
106+
expect(wrapper.querySelector('img')).toBeNull();
107+
expect(wrapper.querySelector('.legend-group-title').textContent).toContain('<img');
108+
});
109+
});

test/map-manager.tooltip-legend.test.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,15 @@ describe('MapManager raster legend (SEC-3)', () => {
8888
});
8989

9090
it('renders HTML in the display name as inert text (both branches)', async () => {
91+
// Multi-class categorical keeps the per-layer <h4> heading (a single-class
92+
// categorical drops it — #328), so this still exercises heading escaping.
9193
const mm = createLegendManager({
9294
displayName: '<script>alert(1)</script>Cover',
9395
legendType: 'categorical',
94-
legendClasses: [{ name: 'Forest', 'color-hint': '00ff00' }],
96+
legendClasses: [
97+
{ name: 'Forest', 'color-hint': '00ff00' },
98+
{ name: 'Water', 'color-hint': '0000ff' },
99+
],
95100
});
96101
await mm._showLegend('A');
97102
expect(mm._legendContent.querySelector('script')).toBeNull();

0 commit comments

Comments
 (0)