Skip to content

Commit bc7ddcf

Browse files
committed
draft: standalone legend component
1 parent 80bd3bc commit bc7ddcf

File tree

4 files changed

+297
-13
lines changed

4 files changed

+297
-13
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { useRef } from "react";
5+
6+
import Button from "@cloudscape-design/components/button";
7+
import SpaceBetween from "@cloudscape-design/components/space-between";
8+
9+
import { StandaloneLegend, StandaloneLegendAPI } from "../../lib/components/internal-do-not-use/standalone-legend";
10+
import { PageSettingsForm } from "../common/page-settings";
11+
import { Page } from "../common/templates";
12+
13+
const series: Highcharts.SeriesOptionsType[] = [
14+
{
15+
name: "A",
16+
type: "line",
17+
data: [],
18+
},
19+
{
20+
name: "B",
21+
type: "line",
22+
data: [],
23+
},
24+
{
25+
name: "C",
26+
type: "line",
27+
data: [],
28+
},
29+
{
30+
name: "X",
31+
type: "scatter",
32+
data: [{ x: 1601012700000, y: 500000 }],
33+
marker: { symbol: "square" },
34+
showInLegend: false,
35+
},
36+
];
37+
38+
export default function () {
39+
const legendAPI = useRef<StandaloneLegendAPI | null>(null);
40+
return (
41+
<Page
42+
title="Standalone legend demo"
43+
subtitle="The page demonstrates the use of the standalone legend."
44+
settings={
45+
<PageSettingsForm
46+
selectedSettings={["showLegend", "legendPosition", "showLegendTitle", "showLegendActions", "useFallback"]}
47+
/>
48+
}
49+
>
50+
<SpaceBetween direction="vertical" size="m">
51+
<SpaceBetween direction="horizontal" size="xs">
52+
<Button onClick={() => legendAPI.current?.clearHighlight()}>Clear Highlight</Button>
53+
<Button onClick={() => legendAPI.current?.highlightItems(["B"])}>Highlight B</Button>
54+
<Button onClick={() => legendAPI.current?.setItemsVisible(["B"])}>Set only B visible</Button>
55+
<Button onClick={() => legendAPI.current?.setItemsVisible(["A", "B", "C"])}>Set all visible</Button>
56+
</SpaceBetween>
57+
<StandaloneLegend
58+
series={series}
59+
callback={(api) => {
60+
legendAPI.current = api;
61+
}}
62+
/>
63+
</SpaceBetween>
64+
</Page>
65+
);
66+
}

src/core/chart-api/chart-extra-legend.tsx

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import AsyncStore from "../../internal/utils/async-store";
99
import { getChartSeries } from "../../internal/utils/chart-series";
1010
import { isEqualArrays } from "../../internal/utils/utils";
1111
import { CoreChartProps } from "../interfaces";
12-
import { getChartLegendItems, getPointId, getSeriesId } from "../utils";
12+
import { getChartLegendItems, getPointId, getSeriesId, isLegendItemsEqual } from "../utils";
1313
import { ChartExtraContext } from "./chart-extra-context";
1414

1515
// The reactive state is used to propagate changes in legend items to the core legend React component.
@@ -90,15 +90,6 @@ export class ChartExtraLegend extends AsyncStore<ReactiveLegendState> {
9090
};
9191

9292
private updateLegendItems = (nextItems: CoreChartProps.LegendItem[]) => {
93-
function isLegendItemsEqual(a: CoreChartProps.LegendItem, b: CoreChartProps.LegendItem) {
94-
return (
95-
a.id === b.id &&
96-
a.name === b.name &&
97-
a.marker === b.marker &&
98-
a.visible === b.visible &&
99-
a.highlighted === b.highlighted
100-
);
101-
}
10293
if (!isEqualArrays(this.get().items, nextItems, isLegendItemsEqual)) {
10394
this.set(() => ({ items: nextItems }));
10495
}

src/core/utils.ts

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33

44
import type Highcharts from "highcharts";
55

6+
import { colors } from "../internal/chart-styles";
67
import { ChartSeriesMarkerType } from "../internal/components/series-marker";
78
import { getChartSeries } from "../internal/utils/chart-series";
89
import { getFormatter } from "./formatters";
910
import { ChartLabels } from "./i18n-utils";
10-
import { Rect } from "./interfaces";
11+
import { CoreChartProps, Rect } from "./interfaces";
1112

1213
export interface LegendItemSpec {
1314
id: string;
@@ -17,6 +18,16 @@ export interface LegendItemSpec {
1718
visible: boolean;
1819
}
1920

21+
export function isLegendItemsEqual(a: CoreChartProps.LegendItem, b: CoreChartProps.LegendItem) {
22+
return (
23+
a.id === b.id &&
24+
a.name === b.name &&
25+
a.marker === b.marker &&
26+
a.visible === b.visible &&
27+
a.highlighted === b.highlighted
28+
);
29+
}
30+
2031
// The below functions extract unique identifier from series, point, or options. The identifier can be item's ID or name.
2132
// We expect that items requiring referencing (e.g. in order to control their visibility) have the unique identifier defined.
2233
// Otherwise, we return a randomized id that is to ensure no accidental matches.
@@ -29,7 +40,7 @@ export function getPointId(point: Highcharts.Point): string {
2940
export function getOptionsId(options: { id?: string; name?: string }): string {
3041
return options.id ?? options.name ?? noIdPlaceholder();
3142
}
32-
function noIdPlaceholder(): string {
43+
export function noIdPlaceholder(): string {
3344
const rand = (Math.random() * 1_000_000).toFixed(0).padStart(6, "0");
3445
return "awsui-no-id-placeholder-" + rand;
3546
}
@@ -83,7 +94,22 @@ export function getSeriesMarkerType(series?: Highcharts.Series): ChartSeriesMark
8394
if ("dashStyle" in series.options && series.options.dashStyle) {
8495
return "dashed";
8596
}
86-
switch (series.type) {
97+
return getMarkerType(series.type, seriesSymbol);
98+
}
99+
100+
export function getSeriesOptionsMarkerType(series?: Highcharts.SeriesOptionsType): ChartSeriesMarkerType {
101+
if (!series) {
102+
return "large-square";
103+
}
104+
const seriesSymbol = "symbol" in series && typeof series.symbol === "string" ? series.symbol : "circle";
105+
if ("dashStyle" in series && series.dashStyle) {
106+
return "dashed";
107+
}
108+
return getMarkerType(series.type, seriesSymbol);
109+
}
110+
111+
function getMarkerType(seriesType: string, seriesSymbol: string) {
112+
switch (seriesType) {
87113
case "area":
88114
case "areaspline":
89115
return "hollow-square";
@@ -121,6 +147,15 @@ export function getSeriesColor(series?: Highcharts.Series): string {
121147
export function getPointColor(point?: Highcharts.Point): string {
122148
return typeof point?.color === "string" ? point.color : "black";
123149
}
150+
export function getPointOptionsColor(point?: Highcharts.PointOptionsObject): string | null {
151+
return typeof point?.color === "string" ? point.color : null;
152+
}
153+
export function getSeriesOptionsColor(series: Highcharts.SeriesOptionsType): string | null {
154+
if ("color" in series) {
155+
return typeof series?.color === "string" ? series.color : null;
156+
}
157+
return null;
158+
}
124159

125160
// The custom legend implementation does not rely on the Highcharts legend. When Highcharts legend is disabled,
126161
// the chart object does not include information on legend items. Instead, we collect series and pie segments
@@ -170,6 +205,60 @@ export function getChartLegendItems(chart: Highcharts.Chart): readonly LegendIte
170205
return legendItems;
171206
}
172207

208+
export function getChartLegendItemsFromSeriesOptions(
209+
series: Highcharts.SeriesOptionsType[],
210+
): readonly LegendItemSpec[] {
211+
const legendItems: LegendItemSpec[] = [];
212+
const addSeriesItem = (series: Highcharts.SeriesOptionsType, markerType: ChartSeriesMarkerType) => {
213+
if (series.type === "pie") {
214+
return;
215+
}
216+
if (series.type === "errorbar") {
217+
return;
218+
}
219+
if (series.showInLegend !== false) {
220+
legendItems.push({
221+
id: getOptionsId(series),
222+
name: series.name ?? "fallbackName",
223+
markerType,
224+
color: getSeriesOptionsColor(series) ?? colors[legendItems.length % colors.length],
225+
visible: series.visible ?? true,
226+
});
227+
}
228+
};
229+
const addPointItem = (
230+
point: NonNullable<Highcharts.SeriesPieOptions["data"]>[number],
231+
markerType: ChartSeriesMarkerType,
232+
) => {
233+
if (point === null) {
234+
return;
235+
}
236+
if (typeof point === "number") {
237+
console.log(point);
238+
return;
239+
}
240+
if (point instanceof Array) {
241+
console.log(point);
242+
return;
243+
}
244+
legendItems.push({
245+
id: getOptionsId(point),
246+
name: point.name ?? "fallbackName",
247+
markerType,
248+
color: getPointOptionsColor(point) ?? colors[legendItems.length % colors.length],
249+
visible: true,
250+
});
251+
};
252+
for (const s of series) {
253+
const markerType = getSeriesOptionsMarkerType(s);
254+
addSeriesItem(s, markerType);
255+
if (s.type === "pie") {
256+
s.data?.forEach((p) => addPointItem(p, markerType));
257+
}
258+
}
259+
return legendItems;
260+
}
261+
173262
export function hasVisibleLegendItems(options: Highcharts.Options) {
174263
return !!options.series?.some((series) => {
175264
// The pie series is not shown in the legend, but their segments are always shown.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { useEffect, useRef, useState } from "react";
5+
6+
import { useInternalI18n } from "@cloudscape-design/components/internal/do-not-use/i18n";
7+
8+
import { BaseI18nStrings, CoreChartProps } from "../../core/interfaces";
9+
import { getChartLegendItemsFromSeriesOptions, isLegendItemsEqual } from "../../core/utils";
10+
import { ChartLegend as ChartLegendComponent } from "../../internal/components/chart-legend";
11+
import { ChartSeriesMarker, ChartSeriesMarkerType } from "../../internal/components/series-marker";
12+
import { fireNonCancelableEvent } from "../../internal/events";
13+
import { isEqualArrays } from "../../internal/utils/utils";
14+
15+
export interface StandaloneLegendAPI {
16+
highlightItems(itemIds: readonly string[]): void;
17+
setItemsVisible(itemIds: readonly string[]): void;
18+
clearHighlight(): void;
19+
}
20+
21+
export function StandaloneLegend({
22+
series,
23+
title,
24+
actions,
25+
position,
26+
i18nStrings,
27+
onItemHighlight,
28+
onClearHighlight,
29+
onVisibleItemsChange,
30+
getLegendTooltipContent,
31+
callback,
32+
}: {
33+
series: Highcharts.SeriesOptionsType[];
34+
title?: string;
35+
actions?: React.ReactNode;
36+
position?: "bottom" | "side";
37+
i18nStrings?: BaseI18nStrings;
38+
onClearHighlight?: CoreChartProps["onClearHighlight"];
39+
onItemHighlight?: CoreChartProps["onLegendItemHighlight"];
40+
onVisibleItemsChange?: CoreChartProps["onVisibleItemsChange"];
41+
getLegendTooltipContent?: CoreChartProps["getLegendTooltipContent"];
42+
callback?: (api: StandaloneLegendAPI) => void;
43+
}) {
44+
const i18n = useInternalI18n("[charts]");
45+
const ariaLabel = i18n("i18nStrings.legendAriaLabel", i18nStrings?.legendAriaLabel);
46+
47+
const markersCache = useRef(new Map<string, React.ReactNode>()).current;
48+
function renderMarker(type: ChartSeriesMarkerType, color: string, visible = true): React.ReactNode {
49+
const key = `${type}:${color}:${visible}`;
50+
const marker = markersCache.get(key) ?? <ChartSeriesMarker type={type} color={color} visible={visible} />;
51+
markersCache.set(key, marker);
52+
return marker;
53+
}
54+
55+
const [items, setItems] = useState(() => {
56+
return getChartLegendItemsFromSeriesOptions(series).map(({ id, name, color, markerType, visible }) => {
57+
const marker = renderMarker(markerType, color, visible);
58+
return { id, name, marker, visible, highlighted: false };
59+
});
60+
});
61+
62+
const internalApi = useRef({
63+
clearHighlight(isApiCall: boolean) {
64+
setItems((prevItems) => {
65+
const hiddenSeries = prevItems.find((i) => !i.visible) !== undefined;
66+
const nextItems = prevItems.map(({ ...i }) => ({ ...i, highlighted: hiddenSeries ? i.visible : false }));
67+
if (!isEqualArrays(prevItems, nextItems, isLegendItemsEqual)) {
68+
fireNonCancelableEvent(onClearHighlight, { isApiCall });
69+
return nextItems;
70+
}
71+
return prevItems;
72+
});
73+
},
74+
highlightItems(itemIds: readonly string[]) {
75+
setItems((prevItems) => {
76+
const nextItems = prevItems.map((i) => ({
77+
...i,
78+
highlighted: itemIds.includes(i.id),
79+
}));
80+
if (!isEqualArrays(prevItems, nextItems, isLegendItemsEqual)) {
81+
return nextItems;
82+
}
83+
return prevItems;
84+
});
85+
},
86+
setItemsVisible(itemIds: readonly string[], isApiCall: boolean) {
87+
setItems((prevItems) => {
88+
const nextItems = prevItems.map((i) => {
89+
const visible = itemIds.includes(i.id);
90+
return { ...i, visible, highlighted: visible };
91+
});
92+
if (!isEqualArrays(prevItems, nextItems, isLegendItemsEqual)) {
93+
fireNonCancelableEvent(onVisibleItemsChange, { items: nextItems, isApiCall });
94+
return nextItems;
95+
}
96+
return prevItems;
97+
});
98+
},
99+
}).current;
100+
101+
const api = useRef<StandaloneLegendAPI>({
102+
clearHighlight() {
103+
internalApi.clearHighlight(true);
104+
},
105+
highlightItems(itemIds: readonly string[]) {
106+
internalApi.highlightItems(itemIds);
107+
},
108+
setItemsVisible(itemIds: readonly string[]) {
109+
internalApi.setItemsVisible(itemIds, true);
110+
},
111+
}).current;
112+
113+
useEffect(() => {
114+
if (callback) {
115+
callback(api);
116+
}
117+
}, [callback, api]);
118+
119+
if (items.length === 0) {
120+
return null;
121+
}
122+
return (
123+
<ChartLegendComponent
124+
items={items}
125+
ariaLabel={ariaLabel}
126+
actions={actions}
127+
legendTitle={title}
128+
position={position ?? "bottom"}
129+
getTooltipContent={(props) => getLegendTooltipContent?.(props) ?? null}
130+
onItemHighlightExit={() => internalApi.clearHighlight(false)}
131+
onItemVisibilityChange={(itemIds) => internalApi.setItemsVisible(itemIds, false)}
132+
onItemHighlightEnter={(item) => {
133+
internalApi.highlightItems([item.id]);
134+
fireNonCancelableEvent(onItemHighlight, { item });
135+
}}
136+
/>
137+
);
138+
}

0 commit comments

Comments
 (0)