Skip to content

Commit 512e840

Browse files
Pixselvepan-kot
andauthored
feat: Tooltip series sorting (#130)
* feat: add configurable series sorting option for chart tooltips Add a new `seriesSorting` property to tooltip options that controls how series items are ordered in tooltips. The property accepts two values: - "asAdded" (default): maintains original series order - "byValue": sorts tooltip items by their values in descending order * feat: change series sorting to byValueDesc and add tests Update tooltip series sorting option from "byValue" to "byValueDesc" to explicitly indicate descending sort order. Add comprehensive test coverage for series sorting. * test: update core-line page * test: updates documenter.test.ts.snap * chore: renames sorting keys * feat(test): add test for default tooltip series order behavior * Update src/core/__tests__/chart-core-tooltip.test.tsx --------- Co-authored-by: Andrei Zhaleznichenka <[email protected]>
1 parent d03e827 commit 512e840

File tree

6 files changed

+219
-5
lines changed

6 files changed

+219
-5
lines changed

pages/03-core/core-line-chart.page.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import Highcharts from "highcharts";
5-
import { omit } from "lodash";
65

76
import Button from "@cloudscape-design/components/button";
87
import Link from "@cloudscape-design/components/link";
@@ -98,12 +97,13 @@ export default function () {
9897
"showLegendTitle",
9998
"showLegendActions",
10099
"useFallback",
100+
"tooltipSeriesSorting",
101101
]}
102102
/>
103103
}
104104
>
105105
<CoreChart
106-
{...omit(chartProps.cartesian, "ref")}
106+
{...chartProps.core}
107107
highcharts={Highcharts}
108108
options={{
109109
lang: {
@@ -127,7 +127,6 @@ export default function () {
127127
},
128128
}}
129129
chartHeight={400}
130-
tooltip={{ placement: "outside" }}
131130
getTooltipContent={() => ({
132131
point({ item, hideTooltip }) {
133132
const value = item ? (item.point.y ?? null) : null;

pages/common/page-settings.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface PageSettings {
3232
emphasizeBaseline: boolean;
3333
tooltipPlacement: "default" | "middle" | "outside";
3434
tooltipSize: "small" | "medium" | "large";
35+
tooltipSeriesSorting: CoreChartProps.TooltipOptions["seriesSorting"];
3536
showLegend: boolean;
3637
showLegendTitle: boolean;
3738
showLegendActions: boolean;
@@ -68,6 +69,7 @@ const DEFAULT_SETTINGS: PageSettings = {
6869
showHeaderFilter: false,
6970
showCustomFooter: false,
7071
useFallback: false,
72+
tooltipSeriesSorting: "as-added",
7173
};
7274

7375
export const PageSettingsContext = createContext<PageSettings>(DEFAULT_SETTINGS);
@@ -85,6 +87,7 @@ export function useChartSettings<SettingsType extends PageSettings = PageSetting
8587
chartProps: {
8688
cartesian: Omit<CartesianChartProps, "series"> & { ref: React.Ref<CartesianChartProps.Ref> };
8789
pie: Omit<PieChartProps, "series"> & { ref: React.Ref<PieChartProps.Ref> };
90+
core: Omit<CoreChartProps, "series" | "options">;
8891
};
8992
isEmpty: boolean;
9093
} {
@@ -180,6 +183,18 @@ export function useChartSettings<SettingsType extends PageSettings = PageSetting
180183
tooltip: { size: settings.tooltipSize },
181184
legend,
182185
},
186+
core: {
187+
highcharts,
188+
noData,
189+
tooltip: {
190+
placement: settings.tooltipPlacement === "default" ? undefined : settings.tooltipPlacement,
191+
size: settings.tooltipSize,
192+
seriesSorting: settings.tooltipSeriesSorting,
193+
},
194+
legend,
195+
emphasizeBaseline: settings.emphasizeBaseline,
196+
verticalAxisTitlePlacement: settings.verticalAxisTitlePlacement,
197+
},
183198
},
184199
isEmpty: settings.emptySeries || settings.seriesLoading || settings.seriesError,
185200
};
@@ -191,6 +206,11 @@ const tooltipSizeOptions = [{ value: "small" }, { value: "medium" }, { value: "l
191206

192207
const verticalAxisTitlePlacementOptions = [{ value: "top" }, { value: "side" }];
193208

209+
const tooltipSeriesSortingOptions = [
210+
{ id: "as-added", text: "as-added" },
211+
{ id: "by-value-desc", text: "by-value-desc" },
212+
];
213+
194214
export function PageSettingsForm({
195215
selectedSettings,
196216
}: {
@@ -341,6 +361,19 @@ export function PageSettingsForm({
341361
/>
342362
</FormField>
343363
);
364+
case "tooltipSeriesSorting":
365+
return (
366+
<SegmentedControl
367+
label="Tooltip Series Sorting"
368+
selectedId={settings.tooltipSeriesSorting ?? null}
369+
options={tooltipSeriesSortingOptions}
370+
onChange={({ detail }) =>
371+
setSettings({
372+
tooltipSeriesSorting: detail.selectedId as CoreChartProps.TooltipOptions["seriesSorting"],
373+
})
374+
}
375+
/>
376+
);
344377
case "showLegend":
345378
return (
346379
<Checkbox

src/__tests__/__snapshots__/documenter.test.ts.snap

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1795,6 +1795,20 @@ for each visited { x, y } point.",
17951795
"optional": true,
17961796
"type": "string",
17971797
},
1798+
{
1799+
"inlineType": {
1800+
"name": ""as-added" | "by-value-desc"",
1801+
"type": "union",
1802+
"valueDescriptions": undefined,
1803+
"values": [
1804+
"as-added",
1805+
"by-value-desc",
1806+
],
1807+
},
1808+
"name": "seriesSorting",
1809+
"optional": true,
1810+
"type": "string",
1811+
},
17981812
{
17991813
"inlineType": {
18001814
"name": ""small" | "medium" | "large"",

src/core/__tests__/chart-core-tooltip.test.tsx

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,4 +660,162 @@ describe("CoreChart: tooltip", () => {
660660
expect(wrapper.findTooltip()).not.toBe(null);
661661
});
662662
});
663+
664+
describe("series sorting", () => {
665+
const lineSeries: Highcharts.SeriesOptionsType[] = [
666+
{
667+
type: "line",
668+
name: "Line series 1",
669+
data: [
670+
{ x: 1, y: 11 },
671+
{ x: 2, y: 23 },
672+
],
673+
},
674+
{
675+
type: "line",
676+
name: "Line series 2",
677+
data: [
678+
{ x: 1, y: 21 },
679+
{ x: 2, y: 11 },
680+
],
681+
},
682+
];
683+
684+
test("maintains series order when explicitly set to 'as-added'", () => {
685+
const { wrapper } = renderChart({
686+
highcharts,
687+
options: {
688+
series: lineSeries,
689+
},
690+
tooltip: { seriesSorting: "as-added" },
691+
getTooltipContent: () => ({
692+
header: () => "Header",
693+
body: ({ items }) => (
694+
<div>
695+
{items.map((item, i) => (
696+
<div key={i} data-testid={`series-${i}`}>
697+
{item.point.series.name}: {item.point.y}
698+
</div>
699+
))}
700+
</div>
701+
),
702+
}),
703+
});
704+
705+
act(() => hc.highlightChartPoint(0, 0));
706+
707+
expect(wrapper.findTooltip()).not.toBe(null);
708+
const series0 = wrapper.findTooltip()!.find('[data-testid="series-0"]');
709+
const series1 = wrapper.findTooltip()!.find('[data-testid="series-1"]');
710+
711+
expect(series0!.getElement().textContent).toBe("Line series 1: 11");
712+
expect(series1!.getElement().textContent).toBe("Line series 2: 21");
713+
});
714+
715+
test("maintains series order when not explicitly provided", () => {
716+
const { wrapper } = renderChart({
717+
highcharts,
718+
options: {
719+
series: lineSeries,
720+
},
721+
getTooltipContent: () => ({
722+
header: () => "Header",
723+
body: ({ items }) => (
724+
<div>
725+
{items.map((item, i) => (
726+
<div key={i} data-testid={`series-${i}`}>
727+
{item.point.series.name}: {item.point.y}
728+
</div>
729+
))}
730+
</div>
731+
),
732+
}),
733+
});
734+
735+
act(() => hc.highlightChartPoint(0, 0));
736+
737+
expect(wrapper.findTooltip()).not.toBe(null);
738+
const series0 = wrapper.findTooltip()!.find('[data-testid="series-0"]');
739+
const series1 = wrapper.findTooltip()!.find('[data-testid="series-1"]');
740+
741+
expect(series0!.getElement().textContent).toBe("Line series 1: 11");
742+
expect(series1!.getElement().textContent).toBe("Line series 2: 21");
743+
});
744+
745+
describe('seriesSorting: "by-value-desc"', () => {
746+
test("sorts series by value in descending order", () => {
747+
const { wrapper } = renderChart({
748+
highcharts,
749+
options: {
750+
series: lineSeries,
751+
},
752+
tooltip: { seriesSorting: "by-value-desc" },
753+
getTooltipContent: () => ({
754+
header: () => "Header",
755+
body: ({ items }) => (
756+
<div>
757+
{items.map((item, i) => (
758+
<div key={i} data-testid={`series-${i}`}>
759+
{item.point.series.name}: {item.point.y}
760+
</div>
761+
))}
762+
</div>
763+
),
764+
}),
765+
});
766+
767+
act(() => hc.highlightChartPoint(0, 0));
768+
769+
expect(wrapper.findTooltip()).not.toBe(null);
770+
const series0 = wrapper.findTooltip()!.find('[data-testid="series-0"]');
771+
const series1 = wrapper.findTooltip()!.find('[data-testid="series-1"]');
772+
773+
expect(series0!.getElement().textContent).toBe("Line series 2: 21");
774+
expect(series1!.getElement().textContent).toBe("Line series 1: 11");
775+
});
776+
777+
test("re-sorts series when hovering different x positions", () => {
778+
const { wrapper } = renderChart({
779+
highcharts,
780+
options: {
781+
series: lineSeries,
782+
},
783+
tooltip: { seriesSorting: "by-value-desc" },
784+
getTooltipContent: () => ({
785+
header: () => "Header",
786+
body: ({ items }) => (
787+
<div>
788+
{items.map((item, i) => (
789+
<div key={i} data-testid={`series-${i}`}>
790+
{item.point.series.name}: {item.point.y}
791+
</div>
792+
))}
793+
</div>
794+
),
795+
}),
796+
});
797+
798+
act(() => hc.highlightChartPoint(0, 0));
799+
800+
{
801+
expect(wrapper.findTooltip()).not.toBe(null);
802+
const series0 = wrapper.findTooltip()!.find('[data-testid="series-0"]');
803+
const series1 = wrapper.findTooltip()!.find('[data-testid="series-1"]');
804+
805+
expect(series0!.getElement().textContent).toBe("Line series 2: 21");
806+
expect(series1!.getElement().textContent).toBe("Line series 1: 11");
807+
}
808+
act(() => hc.highlightChartPoint(0, 1));
809+
810+
{
811+
expect(wrapper.findTooltip()).not.toBe(null);
812+
const series0 = wrapper.findTooltip()!.find('[data-testid="series-0"]');
813+
const series1 = wrapper.findTooltip()!.find('[data-testid="series-1"]');
814+
815+
expect(series0!.getElement().textContent).toBe("Line series 1: 23");
816+
expect(series1!.getElement().textContent).toBe("Line series 2: 11");
817+
}
818+
});
819+
});
820+
});
663821
});

src/core/components/core-tooltip.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export function ChartTooltip({
4747
api,
4848
i18nStrings,
4949
debounce = false,
50+
seriesSorting = "as-added",
5051
}: CoreChartProps.TooltipOptions & {
5152
i18nStrings?: BaseI18nStrings;
5253
getTooltipContent?: CoreChartProps.GetTooltipContent;
@@ -76,6 +77,7 @@ export function ChartTooltip({
7677
group: debouncedTooltip.group,
7778
expandedSeries,
7879
setExpandedSeries,
80+
seriesSorting,
7981
hideTooltip: () => {
8082
api.hideTooltip();
8183
},
@@ -127,6 +129,7 @@ function getTooltipContent(
127129
props: CoreChartProps.GetTooltipContentProps & {
128130
renderers?: CoreChartProps.TooltipContentRenderer;
129131
hideTooltip: () => void;
132+
seriesSorting: NonNullable<CoreChartProps.TooltipOptions["seriesSorting"]>;
130133
} & ExpandedSeriesStateProps,
131134
): null | RenderedTooltipContent {
132135
if (props.point && props.point.series.type === "pie") {
@@ -147,9 +150,11 @@ function getTooltipContentCartesian(
147150
renderers = {},
148151
setExpandedSeries,
149152
hideTooltip,
153+
seriesSorting,
150154
}: CoreChartProps.GetTooltipContentProps & {
151155
renderers?: CoreChartProps.TooltipContentRenderer;
152156
hideTooltip: () => void;
157+
seriesSorting: NonNullable<CoreChartProps.TooltipOptions["seriesSorting"]>;
153158
} & ExpandedSeriesStateProps,
154159
): RenderedTooltipContent {
155160
// The cartesian tooltip might or might not have a selected point, but it always has a non-empty group.
@@ -158,7 +163,7 @@ function getTooltipContentCartesian(
158163
const chart = group[0].series.chart;
159164
const getSeriesMarker = (series: Highcharts.Series) =>
160165
api.renderMarker(getSeriesMarkerType(series), getSeriesColor(series), true);
161-
const matchedItems = findTooltipSeriesItems(getChartSeries(chart.series), group);
166+
const matchedItems = findTooltipSeriesItems(getChartSeries(chart.series), group, seriesSorting);
162167
const detailItems: ChartSeriesDetailItem[] = matchedItems.map((item) => {
163168
const valueFormatter = getFormatter(item.point.series.yAxis);
164169
const itemY = isXThreshold(item.point.series) ? null : (item.point.y ?? null);
@@ -266,6 +271,7 @@ function getTooltipContentPie(
266271
function findTooltipSeriesItems(
267272
series: readonly Highcharts.Series[],
268273
group: readonly Highcharts.Point[],
274+
seriesSorting: NonNullable<CoreChartProps.TooltipOptions["seriesSorting"]>,
269275
): MatchedItem[] {
270276
const seriesOrder = series.reduce((d, s, i) => d.set(s, i), new Map<Highcharts.Series, number>());
271277
const getSeriesIndex = (s: Highcharts.Series) => seriesOrder.get(s) ?? -1;
@@ -310,8 +316,11 @@ function findTooltipSeriesItems(
310316
}
311317
return (
312318
matchedItems
313-
// We sort matched items by series order. If there are multiple items that belong to the same series, we sort them by value.
314319
.sort((i1, i2) => {
320+
if (seriesSorting === "by-value-desc") {
321+
return (i2.point.y ?? 0) - (i1.point.y ?? 0);
322+
}
323+
// We sort matched items by series order. If there are multiple items that belong to the same series, we sort them by value.
315324
const s1 = getSeriesIndex(i1.point.series) - getSeriesIndex(i2.point.series);
316325
return s1 || (i1.point.y ?? 0) - (i2.point.y ?? 0);
317326
})

src/core/interfaces.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,7 @@ export namespace CoreChartProps {
458458
placement?: "middle" | "outside" | "target";
459459
size?: "small" | "medium" | "large";
460460
debounce?: number | boolean;
461+
seriesSorting?: "as-added" | "by-value-desc";
461462
}
462463

463464
export type GetTooltipContent = (props: GetTooltipContentProps) => TooltipContentRenderer;

0 commit comments

Comments
 (0)