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