Skip to content

Commit caa31e3

Browse files
committed
feat(bullet): optional legend with per-item toggling
Every range, marker and marker line is now its own named series (marker lines as empty line series carrying a markLine), so the new Show legend control lists them all as always-visible entries whose clicks natively toggle the corresponding element. Show labels remains independent, and per-item tooltips are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 26340c0 commit caa31e3

4 files changed

Lines changed: 107 additions & 73 deletions

File tree

superset-frontend/plugins/plugin-chart-echarts/src/Bullet/controlPanel.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,20 @@ const config: ControlPanelConfig = {
128128
},
129129
},
130130
],
131+
[
132+
{
133+
name: 'show_legend',
134+
config: {
135+
type: 'CheckboxControl',
136+
label: t('Show legend'),
137+
renderTrigger: true,
138+
default: false,
139+
description: t(
140+
'List every range, marker and marker line in a legend; clicking an entry toggles it on the chart',
141+
),
142+
},
143+
},
144+
],
131145
['y_axis_format'],
132146
],
133147
},

superset-frontend/plugins/plugin-chart-echarts/src/Bullet/transformProps.ts

Lines changed: 71 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export default function transformProps(
4343
markerLineLabels: rawMarkerLineLabels,
4444
yAxisFormat,
4545
showLabels,
46+
showLegend,
4647
} = formData;
4748
const refs: Refs = {};
4849
const { onContextMenu, setDataMask = () => {} } = hooks;
@@ -105,13 +106,20 @@ export default function transformProps(
105106
(MEASURE_BAR_FRACTION / 2) * gridHeight + 12,
106107
);
107108

109+
const rangeName = (value: number, i: number) =>
110+
rangeLabels[i]
111+
? `${rangeLabels[i]}: \u2264 ${formatter(value)}`
112+
: `\u2264 ${formatter(value)}`;
113+
const markerName = (value: number, i: number, labels: string[]) =>
114+
labels[i] ? `${labels[i]}: ${formatter(value)}` : String(formatter(value));
115+
108116
const echartOptions: EChartsCoreOption = {
109117
animation: false,
110-
// A single-measure chart needs no legend; the default one collides
111-
// with the x-axis labels.
112-
legend: { show: false },
118+
// Optional legend listing every range, marker and line as a toggleable
119+
// named entry; each is its own series so ECharts handles the toggling.
120+
legend: { show: Boolean(showLegend), top: 0 },
113121
grid: {
114-
top: theme.sizeUnit * 2,
122+
top: showLegend ? theme.sizeUnit * 10 : theme.sizeUnit * 2,
115123
bottom: theme.sizeUnit * 6,
116124
left: theme.sizeUnit * 2,
117125
right: theme.sizeUnit * 2,
@@ -162,79 +170,84 @@ export default function transformProps(
162170
band.coords[1],
163171
]),
164172
},
165-
markLine: {
166-
silent: true,
167-
symbol: 'none',
168-
data: markerLines.map((value, index) => ({
169-
xAxis: value,
170-
label: {
171-
position: 'insideEndTop',
173+
},
174+
// Invisible hover targets at each range threshold: per-item tooltips
175+
// (markArea is not reliably hoverable), optional on-chart labels, and a
176+
// named legend entry each.
177+
...ranges.map((value, index) => ({
178+
name: rangeName(value, index),
179+
type: 'scatter',
180+
data: [
181+
{
182+
value: [value, 0],
183+
tooltip: {
172184
formatter: () =>
173-
markerLabelAt(index, markerLineLabels, markerLines),
174-
color: theme.colorTextSecondary,
185+
sanitizeHtml(
186+
`${rangeLabels[index] || ''} \u2264 <b>${formatter(value)}</b>`,
187+
),
175188
},
176-
lineStyle: {
177-
color: theme.colorText,
178-
type: 'solid',
179-
width: 2,
189+
label: {
190+
show: Boolean(showLabels) && Boolean(rangeLabels[index]),
191+
position: 'top',
192+
color: theme.colorTextSecondary,
193+
formatter: () => String(rangeLabels[index] || ''),
180194
},
181-
})),
182-
},
183-
},
184-
{
185-
// Invisible hover targets at each range threshold so ranges get their
186-
// own item tooltip (markArea itself is not reliably hoverable), and a
187-
// place to hang always-on labels.
188-
name: 'ranges',
189-
type: 'scatter',
190-
data: ranges.map((value, index) => ({
191-
value: [value, 0],
192-
tooltip: {
193-
formatter: () =>
194-
sanitizeHtml(
195-
`${rangeLabels[index] || ''} \u2264 <b>${formatter(value)}</b>`,
196-
),
197195
},
198-
label: {
199-
show: Boolean(showLabels) && Boolean(rangeLabels[index]),
200-
position: 'top',
201-
color: theme.colorTextSecondary,
202-
formatter: () => String(rangeLabels[index] || ''),
203-
},
204-
})),
196+
],
205197
symbol: 'rect',
206198
symbolSize: [14, 40],
207199
itemStyle: { color: 'transparent' },
208200
z: 15,
209-
},
210-
{
211-
name: 'markers',
201+
})),
202+
...markers.map((value, index) => ({
203+
name: markerName(value, index, markerLabels),
212204
type: 'scatter',
213-
data: markers.map((value, index) => ({
214-
value: [value, 0],
215-
tooltip: {
216-
formatter: () =>
217-
sanitizeHtml(
218-
`${markerLabelAt(index, markerLabels, markers)}: <b>${formatter(
219-
value,
220-
)}</b>`,
221-
),
205+
data: [
206+
{
207+
value: [value, 0],
208+
tooltip: {
209+
formatter: () =>
210+
sanitizeHtml(
211+
`${markerLabelAt(index, markerLabels, markers)}: <b>${formatter(value)}</b>`,
212+
),
213+
},
222214
},
223-
})),
215+
],
224216
symbol: 'triangle',
225217
symbolSize: 14,
218+
symbolOffset: [0, markerOffsetPx],
226219
label: {
227220
show: Boolean(showLabels),
228221
position: 'bottom',
229222
color: theme.colorTextSecondary,
230-
formatter: (params: { dataIndex: number }) =>
231-
String(markerLabelAt(params.dataIndex, markerLabels, markers)),
223+
formatter: () => String(markerLabelAt(index, markerLabels, markers)),
232224
},
233-
// Sit below the measure bar pointing up at the marked value
234-
symbolOffset: [0, markerOffsetPx],
235225
itemStyle: { color: theme.colorText },
236226
z: 20,
237-
},
227+
})),
228+
// Marker lines as their own empty series so each gets a legend entry
229+
// whose toggle shows and hides the line.
230+
...markerLines.map((value, index) => ({
231+
name: markerName(value, index, markerLineLabels),
232+
type: 'line',
233+
data: [],
234+
markLine: {
235+
silent: true,
236+
symbol: 'none',
237+
data: [
238+
{
239+
xAxis: value,
240+
label: {
241+
position: 'insideEndTop',
242+
formatter: () =>
243+
String(markerLabelAt(index, markerLineLabels, markerLines)),
244+
color: theme.colorTextSecondary,
245+
},
246+
lineStyle: { color: theme.colorText, type: 'solid', width: 2 },
247+
},
248+
],
249+
},
250+
})),
238251
],
239252
};
240253

superset-frontend/plugins/plugin-chart-echarts/src/Bullet/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export type EchartsBulletFormData = QueryFormData & {
3636
markerLineLabels?: string;
3737
yAxisFormat?: string;
3838
showLabels?: boolean;
39+
showLegend?: boolean;
3940
};
4041

4142
export interface EchartsBulletChartProps extends BaseChartProps<EchartsBulletFormData> {

superset-frontend/plugins/plugin-chart-echarts/test/Bullet/transformProps.test.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -51,27 +51,31 @@ test('renders the measure bar with markers, marker lines and range bands', () =>
5151
expect(series[0].type).toBe('bar');
5252
expect(series[0].data).toEqual([120]);
5353

54-
// marker lines on the bar series
55-
expect(series[0].markLine.data).toHaveLength(1);
56-
expect(series[0].markLine.data[0].xAxis).toBe(250);
54+
// marker lines are their own named series so the legend can toggle them
55+
const lineSeries = series.filter((x: any) => x.type === 'line');
56+
expect(lineSeries).toHaveLength(1);
57+
expect(lineSeries[0].markLine.data[0].xAxis).toBe(250);
5758

5859
// nested range bands, largest drawn first
5960
const bandEnds = series[0].markArea.data.map((d: any) => d[1].xAxis);
6061
expect(bandEnds).toEqual([300, 200, 100]);
6162

62-
// triangle markers
63-
expect(series[2].type).toBe('scatter');
64-
expect(series[2].data[0].value).toEqual([150, 0]);
63+
// triangle markers, one named series each
64+
const markerSeries = series.filter((x: any) => x.symbol === 'triangle');
65+
expect(markerSeries).toHaveLength(1);
66+
expect(markerSeries[0].data[0].value).toEqual([150, 0]);
6567

6668
// axis spans all values
6769
expect((echartOptions as any).xAxis.max).toBe(300);
6870

69-
// markers sit below the bar (pixel offset); no legend on this chart
70-
expect(series[2].symbolOffset[1]).toBeGreaterThan(0);
71+
// markers sit below the bar (pixel offset); legend off unless enabled
72+
const marker = series.find((x: any) => x.symbol === 'triangle');
73+
expect(marker.symbolOffset[1]).toBeGreaterThan(0);
7174
expect((echartOptions as any).legend.show).toBe(false);
7275

73-
// range thresholds get their own hover targets carrying the labels
74-
const rangeTips = series[1].data.map((d: any) => d.tooltip.formatter());
76+
// range thresholds get their own named hover-target series
77+
const rangeSeries = series.filter((x: any) => x.symbol === 'rect');
78+
const rangeTips = rangeSeries.map((x: any) => x.data[0].tooltip.formatter());
7579
expect(rangeTips.join(' ')).toContain('low');
7680
expect(rangeTips.join(' ')).toContain('high');
7781
});
@@ -91,7 +95,7 @@ test('defaults the band to 110% of the measure when no ranges are set', () => {
9195
expect(series[0].data).toEqual([120]);
9296
const bandEnds = series[0].markArea.data.map((d: any) => d[1].xAxis);
9397
expect(bandEnds).toEqual([120 * 1.1]);
94-
expect(series[2].data).toHaveLength(0);
98+
expect(series.filter((x: any) => x.symbol === 'triangle')).toHaveLength(0);
9599
});
96100

97101
test('keeps distinct labels for duplicate marker and marker-line values', () => {
@@ -105,12 +109,14 @@ test('keeps distinct labels for duplicate marker and marker-line values', () =>
105109
);
106110
const { series } = echartOptions as any;
107111

108-
const markLineLabels = series[0].markLine.data.map((d: any) =>
109-
d.label.formatter(),
110-
);
112+
const markLineLabels = series
113+
.filter((x: any) => x.type === 'line')
114+
.map((x: any) => x.markLine.data[0].label.formatter());
111115
expect(markLineLabels).toEqual(['stretch', 'ceiling']);
112116

113-
const markerTooltips = series[2].data.map((d: any) => d.tooltip.formatter());
117+
const markerTooltips = series
118+
.filter((x: any) => x.symbol === 'triangle')
119+
.map((x: any) => x.data[0].tooltip.formatter());
114120
expect(markerTooltips[0]).toContain('goal');
115121
expect(markerTooltips[1]).toContain('forecast');
116122
});

0 commit comments

Comments
 (0)