/** * IDEA's result chart: one numeric column (Y) against a category column (X) * as bars or a line, with the Y values of equal X combined (sum, average…). */ import { useLayoutEffect, useMemo, useRef, useState } from "react"; import { IconButton, Select, T } from "@/ui"; import { isNumericColumn, type GridColumn } from "./Grid"; import type { Cell } from "./model"; import { chartSeries, formatNumber, niceTicks, type ChartAggregate } from "./resultView"; import styles from "./Database.module.css"; const AGGREGATES: ReadonlyArray<{ value: ChartAggregate; label: string }> = [ { value: "none", label: "No aggregation" }, { value: "sum", label: "Sum" }, { value: "avg", label: "Mean" }, { value: "count", label: "Count" }, { value: "min", label: "Min" }, { value: "max", label: "Max" }, ]; const PAD = { left: 64, right: 16, top: 12, bottom: 44 }; const grouped = new Intl.NumberFormat("en-US", { maximumFractionDigits: 6 }); export function ChartView({ columns, rows }: { columns: readonly GridColumn[]; rows: ReadonlyArray }) { const numeric = useMemo( () => columns.map((c, i) => isNumericColumn( c.type, rows.slice(0, 50).map((r) => r[i] ?? null), ), ), [columns, rows], ); const firstText = numeric.findIndex((n) => !n); const [x, setX] = useState(Math.max(0, firstText)); const [y, setY] = useState(() => { const n = numeric.findLastIndex((isNum, i) => isNum && i !== Math.max(0, firstText)); return n >= 0 ? n : Math.min(1, columns.length - 1); }); const [kind, setKind] = useState<"bar" | "line">("bar"); // IDEA combines equal X values with their mean unless told otherwise. const [aggregate, setAgg] = useState("avg"); const [settings, setSettings] = useState(false); const points = useMemo(() => chartSeries(rows, x, y, aggregate), [rows, x, y, aggregate]); const host = useRef(null); const [size, setSize] = useState({ w: 600, h: 300 }); useLayoutEffect(() => { const el = host.current; if (!el) return; const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight })); ro.observe(el); setSize({ w: el.clientWidth, h: el.clientHeight }); return () => ro.disconnect(); }, []); const options = columns.map((c, i) => ({ value: String(i), label: c.name })); const values = points.map((p) => p.value); const ticks = niceTicks( Math.min(0, ...values), Math.max(0, ...values), Math.max(2, Math.floor((size.h - PAD.top - PAD.bottom) / 32)), ); const lo = ticks[0] ?? 0; const hi = ticks.at(-1) ?? 1; const plotW = Math.max(1, size.w - PAD.left - PAD.right); const plotH = Math.max(1, size.h - PAD.top - PAD.bottom); const yAt = (v: number) => PAD.top + plotH - ((v - lo) / (hi - lo || 1)) * plotH; const band = plotW / Math.max(1, points.length); const xAt = (i: number) => PAD.left + band * i + band / 2; // Every n-th label so they do not overlap. const labelEvery = Math.max(1, Math.ceil(points.length / Math.max(1, Math.floor(plotW / 72)))); const yName = `${columns[y]?.name ?? ""}${aggregate === "none" ? "" : ` (${AGGREGATES.find((a) => a.value === aggregate)!.label.toLowerCase()})`}`; return (
{settings && (
X: setY(Number(v))} /> setKind(v as "bar" | "line")} />
)}
setSettings(!settings)} /> {!points.length ? (
No numeric values to plot
) : ( {ticks.map((t) => ( {grouped.format(t)} ))} {points.map((_, i) => ( ))} {kind === "bar" ? points.map((p, i) => ( {`${p.label}: ${formatNumber(p.value)}`} )) : [ `${xAt(i)},${yAt(p.value)}`).join(" ")} />, ...points.map((p, i) => ( {`${p.label}: ${formatNumber(p.value)}`} )), ]} {points.map((p, i) => i % labelEvery ? null : ( {p.label.length > 14 ? `${p.label.slice(0, 13)}…` : p.label} ), )} {columns[x]?.name} {yName} )}
); }