Skip to content

Commit 38d59fb

Browse files
committed
feat(bullet): per-item tooltips, range hover targets, show-labels mode
The combined axis tooltip is replaced with per-item hovers: the bar shows the measure, markers show their own label and value, and each range threshold gets an invisible hover target carrying its label (markArea is not reliably hoverable). A Show labels control prints marker and range labels directly on the chart and disables tooltips entirely. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 684a97e commit 38d59fb

4 files changed

Lines changed: 66 additions & 30 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
@@ -114,6 +114,20 @@ const config: ControlPanelConfig = {
114114
},
115115
},
116116
],
117+
[
118+
{
119+
name: 'show_labels',
120+
config: {
121+
type: 'CheckboxControl',
122+
label: t('Show labels'),
123+
renderTrigger: true,
124+
default: false,
125+
description: t(
126+
'Print range and marker labels on the chart instead of showing tooltips on hover',
127+
),
128+
},
129+
},
130+
],
117131
['y_axis_format'],
118132
],
119133
},

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

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export default function transformProps(
4242
markerLines: rawMarkerLines,
4343
markerLineLabels: rawMarkerLineLabels,
4444
yAxisFormat,
45+
showLabels,
4546
} = formData;
4647
const refs: Refs = {};
4748
const { onContextMenu, setDataMask = () => {} } = hooks;
@@ -140,26 +141,9 @@ export default function transformProps(
140141
axisTick: { show: false },
141142
axisLabel: { show: false },
142143
},
143-
// Axis trigger so hovering anywhere on the chart shows the breakdown;
144-
// precise hovers on the thin bar or bands are hard to hit.
145144
tooltip: {
146145
confine: true,
147-
trigger: 'axis',
148-
formatter: () =>
149-
sanitizeHtml(
150-
[
151-
`${metricLabel}: <b>${formatter(measure)}</b>`,
152-
...rangeTooltipLines,
153-
...markers.map(
154-
(value, i) =>
155-
`${markerLabelAt(i, markerLabels, markers)}: <b>${formatter(value)}</b>`,
156-
),
157-
...markerLines.map(
158-
(value, i) =>
159-
`${markerLabelAt(i, markerLineLabels, markerLines)}: <b>${formatter(value)}</b>`,
160-
),
161-
].join('<br />'),
162-
),
146+
show: !showLabels,
163147
},
164148
series: [
165149
{
@@ -168,6 +152,10 @@ export default function transformProps(
168152
data: [measure],
169153
barWidth: `${MEASURE_BAR_FRACTION * 100}%`,
170154
itemStyle: { color: theme.colorPrimary },
155+
tooltip: {
156+
formatter: () =>
157+
sanitizeHtml(`${metricLabel}: <b>${formatter(measure)}</b>`),
158+
},
171159
z: 10,
172160
markArea: {
173161
silent: true,
@@ -200,6 +188,32 @@ export default function transformProps(
200188
})),
201189
},
202190
},
191+
{
192+
// Invisible hover targets at each range threshold so ranges get their
193+
// own item tooltip (markArea itself is not reliably hoverable), and a
194+
// place to hang always-on labels.
195+
name: 'ranges',
196+
type: 'scatter',
197+
data: ranges.map((value, index) => ({
198+
value: [value, 0],
199+
tooltip: {
200+
formatter: () =>
201+
sanitizeHtml(
202+
`${rangeLabels[index] || ''} \u2264 <b>${formatter(value)}</b>`,
203+
),
204+
},
205+
label: {
206+
show: Boolean(showLabels) && Boolean(rangeLabels[index]),
207+
position: 'top',
208+
color: theme.colorTextSecondary,
209+
formatter: () => String(rangeLabels[index] || ''),
210+
},
211+
})),
212+
symbol: 'rect',
213+
symbolSize: [14, 40],
214+
itemStyle: { color: 'transparent' },
215+
z: 15,
216+
},
203217
{
204218
name: 'markers',
205219
type: 'scatter',
@@ -216,6 +230,13 @@ export default function transformProps(
216230
})),
217231
symbol: 'triangle',
218232
symbolSize: 14,
233+
label: {
234+
show: Boolean(showLabels),
235+
position: 'bottom',
236+
color: theme.colorTextSecondary,
237+
formatter: (params: { dataIndex: number }) =>
238+
String(markerLabelAt(params.dataIndex, markerLabels, markers)),
239+
},
219240
// Sit below the measure bar pointing up at the marked value
220241
symbolOffset: [0, markerOffsetPx],
221242
itemStyle: { color: theme.colorText },

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export type EchartsBulletFormData = QueryFormData & {
3535
markerLines?: string;
3636
markerLineLabels?: string;
3737
yAxisFormat?: string;
38+
showLabels?: boolean;
3839
};
3940

4041
export interface EchartsBulletChartProps extends BaseChartProps<EchartsBulletFormData> {

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

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -60,20 +60,20 @@ test('renders the measure bar with markers, marker lines and range bands', () =>
6060
expect(bandEnds).toEqual([300, 200, 100]);
6161

6262
// triangle markers
63-
expect(series[1].type).toBe('scatter');
64-
expect(series[1].data[0].value).toEqual([150, 0]);
63+
expect(series[2].type).toBe('scatter');
64+
expect(series[2].data[0].value).toEqual([150, 0]);
6565

6666
// axis spans all values
6767
expect((echartOptions as any).xAxis.max).toBe(300);
6868

6969
// markers sit below the bar (pixel offset); no legend on this chart
70-
expect(series[1].symbolOffset[1]).toBeGreaterThan(0);
70+
expect(series[2].symbolOffset[1]).toBeGreaterThan(0);
7171
expect((echartOptions as any).legend.show).toBe(false);
7272

73-
// range labels surface in the measure tooltip
74-
const tooltip = (echartOptions as any).tooltip.formatter();
75-
expect(tooltip).toContain('low');
76-
expect(tooltip).toContain('high');
73+
// range thresholds get their own hover targets carrying the labels
74+
const rangeTips = series[1].data.map((d: any) => d.tooltip.formatter());
75+
expect(rangeTips.join(' ')).toContain('low');
76+
expect(rangeTips.join(' ')).toContain('high');
7777
});
7878

7979
test('defaults the band to 110% of the measure when no ranges are set', () => {
@@ -91,7 +91,7 @@ test('defaults the band to 110% of the measure when no ranges are set', () => {
9191
expect(series[0].data).toEqual([120]);
9292
const bandEnds = series[0].markArea.data.map((d: any) => d[1].xAxis);
9393
expect(bandEnds).toEqual([120 * 1.1]);
94-
expect(series[1].data).toHaveLength(0);
94+
expect(series[2].data).toHaveLength(0);
9595
});
9696

9797
test('keeps distinct labels for duplicate marker and marker-line values', () => {
@@ -110,7 +110,7 @@ test('keeps distinct labels for duplicate marker and marker-line values', () =>
110110
);
111111
expect(markLineLabels).toEqual(['stretch', 'ceiling']);
112112

113-
const markerTooltips = series[1].data.map((d: any) => d.tooltip.formatter());
113+
const markerTooltips = series[2].data.map((d: any) => d.tooltip.formatter());
114114
expect(markerTooltips[0]).toContain('goal');
115115
expect(markerTooltips[1]).toContain('forecast');
116116
});
@@ -122,10 +122,10 @@ test('sanitizes HTML in metric and marker labels before rendering tooltips', ()
122122
markerLabels: '<img src=x onerror=alert(1)>',
123123
}),
124124
);
125-
const { series, tooltip } = echartOptions as any;
125+
const { series } = echartOptions as any;
126126

127-
expect(tooltip.formatter()).not.toContain('onerror');
128-
expect(series[1].data[0].tooltip.formatter()).not.toContain('onerror');
127+
expect(series[0].tooltip.formatter()).not.toContain('onerror');
128+
expect(series[2].data[0].tooltip.formatter()).not.toContain('onerror');
129129
});
130130

131131
test('handles an empty query result without crashing', () => {

0 commit comments

Comments
 (0)