// --------------------------------------------------------------------------- // viz / chartData.ts (was customer-grid/chartData.ts — EXIT wave 2, W2-5/Y3: // a MOVE, not a rewrite. The arithmetic below is byte-identical to what shipped // in the grid; only the three import lines changed, because the engine now // serves TWO callers — the customer grid's Dashboard mode and the Y1 page // envelope the Sales page renders.) // // Wave-8 I19c (contract C2) — the Dashboard mode's ARITHMETIC, pure and // React-free so verify_charts.py can run it under node. // // The contract's key sentence: charts "compute CLIENT-SIDE from the SAME // pipeline rows the grid paints (filters + cohort scope apply by construction — // that is the point)". So there is no fetching here and no second definition of // what a row is: this module takes the rows the grid already has and buckets // them. If the grid says 412 rows, every chart on the dashboard is describing // those 412 rows. // // Honesty rules (rule 8b) live in the RETURN VALUE, not in the renderer: // - `omitted` counts buckets past the display cap, so the card can say "+N // more" instead of quietly drawing the top 12; // - `blanks` counts rows whose x value is empty — they become a real // "(blank)" bucket rather than vanishing from a total; // - `missingY` counts rows the aggregate could not use, so an average can // never silently be an average of a different denominator than the count // beside it. // --------------------------------------------------------------------------- import type { Field, Row } from "./types"; // ⚠ from `display`, NOT `cells`: cells.ts also exports the glide rating renderer, and // importing it would drag the whole canvas library into a gate that runs under bare node. // // ⚠ These two still come FROM customer-grid, deliberately (Y3 amendment, // 2026-07-30). `formatDisplay` is the ONE formatter charts and cells share — // C2 §4, restated at `chartValueText` below: a revenue figure must not read one // way in a row and another in the chart hovering over it, so a second copy here // would be the bug, not the fix. The two family predicates are likewise a single // definition; `viz/types.ts` pins the vocabulary in lock-step so a drift is a // red build rather than a silently mis-typed axis. import { formatDisplay } from "../customer-grid/display"; import { TS_BUCKETS, TS_MAX_LAST_N, isDateFamilyType, isGroupableField, isNumericFieldType, } from "../customer-grid/types"; export const CHART_KINDS = ["bar", "line", "area", "donut", "kpi", "table"] as const; export type ChartKind = (typeof CHART_KINDS)[number]; export const CHART_AGGS = ["sum", "avg", "count", "min", "max"] as const; export type ChartAgg = (typeof CHART_AGGS)[number]; /** Wave-16 C-CHARTCAP — plain words for the aggregates, ONE copy. The card's auto-title and * the table kind's column headers both read this, so "Sum of Revenue" cannot drift into * "Total Revenue" between a chart and the table beside it. (Was DashboardView-local.) */ export const CHART_AGG_LABELS: Record = { sum: "Sum of", avg: "Average of", count: "Count of records", min: "Smallest", max: "Largest", }; /** C2's per-chart spec. `id` is a client uuid; `y` omitted means count of rows. */ /** * Wave-9 I11 (contract C2) — chart customisation. The CLIENT mirror of * `aios_grid.CHART_PALETTES` / `CHART_FORMATS`. * * A palette names a colour JOB, never a colour. The browser must not be able to post a raw * hex: these four resolve to brand ramps client-side, so a tenant restyle cannot be defeated * by a literal somebody stored last year. STATUS colours are deliberately absent — they are * reserved signal, and reusing them as "series 4" is how a chart starts lying. */ export const CHART_PALETTES = ["brand", "categorical", "sequential", "diverging"] as const; export type ChartPalette = (typeof CHART_PALETTES)[number]; export const CHART_FORMATS = ["auto", "number", "currency", "percent", "compact"] as const; export type ChartFormat = (typeof CHART_FORMATS)[number]; export const MAX_AXIS_LABEL = 40; /** The I10 drag. Width in GRID COLUMNS on a 12-column board, height in px. */ export const CHART_W_RANGE: readonly [number, number] = [1, 12]; export const CHART_H_RANGE: readonly [number, number] = [120, 800]; export interface ChartAxisSide { label?: string; format?: ChartFormat; } export interface ChartSpec { id: string; kind: ChartKind; x?: string; y?: string; agg: ChartAgg; title?: string; /** I11 — the field whose values become the SERIES. ⚠ Named `splitBy`, NOT `colorBy`: * that name already means row colouring (`config.colorBy`) and map pin colour * (`display.colorField`), and a third sense would be unreadable. HOST's ruling. */ splitBy?: string; /** I11 — only meaningful WITH `splitBy` and only for bar/area. Dropped anywhere else * rather than stored as a lie the renderer would have to re-decide. */ stacked?: boolean; palette?: ChartPalette; /** ⛔ ONE y-scale, always. There is no second-axis key and there must never be: two * y-scales on one frame can manufacture any correlation you like by rescaling. Ruled in * C2 against the "Tableau versatility" brief — two charts, small multiples, or index to * a common base. */ axis?: { x?: ChartAxisSide; y?: ChartAxisSide }; /** I10 — the drag. `{w}` in grid columns, `{h}` in px. Clamped at both ends. */ size?: { w?: number; h?: number }; /** * Wave-14 R3 (item 18) — this card is a TREND over time buckets, not a category chart. * * Present ⇒ the series comes from the time-series channel (one `date_trunc` query per * metric, the metric's own window slid across bucket ends) instead of from `chartData`'s * grouping of the view's rows. Absent ⇒ every path below behaves byte-identically to * before, which is the property that makes this safe to add to a shipped renderer. * * Typed as a plain `string` for the same reason `agg` is: the vocabulary is `TS_BUCKETS` in * `customer-grid/types.ts`, and that module is a deliberate LEAF — importing it here to * nominally type one key would invert the dependency the whole viz layer is arranged * around. The host validates it against the real vocabulary (C-ACC) and keeps it only when * `y` is measure-backed. */ bucket?: string; /** R3 — how many buckets back. Meaningful only beside `bucket`; 1..120, host-clamped. */ span?: { lastN?: number }; /** * Wave-16 C-CHARTCAP (owner R3) — the YoY companion. On a PERIOD chart it draws a second * series: the same metric read `TS_YOY_BACK[bucket]` buckets earlier, aligned to the same * x (`timeSeriesData.buildTsCompare`'s law). On a `kpi` card it puts a delta line under the * number: the pooled metric at the latest month-end against the same month-end a year * earlier (`salesParity.kpiYoyFromSeries`). * * Kept ONLY where it can mean something — beside a `bucket`, or on a sum-of-metric KPI — * mirroring `aios_grid._clean_chart` exactly. The single value is deliberate: "prior_year" * is the one comparison pages_sales drew, and a second vocabulary entry should arrive with * its own law, not ride this key. */ compare?: "prior_year"; } /** C2: at most 12 charts per view. */ export const MAX_CHARTS = 12; /** Buckets drawn per chart before the card states what it left out. */ export const MAX_BUCKETS = 12; /** * Wave-14 R3 — the chart kinds a PERIOD may be drawn over. * * A donut of months is a part-of-whole claim about time that nobody makes, and a * single-number card has no axis to put periods on. Narrowing is a design call, so it is * stated here rather than left implicit in a JSX condition. */ export const PERIOD_KINDS: readonly ChartKind[] = ["bar", "line", "area"]; /** A metric field — the only kind with a time dimension of its own (C-TS v1 eligibility). */ export function isMetricKey(key: string | undefined): boolean { return !!key && key.startsWith("measure_"); } /** * Does this spec ask for a TREND over time buckets? * * ⚠ THIS PREDICATE DECIDES WHICH CAP APPLIES, which is why it lives here beside the cap and * not in the component. `true` routes the card's series through the time-series channel, where * `MAX_BUCKETS` must NEVER be applied: dropping the 13th of 24 months leaves an axis that * still reads like a complete run while two years of history quietly become one. `false` is * the category path, unchanged, where the same cap is correct and the card discloses it. * * The bucket vocabulary is checked by the caller and by the host (C-ACC keeps `bucket` only * when `y` is measure-backed); what is checked HERE is everything this module can see, so a * spec that is half-a-trend — a bucket on a donut, a bucket on a plain number column — takes * the category path rather than a broken one. */ export function isPeriodChart(spec: ChartSpec): boolean { return !!spec.bucket && isMetricKey(spec.y) && PERIOD_KINDS.includes(spec.kind); } export interface Bucket { key: string; label: string; value: number; /** Rows in this bucket — the denominator behind `value`, always available so * a card can show "n = ..." rather than an unexplained number. */ n: number; } export interface ChartData { buckets: Bucket[]; /** Buckets not drawn because of MAX_BUCKETS. Never silently dropped. */ omitted: number; /** Value of the omitted buckets, so "+N more" can carry its weight. */ omittedValue: number; /** Rows whose x value was empty (they form the "(blank)" bucket). */ blanks: number; /** Rows the aggregate could not use (non-numeric/empty y under sum/avg/...). */ missingY: number; /** Total rows considered — always the grid's row count for this view. */ rows: number; /** Aggregate buckets across the FULL domain, before the display cap. */ negativeBuckets?: number; negativeValue?: number; zeroBuckets?: number; /** Populated when the spec cannot be drawn; the card shows this verbatim. */ problem?: string; /** * wave17 GRID — item 3 / owner R6. **Waiting is not a problem.** * * The period-series chart used to report its wait by writing "Loading the periods…" INTO * `problem`, which meant a card that was merely early and a card that is refusing were the * same state to every reader. R6 asks for a spinner and no words, and you cannot render a * spinner from a string field — so the two states are now distinct, and the card checks this * FIRST. A `problem` set beside it still says what went wrong once the wait ends. */ pending?: boolean; } /** * The renderer-neutral data model consumed by Chart View. * * This is intentionally not a Vega-Lite type. Saved views persist `ChartSpec`, * the arithmetic produces this small table, and the current renderer translates * it at the final boundary. Replacing Vega later therefore changes one adapter * rather than every saved view and every aggregation rule. */ export interface ChartPoint extends Bucket { seriesKey: string; seriesLabel: string; } export interface ChartModel extends Omit { points: ChartPoint[]; xOrder: string[]; seriesOrder: string[]; omittedSeries: number; omittedSeriesValue: number; /** Donut-only: zero/negative groups cannot be represented as angular share. */ nonPositive: number; nonPositiveValue: number; } /** A legend with dozens of entries is a data dump, not a chart. */ export const MAX_CHART_SERIES = 12; /** A date value bucketed to its month, the only date bucket wave 8 offers. */ function monthOf(v: unknown): string | null { const s = String(v ?? ""); return /^\d{4}-\d{2}/.test(s) ? s.slice(0, 7) : null; } function monthLabel(ym: string): string { const y = Number(ym.slice(0, 4)); const m = Number(ym.slice(5, 7)); if (!Number.isFinite(y) || !Number.isFinite(m)) return ym; return new Date(Date.UTC(y, m - 1, 1)).toLocaleDateString(undefined, { month: "short", year: "numeric", timeZone: "UTC", }); } function numOf(v: unknown): number | null { if (v == null || v === "") return null; const n = typeof v === "number" ? v : Number(v); return Number.isFinite(n) ? n : null; } /** Reduce a bucket's collected values to the aggregate. */ function reduce(agg: ChartAgg, vals: number[], n: number): number { if (agg === "count") return n; if (vals.length === 0) return 0; switch (agg) { case "sum": return vals.reduce((a, b) => a + b, 0); case "avg": return vals.reduce((a, b) => a + b, 0) / vals.length; case "min": return Math.min(...vals); case "max": return Math.max(...vals); } } /** The numeric coercion the aggregates use — exported for item 4's calendar summaries so the * two surfaces agree on what counts as a number. */ export { numOf as asChartNumber }; /** * Owner item 4 (C-DISP) — the aggregate, for callers OUTSIDE a chart card. * * ⚠ Deliberately a different return type from the private `reduce` above, and the difference * is honesty, not style. `reduce` answers `0` when a bucket collected no usable values, * because a bar chart needs a height and the card states its `missingY` separately. A calendar * day cell has no such companion disclosure: a lone "0" in a Wednesday IS the whole claim, and * "the average of nothing is zero" is false. So this returns **null** for an empty * sum/avg/min/max bucket, which the caller renders as "—". * * `count` is exempt — the count of no rows really is 0, and that is a fact rather than a * substitute for one. * * `reduce` itself is untouched: `verify_charts.py` holds 12 negative controls against its * behaviour, and changing the shared function to suit a second caller is how a gated rule * quietly stops being the rule. */ export function aggregateOrNull(agg: ChartAgg, vals: number[], n: number): number | null { if (agg === "count") return n; if (vals.length === 0) return null; return reduce(agg, vals, n); } /** * Build one chart's data from the view's rows. * * `fieldByKey` resolves the spec's refs; a ref naming a field that no longer * exists yields a `problem` rather than an empty chart, because a blank card is * indistinguishable from "no data matched" and sends people looking in the wrong * place. */ export interface ChartDataOpts { /** * Raise (or effectively remove) the display cap. * * ⚠ Wave-15 C-CHARTCAP. `MAX_BUCKETS` is a CHART's cap — twelve bars is where an axis stops * being readable — and the card discloses what it dropped. A group-by TABLE has no axis to * run out of: it is a list, and its cap is its own row limit, which reports the total it * truncated from. Passing the chart's cap to a table would silently keep 12 salespeople out * of 40 and then let the table's own "showing 25 of 25" agree with it. * Absent = `MAX_BUCKETS`, i.e. every existing caller is byte-identical. */ maxBuckets?: number; } export function chartData( spec: ChartSpec, rows: Row[], fieldByKey: Map, opts?: ChartDataOpts ): ChartData { const base: ChartData = { buckets: [], omitted: 0, omittedValue: 0, blanks: 0, missingY: 0, rows: rows.length, }; const yField = spec.y ? fieldByKey.get(spec.y) : undefined; if (spec.y && !yField) return { ...base, problem: "The field this chart measured no longer exists." }; if (yField && spec.agg !== "count" && !isNumericFieldType(yField.type)) return { ...base, problem: `${yField.label} is not a number, so it cannot be ${spec.agg === "avg" ? "averaged" : spec.agg + "med"}.`, }; if (spec.agg !== "count" && !yField) return { ...base, problem: "Choose a number field to measure, or switch to Count." }; // --- KPI: one number over every row, no bucketing. if (spec.kind === "kpi") { const vals: number[] = []; let missingY = 0; for (const r of rows) { if (!yField) continue; const n = numOf(r[yField.key]); if (n == null) missingY += 1; else vals.push(n); } return { ...base, missingY, buckets: [ { key: "", label: "", value: reduce(spec.agg, vals, rows.length), n: rows.length }, ], }; } const xField = spec.x ? fieldByKey.get(spec.x) : undefined; if (!spec.x) return { ...base, problem: "Choose a field to group by." }; if (!xField) return { ...base, problem: "The field this chart grouped by no longer exists." }; const byMonth = isDateFamilyType(xField.type); const groups = new Map(); let blanks = 0; let missingY = 0; for (const r of rows) { const raw = r[xField.key]; let key: string; let label: string; if (byMonth) { const m = monthOf(raw); if (m == null) { blanks += 1; key = ""; label = "(no date)"; } else { key = m; label = monthLabel(m); } } else { const s = String(raw ?? "").trim(); if (s === "") { blanks += 1; key = ""; label = "(blank)"; } else { key = s; label = s; } } let g = groups.get(key); if (!g) { g = { label, vals: [], n: 0 }; groups.set(key, g); } g.n += 1; if (yField) { const n = numOf(r[yField.key]); if (n == null) missingY += 1; else g.vals.push(n); } } let buckets: Bucket[] = [...groups.entries()].map(([key, g]) => ({ key, label: g.label, value: reduce(spec.agg, g.vals, g.n), n: g.n, })); // Time reads chronologically; categories read biggest-first. Sorting a date // axis by value would turn a trend line into a sawtooth that means nothing. if (byMonth) { buckets.sort((a, b) => (a.key === "" ? 1 : b.key === "" ? -1 : a.key.localeCompare(b.key))); } else { buckets.sort((a, b) => b.value - a.value || a.label.localeCompare(b.label)); } // Signed-domain facts are computed BEFORE the display cap. A negative group // that sorts below the top 12 must still block a donut; otherwise the cap // would turn invalid source data into a plausible positive composition. const negativeBuckets = buckets.filter((bucket) => bucket.value < 0); const zeroBuckets = buckets.filter((bucket) => bucket.value === 0).length; const negativeValue = negativeBuckets.reduce((sum, bucket) => sum + bucket.value, 0); let omitted = 0; let omittedValue = 0; const cap = opts?.maxBuckets ?? MAX_BUCKETS; if (buckets.length > cap) { const kept = buckets.slice(0, cap); const rest = buckets.slice(cap); omitted = rest.length; omittedValue = rest.reduce((a, b) => a + b.value, 0); buckets = kept; } return { ...base, buckets, omitted, omittedValue, blanks, missingY, negativeBuckets: negativeBuckets.length, negativeValue, zeroBuckets, }; } /** * Expand the flat aggregation into the renderer-neutral table used at the * chart boundary. `splitBy` becomes a real series dimension here; saved view * state remains independent of Vega-Lite (or whichever renderer replaces it). */ export function chartModel( spec: ChartSpec, rows: Row[], fieldByKey: Map ): ChartModel { const base = chartData({ ...spec, splitBy: undefined }, rows, fieldByKey); const empty: ChartModel = { points: [], xOrder: [], seriesOrder: [], omitted: base.omitted, omittedValue: base.omittedValue, omittedSeries: 0, omittedSeriesValue: 0, nonPositive: 0, nonPositiveValue: 0, blanks: base.blanks, missingY: base.missingY, rows: base.rows, problem: base.problem, }; if (base.problem) return empty; if (spec.kind === "donut") { // Arc length encodes a share of a positive whole. Vega (like most chart // engines) can accept signed theta values syntactically, but the resulting // geometry has no truthful business meaning. Zero groups preserve the old // positive-only omission (with disclosure); one negative group blocks the // entire composition and directs the user to a signed chart. const positive = base.buckets.filter((bucket) => bucket.value > 0); const negativeCount = base.negativeBuckets ?? base.buckets.filter((bucket) => bucket.value < 0).length; const zeroCount = base.zeroBuckets ?? base.buckets.filter((bucket) => bucket.value === 0).length; const nonPositive = negativeCount + zeroCount; const nonPositiveValue = base.negativeValue ?? base.buckets .filter((bucket) => bucket.value < 0) .reduce((sum, bucket) => sum + bucket.value, 0); if (negativeCount > 0) { return { ...empty, nonPositive, nonPositiveValue, problem: "A donut cannot represent negative values without misrepresenting the whole. Use a bar or line chart for signed values.", }; } if (base.buckets.length > 0 && positive.length === 0) { return { ...empty, nonPositive, nonPositiveValue, problem: "A donut needs at least one positive value. Zero-value groups cannot be drawn as slices.", }; } return { ...empty, points: positive.map((bucket) => ({ ...bucket, seriesKey: "", seriesLabel: "", })), xOrder: positive.map((bucket) => bucket.key), nonPositive, nonPositiveValue, }; } if (!spec.splitBy || spec.kind === "kpi") { return { ...empty, points: base.buckets.map((bucket) => ({ ...bucket, seriesKey: "", seriesLabel: "", })), xOrder: base.buckets.map((bucket) => bucket.key), }; } const splitField = fieldByKey.get(spec.splitBy); const xField = spec.x ? fieldByKey.get(spec.x) : undefined; if (!splitField) return { ...empty, problem: "The field this chart split into series no longer exists." }; if (!xField) return { ...empty, problem: "The field this chart grouped by no longer exists." }; const xKey = (row: Row): string => { if (isDateFamilyType(xField.type)) return monthOf(row[xField.key]) ?? ""; return String(row[xField.key] ?? "").trim(); }; const seriesKey = (row: Row): string => String(row[splitField.key] ?? "").trim(); const xAllowed = new Set(base.buckets.map((bucket) => bucket.key)); const bySeries = new Map(); for (const row of rows) { if (!xAllowed.has(xKey(row))) continue; const key = seriesKey(row); const group = bySeries.get(key); if (group) group.push(row); else bySeries.set(key, [row]); } const computed = [...bySeries.entries()].map(([key, seriesRows]) => { const data = chartData( { ...spec, splitBy: undefined }, seriesRows, fieldByKey ); return { key, label: key || "(blank)", data, weight: data.buckets.reduce((sum, bucket) => sum + Math.abs(bucket.value), 0), }; }); computed.sort((a, b) => b.weight - a.weight || a.label.localeCompare(b.label)); const kept = computed.slice(0, MAX_CHART_SERIES); const omittedSeries = Math.max(0, computed.length - kept.length); const omittedSeriesValue = computed .slice(MAX_CHART_SERIES) .reduce((sum, series) => sum + series.weight, 0); const xOrder = base.buckets.map((bucket) => bucket.key); const labelByX = new Map(base.buckets.map((bucket) => [bucket.key, bucket.label])); const points: ChartPoint[] = []; for (const series of kept) { const byX = new Map(series.data.buckets.map((bucket) => [bucket.key, bucket])); for (const key of xOrder) { const bucket = byX.get(key); points.push({ key, label: labelByX.get(key) ?? key, value: bucket?.value ?? 0, n: bucket?.n ?? 0, seriesKey: series.key, seriesLabel: series.label, }); } } return { ...empty, points, xOrder, seriesOrder: kept.map((series) => series.key), omittedSeries, omittedSeriesValue, }; } /** The default spec for a freshly added chart — count of rows by the first * groupable field, which draws something real immediately rather than an empty * card the user has to configure before seeing anything. */ export function defaultChart(id: string, fields: Field[]): ChartSpec { const x = fields.find((f) => f.type === "status" || f.type === "select")?.key; return { id, kind: "bar", x, agg: "count", size: { w: 6, h: 280 } }; } /** * Fields offerable as a chart's x (group-by). * * ⭐ ONE evaluator, not two. `types.isGroupableField` is the app's answer to * "can this column be a group key" — `Toolbar.tsx:637` has said so in those * words since wave 20 — and this function used to carry a SECOND, hand-kept * allow-list that disagreed with it about `multiselect` and `checkbox`. Nobody * ever decided that disagreement; two lists drifted, and the picker quietly * offered a different vocabulary from the grid's own group-by * ([[one-evaluator-per-question]]). The categorical answer now comes from * there and from nowhere else. * * TWO declared deltas, each with a stated cause. **A declared delta is the whole * difference from what stood here before**, which was an opaque parallel list * nobody could tell from an oversight — and the delegation means a type added * to `isGroupableField` tomorrow is offered here automatically instead of * silently missing. * * **+ the date family.** A chart buckets a date into PERIODS (`monthOf`, in * `chartData` below), so the one-bucket-per-day objection that keeps raw dates * out of the GRID's grouping simply does not apply to a chart. * * **− set-like and checkbox columns.** The grid keys these TYPE-AWARE and this * module does not. `useVisibleRows.ts:563-575 groupRows` expands a multi cell * into one bucket PER MEMBER — in its own words, *"a customer in A and in B is * in both groups — not in a combined 'A, B' bucket that is nobody's list"* — * and maps a blank checkbox to its UNCHECKED label, blank being the storage * contract's false rather than a missing value. `chartData` keys every non-date * bucket as `String(cell).trim()`, so offering them here would draw precisely * the combined bucket the grid refuses to draw, and label the unchecked half * "(blank)". ⛔ **Offering a type the renderer keys WRONG is worse than not * offering it at all** — a chart is the surface where a wrong bucket looks most * convincing. Closing this is a change to the KEYING, not to this list, and the * set case is not a relabel: one row landing in several buckets changes what a * sum means. * * ⚠ `lockedKey` is `""` deliberately. `ChartCard` has no identity column in * scope, and every text field INCLUDING the identity one was already offered * here before this change — so `""` preserves today's offer exactly. Narrowing * it is a product decision, not a refactor, and it does not belong in a defect * fix. */ export function chartKeyable(f: Field): boolean { return !f.multi && f.type !== "multiselect" && f.type !== "checkbox"; } export function groupableForChart(fields: Field[]): Field[] { return fields.filter((f) => (isGroupableField(f, "") || isDateFamilyType(f.type)) && chartKeyable(f)); } /** Fields offerable as a chart's y (the measure). */ export function measurableForChart(fields: Field[]): Field[] { return fields.filter((f) => isNumericFieldType(f.type)); } /** One entry of a chart field picker's option list. */ export interface FieldOption { key: string; label: string; } /** * The options a chart's field picker must render for a STORED value. * * ⛔ THE STORED VALUE IS ALWAYS AN OPTION. A `