orchid-ai 2.2.0 → 2.3.1
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/orchid-ai.css
CHANGED
|
@@ -860,39 +860,6 @@
|
|
|
860
860
|
color: #1f2937;
|
|
861
861
|
}
|
|
862
862
|
|
|
863
|
-
.ai-chart-card-header {
|
|
864
|
-
display: flex;
|
|
865
|
-
align-items: center;
|
|
866
|
-
justify-content: space-between;
|
|
867
|
-
gap: 12px;
|
|
868
|
-
margin-bottom: 10px;
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
.ai-chart-card-header .ai-chart-title {
|
|
872
|
-
margin: 0;
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
.ai-chart-csv-btn {
|
|
876
|
-
display: inline-flex;
|
|
877
|
-
align-items: center;
|
|
878
|
-
gap: 4px;
|
|
879
|
-
flex-shrink: 0;
|
|
880
|
-
padding: 4px 10px;
|
|
881
|
-
font-size: 12px;
|
|
882
|
-
font-weight: 500;
|
|
883
|
-
color: #1eaaf1;
|
|
884
|
-
background: #ffffff;
|
|
885
|
-
border: 1px solid #d1d5db;
|
|
886
|
-
border-radius: 6px;
|
|
887
|
-
cursor: pointer;
|
|
888
|
-
transition: background 0.15s, border-color 0.15s;
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
.ai-chart-csv-btn:hover {
|
|
892
|
-
background: #f0f9ff;
|
|
893
|
-
border-color: #1eaaf1;
|
|
894
|
-
}
|
|
895
|
-
|
|
896
863
|
.ai-chart-error {
|
|
897
864
|
color: #b91c1c;
|
|
898
865
|
background: #fef2f2;
|
|
@@ -954,6 +921,9 @@
|
|
|
954
921
|
top: 8px;
|
|
955
922
|
right: 8px;
|
|
956
923
|
z-index: 12;
|
|
924
|
+
display: flex;
|
|
925
|
+
align-items: center;
|
|
926
|
+
gap: 6px;
|
|
957
927
|
}
|
|
958
928
|
|
|
959
929
|
.ai-chart-export-btn {
|
|
@@ -1012,6 +982,136 @@
|
|
|
1012
982
|
opacity: 0.65;
|
|
1013
983
|
}
|
|
1014
984
|
|
|
985
|
+
.ai-chart-export-menu-wrap {
|
|
986
|
+
position: relative;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
.ai-chart-export-menu {
|
|
990
|
+
position: absolute;
|
|
991
|
+
top: calc(100% + 6px);
|
|
992
|
+
right: 0;
|
|
993
|
+
z-index: 13;
|
|
994
|
+
min-width: 172px;
|
|
995
|
+
padding: 4px;
|
|
996
|
+
display: flex;
|
|
997
|
+
flex-direction: column;
|
|
998
|
+
background: #ffffff;
|
|
999
|
+
border: 1px solid #e5e7eb;
|
|
1000
|
+
border-radius: 8px;
|
|
1001
|
+
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.14);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
.ai-chart-export-menu-item {
|
|
1005
|
+
width: 100%;
|
|
1006
|
+
padding: 8px 10px;
|
|
1007
|
+
border: none;
|
|
1008
|
+
border-radius: 6px;
|
|
1009
|
+
background: transparent;
|
|
1010
|
+
color: #1f2937;
|
|
1011
|
+
font-size: 13px;
|
|
1012
|
+
font-family: inherit;
|
|
1013
|
+
text-align: left;
|
|
1014
|
+
cursor: pointer;
|
|
1015
|
+
transition: background 0.12s;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
.ai-chart-export-menu-item:hover {
|
|
1019
|
+
background: #f3f4f6;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/* ── Chart full-screen modal ── */
|
|
1023
|
+
.ai-chart-modal-overlay {
|
|
1024
|
+
position: fixed;
|
|
1025
|
+
inset: 0;
|
|
1026
|
+
z-index: 1000;
|
|
1027
|
+
display: flex;
|
|
1028
|
+
align-items: center;
|
|
1029
|
+
justify-content: center;
|
|
1030
|
+
padding: 24px;
|
|
1031
|
+
background: rgba(17, 24, 39, 0.55);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
.ai-chart-modal {
|
|
1035
|
+
display: flex;
|
|
1036
|
+
flex-direction: column;
|
|
1037
|
+
width: 96vw;
|
|
1038
|
+
max-width: 1100px;
|
|
1039
|
+
max-height: 92vh;
|
|
1040
|
+
overflow: hidden;
|
|
1041
|
+
background: #ffffff;
|
|
1042
|
+
border-radius: 12px;
|
|
1043
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
.ai-chart-modal-head {
|
|
1047
|
+
display: flex;
|
|
1048
|
+
align-items: center;
|
|
1049
|
+
justify-content: space-between;
|
|
1050
|
+
gap: 12px;
|
|
1051
|
+
padding: 14px 18px;
|
|
1052
|
+
border-bottom: 1px solid #e5e7eb;
|
|
1053
|
+
flex: 0 0 auto;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
.ai-chart-modal-title {
|
|
1057
|
+
font-size: 15px;
|
|
1058
|
+
font-weight: 600;
|
|
1059
|
+
color: #1f2937;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
.ai-chart-modal-actions {
|
|
1063
|
+
display: flex;
|
|
1064
|
+
align-items: center;
|
|
1065
|
+
gap: 8px;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
.ai-chart-modal-close {
|
|
1069
|
+
border: none;
|
|
1070
|
+
background: transparent;
|
|
1071
|
+
font-size: 24px;
|
|
1072
|
+
line-height: 1;
|
|
1073
|
+
color: #6b7280;
|
|
1074
|
+
cursor: pointer;
|
|
1075
|
+
padding: 0 4px;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
.ai-chart-modal-close:hover {
|
|
1079
|
+
color: #1f2937;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/* The body is a single flex scroll context so the table wrap is the only
|
|
1083
|
+
scroller — avoids nested scrolling that let rows peek above the sticky header. */
|
|
1084
|
+
.ai-chart-modal-body {
|
|
1085
|
+
flex: 1 1 auto;
|
|
1086
|
+
min-height: 0;
|
|
1087
|
+
display: flex;
|
|
1088
|
+
flex-direction: column;
|
|
1089
|
+
padding: 0 18px 18px;
|
|
1090
|
+
overflow: hidden;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/* Card chrome (border/background/title) is redundant inside the modal */
|
|
1094
|
+
.ai-chart-modal-body .ai-chart-card {
|
|
1095
|
+
flex: 1 1 auto;
|
|
1096
|
+
min-height: 0;
|
|
1097
|
+
display: flex;
|
|
1098
|
+
flex-direction: column;
|
|
1099
|
+
margin: 0;
|
|
1100
|
+
padding: 0;
|
|
1101
|
+
border: none;
|
|
1102
|
+
background: transparent;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
.ai-chart-modal-body .ai-chart-title {
|
|
1106
|
+
display: none;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
.ai-chart-modal-body .ai-data-table-wrap {
|
|
1110
|
+
flex: 1 1 auto;
|
|
1111
|
+
min-height: 0;
|
|
1112
|
+
max-height: none;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1015
1115
|
.ai-dot-chart-header {
|
|
1016
1116
|
display: flex;
|
|
1017
1117
|
justify-content: flex-start;
|
|
@@ -1217,11 +1317,14 @@
|
|
|
1217
1317
|
|
|
1218
1318
|
.ai-data-table-wrap {
|
|
1219
1319
|
overflow-x: auto;
|
|
1320
|
+
overflow-y: auto;
|
|
1321
|
+
max-height: 620px; /* ~20 rows + header; scrolls beyond, header stays pinned */
|
|
1220
1322
|
}
|
|
1221
1323
|
|
|
1222
1324
|
.ai-data-table {
|
|
1223
1325
|
width: 100%;
|
|
1224
|
-
border-collapse: collapse
|
|
1326
|
+
border-collapse: separate; /* not collapse: keeps the sticky header from leaking row slivers */
|
|
1327
|
+
border-spacing: 0;
|
|
1225
1328
|
font-size: 12px;
|
|
1226
1329
|
}
|
|
1227
1330
|
|
|
@@ -1235,6 +1338,37 @@
|
|
|
1235
1338
|
letter-spacing: 0.04em;
|
|
1236
1339
|
border-bottom: 2px solid #e5e7eb;
|
|
1237
1340
|
white-space: nowrap;
|
|
1341
|
+
position: sticky;
|
|
1342
|
+
top: -1px; /* close the sub-pixel gap so rows can't peek above the header */
|
|
1343
|
+
z-index: 3;
|
|
1344
|
+
background: #eef2f6;
|
|
1345
|
+
background-clip: padding-box;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
.ai-data-table-th--sortable {
|
|
1349
|
+
cursor: pointer;
|
|
1350
|
+
-webkit-user-select: none;
|
|
1351
|
+
user-select: none;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
.ai-data-table-th--sortable:hover {
|
|
1355
|
+
color: #1f2937;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
.ai-data-table-th-inner {
|
|
1359
|
+
display: inline-flex;
|
|
1360
|
+
align-items: center;
|
|
1361
|
+
gap: 6px;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
.ai-data-table-sort {
|
|
1365
|
+
font-size: 9px;
|
|
1366
|
+
opacity: 0.4;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
.ai-data-table-th.is-sorted .ai-data-table-sort {
|
|
1370
|
+
opacity: 1;
|
|
1371
|
+
color: #1eaaf1;
|
|
1238
1372
|
}
|
|
1239
1373
|
|
|
1240
1374
|
.ai-data-table-row:nth-child(even) .ai-data-table-td {
|
|
@@ -1248,6 +1382,18 @@
|
|
|
1248
1382
|
vertical-align: top;
|
|
1249
1383
|
}
|
|
1250
1384
|
|
|
1385
|
+
/* TOTAL / pinned rows live in <tfoot> and stick to the bottom like a footer.
|
|
1386
|
+
At full scroll they sit naturally below the last row, so nothing is obscured. */
|
|
1387
|
+
.ai-data-table tfoot .ai-data-table-td {
|
|
1388
|
+
position: sticky;
|
|
1389
|
+
bottom: 0;
|
|
1390
|
+
z-index: 2;
|
|
1391
|
+
font-weight: 700;
|
|
1392
|
+
color: #1f2937;
|
|
1393
|
+
background: #e7edf3;
|
|
1394
|
+
border-top: 2px solid #cbd5e1;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1251
1397
|
/* ── Bar Chart ── */
|
|
1252
1398
|
|
|
1253
1399
|
.ai-bar-chart {
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useRef, useState } from "react";
|
|
1
|
+
import React, { useRef, useState, useEffect } from "react";
|
|
2
2
|
import html2canvas from "html2canvas";
|
|
3
3
|
import {
|
|
4
4
|
DOT_CHART_TYPE,
|
|
@@ -36,76 +36,195 @@ const COMPONENTS = {
|
|
|
36
36
|
[SCATTER_PLOT_TYPE]: ScatterPlot,
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
const DOWNLOAD_ICON = (
|
|
40
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
41
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
42
|
+
<polyline points="7 10 12 15 17 10" />
|
|
43
|
+
<line x1="12" y1="15" x2="12" y2="3" />
|
|
44
|
+
</svg>
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const EXPAND_ICON = (
|
|
48
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
49
|
+
<polyline points="15 3 21 3 21 9" />
|
|
50
|
+
<polyline points="9 21 3 21 3 15" />
|
|
51
|
+
<line x1="21" y1="3" x2="14" y2="10" />
|
|
52
|
+
<line x1="3" y1="21" x2="10" y2="14" />
|
|
53
|
+
</svg>
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
function slug(value) {
|
|
57
|
+
return String(value || "").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildChartFilename(chart, ext) {
|
|
61
|
+
const typePart = slug(chart?.type) || "chart";
|
|
62
|
+
const titlePart = slug(chart?.title);
|
|
42
63
|
const datePart = new Date().toISOString().slice(0, 10);
|
|
43
64
|
const base = titlePart ? `${typePart}-${titlePart}` : typePart;
|
|
44
|
-
return `${base || "chart"}-${datePart}
|
|
65
|
+
return `${base || "chart"}-${datePart}.${ext}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function csvEscape(value) {
|
|
69
|
+
const s = (value ?? "").toString();
|
|
70
|
+
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function tableToCsv(chart) {
|
|
74
|
+
const columns = chart.columns || [];
|
|
75
|
+
const rows = chart.rows || [];
|
|
76
|
+
const header = columns.map((c) => csvEscape(c.label)).join(",");
|
|
77
|
+
const body = rows.map((row) => columns.map((c) => csvEscape(row[c.key])).join(",")).join("\r\n");
|
|
78
|
+
return `${header}\r\n${body}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function triggerDownload(href, filename) {
|
|
82
|
+
const link = document.createElement("a");
|
|
83
|
+
link.href = href;
|
|
84
|
+
link.download = filename;
|
|
85
|
+
document.body.appendChild(link);
|
|
86
|
+
link.click();
|
|
87
|
+
document.body.removeChild(link);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Download control: a single PNG button for non-tables, a CSV/Image menu for tables.
|
|
91
|
+
function DownloadMenu({ supportsCsv, isDownloading, onCsv, onImage }) {
|
|
92
|
+
const [open, setOpen] = useState(false);
|
|
93
|
+
const ref = useRef(null);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!open) return undefined;
|
|
97
|
+
const onDocMouseDown = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
|
98
|
+
document.addEventListener("mousedown", onDocMouseDown);
|
|
99
|
+
return () => document.removeEventListener("mousedown", onDocMouseDown);
|
|
100
|
+
}, [open]);
|
|
101
|
+
|
|
102
|
+
if (!supportsCsv) {
|
|
103
|
+
return (
|
|
104
|
+
<button type="button" className="ai-chart-export-btn" onClick={onImage} disabled={isDownloading} title="Save chart as PNG" aria-label="Save chart as PNG">
|
|
105
|
+
{DOWNLOAD_ICON}
|
|
106
|
+
<span className="ai-chart-export-label">Download</span>
|
|
107
|
+
</button>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className="ai-chart-export-menu-wrap" ref={ref}>
|
|
113
|
+
<button type="button" className="ai-chart-export-btn" onClick={() => setOpen((o) => !o)} disabled={isDownloading} aria-haspopup="menu" aria-expanded={open} title="Download" aria-label="Download">
|
|
114
|
+
{DOWNLOAD_ICON}
|
|
115
|
+
<span className="ai-chart-export-label">Download</span>
|
|
116
|
+
</button>
|
|
117
|
+
{open && (
|
|
118
|
+
<div className="ai-chart-export-menu" role="menu">
|
|
119
|
+
<button type="button" role="menuitem" className="ai-chart-export-menu-item" onClick={() => { setOpen(false); onCsv(); }}>CSV (spreadsheet)</button>
|
|
120
|
+
<button type="button" role="menuitem" className="ai-chart-export-menu-item" onClick={() => { setOpen(false); onImage(); }}>Image (PNG)</button>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
45
125
|
}
|
|
46
126
|
|
|
47
127
|
export default function AiVisualization({ chart }) {
|
|
48
128
|
const chartRef = useRef(null);
|
|
129
|
+
const modalBodyRef = useRef(null);
|
|
49
130
|
const [isDownloading, setIsDownloading] = useState(false);
|
|
131
|
+
const [expanded, setExpanded] = useState(false);
|
|
132
|
+
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (!expanded) return undefined;
|
|
135
|
+
const onKeyDown = (e) => { if (e.key === "Escape") setExpanded(false); };
|
|
136
|
+
document.addEventListener("keydown", onKeyDown);
|
|
137
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
138
|
+
}, [expanded]);
|
|
50
139
|
|
|
51
140
|
if (!chart || !chart.type) {
|
|
52
141
|
return null;
|
|
53
142
|
}
|
|
54
143
|
|
|
55
144
|
const Component = COMPONENTS[chart.type];
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
145
|
+
const isTable = chart.type === TABLE_TYPE && Array.isArray(chart.columns) && Array.isArray(chart.rows);
|
|
146
|
+
const supportsCsv = isTable;
|
|
147
|
+
const canExpand = isTable && chart.rows.length > 10;
|
|
60
148
|
|
|
149
|
+
const downloadImage = async (targetRef) => {
|
|
150
|
+
const node = (targetRef && targetRef.current) || chartRef.current;
|
|
151
|
+
if (!node || isDownloading) return;
|
|
61
152
|
setIsDownloading(true);
|
|
62
153
|
try {
|
|
63
|
-
const canvas = await html2canvas(
|
|
64
|
-
backgroundColor:
|
|
154
|
+
const canvas = await html2canvas(node, {
|
|
155
|
+
backgroundColor: "#ffffff",
|
|
65
156
|
useCORS: true,
|
|
66
157
|
scale: Math.max(window.devicePixelRatio || 1, 2),
|
|
67
158
|
});
|
|
68
|
-
|
|
69
|
-
const link = document.createElement("a");
|
|
70
|
-
link.href = url;
|
|
71
|
-
link.download = buildChartFilename(chart);
|
|
72
|
-
link.click();
|
|
159
|
+
triggerDownload(canvas.toDataURL("image/png"), buildChartFilename(chart, "png"));
|
|
73
160
|
} finally {
|
|
74
161
|
setIsDownloading(false);
|
|
75
162
|
}
|
|
76
163
|
};
|
|
77
164
|
|
|
78
|
-
|
|
165
|
+
const downloadCsv = () => {
|
|
166
|
+
const blob = new Blob([`${tableToCsv(chart)}`], { type: "text/csv;charset=utf-8;" });
|
|
167
|
+
const url = URL.createObjectURL(blob);
|
|
168
|
+
triggerDownload(url, buildChartFilename(chart, "csv"));
|
|
169
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
if (!Component) {
|
|
79
173
|
return (
|
|
80
|
-
<div className="ai-chart-
|
|
81
|
-
<
|
|
174
|
+
<div className="ai-chart-card ai-chart-error">
|
|
175
|
+
<p>Unsupported chart type: {chart.type}</p>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<div className="ai-chart-export-wrap">
|
|
182
|
+
<div className="ai-chart-export-actions">
|
|
183
|
+
{canExpand && (
|
|
82
184
|
<button
|
|
83
185
|
type="button"
|
|
84
186
|
className="ai-chart-export-btn"
|
|
85
|
-
onClick={
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
aria-label="Save chart as PNG"
|
|
187
|
+
onClick={() => setExpanded(true)}
|
|
188
|
+
title="Expand to full screen"
|
|
189
|
+
aria-label="Expand table to full screen"
|
|
89
190
|
>
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
<polyline points="7 10 12 15 17 10" />
|
|
93
|
-
<line x1="12" y1="15" x2="12" y2="3" />
|
|
94
|
-
</svg>
|
|
95
|
-
<span className="ai-chart-export-label">{isDownloading ? "Saving..." : "Download"}</span>
|
|
191
|
+
{EXPAND_ICON}
|
|
192
|
+
<span className="ai-chart-export-label">Expand</span>
|
|
96
193
|
</button>
|
|
97
|
-
|
|
98
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
194
|
+
)}
|
|
195
|
+
<DownloadMenu
|
|
196
|
+
supportsCsv={supportsCsv}
|
|
197
|
+
isDownloading={isDownloading}
|
|
198
|
+
onCsv={downloadCsv}
|
|
199
|
+
onImage={() => downloadImage(chartRef)}
|
|
200
|
+
/>
|
|
101
201
|
</div>
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
202
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
203
|
+
<div className="ai-chart-export-target" ref={chartRef}>
|
|
204
|
+
<Component chart={chart} />
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
{expanded && (
|
|
208
|
+
<div className="ai-chart-modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) setExpanded(false); }}>
|
|
209
|
+
<div className="ai-chart-modal" role="dialog" aria-modal="true">
|
|
210
|
+
<div className="ai-chart-modal-head">
|
|
211
|
+
<span className="ai-chart-modal-title">{chart.title || "Table"}</span>
|
|
212
|
+
<div className="ai-chart-modal-actions">
|
|
213
|
+
<DownloadMenu
|
|
214
|
+
supportsCsv={supportsCsv}
|
|
215
|
+
isDownloading={isDownloading}
|
|
216
|
+
onCsv={downloadCsv}
|
|
217
|
+
onImage={() => downloadImage(modalBodyRef)}
|
|
218
|
+
/>
|
|
219
|
+
<button type="button" className="ai-chart-modal-close" onClick={() => setExpanded(false)} aria-label="Close">×</button>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
<div className="ai-chart-modal-body" ref={modalBodyRef}>
|
|
223
|
+
<Component chart={chart} />
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
108
228
|
</div>
|
|
109
229
|
);
|
|
110
230
|
}
|
|
111
|
-
|
|
@@ -1,69 +1,78 @@
|
|
|
1
|
-
import React from "react";
|
|
1
|
+
import React, { useState, useMemo } from "react";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
return `${header}\r\n${body}`;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function downloadCsv(filename, csv) {
|
|
15
|
-
// Prepend a BOM so Excel reads UTF-8 correctly.
|
|
16
|
-
const blob = new Blob([`${csv}`], { type: "text/csv;charset=utf-8;" });
|
|
17
|
-
const url = URL.createObjectURL(blob);
|
|
18
|
-
const a = document.createElement("a");
|
|
19
|
-
a.href = url;
|
|
20
|
-
a.download = filename;
|
|
21
|
-
document.body.appendChild(a);
|
|
22
|
-
a.click();
|
|
23
|
-
document.body.removeChild(a);
|
|
24
|
-
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function safeFilename(title) {
|
|
28
|
-
const base = (title || "table").replace(/[\\/:*?"<>|]+/g, "").trim().slice(0, 80);
|
|
29
|
-
return `${base || "table"}.csv`;
|
|
3
|
+
// Compare two cell values numerically when both look numeric, else as text.
|
|
4
|
+
function compareValues(a, b) {
|
|
5
|
+
const na = parseFloat(String(a ?? "").replace(/[, ]/g, ""));
|
|
6
|
+
const nb = parseFloat(String(b ?? "").replace(/[, ]/g, ""));
|
|
7
|
+
const aNum = String(a ?? "").trim() !== "" && !Number.isNaN(na);
|
|
8
|
+
const bNum = String(b ?? "").trim() !== "" && !Number.isNaN(nb);
|
|
9
|
+
if (aNum && bNum) return na - nb;
|
|
10
|
+
return String(a ?? "").localeCompare(String(b ?? ""), undefined, { numeric: true, sensitivity: "base" });
|
|
30
11
|
}
|
|
31
12
|
|
|
32
13
|
export default function DataTable({ chart }) {
|
|
33
14
|
const { title, columns, rows } = chart;
|
|
15
|
+
const [sortKey, setSortKey] = useState(null);
|
|
16
|
+
const [sortDir, setSortDir] = useState("asc");
|
|
17
|
+
|
|
18
|
+
// Rows flagged `_pinned` (e.g. a TOTAL row) render in a sticky <tfoot> so they
|
|
19
|
+
// stay visible like a footer; the rest sort within the <tbody>.
|
|
20
|
+
const { bodyRows, footRows } = useMemo(() => {
|
|
21
|
+
const pinned = rows.filter((r) => r && r._pinned);
|
|
22
|
+
const sortable = rows.filter((r) => !(r && r._pinned));
|
|
23
|
+
const sorted = sortKey
|
|
24
|
+
? [...sortable].sort((a, b) => {
|
|
25
|
+
const cmp = compareValues(a?.[sortKey], b?.[sortKey]);
|
|
26
|
+
return sortDir === "asc" ? cmp : -cmp;
|
|
27
|
+
})
|
|
28
|
+
: sortable;
|
|
29
|
+
return { bodyRows: sorted, footRows: pinned };
|
|
30
|
+
}, [rows, sortKey, sortDir]);
|
|
34
31
|
|
|
35
|
-
|
|
32
|
+
// Cycle: unsorted -> ascending -> descending -> unsorted.
|
|
33
|
+
const handleSort = (key) => {
|
|
34
|
+
if (sortKey !== key) {
|
|
35
|
+
setSortKey(key);
|
|
36
|
+
setSortDir("asc");
|
|
37
|
+
} else if (sortDir === "asc") {
|
|
38
|
+
setSortDir("desc");
|
|
39
|
+
} else {
|
|
40
|
+
setSortKey(null);
|
|
41
|
+
setSortDir("asc");
|
|
42
|
+
}
|
|
43
|
+
};
|
|
36
44
|
|
|
37
45
|
return (
|
|
38
46
|
<div className="ai-chart-card">
|
|
39
|
-
<
|
|
40
|
-
{title ? <h4 className="ai-chart-title">{title}</h4> : <span />}
|
|
41
|
-
<button
|
|
42
|
-
type="button"
|
|
43
|
-
className="ai-chart-csv-btn"
|
|
44
|
-
onClick={handleDownload}
|
|
45
|
-
title="Download as CSV"
|
|
46
|
-
aria-label="Download table as CSV"
|
|
47
|
-
>
|
|
48
|
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
49
|
-
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
50
|
-
<polyline points="7 10 12 15 17 10" />
|
|
51
|
-
<line x1="12" y1="15" x2="12" y2="3" />
|
|
52
|
-
</svg>
|
|
53
|
-
CSV
|
|
54
|
-
</button>
|
|
55
|
-
</div>
|
|
47
|
+
{title ? <h4 className="ai-chart-title">{title}</h4> : null}
|
|
56
48
|
<div className="ai-data-table-wrap">
|
|
57
49
|
<table className="ai-data-table">
|
|
58
50
|
<thead>
|
|
59
51
|
<tr>
|
|
60
|
-
{columns.map((col) =>
|
|
61
|
-
|
|
62
|
-
|
|
52
|
+
{columns.map((col) => {
|
|
53
|
+
const active = sortKey === col.key;
|
|
54
|
+
return (
|
|
55
|
+
<th
|
|
56
|
+
key={col.key}
|
|
57
|
+
className={`ai-data-table-th ai-data-table-th--sortable${active ? " is-sorted" : ""}`}
|
|
58
|
+
onClick={() => handleSort(col.key)}
|
|
59
|
+
role="button"
|
|
60
|
+
tabIndex={0}
|
|
61
|
+
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleSort(col.key); } }}
|
|
62
|
+
aria-sort={active ? (sortDir === "asc" ? "ascending" : "descending") : "none"}
|
|
63
|
+
title={`Sort by ${col.label}`}
|
|
64
|
+
>
|
|
65
|
+
<span className="ai-data-table-th-inner">
|
|
66
|
+
<span>{col.label}</span>
|
|
67
|
+
<span className="ai-data-table-sort" aria-hidden="true">{active ? (sortDir === "asc" ? "▲" : "▼") : "↕"}</span>
|
|
68
|
+
</span>
|
|
69
|
+
</th>
|
|
70
|
+
);
|
|
71
|
+
})}
|
|
63
72
|
</tr>
|
|
64
73
|
</thead>
|
|
65
74
|
<tbody>
|
|
66
|
-
{
|
|
75
|
+
{bodyRows.map((row, i) => (
|
|
67
76
|
<tr key={i} className="ai-data-table-row">
|
|
68
77
|
{columns.map((col) => (
|
|
69
78
|
<td key={col.key} className="ai-data-table-td">{row[col.key]}</td>
|
|
@@ -71,6 +80,17 @@ export default function DataTable({ chart }) {
|
|
|
71
80
|
</tr>
|
|
72
81
|
))}
|
|
73
82
|
</tbody>
|
|
83
|
+
{footRows.length > 0 && (
|
|
84
|
+
<tfoot>
|
|
85
|
+
{footRows.map((row, i) => (
|
|
86
|
+
<tr key={i} className="ai-data-table-row ai-data-table-row--pinned">
|
|
87
|
+
{columns.map((col) => (
|
|
88
|
+
<td key={col.key} className="ai-data-table-td">{row[col.key]}</td>
|
|
89
|
+
))}
|
|
90
|
+
</tr>
|
|
91
|
+
))}
|
|
92
|
+
</tfoot>
|
|
93
|
+
)}
|
|
74
94
|
</table>
|
|
75
95
|
</div>
|
|
76
96
|
</div>
|