1"use client";
2
3
17import { useMemo, useState } from "react";
18import { BarChart3 } from "lucide-react";
19import { cn } from "@/lib/utils";
20import {
21 ChartConfig,
22 DatabaseProperty,
23 DatabaseView,
24 RecordDto,
25 UseDatabaseRecordsResult,
26 valueToText
27} from "@/hooks/use-database";
28
29export interface ChartProps {
30 databaseId: string;
31 view: DatabaseView;
32 properties: DatabaseProperty[];
33 records: UseDatabaseRecordsResult;
34}
35
36interface Bucket {
37 label: string;
38 value: number;
39}
40
41const CHART_COLORS = [
42 "var(--factory-color-blue, #337ea9)",
43 "var(--factory-color-green, #448361)",
44 "var(--factory-color-orange, #d9730d)",
45 "var(--factory-color-purple, #9065b0)",
46 "var(--factory-color-pink, #c14c8a)",
47 "var(--factory-color-red, #d44c47)",
48 "var(--factory-color-yellow, #cb912f)",
49 "var(--factory-color-brown, #9f6b53)"
50];
51
52function numericValue(r: RecordDto, prop: DatabaseProperty): number | null {
53 const v = r.properties[prop.id];
54 if (!v) return null;
55
56 const raw = v.value;
57 if (typeof raw === "number" && Number.isFinite(raw)) return raw;
58 if (typeof raw === "string" && raw.trim() !== "" && !Number.isNaN(Number(raw))) return Number(raw);
59 if (typeof v.text === "string") {
60 const n = Number(v.text);
61 if (Number.isFinite(n)) return n;
62 }
63 return null;
64}
65
66function aggregate(values: number[], agg?: string): number {
67 if (values.length === 0) return 0;
68 switch (agg) {
69 case "SUM":
70 return values.reduce((a, b) => a + b, 0);
71 case "AVERAGE":
72 return values.reduce((a, b) => a + b, 0) / values.length;
73 case "MIN":
74 return Math.min(...values);
75 case "MAX":
76 return Math.max(...values);
77 case "COUNT":
78 default:
79 return values.length;
80 }
81}
82
83function computeBuckets(
84 records: RecordDto[],
85 numProp: DatabaseProperty,
86 groupProp: DatabaseProperty | undefined,
87 aggregation?: string
88): Bucket[] {
89 if (groupProp) {
90 const map = new Map<string, number[]>();
91 for (const r of records) {
92 const label = valueToText(r.properties[groupProp.id], groupProp) || "—";
93 const n = numericValue(r, numProp);
94 const arr = map.get(label) ?? [];
95 if (n != null) arr.push(n);
96 map.set(label, arr);
97 }
98 return Array.from(map.entries())
99 .map(([label, values]) => ({ label, value: aggregate(values, aggregation) }))
100 .sort((a, b) => b.value - a.value);
101 }
102 const all: number[] = [];
103 for (const r of records) {
104 const n = numericValue(r, numProp);
105 if (n != null) all.push(n);
106 }
107 return [{ label: numProp.name, value: aggregate(all, aggregation) }];
108}
109
110function fmt(v: number): string {
111 return Number.isInteger(v) ? String(v) : v.toFixed(2);
112}
113
114
115
116
117
118function BarChart({ buckets }: { buckets: Bucket[] }) {
119 const [hover, setHover] = useState<number | null>(null);
120 const max = Math.max(...buckets.map((b) => b.value), 1);
121 return (
122 <div className="flex h-full w-full items-end gap-2 p-4" role="img" aria-label="Bar chart">
123 {buckets.map((b, i) => (
124 <div
125 key={b.label}
126 className="group relative flex h-full min-w-0 flex-1 flex-col items-center justify-end"
127 onMouseEnter={() => setHover(i)}
128 onMouseLeave={() => setHover(null)}
129 >
130 <div className="relative flex w-full flex-1 items-end justify-center">
131 {hover === i && (
132 <span className="absolute -top-1 z-10 -translate-y-full whitespace-nowrap rounded bg-foreground px-1.5 py-0.5 text-[11px] text-background">
133 {b.label}: {fmt(b.value)}
134 </span>
135 )}
136 <div
137 className="w-full max-w-16 rounded-t bg-primary/70 transition-colors group-hover:bg-primary"
138 style={{ height: `${Math.max(2, (b.value / max) * 100)}%` }}
139 />
140 </div>
141 <span className="mt-1 w-full truncate text-center text-[10px] text-muted-foreground" title={b.label}>
142 {b.label}
143 </span>
144 </div>
145 ))}
146 </div>
147 );
148}
149
150
151
152
153
154function DoughnutChart({ buckets }: { buckets: Bucket[] }) {
155 const [hover, setHover] = useState<number | null>(null);
156 const total = buckets.reduce((a, b) => a + b.value, 0) || 1;
157 const R = 60;
158 const C = 2 * Math.PI * R;
159 let offset = 0;
160
161 return (
162 <div className="flex h-full w-full items-center justify-center gap-6 p-4">
163 <svg viewBox="0 0 160 160" className="size-48 -rotate-90" role="img" aria-label="Doughnut chart">
164 {buckets.map((b, i) => {
165 const frac = b.value / total;
166 const len = frac * C;
167 const seg = (
168 <circle
169 key={i}
170 cx="80"
171 cy="80"
172 r={R}
173 fill="none"
174 stroke={CHART_COLORS[i % CHART_COLORS.length]}
175 strokeWidth={hover === i ? 30 : 24}
176 strokeDasharray={`${len} ${C - len}`}
177 strokeDashoffset={-offset}
178 onMouseEnter={() => setHover(i)}
179 onMouseLeave={() => setHover(null)}
180 className="cursor-pointer transition-all"
181 />
182 );
183 offset += len;
184 return seg;
185 })}
186 <circle cx="80" cy="80" r="34" fill="var(--card, transparent)" />
187 <text x="80" y="76" textAnchor="middle" className="fill-foreground text-[13px] font-semibold">
188 {hover != null ? fmt(buckets[hover].value) : fmt(total)}
189 </text>
190 <text x="80" y="92" textAnchor="middle" className="fill-muted-foreground text-[9px]">
191 {hover != null ? buckets[hover].label : "total"}
192 </text>
193 </svg>
194 <ul className="space-y-1 text-xs">
195 {buckets.map((b, i) => (
196 <li
197 key={b.label}
198 className={cn("flex items-center gap-1.5", hover === i && "font-medium")}
199 onMouseEnter={() => setHover(i)}
200 onMouseLeave={() => setHover(null)}
201 >
202 <span className="size-2.5 rounded-full" style={{ backgroundColor: CHART_COLORS[i % CHART_COLORS.length] }} aria-hidden />
203 <span className="max-w-32 truncate">{b.label}</span>
204 <span className="text-muted-foreground">
205 {fmt(b.value)} · {Math.round((b.value / total) * 100)}%
206 </span>
207 </li>
208 ))}
209 </ul>
210 </div>
211 );
212}
213
214
215
216
217
218function LineChart({ buckets }: { buckets: Bucket[] }) {
219 const [hover, setHover] = useState<number | null>(null);
220 const W = 480;
221 const H = 200;
222 const pad = 24;
223 const max = Math.max(...buckets.map((b) => b.value), 1);
224 const min = Math.min(...buckets.map((b) => b.value), 0);
225 const range = max - min || 1;
226 const x = (i: number) => pad + (buckets.length <= 1 ? 0 : (i / (buckets.length - 1)) * (W - pad * 2));
227 const y = (v: number) => H - pad - ((v - min) / range) * (H - pad * 2);
228 const points = buckets.map((b, i) => `${x(i)},${y(b.value)}`).join(" ");
229
230 return (
231 <div className="flex h-full w-full items-center justify-center p-2">
232 <svg viewBox={`0 0 ${W} ${H}`} className="w-full max-w-2xl" role="img" aria-label="Line chart">
233 {}
234 {[0.25, 0.5, 0.75, 1].map((f) => (
235 <line key={f} x1={pad} x2={W - pad} y1={H - pad - f * (H - pad * 2)} y2={H - pad - f * (H - pad * 2)} stroke="oklch(var(--border))" strokeDasharray="3 3" />
236 ))}
237 <polyline points={points} fill="none" stroke="var(--primary, #337ea9)" strokeWidth={2} />
238 {buckets.map((b, i) => (
239 <g key={b.label} onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)} className="cursor-pointer">
240 <circle cx={x(i)} cy={y(b.value)} r={hover === i ? 5 : 3.5} fill="var(--primary, #337ea9)" />
241 {hover === i && (
242 <text x={x(i)} y={y(b.value) - 10} textAnchor="middle" className="fill-foreground text-[10px]">
243 {fmt(b.value)}
244 </text>
245 )}
246 <text x={x(i)} y={H - pad + 14} textAnchor="middle" className="fill-muted-foreground text-[9px]">
247 {b.label.length > 8 ? `${b.label.slice(0, 8)}…` : b.label}
248 </text>
249 </g>
250 ))}
251 </svg>
252 </div>
253 );
254}
255
256
257
258export function ChartView({ view, properties, records }: ChartProps) {
259 const { data, isLoading } = records;
260 const cfg: ChartConfig | undefined = view.config.chartConfig;
261
262 const numProp = cfg?.property ? properties.find((p) => p.id === cfg.property) : undefined;
263 const groupProp = cfg?.groupBy ? properties.find((p) => p.id === cfg.groupBy) : undefined;
264
265 const buckets = useMemo(() => {
266 if (!numProp) return null;
267 const b = computeBuckets(data.records, numProp, groupProp, cfg?.aggregation);
268 return b.length ? b : null;
269 }, [data.records, numProp, groupProp, cfg]);
270
271 if (isLoading) {
272 return (
273 <div className="flex h-full items-center justify-center">
274 <div className="h-40 w-full max-w-lg animate-pulse rounded-lg bg-muted" />
275 </div>
276 );
277 }
278
279 if (!cfg || !numProp || !buckets) {
280 return (
281 <div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
282 <BarChart3 className="size-8 text-muted-foreground" aria-hidden />
283 <p className="text-sm text-muted-foreground">
284 {!cfg
285 ? "Configure this chart view (metric, grouping, type) to see data."
286 : !numProp
287 ? "This chart's metric property no longer exists."
288 : "No numeric values to chart in the loaded records."}
289 </p>
290 </div>
291 );
292 }
293
294 const type = cfg.type ?? "bar";
295 return (
296 <div className="h-full w-full">
297 {type === "doughnut" ? (
298 <DoughnutChart buckets={buckets} />
299 ) : type === "line" ? (
300 <LineChart buckets={buckets} />
301 ) : (
302 <BarChart buckets={buckets} />
303 )}
304 </div>
305 );
306}
307