@react-magma/charts 13.0.4-next.0 → 13.1.0
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 +447 -27
- package/dist/charts.js.map +1 -1
- package/dist/charts.modern.module.js +443 -30
- package/dist/charts.modern.module.js.map +1 -1
- package/dist/charts.umd.js +1576 -214
- package/dist/charts.umd.js.map +1 -1
- package/dist/components/CarbonChart/CarbonChart.d.ts +41 -0
- 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/ChartTable.test.d.ts +1 -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 +16 -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 +143 -2
- package/src/components/CarbonChart/CarbonChart.tsx +603 -15
- package/src/components/ChartTable/ChartDataTable.tsx +72 -0
- package/src/components/ChartTable/ChartFullscreenButton.tsx +59 -0
- package/src/components/ChartTable/ChartMoreOptionsButton.tsx +47 -0
- package/src/components/ChartTable/ChartTable.stories.tsx +152 -0
- package/src/components/ChartTable/ChartTable.test.tsx +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 +55 -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,30 @@ 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
|
+
DropdownDivider,
|
|
29
|
+
DropdownMenuItem,
|
|
30
|
+
ThemeInterface,
|
|
31
|
+
ThemeContext,
|
|
32
|
+
useIsInverse,
|
|
33
|
+
} from 'react-magma-dom';
|
|
34
|
+
import {
|
|
35
|
+
FullscreenExitIcon,
|
|
36
|
+
FullscreenIcon,
|
|
37
|
+
MoreVertIcon,
|
|
38
|
+
TableIcon,
|
|
39
|
+
} from 'react-magma-icons';
|
|
27
40
|
|
|
28
41
|
import { useCarbonModalFocusManagement } from '../../hooks/useCarbonModalFocusManagement';
|
|
29
42
|
import './carbon-charts.css';
|
|
43
|
+
import {
|
|
44
|
+
ChartFullscreenButton,
|
|
45
|
+
ChartMoreOptionsButton,
|
|
46
|
+
ChartTableButton,
|
|
47
|
+
ChartTableModal,
|
|
48
|
+
} from '../ChartTable';
|
|
49
|
+
import type { ChartDataTableColumn } from '../ChartTable';
|
|
50
|
+
import { useChartToolbarI18n } from '../ChartTable/chartToolbarI18n';
|
|
30
51
|
|
|
31
52
|
export enum CarbonChartType {
|
|
32
53
|
area = 'area',
|
|
@@ -49,6 +70,41 @@ export enum CarbonChartType {
|
|
|
49
70
|
combo = 'combo',
|
|
50
71
|
}
|
|
51
72
|
|
|
73
|
+
export interface ChartToolbarConfig {
|
|
74
|
+
/**
|
|
75
|
+
* When true, renders a "Show as table" button that opens a Magma Modal
|
|
76
|
+
* with the chart data in an accessible table.
|
|
77
|
+
* @default true
|
|
78
|
+
*/
|
|
79
|
+
showAsTable?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* When true, renders a fullscreen toggle button.
|
|
82
|
+
* @default true
|
|
83
|
+
*/
|
|
84
|
+
fullscreen?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Additional menu items rendered inside a "More options" dropdown,
|
|
87
|
+
* below the built-in "Download as CSV", "Download as PNG", and "Download as JPG" items.
|
|
88
|
+
* Pass DropdownMenuItem elements.
|
|
89
|
+
*/
|
|
90
|
+
moreOptions?: React.ReactNode;
|
|
91
|
+
/**
|
|
92
|
+
* Custom column definitions for the table modal.
|
|
93
|
+
* If omitted, columns are auto-derived from the dataset object keys.
|
|
94
|
+
*/
|
|
95
|
+
tableColumns?: ChartDataTableColumn[];
|
|
96
|
+
/**
|
|
97
|
+
* First line of the modal heading.
|
|
98
|
+
* @default "Tabular representation"
|
|
99
|
+
*/
|
|
100
|
+
tableHeaderLabel?: string;
|
|
101
|
+
/**
|
|
102
|
+
* Heading level for the modal header.
|
|
103
|
+
* @default 2
|
|
104
|
+
*/
|
|
105
|
+
tableHeaderLevel?: 1 | 2 | 3 | 4 | 5 | 6;
|
|
106
|
+
}
|
|
107
|
+
|
|
52
108
|
export interface CarbonChartProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
53
109
|
dataSet: Array<Object>;
|
|
54
110
|
isInverse?: boolean;
|
|
@@ -69,13 +125,55 @@ export interface CarbonChartProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
69
125
|
* Text for the aria-label attribute for main SVG container, if provided
|
|
70
126
|
*/
|
|
71
127
|
ariaLabel?: string;
|
|
128
|
+
/**
|
|
129
|
+
* When provided, renders an accessible Magma toolbar above the chart with
|
|
130
|
+
* "Show as table", fullscreen, and "More options" buttons. Carbon's built-in
|
|
131
|
+
* toolbar is automatically disabled.
|
|
132
|
+
*/
|
|
133
|
+
chartToolbar?: ChartToolbarConfig;
|
|
72
134
|
}
|
|
73
135
|
|
|
136
|
+
const ChartContentWrapper = styled.div`
|
|
137
|
+
height: 100%;
|
|
138
|
+
position: relative;
|
|
139
|
+
`;
|
|
140
|
+
|
|
141
|
+
const FullscreenRoot = styled.div<{
|
|
142
|
+
isInverse?: boolean;
|
|
143
|
+
theme: ThemeInterface;
|
|
144
|
+
}>`
|
|
145
|
+
height: 100%;
|
|
146
|
+
width: 100%;
|
|
147
|
+
|
|
148
|
+
&:fullscreen,
|
|
149
|
+
&:-webkit-full-screen {
|
|
150
|
+
background: ${props =>
|
|
151
|
+
props.isInverse
|
|
152
|
+
? props.theme.colors.primary700
|
|
153
|
+
: props.theme.colors.neutral100};
|
|
154
|
+
.cds--chart-holder {
|
|
155
|
+
height: 100vh !important;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
`;
|
|
159
|
+
|
|
74
160
|
const CarbonChartWrapper = styled.div<{
|
|
75
161
|
isInverse?: boolean;
|
|
76
162
|
groupsLength: number;
|
|
77
163
|
theme: ThemeInterface;
|
|
78
164
|
}>`
|
|
165
|
+
&:fullscreen,
|
|
166
|
+
&:-webkit-full-screen {
|
|
167
|
+
background: ${props =>
|
|
168
|
+
props.isInverse
|
|
169
|
+
? props.theme.colors.primary700
|
|
170
|
+
: props.theme.colors.neutral100};
|
|
171
|
+
|
|
172
|
+
.cds--chart-holder {
|
|
173
|
+
height: 100vh !important;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
79
177
|
.cds--data-table thead tr th {
|
|
80
178
|
background: ${props =>
|
|
81
179
|
props.isInverse ? props.theme.colors.primary700 : ''} !important;
|
|
@@ -131,9 +229,9 @@ const CarbonChartWrapper = styled.div<{
|
|
|
131
229
|
}
|
|
132
230
|
}
|
|
133
231
|
|
|
134
|
-
p,
|
|
135
|
-
div,
|
|
136
|
-
text,
|
|
232
|
+
.chart-holder p,
|
|
233
|
+
.chart-holder div,
|
|
234
|
+
.chart-holder text,
|
|
137
235
|
.cds--cc--axes g.axis .axis-title,
|
|
138
236
|
.cds--cc--title p.title,
|
|
139
237
|
.cds--cc--axes g.axis g.tick text {
|
|
@@ -488,6 +586,10 @@ const CarbonChartWrapper = styled.div<{
|
|
|
488
586
|
}
|
|
489
587
|
}
|
|
490
588
|
|
|
589
|
+
&.has-magma-toolbar .cds--cc--title p.title {
|
|
590
|
+
visibility: hidden;
|
|
591
|
+
}
|
|
592
|
+
|
|
491
593
|
svg:not(:root) {
|
|
492
594
|
overflow: visible;
|
|
493
595
|
}
|
|
@@ -535,6 +637,389 @@ interface ColorsObject {
|
|
|
535
637
|
[key: string]: string;
|
|
536
638
|
}
|
|
537
639
|
|
|
640
|
+
const ToolbarWrapper = styled.div<{
|
|
641
|
+
isFullscreen?: boolean;
|
|
642
|
+
isInverse?: boolean;
|
|
643
|
+
theme: ThemeInterface;
|
|
644
|
+
}>`
|
|
645
|
+
align-items: center;
|
|
646
|
+
display: flex;
|
|
647
|
+
justify-content: space-between;
|
|
648
|
+
left: ${props => (props.isFullscreen ? '2em' : '0')};
|
|
649
|
+
position: absolute;
|
|
650
|
+
right: ${props => (props.isFullscreen ? '2em' : '0')};
|
|
651
|
+
top: ${props => (props.isFullscreen ? '2em' : '0')};
|
|
652
|
+
z-index: 2;
|
|
653
|
+
|
|
654
|
+
button {
|
|
655
|
+
color: ${props =>
|
|
656
|
+
props.isInverse
|
|
657
|
+
? props.theme.colors.neutral100
|
|
658
|
+
: props.theme.colors.primary500};
|
|
659
|
+
|
|
660
|
+
&:focus {
|
|
661
|
+
outline: 2px solid
|
|
662
|
+
${props =>
|
|
663
|
+
props.isInverse
|
|
664
|
+
? props.theme.colors.focusInverse
|
|
665
|
+
: props.theme.colors.focus};
|
|
666
|
+
outline-offset: 0;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
`;
|
|
670
|
+
|
|
671
|
+
const ChartTitle = styled.h2<{
|
|
672
|
+
isInverse?: boolean;
|
|
673
|
+
theme: ThemeInterface;
|
|
674
|
+
}>`
|
|
675
|
+
font-family: ${props => props.theme.bodyFont} !important;
|
|
676
|
+
font-size: 16px !important;
|
|
677
|
+
font-weight: 700 !important;
|
|
678
|
+
letter-spacing: normal !important;
|
|
679
|
+
line-height: 1.25 !important;
|
|
680
|
+
margin: 0 !important;
|
|
681
|
+
color: ${props =>
|
|
682
|
+
props.isInverse
|
|
683
|
+
? props.theme.colors.neutral100
|
|
684
|
+
: props.theme.colors.neutral700} !important;
|
|
685
|
+
`;
|
|
686
|
+
|
|
687
|
+
const ToolbarActions = styled.div<{ theme: ThemeInterface }>`
|
|
688
|
+
align-items: center;
|
|
689
|
+
display: flex;
|
|
690
|
+
gap: ${props => props.theme.spaceScale.spacing02};
|
|
691
|
+
|
|
692
|
+
> * button,
|
|
693
|
+
> button {
|
|
694
|
+
height: 2rem;
|
|
695
|
+
min-height: 2rem;
|
|
696
|
+
min-width: 2rem;
|
|
697
|
+
padding: 4px;
|
|
698
|
+
width: 2rem;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
[role='tooltip'] {
|
|
702
|
+
padding: ${props => props.theme.spaceScale.spacing03}
|
|
703
|
+
${props => props.theme.spaceScale.spacing04};
|
|
704
|
+
text-align: center;
|
|
705
|
+
white-space: nowrap;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
[data-testid='dropdownContent'] {
|
|
709
|
+
padding: ${props => props.theme.spaceScale.spacing03} 0;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
[role='menuitem'],
|
|
713
|
+
[data-testid='dropdownMenuItem'] {
|
|
714
|
+
padding: ${props => props.theme.spaceScale.spacing03}
|
|
715
|
+
${props => props.theme.spaceScale.spacing05};
|
|
716
|
+
}
|
|
717
|
+
`;
|
|
718
|
+
|
|
719
|
+
function sanitizeCsvValue(value: string): string {
|
|
720
|
+
const trimmed = value.trimStart();
|
|
721
|
+
if (/^[=+\-@\t\r]/.test(trimmed)) {
|
|
722
|
+
return `'${value}`;
|
|
723
|
+
}
|
|
724
|
+
return value;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function downloadCsv(dataSet: Array<Record<string, unknown>>, title: string) {
|
|
728
|
+
if (!dataSet.length) return;
|
|
729
|
+
const keys = Object.keys(dataSet[0]);
|
|
730
|
+
const header = keys.map(k => sanitizeCsvValue(k)).join(',');
|
|
731
|
+
const rows = dataSet.map(row =>
|
|
732
|
+
keys
|
|
733
|
+
.map(k => {
|
|
734
|
+
const v = row[k];
|
|
735
|
+
const s = sanitizeCsvValue(String(v ?? ''));
|
|
736
|
+
return s.includes(',') || s.includes('"') || s.includes('\n')
|
|
737
|
+
? `"${s.replace(/"/g, '""')}"`
|
|
738
|
+
: s;
|
|
739
|
+
})
|
|
740
|
+
.join(',')
|
|
741
|
+
);
|
|
742
|
+
const csv = [header, ...rows].join('\n');
|
|
743
|
+
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
|
744
|
+
const url = URL.createObjectURL(blob);
|
|
745
|
+
const a = document.createElement('a');
|
|
746
|
+
a.href = url;
|
|
747
|
+
a.download = `${title || 'chart-data'}.csv`;
|
|
748
|
+
a.click();
|
|
749
|
+
URL.revokeObjectURL(url);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function inlineStyles(source: Element, target: Element) {
|
|
753
|
+
const computed = window.getComputedStyle(source);
|
|
754
|
+
if (target instanceof HTMLElement || target instanceof SVGElement) {
|
|
755
|
+
target.setAttribute(
|
|
756
|
+
'style',
|
|
757
|
+
Array.from(computed)
|
|
758
|
+
.map(prop => `${prop}:${computed.getPropertyValue(prop)}`)
|
|
759
|
+
.join(';')
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
for (let i = 0; i < source.children.length; i++) {
|
|
763
|
+
if (target.children[i]) {
|
|
764
|
+
inlineStyles(source.children[i], target.children[i]);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
interface LegendItem {
|
|
770
|
+
label: string;
|
|
771
|
+
color: string;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function readLegendItems(wrapper: HTMLElement): LegendItem[] {
|
|
775
|
+
const items: LegendItem[] = [];
|
|
776
|
+
wrapper.querySelectorAll('.legend-item').forEach(item => {
|
|
777
|
+
const checkbox = item.querySelector<HTMLElement>('.checkbox');
|
|
778
|
+
const label = item.querySelector('p');
|
|
779
|
+
if (checkbox && label) {
|
|
780
|
+
items.push({
|
|
781
|
+
color: checkbox.style.background || checkbox.style.backgroundColor,
|
|
782
|
+
label: label.textContent || '',
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
return items;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function drawLegend(
|
|
790
|
+
ctx: CanvasRenderingContext2D,
|
|
791
|
+
items: LegendItem[],
|
|
792
|
+
startY: number,
|
|
793
|
+
canvasWidth: number,
|
|
794
|
+
scale: number
|
|
795
|
+
) {
|
|
796
|
+
const fontSize = 13 * scale;
|
|
797
|
+
const swatchSize = 12 * scale;
|
|
798
|
+
const gap = 8 * scale;
|
|
799
|
+
const itemGap = 16 * scale;
|
|
800
|
+
const paddingX = 16 * scale;
|
|
801
|
+
|
|
802
|
+
ctx.font = `${fontSize}px sans-serif`;
|
|
803
|
+
ctx.textBaseline = 'middle';
|
|
804
|
+
|
|
805
|
+
let x = paddingX;
|
|
806
|
+
let y = startY;
|
|
807
|
+
|
|
808
|
+
for (const item of items) {
|
|
809
|
+
const textWidth = ctx.measureText(item.label).width;
|
|
810
|
+
const itemWidth = swatchSize + gap + textWidth + itemGap;
|
|
811
|
+
|
|
812
|
+
if (x + itemWidth > canvasWidth - paddingX && x > paddingX) {
|
|
813
|
+
x = paddingX;
|
|
814
|
+
y += fontSize + gap;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
ctx.fillStyle = item.color;
|
|
818
|
+
ctx.fillRect(x, y - swatchSize / 2, swatchSize, swatchSize);
|
|
819
|
+
|
|
820
|
+
ctx.fillStyle = '#161616';
|
|
821
|
+
ctx.fillText(item.label, x + swatchSize + gap, y);
|
|
822
|
+
|
|
823
|
+
x += itemWidth;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
return y + fontSize + gap;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function downloadImage(
|
|
830
|
+
wrapperRef: React.RefObject<HTMLDivElement | null>,
|
|
831
|
+
title: string,
|
|
832
|
+
format: 'png' | 'jpg'
|
|
833
|
+
) {
|
|
834
|
+
const wrapper = wrapperRef.current;
|
|
835
|
+
if (!wrapper) return;
|
|
836
|
+
const svg = wrapper.querySelector('svg.layout-svg-wrapper');
|
|
837
|
+
if (!svg) return;
|
|
838
|
+
|
|
839
|
+
const svgRect = svg.getBoundingClientRect();
|
|
840
|
+
const legendItems = readLegendItems(wrapper);
|
|
841
|
+
const scale = 2;
|
|
842
|
+
|
|
843
|
+
// Measure legend height
|
|
844
|
+
const tempCanvas = document.createElement('canvas');
|
|
845
|
+
const tempCtx = tempCanvas.getContext('2d');
|
|
846
|
+
const fontSize = 13 * scale;
|
|
847
|
+
const swatchSize = 12 * scale;
|
|
848
|
+
const gap = 8 * scale;
|
|
849
|
+
const itemGap = 16 * scale;
|
|
850
|
+
const paddingX = 16 * scale;
|
|
851
|
+
const canvasWidth = svgRect.width * scale;
|
|
852
|
+
|
|
853
|
+
let legendHeight = 0;
|
|
854
|
+
if (legendItems.length > 0 && tempCtx) {
|
|
855
|
+
tempCtx.font = `${fontSize}px sans-serif`;
|
|
856
|
+
let x = paddingX;
|
|
857
|
+
let rows = 1;
|
|
858
|
+
for (const item of legendItems) {
|
|
859
|
+
const textWidth = tempCtx.measureText(item.label).width;
|
|
860
|
+
const itemWidth = swatchSize + gap + textWidth + itemGap;
|
|
861
|
+
if (x + itemWidth > canvasWidth - paddingX && x > paddingX) {
|
|
862
|
+
x = paddingX;
|
|
863
|
+
rows++;
|
|
864
|
+
}
|
|
865
|
+
x += itemWidth;
|
|
866
|
+
}
|
|
867
|
+
legendHeight = rows * (fontSize + gap) + gap * 2;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const width = svgRect.width * scale;
|
|
871
|
+
const height = svgRect.height * scale + legendHeight;
|
|
872
|
+
|
|
873
|
+
const clone = svg.cloneNode(true) as SVGSVGElement;
|
|
874
|
+
clone.setAttribute('width', String(svgRect.width));
|
|
875
|
+
clone.setAttribute('height', String(svgRect.height));
|
|
876
|
+
clone.setAttribute('viewBox', `0 0 ${svgRect.width} ${svgRect.height}`);
|
|
877
|
+
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
878
|
+
|
|
879
|
+
inlineStyles(svg, clone);
|
|
880
|
+
|
|
881
|
+
const serializer = new XMLSerializer();
|
|
882
|
+
const svgString = serializer.serializeToString(clone);
|
|
883
|
+
const svgBlob = new Blob([svgString], {
|
|
884
|
+
type: 'image/svg+xml;charset=utf-8',
|
|
885
|
+
});
|
|
886
|
+
const url = URL.createObjectURL(svgBlob);
|
|
887
|
+
|
|
888
|
+
const mimeType = format === 'jpg' ? 'image/jpeg' : 'image/png';
|
|
889
|
+
const ext = format === 'jpg' ? 'jpg' : 'png';
|
|
890
|
+
|
|
891
|
+
const img = new Image();
|
|
892
|
+
img.onload = () => {
|
|
893
|
+
const canvas = document.createElement('canvas');
|
|
894
|
+
canvas.width = width;
|
|
895
|
+
canvas.height = height;
|
|
896
|
+
const ctx = canvas.getContext('2d');
|
|
897
|
+
if (!ctx) return;
|
|
898
|
+
|
|
899
|
+
ctx.fillStyle = '#ffffff';
|
|
900
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
901
|
+
ctx.drawImage(img, 0, 0, svgRect.width * scale, svgRect.height * scale);
|
|
902
|
+
URL.revokeObjectURL(url);
|
|
903
|
+
|
|
904
|
+
if (legendItems.length > 0) {
|
|
905
|
+
drawLegend(ctx, legendItems, svgRect.height * scale + gap, width, scale);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
canvas.toBlob(blob => {
|
|
909
|
+
if (!blob) return;
|
|
910
|
+
const imgUrl = URL.createObjectURL(blob);
|
|
911
|
+
const a = document.createElement('a');
|
|
912
|
+
a.href = imgUrl;
|
|
913
|
+
a.download = `${title || 'chart'}.${ext}`;
|
|
914
|
+
a.click();
|
|
915
|
+
URL.revokeObjectURL(imgUrl);
|
|
916
|
+
}, mimeType);
|
|
917
|
+
};
|
|
918
|
+
img.src = url;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
interface InternalToolbarProps {
|
|
922
|
+
config: ChartToolbarConfig;
|
|
923
|
+
dataSet: Array<Record<string, unknown>>;
|
|
924
|
+
isInverse: boolean;
|
|
925
|
+
isTableOpen: boolean;
|
|
926
|
+
isFullscreen: boolean;
|
|
927
|
+
onOpenTable: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
|
928
|
+
onToggleFullscreen: () => void;
|
|
929
|
+
theme: ThemeInterface;
|
|
930
|
+
title: string;
|
|
931
|
+
wrapperRef: React.RefObject<HTMLDivElement | null>;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function CarbonChartToolbar({
|
|
935
|
+
config,
|
|
936
|
+
dataSet,
|
|
937
|
+
isInverse,
|
|
938
|
+
isTableOpen,
|
|
939
|
+
isFullscreen,
|
|
940
|
+
onOpenTable,
|
|
941
|
+
onToggleFullscreen,
|
|
942
|
+
theme,
|
|
943
|
+
title,
|
|
944
|
+
wrapperRef,
|
|
945
|
+
}: InternalToolbarProps) {
|
|
946
|
+
const t = useChartToolbarI18n();
|
|
947
|
+
const showTable = config.showAsTable !== false;
|
|
948
|
+
const showFullscreen = config.fullscreen !== false;
|
|
949
|
+
const resolvedTitle = title || t.defaultTitle;
|
|
950
|
+
|
|
951
|
+
const handleDownloadCsv = React.useCallback(() => {
|
|
952
|
+
downloadCsv(dataSet, title);
|
|
953
|
+
}, [dataSet, title]);
|
|
954
|
+
|
|
955
|
+
const handleDownloadPng = React.useCallback(() => {
|
|
956
|
+
downloadImage(wrapperRef, title, 'png');
|
|
957
|
+
}, [wrapperRef, title]);
|
|
958
|
+
|
|
959
|
+
const handleDownloadJpg = React.useCallback(() => {
|
|
960
|
+
downloadImage(wrapperRef, title, 'jpg');
|
|
961
|
+
}, [wrapperRef, title]);
|
|
962
|
+
|
|
963
|
+
const moreOptionsContent = (
|
|
964
|
+
<>
|
|
965
|
+
<DropdownMenuItem onClick={handleDownloadCsv}>
|
|
966
|
+
{t.downloadAsCsv}
|
|
967
|
+
</DropdownMenuItem>
|
|
968
|
+
<DropdownMenuItem onClick={handleDownloadPng}>
|
|
969
|
+
{t.downloadAsPng}
|
|
970
|
+
</DropdownMenuItem>
|
|
971
|
+
<DropdownMenuItem onClick={handleDownloadJpg}>
|
|
972
|
+
{t.downloadAsJpg}
|
|
973
|
+
</DropdownMenuItem>
|
|
974
|
+
{config.moreOptions && (
|
|
975
|
+
<>
|
|
976
|
+
<DropdownDivider />
|
|
977
|
+
{config.moreOptions}
|
|
978
|
+
</>
|
|
979
|
+
)}
|
|
980
|
+
</>
|
|
981
|
+
);
|
|
982
|
+
|
|
983
|
+
return (
|
|
984
|
+
<ToolbarWrapper
|
|
985
|
+
isFullscreen={isFullscreen}
|
|
986
|
+
isInverse={isInverse}
|
|
987
|
+
theme={theme}
|
|
988
|
+
>
|
|
989
|
+
<ChartTitle isInverse={isInverse} theme={theme}>
|
|
990
|
+
{resolvedTitle}
|
|
991
|
+
</ChartTitle>
|
|
992
|
+
<ToolbarActions theme={theme}>
|
|
993
|
+
{showTable && (
|
|
994
|
+
<ChartTableButton
|
|
995
|
+
ariaLabel={resolvedTitle}
|
|
996
|
+
icon={<TableIcon size={20} />}
|
|
997
|
+
isInverse={isInverse}
|
|
998
|
+
isTableOpen={isTableOpen}
|
|
999
|
+
onClick={onOpenTable}
|
|
1000
|
+
/>
|
|
1001
|
+
)}
|
|
1002
|
+
{showFullscreen && (
|
|
1003
|
+
<ChartFullscreenButton
|
|
1004
|
+
ariaLabel={`${isFullscreen ? 'Exit' : 'View'} ${resolvedTitle} full screen`}
|
|
1005
|
+
icon={<FullscreenIcon size={20} />}
|
|
1006
|
+
exitIcon={<FullscreenExitIcon size={20} />}
|
|
1007
|
+
isInverse={isInverse}
|
|
1008
|
+
isFullscreen={isFullscreen}
|
|
1009
|
+
onClick={onToggleFullscreen}
|
|
1010
|
+
/>
|
|
1011
|
+
)}
|
|
1012
|
+
<ChartMoreOptionsButton
|
|
1013
|
+
icon={<MoreVertIcon size={20} />}
|
|
1014
|
+
isInverse={isInverse}
|
|
1015
|
+
>
|
|
1016
|
+
{moreOptionsContent}
|
|
1017
|
+
</ChartMoreOptionsButton>
|
|
1018
|
+
</ToolbarActions>
|
|
1019
|
+
</ToolbarWrapper>
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
538
1023
|
export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
|
|
539
1024
|
(props, ref) => {
|
|
540
1025
|
const {
|
|
@@ -544,12 +1029,18 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
|
|
|
544
1029
|
dataSet,
|
|
545
1030
|
options,
|
|
546
1031
|
ariaLabel,
|
|
1032
|
+
chartToolbar,
|
|
547
1033
|
...rest
|
|
548
1034
|
} = props;
|
|
549
1035
|
const theme = React.useContext(ThemeContext);
|
|
550
1036
|
const isInverse = useIsInverse(isInverseProp);
|
|
1037
|
+
const toolbarI18n = useChartToolbarI18n();
|
|
551
1038
|
const internalRef = React.useRef<HTMLDivElement | null>(null);
|
|
552
1039
|
|
|
1040
|
+
const [isTableOpen, setIsTableOpen] = React.useState(false);
|
|
1041
|
+
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
|
1042
|
+
const lastTableTriggerRef = React.useRef<HTMLButtonElement | null>(null);
|
|
1043
|
+
|
|
553
1044
|
const mergedRef = React.useCallback(
|
|
554
1045
|
(node: HTMLDivElement | null) => {
|
|
555
1046
|
internalRef.current = node;
|
|
@@ -562,6 +1053,69 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
|
|
|
562
1053
|
[ref]
|
|
563
1054
|
);
|
|
564
1055
|
|
|
1056
|
+
const fullscreenEnabled = chartToolbar
|
|
1057
|
+
? chartToolbar.fullscreen !== false
|
|
1058
|
+
: false;
|
|
1059
|
+
|
|
1060
|
+
const savedHeightRef = React.useRef<string>('');
|
|
1061
|
+
|
|
1062
|
+
React.useEffect(() => {
|
|
1063
|
+
if (!fullscreenEnabled) return;
|
|
1064
|
+
|
|
1065
|
+
const onFullscreenChange = () => {
|
|
1066
|
+
const isFs = !!document.fullscreenElement;
|
|
1067
|
+
setIsFullscreen(isFs);
|
|
1068
|
+
|
|
1069
|
+
const chartHolder =
|
|
1070
|
+
internalRef.current?.querySelector<HTMLElement>('.cds--chart-holder');
|
|
1071
|
+
if (chartHolder) {
|
|
1072
|
+
if (isFs) {
|
|
1073
|
+
savedHeightRef.current = chartHolder.style.height;
|
|
1074
|
+
chartHolder.style.height = '100vh';
|
|
1075
|
+
} else {
|
|
1076
|
+
chartHolder.style.height = savedHeightRef.current;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
};
|
|
1080
|
+
document.addEventListener('fullscreenchange', onFullscreenChange);
|
|
1081
|
+
return () =>
|
|
1082
|
+
document.removeEventListener('fullscreenchange', onFullscreenChange);
|
|
1083
|
+
}, [fullscreenEnabled]);
|
|
1084
|
+
|
|
1085
|
+
const openTableModal = React.useCallback(
|
|
1086
|
+
(event: React.MouseEvent<HTMLButtonElement>) => {
|
|
1087
|
+
lastTableTriggerRef.current = event.currentTarget;
|
|
1088
|
+
setIsTableOpen(true);
|
|
1089
|
+
},
|
|
1090
|
+
[]
|
|
1091
|
+
);
|
|
1092
|
+
|
|
1093
|
+
const closeTableModal = React.useCallback(() => {
|
|
1094
|
+
setIsTableOpen(false);
|
|
1095
|
+
setTimeout(() => {
|
|
1096
|
+
lastTableTriggerRef.current?.focus();
|
|
1097
|
+
}, 0);
|
|
1098
|
+
}, []);
|
|
1099
|
+
|
|
1100
|
+
const toggleFullscreen = React.useCallback(() => {
|
|
1101
|
+
if (!document.fullscreenElement && internalRef.current) {
|
|
1102
|
+
internalRef.current.requestFullscreen().catch(() => {
|
|
1103
|
+
// Fullscreen request may be denied by the browser or user agent
|
|
1104
|
+
});
|
|
1105
|
+
} else if (document.fullscreenElement) {
|
|
1106
|
+
document.exitFullscreen().catch(() => {
|
|
1107
|
+
// Exit fullscreen may fail if already exited
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
}, []);
|
|
1111
|
+
|
|
1112
|
+
const chartTitle: string =
|
|
1113
|
+
(options as any).title || toolbarI18n.defaultTitle;
|
|
1114
|
+
|
|
1115
|
+
const handleModalDownloadCsv = React.useCallback(() => {
|
|
1116
|
+
downloadCsv(dataSet as Array<Record<string, unknown>>, chartTitle);
|
|
1117
|
+
}, [dataSet, chartTitle]);
|
|
1118
|
+
|
|
565
1119
|
useCarbonModalFocusManagement(internalRef);
|
|
566
1120
|
const allCharts = {
|
|
567
1121
|
area: AreaChart,
|
|
@@ -621,6 +1175,7 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
|
|
|
621
1175
|
type: 'none',
|
|
622
1176
|
},
|
|
623
1177
|
},
|
|
1178
|
+
...(chartToolbar ? { toolbar: { enabled: false } } : {}),
|
|
624
1179
|
};
|
|
625
1180
|
|
|
626
1181
|
const ChartType = allCharts[type] as any;
|
|
@@ -636,18 +1191,51 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
|
|
|
636
1191
|
|
|
637
1192
|
const groupsLength = Object.keys(buildColors()).length;
|
|
638
1193
|
|
|
1194
|
+
const showTable = chartToolbar?.showAsTable !== false;
|
|
1195
|
+
|
|
639
1196
|
return (
|
|
640
|
-
<
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
1197
|
+
<FullscreenRoot ref={mergedRef} isInverse={isInverse} theme={theme}>
|
|
1198
|
+
<CarbonChartWrapper
|
|
1199
|
+
data-testid={testId}
|
|
1200
|
+
isInverse={isInverse}
|
|
1201
|
+
theme={theme}
|
|
1202
|
+
className={`carbon-chart-wrapper${chartToolbar ? ' has-magma-toolbar' : ''}`}
|
|
1203
|
+
groupsLength={groupsLength < 6 ? groupsLength : 14}
|
|
1204
|
+
{...rest}
|
|
1205
|
+
>
|
|
1206
|
+
<ChartContentWrapper>
|
|
1207
|
+
{chartToolbar && (
|
|
1208
|
+
<CarbonChartToolbar
|
|
1209
|
+
config={chartToolbar}
|
|
1210
|
+
dataSet={dataSet as Array<Record<string, unknown>>}
|
|
1211
|
+
isInverse={isInverse}
|
|
1212
|
+
isTableOpen={isTableOpen}
|
|
1213
|
+
isFullscreen={isFullscreen}
|
|
1214
|
+
onOpenTable={openTableModal}
|
|
1215
|
+
onToggleFullscreen={toggleFullscreen}
|
|
1216
|
+
theme={theme}
|
|
1217
|
+
title={chartTitle}
|
|
1218
|
+
wrapperRef={internalRef}
|
|
1219
|
+
/>
|
|
1220
|
+
)}
|
|
1221
|
+
<ChartType data={dataSet} options={newOptions} />
|
|
1222
|
+
</ChartContentWrapper>
|
|
1223
|
+
</CarbonChartWrapper>
|
|
1224
|
+
{chartToolbar && showTable && (
|
|
1225
|
+
<ChartTableModal
|
|
1226
|
+
columns={chartToolbar.tableColumns}
|
|
1227
|
+
portalContainer={isFullscreen ? internalRef.current : undefined}
|
|
1228
|
+
dataSet={dataSet as Array<Record<string, React.ReactNode>>}
|
|
1229
|
+
headerLabel={chartToolbar.tableHeaderLabel}
|
|
1230
|
+
headerLevel={chartToolbar.tableHeaderLevel}
|
|
1231
|
+
isInverse={isInverse}
|
|
1232
|
+
isOpen={isTableOpen}
|
|
1233
|
+
onClose={closeTableModal}
|
|
1234
|
+
onDownloadCsv={handleModalDownloadCsv}
|
|
1235
|
+
title={chartTitle}
|
|
1236
|
+
/>
|
|
1237
|
+
)}
|
|
1238
|
+
</FullscreenRoot>
|
|
651
1239
|
);
|
|
652
1240
|
}
|
|
653
1241
|
);
|