@react-magma/charts 14.0.0-next.2 → 14.0.0-next.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/charts.js +851 -69
- package/dist/charts.js.map +1 -1
- package/dist/charts.modern.module.js +847 -72
- package/dist/charts.modern.module.js.map +1 -1
- package/dist/charts.umd.js +3088 -680
- package/dist/charts.umd.js.map +1 -1
- package/dist/components/CarbonChart/CarbonChart.d.ts +46 -0
- package/dist/components/CarbonChart/CarbonChartBar.stories.d.ts +27 -9
- package/dist/components/ChartTable/ChartDataTable.d.ts +19 -0
- package/dist/components/ChartTable/ChartFullscreenButton.d.ts +26 -0
- package/dist/components/ChartTable/ChartMoreOptionsButton.d.ts +18 -0
- package/dist/components/ChartTable/ChartTable.stories.d.ts +116 -0
- package/dist/components/ChartTable/ChartTableButton.d.ts +24 -0
- package/dist/components/ChartTable/ChartTableModal.d.ts +44 -0
- package/dist/components/ChartTable/ChartToolbar.d.ts +19 -0
- package/dist/components/ChartTable/chartToolbarI18n.d.ts +17 -0
- package/dist/components/ChartTable/index.d.ts +14 -0
- package/dist/components/LineChart/DataTable.d.ts +1 -1
- package/dist/hooks/useCarbonModalFocusManagement.d.ts +2 -0
- package/dist/index.d.ts +1 -0
- package/package.json +5 -5
- package/src/components/CarbonChart/CarbonChart.test.js +645 -2
- package/src/components/CarbonChart/CarbonChart.tsx +950 -59
- package/src/components/CarbonChart/CarbonChartArea.stories.tsx +1 -1
- package/src/components/CarbonChart/CarbonChartAreaStacked.stories.tsx +1 -1
- package/src/components/CarbonChart/CarbonChartBar.stories.tsx +9 -2
- package/src/components/CarbonChart/CarbonChartBarFloating.stories.tsx +1 -1
- package/src/components/CarbonChart/CarbonChartBarGrouped.stories.tsx +1 -1
- package/src/components/CarbonChart/CarbonChartBarStacked.stories.tsx +1 -1
- package/src/components/CarbonChart/CarbonChartBoxplot.stories.tsx +1 -1
- package/src/components/CarbonChart/CarbonChartBubble.stories.tsx +1 -1
- package/src/components/CarbonChart/CarbonChartBullet.stories.tsx +1 -1
- package/src/components/CarbonChart/CarbonChartCombo.stories.tsx +1 -1
- package/src/components/CarbonChart/CarbonChartDonut.stories.tsx +3 -1
- package/src/components/CarbonChart/CarbonChartGauge.stories.tsx +1 -1
- package/src/components/CarbonChart/CarbonChartHistogram.stories.tsx +1 -1
- package/src/components/CarbonChart/CarbonChartLine.stories.tsx +1 -1
- package/src/components/CarbonChart/CarbonChartLollipop.stories.tsx +1 -1
- package/src/components/CarbonChart/CarbonChartMeter.stories.tsx +1 -1
- package/src/components/CarbonChart/CarbonChartPie.stories.tsx +1 -1
- package/src/components/CarbonChart/CarbonChartRadar.stories.tsx +1 -1
- package/src/components/CarbonChart/CarbonChartScatter.stories.tsx +1 -1
- package/src/components/CarbonChart/CarbonChartSparkline.stories.tsx +1 -1
- package/src/components/CarbonChart/CarbonChartStep.stories.tsx +1 -1
- package/src/components/ChartTable/ChartDataTable.tsx +72 -0
- package/src/components/ChartTable/ChartFullscreenButton.tsx +59 -0
- package/src/components/ChartTable/ChartMoreOptionsButton.tsx +48 -0
- package/src/components/ChartTable/ChartTable.stories.tsx +152 -0
- package/src/components/ChartTable/ChartTable.test.js +444 -0
- package/src/components/ChartTable/ChartTableButton.tsx +55 -0
- package/src/components/ChartTable/ChartTableModal.tsx +135 -0
- package/src/components/ChartTable/ChartToolbar.tsx +50 -0
- package/src/components/ChartTable/chartToolbarI18n.ts +59 -0
- package/src/components/ChartTable/index.ts +23 -0
- package/src/components/LineChart/DataTable.tsx +3 -3
- package/src/components/LineChart/LineChart.tsx +1 -1
- package/src/hooks/useCarbonModalFocusManagement.ts +173 -0
- package/src/index.ts +1 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* eslint-disable complexity */
|
|
1
2
|
import * as React from 'react';
|
|
2
3
|
|
|
3
4
|
import {
|
|
@@ -23,9 +24,33 @@ import {
|
|
|
23
24
|
} from '@carbon/charts-react';
|
|
24
25
|
import styled from '@emotion/styled';
|
|
25
26
|
import { transparentize } from 'polished';
|
|
26
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
Announce,
|
|
29
|
+
DropdownDivider,
|
|
30
|
+
DropdownMenuItem,
|
|
31
|
+
ThemeInterface,
|
|
32
|
+
ThemeContext,
|
|
33
|
+
useIsInverse,
|
|
34
|
+
VisuallyHidden,
|
|
35
|
+
} from 'react-magma-dom';
|
|
36
|
+
import {
|
|
37
|
+
FullscreenExitIcon,
|
|
38
|
+
FullscreenIcon,
|
|
39
|
+
MoreVertIcon,
|
|
40
|
+
TableIcon,
|
|
41
|
+
} from 'react-magma-icons';
|
|
27
42
|
|
|
43
|
+
import { useCarbonModalFocusManagement } from '../../hooks/useCarbonModalFocusManagement';
|
|
44
|
+
// @ts-ignore
|
|
28
45
|
import './carbon-charts.css';
|
|
46
|
+
import {
|
|
47
|
+
ChartFullscreenButton,
|
|
48
|
+
ChartMoreOptionsButton,
|
|
49
|
+
ChartTableButton,
|
|
50
|
+
ChartTableModal,
|
|
51
|
+
} from '../ChartTable';
|
|
52
|
+
import type { ChartDataTableColumn } from '../ChartTable';
|
|
53
|
+
import { useChartToolbarI18n } from '../ChartTable/chartToolbarI18n';
|
|
29
54
|
|
|
30
55
|
export enum CarbonChartType {
|
|
31
56
|
area = 'area',
|
|
@@ -48,6 +73,46 @@ export enum CarbonChartType {
|
|
|
48
73
|
combo = 'combo',
|
|
49
74
|
}
|
|
50
75
|
|
|
76
|
+
export interface ChartToolbarConfig {
|
|
77
|
+
/**
|
|
78
|
+
* When true, renders a "Show as table" button that opens a Magma Modal
|
|
79
|
+
* with the chart data in an accessible table.
|
|
80
|
+
* @default true
|
|
81
|
+
*/
|
|
82
|
+
showAsTable?: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* When true, renders a fullscreen toggle button.
|
|
85
|
+
* @default true
|
|
86
|
+
*/
|
|
87
|
+
fullscreen?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Additional menu items rendered inside a "More options" dropdown,
|
|
90
|
+
* below the built-in "Download as CSV", "Download as PNG", and "Download as JPG" items.
|
|
91
|
+
* Pass DropdownMenuItem elements.
|
|
92
|
+
*/
|
|
93
|
+
moreOptions?: React.ReactNode;
|
|
94
|
+
/**
|
|
95
|
+
* Custom column definitions for the table modal.
|
|
96
|
+
* If omitted, columns are auto-derived from the dataset object keys.
|
|
97
|
+
*/
|
|
98
|
+
tableColumns?: ChartDataTableColumn[];
|
|
99
|
+
/**
|
|
100
|
+
* First line of the modal heading.
|
|
101
|
+
* @default "Tabular representation"
|
|
102
|
+
*/
|
|
103
|
+
tableHeaderLabel?: string;
|
|
104
|
+
/**
|
|
105
|
+
* Heading level for the modal header.
|
|
106
|
+
* @default 2
|
|
107
|
+
*/
|
|
108
|
+
tableHeaderLevel?: 1 | 2 | 3 | 4 | 5 | 6;
|
|
109
|
+
/**
|
|
110
|
+
* Heading level for the chart title.
|
|
111
|
+
* @default 2
|
|
112
|
+
*/
|
|
113
|
+
titleLevel?: 1 | 2 | 3 | 4 | 5 | 6;
|
|
114
|
+
}
|
|
115
|
+
|
|
51
116
|
export interface CarbonChartProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
52
117
|
dataSet: Array<Object>;
|
|
53
118
|
isInverse?: boolean;
|
|
@@ -68,13 +133,72 @@ export interface CarbonChartProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
68
133
|
* Text for the aria-label attribute for main SVG container, if provided
|
|
69
134
|
*/
|
|
70
135
|
ariaLabel?: string;
|
|
136
|
+
/**
|
|
137
|
+
* When provided, renders an accessible Magma toolbar above the chart with
|
|
138
|
+
* "Show as table", fullscreen, and "More options" buttons. Carbon's built-in
|
|
139
|
+
* toolbar is automatically disabled.
|
|
140
|
+
*/
|
|
141
|
+
chartToolbar?: ChartToolbarConfig;
|
|
71
142
|
}
|
|
72
143
|
|
|
144
|
+
const ChartContentWrapper = styled.div`
|
|
145
|
+
height: 100%;
|
|
146
|
+
position: relative;
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
const FullscreenRoot = styled.div<{
|
|
150
|
+
isInverse?: boolean;
|
|
151
|
+
theme: ThemeInterface;
|
|
152
|
+
}>`
|
|
153
|
+
height: 100%;
|
|
154
|
+
width: 100%;
|
|
155
|
+
|
|
156
|
+
&:fullscreen,
|
|
157
|
+
&:-webkit-full-screen {
|
|
158
|
+
background: ${props =>
|
|
159
|
+
props.isInverse
|
|
160
|
+
? props.theme.colors.primary700
|
|
161
|
+
: props.theme.colors.neutral100};
|
|
162
|
+
.cds--chart-holder {
|
|
163
|
+
height: 100vh !important;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
`;
|
|
167
|
+
|
|
73
168
|
const CarbonChartWrapper = styled.div<{
|
|
74
169
|
isInverse?: boolean;
|
|
75
170
|
groupsLength: number;
|
|
76
171
|
theme: ThemeInterface;
|
|
77
172
|
}>`
|
|
173
|
+
.cds--cc--legend-fieldset {
|
|
174
|
+
border: 0;
|
|
175
|
+
margin: 0;
|
|
176
|
+
min-inline-size: 0;
|
|
177
|
+
padding: 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.cds--cc--legend-fieldset > legend {
|
|
181
|
+
clip: rect(1px, 1px, 1px, 1px);
|
|
182
|
+
height: 1px;
|
|
183
|
+
overflow: hidden;
|
|
184
|
+
position: absolute;
|
|
185
|
+
top: auto;
|
|
186
|
+
white-space: nowrap;
|
|
187
|
+
width: 1px;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
&:fullscreen,
|
|
191
|
+
&:-webkit-full-screen {
|
|
192
|
+
background: ${props =>
|
|
193
|
+
props.isInverse
|
|
194
|
+
? props.theme.colors.primary700
|
|
195
|
+
: props.theme.colors.neutral100};
|
|
196
|
+
|
|
197
|
+
.cds--chart-holder {
|
|
198
|
+
height: 100vh !important;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
78
202
|
.cds--data-table thead tr th {
|
|
79
203
|
background: ${props =>
|
|
80
204
|
props.isInverse ? props.theme.colors.primary700 : ''} !important;
|
|
@@ -130,9 +254,9 @@ const CarbonChartWrapper = styled.div<{
|
|
|
130
254
|
}
|
|
131
255
|
}
|
|
132
256
|
|
|
133
|
-
p,
|
|
134
|
-
div,
|
|
135
|
-
text,
|
|
257
|
+
.chart-holder p,
|
|
258
|
+
.chart-holder div,
|
|
259
|
+
.chart-holder text,
|
|
136
260
|
.cds--cc--axes g.axis .axis-title,
|
|
137
261
|
.cds--cc--title p.title,
|
|
138
262
|
.cds--cc--axes g.axis g.tick text {
|
|
@@ -391,6 +515,9 @@ const CarbonChartWrapper = styled.div<{
|
|
|
391
515
|
: props.theme.colors.focus} !important;
|
|
392
516
|
outline-offset: 0;
|
|
393
517
|
}
|
|
518
|
+
circle.dot:focus {
|
|
519
|
+
outline: none !important;
|
|
520
|
+
}
|
|
394
521
|
.cds--overflow-menu-options__btn:focus,
|
|
395
522
|
.cds--overflow-menu:focus,
|
|
396
523
|
.cds--overflow-menu__trigger:focus,
|
|
@@ -487,10 +614,25 @@ const CarbonChartWrapper = styled.div<{
|
|
|
487
614
|
}
|
|
488
615
|
}
|
|
489
616
|
|
|
617
|
+
&.has-magma-toolbar .cds--cc--title p.title {
|
|
618
|
+
visibility: hidden;
|
|
619
|
+
}
|
|
620
|
+
|
|
490
621
|
svg:not(:root) {
|
|
491
622
|
overflow: visible;
|
|
492
623
|
}
|
|
493
624
|
|
|
625
|
+
circle.dot:focus {
|
|
626
|
+
outline: none;
|
|
627
|
+
stroke: ${props =>
|
|
628
|
+
props.isInverse
|
|
629
|
+
? props.theme.colors.focusInverse
|
|
630
|
+
: props.theme.colors.focus} !important;
|
|
631
|
+
stroke-width: 6px !important;
|
|
632
|
+
stroke-opacity: 1 !important;
|
|
633
|
+
paint-order: stroke fill;
|
|
634
|
+
}
|
|
635
|
+
|
|
494
636
|
.cds--cc--chart-wrapper text {
|
|
495
637
|
font-size: ${props => props.theme.typeScale.size02.fontSize};
|
|
496
638
|
}
|
|
@@ -534,6 +676,436 @@ interface ColorsObject {
|
|
|
534
676
|
[key: string]: string;
|
|
535
677
|
}
|
|
536
678
|
|
|
679
|
+
interface ExtendedChartOptions extends ChartOptions {
|
|
680
|
+
title?: string;
|
|
681
|
+
colors?: string[];
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const ToolbarWrapper = styled.div<{
|
|
685
|
+
isFullscreen?: boolean;
|
|
686
|
+
isInverse?: boolean;
|
|
687
|
+
theme: ThemeInterface;
|
|
688
|
+
}>`
|
|
689
|
+
align-items: center;
|
|
690
|
+
display: flex;
|
|
691
|
+
justify-content: space-between;
|
|
692
|
+
left: ${props => (props.isFullscreen ? '2em' : '0')};
|
|
693
|
+
position: absolute;
|
|
694
|
+
right: ${props => (props.isFullscreen ? '2em' : '0')};
|
|
695
|
+
top: ${props => (props.isFullscreen ? '2em' : '0')};
|
|
696
|
+
z-index: 2;
|
|
697
|
+
|
|
698
|
+
button {
|
|
699
|
+
color: ${props =>
|
|
700
|
+
props.isInverse
|
|
701
|
+
? props.theme.colors.neutral100
|
|
702
|
+
: props.theme.colors.primary500};
|
|
703
|
+
|
|
704
|
+
&:focus {
|
|
705
|
+
outline: 2px solid
|
|
706
|
+
${props =>
|
|
707
|
+
props.isInverse
|
|
708
|
+
? props.theme.colors.focusInverse
|
|
709
|
+
: props.theme.colors.focus};
|
|
710
|
+
outline-offset: 0;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
`;
|
|
714
|
+
|
|
715
|
+
const ChartTitle = styled.h2<{
|
|
716
|
+
isInverse?: boolean;
|
|
717
|
+
theme: ThemeInterface;
|
|
718
|
+
}>`
|
|
719
|
+
font-family: ${props => props.theme.bodyFont} !important;
|
|
720
|
+
font-size: 16px !important;
|
|
721
|
+
font-weight: 700 !important;
|
|
722
|
+
letter-spacing: normal !important;
|
|
723
|
+
line-height: 1.25 !important;
|
|
724
|
+
margin: 0 !important;
|
|
725
|
+
color: ${props =>
|
|
726
|
+
props.isInverse
|
|
727
|
+
? props.theme.colors.neutral100
|
|
728
|
+
: props.theme.colors.neutral700} !important;
|
|
729
|
+
`;
|
|
730
|
+
|
|
731
|
+
const ToolbarActions = styled.div<{ theme: ThemeInterface }>`
|
|
732
|
+
align-items: center;
|
|
733
|
+
display: flex;
|
|
734
|
+
gap: ${props => props.theme.spaceScale.spacing02};
|
|
735
|
+
|
|
736
|
+
> * button,
|
|
737
|
+
> button {
|
|
738
|
+
height: 2rem;
|
|
739
|
+
min-height: 2rem;
|
|
740
|
+
min-width: 2rem;
|
|
741
|
+
padding: 4px;
|
|
742
|
+
width: 2rem;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
[role='tooltip'] {
|
|
746
|
+
padding: ${props => props.theme.spaceScale.spacing03}
|
|
747
|
+
${props => props.theme.spaceScale.spacing04};
|
|
748
|
+
text-align: center;
|
|
749
|
+
white-space: nowrap;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
[data-testid='dropdownContent'] {
|
|
753
|
+
padding: ${props => props.theme.spaceScale.spacing03} 0;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
[role='menuitem'],
|
|
757
|
+
[data-testid='dropdownMenuItem'] {
|
|
758
|
+
padding: ${props => props.theme.spaceScale.spacing03}
|
|
759
|
+
${props => props.theme.spaceScale.spacing05};
|
|
760
|
+
}
|
|
761
|
+
`;
|
|
762
|
+
|
|
763
|
+
function sanitizeCsvValue(value: string): string {
|
|
764
|
+
const trimmed = value.trimStart();
|
|
765
|
+
if (/^[=+\-@\t\r]/.test(trimmed)) {
|
|
766
|
+
return `'${value}`;
|
|
767
|
+
}
|
|
768
|
+
return value;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function downloadCsv(dataSet: Array<Record<string, unknown>>, title: string) {
|
|
772
|
+
if (!dataSet.length) return;
|
|
773
|
+
const keys = Object.keys(dataSet[0]);
|
|
774
|
+
const header = keys.map(k => sanitizeCsvValue(k)).join(',');
|
|
775
|
+
const rows = dataSet.map(row =>
|
|
776
|
+
keys
|
|
777
|
+
.map(k => {
|
|
778
|
+
const v = row[k];
|
|
779
|
+
const s = sanitizeCsvValue(String(v ?? ''));
|
|
780
|
+
return s.includes(',') || s.includes('"') || s.includes('\n')
|
|
781
|
+
? `"${s.replace(/"/g, '""')}"`
|
|
782
|
+
: s;
|
|
783
|
+
})
|
|
784
|
+
.join(',')
|
|
785
|
+
);
|
|
786
|
+
const csv = [header, ...rows].join('\n');
|
|
787
|
+
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
|
788
|
+
const url = URL.createObjectURL(blob);
|
|
789
|
+
const a = document.createElement('a');
|
|
790
|
+
a.href = url;
|
|
791
|
+
a.download = `${title || 'chart-data'}.csv`;
|
|
792
|
+
a.click();
|
|
793
|
+
URL.revokeObjectURL(url);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function inlineStyles(source: Element, target: Element) {
|
|
797
|
+
const computed = window.getComputedStyle(source);
|
|
798
|
+
if (target instanceof HTMLElement || target instanceof SVGElement) {
|
|
799
|
+
target.setAttribute(
|
|
800
|
+
'style',
|
|
801
|
+
Array.from(computed)
|
|
802
|
+
.map(prop => `${prop}:${computed.getPropertyValue(prop)}`)
|
|
803
|
+
.join(';')
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
for (let i = 0; i < source.children.length; i++) {
|
|
807
|
+
if (target.children[i]) {
|
|
808
|
+
inlineStyles(source.children[i], target.children[i]);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
interface LegendItem {
|
|
814
|
+
label: string;
|
|
815
|
+
color: string;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function readLegendItems(wrapper: HTMLElement): LegendItem[] {
|
|
819
|
+
const items: LegendItem[] = [];
|
|
820
|
+
wrapper.querySelectorAll('.legend-item').forEach(item => {
|
|
821
|
+
const checkbox = item.querySelector<HTMLElement>('.checkbox');
|
|
822
|
+
const label = item.querySelector('p');
|
|
823
|
+
if (checkbox && label) {
|
|
824
|
+
items.push({
|
|
825
|
+
color: checkbox.style.background || checkbox.style.backgroundColor,
|
|
826
|
+
label: label.textContent || '',
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
return items;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function drawLegend(
|
|
834
|
+
ctx: CanvasRenderingContext2D,
|
|
835
|
+
items: LegendItem[],
|
|
836
|
+
startY: number,
|
|
837
|
+
canvasWidth: number,
|
|
838
|
+
scale: number
|
|
839
|
+
) {
|
|
840
|
+
const fontSize = 13 * scale;
|
|
841
|
+
const swatchSize = 12 * scale;
|
|
842
|
+
const gap = 8 * scale;
|
|
843
|
+
const itemGap = 16 * scale;
|
|
844
|
+
const paddingX = 16 * scale;
|
|
845
|
+
|
|
846
|
+
ctx.font = `${fontSize}px sans-serif`;
|
|
847
|
+
ctx.textBaseline = 'middle';
|
|
848
|
+
|
|
849
|
+
let x = paddingX;
|
|
850
|
+
let y = startY;
|
|
851
|
+
|
|
852
|
+
for (const item of items) {
|
|
853
|
+
const textWidth = ctx.measureText(item.label).width;
|
|
854
|
+
const itemWidth = swatchSize + gap + textWidth + itemGap;
|
|
855
|
+
|
|
856
|
+
if (x + itemWidth > canvasWidth - paddingX && x > paddingX) {
|
|
857
|
+
x = paddingX;
|
|
858
|
+
y += fontSize + gap;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
ctx.fillStyle = item.color;
|
|
862
|
+
ctx.fillRect(x, y - swatchSize / 2, swatchSize, swatchSize);
|
|
863
|
+
|
|
864
|
+
ctx.fillStyle = '#161616';
|
|
865
|
+
ctx.fillText(item.label, x + swatchSize + gap, y);
|
|
866
|
+
|
|
867
|
+
x += itemWidth;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return y + fontSize + gap;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function downloadImage(
|
|
874
|
+
wrapperRef: React.RefObject<HTMLDivElement | null>,
|
|
875
|
+
title: string,
|
|
876
|
+
format: 'png' | 'jpg'
|
|
877
|
+
) {
|
|
878
|
+
const wrapper = wrapperRef.current;
|
|
879
|
+
if (!wrapper) return;
|
|
880
|
+
const svg = wrapper.querySelector('svg.layout-svg-wrapper');
|
|
881
|
+
if (!svg) return;
|
|
882
|
+
|
|
883
|
+
const svgRect = svg.getBoundingClientRect();
|
|
884
|
+
const legendItems = readLegendItems(wrapper);
|
|
885
|
+
|
|
886
|
+
const doWork = () => {
|
|
887
|
+
const scale = 2;
|
|
888
|
+
|
|
889
|
+
// Measure legend height
|
|
890
|
+
const tempCanvas = document.createElement('canvas');
|
|
891
|
+
const tempCtx = tempCanvas.getContext('2d');
|
|
892
|
+
const fontSize = 13 * scale;
|
|
893
|
+
const swatchSize = 12 * scale;
|
|
894
|
+
const gap = 8 * scale;
|
|
895
|
+
const itemGap = 16 * scale;
|
|
896
|
+
const paddingX = 16 * scale;
|
|
897
|
+
const canvasWidth = svgRect.width * scale;
|
|
898
|
+
|
|
899
|
+
let legendHeight = 0;
|
|
900
|
+
if (legendItems.length > 0 && tempCtx) {
|
|
901
|
+
tempCtx.font = `${fontSize}px sans-serif`;
|
|
902
|
+
let x = paddingX;
|
|
903
|
+
let rows = 1;
|
|
904
|
+
for (const item of legendItems) {
|
|
905
|
+
const textWidth = tempCtx.measureText(item.label).width;
|
|
906
|
+
const itemWidth = swatchSize + gap + textWidth + itemGap;
|
|
907
|
+
if (x + itemWidth > canvasWidth - paddingX && x > paddingX) {
|
|
908
|
+
x = paddingX;
|
|
909
|
+
rows++;
|
|
910
|
+
}
|
|
911
|
+
x += itemWidth;
|
|
912
|
+
}
|
|
913
|
+
legendHeight = rows * (fontSize + gap) + gap * 2;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const width = svgRect.width * scale;
|
|
917
|
+
const height = svgRect.height * scale + legendHeight;
|
|
918
|
+
|
|
919
|
+
const clone = svg.cloneNode(true) as SVGSVGElement;
|
|
920
|
+
clone.setAttribute('width', String(svgRect.width));
|
|
921
|
+
clone.setAttribute('height', String(svgRect.height));
|
|
922
|
+
clone.setAttribute('viewBox', `0 0 ${svgRect.width} ${svgRect.height}`);
|
|
923
|
+
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
924
|
+
|
|
925
|
+
inlineStyles(svg, clone);
|
|
926
|
+
|
|
927
|
+
const serializer = new XMLSerializer();
|
|
928
|
+
const svgString = serializer.serializeToString(clone);
|
|
929
|
+
const svgBlob = new Blob([svgString], {
|
|
930
|
+
type: 'image/svg+xml;charset=utf-8',
|
|
931
|
+
});
|
|
932
|
+
const url = URL.createObjectURL(svgBlob);
|
|
933
|
+
|
|
934
|
+
const mimeType = format === 'jpg' ? 'image/jpeg' : 'image/png';
|
|
935
|
+
const ext = format === 'jpg' ? 'jpg' : 'png';
|
|
936
|
+
|
|
937
|
+
const img = new Image();
|
|
938
|
+
img.onload = () => {
|
|
939
|
+
const canvas = document.createElement('canvas');
|
|
940
|
+
canvas.width = width;
|
|
941
|
+
canvas.height = height;
|
|
942
|
+
const ctx = canvas.getContext('2d');
|
|
943
|
+
if (!ctx) return;
|
|
944
|
+
|
|
945
|
+
ctx.fillStyle = '#ffffff';
|
|
946
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
947
|
+
ctx.drawImage(img, 0, 0, svgRect.width * scale, svgRect.height * scale);
|
|
948
|
+
URL.revokeObjectURL(url);
|
|
949
|
+
|
|
950
|
+
if (legendItems.length > 0) {
|
|
951
|
+
drawLegend(
|
|
952
|
+
ctx,
|
|
953
|
+
legendItems,
|
|
954
|
+
svgRect.height * scale + gap,
|
|
955
|
+
width,
|
|
956
|
+
scale
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
canvas.toBlob(blob => {
|
|
961
|
+
if (!blob) return;
|
|
962
|
+
const imgUrl = URL.createObjectURL(blob);
|
|
963
|
+
const a = document.createElement('a');
|
|
964
|
+
a.href = imgUrl;
|
|
965
|
+
a.download = `${title || 'chart'}.${ext}`;
|
|
966
|
+
a.click();
|
|
967
|
+
URL.revokeObjectURL(imgUrl);
|
|
968
|
+
}, mimeType);
|
|
969
|
+
};
|
|
970
|
+
img.onerror = () => URL.revokeObjectURL(url);
|
|
971
|
+
img.src = url;
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
// Defer to idle time with a 2-second deadline so it always runs.
|
|
975
|
+
if (typeof requestIdleCallback === 'function') {
|
|
976
|
+
requestIdleCallback(doWork, { timeout: 2000 });
|
|
977
|
+
} else {
|
|
978
|
+
setTimeout(doWork, 0);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const ALL_CHARTS: Record<CarbonChartType, React.ComponentType<any>> = {
|
|
983
|
+
area: AreaChart,
|
|
984
|
+
areaStacked: StackedAreaChart,
|
|
985
|
+
bar: SimpleBarChart,
|
|
986
|
+
barGrouped: GroupedBarChart,
|
|
987
|
+
barStacked: StackedBarChart,
|
|
988
|
+
donut: DonutChart,
|
|
989
|
+
line: LineChart,
|
|
990
|
+
lollipop: LollipopChart,
|
|
991
|
+
pie: PieChart,
|
|
992
|
+
radar: RadarChart,
|
|
993
|
+
boxplot: BoxplotChart,
|
|
994
|
+
bubble: BubbleChart,
|
|
995
|
+
bullet: BulletChart,
|
|
996
|
+
gauge: GaugeChart,
|
|
997
|
+
histogram: HistogramChart,
|
|
998
|
+
meter: MeterChart,
|
|
999
|
+
scatter: ScatterChart,
|
|
1000
|
+
combo: ComboChart,
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
interface InternalToolbarProps {
|
|
1004
|
+
config: ChartToolbarConfig;
|
|
1005
|
+
dataSet: Array<Record<string, unknown>>;
|
|
1006
|
+
isInverse: boolean;
|
|
1007
|
+
isTableOpen: boolean;
|
|
1008
|
+
isFullscreen: boolean;
|
|
1009
|
+
onOpenTable: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
|
1010
|
+
onToggleFullscreen: () => void;
|
|
1011
|
+
theme: ThemeInterface;
|
|
1012
|
+
title: string;
|
|
1013
|
+
wrapperRef: React.RefObject<HTMLDivElement | null>;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function CarbonChartToolbar({
|
|
1017
|
+
config,
|
|
1018
|
+
dataSet,
|
|
1019
|
+
isInverse,
|
|
1020
|
+
isTableOpen,
|
|
1021
|
+
isFullscreen,
|
|
1022
|
+
onOpenTable,
|
|
1023
|
+
onToggleFullscreen,
|
|
1024
|
+
theme,
|
|
1025
|
+
title,
|
|
1026
|
+
wrapperRef,
|
|
1027
|
+
}: InternalToolbarProps) {
|
|
1028
|
+
const t = useChartToolbarI18n();
|
|
1029
|
+
const showTable = config.showAsTable !== false;
|
|
1030
|
+
const showFullscreen = config.fullscreen !== false;
|
|
1031
|
+
const resolvedTitle = title || t.defaultTitle;
|
|
1032
|
+
|
|
1033
|
+
const handleDownloadCsv = React.useCallback(() => {
|
|
1034
|
+
downloadCsv(dataSet, title);
|
|
1035
|
+
}, [dataSet, title]);
|
|
1036
|
+
|
|
1037
|
+
const handleDownloadPng = React.useCallback(() => {
|
|
1038
|
+
downloadImage(wrapperRef, title, 'png');
|
|
1039
|
+
}, [wrapperRef, title]);
|
|
1040
|
+
|
|
1041
|
+
const handleDownloadJpg = React.useCallback(() => {
|
|
1042
|
+
downloadImage(wrapperRef, title, 'jpg');
|
|
1043
|
+
}, [wrapperRef, title]);
|
|
1044
|
+
|
|
1045
|
+
const moreOptionsContent = (
|
|
1046
|
+
<>
|
|
1047
|
+
<DropdownMenuItem onClick={handleDownloadCsv}>
|
|
1048
|
+
{t.downloadAsCsv}
|
|
1049
|
+
</DropdownMenuItem>
|
|
1050
|
+
<DropdownMenuItem onClick={handleDownloadPng}>
|
|
1051
|
+
{t.downloadAsPng}
|
|
1052
|
+
</DropdownMenuItem>
|
|
1053
|
+
<DropdownMenuItem onClick={handleDownloadJpg}>
|
|
1054
|
+
{t.downloadAsJpg}
|
|
1055
|
+
</DropdownMenuItem>
|
|
1056
|
+
{config.moreOptions && (
|
|
1057
|
+
<>
|
|
1058
|
+
<DropdownDivider />
|
|
1059
|
+
{config.moreOptions}
|
|
1060
|
+
</>
|
|
1061
|
+
)}
|
|
1062
|
+
</>
|
|
1063
|
+
);
|
|
1064
|
+
|
|
1065
|
+
return (
|
|
1066
|
+
<ToolbarWrapper
|
|
1067
|
+
isFullscreen={isFullscreen}
|
|
1068
|
+
isInverse={isInverse}
|
|
1069
|
+
theme={theme}
|
|
1070
|
+
>
|
|
1071
|
+
<ChartTitle
|
|
1072
|
+
as={`h${config.titleLevel ?? 2}` as keyof JSX.IntrinsicElements}
|
|
1073
|
+
isInverse={isInverse}
|
|
1074
|
+
theme={theme}
|
|
1075
|
+
>
|
|
1076
|
+
{resolvedTitle}
|
|
1077
|
+
</ChartTitle>
|
|
1078
|
+
<ToolbarActions theme={theme}>
|
|
1079
|
+
{showTable && (
|
|
1080
|
+
<ChartTableButton
|
|
1081
|
+
ariaLabel={resolvedTitle}
|
|
1082
|
+
icon={<TableIcon size={20} />}
|
|
1083
|
+
isInverse={isInverse}
|
|
1084
|
+
isTableOpen={isTableOpen}
|
|
1085
|
+
onClick={onOpenTable}
|
|
1086
|
+
/>
|
|
1087
|
+
)}
|
|
1088
|
+
{showFullscreen && (
|
|
1089
|
+
<ChartFullscreenButton
|
|
1090
|
+
ariaLabel={`${isFullscreen ? 'Exit' : 'View'} ${resolvedTitle} full screen`}
|
|
1091
|
+
icon={<FullscreenIcon size={20} />}
|
|
1092
|
+
exitIcon={<FullscreenExitIcon size={20} />}
|
|
1093
|
+
isInverse={isInverse}
|
|
1094
|
+
isFullscreen={isFullscreen}
|
|
1095
|
+
onClick={onToggleFullscreen}
|
|
1096
|
+
/>
|
|
1097
|
+
)}
|
|
1098
|
+
<ChartMoreOptionsButton
|
|
1099
|
+
icon={<MoreVertIcon size={20} />}
|
|
1100
|
+
isInverse={isInverse}
|
|
1101
|
+
>
|
|
1102
|
+
{moreOptionsContent}
|
|
1103
|
+
</ChartMoreOptionsButton>
|
|
1104
|
+
</ToolbarActions>
|
|
1105
|
+
</ToolbarWrapper>
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
537
1109
|
export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
|
|
538
1110
|
(props, ref) => {
|
|
539
1111
|
const {
|
|
@@ -543,61 +1115,188 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
|
|
|
543
1115
|
dataSet,
|
|
544
1116
|
options,
|
|
545
1117
|
ariaLabel,
|
|
1118
|
+
chartToolbar,
|
|
546
1119
|
...rest
|
|
547
1120
|
} = props;
|
|
548
1121
|
const theme = React.useContext(ThemeContext);
|
|
549
1122
|
const isInverse = useIsInverse(isInverseProp);
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
areaStacked: StackedAreaChart,
|
|
553
|
-
bar: SimpleBarChart,
|
|
554
|
-
barGrouped: GroupedBarChart,
|
|
555
|
-
barStacked: StackedBarChart,
|
|
556
|
-
donut: DonutChart,
|
|
557
|
-
line: LineChart,
|
|
558
|
-
lollipop: LollipopChart,
|
|
559
|
-
pie: PieChart,
|
|
560
|
-
radar: RadarChart,
|
|
561
|
-
boxplot: BoxplotChart,
|
|
562
|
-
bubble: BubbleChart,
|
|
563
|
-
bullet: BulletChart,
|
|
564
|
-
gauge: GaugeChart,
|
|
565
|
-
histogram: HistogramChart,
|
|
566
|
-
meter: MeterChart,
|
|
567
|
-
scatter: ScatterChart,
|
|
568
|
-
combo: ComboChart,
|
|
569
|
-
};
|
|
1123
|
+
const toolbarI18n = useChartToolbarI18n();
|
|
1124
|
+
const internalRef = React.useRef<HTMLDivElement | null>(null);
|
|
570
1125
|
|
|
571
|
-
|
|
572
|
-
|
|
1126
|
+
const [isTableOpen, setIsTableOpen] = React.useState(false);
|
|
1127
|
+
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
|
1128
|
+
const [legendAnnouncement, setLegendAnnouncement] = React.useState('');
|
|
1129
|
+
const lastTableTriggerRef = React.useRef<HTMLButtonElement | null>(null);
|
|
1130
|
+
const legendAnnouncedRef = React.useRef(false);
|
|
573
1131
|
|
|
574
|
-
|
|
575
|
-
|
|
1132
|
+
const mergedRef = React.useCallback(
|
|
1133
|
+
(node: HTMLDivElement | null) => {
|
|
1134
|
+
internalRef.current = node;
|
|
1135
|
+
if (typeof ref === 'function') {
|
|
1136
|
+
ref(node);
|
|
1137
|
+
} else if (ref) {
|
|
1138
|
+
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
1139
|
+
}
|
|
1140
|
+
},
|
|
1141
|
+
[ref]
|
|
1142
|
+
);
|
|
1143
|
+
|
|
1144
|
+
const fullscreenEnabled = chartToolbar
|
|
1145
|
+
? chartToolbar.fullscreen !== false
|
|
1146
|
+
: false;
|
|
1147
|
+
|
|
1148
|
+
const savedHeightRef = React.useRef<string>('');
|
|
1149
|
+
|
|
1150
|
+
React.useEffect(() => {
|
|
1151
|
+
if (!fullscreenEnabled) return;
|
|
1152
|
+
|
|
1153
|
+
const onFullscreenChange = () => {
|
|
1154
|
+
const isFs = !!document.fullscreenElement;
|
|
1155
|
+
setIsFullscreen(isFs);
|
|
1156
|
+
|
|
1157
|
+
const chartHolder =
|
|
1158
|
+
internalRef.current?.querySelector<HTMLElement>('.cds--chart-holder');
|
|
1159
|
+
if (chartHolder) {
|
|
1160
|
+
if (isFs) {
|
|
1161
|
+
savedHeightRef.current = chartHolder.style.height;
|
|
1162
|
+
chartHolder.style.height = '100vh';
|
|
1163
|
+
} else {
|
|
1164
|
+
chartHolder.style.height = savedHeightRef.current;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
};
|
|
1168
|
+
document.addEventListener('fullscreenchange', onFullscreenChange);
|
|
1169
|
+
return () => {
|
|
1170
|
+
document.removeEventListener('fullscreenchange', onFullscreenChange);
|
|
1171
|
+
// Restore height if the component unmounts while in fullscreen.
|
|
1172
|
+
const chartHolder =
|
|
1173
|
+
internalRef.current?.querySelector<HTMLElement>('.cds--chart-holder');
|
|
1174
|
+
if (chartHolder && document.fullscreenElement) {
|
|
1175
|
+
chartHolder.style.height = savedHeightRef.current;
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
}, [fullscreenEnabled]);
|
|
1179
|
+
|
|
1180
|
+
const openTableModal = React.useCallback(
|
|
1181
|
+
(event: React.MouseEvent<HTMLButtonElement>) => {
|
|
1182
|
+
lastTableTriggerRef.current = event.currentTarget;
|
|
1183
|
+
setIsTableOpen(true);
|
|
1184
|
+
},
|
|
1185
|
+
[]
|
|
1186
|
+
);
|
|
1187
|
+
|
|
1188
|
+
const closeTableModal = React.useCallback(() => {
|
|
1189
|
+
setIsTableOpen(false);
|
|
1190
|
+
requestAnimationFrame(() => {
|
|
1191
|
+
lastTableTriggerRef.current?.focus();
|
|
576
1192
|
});
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
1193
|
+
}, []);
|
|
1194
|
+
|
|
1195
|
+
const toggleFullscreen = React.useCallback(() => {
|
|
1196
|
+
if (!document.fullscreenElement && internalRef.current) {
|
|
1197
|
+
internalRef.current.requestFullscreen().catch(() => {
|
|
1198
|
+
// Fullscreen request may be denied by the browser or user agent
|
|
1199
|
+
});
|
|
1200
|
+
} else if (document.fullscreenElement) {
|
|
1201
|
+
document.exitFullscreen().catch(() => {
|
|
1202
|
+
// Exit fullscreen may fail if already exited
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
}, []);
|
|
1206
|
+
|
|
1207
|
+
const chartTitle: string =
|
|
1208
|
+
(options as ExtendedChartOptions).title || toolbarI18n.defaultTitle;
|
|
1209
|
+
|
|
1210
|
+
const legendLabel = `${ariaLabel || chartTitle}. ${toolbarI18n.legendInstructions}`;
|
|
1211
|
+
|
|
1212
|
+
React.useEffect(() => {
|
|
1213
|
+
const container = internalRef.current;
|
|
1214
|
+
|
|
1215
|
+
if (!container) return;
|
|
1216
|
+
|
|
1217
|
+
const applyListSemantics = (legend: Element) => {
|
|
1218
|
+
legend.removeAttribute('aria-label');
|
|
1219
|
+
legend.setAttribute('role', 'list');
|
|
1220
|
+
legend
|
|
1221
|
+
.querySelectorAll('.legend-item')
|
|
1222
|
+
.forEach(item => item.setAttribute('role', 'listitem'));
|
|
1223
|
+
};
|
|
1224
|
+
|
|
1225
|
+
const wrapLegend = () => {
|
|
1226
|
+
const legend = container.querySelector('.cds--cc--legend');
|
|
1227
|
+
|
|
1228
|
+
if (!legend) return;
|
|
1229
|
+
|
|
1230
|
+
applyListSemantics(legend);
|
|
1231
|
+
|
|
1232
|
+
const parent = legend.parentElement;
|
|
1233
|
+
|
|
1234
|
+
if (parent?.tagName === 'FIELDSET') {
|
|
1235
|
+
const existingCaption = parent.querySelector('legend');
|
|
1236
|
+
|
|
1237
|
+
if (existingCaption && existingCaption.textContent !== legendLabel) {
|
|
1238
|
+
existingCaption.textContent = legendLabel;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
const fieldset = document.createElement('fieldset');
|
|
1245
|
+
const caption = document.createElement('legend');
|
|
1246
|
+
|
|
1247
|
+
fieldset.className = 'cds--cc--legend-fieldset';
|
|
1248
|
+
caption.textContent = legendLabel;
|
|
1249
|
+
legend.before(fieldset);
|
|
1250
|
+
fieldset.append(caption, legend);
|
|
1251
|
+
};
|
|
1252
|
+
|
|
1253
|
+
wrapLegend();
|
|
1254
|
+
const observer = new MutationObserver(wrapLegend);
|
|
1255
|
+
|
|
1256
|
+
observer.observe(container, { childList: true, subtree: true });
|
|
1257
|
+
|
|
1258
|
+
return () => observer.disconnect();
|
|
1259
|
+
}, [legendLabel]);
|
|
1260
|
+
|
|
1261
|
+
const handleModalDownloadCsv = React.useCallback(() => {
|
|
1262
|
+
downloadCsv(dataSet as Array<Record<string, unknown>>, chartTitle);
|
|
1263
|
+
}, [dataSet, chartTitle]);
|
|
1264
|
+
|
|
1265
|
+
useCarbonModalFocusManagement(internalRef);
|
|
1266
|
+
|
|
1267
|
+
const colorScale = React.useMemo(() => {
|
|
1268
|
+
const scaleColorsObj: ColorsObject = {};
|
|
1269
|
+
const customColors = (options as ExtendedChartOptions).colors || [];
|
|
581
1270
|
const allColors = [...customColors, ...theme.chartColors];
|
|
582
1271
|
const allInverseColors = [...customColors, ...theme.chartColorsInverse];
|
|
1272
|
+
const allGroups = dataSet.map(item =>
|
|
1273
|
+
'group' in item ? (item as Record<string, unknown>)['group'] : null
|
|
1274
|
+
);
|
|
1275
|
+
const uniqueGroups = Array.from(new Set(allGroups));
|
|
583
1276
|
|
|
584
|
-
uniqueGroups.
|
|
585
|
-
|
|
586
|
-
|
|
1277
|
+
if (uniqueGroups.length <= allColors.length) {
|
|
1278
|
+
for (let i = 0; i < uniqueGroups.length; i++) {
|
|
1279
|
+
const group = uniqueGroups[i];
|
|
1280
|
+
scaleColorsObj[String(group ?? 'null')] = isInverse
|
|
587
1281
|
? allInverseColors[i]
|
|
588
|
-
: allColors[i]
|
|
1282
|
+
: allColors[i];
|
|
589
1283
|
}
|
|
590
|
-
|
|
591
|
-
});
|
|
1284
|
+
}
|
|
592
1285
|
|
|
593
1286
|
return scaleColorsObj;
|
|
594
|
-
}
|
|
1287
|
+
}, [
|
|
1288
|
+
dataSet,
|
|
1289
|
+
options,
|
|
1290
|
+
theme.chartColors,
|
|
1291
|
+
theme.chartColorsInverse,
|
|
1292
|
+
isInverse,
|
|
1293
|
+
]);
|
|
595
1294
|
|
|
596
1295
|
const newOptions = {
|
|
597
1296
|
...options,
|
|
598
1297
|
theme: isInverse ? ChartTheme.G100 : ChartTheme.WHITE,
|
|
599
1298
|
color: {
|
|
600
|
-
scale:
|
|
1299
|
+
scale: colorScale,
|
|
601
1300
|
},
|
|
602
1301
|
tooltip: {
|
|
603
1302
|
...(options?.tooltip || {}),
|
|
@@ -605,33 +1304,225 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
|
|
|
605
1304
|
type: 'none',
|
|
606
1305
|
},
|
|
607
1306
|
},
|
|
1307
|
+
...(chartToolbar ? { toolbar: { enabled: false } } : {}),
|
|
608
1308
|
};
|
|
609
1309
|
|
|
610
|
-
const ChartType =
|
|
1310
|
+
const ChartType = ALL_CHARTS[type];
|
|
1311
|
+
|
|
1312
|
+
React.useEffect(() => {
|
|
1313
|
+
if (!ariaLabel) return;
|
|
1314
|
+
const rafId = requestAnimationFrame(() => {
|
|
1315
|
+
const svgEl = internalRef.current?.querySelector('.graph-frame');
|
|
1316
|
+
if (!svgEl) return;
|
|
1317
|
+
svgEl.setAttribute('aria-label', ariaLabel);
|
|
1318
|
+
});
|
|
1319
|
+
return () => cancelAnimationFrame(rafId);
|
|
1320
|
+
}, [ariaLabel]);
|
|
1321
|
+
|
|
1322
|
+
// Make Carbon Charts data points keyboard-focusable.
|
|
1323
|
+
React.useEffect(() => {
|
|
1324
|
+
const wrapper = internalRef.current;
|
|
1325
|
+
if (!wrapper) return;
|
|
1326
|
+
|
|
1327
|
+
const isDot = (el: EventTarget | null): el is SVGCircleElement =>
|
|
1328
|
+
el instanceof Element &&
|
|
1329
|
+
el.nodeName.toLowerCase() === 'circle' &&
|
|
1330
|
+
el.classList.contains('dot');
|
|
1331
|
+
|
|
1332
|
+
const onFocusIn = (e: FocusEvent) => {
|
|
1333
|
+
if (!isDot(e.target)) return;
|
|
1334
|
+
const dot = e.target as SVGCircleElement;
|
|
1335
|
+
dot.style.opacity = '1';
|
|
1336
|
+
const { left, top, width, height } = dot.getBoundingClientRect();
|
|
1337
|
+
const cx = left + width / 2;
|
|
1338
|
+
const cy = top + height / 2;
|
|
1339
|
+
dot.dispatchEvent(
|
|
1340
|
+
new MouseEvent('mouseover', {
|
|
1341
|
+
bubbles: true,
|
|
1342
|
+
clientX: cx,
|
|
1343
|
+
clientY: cy,
|
|
1344
|
+
screenX: cx,
|
|
1345
|
+
screenY: cy,
|
|
1346
|
+
})
|
|
1347
|
+
);
|
|
1348
|
+
dot.dispatchEvent(
|
|
1349
|
+
new MouseEvent('mousemove', {
|
|
1350
|
+
bubbles: true,
|
|
1351
|
+
clientX: cx,
|
|
1352
|
+
clientY: cy,
|
|
1353
|
+
screenX: cx,
|
|
1354
|
+
screenY: cy,
|
|
1355
|
+
})
|
|
1356
|
+
);
|
|
1357
|
+
};
|
|
1358
|
+
|
|
1359
|
+
const onFocusOut = (e: FocusEvent) => {
|
|
1360
|
+
if (!isDot(e.target)) return;
|
|
1361
|
+
const dot = e.target;
|
|
1362
|
+
dot.style.opacity = '';
|
|
1363
|
+
dot.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }));
|
|
1364
|
+
};
|
|
1365
|
+
|
|
1366
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
1367
|
+
if (!isDot(e.target)) return;
|
|
1368
|
+
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
1369
|
+
e.preventDefault();
|
|
1370
|
+
const dot = e.target;
|
|
1371
|
+
const { left, top, width, height } = dot.getBoundingClientRect();
|
|
1372
|
+
const cx = left + width / 2;
|
|
1373
|
+
const cy = top + height / 2;
|
|
1374
|
+
dot.dispatchEvent(
|
|
1375
|
+
new MouseEvent('click', {
|
|
1376
|
+
bubbles: true,
|
|
1377
|
+
clientX: cx,
|
|
1378
|
+
clientY: cy,
|
|
1379
|
+
})
|
|
1380
|
+
);
|
|
1381
|
+
};
|
|
1382
|
+
|
|
1383
|
+
const rafId = requestAnimationFrame(() => {
|
|
1384
|
+
wrapper
|
|
1385
|
+
.querySelectorAll<SVGCircleElement>('circle.dot')
|
|
1386
|
+
.forEach(dot => {
|
|
1387
|
+
if (!dot.hasAttribute('tabindex')) {
|
|
1388
|
+
dot.setAttribute('tabindex', '0');
|
|
1389
|
+
}
|
|
1390
|
+
});
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
wrapper.addEventListener('focusin', onFocusIn);
|
|
1394
|
+
wrapper.addEventListener('focusout', onFocusOut);
|
|
1395
|
+
wrapper.addEventListener('keydown', onKeyDown);
|
|
1396
|
+
|
|
1397
|
+
return () => {
|
|
1398
|
+
cancelAnimationFrame(rafId);
|
|
1399
|
+
wrapper.removeEventListener('focusin', onFocusIn);
|
|
1400
|
+
wrapper.removeEventListener('focusout', onFocusOut);
|
|
1401
|
+
wrapper.removeEventListener('keydown', onKeyDown);
|
|
1402
|
+
};
|
|
1403
|
+
}, [type]);
|
|
611
1404
|
|
|
612
|
-
// Adding aria-label to main SVG container
|
|
613
1405
|
React.useEffect(() => {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
1406
|
+
const timer = setTimeout(() => {
|
|
1407
|
+
if (internalRef.current) {
|
|
1408
|
+
internalRef.current
|
|
1409
|
+
.querySelectorAll<SVGGElement>('g[aria-label]')
|
|
1410
|
+
.forEach(g => {
|
|
1411
|
+
const role = g.getAttribute('role');
|
|
1412
|
+
|
|
1413
|
+
if (!role) {
|
|
1414
|
+
g.setAttribute('role', 'img');
|
|
1415
|
+
} else if (role === 'graphics-object group') {
|
|
1416
|
+
g.setAttribute('role', 'graphics-object');
|
|
1417
|
+
}
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
}, 0);
|
|
1421
|
+
|
|
1422
|
+
return () => clearTimeout(timer);
|
|
1423
|
+
}, [type, dataSet]);
|
|
1424
|
+
|
|
1425
|
+
React.useEffect(() => {
|
|
1426
|
+
const wrapper = internalRef.current;
|
|
1427
|
+
|
|
1428
|
+
if (!wrapper) return;
|
|
1429
|
+
|
|
1430
|
+
const observer = new MutationObserver(() => {
|
|
1431
|
+
const allItems = wrapper.querySelectorAll<HTMLElement>(
|
|
1432
|
+
'.legend-item:not(.additional)'
|
|
1433
|
+
);
|
|
1434
|
+
|
|
1435
|
+
if (allItems.length <= 1) return;
|
|
1436
|
+
|
|
1437
|
+
const activeLabels: string[] = [];
|
|
1438
|
+
|
|
1439
|
+
allItems.forEach(el => {
|
|
1440
|
+
const checkbox = el.querySelector<HTMLElement>('.checkbox');
|
|
1441
|
+
const label = el.querySelector('p');
|
|
1442
|
+
|
|
1443
|
+
if (checkbox?.getAttribute('aria-checked') === 'true' && label) {
|
|
1444
|
+
activeLabels.push(label.textContent?.trim() || '');
|
|
1445
|
+
}
|
|
617
1446
|
});
|
|
618
|
-
}
|
|
619
|
-
});
|
|
620
1447
|
|
|
621
|
-
|
|
1448
|
+
if (activeLabels.length === 1) {
|
|
1449
|
+
if (!legendAnnouncedRef.current) {
|
|
1450
|
+
legendAnnouncedRef.current = true;
|
|
1451
|
+
setLegendAnnouncement(`Only ${activeLabels[0]} is selected`);
|
|
1452
|
+
}
|
|
1453
|
+
} else if (activeLabels.length === allItems.length) {
|
|
1454
|
+
legendAnnouncedRef.current = false;
|
|
1455
|
+
setLegendAnnouncement('All items selected');
|
|
1456
|
+
} else {
|
|
1457
|
+
legendAnnouncedRef.current = false;
|
|
1458
|
+
setLegendAnnouncement('');
|
|
1459
|
+
}
|
|
1460
|
+
});
|
|
1461
|
+
|
|
1462
|
+
observer.observe(wrapper, {
|
|
1463
|
+
subtree: true,
|
|
1464
|
+
attributes: true,
|
|
1465
|
+
attributeFilter: ['aria-checked'],
|
|
1466
|
+
});
|
|
1467
|
+
|
|
1468
|
+
return () => observer.disconnect();
|
|
1469
|
+
}, [type, dataSet]);
|
|
1470
|
+
|
|
1471
|
+
const groupsLength = Object.keys(colorScale).length;
|
|
1472
|
+
|
|
1473
|
+
const showTable = chartToolbar?.showAsTable !== false;
|
|
1474
|
+
|
|
1475
|
+
if (!ChartType) return null;
|
|
622
1476
|
|
|
623
1477
|
return (
|
|
624
|
-
<
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
1478
|
+
<FullscreenRoot ref={mergedRef} isInverse={isInverse} theme={theme}>
|
|
1479
|
+
<VisuallyHidden>
|
|
1480
|
+
<Announce>{legendAnnouncement}</Announce>
|
|
1481
|
+
</VisuallyHidden>
|
|
1482
|
+
<CarbonChartWrapper
|
|
1483
|
+
data-testid={testId}
|
|
1484
|
+
isInverse={isInverse}
|
|
1485
|
+
theme={theme}
|
|
1486
|
+
className={`carbon-chart-wrapper${chartToolbar ? ' has-magma-toolbar' : ''}`}
|
|
1487
|
+
groupsLength={groupsLength < 6 ? groupsLength : 14}
|
|
1488
|
+
role="region"
|
|
1489
|
+
aria-label={ariaLabel || chartTitle}
|
|
1490
|
+
aria-roledescription="chart"
|
|
1491
|
+
{...rest}
|
|
1492
|
+
>
|
|
1493
|
+
<ChartContentWrapper>
|
|
1494
|
+
{chartToolbar && (
|
|
1495
|
+
<CarbonChartToolbar
|
|
1496
|
+
config={chartToolbar}
|
|
1497
|
+
dataSet={dataSet as Array<Record<string, unknown>>}
|
|
1498
|
+
isInverse={isInverse}
|
|
1499
|
+
isTableOpen={isTableOpen}
|
|
1500
|
+
isFullscreen={isFullscreen}
|
|
1501
|
+
onOpenTable={openTableModal}
|
|
1502
|
+
onToggleFullscreen={toggleFullscreen}
|
|
1503
|
+
theme={theme}
|
|
1504
|
+
title={chartTitle}
|
|
1505
|
+
wrapperRef={internalRef}
|
|
1506
|
+
/>
|
|
1507
|
+
)}
|
|
1508
|
+
<ChartType data={dataSet} options={newOptions} />
|
|
1509
|
+
</ChartContentWrapper>
|
|
1510
|
+
</CarbonChartWrapper>
|
|
1511
|
+
{chartToolbar && showTable && (
|
|
1512
|
+
<ChartTableModal
|
|
1513
|
+
columns={chartToolbar.tableColumns}
|
|
1514
|
+
portalContainer={isFullscreen ? internalRef.current : undefined}
|
|
1515
|
+
dataSet={dataSet as Array<Record<string, React.ReactNode>>}
|
|
1516
|
+
headerLabel={chartToolbar.tableHeaderLabel}
|
|
1517
|
+
headerLevel={chartToolbar.tableHeaderLevel}
|
|
1518
|
+
isInverse={isInverse}
|
|
1519
|
+
isOpen={isTableOpen}
|
|
1520
|
+
onClose={closeTableModal}
|
|
1521
|
+
onDownloadCsv={handleModalDownloadCsv}
|
|
1522
|
+
title={chartTitle}
|
|
1523
|
+
/>
|
|
1524
|
+
)}
|
|
1525
|
+
</FullscreenRoot>
|
|
635
1526
|
);
|
|
636
1527
|
}
|
|
637
1528
|
);
|