Skip to content

Commit 1a14a20

Browse files
authored
feat: standalone legend (#93)
* feat: standalone legend * fix: address comments * chore: rename standalone-legend to core-legend * fix: make ariaLabel prop optional
1 parent 0cdd820 commit 1a14a20

File tree

4 files changed

+380
-1
lines changed

4 files changed

+380
-1
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@
4646
"./test-utils/selectors": "./test-utils/selectors/index.js",
4747
"./test-utils/selectors/internal/core": "./test-utils/selectors/internal/core.js",
4848
"./internal/api-docs/*.js": "./internal/api-docs/*.js",
49-
"./internal-do-not-use/core-chart": "./internal-do-not-use/core-chart/index.js"
49+
"./internal-do-not-use/core-chart": "./internal-do-not-use/core-chart/index.js",
50+
"./internal-do-not-use/core-legend": "./internal-do-not-use/core-legend/index.js"
5051
},
5152
"dependencies": {
5253
"@cloudscape-design/component-toolkit": "^1.0.0-beta",

pages/03-core/core-legend.page.tsx

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { useRef, useState } from "react";
5+
import Highcharts from "highcharts";
6+
import { omit } from "lodash";
7+
8+
import Link from "@cloudscape-design/components/link";
9+
import SpaceBetween from "@cloudscape-design/components/space-between";
10+
11+
import { CoreChartProps } from "../../lib/components/core/interfaces";
12+
import { colors } from "../../lib/components/internal/chart-styles";
13+
import { LegendItem } from "../../lib/components/internal/components/interfaces";
14+
import { ChartSeriesMarker } from "../../lib/components/internal/components/series-marker";
15+
import CoreChart from "../../lib/components/internal-do-not-use/core-chart";
16+
import { CoreLegend } from "../../lib/components/internal-do-not-use/core-legend";
17+
import { dateFormatter } from "../common/formatters";
18+
import { PageSettingsForm, useChartSettings } from "../common/page-settings";
19+
import { Page } from "../common/templates";
20+
import pseudoRandom from "../utils/pseudo-random";
21+
22+
function randomInt(min: number, max: number) {
23+
return min + Math.floor(pseudoRandom() * (max - min));
24+
}
25+
26+
const baseline = [
27+
{ x: 1600984800000, y: 58020 },
28+
{ x: 1600985700000, y: 102402 },
29+
{ x: 1600986600000, y: 104920 },
30+
{ x: 1600987500000, y: 94031 },
31+
{ x: 1600988400000, y: 125021 },
32+
{ x: 1600989300000, y: 159219 },
33+
{ x: 1600990200000, y: 193082 },
34+
{ x: 1600991100000, y: 162592 },
35+
{ x: 1600992000000, y: 274021 },
36+
{ x: 1600992900000, y: 264286 },
37+
{ x: 1600993800000, y: 289210 },
38+
{ x: 1600994700000, y: 256362 },
39+
{ x: 1600995600000, y: 257306 },
40+
{ x: 1600996500000, y: 186776 },
41+
{ x: 1600997400000, y: 294020 },
42+
{ x: 1600998300000, y: 385975 },
43+
{ x: 1600999200000, y: 486039 },
44+
{ x: 1601000100000, y: 490447 },
45+
{ x: 1601001000000, y: 361845 },
46+
{ x: 1601001900000, y: 339058 },
47+
{ x: 1601002800000, y: 298028 },
48+
{ x: 1601003400000, y: 255555 },
49+
{ x: 1601003700000, y: 231902 },
50+
{ x: 1601004600000, y: 224558 },
51+
{ x: 1601005500000, y: 253901 },
52+
{ x: 1601006400000, y: 102839 },
53+
{ x: 1601007300000, y: 234943 },
54+
{ x: 1601008200000, y: 204405 },
55+
{ x: 1601009100000, y: 190391 },
56+
{ x: 1601010000000, y: 183570 },
57+
{ x: 1601010900000, y: 162592 },
58+
{ x: 1601011800000, y: 148910 },
59+
{ x: 1601012700000, y: null },
60+
{ x: 1601013600000, y: 293910 },
61+
];
62+
63+
const dataA = baseline.map(({ x, y }) => ({ x, y }));
64+
const dataB = baseline.map(({ x, y }) => ({ x, y: y === null ? null : y + randomInt(-100000, 100000) }));
65+
const dataC = baseline.map(({ x, y }) => ({ x, y: y === null ? null : y + randomInt(-150000, 50000) }));
66+
67+
const pieSeries: Highcharts.SeriesOptionsType[] = [
68+
{
69+
name: "Resource Allocation",
70+
type: "pie",
71+
data: [
72+
{ name: "CPU Utilization", y: 45.0 },
73+
{ name: "Memory Usage", y: 26.8 },
74+
{ name: "Storage Capacity", y: 12.8 },
75+
],
76+
},
77+
];
78+
79+
const lineSeries: Highcharts.SeriesOptionsType[] = [
80+
{
81+
name: "CPU Utilization",
82+
type: "line",
83+
data: dataA,
84+
},
85+
{
86+
name: "Memory Usage",
87+
type: "line",
88+
data: dataB,
89+
},
90+
{
91+
name: "Storage Capacity",
92+
type: "line",
93+
data: dataC,
94+
},
95+
{
96+
name: "X",
97+
type: "scatter",
98+
data: [{ x: 1601012700000, y: 500000 }],
99+
marker: { symbol: "square" },
100+
showInLegend: false,
101+
},
102+
];
103+
104+
const initialLegendItems: readonly LegendItem[] = [
105+
{
106+
id: "CPU Utilization",
107+
name: "CPU Utilization",
108+
marker: <ChartSeriesMarker color={colors[0]} type={"square"} visible={true} />,
109+
visible: true,
110+
highlighted: false,
111+
},
112+
{
113+
id: "Memory Usage",
114+
name: "Memory Usage",
115+
marker: <ChartSeriesMarker color={colors[1]} type={"square"} visible={true} />,
116+
visible: true,
117+
highlighted: false,
118+
},
119+
{
120+
id: "Storage Capacity",
121+
name: "Storage Capacity",
122+
marker: <ChartSeriesMarker color={colors[2]} type={"square"} visible={true} />,
123+
visible: true,
124+
highlighted: false,
125+
},
126+
];
127+
128+
type SourceType = "legend" | "pie-chart" | "line-chart";
129+
130+
export default function () {
131+
const { chartProps } = useChartSettings();
132+
133+
const [items, setItems] = useState(initialLegendItems);
134+
const sources = useRef<Map<SourceType, CoreChartProps.ChartAPI>>(new Map());
135+
136+
const legendProps = {
137+
title: chartProps.cartesian.legend?.title,
138+
actions: chartProps.cartesian.legend?.actions,
139+
} as const;
140+
141+
const clearLegendHighlight = (source: SourceType, isApiCall: boolean) => {
142+
if (isApiCall) {
143+
return;
144+
}
145+
for (const [name, chart] of sources.current.entries()) {
146+
if (name !== source) {
147+
chart.clearChartHighlight();
148+
}
149+
}
150+
setItems((prevItems) => {
151+
const hiddenSeries = prevItems.find((i) => !i.visible) !== undefined;
152+
return prevItems.map(({ ...i }) => ({ ...i, highlighted: hiddenSeries ? i.visible : false }));
153+
});
154+
};
155+
156+
const highlightLegendItem = (source: SourceType, itemId: string, isApiCall: boolean) => {
157+
if (isApiCall) {
158+
return;
159+
}
160+
for (const [name, chart] of sources.current.entries()) {
161+
if (name !== source) {
162+
chart.highlightItems([itemId]);
163+
}
164+
}
165+
setItems((prevItems) => {
166+
return prevItems.map((i) => ({ ...i, highlighted: i.id === itemId }));
167+
});
168+
};
169+
170+
const changeVisibleLegendItems = (source: SourceType, itemIds: readonly string[]) => {
171+
for (const [name, chart] of sources.current.entries()) {
172+
if (name !== source) {
173+
chart.setItemsVisible(itemIds);
174+
}
175+
}
176+
setItems((prevItems) => {
177+
return prevItems.map((item, index) => {
178+
const visible = itemIds.includes(item.id);
179+
return {
180+
...item,
181+
visible,
182+
highlighted: visible,
183+
marker: <ChartSeriesMarker color={colors[index % colors.length]} type={"square"} visible={visible} />,
184+
};
185+
});
186+
});
187+
};
188+
189+
return (
190+
<Page
191+
title="Core Legend demo"
192+
subtitle="The page demonstrates the use of the core legend."
193+
settings={<PageSettingsForm selectedSettings={["showLegendTitle", "showLegendActions"]} />}
194+
>
195+
<SpaceBetween direction="vertical" size="m">
196+
<CoreLegend
197+
items={items}
198+
{...legendProps}
199+
ariaLabel="Dashboard Legend"
200+
onClearHighlight={() => clearLegendHighlight("legend", false)}
201+
onItemHighlight={(e) => highlightLegendItem("legend", e.detail.item.id, false)}
202+
onVisibleItemsChange={(e) => changeVisibleLegendItems("legend", e.detail.items)}
203+
getLegendTooltipContent={({ legendItem }) => ({
204+
header: (
205+
<div>
206+
<div style={{ display: "flex" }}>
207+
{legendItem.marker}
208+
{legendItem.name}
209+
</div>
210+
</div>
211+
),
212+
body: (
213+
<>
214+
<table>
215+
<tbody style={{ textAlign: "left" }}>
216+
<tr>
217+
<th scope="row">Period</th>
218+
<td>15 min</td>
219+
</tr>
220+
<tr>
221+
<th scope="row">Statistic</th>
222+
<td>Average</td>
223+
</tr>
224+
<tr>
225+
<th scope="row">Unit</th>
226+
<td>Count</td>
227+
</tr>
228+
</tbody>
229+
</table>
230+
</>
231+
),
232+
footer: (
233+
<Link external={true} href="https://example.com/" variant="primary">
234+
Learn more
235+
</Link>
236+
),
237+
})}
238+
/>
239+
<CoreChart
240+
{...omit(chartProps.pie, "ref")}
241+
highcharts={Highcharts}
242+
chartHeight={400}
243+
legend={{ enabled: false }}
244+
callback={(chart) => {
245+
sources.current.set("pie-chart", chart);
246+
}}
247+
onHighlight={(e) => {
248+
if (e.detail.point) {
249+
highlightLegendItem("pie-chart", e.detail.point.name, e.detail.isApiCall);
250+
}
251+
}}
252+
onClearHighlight={(e) => clearLegendHighlight("pie-chart", e.detail.isApiCall)}
253+
options={{
254+
lang: {
255+
accessibility: {
256+
chartContainerLabel: "Pie chart",
257+
},
258+
},
259+
series: pieSeries,
260+
chart: {
261+
type: "pie",
262+
},
263+
title: {
264+
text: "Resource Allocation",
265+
},
266+
plotOptions: {
267+
pie: {
268+
allowPointSelect: true,
269+
cursor: "pointer",
270+
dataLabels: {
271+
enabled: true,
272+
format: "{point.name}: {point.percentage:.1f}%",
273+
},
274+
},
275+
},
276+
}}
277+
/>
278+
<CoreChart
279+
{...omit(chartProps.cartesian, "ref")}
280+
highcharts={Highcharts}
281+
chartHeight={300}
282+
legend={{ enabled: false }}
283+
callback={(chart) => {
284+
sources.current.set("line-chart", chart);
285+
}}
286+
onHighlight={(e) => {
287+
if (e.detail.point) {
288+
highlightLegendItem("line-chart", e.detail.point.series.name, e.detail.isApiCall);
289+
}
290+
}}
291+
onClearHighlight={(e) => clearLegendHighlight("line-chart", e.detail.isApiCall)}
292+
options={{
293+
lang: {
294+
accessibility: {
295+
chartContainerLabel: "Line chart",
296+
},
297+
},
298+
series: lineSeries,
299+
xAxis: [
300+
{
301+
type: "datetime",
302+
title: { text: "Time (UTC)" },
303+
valueFormatter: dateFormatter,
304+
},
305+
],
306+
yAxis: [{ title: { text: "Events" } }],
307+
chart: {
308+
zooming: {
309+
type: "x",
310+
},
311+
},
312+
}}
313+
/>
314+
</SpaceBetween>
315+
</Page>
316+
);
317+
}

src/core/interfaces.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,29 @@ export namespace CoreChartProps {
476476
export type I18nStrings = CartesianI18nStrings & PieI18nStrings;
477477
}
478478

479+
export interface CoreLegendProps {
480+
items: readonly CoreLegendProps.Item[];
481+
title?: string;
482+
ariaLabel?: string;
483+
actions?: React.ReactNode;
484+
alignment?: "horizontal" | "vertical";
485+
onClearHighlight?: NonCancelableEventHandler;
486+
onItemHighlight?: NonCancelableEventHandler<CoreLegendProps.ItemHighlightDetail>;
487+
onVisibleItemsChange?: NonCancelableEventHandler<CoreLegendProps.VisibleItemsChangeDetail>;
488+
getLegendTooltipContent?: CoreLegendProps.GetTooltipContent;
489+
}
490+
491+
export namespace CoreLegendProps {
492+
export type Item = InternalComponentTypes.LegendItem;
493+
export type GetTooltipContent = InternalComponentTypes.GetLegendTooltipContent;
494+
export interface ItemHighlightDetail {
495+
item: Item;
496+
}
497+
export interface VisibleItemsChangeDetail {
498+
items: readonly string[];
499+
}
500+
}
501+
479502
// Utility types
480503

481504
export interface Rect {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { CoreLegendProps } from "../../core/interfaces";
5+
import { ChartLegend as ChartLegendComponent } from "../../internal/components/chart-legend";
6+
import { fireNonCancelableEvent } from "../../internal/events";
7+
8+
export const CoreLegend = ({
9+
items,
10+
title,
11+
actions,
12+
ariaLabel,
13+
alignment = "horizontal",
14+
onItemHighlight,
15+
onClearHighlight,
16+
onVisibleItemsChange,
17+
getLegendTooltipContent,
18+
}: CoreLegendProps) => {
19+
const position = alignment === "horizontal" ? "bottom" : "side";
20+
21+
if (items.length === 0) {
22+
return null;
23+
}
24+
25+
return (
26+
<ChartLegendComponent
27+
items={items}
28+
actions={actions}
29+
legendTitle={title}
30+
position={position}
31+
ariaLabel={ariaLabel}
32+
getTooltipContent={(props) => getLegendTooltipContent?.(props) ?? null}
33+
onItemHighlightExit={() => fireNonCancelableEvent(onClearHighlight)}
34+
onItemHighlightEnter={(item) => fireNonCancelableEvent(onItemHighlight, { item })}
35+
onItemVisibilityChange={(items) => fireNonCancelableEvent(onVisibleItemsChange, { items })}
36+
/>
37+
);
38+
};

0 commit comments

Comments
 (0)