File size: 36,205 Bytes
da5297e dcdb685 da5297e 7127075 da5297e dcdb685 da5297e dcdb685 da5297e dcdb685 da5297e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 | // ---------------------------------------------------------------------------
// 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;
}
|