Skip to content

Commit ab14247

Browse files
committed
feat: legend dual axis
1 parent 6f249e2 commit ab14247

File tree

13 files changed

+963
-219
lines changed

13 files changed

+963
-219
lines changed

package-lock.json

Lines changed: 370 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { omit } from "lodash";
5+
6+
import CoreChart from "../../lib/components/internal-do-not-use/core-chart";
7+
import { dateFormatter } from "../common/formatters";
8+
import { PageSettingsForm, useChartSettings } from "../common/page-settings";
9+
import { Page } from "../common/templates";
10+
import pseudoRandom from "../utils/pseudo-random";
11+
12+
function randomInt(min: number, max: number) {
13+
return min + Math.floor(pseudoRandom() * (max - min));
14+
}
15+
16+
function shuffle<T>(array: T[]): void {
17+
let currentIndex = array.length;
18+
while (currentIndex !== 0) {
19+
const randomIndex = Math.floor(Math.random() * currentIndex);
20+
currentIndex--;
21+
[array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]];
22+
}
23+
}
24+
25+
const colors = [
26+
"#F15C80",
27+
"#2B908F",
28+
"#F45B5B",
29+
"#91E8E1",
30+
"#8085E9",
31+
"#E4D354",
32+
"#8D4654",
33+
"#7798BF",
34+
"#AAEEEE",
35+
"#FF9655",
36+
];
37+
38+
const dashStyles: Highcharts.DashStyleValue[] = [
39+
"Dash",
40+
"DashDot",
41+
"Dot",
42+
"LongDash",
43+
"LongDashDot",
44+
"LongDashDotDot",
45+
"ShortDash",
46+
"ShortDashDot",
47+
"ShortDashDotDot",
48+
"ShortDot",
49+
"Solid",
50+
];
51+
52+
const baseline = [
53+
{ x: 1600984800000, y: 58020 },
54+
{ x: 1600985700000, y: 102402 },
55+
{ x: 1600986600000, y: 104920 },
56+
{ x: 1600987500000, y: 94031 },
57+
{ x: 1600988400000, y: 125021 },
58+
{ x: 1600989300000, y: 159219 },
59+
{ x: 1600990200000, y: 193082 },
60+
{ x: 1600991100000, y: 162592 },
61+
{ x: 1600992000000, y: 274021 },
62+
{ x: 1600992900000, y: 264286 },
63+
{ x: 1600993800000, y: 289210 },
64+
{ x: 1600994700000, y: 256362 },
65+
{ x: 1600995600000, y: 257306 },
66+
{ x: 1600996500000, y: 186776 },
67+
{ x: 1600997400000, y: 294020 },
68+
{ x: 1600998300000, y: 385975 },
69+
{ x: 1600999200000, y: 486039 },
70+
{ x: 1601000100000, y: 490447 },
71+
{ x: 1601001000000, y: 361845 },
72+
{ x: 1601001900000, y: 339058 },
73+
{ x: 1601002800000, y: 298028 },
74+
{ x: 1601003400000, y: 255555 },
75+
{ x: 1601003700000, y: 231902 },
76+
{ x: 1601004600000, y: 224558 },
77+
{ x: 1601005500000, y: 253901 },
78+
{ x: 1601006400000, y: 102839 },
79+
{ x: 1601007300000, y: 234943 },
80+
{ x: 1601008200000, y: 204405 },
81+
{ x: 1601009100000, y: 190391 },
82+
{ x: 1601010000000, y: 183570 },
83+
{ x: 1601010900000, y: 162592 },
84+
{ x: 1601011800000, y: 148910 },
85+
];
86+
87+
const generatePrimaryAxisData = (letter: string, index: number) => {
88+
return baseline.map(({ x, y }) => ({
89+
name: `Events ${letter}`,
90+
x,
91+
y: y === null ? null : y + randomInt(-100000 * ((index % 3) + 1), 100000 * ((index % 3) + 1)),
92+
}));
93+
};
94+
95+
const generateSecondaryAxisData = (letter: string, index: number) => {
96+
return baseline.map(({ x, y }) => ({
97+
name: `Percentage ${letter}`,
98+
x,
99+
y: y === null ? null : (y / 10000) * randomInt(3 + (index % 5), 10 + (index % 10)),
100+
}));
101+
};
102+
103+
const primarySeriesData: Record<string, any[]> = {};
104+
for (let i = 0; i < 10; i++) {
105+
const letter = String.fromCharCode(65 + i);
106+
primarySeriesData[`data${letter}`] = generatePrimaryAxisData(letter, i);
107+
}
108+
109+
const secondarySeriesData: Record<string, any[]> = {};
110+
for (let i = 0; i < 10; i++) {
111+
const letter = String.fromCharCode(65 + i);
112+
secondarySeriesData[`data${letter}`] = generateSecondaryAxisData(letter, i);
113+
}
114+
115+
const series: Highcharts.SeriesOptionsType[] = [];
116+
117+
Object.entries(primarySeriesData).forEach(([, data], index) => {
118+
series.push({
119+
name: data[0].name,
120+
type: "line",
121+
data: data,
122+
yAxis: 0,
123+
color: colors[index],
124+
});
125+
});
126+
127+
Object.entries(secondarySeriesData).forEach(([, data], index) => {
128+
series.push({
129+
name: data[0].name,
130+
type: "line",
131+
data: data,
132+
yAxis: 1,
133+
color: colors[index],
134+
dashStyle: dashStyles[index % dashStyles.length],
135+
});
136+
});
137+
138+
shuffle(series);
139+
140+
export default function () {
141+
const { chartProps } = useChartSettings();
142+
return (
143+
<Page
144+
title="Core dual-axis chart demo"
145+
subtitle="This page demonstrates the use of the core chart with two Y axes for displaying data with different scales."
146+
settings={
147+
<PageSettingsForm
148+
selectedSettings={[
149+
"showLegend",
150+
"legendType",
151+
"legendPosition",
152+
"legendBottomMaxHeight",
153+
"showLegendTitle",
154+
"showOppositeLegendTitle",
155+
"showLegendActions",
156+
]}
157+
/>
158+
}
159+
>
160+
<CoreChart
161+
{...omit(chartProps.cartesian, "ref")}
162+
chartHeight={400}
163+
ariaLabel="Dual axis line chart"
164+
tooltip={{ placement: "outside" }}
165+
options={{
166+
series: series,
167+
xAxis: [
168+
{
169+
type: "datetime",
170+
title: { text: "Time (UTC)" },
171+
valueFormatter: dateFormatter,
172+
},
173+
],
174+
yAxis: [
175+
{
176+
title: { text: "Events" },
177+
},
178+
{
179+
opposite: true,
180+
title: { text: "Percentage (%)" },
181+
},
182+
],
183+
}}
184+
/>
185+
</Page>
186+
);
187+
}

pages/common/page-settings.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ export interface PageSettings {
3333
tooltipSize: "small" | "medium" | "large";
3434
showLegend: boolean;
3535
showLegendTitle: boolean;
36+
showOppositeLegendTitle: boolean;
3637
showLegendActions: boolean;
38+
legendType: "single" | "dual";
3739
legendBottomMaxHeight?: number;
3840
legendPosition: "bottom" | "side";
3941
showCustomHeader: boolean;
@@ -59,6 +61,8 @@ const DEFAULT_SETTINGS: PageSettings = {
5961
tooltipSize: "medium",
6062
showLegend: true,
6163
showLegendTitle: false,
64+
showOppositeLegendTitle: false,
65+
legendType: "single",
6266
legendPosition: "bottom",
6367
showLegendActions: false,
6468
showCustomHeader: false,
@@ -142,10 +146,13 @@ export function useChartSettings<SettingsType extends PageSettings = PageSetting
142146
// Adding an empty recovery click handler to make the default recovery button appear.
143147
onRecoveryClick: () => {},
144148
};
149+
console.log(settings.showOppositeLegendTitle);
145150
const legend = {
146151
enabled: settings.showLegend,
147152
title: settings.showLegendTitle ? "Legend title" : undefined,
153+
oppositeLegendTitle: settings.showOppositeLegendTitle ? "Opposite Legend title" : undefined,
148154
actions: settings.showLegendActions ? <Button variant="icon" iconName="search" /> : undefined,
155+
type: settings.legendType,
149156
position: settings.legendPosition,
150157
bottomMaxHeight: settings.legendBottomMaxHeight,
151158
};
@@ -342,6 +349,20 @@ export function PageSettingsForm({
342349
Show legend
343350
</Checkbox>
344351
);
352+
case "legendType":
353+
return (
354+
<SegmentedControl
355+
label="Legend Type"
356+
selectedId={settings.legendType}
357+
options={[
358+
{ text: "Single", id: "single", disabled: !settings.showLegend },
359+
{ text: "Dual", id: "dual", disabled: !settings.showLegend },
360+
]}
361+
onChange={({ detail }) =>
362+
setSettings({ legendType: detail.selectedId as string as "single" | "dual" })
363+
}
364+
/>
365+
);
345366
case "showLegendTitle":
346367
return (
347368
<Checkbox
@@ -351,6 +372,15 @@ export function PageSettingsForm({
351372
Show legend title
352373
</Checkbox>
353374
);
375+
case "showOppositeLegendTitle":
376+
return (
377+
<Checkbox
378+
checked={settings.showOppositeLegendTitle}
379+
onChange={({ detail }) => setSettings({ showOppositeLegendTitle: detail.checked })}
380+
>
381+
Show opposite legend title
382+
</Checkbox>
383+
);
354384
case "showLegendActions":
355385
return (
356386
<Checkbox

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,9 @@ export class ChartExtraLegend extends AsyncStore<ReactiveLegendState> {
8181

8282
private initLegend = () => {
8383
const itemSpecs = getChartLegendItems(this.context.chart());
84-
const legendItems = itemSpecs.map(({ id, name, color, markerType, visible }) => {
84+
const legendItems = itemSpecs.map(({ id, name, color, markerType, visible, oppositeAxis }) => {
8585
const marker = this.renderMarker(markerType, color, visible);
86-
return { id, name, marker, visible, highlighted: false };
86+
return { id, name, marker, visible, oppositeAxis, highlighted: false };
8787
});
8888
this.updateLegendItems(legendItems);
8989
};

src/core/chart-container.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ interface ChartContainerProps {
2929
filter?: React.ReactNode;
3030
navigator?: React.ReactNode;
3131
legend?: React.ReactNode;
32-
legendBottomMaxHeight?: number;
3332
legendPosition: "bottom" | "side";
3433
footer?: React.ReactNode;
3534
fitHeight?: boolean;
@@ -47,7 +46,6 @@ export function ChartContainer({
4746
footer,
4847
legend,
4948
legendPosition,
50-
legendBottomMaxHeight,
5149
navigator,
5250
fitHeight,
5351
chartHeight,
@@ -102,9 +100,7 @@ export function ChartContainer({
102100

103101
<div ref={refs.footer} style={chartMinWidth !== undefined ? { minInlineSize: chartMinWidth } : {}}>
104102
{navigator && <div className={testClasses["chart-navigator"]}>{navigator}</div>}
105-
{legend &&
106-
legendPosition === "bottom" &&
107-
(legendBottomMaxHeight ? <div style={{ maxHeight: `${legendBottomMaxHeight}px` }}>{legend}</div> : legend)}
103+
{legendPosition === "bottom" && legend}
108104
{footer}
109105
</div>
110106
</div>

src/core/chart-core.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export function InternalCoreChart({
8383
const rootRef = useRef<HTMLDivElement>(null);
8484
const mergedRootRef = useMergeRefs(rootRef, __internalRootRef);
8585
const rootProps = { ref: mergedRootRef, className: rootClassName, ...getDataAttributes(rest) };
86+
const legendType = legendOptions?.type ?? "single";
8687
const legendPosition = legendOptions?.position ?? "bottom";
8788
const containerProps = {
8889
fitHeight,
@@ -91,7 +92,6 @@ export function InternalCoreChart({
9192
chartMinWidth,
9293
legendPosition,
9394
verticalAxisTitlePlacement,
94-
legendBottomMaxHeight: legendOptions?.bottomMaxHeight,
9595
};
9696

9797
// Render fallback using the same root and container props as for the chart to ensure consistent
@@ -305,6 +305,7 @@ export function InternalCoreChart({
305305
context.legendEnabled ? (
306306
<ChartLegend
307307
{...legendOptions}
308+
type={legendType}
308309
position={legendPosition}
309310
api={api}
310311
i18nStrings={i18nStrings}

src/core/components/core-legend.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,20 @@ export function ChartLegend({
1212
api,
1313
title,
1414
actions,
15+
type,
1516
position,
1617
i18nStrings,
18+
bottomMaxHeight,
19+
oppositeLegendTitle,
1720
onItemHighlight,
1821
getLegendTooltipContent,
1922
}: {
2023
api: ChartAPI;
2124
title?: string;
25+
oppositeLegendTitle?: string;
26+
bottomMaxHeight?: number;
2227
actions?: React.ReactNode;
28+
type: "single" | "dual";
2329
position: "bottom" | "side";
2430
i18nStrings?: BaseI18nStrings;
2531
onItemHighlight?: (detail: CoreChartProps.LegendItemHighlightDetail) => void;
@@ -36,10 +42,13 @@ export function ChartLegend({
3642
return (
3743
<ChartLegendComponent
3844
ariaLabel={ariaLabel}
39-
legendTitle={title}
45+
type={type}
46+
defaultTitle={title}
47+
oppositeTitle={oppositeLegendTitle}
4048
items={legendItems}
4149
actions={actions}
4250
position={position}
51+
bottomMaxHeight={bottomMaxHeight}
4352
onItemVisibilityChange={api.onItemVisibilityChange}
4453
onItemHighlightExit={api.onClearChartItemsHighlight}
4554
onItemHighlightEnter={(item) => {

src/core/interfaces.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,9 @@ export namespace CoreChartProps {
408408

409409
export interface LegendOptions extends BaseLegendOptions {
410410
bottomMaxHeight?: number;
411+
type?: "single" | "dual";
411412
position?: "bottom" | "side";
413+
oppositeLegendTitle?: string;
412414
}
413415
export type LegendItem = InternalComponentTypes.LegendItem;
414416
export type LegendTooltipContent = InternalComponentTypes.LegendTooltipContent;

src/core/styles.scss

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,6 @@ $side-legend-min-inline-size: max(20%, 150px);
113113

114114
.side-legend-container {
115115
flex: 0;
116-
overflow-y: auto;
117116
max-inline-size: $side-legend-max-inline-size;
118117
min-inline-size: $side-legend-min-inline-size;
119118
}

src/core/utils.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface LegendItemSpec {
1515
markerType: ChartSeriesMarkerType;
1616
color: string;
1717
visible: boolean;
18+
oppositeAxis: boolean;
1819
}
1920

2021
// The below functions extract unique identifier from series, point, or options. The identifier can be item's ID or name.
@@ -149,6 +150,7 @@ export function getChartLegendItems(chart: Highcharts.Chart): readonly LegendIte
149150
markerType: getSeriesMarkerType(series),
150151
color: getSeriesColor(series),
151152
visible: series.visible,
153+
oppositeAxis: series.yAxis.options.opposite ?? false,
152154
});
153155
}
154156
};
@@ -160,6 +162,7 @@ export function getChartLegendItems(chart: Highcharts.Chart): readonly LegendIte
160162
markerType: getSeriesMarkerType(point.series),
161163
color: getPointColor(point),
162164
visible: point.visible,
165+
oppositeAxis: false,
163166
});
164167
}
165168
};

0 commit comments

Comments
 (0)