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,6 +1,6 @@
1
1
  {
2
2
  "name": "orchid-ai",
3
- "version": "2.1.5",
3
+ "version": "2.3.0",
4
4
  "description": "Shared Orchid AI chat UI and visualization components",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -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
- function buildChartFilename(chart) {
40
- const typePart = String(chart?.type || "chart").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase();
41
- const titlePart = String(chart?.title || "").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase();
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}.png`;
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 handleDownload = async () => {
57
- if (!chartRef.current || isDownloading) {
58
- return;
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(chartRef.current, {
64
- backgroundColor: null,
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
- const url = canvas.toDataURL("image/png");
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
- if (Component) {
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-export-wrap">
81
- <div className="ai-chart-export-actions">
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={handleDownload}
86
- disabled={isDownloading}
87
- title="Save chart as PNG"
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
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
91
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
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
- </div>
98
- <div className="ai-chart-export-target" ref={chartRef}>
99
- <Component chart={chart} />
100
- </div>
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
- return (
106
- <div className="ai-chart-card ai-chart-error">
107
- <p>Unsupported chart type: {chart.type}</p>
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
- <th key={col.key} className="ai-data-table-th">{col.label}</th>
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
- {rows.map((row, i) => (
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>