orchid-ai 2.1.5 → 2.3.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/orchid-ai.css
CHANGED
|
@@ -921,6 +921,9 @@
|
|
|
921
921
|
top: 8px;
|
|
922
922
|
right: 8px;
|
|
923
923
|
z-index: 12;
|
|
924
|
+
display: flex;
|
|
925
|
+
align-items: center;
|
|
926
|
+
gap: 6px;
|
|
924
927
|
}
|
|
925
928
|
|
|
926
929
|
.ai-chart-export-btn {
|
|
@@ -979,6 +982,135 @@
|
|
|
979
982
|
opacity: 0.65;
|
|
980
983
|
}
|
|
981
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: min(1100px, 96vw);
|
|
1038
|
+
max-height: 92vh;
|
|
1039
|
+
overflow: hidden;
|
|
1040
|
+
background: #ffffff;
|
|
1041
|
+
border-radius: 12px;
|
|
1042
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
.ai-chart-modal-head {
|
|
1046
|
+
display: flex;
|
|
1047
|
+
align-items: center;
|
|
1048
|
+
justify-content: space-between;
|
|
1049
|
+
gap: 12px;
|
|
1050
|
+
padding: 14px 18px;
|
|
1051
|
+
border-bottom: 1px solid #e5e7eb;
|
|
1052
|
+
flex: 0 0 auto;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
.ai-chart-modal-title {
|
|
1056
|
+
font-size: 15px;
|
|
1057
|
+
font-weight: 600;
|
|
1058
|
+
color: #1f2937;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
.ai-chart-modal-actions {
|
|
1062
|
+
display: flex;
|
|
1063
|
+
align-items: center;
|
|
1064
|
+
gap: 8px;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
.ai-chart-modal-close {
|
|
1068
|
+
border: none;
|
|
1069
|
+
background: transparent;
|
|
1070
|
+
font-size: 24px;
|
|
1071
|
+
line-height: 1;
|
|
1072
|
+
color: #6b7280;
|
|
1073
|
+
cursor: pointer;
|
|
1074
|
+
padding: 0 4px;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
.ai-chart-modal-close:hover {
|
|
1078
|
+
color: #1f2937;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/* The body is a single flex scroll context so the table wrap is the only
|
|
1082
|
+
scroller — avoids nested scrolling that let rows peek above the sticky header. */
|
|
1083
|
+
.ai-chart-modal-body {
|
|
1084
|
+
flex: 1 1 auto;
|
|
1085
|
+
min-height: 0;
|
|
1086
|
+
display: flex;
|
|
1087
|
+
flex-direction: column;
|
|
1088
|
+
padding: 0 18px 18px;
|
|
1089
|
+
overflow: hidden;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/* Card chrome (border/background/title) is redundant inside the modal */
|
|
1093
|
+
.ai-chart-modal-body .ai-chart-card {
|
|
1094
|
+
flex: 1 1 auto;
|
|
1095
|
+
min-height: 0;
|
|
1096
|
+
display: flex;
|
|
1097
|
+
flex-direction: column;
|
|
1098
|
+
margin: 0;
|
|
1099
|
+
padding: 0;
|
|
1100
|
+
border: none;
|
|
1101
|
+
background: transparent;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
.ai-chart-modal-body .ai-chart-title {
|
|
1105
|
+
display: none;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
.ai-chart-modal-body .ai-data-table-wrap {
|
|
1109
|
+
flex: 1 1 auto;
|
|
1110
|
+
min-height: 0;
|
|
1111
|
+
max-height: none;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
982
1114
|
.ai-dot-chart-header {
|
|
983
1115
|
display: flex;
|
|
984
1116
|
justify-content: flex-start;
|
|
@@ -1184,11 +1316,14 @@
|
|
|
1184
1316
|
|
|
1185
1317
|
.ai-data-table-wrap {
|
|
1186
1318
|
overflow-x: auto;
|
|
1319
|
+
overflow-y: auto;
|
|
1320
|
+
max-height: 620px; /* ~20 rows + header; scrolls beyond, header stays pinned */
|
|
1187
1321
|
}
|
|
1188
1322
|
|
|
1189
1323
|
.ai-data-table {
|
|
1190
1324
|
width: 100%;
|
|
1191
|
-
border-collapse: collapse
|
|
1325
|
+
border-collapse: separate; /* not collapse: keeps the sticky header from leaking row slivers */
|
|
1326
|
+
border-spacing: 0;
|
|
1192
1327
|
font-size: 12px;
|
|
1193
1328
|
}
|
|
1194
1329
|
|
|
@@ -1202,6 +1337,37 @@
|
|
|
1202
1337
|
letter-spacing: 0.04em;
|
|
1203
1338
|
border-bottom: 2px solid #e5e7eb;
|
|
1204
1339
|
white-space: nowrap;
|
|
1340
|
+
position: sticky;
|
|
1341
|
+
top: -1px; /* close the sub-pixel gap so rows can't peek above the header */
|
|
1342
|
+
z-index: 3;
|
|
1343
|
+
background: #eef2f6;
|
|
1344
|
+
background-clip: padding-box;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
.ai-data-table-th--sortable {
|
|
1348
|
+
cursor: pointer;
|
|
1349
|
+
-webkit-user-select: none;
|
|
1350
|
+
user-select: none;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
.ai-data-table-th--sortable:hover {
|
|
1354
|
+
color: #1f2937;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
.ai-data-table-th-inner {
|
|
1358
|
+
display: inline-flex;
|
|
1359
|
+
align-items: center;
|
|
1360
|
+
gap: 6px;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
.ai-data-table-sort {
|
|
1364
|
+
font-size: 9px;
|
|
1365
|
+
opacity: 0.4;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
.ai-data-table-th.is-sorted .ai-data-table-sort {
|
|
1369
|
+
opacity: 1;
|
|
1370
|
+
color: #1eaaf1;
|
|
1205
1371
|
}
|
|
1206
1372
|
|
|
1207
1373
|
.ai-data-table-row:nth-child(even) .ai-data-table-td {
|
|
@@ -1215,6 +1381,18 @@
|
|
|
1215
1381
|
vertical-align: top;
|
|
1216
1382
|
}
|
|
1217
1383
|
|
|
1384
|
+
/* TOTAL / pinned rows live in <tfoot> and stick to the bottom like a footer.
|
|
1385
|
+
At full scroll they sit naturally below the last row, so nothing is obscured. */
|
|
1386
|
+
.ai-data-table tfoot .ai-data-table-td {
|
|
1387
|
+
position: sticky;
|
|
1388
|
+
bottom: 0;
|
|
1389
|
+
z-index: 2;
|
|
1390
|
+
font-weight: 700;
|
|
1391
|
+
color: #1f2937;
|
|
1392
|
+
background: #e7edf3;
|
|
1393
|
+
border-top: 2px solid #cbd5e1;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1218
1396
|
/* ── Bar Chart ── */
|
|
1219
1397
|
|
|
1220
1398
|
.ai-bar-chart {
|
|
@@ -1877,8 +2055,9 @@
|
|
|
1877
2055
|
font-size: 22px;
|
|
1878
2056
|
font-weight: 700;
|
|
1879
2057
|
color: #1f2937;
|
|
1880
|
-
line-height: 1;
|
|
2058
|
+
line-height: 1.15;
|
|
1881
2059
|
margin-bottom: 5px;
|
|
2060
|
+
overflow-wrap: break-word;
|
|
1882
2061
|
}
|
|
1883
2062
|
|
|
1884
2063
|
.ai-stat-unit {
|
|
@@ -2762,6 +2941,20 @@
|
|
|
2762
2941
|
color: #1f2937 !important;
|
|
2763
2942
|
box-shadow: none !important;
|
|
2764
2943
|
}
|
|
2944
|
+
|
|
2945
|
+
body.ai-chat-printing #ai-cortex-print-section .ai-stat-card {
|
|
2946
|
+
padding: 8px 10px !important;
|
|
2947
|
+
min-width: 80px !important;
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
body.ai-chat-printing #ai-cortex-print-section .ai-stat-value {
|
|
2951
|
+
font-size: 17px !important;
|
|
2952
|
+
line-height: 1.2 !important;
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
body.ai-chat-printing #ai-cortex-print-section .ai-stat-label {
|
|
2956
|
+
font-size: 9px !important;
|
|
2957
|
+
}
|
|
2765
2958
|
}
|
|
2766
2959
|
|
|
2767
2960
|
/* ── Responsive ── */
|
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,7 +1,46 @@
|
|
|
1
|
-
import React from "react";
|
|
1
|
+
import React, { useState, useMemo } from "react";
|
|
2
|
+
|
|
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" });
|
|
11
|
+
}
|
|
2
12
|
|
|
3
13
|
export default function DataTable({ chart }) {
|
|
4
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]);
|
|
31
|
+
|
|
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
|
+
};
|
|
5
44
|
|
|
6
45
|
return (
|
|
7
46
|
<div className="ai-chart-card">
|
|
@@ -10,13 +49,30 @@ export default function DataTable({ chart }) {
|
|
|
10
49
|
<table className="ai-data-table">
|
|
11
50
|
<thead>
|
|
12
51
|
<tr>
|
|
13
|
-
{columns.map((col) =>
|
|
14
|
-
|
|
15
|
-
|
|
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
|
+
})}
|
|
16
72
|
</tr>
|
|
17
73
|
</thead>
|
|
18
74
|
<tbody>
|
|
19
|
-
{
|
|
75
|
+
{bodyRows.map((row, i) => (
|
|
20
76
|
<tr key={i} className="ai-data-table-row">
|
|
21
77
|
{columns.map((col) => (
|
|
22
78
|
<td key={col.key} className="ai-data-table-td">{row[col.key]}</td>
|
|
@@ -24,6 +80,17 @@ export default function DataTable({ chart }) {
|
|
|
24
80
|
</tr>
|
|
25
81
|
))}
|
|
26
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
|
+
)}
|
|
27
94
|
</table>
|
|
28
95
|
</div>
|
|
29
96
|
</div>
|