@useatlas/react 0.0.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/README.md +95 -0
- package/dist/chunk-2WFDP7G5.js +231 -0
- package/dist/chunk-2WFDP7G5.js.map +1 -0
- package/dist/chunk-44HBZYKP.js +224 -0
- package/dist/chunk-44HBZYKP.js.map +1 -0
- package/dist/chunk-5SEVKHS5.cjs +229 -0
- package/dist/chunk-5SEVKHS5.cjs.map +1 -0
- package/dist/chunk-UIRB6L36.cjs +249 -0
- package/dist/chunk-UIRB6L36.cjs.map +1 -0
- package/dist/hooks.cjs +251 -0
- package/dist/hooks.cjs.map +1 -0
- package/dist/hooks.d.cts +132 -0
- package/dist/hooks.d.ts +132 -0
- package/dist/hooks.js +237 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.cjs +2976 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +69 -0
- package/dist/index.d.ts +69 -0
- package/dist/index.js +2926 -0
- package/dist/index.js.map +1 -0
- package/dist/result-chart-NFAJ4IQ5.js +398 -0
- package/dist/result-chart-NFAJ4IQ5.js.map +1 -0
- package/dist/result-chart-YLCKBNV4.cjs +400 -0
- package/dist/result-chart-YLCKBNV4.cjs.map +1 -0
- package/dist/styles.css +59 -0
- package/dist/use-dark-mode-rFxawUv1.d.cts +123 -0
- package/dist/use-dark-mode-rFxawUv1.d.ts +123 -0
- package/dist/widget.css +2 -0
- package/dist/widget.js +445 -0
- package/package.json +113 -0
- package/src/components/__tests__/tool-renderers.test.tsx +239 -0
- package/src/components/actions/action-approval-card.tsx +296 -0
- package/src/components/actions/action-status-badge.tsx +50 -0
- package/src/components/admin/change-password-dialog.tsx +128 -0
- package/src/components/atlas-chat.tsx +656 -0
- package/src/components/chart/chart-detection.ts +318 -0
- package/src/components/chart/result-chart.tsx +590 -0
- package/src/components/chat/api-key-bar.tsx +66 -0
- package/src/components/chat/copy-button.tsx +25 -0
- package/src/components/chat/data-table.tsx +104 -0
- package/src/components/chat/error-banner.tsx +32 -0
- package/src/components/chat/explore-card.tsx +41 -0
- package/src/components/chat/follow-up-chips.tsx +29 -0
- package/src/components/chat/loading-card.tsx +10 -0
- package/src/components/chat/managed-auth-card.tsx +116 -0
- package/src/components/chat/markdown.tsx +146 -0
- package/src/components/chat/python-result-card.tsx +245 -0
- package/src/components/chat/sql-block.tsx +54 -0
- package/src/components/chat/sql-result-card.tsx +163 -0
- package/src/components/chat/starter-prompts.ts +6 -0
- package/src/components/chat/tool-part.tsx +106 -0
- package/src/components/chat/typing-indicator.tsx +22 -0
- package/src/components/conversations/conversation-item.tsx +135 -0
- package/src/components/conversations/conversation-list.tsx +69 -0
- package/src/components/conversations/conversation-sidebar.tsx +113 -0
- package/src/components/conversations/delete-confirmation.tsx +27 -0
- package/src/components/schema-explorer/schema-explorer.tsx +517 -0
- package/src/components/ui/alert-dialog.tsx +196 -0
- package/src/components/ui/badge.tsx +48 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/dialog.tsx +158 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +24 -0
- package/src/components/ui/scroll-area.tsx +62 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +143 -0
- package/src/components/ui/table.tsx +116 -0
- package/src/components/ui/toggle-group.tsx +83 -0
- package/src/components/ui/toggle.tsx +47 -0
- package/src/context.tsx +85 -0
- package/src/env.d.ts +9 -0
- package/src/hooks/__tests__/provider.test.tsx +83 -0
- package/src/hooks/__tests__/use-atlas-auth.test.tsx +283 -0
- package/src/hooks/__tests__/use-atlas-chat.test.tsx +157 -0
- package/src/hooks/__tests__/use-atlas-conversations.test.tsx +159 -0
- package/src/hooks/__tests__/use-atlas-theme.test.tsx +56 -0
- package/src/hooks/index.ts +47 -0
- package/src/hooks/provider.tsx +77 -0
- package/src/hooks/theme-init-script.ts +17 -0
- package/src/hooks/use-atlas-auth.ts +131 -0
- package/src/hooks/use-atlas-chat.ts +102 -0
- package/src/hooks/use-atlas-conversations.ts +61 -0
- package/src/hooks/use-atlas-theme.ts +34 -0
- package/src/hooks/use-conversations.ts +189 -0
- package/src/hooks/use-dark-mode.ts +150 -0
- package/src/index.ts +36 -0
- package/src/lib/action-types.ts +11 -0
- package/src/lib/helpers.ts +198 -0
- package/src/lib/tool-renderer-types.ts +76 -0
- package/src/lib/types.ts +29 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles.css +59 -0
- package/src/test-setup.ts +55 -0
- package/src/widget-entry.ts +20 -0
- package/src/widget.css +12 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Component, type ReactNode, type ErrorInfo, useMemo, useId, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
ResponsiveContainer,
|
|
6
|
+
BarChart,
|
|
7
|
+
Bar,
|
|
8
|
+
LineChart,
|
|
9
|
+
Line,
|
|
10
|
+
AreaChart,
|
|
11
|
+
Area,
|
|
12
|
+
ScatterChart,
|
|
13
|
+
Scatter,
|
|
14
|
+
ZAxis,
|
|
15
|
+
PieChart,
|
|
16
|
+
Pie,
|
|
17
|
+
Cell,
|
|
18
|
+
CartesianGrid,
|
|
19
|
+
XAxis,
|
|
20
|
+
YAxis,
|
|
21
|
+
Tooltip,
|
|
22
|
+
Legend,
|
|
23
|
+
} from "recharts";
|
|
24
|
+
import {
|
|
25
|
+
detectCharts,
|
|
26
|
+
transformData,
|
|
27
|
+
CHART_COLORS_LIGHT,
|
|
28
|
+
CHART_COLORS_DARK,
|
|
29
|
+
type ChartRecommendation,
|
|
30
|
+
type ChartType,
|
|
31
|
+
type RechartsRow,
|
|
32
|
+
type ChartDetectionResult,
|
|
33
|
+
} from "./chart-detection";
|
|
34
|
+
|
|
35
|
+
/* ------------------------------------------------------------------ */
|
|
36
|
+
/* Error boundary */
|
|
37
|
+
/* ------------------------------------------------------------------ */
|
|
38
|
+
|
|
39
|
+
class ChartErrorBoundary extends Component<
|
|
40
|
+
{ children: ReactNode; fallback?: ReactNode },
|
|
41
|
+
{ hasError: boolean }
|
|
42
|
+
> {
|
|
43
|
+
constructor(props: { children: ReactNode; fallback?: ReactNode }) {
|
|
44
|
+
super(props);
|
|
45
|
+
this.state = { hasError: false };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static getDerivedStateFromError(): { hasError: boolean } {
|
|
49
|
+
return { hasError: true };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
componentDidCatch(error: Error, info: ErrorInfo) {
|
|
53
|
+
console.error("Chart rendering failed:", error, info.componentStack);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
render() {
|
|
57
|
+
if (this.state.hasError) {
|
|
58
|
+
return this.props.fallback ?? (
|
|
59
|
+
<div className="rounded-lg border border-yellow-300 bg-yellow-50 px-3 py-2 text-xs text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-950/20 dark:text-yellow-400">
|
|
60
|
+
Chart could not be rendered. Switch to Table view to see your data.
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return this.props.children;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* ------------------------------------------------------------------ */
|
|
69
|
+
/* Theme helpers */
|
|
70
|
+
/* ------------------------------------------------------------------ */
|
|
71
|
+
|
|
72
|
+
function getColors(dark: boolean) {
|
|
73
|
+
return dark ? CHART_COLORS_DARK : CHART_COLORS_LIGHT;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function themeTokens(dark: boolean) {
|
|
77
|
+
return {
|
|
78
|
+
grid: dark ? "#3f3f46" : "#e4e4e7",
|
|
79
|
+
axis: dark ? "#a1a1aa" : "#71717a",
|
|
80
|
+
tooltipBg: dark ? "#18181b" : "#ffffff",
|
|
81
|
+
tooltipBorder: dark ? "#3f3f46" : "#e4e4e7",
|
|
82
|
+
tooltipText: dark ? "#e4e4e7" : "#27272a",
|
|
83
|
+
legendText: dark ? "#a1a1aa" : "#71717a",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* ------------------------------------------------------------------ */
|
|
88
|
+
/* Number formatter for axis / tooltip */
|
|
89
|
+
/* ------------------------------------------------------------------ */
|
|
90
|
+
|
|
91
|
+
function formatNumber(value: unknown): string {
|
|
92
|
+
const num = Number(value);
|
|
93
|
+
if (!isFinite(num)) return String(value ?? "");
|
|
94
|
+
if (Math.abs(num) >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
|
95
|
+
if (Math.abs(num) >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
|
|
96
|
+
return Number.isInteger(num) ? num.toLocaleString() : num.toFixed(2);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function truncateLabel(label: unknown, maxLen = 12): string {
|
|
100
|
+
const str = String(label ?? "");
|
|
101
|
+
return str.length > maxLen ? str.slice(0, maxLen) + "\u2026" : str;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* ------------------------------------------------------------------ */
|
|
105
|
+
/* Tooltip */
|
|
106
|
+
/* ------------------------------------------------------------------ */
|
|
107
|
+
|
|
108
|
+
const TOOLTIP_LABEL_STYLE = { fontWeight: 600, marginBottom: 4 } as const;
|
|
109
|
+
|
|
110
|
+
const tooltipStyleCache = new Map<boolean, React.CSSProperties>();
|
|
111
|
+
function getTooltipStyle(dark: boolean): React.CSSProperties {
|
|
112
|
+
let style = tooltipStyleCache.get(dark);
|
|
113
|
+
if (!style) {
|
|
114
|
+
const t = themeTokens(dark);
|
|
115
|
+
style = {
|
|
116
|
+
background: t.tooltipBg,
|
|
117
|
+
border: `1px solid ${t.tooltipBorder}`,
|
|
118
|
+
borderRadius: 6,
|
|
119
|
+
padding: "8px 12px",
|
|
120
|
+
fontSize: 12,
|
|
121
|
+
color: t.tooltipText,
|
|
122
|
+
};
|
|
123
|
+
tooltipStyleCache.set(dark, style);
|
|
124
|
+
}
|
|
125
|
+
return style;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function ChartTooltip({ active, payload, label, dark }: {
|
|
129
|
+
active?: boolean;
|
|
130
|
+
payload?: Array<{ name: string; value: number; color: string }>;
|
|
131
|
+
label?: string;
|
|
132
|
+
dark: boolean;
|
|
133
|
+
}) {
|
|
134
|
+
if (!active || !payload?.length) return null;
|
|
135
|
+
return (
|
|
136
|
+
<div style={getTooltipStyle(dark)}>
|
|
137
|
+
{label && <p style={TOOLTIP_LABEL_STYLE}>{label}</p>}
|
|
138
|
+
{payload.map((entry, i) => (
|
|
139
|
+
<p key={i} style={{ color: entry.color }}>
|
|
140
|
+
{entry.name}: {typeof entry.value === "number" ? formatNumber(entry.value) : entry.value}
|
|
141
|
+
</p>
|
|
142
|
+
))}
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* ------------------------------------------------------------------ */
|
|
148
|
+
/* Sub-chart components */
|
|
149
|
+
/* ------------------------------------------------------------------ */
|
|
150
|
+
|
|
151
|
+
function BarChartView({
|
|
152
|
+
data,
|
|
153
|
+
rec,
|
|
154
|
+
dark,
|
|
155
|
+
}: {
|
|
156
|
+
data: RechartsRow[];
|
|
157
|
+
rec: ChartRecommendation;
|
|
158
|
+
dark: boolean;
|
|
159
|
+
}) {
|
|
160
|
+
const colors = getColors(dark);
|
|
161
|
+
const t = themeTokens(dark);
|
|
162
|
+
const catKey = rec.categoryColumn.header;
|
|
163
|
+
const valKeys = rec.valueColumns.map((c) => c.header);
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<div className="aspect-[4/3] sm:aspect-[16/9]">
|
|
167
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
168
|
+
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 40, left: 8 }}>
|
|
169
|
+
<CartesianGrid strokeDasharray="3 3" stroke={t.grid} />
|
|
170
|
+
<XAxis
|
|
171
|
+
dataKey={catKey}
|
|
172
|
+
tick={{ fill: t.axis, fontSize: 11 }}
|
|
173
|
+
tickFormatter={(v: string) => truncateLabel(v)}
|
|
174
|
+
angle={-45}
|
|
175
|
+
textAnchor="end"
|
|
176
|
+
height={60}
|
|
177
|
+
/>
|
|
178
|
+
<YAxis tick={{ fill: t.axis, fontSize: 11 }} tickFormatter={formatNumber} />
|
|
179
|
+
<Tooltip content={<ChartTooltip dark={dark} />} />
|
|
180
|
+
{valKeys.length > 1 && (
|
|
181
|
+
<Legend wrapperStyle={{ fontSize: 12, color: t.legendText }} />
|
|
182
|
+
)}
|
|
183
|
+
{valKeys.map((key, i) => (
|
|
184
|
+
<Bar
|
|
185
|
+
key={key}
|
|
186
|
+
dataKey={key}
|
|
187
|
+
fill={colors[i % colors.length]}
|
|
188
|
+
radius={[4, 4, 0, 0]}
|
|
189
|
+
/>
|
|
190
|
+
))}
|
|
191
|
+
</BarChart>
|
|
192
|
+
</ResponsiveContainer>
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function LineChartView({
|
|
198
|
+
data,
|
|
199
|
+
rec,
|
|
200
|
+
dark,
|
|
201
|
+
}: {
|
|
202
|
+
data: RechartsRow[];
|
|
203
|
+
rec: ChartRecommendation;
|
|
204
|
+
dark: boolean;
|
|
205
|
+
}) {
|
|
206
|
+
const colors = getColors(dark);
|
|
207
|
+
const t = themeTokens(dark);
|
|
208
|
+
const catKey = rec.categoryColumn.header;
|
|
209
|
+
const valKeys = rec.valueColumns.map((c) => c.header);
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<div className="aspect-[4/3] sm:aspect-[16/9]">
|
|
213
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
214
|
+
<LineChart data={data} margin={{ top: 8, right: 8, bottom: 40, left: 8 }}>
|
|
215
|
+
<CartesianGrid strokeDasharray="3 3" stroke={t.grid} />
|
|
216
|
+
<XAxis
|
|
217
|
+
dataKey={catKey}
|
|
218
|
+
tick={{ fill: t.axis, fontSize: 11 }}
|
|
219
|
+
tickFormatter={(v: string) => truncateLabel(v)}
|
|
220
|
+
angle={-45}
|
|
221
|
+
textAnchor="end"
|
|
222
|
+
height={60}
|
|
223
|
+
/>
|
|
224
|
+
<YAxis tick={{ fill: t.axis, fontSize: 11 }} tickFormatter={formatNumber} />
|
|
225
|
+
<Tooltip content={<ChartTooltip dark={dark} />} />
|
|
226
|
+
{valKeys.length > 1 && (
|
|
227
|
+
<Legend wrapperStyle={{ fontSize: 12, color: t.legendText }} />
|
|
228
|
+
)}
|
|
229
|
+
{valKeys.map((key, i) => (
|
|
230
|
+
<Line
|
|
231
|
+
key={key}
|
|
232
|
+
type="monotone"
|
|
233
|
+
dataKey={key}
|
|
234
|
+
stroke={colors[i % colors.length]}
|
|
235
|
+
strokeWidth={2}
|
|
236
|
+
dot={{ r: 3, fill: colors[i % colors.length] }}
|
|
237
|
+
activeDot={{ r: 5 }}
|
|
238
|
+
/>
|
|
239
|
+
))}
|
|
240
|
+
</LineChart>
|
|
241
|
+
</ResponsiveContainer>
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function PieChartView({
|
|
247
|
+
data,
|
|
248
|
+
rec,
|
|
249
|
+
dark,
|
|
250
|
+
}: {
|
|
251
|
+
data: RechartsRow[];
|
|
252
|
+
rec: ChartRecommendation;
|
|
253
|
+
dark: boolean;
|
|
254
|
+
}) {
|
|
255
|
+
const colors = getColors(dark);
|
|
256
|
+
const t = themeTokens(dark);
|
|
257
|
+
const catKey = rec.categoryColumn.header;
|
|
258
|
+
const valKey = rec.valueColumns[0].header;
|
|
259
|
+
|
|
260
|
+
const total = data.reduce((sum, d) => sum + (typeof d[valKey] === "number" ? (d[valKey] as number) : 0), 0);
|
|
261
|
+
|
|
262
|
+
const hasNegative = data.some(d => typeof d[valKey] === "number" && (d[valKey] as number) < 0);
|
|
263
|
+
if (total <= 0 || hasNegative) {
|
|
264
|
+
return (
|
|
265
|
+
<div className="flex aspect-[4/3] items-center justify-center text-xs text-zinc-400 sm:aspect-[16/9]">
|
|
266
|
+
Pie chart is not suitable for this data.
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<div className="aspect-[4/3] sm:aspect-[16/9]">
|
|
273
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
274
|
+
<PieChart>
|
|
275
|
+
<Pie
|
|
276
|
+
data={data}
|
|
277
|
+
dataKey={valKey}
|
|
278
|
+
nameKey={catKey}
|
|
279
|
+
cx="50%"
|
|
280
|
+
cy="50%"
|
|
281
|
+
innerRadius={40}
|
|
282
|
+
outerRadius={100}
|
|
283
|
+
label={({ name, value }: { name?: string; value?: number }) =>
|
|
284
|
+
`${truncateLabel(String(name ?? ""), 10)} ${total > 0 && value != null ? ((value / total) * 100).toFixed(0) : 0}%`
|
|
285
|
+
}
|
|
286
|
+
labelLine={{ stroke: t.axis }}
|
|
287
|
+
fontSize={11}
|
|
288
|
+
>
|
|
289
|
+
{data.map((_, i) => (
|
|
290
|
+
<Cell key={i} fill={colors[i % colors.length]} />
|
|
291
|
+
))}
|
|
292
|
+
</Pie>
|
|
293
|
+
<Tooltip content={<ChartTooltip dark={dark} />} />
|
|
294
|
+
</PieChart>
|
|
295
|
+
</ResponsiveContainer>
|
|
296
|
+
</div>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/* ------------------------------------------------------------------ */
|
|
301
|
+
/* Area chart */
|
|
302
|
+
/* ------------------------------------------------------------------ */
|
|
303
|
+
|
|
304
|
+
function AreaChartView({
|
|
305
|
+
data,
|
|
306
|
+
rec,
|
|
307
|
+
dark,
|
|
308
|
+
}: {
|
|
309
|
+
data: RechartsRow[];
|
|
310
|
+
rec: ChartRecommendation;
|
|
311
|
+
dark: boolean;
|
|
312
|
+
}) {
|
|
313
|
+
const chartId = useId();
|
|
314
|
+
const colors = getColors(dark);
|
|
315
|
+
const t = themeTokens(dark);
|
|
316
|
+
const catKey = rec.categoryColumn.header;
|
|
317
|
+
const valKeys = rec.valueColumns.map((c) => c.header);
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<ResponsiveContainer width="100%" height={300}>
|
|
321
|
+
<AreaChart data={data} margin={{ top: 8, right: 8, bottom: 40, left: 8 }}>
|
|
322
|
+
<defs>
|
|
323
|
+
{valKeys.map((key, i) => (
|
|
324
|
+
<linearGradient key={key} id={`area-grad-${chartId}-${i}`} x1="0" y1="0" x2="0" y2="1">
|
|
325
|
+
<stop offset="5%" stopColor={colors[i % colors.length]} stopOpacity={0.3} />
|
|
326
|
+
<stop offset="95%" stopColor={colors[i % colors.length]} stopOpacity={0.05} />
|
|
327
|
+
</linearGradient>
|
|
328
|
+
))}
|
|
329
|
+
</defs>
|
|
330
|
+
<CartesianGrid strokeDasharray="3 3" stroke={t.grid} />
|
|
331
|
+
<XAxis
|
|
332
|
+
dataKey={catKey}
|
|
333
|
+
tick={{ fill: t.axis, fontSize: 11 }}
|
|
334
|
+
tickFormatter={(v: string) => truncateLabel(v)}
|
|
335
|
+
angle={-45}
|
|
336
|
+
textAnchor="end"
|
|
337
|
+
height={60}
|
|
338
|
+
/>
|
|
339
|
+
<YAxis tick={{ fill: t.axis, fontSize: 11 }} tickFormatter={formatNumber} />
|
|
340
|
+
<Tooltip content={<ChartTooltip dark={dark} />} />
|
|
341
|
+
{valKeys.length > 1 && (
|
|
342
|
+
<Legend wrapperStyle={{ fontSize: 12, color: t.legendText }} />
|
|
343
|
+
)}
|
|
344
|
+
{valKeys.map((key, i) => (
|
|
345
|
+
<Area
|
|
346
|
+
key={key}
|
|
347
|
+
type="monotone"
|
|
348
|
+
dataKey={key}
|
|
349
|
+
stroke={colors[i % colors.length]}
|
|
350
|
+
strokeWidth={2}
|
|
351
|
+
fill={`url(#area-grad-${chartId}-${i})`}
|
|
352
|
+
/>
|
|
353
|
+
))}
|
|
354
|
+
</AreaChart>
|
|
355
|
+
</ResponsiveContainer>
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/* ------------------------------------------------------------------ */
|
|
360
|
+
/* Stacked bar chart */
|
|
361
|
+
/* ------------------------------------------------------------------ */
|
|
362
|
+
|
|
363
|
+
function StackedBarChartView({
|
|
364
|
+
data,
|
|
365
|
+
rec,
|
|
366
|
+
dark,
|
|
367
|
+
}: {
|
|
368
|
+
data: RechartsRow[];
|
|
369
|
+
rec: ChartRecommendation;
|
|
370
|
+
dark: boolean;
|
|
371
|
+
}) {
|
|
372
|
+
const colors = getColors(dark);
|
|
373
|
+
const t = themeTokens(dark);
|
|
374
|
+
const catKey = rec.categoryColumn.header;
|
|
375
|
+
const valKeys = rec.valueColumns.map((c) => c.header);
|
|
376
|
+
|
|
377
|
+
return (
|
|
378
|
+
<ResponsiveContainer width="100%" height={300}>
|
|
379
|
+
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 40, left: 8 }}>
|
|
380
|
+
<CartesianGrid strokeDasharray="3 3" stroke={t.grid} />
|
|
381
|
+
<XAxis
|
|
382
|
+
dataKey={catKey}
|
|
383
|
+
tick={{ fill: t.axis, fontSize: 11 }}
|
|
384
|
+
tickFormatter={(v: string) => truncateLabel(v)}
|
|
385
|
+
angle={-45}
|
|
386
|
+
textAnchor="end"
|
|
387
|
+
height={60}
|
|
388
|
+
/>
|
|
389
|
+
<YAxis tick={{ fill: t.axis, fontSize: 11 }} tickFormatter={formatNumber} />
|
|
390
|
+
<Tooltip content={<ChartTooltip dark={dark} />} />
|
|
391
|
+
<Legend wrapperStyle={{ fontSize: 12, color: t.legendText }} />
|
|
392
|
+
{valKeys.map((key, i) => (
|
|
393
|
+
<Bar
|
|
394
|
+
key={key}
|
|
395
|
+
dataKey={key}
|
|
396
|
+
stackId="a"
|
|
397
|
+
fill={colors[i % colors.length]}
|
|
398
|
+
radius={i === valKeys.length - 1 ? [4, 4, 0, 0] : undefined}
|
|
399
|
+
/>
|
|
400
|
+
))}
|
|
401
|
+
</BarChart>
|
|
402
|
+
</ResponsiveContainer>
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/* ------------------------------------------------------------------ */
|
|
407
|
+
/* Scatter chart */
|
|
408
|
+
/* ------------------------------------------------------------------ */
|
|
409
|
+
|
|
410
|
+
function ScatterChartView({
|
|
411
|
+
data,
|
|
412
|
+
rec,
|
|
413
|
+
dark,
|
|
414
|
+
}: {
|
|
415
|
+
data: RechartsRow[];
|
|
416
|
+
rec: ChartRecommendation;
|
|
417
|
+
dark: boolean;
|
|
418
|
+
}) {
|
|
419
|
+
const colors = getColors(dark);
|
|
420
|
+
const t = themeTokens(dark);
|
|
421
|
+
const xKey = rec.categoryColumn.header;
|
|
422
|
+
const yKey = rec.valueColumns[0].header;
|
|
423
|
+
const zKey = rec.valueColumns.length > 1 ? rec.valueColumns[1].header : undefined;
|
|
424
|
+
|
|
425
|
+
return (
|
|
426
|
+
<ResponsiveContainer width="100%" height={300}>
|
|
427
|
+
<ScatterChart margin={{ top: 8, right: 8, bottom: 40, left: 8 }}>
|
|
428
|
+
<CartesianGrid strokeDasharray="3 3" stroke={t.grid} />
|
|
429
|
+
<XAxis
|
|
430
|
+
dataKey={xKey}
|
|
431
|
+
type="number"
|
|
432
|
+
name={xKey}
|
|
433
|
+
tick={{ fill: t.axis, fontSize: 11 }}
|
|
434
|
+
tickFormatter={formatNumber}
|
|
435
|
+
/>
|
|
436
|
+
<YAxis
|
|
437
|
+
dataKey={yKey}
|
|
438
|
+
type="number"
|
|
439
|
+
name={yKey}
|
|
440
|
+
tick={{ fill: t.axis, fontSize: 11 }}
|
|
441
|
+
tickFormatter={formatNumber}
|
|
442
|
+
/>
|
|
443
|
+
{zKey && <ZAxis dataKey={zKey} type="number" name={zKey} range={[40, 400]} />}
|
|
444
|
+
<Tooltip
|
|
445
|
+
content={<ChartTooltip dark={dark} />}
|
|
446
|
+
cursor={{ strokeDasharray: "3 3" }}
|
|
447
|
+
/>
|
|
448
|
+
<Scatter
|
|
449
|
+
data={data}
|
|
450
|
+
fill={colors[0]}
|
|
451
|
+
/>
|
|
452
|
+
</ScatterChart>
|
|
453
|
+
</ResponsiveContainer>
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/* ------------------------------------------------------------------ */
|
|
458
|
+
/* Chart type selector */
|
|
459
|
+
/* ------------------------------------------------------------------ */
|
|
460
|
+
|
|
461
|
+
const CHART_LABELS: Record<ChartType, string> = {
|
|
462
|
+
bar: "Bar",
|
|
463
|
+
line: "Line",
|
|
464
|
+
pie: "Pie",
|
|
465
|
+
area: "Area",
|
|
466
|
+
"stacked-bar": "Stacked",
|
|
467
|
+
scatter: "Scatter",
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
function ChartTypeSelector({
|
|
471
|
+
recommendations,
|
|
472
|
+
active,
|
|
473
|
+
onChange,
|
|
474
|
+
}: {
|
|
475
|
+
recommendations: ChartRecommendation[];
|
|
476
|
+
active: ChartType;
|
|
477
|
+
onChange: (t: ChartType) => void;
|
|
478
|
+
}) {
|
|
479
|
+
if (recommendations.length <= 1) return null;
|
|
480
|
+
|
|
481
|
+
const seen = new Set<ChartType>();
|
|
482
|
+
const unique = recommendations.filter((r) => {
|
|
483
|
+
if (seen.has(r.type)) return false;
|
|
484
|
+
seen.add(r.type);
|
|
485
|
+
return true;
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
if (unique.length <= 1) return null;
|
|
489
|
+
|
|
490
|
+
return (
|
|
491
|
+
<div className="flex gap-1">
|
|
492
|
+
{unique.map((rec) => (
|
|
493
|
+
<button
|
|
494
|
+
key={rec.type}
|
|
495
|
+
onClick={() => onChange(rec.type)}
|
|
496
|
+
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
|
|
497
|
+
active === rec.type
|
|
498
|
+
? "bg-blue-100 text-blue-700 dark:bg-blue-600/20 dark:text-blue-400"
|
|
499
|
+
: "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
|
|
500
|
+
}`}
|
|
501
|
+
>
|
|
502
|
+
{CHART_LABELS[rec.type]}
|
|
503
|
+
</button>
|
|
504
|
+
))}
|
|
505
|
+
</div>
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/* ------------------------------------------------------------------ */
|
|
510
|
+
/* Chart renderer (inside error boundary) */
|
|
511
|
+
/* ------------------------------------------------------------------ */
|
|
512
|
+
|
|
513
|
+
function ChartRenderer({
|
|
514
|
+
rows,
|
|
515
|
+
rec,
|
|
516
|
+
defaultData,
|
|
517
|
+
defaultRec,
|
|
518
|
+
dark,
|
|
519
|
+
}: {
|
|
520
|
+
rows: string[][];
|
|
521
|
+
rec: ChartRecommendation;
|
|
522
|
+
defaultData: RechartsRow[];
|
|
523
|
+
defaultRec: ChartRecommendation;
|
|
524
|
+
dark: boolean;
|
|
525
|
+
}) {
|
|
526
|
+
// Re-transform data when switching chart type (category axis may differ)
|
|
527
|
+
const chartData = rec === defaultRec ? defaultData : transformData(rows, rec);
|
|
528
|
+
const type = rec.type;
|
|
529
|
+
|
|
530
|
+
return (
|
|
531
|
+
<div className="p-2">
|
|
532
|
+
{type === "bar" ? <BarChartView data={chartData} rec={rec} dark={dark} />
|
|
533
|
+
: type === "line" ? <LineChartView data={chartData} rec={rec} dark={dark} />
|
|
534
|
+
: type === "area" ? <AreaChartView data={chartData} rec={rec} dark={dark} />
|
|
535
|
+
: type === "stacked-bar" ? <StackedBarChartView data={chartData} rec={rec} dark={dark} />
|
|
536
|
+
: type === "scatter" ? <ScatterChartView data={chartData} rec={rec} dark={dark} />
|
|
537
|
+
: <PieChartView data={chartData} rec={rec} dark={dark} />}
|
|
538
|
+
</div>
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/* ------------------------------------------------------------------ */
|
|
543
|
+
/* Main ResultChart component */
|
|
544
|
+
/* ------------------------------------------------------------------ */
|
|
545
|
+
|
|
546
|
+
export function ResultChart({
|
|
547
|
+
headers,
|
|
548
|
+
rows,
|
|
549
|
+
dark,
|
|
550
|
+
detectionResult,
|
|
551
|
+
}: {
|
|
552
|
+
headers: string[];
|
|
553
|
+
rows: string[][];
|
|
554
|
+
dark: boolean;
|
|
555
|
+
detectionResult?: ChartDetectionResult;
|
|
556
|
+
}) {
|
|
557
|
+
const result = useMemo(
|
|
558
|
+
() => detectionResult ?? detectCharts(headers, rows),
|
|
559
|
+
[headers, rows, detectionResult],
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
const [activeType, setActiveType] = useState<ChartType | null>(null);
|
|
563
|
+
|
|
564
|
+
if (!result.chartable) return null;
|
|
565
|
+
|
|
566
|
+
const currentType = activeType ?? result.recommendations[0].type;
|
|
567
|
+
const currentRec = result.recommendations.find((r) => r.type === currentType) ?? result.recommendations[0];
|
|
568
|
+
|
|
569
|
+
return (
|
|
570
|
+
<div className="overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-700">
|
|
571
|
+
<div className="flex items-center justify-between border-b border-zinc-100 bg-zinc-50/50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900/50">
|
|
572
|
+
<span className="text-xs text-zinc-500 dark:text-zinc-400">{currentRec.reason}</span>
|
|
573
|
+
<ChartTypeSelector
|
|
574
|
+
recommendations={result.recommendations}
|
|
575
|
+
active={currentType}
|
|
576
|
+
onChange={setActiveType}
|
|
577
|
+
/>
|
|
578
|
+
</div>
|
|
579
|
+
<ChartErrorBoundary key={currentType}>
|
|
580
|
+
<ChartRenderer
|
|
581
|
+
rows={rows}
|
|
582
|
+
rec={currentRec}
|
|
583
|
+
defaultData={result.data}
|
|
584
|
+
defaultRec={result.recommendations[0]}
|
|
585
|
+
dark={dark}
|
|
586
|
+
/>
|
|
587
|
+
</ChartErrorBoundary>
|
|
588
|
+
</div>
|
|
589
|
+
);
|
|
590
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
|
|
5
|
+
export function ApiKeyBar({
|
|
6
|
+
apiKey,
|
|
7
|
+
onSave,
|
|
8
|
+
}: {
|
|
9
|
+
apiKey: string;
|
|
10
|
+
onSave: (key: string) => void;
|
|
11
|
+
}) {
|
|
12
|
+
const [editing, setEditing] = useState(!apiKey);
|
|
13
|
+
const [draft, setDraft] = useState(apiKey);
|
|
14
|
+
|
|
15
|
+
if (!editing && apiKey) {
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex items-center gap-2 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs dark:border-zinc-700 dark:bg-zinc-900">
|
|
18
|
+
<span className="text-zinc-500 dark:text-zinc-400">API key configured</span>
|
|
19
|
+
<button
|
|
20
|
+
onClick={() => { setDraft(apiKey); setEditing(true); }}
|
|
21
|
+
className="rounded border border-zinc-200 px-2 py-0.5 text-zinc-500 transition-colors hover:border-zinc-400 hover:text-zinc-800 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-zinc-500 dark:hover:text-zinc-200"
|
|
22
|
+
>
|
|
23
|
+
Change
|
|
24
|
+
</button>
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<form
|
|
31
|
+
onSubmit={(e) => {
|
|
32
|
+
e.preventDefault();
|
|
33
|
+
if (draft.trim()) {
|
|
34
|
+
onSave(draft.trim());
|
|
35
|
+
setEditing(false);
|
|
36
|
+
}
|
|
37
|
+
}}
|
|
38
|
+
className="flex items-center gap-2 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-700 dark:bg-zinc-900"
|
|
39
|
+
>
|
|
40
|
+
<input
|
|
41
|
+
type="password"
|
|
42
|
+
value={draft}
|
|
43
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
44
|
+
placeholder="Enter your API key..."
|
|
45
|
+
className="flex-1 bg-transparent text-xs text-zinc-900 placeholder-zinc-400 outline-none dark:text-zinc-100 dark:placeholder-zinc-600"
|
|
46
|
+
autoFocus
|
|
47
|
+
/>
|
|
48
|
+
<button
|
|
49
|
+
type="submit"
|
|
50
|
+
disabled={!draft.trim()}
|
|
51
|
+
className="rounded border border-zinc-200 px-2 py-0.5 text-xs text-zinc-500 transition-colors hover:border-zinc-400 hover:text-zinc-800 disabled:opacity-40 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-zinc-500 dark:hover:text-zinc-200"
|
|
52
|
+
>
|
|
53
|
+
Save
|
|
54
|
+
</button>
|
|
55
|
+
{apiKey && (
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
onClick={() => setEditing(false)}
|
|
59
|
+
className="text-xs text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
|
|
60
|
+
>
|
|
61
|
+
Cancel
|
|
62
|
+
</button>
|
|
63
|
+
)}
|
|
64
|
+
</form>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
|
|
5
|
+
export function CopyButton({ text, label = "Copy" }: { text: string; label?: string }) {
|
|
6
|
+
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
|
|
7
|
+
return (
|
|
8
|
+
<button
|
|
9
|
+
onClick={async () => {
|
|
10
|
+
try {
|
|
11
|
+
await navigator.clipboard.writeText(text);
|
|
12
|
+
setState("copied");
|
|
13
|
+
setTimeout(() => setState("idle"), 2000);
|
|
14
|
+
} catch (err) {
|
|
15
|
+
console.warn("Clipboard write failed:", err);
|
|
16
|
+
setState("failed");
|
|
17
|
+
setTimeout(() => setState("idle"), 2000);
|
|
18
|
+
}
|
|
19
|
+
}}
|
|
20
|
+
className="rounded border border-zinc-200 px-2 py-1 text-xs text-zinc-500 transition-colors hover:border-zinc-400 hover:text-zinc-800 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-zinc-500 dark:hover:text-zinc-200"
|
|
21
|
+
>
|
|
22
|
+
{state === "copied" ? "Copied!" : state === "failed" ? "Failed" : label}
|
|
23
|
+
</button>
|
|
24
|
+
);
|
|
25
|
+
}
|