@react-magma/charts 14.0.0-next.3 → 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 +698 -69
- package/dist/charts.js.map +1 -1
- package/dist/charts.modern.module.js +694 -72
- package/dist/charts.modern.module.js.map +1 -1
- package/dist/charts.umd.js +2451 -434
- 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/index.d.ts +1 -0
- package/package.json +5 -5
- package/src/components/CarbonChart/CarbonChart.test.js +473 -5
- package/src/components/CarbonChart/CarbonChart.tsx +935 -60
- 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/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,10 +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
|
|
|
28
43
|
import { useCarbonModalFocusManagement } from '../../hooks/useCarbonModalFocusManagement';
|
|
44
|
+
// @ts-ignore
|
|
29
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';
|
|
30
54
|
|
|
31
55
|
export enum CarbonChartType {
|
|
32
56
|
area = 'area',
|
|
@@ -49,6 +73,46 @@ export enum CarbonChartType {
|
|
|
49
73
|
combo = 'combo',
|
|
50
74
|
}
|
|
51
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
|
+
|
|
52
116
|
export interface CarbonChartProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
53
117
|
dataSet: Array<Object>;
|
|
54
118
|
isInverse?: boolean;
|
|
@@ -69,13 +133,72 @@ export interface CarbonChartProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
69
133
|
* Text for the aria-label attribute for main SVG container, if provided
|
|
70
134
|
*/
|
|
71
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;
|
|
72
142
|
}
|
|
73
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
|
+
|
|
74
168
|
const CarbonChartWrapper = styled.div<{
|
|
75
169
|
isInverse?: boolean;
|
|
76
170
|
groupsLength: number;
|
|
77
171
|
theme: ThemeInterface;
|
|
78
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
|
+
|
|
79
202
|
.cds--data-table thead tr th {
|
|
80
203
|
background: ${props =>
|
|
81
204
|
props.isInverse ? props.theme.colors.primary700 : ''} !important;
|
|
@@ -131,9 +254,9 @@ const CarbonChartWrapper = styled.div<{
|
|
|
131
254
|
}
|
|
132
255
|
}
|
|
133
256
|
|
|
134
|
-
p,
|
|
135
|
-
div,
|
|
136
|
-
text,
|
|
257
|
+
.chart-holder p,
|
|
258
|
+
.chart-holder div,
|
|
259
|
+
.chart-holder text,
|
|
137
260
|
.cds--cc--axes g.axis .axis-title,
|
|
138
261
|
.cds--cc--title p.title,
|
|
139
262
|
.cds--cc--axes g.axis g.tick text {
|
|
@@ -392,6 +515,9 @@ const CarbonChartWrapper = styled.div<{
|
|
|
392
515
|
: props.theme.colors.focus} !important;
|
|
393
516
|
outline-offset: 0;
|
|
394
517
|
}
|
|
518
|
+
circle.dot:focus {
|
|
519
|
+
outline: none !important;
|
|
520
|
+
}
|
|
395
521
|
.cds--overflow-menu-options__btn:focus,
|
|
396
522
|
.cds--overflow-menu:focus,
|
|
397
523
|
.cds--overflow-menu__trigger:focus,
|
|
@@ -488,10 +614,25 @@ const CarbonChartWrapper = styled.div<{
|
|
|
488
614
|
}
|
|
489
615
|
}
|
|
490
616
|
|
|
617
|
+
&.has-magma-toolbar .cds--cc--title p.title {
|
|
618
|
+
visibility: hidden;
|
|
619
|
+
}
|
|
620
|
+
|
|
491
621
|
svg:not(:root) {
|
|
492
622
|
overflow: visible;
|
|
493
623
|
}
|
|
494
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
|
+
|
|
495
636
|
.cds--cc--chart-wrapper text {
|
|
496
637
|
font-size: ${props => props.theme.typeScale.size02.fontSize};
|
|
497
638
|
}
|
|
@@ -535,6 +676,436 @@ interface ColorsObject {
|
|
|
535
676
|
[key: string]: string;
|
|
536
677
|
}
|
|
537
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
|
+
|
|
538
1109
|
export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
|
|
539
1110
|
(props, ref) => {
|
|
540
1111
|
const {
|
|
@@ -544,12 +1115,20 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
|
|
|
544
1115
|
dataSet,
|
|
545
1116
|
options,
|
|
546
1117
|
ariaLabel,
|
|
1118
|
+
chartToolbar,
|
|
547
1119
|
...rest
|
|
548
1120
|
} = props;
|
|
549
1121
|
const theme = React.useContext(ThemeContext);
|
|
550
1122
|
const isInverse = useIsInverse(isInverseProp);
|
|
1123
|
+
const toolbarI18n = useChartToolbarI18n();
|
|
551
1124
|
const internalRef = React.useRef<HTMLDivElement | null>(null);
|
|
552
1125
|
|
|
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);
|
|
1131
|
+
|
|
553
1132
|
const mergedRef = React.useCallback(
|
|
554
1133
|
(node: HTMLDivElement | null) => {
|
|
555
1134
|
internalRef.current = node;
|
|
@@ -562,58 +1141,162 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
|
|
|
562
1141
|
[ref]
|
|
563
1142
|
);
|
|
564
1143
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
areaStacked: StackedAreaChart,
|
|
569
|
-
bar: SimpleBarChart,
|
|
570
|
-
barGrouped: GroupedBarChart,
|
|
571
|
-
barStacked: StackedBarChart,
|
|
572
|
-
donut: DonutChart,
|
|
573
|
-
line: LineChart,
|
|
574
|
-
lollipop: LollipopChart,
|
|
575
|
-
pie: PieChart,
|
|
576
|
-
radar: RadarChart,
|
|
577
|
-
boxplot: BoxplotChart,
|
|
578
|
-
bubble: BubbleChart,
|
|
579
|
-
bullet: BulletChart,
|
|
580
|
-
gauge: GaugeChart,
|
|
581
|
-
histogram: HistogramChart,
|
|
582
|
-
meter: MeterChart,
|
|
583
|
-
scatter: ScatterChart,
|
|
584
|
-
combo: ComboChart,
|
|
585
|
-
};
|
|
1144
|
+
const fullscreenEnabled = chartToolbar
|
|
1145
|
+
? chartToolbar.fullscreen !== false
|
|
1146
|
+
: false;
|
|
586
1147
|
|
|
587
|
-
|
|
588
|
-
const scaleColorsObj: ColorsObject = {};
|
|
1148
|
+
const savedHeightRef = React.useRef<string>('');
|
|
589
1149
|
|
|
590
|
-
|
|
591
|
-
|
|
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();
|
|
592
1192
|
});
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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 || [];
|
|
597
1270
|
const allColors = [...customColors, ...theme.chartColors];
|
|
598
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));
|
|
599
1276
|
|
|
600
|
-
uniqueGroups.
|
|
601
|
-
|
|
602
|
-
|
|
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
|
|
603
1281
|
? allInverseColors[i]
|
|
604
|
-
: allColors[i]
|
|
1282
|
+
: allColors[i];
|
|
605
1283
|
}
|
|
606
|
-
|
|
607
|
-
});
|
|
1284
|
+
}
|
|
608
1285
|
|
|
609
1286
|
return scaleColorsObj;
|
|
610
|
-
}
|
|
1287
|
+
}, [
|
|
1288
|
+
dataSet,
|
|
1289
|
+
options,
|
|
1290
|
+
theme.chartColors,
|
|
1291
|
+
theme.chartColorsInverse,
|
|
1292
|
+
isInverse,
|
|
1293
|
+
]);
|
|
611
1294
|
|
|
612
1295
|
const newOptions = {
|
|
613
1296
|
...options,
|
|
614
1297
|
theme: isInverse ? ChartTheme.G100 : ChartTheme.WHITE,
|
|
615
1298
|
color: {
|
|
616
|
-
scale:
|
|
1299
|
+
scale: colorScale,
|
|
617
1300
|
},
|
|
618
1301
|
tooltip: {
|
|
619
1302
|
...(options?.tooltip || {}),
|
|
@@ -621,33 +1304,225 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
|
|
|
621
1304
|
type: 'none',
|
|
622
1305
|
},
|
|
623
1306
|
},
|
|
1307
|
+
...(chartToolbar ? { toolbar: { enabled: false } } : {}),
|
|
624
1308
|
};
|
|
625
1309
|
|
|
626
|
-
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]);
|
|
627
1321
|
|
|
628
|
-
//
|
|
1322
|
+
// Make Carbon Charts data points keyboard-focusable.
|
|
629
1323
|
React.useEffect(() => {
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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]);
|
|
1404
|
+
|
|
1405
|
+
React.useEffect(() => {
|
|
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
|
+
}
|
|
633
1446
|
});
|
|
634
|
-
}
|
|
635
|
-
});
|
|
636
1447
|
|
|
637
|
-
|
|
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;
|
|
638
1476
|
|
|
639
1477
|
return (
|
|
640
|
-
<
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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>
|
|
651
1526
|
);
|
|
652
1527
|
}
|
|
653
1528
|
);
|