"use client"; /** * ChartView — simple chart renderer for CHART views (no chart library). * * view.chartConfig: { type: "bar"|"doughnut"|"line", property (numeric * number/rollup/formula), groupBy?, aggregation? }. Series are computed * client-side from the loaded records (aggregation is a trivial group/sum/ * avg/min/max over *display* values — the heavy query engine stays * server-side). * * - bar: flex columns with heights + value labels * - doughnut: SVG circle stroke-dasharray segments + legend * - line: SVG polyline + dots + x labels * Hover tooltips; empty state when there are no numeric values. */ import { useMemo, useState } from "react"; import { BarChart3 } from "lucide-react"; import { cn } from "@/lib/utils"; import { ChartConfig, DatabaseProperty, DatabaseView, RecordDto, UseDatabaseRecordsResult, valueToText } from "@/hooks/use-database"; export interface ChartProps { databaseId: string; view: DatabaseView; properties: DatabaseProperty[]; records: UseDatabaseRecordsResult; } interface Bucket { label: string; value: number; } const CHART_COLORS = [ "var(--factory-color-blue, #337ea9)", "var(--factory-color-green, #448361)", "var(--factory-color-orange, #d9730d)", "var(--factory-color-purple, #9065b0)", "var(--factory-color-pink, #c14c8a)", "var(--factory-color-red, #d44c47)", "var(--factory-color-yellow, #cb912f)", "var(--factory-color-brown, #9f6b53)" ]; function numericValue(r: RecordDto, prop: DatabaseProperty): number | null { const v = r.properties[prop.id]; if (!v) return null; // rollup / formula carry .value; plain number carries .value too. const raw = v.value; if (typeof raw === "number" && Number.isFinite(raw)) return raw; if (typeof raw === "string" && raw.trim() !== "" && !Number.isNaN(Number(raw))) return Number(raw); if (typeof v.text === "string") { const n = Number(v.text); if (Number.isFinite(n)) return n; } return null; } function aggregate(values: number[], agg?: string): number { if (values.length === 0) return 0; switch (agg) { case "SUM": return values.reduce((a, b) => a + b, 0); case "AVERAGE": return values.reduce((a, b) => a + b, 0) / values.length; case "MIN": return Math.min(...values); case "MAX": return Math.max(...values); case "COUNT": default: return values.length; } } function computeBuckets( records: RecordDto[], numProp: DatabaseProperty, groupProp: DatabaseProperty | undefined, aggregation?: string ): Bucket[] { if (groupProp) { const map = new Map(); for (const r of records) { const label = valueToText(r.properties[groupProp.id], groupProp) || "—"; const n = numericValue(r, numProp); const arr = map.get(label) ?? []; if (n != null) arr.push(n); map.set(label, arr); } return Array.from(map.entries()) .map(([label, values]) => ({ label, value: aggregate(values, aggregation) })) .sort((a, b) => b.value - a.value); } const all: number[] = []; for (const r of records) { const n = numericValue(r, numProp); if (n != null) all.push(n); } return [{ label: numProp.name, value: aggregate(all, aggregation) }]; } function fmt(v: number): string { return Number.isInteger(v) ? String(v) : v.toFixed(2); } // --------------------------------------------------------------------------- // Bars // --------------------------------------------------------------------------- function BarChart({ buckets }: { buckets: Bucket[] }) { const [hover, setHover] = useState(null); const max = Math.max(...buckets.map((b) => b.value), 1); return (
{buckets.map((b, i) => (
setHover(i)} onMouseLeave={() => setHover(null)} >
{hover === i && ( {b.label}: {fmt(b.value)} )}
{b.label}
))}
); } // --------------------------------------------------------------------------- // Doughnut (SVG stroke-dasharray) // --------------------------------------------------------------------------- function DoughnutChart({ buckets }: { buckets: Bucket[] }) { const [hover, setHover] = useState(null); const total = buckets.reduce((a, b) => a + b.value, 0) || 1; const R = 60; const C = 2 * Math.PI * R; let offset = 0; return (
{buckets.map((b, i) => { const frac = b.value / total; const len = frac * C; const seg = ( setHover(i)} onMouseLeave={() => setHover(null)} className="cursor-pointer transition-all" /> ); offset += len; return seg; })} {hover != null ? fmt(buckets[hover].value) : fmt(total)} {hover != null ? buckets[hover].label : "total"}
    {buckets.map((b, i) => (
  • setHover(i)} onMouseLeave={() => setHover(null)} > {b.label} {fmt(b.value)} · {Math.round((b.value / total) * 100)}%
  • ))}
); } // --------------------------------------------------------------------------- // Line (SVG polyline) // --------------------------------------------------------------------------- function LineChart({ buckets }: { buckets: Bucket[] }) { const [hover, setHover] = useState(null); const W = 480; const H = 200; const pad = 24; const max = Math.max(...buckets.map((b) => b.value), 1); const min = Math.min(...buckets.map((b) => b.value), 0); const range = max - min || 1; const x = (i: number) => pad + (buckets.length <= 1 ? 0 : (i / (buckets.length - 1)) * (W - pad * 2)); const y = (v: number) => H - pad - ((v - min) / range) * (H - pad * 2); const points = buckets.map((b, i) => `${x(i)},${y(b.value)}`).join(" "); return (
{/* gridlines */} {[0.25, 0.5, 0.75, 1].map((f) => ( ))} {buckets.map((b, i) => ( setHover(i)} onMouseLeave={() => setHover(null)} className="cursor-pointer"> {hover === i && ( {fmt(b.value)} )} {b.label.length > 8 ? `${b.label.slice(0, 8)}…` : b.label} ))}
); } // --------------------------------------------------------------------------- export function ChartView({ view, properties, records }: ChartProps) { const { data, isLoading } = records; const cfg: ChartConfig | undefined = view.config.chartConfig; const numProp = cfg?.property ? properties.find((p) => p.id === cfg.property) : undefined; const groupProp = cfg?.groupBy ? properties.find((p) => p.id === cfg.groupBy) : undefined; const buckets = useMemo(() => { if (!numProp) return null; const b = computeBuckets(data.records, numProp, groupProp, cfg?.aggregation); return b.length ? b : null; }, [data.records, numProp, groupProp, cfg]); if (isLoading) { return (
); } if (!cfg || !numProp || !buckets) { return (

{!cfg ? "Configure this chart view (metric, grouping, type) to see data." : !numProp ? "This chart's metric property no longer exists." : "No numeric values to chart in the loaded records."}

); } const type = cfg.type ?? "bar"; return (
{type === "doughnut" ? ( ) : type === "line" ? ( ) : ( )}
); }