@bicharts/chart-host 0.5.31 → 0.5.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -569,6 +569,170 @@ function qualifyGroupHeadingFor(row, state) {
569
569
  return null;
570
570
  }
571
571
 
572
+ // src/selectionCard.ts
573
+ var SYNTHETIC_PREFIX = "__";
574
+ var NUMERIC_DATATYPE = /int|double|decimal|single|float|number|currency|money/i;
575
+ function normaliseAggregation(raw) {
576
+ const s = String(raw ?? "").trim().toLowerCase();
577
+ if (!s) return "sum";
578
+ if (/^(avg|average|mean)$/.test(s)) return "average";
579
+ if (/^(min|minimum)$/.test(s)) return "min";
580
+ if (/^(max|maximum)$/.test(s)) return "max";
581
+ if (/^(count|countrows)$/.test(s)) return "count";
582
+ if (/^(median|med)$/.test(s)) return "median";
583
+ if (/^(distinctcount|dcount|countdistinct|uniques?)$/.test(s)) return "distinctcount";
584
+ return "sum";
585
+ }
586
+ function isAdditive(agg) {
587
+ return agg === "sum" || agg === "count";
588
+ }
589
+ function aggLabel(agg) {
590
+ switch (agg) {
591
+ case "average":
592
+ return "Average";
593
+ case "min":
594
+ return "Min";
595
+ case "max":
596
+ return "Max";
597
+ case "count":
598
+ return "Count";
599
+ case "median":
600
+ return "Median";
601
+ case "distinctcount":
602
+ return "Distinct";
603
+ default:
604
+ return "Sum";
605
+ }
606
+ }
607
+ function isSynthetic(name) {
608
+ return String(name ?? "").startsWith(SYNTHETIC_PREFIX);
609
+ }
610
+ function isMeasureColumn(col) {
611
+ if (!col || isSynthetic(col.name)) return false;
612
+ if (col.isMeasure === true) return true;
613
+ return NUMERIC_DATATYPE.test(String(col.dataType ?? ""));
614
+ }
615
+ function toNumber(v) {
616
+ if (v == null || v === "") return null;
617
+ const n = typeof v === "number" ? v : Number(v);
618
+ return Number.isFinite(n) ? n : null;
619
+ }
620
+ function reduce(values, agg, distinctRaw) {
621
+ if (agg === "count") return values.length;
622
+ if (agg === "distinctcount") return new Set(distinctRaw.map((v) => v == null ? "\0" : String(v))).size;
623
+ if (!values.length) return null;
624
+ switch (agg) {
625
+ case "average":
626
+ return values.reduce((a, b) => a + b, 0) / values.length;
627
+ case "min":
628
+ return Math.min(...values);
629
+ case "max":
630
+ return Math.max(...values);
631
+ case "median": {
632
+ const s = [...values].sort((a, b) => a - b);
633
+ const m = s.length >> 1;
634
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
635
+ }
636
+ default:
637
+ return values.reduce((a, b) => a + b, 0);
638
+ }
639
+ }
640
+ function fmtNumber(v, culture) {
641
+ try {
642
+ const abs = Math.abs(v);
643
+ const opts = abs >= 1e6 ? { notation: "compact", maximumFractionDigits: 1 } : { maximumFractionDigits: abs < 10 ? 2 : 0 };
644
+ return new Intl.NumberFormat(culture || void 0, opts).format(v);
645
+ } catch {
646
+ return String(Math.round(v * 100) / 100);
647
+ }
648
+ }
649
+ function computeSelectionCard(payload, selectedRowIdxs, opts = {}) {
650
+ const columns = payload?.columns ?? [];
651
+ const rows = payload?.rows ?? [];
652
+ if (!columns.length || !rows.length) return null;
653
+ const wanted = Array.from(new Set((selectedRowIdxs ?? []).filter((n) => Number.isInteger(n))));
654
+ if (!wanted.length) return null;
655
+ const rowIdxCol = columns.findIndex((c) => c?.name === "__rowIdx__");
656
+ let byIdx = null;
657
+ if (rowIdxCol >= 0) {
658
+ byIdx = /* @__PURE__ */ new Map();
659
+ for (let r = 0; r < rows.length; r++) {
660
+ const v = toNumber(rows[r]?.[rowIdxCol]);
661
+ if (v != null) byIdx.set(v, r);
662
+ }
663
+ }
664
+ const positions = [];
665
+ for (const idx of wanted) {
666
+ const pos = byIdx ? byIdx.get(idx) : idx;
667
+ if (pos != null && pos >= 0 && pos < rows.length) positions.push(pos);
668
+ }
669
+ if (!positions.length) return null;
670
+ const agg = normaliseAggregation(opts.aggregation);
671
+ const maxMeasures = opts.maxMeasures ?? 4;
672
+ const maxHeaderValues = opts.maxHeaderValues ?? 3;
673
+ const dimIdx = columns.findIndex((c) => c && !isSynthetic(c.name) && !isMeasureColumn(c));
674
+ let header;
675
+ if (dimIdx >= 0) {
676
+ const seen = [];
677
+ const set = /* @__PURE__ */ new Set();
678
+ for (const p of positions) {
679
+ const raw = rows[p]?.[dimIdx];
680
+ const s = raw == null || raw === "" ? "(blank)" : String(raw);
681
+ if (!set.has(s)) {
682
+ set.add(s);
683
+ seen.push(s);
684
+ }
685
+ if (seen.length > maxHeaderValues) break;
686
+ }
687
+ header = seen.length > maxHeaderValues ? `${positions.length} marks` : seen.join(", ");
688
+ } else {
689
+ header = `${positions.length} mark${positions.length === 1 ? "" : "s"}`;
690
+ }
691
+ const measureCols = [];
692
+ for (let c = 0; c < columns.length; c++) if (isMeasureColumn(columns[c])) measureCols.push(c);
693
+ const shown = measureCols.slice(0, maxMeasures);
694
+ const lines = [];
695
+ for (const c of shown) {
696
+ const selVals = [];
697
+ const selRaw = [];
698
+ for (const p of positions) {
699
+ const n = toNumber(rows[p]?.[c]);
700
+ selRaw.push(rows[p]?.[c]);
701
+ if (n != null) selVals.push(n);
702
+ }
703
+ const value = reduce(selVals, agg, selRaw);
704
+ let sharePct = null;
705
+ if (isAdditive(agg) && value != null) {
706
+ const allVals = [];
707
+ const allRaw = [];
708
+ for (let r = 0; r < rows.length; r++) {
709
+ const n = toNumber(rows[r]?.[c]);
710
+ allRaw.push(rows[r]?.[c]);
711
+ if (n != null) allVals.push(n);
712
+ }
713
+ const total = reduce(allVals, agg, allRaw);
714
+ if (total != null && total > 0) sharePct = value / total * 100;
715
+ }
716
+ lines.push({
717
+ column: String(columns[c]?.name ?? ""),
718
+ label: `${aggLabel(agg)} of ${columns[c]?.name ?? ""}`,
719
+ value,
720
+ valueText: value == null ? "\u2014" : fmtNumber(value, opts.cultureCode),
721
+ sharePct,
722
+ shareText: sharePct == null ? "" : `${sharePct < 0.1 ? "<0.1" : sharePct.toFixed(1)}% of total`
723
+ });
724
+ }
725
+ return {
726
+ header,
727
+ lines,
728
+ rowsText: `${positions.length} of ${rows.length} rows`,
729
+ selectedRows: positions.length,
730
+ totalRows: rows.length,
731
+ hiddenMeasures: Math.max(0, measureCols.length - shown.length),
732
+ aggregation: agg
733
+ };
734
+ }
735
+
572
736
  // src/index.ts
573
737
  function registerCityTable(packed) {
574
738
  L3(packed);
@@ -610,6 +774,7 @@ export {
610
774
  clearGeoCache,
611
775
  compileRenderFn,
612
776
  compileTrivialSource,
777
+ computeSelectionCard,
613
778
  createChartHost,
614
779
  createMarkResolver,
615
780
  explainRenderFailure,
@@ -617,6 +782,7 @@ export {
617
782
  geoFromCache,
618
783
  loadGeo,
619
784
  newQualifyGroupState,
785
+ normaliseAggregation,
620
786
  planTrivialChart,
621
787
  qualifyGroupHeadingFor,
622
788
  rasterizeSvgToPngDataUrl,
@@ -12,3 +12,4 @@ export { captureSvgSnapshot, svgToDataUrl, svgNaturalSize, rasterizeSvgToPngData
12
12
  export { shouldReview, buildReviewWire, bareBase64, actionFor, type ReviewGate, type ReviewWire, type ReviewVerdict, type ReviewAction } from "./review";
13
13
  export { askApplyImprovements, type ReviewDialogOptions, type ReviewDialogText } from "./reviewDialog";
14
14
  export { qualifyGroupHeadingFor, newQualifyGroupState, type QualifyGroupRow, type QualifyGroupState, type QualifyGroupHeading, } from "./qualifyGroups";
15
+ export { computeSelectionCard, normaliseAggregation, type SelectionCardModel, type SelectionCardLine, type SelectionCardOptions, } from "./selectionCard";
@@ -0,0 +1,53 @@
1
+ import type { RenderPayload } from "./payload";
2
+ export interface SelectionCardOptions {
3
+ /** The chart's own aggregation, so the card AGREES with the picture it sits on. A card
4
+ * reading "Sum" beside a chart drawn from averages is two answers to one question. */
5
+ aggregation?: string;
6
+ cultureCode?: string;
7
+ /** Cards are capped at roughly a third of the tile, so the measure list is capped too.
8
+ * `hiddenMeasures` reports what was dropped rather than truncating silently. */
9
+ maxMeasures?: number;
10
+ /** Past this many distinct dimension values the header becomes "N marks" — naming twelve
11
+ * cities in a header is not a header. */
12
+ maxHeaderValues?: number;
13
+ }
14
+ export interface SelectionCardLine {
15
+ column: string;
16
+ /** "Sum of Revenue" — the aggregation is named because it is not always sum. */
17
+ label: string;
18
+ value: number | null;
19
+ valueText: string;
20
+ /** Share of the same aggregate over ALL rows. Null whenever a share would be a lie — see
21
+ * isAdditive: the average of a subset is not a percentage of the average of the whole. */
22
+ sharePct: number | null;
23
+ shareText: string;
24
+ }
25
+ export interface SelectionCardModel {
26
+ header: string;
27
+ lines: SelectionCardLine[];
28
+ /** "6 of 24 rows" — the one line that is true regardless of what the columns contain. */
29
+ rowsText: string;
30
+ selectedRows: number;
31
+ totalRows: number;
32
+ /** Measures the cap left out, so a host can say "+2 more" instead of implying there were none. */
33
+ hiddenMeasures: number;
34
+ aggregation: string;
35
+ }
36
+ type Agg = "sum" | "average" | "min" | "max" | "count" | "median" | "distinctcount";
37
+ /** Normalise the many spellings the hosts use into the set this file reduces over. Unknown
38
+ * spellings fall back to sum rather than throwing: a card is a courtesy, and refusing to draw
39
+ * one because a host wrote "Total" is a worse outcome than summing. */
40
+ export declare function normaliseAggregation(raw: string | undefined | null): Agg;
41
+ /**
42
+ * The card for a selection, or NULL when there is nothing to say.
43
+ *
44
+ * `selectedRowIdxs` are the values a mark carries in `data-row-idx` — which ARE row positions,
45
+ * because `buildRenderPayload` writes `__rowIdx__` as the row's own index. The mapping is
46
+ * resolved through that column when it is present rather than assumed, so a payload that ever
47
+ * reorders rows keeps working.
48
+ *
49
+ * Null (rather than an empty model) when the selection is empty, because "no selection" and "a
50
+ * selection that sums to nothing" are different states and only the first should dismiss the card.
51
+ */
52
+ export declare function computeSelectionCard(payload: RenderPayload | null | undefined, selectedRowIdxs: number[] | null | undefined, opts?: SelectionCardOptions): SelectionCardModel | null;
53
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bicharts/chart-host",
3
- "version": "0.5.31",
3
+ "version": "0.5.32",
4
4
  "description": "Run a BIC-generated D3 chart in any web host: compiles the generated render() function, applies the shared option defaults, resolves mark clicks (through tooltip overlays), owns the selection affordance, and translates row indices between cross-filtered charts. The same contract the BIC Power BI visual implements, minus Power BI. React bindings at @bicharts/chart-host/react.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",