| // --------------------------------------------------------------------------- | |
| // 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<ChartAgg, string> = { | |
| 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<ChartData, "buckets"> { | |
| 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<string, Field>, | |
| 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<string, { label: string; vals: number[]; n: number }>(); | |
| 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<string, Field> | |
| ): 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<string, Row[]>(); | |
| 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 `<select>` whose `value` matches no | |
| * `<option>` does not complain β it deselects, so the control reads "Choose a | |
| * fieldβ¦" while the chart is still perfectly well grouped by that column, and | |
| * the next edit to any other control writes back whatever the user can now see. | |
| * The grouping is lost without anybody choosing to lose it. This repo has paid | |
| * for that twice ([[cg-condition-builder-items]]); | |
| * `automation/CondBuilder.tsx:288-290` is the hardened precedent, and the | |
| * synthesised entry leads the list there too. | |
| * | |
| * β It lives here, next to the predicate that decides the offer, rather than | |
| * inline in the .tsx β the same reason `clientToUser` moved into | |
| * `mapProjection.ts` this wave: a rule written inside a component is a rule the | |
| * gate's compiled-artifact mutations cannot reach, and this one shipped broken | |
| * behind a green gate for exactly that reason. | |
| * | |
| * Two ways a stored key falls out of the offered list, and they deserve | |
| * different words: | |
| * - **the column exists but is not offerable here** β a `user` column, or a | |
| * text column somebody retyped. It carries its OWN LABEL: the chart really | |
| * is grouped by it, and saying anything else would be the lie. | |
| * - **the column is gone from this database** β it is named and marked, in the | |
| * same words the card's own `problem` sentence uses. | |
| */ | |
| export function fieldOptions( | |
| offered: Field[], | |
| fields: Field[], | |
| stored: string | undefined | |
| ): FieldOption[] { | |
| const list = offered.map((f) => ({ key: f.key, label: f.label })); | |
| if (!stored || list.some((o) => o.key === stored)) return list; | |
| const known = fields.find((f) => f.key === stored); | |
| return [{ key: stored, label: known ? known.label : `${stored} (not in this database)` }, ...list]; | |
| } | |
| /** | |
| * Wave-9 I10 (contract C2 Β§4) β **ONE number formatter for charts, not two.** | |
| * | |
| * C2 is explicit: hover/tooltip values use the same formatter as the CELLS (`formatDisplay`), | |
| * so a revenue figure never reads one way in a row and another way in a chart hovering over | |
| * it. `auto` (the default) therefore delegates to the field's own formatting, which already | |
| * knows that field's conventions. | |
| * | |
| * β An EXPLICIT format is presentation only and **never rescales the number.** In particular | |
| * `percent` appends the sign without multiplying by 100: semantic percentages here are 0β1 | |
| * while transform `*_pct` values are 0β100 ([[analyst-chart-library]]), so a blanket Γ100 | |
| * would silently be wrong for half the fields it is applied to. Choosing a format changes how | |
| * a value is WRITTEN, never what it is. | |
| */ | |
| export function chartValueText( | |
| v: number, | |
| yField: Field | undefined, | |
| format: ChartFormat | undefined, | |
| fallback: (n: number) => string | |
| ): string { | |
| if (!format || format === "auto") { | |
| return yField ? formatDisplay(yField, v) : fallback(v); | |
| } | |
| if (format === "compact") return fallback(v); | |
| if (format === "number") return v.toLocaleString(undefined, { maximumFractionDigits: 2 }); | |
| if (format === "percent") | |
| return `${v.toLocaleString(undefined, { maximumFractionDigits: 2 })}%`; | |
| // currency | |
| return v.toLocaleString(undefined, { | |
| style: "currency", | |
| currency: "USD", | |
| maximumFractionDigits: 0, | |
| }); | |
| } | |
| function clampRange(n: number, [lo, hi]: readonly [number, number]): number { | |
| return Math.max(lo, Math.min(hi, n)); | |
| } | |
| /** C2's `charts` array, normalized. Unknown kinds/aggs and dead shapes are | |
| * dropped rather than rendered as a broken card; the cap is enforced here so | |
| * both the writer and the reader agree on it. */ | |
| export function cleanCharts(raw: unknown): ChartSpec[] | undefined { | |
| if (!Array.isArray(raw)) return undefined; | |
| const out: ChartSpec[] = []; | |
| for (const item of raw) { | |
| if (!item || typeof item !== "object") continue; | |
| const d = item as Record<string, unknown>; | |
| const id = typeof d.id === "string" && d.id ? d.id : ""; | |
| const kind = d.kind as ChartKind; | |
| const agg = d.agg as ChartAgg; | |
| if (!id || !CHART_KINDS.includes(kind) || !CHART_AGGS.includes(agg)) continue; | |
| const spec: ChartSpec = { id, kind, agg }; | |
| if (typeof d.x === "string" && d.x) spec.x = d.x; | |
| if (typeof d.y === "string" && d.y) spec.y = d.y; | |
| if (typeof d.title === "string" && d.title) spec.title = d.title.slice(0, 60); | |
| // ββ I11 (C2) customisation. Mirrors `aios_grid._clean_chart` key for key and RULE for | |
| // rule: a mirror that is merely "close" is how the picker ends up offering something the | |
| // host silently drops on save. | |
| // `splitBy` may not be the category axis itself β a series per category is one bar each | |
| // and no information. | |
| if (typeof d.splitBy === "string" && d.splitBy && d.splitBy !== spec.x) | |
| spec.splitBy = d.splitBy; | |
| if (spec.splitBy && (kind === "bar" || kind === "area") && d.stacked === true) | |
| spec.stacked = true; | |
| if (CHART_PALETTES.includes(d.palette as ChartPalette)) | |
| spec.palette = d.palette as ChartPalette; | |
| if (d.axis && typeof d.axis === "object" && !Array.isArray(d.axis)) { | |
| const rawAxis = d.axis as Record<string, unknown>; | |
| const axis: { x?: ChartAxisSide; y?: ChartAxisSide } = {}; | |
| for (const side of ["x", "y"] as const) { | |
| const s = rawAxis[side]; | |
| if (!s || typeof s !== "object" || Array.isArray(s)) continue; | |
| const sr = s as Record<string, unknown>; | |
| const one: ChartAxisSide = {}; | |
| if (typeof sr.label === "string" && sr.label.trim()) | |
| one.label = sr.label.trim().slice(0, MAX_AXIS_LABEL); | |
| if (CHART_FORMATS.includes(sr.format as ChartFormat)) | |
| one.format = sr.format as ChartFormat; | |
| if (one.label !== undefined || one.format !== undefined) axis[side] = one; | |
| } | |
| if (axis.x || axis.y) spec.axis = axis; | |
| } | |
| if (d.size && typeof d.size === "object" && !Array.isArray(d.size)) { | |
| const sz = d.size as Record<string, unknown>; | |
| const one: { w?: number; h?: number } = {}; | |
| if (typeof sz.w === "number" && Number.isFinite(sz.w)) | |
| one.w = clampRange(Math.round(sz.w), CHART_W_RANGE); | |
| if (typeof sz.h === "number" && Number.isFinite(sz.h)) | |
| one.h = clampRange(Math.round(sz.h), CHART_H_RANGE); | |
| if (one.w !== undefined || one.h !== undefined) spec.size = one; | |
| } | |
| // ββ Wave-14 R3 (item 18): the PERIOD. β This branch is not optional decoration β THIS | |
| // FUNCTION IS A REBUILDER. It constructs `{id, kind, agg}` and copies named keys, so a | |
| // key it does not name is DROPPED on every read (`CustomerGrid.tsx:2566` runs it over the | |
| // stored display). Without these lines the host would store the period faithfully and the | |
| // client would throw it away one layer later β the control would simply look like it does | |
| // not save, with nothing anywhere going red. Same law GRID booked from the other side of | |
| // `cleanDisplay`: when a validator narrows a key inside a collection, every cleaner in the | |
| // chain has to know the key exists. | |
| // | |
| // The rules mirror `aios_grid._clean_chart` (C-ACC) exactly: a period only means something | |
| // on a metric, and a span only means something beside a period. | |
| if ( | |
| typeof d.bucket === "string" && | |
| (TS_BUCKETS as readonly string[]).includes(d.bucket) && | |
| isMetricKey(spec.y) | |
| ) { | |
| spec.bucket = d.bucket; | |
| if (d.span && typeof d.span === "object" && !Array.isArray(d.span)) { | |
| const n = (d.span as Record<string, unknown>).lastN; | |
| if (typeof n === "number" && Number.isFinite(n)) { | |
| const lastN = Math.round(n); | |
| if (lastN >= 1 && lastN <= TS_MAX_LAST_N) spec.span = { lastN }; | |
| } | |
| } | |
| } | |
| // Wave-16 C-CHARTCAP β the YoY companion survives the rebuild ONLY where it can mean | |
| // something: beside a kept bucket (the compare series) or on a sum-of-metric KPI (the | |
| // delta line). This function rebuilds `{id, kind, agg}` and copies NAMED keys, so | |
| // omitting this branch would make the toggle look like it does not save β the exact | |
| // failure the period branch above documents. Mirrors `aios_grid._clean_chart`. | |
| if ( | |
| d.compare === "prior_year" && | |
| (spec.bucket || (kind === "kpi" && spec.agg === "sum" && isMetricKey(spec.y))) | |
| ) | |
| spec.compare = "prior_year"; | |
| out.push(spec); | |
| if (out.length >= MAX_CHARTS) break; | |
| } | |
| return out.length ? out : undefined; | |
| } | |