loopable / web /src /viz /chartData.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
dcdb685 verified
Raw
History Blame Contribute Delete
36.2 kB
// ---------------------------------------------------------------------------
// 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;
}