mviz 1.6.3 → 1.6.6

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.
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Table interactivity (sorting and filtering).
3
+ *
4
+ * Used by both standalone tables (`renderStandaloneTable`) and dashboard-embedded
5
+ * tables (`renderTable` in the layout parser). Produces the chrome (global filter
6
+ * input), the per-cell `data-sort` attribute for numeric sorting, and one
7
+ * vanilla-JS init function that wires up click/input handlers.
8
+ */
9
+ /**
10
+ * Read sortable/filter options from a spec. Sort defaults ON, filter defaults OFF.
11
+ */
12
+ export function parseTableInteractivity(spec) {
13
+ return {
14
+ sortable: spec.sortable !== false,
15
+ filter: spec.filter === true,
16
+ };
17
+ }
18
+ /**
19
+ * Generate an HTML attribute string for a sortable `<th>`.
20
+ * `colIdx` maps to the visual column index (including the row-number column if present).
21
+ */
22
+ export function sortableHeaderAttrs(colIdx, sortable) {
23
+ if (!sortable)
24
+ return '';
25
+ return ` class="sortable" data-col="${colIdx}"`;
26
+ }
27
+ /**
28
+ * Generate `data-sort="..."` for a cell so numeric sort doesn't depend on the
29
+ * rendered text (which may be formatted, contain SVG sparklines, etc.).
30
+ */
31
+ export function cellSortAttr(value) {
32
+ if (value === null || value === undefined)
33
+ return '';
34
+ if (typeof value === 'number' && !isNaN(value)) {
35
+ return ` data-sort="${value}"`;
36
+ }
37
+ if (typeof value === 'string') {
38
+ const n = parseFloat(value);
39
+ if (!isNaN(n) && String(n) === value.trim()) {
40
+ return ` data-sort="${n}"`;
41
+ }
42
+ return ` data-sort="${escapeAttr(value)}"`;
43
+ }
44
+ // Objects/arrays (e.g., sparkline data) are not sortable; omit the attribute.
45
+ return '';
46
+ }
47
+ function escapeAttr(s) {
48
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
49
+ }
50
+ export function buildTableChrome(tableId, interactivity, colors) {
51
+ if (!interactivity.filter) {
52
+ return { toolbarHtml: '' };
53
+ }
54
+ return {
55
+ toolbarHtml: `<div class="mviz-table-toolbar" style="margin-bottom: 6px;">` +
56
+ `<input type="search" class="mviz-filter-global" data-table="${tableId}" placeholder="Filter by any value…" ` +
57
+ `style="font-family: inherit; font-size: 11px; padding: 4px 8px; width: 240px; max-width: 100%; ` +
58
+ `border: 1px solid ${colors.border}; border-radius: 3px; background: ${colors.background}; color: ${colors.text};" />` +
59
+ `</div>`,
60
+ };
61
+ }
62
+ /**
63
+ * Per-table init call. Invoked after the HTML is in the DOM.
64
+ */
65
+ export function tableInitCall(tableId) {
66
+ return `if (window.mvizTableInit) window.mvizTableInit(${JSON.stringify(tableId)});`;
67
+ }
68
+ /**
69
+ * Shared JS that defines `window.mvizTableInit`. Include once per output document.
70
+ * Responsible for sort clicks and the global filter input.
71
+ */
72
+ export function sharedTableScript() {
73
+ return `(function() {
74
+ if (window.mvizTableInit) return;
75
+ function compareCells(a, b) {
76
+ var an = parseFloat(a), bn = parseFloat(b);
77
+ if (!isNaN(an) && !isNaN(bn)) return an - bn;
78
+ return String(a).localeCompare(String(b));
79
+ }
80
+ function getSortKey(cell) {
81
+ var v = cell.getAttribute('data-sort');
82
+ return v != null ? v : cell.textContent.trim();
83
+ }
84
+ window.mvizTableInit = function(tableId) {
85
+ var table = document.getElementById(tableId);
86
+ if (!table) return;
87
+ var thead = table.tHead;
88
+ var tbody = table.tBodies[0];
89
+ if (!thead || !tbody) return;
90
+ var origRows = Array.prototype.slice.call(tbody.rows);
91
+ var visibility = {};
92
+ origRows.forEach(function(r, i) { visibility[i] = true; });
93
+
94
+ function applyVisibility() {
95
+ origRows.forEach(function(r, i) { r.style.display = visibility[i] ? '' : 'none'; });
96
+ }
97
+
98
+ // ---- Sort ----
99
+ // Shared active sort state (held at the table level, not per-header) so that
100
+ // clicking header A, then B, then A again always starts A at ascending. A
101
+ // per-header closure variable would resume A from its previous state even
102
+ // though the indicator was cleared, producing a confusing cycle.
103
+ var sortableHeaders = thead.querySelectorAll('th.sortable');
104
+ var activeTh = null;
105
+ var activeState = 0; // 0 none, 1 asc, -1 desc
106
+ sortableHeaders.forEach(function(th) {
107
+ th.style.cursor = 'pointer';
108
+ th.style.userSelect = 'none';
109
+ th.addEventListener('click', function() {
110
+ var colIdx = parseInt(th.getAttribute('data-col'), 10);
111
+ if (isNaN(colIdx)) return;
112
+ if (activeTh === th) {
113
+ // Same column clicked again — cycle asc → desc → none.
114
+ activeState = activeState === 1 ? -1 : activeState === -1 ? 0 : 1;
115
+ } else {
116
+ // New column — reset the old one, start new one at ascending.
117
+ if (activeTh) {
118
+ activeTh.removeAttribute('data-sort-dir');
119
+ var prevInd = activeTh.querySelector('.mviz-sort-ind');
120
+ if (prevInd) prevInd.textContent = '';
121
+ }
122
+ activeTh = th;
123
+ activeState = 1;
124
+ }
125
+ var ind = th.querySelector('.mviz-sort-ind');
126
+ if (activeState === 0) {
127
+ th.removeAttribute('data-sort-dir');
128
+ if (ind) ind.textContent = '';
129
+ activeTh = null;
130
+ origRows.forEach(function(r) { tbody.appendChild(r); });
131
+ applyVisibility();
132
+ return;
133
+ }
134
+ th.setAttribute('data-sort-dir', activeState === 1 ? 'asc' : 'desc');
135
+ if (ind) ind.textContent = activeState === 1 ? '▲' : '▼';
136
+ var sorted = origRows.slice().sort(function(a, b) {
137
+ var av = getSortKey(a.cells[colIdx]);
138
+ var bv = getSortKey(b.cells[colIdx]);
139
+ var cmp = compareCells(av, bv);
140
+ return activeState === 1 ? cmp : -cmp;
141
+ });
142
+ sorted.forEach(function(r) { tbody.appendChild(r); });
143
+ applyVisibility();
144
+ });
145
+ });
146
+
147
+ // ---- Global filter ----
148
+ var container = table.parentElement;
149
+ var globalInput = container && container.querySelector('.mviz-filter-global[data-table="' + tableId + '"]');
150
+ if (globalInput) {
151
+ globalInput.addEventListener('input', function() {
152
+ var q = globalInput.value.toLowerCase();
153
+ origRows.forEach(function(r, i) {
154
+ visibility[i] = !q || r.textContent.toLowerCase().indexOf(q) !== -1;
155
+ });
156
+ applyVisibility();
157
+ });
158
+ }
159
+ };
160
+ })();`;
161
+ }
162
+ /**
163
+ * Shared CSS for sort indicators. Appended once per standalone document or
164
+ * injected once into dashboard mode alongside the shared script.
165
+ */
166
+ export function sharedTableCss() {
167
+ return `th.sortable { cursor: pointer; }
168
+ th.sortable[data-sort-dir] { color: inherit; }
169
+ /*
170
+ * Reserve the arrow's width always so adding ▲/▼ on sort doesn't widen the
171
+ * column and push the rest of the header row around.
172
+ */
173
+ .mviz-sort-ind {
174
+ display: inline-block;
175
+ min-width: 0.9em;
176
+ margin-left: 2px;
177
+ font-size: 9px;
178
+ opacity: 0.7;
179
+ text-align: left;
180
+ white-space: nowrap;
181
+ }`;
182
+ }
183
+ //# sourceMappingURL=table-interactivity.js.map
@@ -65,6 +65,11 @@ export declare function computeHeatmapRanges(columns: ColumnDef[], data: Record<
65
65
  min: number;
66
66
  max: number;
67
67
  }>;
68
+ /**
69
+ * Compute per-column pctMultiply for pct-formatted columns. A column maps to `false` when
70
+ * its values are already in percent units (max |v| > 1); otherwise defaults to `true`.
71
+ */
72
+ export declare function computePctMultiplyRanges(columns: ColumnDef[], data: Record<string, unknown>[]): Map<string, boolean>;
68
73
  /**
69
74
  * Compute min/max ranges for dumbbell sparkline columns
70
75
  */
@@ -81,7 +86,7 @@ export declare function formatCell(value: unknown, colDef: ColumnDef, themeColor
81
86
  }>, dumbbellRanges: Map<string, {
82
87
  min: number;
83
88
  max: number;
84
- }>, defaultHeatmapColors: string[], customPalette?: readonly string[], currency?: CurrencyConfig): CellResult;
89
+ }>, defaultHeatmapColors: string[], customPalette?: readonly string[], currency?: CurrencyConfig, pctMultiplyByColumn?: Map<string, boolean>): CellResult;
85
90
  /**
86
91
  * Generate a styled data table
87
92
  */
@@ -2,9 +2,10 @@
2
2
  * Data table component
3
3
  */
4
4
  import { COLORS, PALETTE, FONT_STACK, SPARKLINE_WIDTH, SPARKLINE_HEIGHT, DUMBBELL_DOT_RADIUS, getThemeColors, getHeatmapColors, getSparklineColors, } from '../core/themes.js';
5
- import { formatNumber, inferFormat, resolveCurrency } from '../core/formatting.js';
5
+ import { formatNumber, inferFormat, resolveCurrency, shouldPctMultiply } from '../core/formatting.js';
6
6
  import { calculateHeatmapColorWithContrast } from '../core/colors.js';
7
7
  import { registerComponent } from './registry.js';
8
+ import { parseTableInteractivity, sortableHeaderAttrs, cellSortAttr, buildTableChrome, sharedTableScript, sharedTableCss, tableInitCall, } from './table-interactivity.js';
8
9
  /**
9
10
  * Extract raw value and style overrides from cell data
10
11
  */
@@ -269,6 +270,26 @@ export function computeHeatmapRanges(columns, data) {
269
270
  }
270
271
  return ranges;
271
272
  }
273
+ /**
274
+ * Compute per-column pctMultiply for pct-formatted columns. A column maps to `false` when
275
+ * its values are already in percent units (max |v| > 1); otherwise defaults to `true`.
276
+ */
277
+ export function computePctMultiplyRanges(columns, data) {
278
+ const map = new Map();
279
+ for (const col of columns) {
280
+ const fmt = col.fmt;
281
+ if (fmt !== 'pct' && fmt !== 'pct0' && fmt !== 'pct1')
282
+ continue;
283
+ const values = [];
284
+ for (const row of data) {
285
+ const { value: rawVal } = extractCellValue(row[col.id], col);
286
+ if (typeof rawVal === 'number')
287
+ values.push(rawVal);
288
+ }
289
+ map.set(col.id, shouldPctMultiply(fmt, values));
290
+ }
291
+ return map;
292
+ }
272
293
  /**
273
294
  * Compute min/max ranges for dumbbell sparkline columns
274
295
  */
@@ -334,7 +355,7 @@ function formatSparklineCell(rawValue, colDef, colId, themeColors, dumbbellRange
334
355
  /**
335
356
  * Format a cell value and return content with optional styling
336
357
  */
337
- export function formatCell(value, colDef, themeColors, heatmapRanges, dumbbellRanges, defaultHeatmapColors, customPalette, currency) {
358
+ export function formatCell(value, colDef, themeColors, heatmapRanges, dumbbellRanges, defaultHeatmapColors, customPalette, currency, pctMultiplyByColumn) {
338
359
  const colType = colDef.type;
339
360
  const colId = colDef.id;
340
361
  const palette = customPalette ?? PALETTE;
@@ -370,7 +391,8 @@ export function formatCell(value, colDef, themeColors, heatmapRanges, dumbbellRa
370
391
  formatted = rawValue;
371
392
  }
372
393
  else {
373
- formatted = formatNumber(rawValue, fmt, false, currency);
394
+ const pctMultiply = pctMultiplyByColumn?.get(colId) ?? true;
395
+ formatted = formatNumber(rawValue, fmt, false, currency, pctMultiply);
374
396
  }
375
397
  // Apply bold/italic styling
376
398
  if (styles.bold) {
@@ -381,6 +403,7 @@ export function formatCell(value, colDef, themeColors, heatmapRanges, dumbbellRa
381
403
  }
382
404
  return { content: formatted, bgColor, textColor };
383
405
  }
406
+ let tableIdCounter = 0;
384
407
  /**
385
408
  * Parse and validate table specification
386
409
  */
@@ -406,8 +429,11 @@ function parseTableConfig(spec) {
406
429
  stripeBg: colors.border + (theme === 'light' ? '30' : '40'),
407
430
  heatmapRanges: computeHeatmapRanges(columns, data),
408
431
  dumbbellRanges: computeDumbbellRanges(columns, data),
432
+ pctMultiplyByColumn: computePctMultiplyRanges(columns, data),
409
433
  defaultHeatmapColors: getHeatmapColors(theme),
410
434
  currency: resolveCurrency(spec.currency),
435
+ interactivity: parseTableInteractivity(spec),
436
+ tableId: spec.id ?? `mviz-table-${++tableIdCounter}`,
411
437
  };
412
438
  }
413
439
  /**
@@ -426,17 +452,22 @@ function buildHeaderRow(cfg, inlineStyles = false) {
426
452
  cells.push('<th class="row-num">#</th>');
427
453
  }
428
454
  }
429
- for (const col of cfg.columns) {
455
+ const visualOffset = cfg.showRowNumbers ? 1 : 0;
456
+ for (let i = 0; i < cfg.columns.length; i++) {
457
+ const col = cfg.columns[i];
430
458
  const colTitle = col.title ?? col.id;
431
459
  const align = col.align ?? 'left';
460
+ const sortIdx = i + visualOffset;
461
+ const sortAttrs = sortableHeaderAttrs(sortIdx, cfg.interactivity.sortable);
462
+ const sortInd = cfg.interactivity.sortable ? '<span class="mviz-sort-ind"></span>' : '';
432
463
  if (inlineStyles) {
433
- cells.push(`<th style="font-family: ${FONT_STACK}; font-size: 10px; font-weight: bold; ` +
464
+ cells.push(`<th${sortAttrs} style="font-family: ${FONT_STACK}; font-size: 10px; font-weight: bold; ` +
434
465
  `color: ${cfg.colors.textSecondary}; text-transform: uppercase; ` +
435
466
  `letter-spacing: 0.03em; padding: ${cfg.padding}; ` +
436
- `border-bottom: 1px solid ${cfg.colors.text}; text-align: ${align};">${colTitle}</th>`);
467
+ `border-bottom: 1px solid ${cfg.colors.text}; text-align: ${align};">${colTitle}${sortInd}</th>`);
437
468
  }
438
469
  else {
439
- cells.push(`<th style="text-align: ${align};">${colTitle}</th>`);
470
+ cells.push(`<th${sortAttrs} style="text-align: ${align};">${colTitle}${sortInd}</th>`);
440
471
  }
441
472
  }
442
473
  return `<tr>${cells.join('')}</tr>`;
@@ -464,18 +495,21 @@ function buildDataRows(cfg, inlineStyles = false) {
464
495
  for (const col of cfg.columns) {
465
496
  const value = rowData[col.id];
466
497
  const align = col.align ?? 'left';
467
- const cellResult = formatCell(value, col, cfg.colors, cfg.heatmapRanges, cfg.dumbbellRanges, cfg.defaultHeatmapColors, undefined, cfg.currency);
498
+ const cellResult = formatCell(value, col, cfg.colors, cfg.heatmapRanges, cfg.dumbbellRanges, cfg.defaultHeatmapColors, undefined, cfg.currency, cfg.pctMultiplyByColumn);
499
+ // Unwrap `{value, bold, italic}` overrides so numeric sort sees the real value.
500
+ const sortRaw = extractCellValue(value, col).value;
501
+ const sortAttr = cfg.interactivity.sortable ? cellSortAttr(sortRaw) : '';
468
502
  if (inlineStyles) {
469
503
  const border = isLast ? `border-bottom: 1px solid ${cfg.colors.text};` : '';
470
504
  const cellBg = cellResult.bgColor ?? rowBg;
471
505
  const cellText = cellResult.textColor ?? cfg.colors.text;
472
- cells.push(`<td style="padding: ${cfg.padding}; color: ${cellText}; ` +
506
+ cells.push(`<td${sortAttr} style="padding: ${cfg.padding}; color: ${cellText}; ` +
473
507
  `${border} text-align: ${align}; background: ${cellBg};">${cellResult.content}</td>`);
474
508
  }
475
509
  else {
476
510
  const bgStyle = cellResult.bgColor ? ` background-color: ${cellResult.bgColor};` : '';
477
511
  const textStyle = cellResult.textColor ? ` color: ${cellResult.textColor};` : '';
478
- cells.push(`<td style="text-align: ${align};${bgStyle}${textStyle}">${cellResult.content}</td>`);
512
+ cells.push(`<td${sortAttr} style="text-align: ${align};${bgStyle}${textStyle}">${cellResult.content}</td>`);
479
513
  }
480
514
  }
481
515
  if (inlineStyles) {
@@ -556,9 +590,11 @@ function renderEmbeddedTable(cfg) {
556
590
  const tableClass = 'data-table' + (cfg.striped ? ' table-striped' : '') + (cfg.compact ? ' table-compact' : '');
557
591
  const headerRow = buildHeaderRow(cfg, true);
558
592
  const dataRows = buildDataRows(cfg, true);
593
+ const chrome = buildTableChrome(cfg.tableId, cfg.interactivity, cfg.colors);
559
594
  return `<div style="background: ${cfg.colors.background}; font-family: ${FONT_STACK};">
560
595
  ${titleHtml}
561
- <table class="${tableClass}" style="width: 100%; border-collapse: collapse; font-size: 12px;">
596
+ ${chrome.toolbarHtml}
597
+ <table id="${cfg.tableId}" class="${tableClass}" style="width: 100%; border-collapse: collapse; font-size: 12px;">
562
598
  <thead>${headerRow}</thead>
563
599
  <tbody>${dataRows.join('')}</tbody>
564
600
  </table>
@@ -572,6 +608,7 @@ function renderStandaloneTable(cfg) {
572
608
  const tableClass = 'data-table' + (cfg.striped ? ' table-striped' : '') + (cfg.compact ? ' table-compact' : '');
573
609
  const headerRow = buildHeaderRow(cfg, false);
574
610
  const dataRows = buildDataRows(cfg, false);
611
+ const chrome = buildTableChrome(cfg.tableId, cfg.interactivity, cfg.colors);
575
612
  return `<!DOCTYPE html>
576
613
  <html lang="en">
577
614
  <head>
@@ -579,18 +616,22 @@ function renderStandaloneTable(cfg) {
579
616
  <title>${cfg.title}</title>
580
617
  <style>
581
618
  ${generateTableCss(cfg)}
619
+ ${sharedTableCss()}
582
620
  </style>
583
621
  </head>
584
622
  <body>
585
623
  <div class="container">
586
624
  <h2>${titleUpper}</h2>
587
- <table class="${tableClass}">
625
+ ${chrome.toolbarHtml}
626
+ <table id="${cfg.tableId}" class="${tableClass}">
588
627
  <thead>${headerRow}</thead>
589
628
  <tbody>${dataRows.join('')}</tbody>
590
629
  </table>
591
630
  </div>
592
631
  <script>
593
632
  ${generateSparklineScript()}
633
+ ${sharedTableScript()}
634
+ ${tableInitCall(cfg.tableId)}
594
635
  </script>
595
636
  </body>
596
637
  </html>`;
@@ -37,7 +37,7 @@ export function generateExportCss() {
37
37
  box-shadow: 0 2px 8px rgba(0,0,0,0.18);
38
38
  padding: 4px 0;
39
39
  min-width: 200px;
40
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
40
+ font-family: var(--font-family, 'Helvetica Neue', Helvetica, Arial, sans-serif);
41
41
  font-size: 12px;
42
42
  color: ${COLORS.TEXT_DARK};
43
43
  }
@@ -136,9 +136,14 @@ export function generateExportJs() {
136
136
  return { div: tmpDiv, chart: tmpChart };
137
137
  }
138
138
 
139
+ function getThemeFont() {
140
+ var fontFamily = getComputedStyle(document.body).fontFamily;
141
+ return fontFamily || '"Helvetica Neue", Helvetica, Arial, sans-serif';
142
+ }
143
+
139
144
  function drawTitle(ctx, title, pixelRatio) {
140
145
  ctx.fillStyle = isDarkTheme() ? CFG.TEXT_DARK : CFG.TEXT_LIGHT;
141
- ctx.font = 'bold ' + Math.round(CFG.TITLE_FONT_SIZE * pixelRatio) + 'px "Helvetica Neue", Helvetica, Arial, sans-serif';
146
+ ctx.font = 'bold ' + Math.round(CFG.TITLE_FONT_SIZE * pixelRatio) + 'px ' + getThemeFont();
142
147
  ctx.textAlign = 'left';
143
148
  ctx.textBaseline = 'top';
144
149
  ctx.fillText(
@@ -21,8 +21,32 @@ export declare function localeFixed(value: number, decimals: number, locale: str
21
21
  * Format a number according to the specified format type
22
22
  * @param showSign - If true, prefix positive numbers with +
23
23
  * @param currency - Optional currency config for currency formats (defaults to USD)
24
+ * @param pctMultiply - For pct formats: multiply the value by 100 before formatting.
25
+ * Default true. Set false when the caller has already determined that the series
26
+ * values are in percentage units (via detectPctMultiply).
24
27
  */
25
- export declare function formatNumber(value: number | null | undefined, format?: FormatType, showSign?: boolean, currency?: CurrencyConfig): string;
28
+ export declare function formatNumber(value: number | null | undefined, format?: FormatType, showSign?: boolean, currency?: CurrencyConfig, pctMultiply?: boolean): string;
29
+ /**
30
+ * Detect whether a series of values needs to be multiplied by 100 for percent formatting.
31
+ * Returns true when the series looks fractional (max |v| <= 1). Returns false when any
32
+ * value exceeds 1, indicating the series is already in percentage units (e.g., [15, 22, 38]).
33
+ *
34
+ * Empty / all-null series default to true (fractional), matching the legacy behavior.
35
+ */
36
+ export declare function detectPctMultiply(values: Array<number | null | undefined>): boolean;
37
+ /**
38
+ * Series-aware pctMultiply for a chart/table column.
39
+ *
40
+ * For non-pct formats, always returns true (no effect — the flag is unused for those).
41
+ * For pct formats, returns false when the series contains a value > 1, so callers can
42
+ * format already-percent data like [15, 22, 38] as 15%, 22%, 38% instead of 1500% etc.
43
+ */
44
+ export declare function shouldPctMultiply(format: FormatType | null | undefined, values: Array<number | null | undefined>): boolean;
45
+ /**
46
+ * Pull numeric values out of a data array for one or more field names.
47
+ * Used by charts to sample a y-series for pct auto-detection.
48
+ */
49
+ export declare function collectNumericFieldValues(data: Array<Record<string, unknown>> | undefined, fields: string[]): number[];
26
50
  /**
27
51
  * Infer format type from field name and sample value
28
52
  */
@@ -50,14 +74,14 @@ export declare function parseNumericString(value: string): number | string;
50
74
  * @param currencySymbol - Symbol to use for currency formats (defaults to '$')
51
75
  * @param locale - Locale for number formatting (defaults to 'en-US')
52
76
  */
53
- export declare function formatValue(fmt: FormatType | null, currencySymbol?: string, locale?: string): string;
77
+ export declare function formatValue(fmt: FormatType | null, currencySymbol?: string, locale?: string, pctMultiply?: boolean): string;
54
78
  /**
55
79
  * Generate an ECharts label formatter function based on format type.
56
80
  * Returns a dict with _js_ key containing the formatter function.
57
81
  * @param currencySymbol - Symbol to use for currency formats (defaults to '$')
58
82
  * @param locale - Locale for number formatting (defaults to 'en-US')
59
83
  */
60
- export declare function getLabelFormatterJs(fmt: FormatType | null | undefined, currencySymbol?: string, locale?: string): {
84
+ export declare function getLabelFormatterJs(fmt: FormatType | null | undefined, currencySymbol?: string, locale?: string, pctMultiply?: boolean): {
61
85
  _js_: string;
62
86
  } | null;
63
87
  /**
@@ -65,7 +89,7 @@ export declare function getLabelFormatterJs(fmt: FormatType | null | undefined,
65
89
  * @param currencySymbol - Symbol to use for currency formats (defaults to '$')
66
90
  * @param locale - Locale for number formatting (defaults to 'en-US')
67
91
  */
68
- export declare function getAxisFormatterJs(fmt: FormatType | null | undefined, currencySymbol?: string, locale?: string): {
92
+ export declare function getAxisFormatterJs(fmt: FormatType | null | undefined, currencySymbol?: string, locale?: string, pctMultiply?: boolean): {
69
93
  _js_: string;
70
94
  } | null;
71
95
  //# sourceMappingURL=formatting.d.ts.map
@@ -40,8 +40,11 @@ export function localeFixed(value, decimals, locale) {
40
40
  * Format a number according to the specified format type
41
41
  * @param showSign - If true, prefix positive numbers with +
42
42
  * @param currency - Optional currency config for currency formats (defaults to USD)
43
+ * @param pctMultiply - For pct formats: multiply the value by 100 before formatting.
44
+ * Default true. Set false when the caller has already determined that the series
45
+ * values are in percentage units (via detectPctMultiply).
43
46
  */
44
- export function formatNumber(value, format, showSign = false, currency) {
47
+ export function formatNumber(value, format, showSign = false, currency, pctMultiply = true) {
45
48
  if (value === null || value === undefined || isNaN(value)) {
46
49
  return '';
47
50
  }
@@ -69,10 +72,10 @@ export function formatNumber(value, format, showSign = false, currency) {
69
72
  break;
70
73
  case 'pct':
71
74
  case 'pct1':
72
- result = formatPct1(value);
75
+ result = formatPct1(value, pctMultiply);
73
76
  break;
74
77
  case 'pct0':
75
- result = formatPct0(value);
78
+ result = formatPct0(value, pctMultiply);
76
79
  break;
77
80
  case 'num0':
78
81
  result = formatNum0(value);
@@ -195,17 +198,70 @@ function formatCurrency0b(value, cur) {
195
198
  const formatted = cur.symbol + localeFixed(absValue / 1_000_000_000, 1, cur.locale) + 'b';
196
199
  return isNegative ? `(${formatted})` : formatted;
197
200
  }
201
+ /**
202
+ * Detect whether a series of values needs to be multiplied by 100 for percent formatting.
203
+ * Returns true when the series looks fractional (max |v| <= 1). Returns false when any
204
+ * value exceeds 1, indicating the series is already in percentage units (e.g., [15, 22, 38]).
205
+ *
206
+ * Empty / all-null series default to true (fractional), matching the legacy behavior.
207
+ */
208
+ export function detectPctMultiply(values) {
209
+ let max = 0;
210
+ let sawAny = false;
211
+ for (const v of values) {
212
+ if (v === null || v === undefined || isNaN(v))
213
+ continue;
214
+ sawAny = true;
215
+ const abs = Math.abs(v);
216
+ if (abs > max)
217
+ max = abs;
218
+ }
219
+ if (!sawAny)
220
+ return true;
221
+ return max <= 1;
222
+ }
223
+ /**
224
+ * Series-aware pctMultiply for a chart/table column.
225
+ *
226
+ * For non-pct formats, always returns true (no effect — the flag is unused for those).
227
+ * For pct formats, returns false when the series contains a value > 1, so callers can
228
+ * format already-percent data like [15, 22, 38] as 15%, 22%, 38% instead of 1500% etc.
229
+ */
230
+ export function shouldPctMultiply(format, values) {
231
+ if (format !== 'pct' && format !== 'pct0' && format !== 'pct1')
232
+ return true;
233
+ return detectPctMultiply(values);
234
+ }
235
+ /**
236
+ * Pull numeric values out of a data array for one or more field names.
237
+ * Used by charts to sample a y-series for pct auto-detection.
238
+ */
239
+ export function collectNumericFieldValues(data, fields) {
240
+ const out = [];
241
+ if (!data)
242
+ return out;
243
+ for (const row of data) {
244
+ for (const f of fields) {
245
+ const v = row[f];
246
+ if (typeof v === 'number' && !isNaN(v))
247
+ out.push(v);
248
+ }
249
+ }
250
+ return out;
251
+ }
198
252
  /**
199
253
  * Format as percentage with 1 decimal: 15.0%
200
254
  */
201
- function formatPct1(value) {
202
- return (value * 100).toFixed(1) + '%';
255
+ function formatPct1(value, pctMultiply = true) {
256
+ const scaled = pctMultiply ? value * 100 : value;
257
+ return scaled.toFixed(1) + '%';
203
258
  }
204
259
  /**
205
260
  * Format as percentage with 0 decimals: 15%
206
261
  */
207
- function formatPct0(value) {
208
- return Math.round(value * 100) + '%';
262
+ function formatPct0(value, pctMultiply = true) {
263
+ const scaled = pctMultiply ? value * 100 : value;
264
+ return Math.round(scaled) + '%';
209
265
  }
210
266
  /**
211
267
  * Format as number with 0 decimals: 1,250
@@ -394,8 +450,9 @@ export function parseNumericString(value) {
394
450
  * @param currencySymbol - Symbol to use for currency formats (defaults to '$')
395
451
  * @param locale - Locale for number formatting (defaults to 'en-US')
396
452
  */
397
- export function formatValue(fmt, currencySymbol = '$', locale = 'en-US') {
453
+ export function formatValue(fmt, currencySymbol = '$', locale = 'en-US', pctMultiply = true) {
398
454
  const locStr = JSON.stringify(locale);
455
+ const pctExpr = pctMultiply ? '(value * 100)' : 'value';
399
456
  switch (fmt) {
400
457
  case 'currency':
401
458
  return `'${currencySymbol}' + value.toLocaleString(${locStr})`;
@@ -407,9 +464,9 @@ export function formatValue(fmt, currencySymbol = '$', locale = 'en-US') {
407
464
  return `'${currencySymbol}' + (value/1000000000).toLocaleString(${locStr}, {minimumFractionDigits:1, maximumFractionDigits:1}) + 'b'`;
408
465
  case 'pct':
409
466
  case 'pct1':
410
- return "(value * 100).toFixed(1) + '%'";
467
+ return `${pctExpr}.toFixed(1) + '%'`;
411
468
  case 'pct0':
412
- return "(value * 100).toFixed(0) + '%'";
469
+ return `${pctExpr}.toFixed(0) + '%'`;
413
470
  case 'num0':
414
471
  return "value.toLocaleString()";
415
472
  case 'num1':
@@ -430,7 +487,7 @@ export function formatValue(fmt, currencySymbol = '$', locale = 'en-US') {
430
487
  * @param currencySymbol - Symbol to use for currency formats (defaults to '$')
431
488
  * @param locale - Locale for number formatting (defaults to 'en-US')
432
489
  */
433
- export function getLabelFormatterJs(fmt, currencySymbol = '$', locale = 'en-US') {
490
+ export function getLabelFormatterJs(fmt, currencySymbol = '$', locale = 'en-US', pctMultiply = true) {
434
491
  if (fmt === null || fmt === undefined) {
435
492
  return null;
436
493
  }
@@ -473,7 +530,7 @@ export function getLabelFormatterJs(fmt, currencySymbol = '$', locale = 'en-US')
473
530
  };
474
531
  }
475
532
  // Get the formatting expression for specific formats
476
- const expr = formatValue(fmt, currencySymbol, locale);
533
+ const expr = formatValue(fmt, currencySymbol, locale, pctMultiply);
477
534
  if (expr === 'value') {
478
535
  return null;
479
536
  }
@@ -488,7 +545,7 @@ export function getLabelFormatterJs(fmt, currencySymbol = '$', locale = 'en-US')
488
545
  * @param currencySymbol - Symbol to use for currency formats (defaults to '$')
489
546
  * @param locale - Locale for number formatting (defaults to 'en-US')
490
547
  */
491
- export function getAxisFormatterJs(fmt, currencySymbol = '$', locale = 'en-US') {
548
+ export function getAxisFormatterJs(fmt, currencySymbol = '$', locale = 'en-US', pctMultiply = true) {
492
549
  if (fmt === null || fmt === undefined) {
493
550
  return null;
494
551
  }
@@ -526,19 +583,26 @@ export function getAxisFormatterJs(fmt, currencySymbol = '$', locale = 'en-US')
526
583
  }`,
527
584
  };
528
585
  }
529
- // For percentage, multiply by 100
586
+ // For percentage, multiply by 100 unless the caller signals the series is already in
587
+ // percent units (pctMultiply === false).
530
588
  if (fmt === 'pct' || fmt === 'pct1') {
589
+ const body = pctMultiply
590
+ ? `return (value * 100).toFixed(1) + '%';`
591
+ : `return value.toFixed(1) + '%';`;
531
592
  return {
532
- _js_: `function(value) { return (value * 100).toFixed(1) + '%'; }`,
593
+ _js_: `function(value) { ${body} }`,
533
594
  };
534
595
  }
535
596
  if (fmt === 'pct0') {
597
+ const body = pctMultiply
598
+ ? `return (value * 100).toFixed(0) + '%';`
599
+ : `return value.toFixed(0) + '%';`;
536
600
  return {
537
- _js_: `function(value) { return (value * 100).toFixed(0) + '%'; }`,
601
+ _js_: `function(value) { ${body} }`,
538
602
  };
539
603
  }
540
604
  // Get the formatting expression for specific formats
541
- const expr = formatValue(fmt, currencySymbol, locale);
605
+ const expr = formatValue(fmt, currencySymbol, locale, pctMultiply);
542
606
  if (expr === 'value') {
543
607
  return null;
544
608
  }
@@ -4,7 +4,7 @@
4
4
  * Rules register themselves at module load time. The linter
5
5
  * calls executeLintRules() to run all applicable rules.
6
6
  */
7
- import type { LintContext, LintExecutionResult, LintRuleDefinition } from './types.js';
7
+ import type { LintContext, LintExecutionMode, LintExecutionResult, LintRuleDefinition } from './types.js';
8
8
  /**
9
9
  * Register a lint rule in the global registry.
10
10
  * Called at module load time by rule files.
@@ -14,9 +14,11 @@ export declare function registerLintRule(definition: LintRuleDefinition): void;
14
14
  * Execute all applicable lint rules for a given context.
15
15
  *
16
16
  * @param context - The lint context with spec, type, data, etc.
17
+ * @param mode - 'generate' (default) skips rules flagged `lintOnly: true`;
18
+ * 'lint' runs every applicable rule.
17
19
  * @returns Execution result with errors, warnings, and validity
18
20
  */
19
- export declare function executeLintRules(context: LintContext): LintExecutionResult;
21
+ export declare function executeLintRules(context: LintContext, mode?: LintExecutionMode): LintExecutionResult;
20
22
  /**
21
23
  * Get all registered rules (for testing/debugging)
22
24
  */