mviz 1.6.4 → 1.6.7

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.
Files changed (47) hide show
  1. package/README.md +8 -8
  2. package/dist/charts/area.js +8 -36
  3. package/dist/charts/bar.js +8 -26
  4. package/dist/charts/bubble.js +9 -7
  5. package/dist/charts/combo.js +17 -39
  6. package/dist/charts/dumbbell.js +15 -14
  7. package/dist/charts/funnel.js +12 -7
  8. package/dist/charts/heatmap.js +8 -6
  9. package/dist/charts/line.js +6 -27
  10. package/dist/charts/scatter.js +7 -5
  11. package/dist/cli.js +4 -3
  12. package/dist/components/big_value.d.ts +23 -1
  13. package/dist/components/big_value.js +84 -25
  14. package/dist/components/delta.d.ts +24 -1
  15. package/dist/components/delta.js +63 -17
  16. package/dist/components/table-interactivity.d.ts +69 -0
  17. package/dist/components/table-interactivity.js +216 -0
  18. package/dist/components/table.d.ts +6 -1
  19. package/dist/components/table.js +53 -12
  20. package/dist/core/chart-helpers.d.ts +59 -5
  21. package/dist/core/chart-helpers.js +84 -5
  22. package/dist/core/formatting.d.ts +61 -4
  23. package/dist/core/formatting.js +216 -17
  24. package/dist/core/lint-rules/registry.d.ts +4 -2
  25. package/dist/core/lint-rules/registry.js +6 -1
  26. package/dist/core/lint-rules/rules/index.d.ts +1 -0
  27. package/dist/core/lint-rules/rules/index.js +1 -0
  28. package/dist/core/lint-rules/rules/pct-scalar-gt-one.d.ts +13 -0
  29. package/dist/core/lint-rules/rules/pct-scalar-gt-one.js +46 -0
  30. package/dist/core/lint-rules/types.d.ts +12 -0
  31. package/dist/core/linter.d.ts +10 -2
  32. package/dist/core/linter.js +60 -12
  33. package/dist/layout/block-loader.d.ts +31 -0
  34. package/dist/layout/block-loader.js +143 -0
  35. package/dist/layout/layout-resolver.d.ts +33 -0
  36. package/dist/layout/layout-resolver.js +73 -0
  37. package/dist/layout/markdown-parser.d.ts +34 -0
  38. package/dist/layout/markdown-parser.js +395 -0
  39. package/dist/layout/parser-types.d.ts +116 -0
  40. package/dist/layout/parser-types.js +11 -0
  41. package/dist/layout/parser.d.ts +31 -22
  42. package/dist/layout/parser.js +118 -1006
  43. package/dist/layout/renderer.d.ts +33 -0
  44. package/dist/layout/renderer.js +450 -0
  45. package/dist/types.d.ts +1 -1
  46. package/package.json +6 -6
  47. package/schema/mviz.v1.schema.json +402 -33
package/dist/cli.js CHANGED
@@ -177,22 +177,23 @@ async function main() {
177
177
  let errors = [];
178
178
  // Detect input type
179
179
  const trimmed = input.trim();
180
+ const lintMode = lintOnly ? 'lint' : 'generate';
180
181
  if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
181
182
  // JSON input - lint then generate
182
183
  const spec = JSON.parse(trimmed);
183
- lintSpec(spec);
184
+ lintSpec(spec, lintMode);
184
185
  html = await generateChartAsync(spec);
185
186
  }
186
187
  else if (trimmed.startsWith('---') || trimmed.includes('```')) {
187
188
  // Markdown input - parser handles linting internally (async for mermaid)
188
- const result = await parseMarkdownToDashboardAsync(input, 'light', baseDir, false, false, customTheme);
189
+ const result = await parseMarkdownToDashboardAsync(input, 'light', baseDir, false, false, customTheme, lintMode);
189
190
  html = result.html;
190
191
  errors = result.errors;
191
192
  }
192
193
  else {
193
194
  // Try JSON anyway - lint then generate
194
195
  const spec = JSON.parse(trimmed);
195
- lintSpec(spec);
196
+ lintSpec(spec, lintMode);
196
197
  html = await generateChartAsync(spec);
197
198
  }
198
199
  // Check for errors (unless --allow-errors is set)
@@ -1,9 +1,31 @@
1
1
  /**
2
2
  * Big value display component
3
+ *
4
+ * Provides two rendering paths that share the same business rules:
5
+ * - generateBigValue(spec) – standalone HTML document
6
+ * - renderBigValue(spec, ctx) – dashboard grid-item fragment
7
+ *
8
+ * Validation (value must be numeric) and format inference live in the shared
9
+ * resolveBigValueState() helper so the two paths cannot drift.
3
10
  */
4
11
  import type { ComponentSpec } from '../types.js';
5
12
  /**
6
- * Generate a big value display for key metrics
13
+ * Optional dashboard rendering context.
14
+ */
15
+ export interface BigValueRenderContext {
16
+ colSpan?: number | undefined;
17
+ anchorId?: string | undefined;
18
+ /** Currency code from frontmatter; falls back to spec.currency */
19
+ currencyCode?: string | undefined;
20
+ }
21
+ /**
22
+ * Render a big_value as a dashboard grid-item fragment.
23
+ *
24
+ * Throws ValidationError on non-numeric `value` (matches generateBigValue).
25
+ */
26
+ export declare function renderBigValue(spec: Record<string, unknown>, ctx?: BigValueRenderContext): string;
27
+ /**
28
+ * Generate a standalone big_value HTML document.
7
29
  */
8
30
  declare function generateBigValue(spec: ComponentSpec): string;
9
31
  export { generateBigValue };
@@ -1,54 +1,113 @@
1
1
  /**
2
2
  * Big value display component
3
+ *
4
+ * Provides two rendering paths that share the same business rules:
5
+ * - generateBigValue(spec) – standalone HTML document
6
+ * - renderBigValue(spec, ctx) – dashboard grid-item fragment
7
+ *
8
+ * Validation (value must be numeric) and format inference live in the shared
9
+ * resolveBigValueState() helper so the two paths cannot drift.
3
10
  */
4
11
  import { COLORS, FONT_STACK, getThemeColors } from '../core/themes.js';
5
12
  import { formatNumber, inferFormat, resolveCurrency } from '../core/formatting.js';
6
13
  import { registerComponent } from './registry.js';
7
14
  import { ValidationError } from '../core/exceptions.js';
8
15
  /**
9
- * Generate a big value display for key metrics
16
+ * Resolve the display state for a big_value spec.
17
+ *
18
+ * Throws ValidationError if `value` is not a number — this is intentional and
19
+ * matches the strict standalone behavior (the linter's `big-value-string` rule
20
+ * catches string values for spec authors; this throw is defensive for
21
+ * programmatic callers).
10
22
  */
11
- function generateBigValue(spec) {
23
+ function resolveBigValueState(spec, currencyCode) {
12
24
  if (typeof spec.value !== 'number') {
13
25
  throw new ValidationError('value', 'big_value', 'number', spec.value);
14
26
  }
15
27
  const value = spec.value;
16
28
  const specLabel = spec.label;
17
29
  const specTitle = spec.title;
18
- const theme = (spec.theme ?? 'light');
19
- const comparison = spec.comparison;
20
- // If only title is provided (no label), use title as the label below the number
21
- // This matches user expectations that "title" describes what the number represents
30
+ // If only title is provided (no label), use title as the label below the number.
31
+ // If both provided, title becomes header and label goes below.
22
32
  const label = specLabel ?? specTitle ?? '';
23
- // Only show H2 header if BOTH title and label are explicitly provided
24
- const showTitleHeader = specTitle && specLabel;
33
+ const showTitleHeader = Boolean(specTitle && specLabel);
25
34
  const title = showTitleHeader ? specTitle : '';
26
- // Auto-infer format from label if not specified
27
35
  const fmt = spec.format ?? inferFormat(label, value);
28
- const currency = resolveCurrency(spec.currency);
29
- const colors = getThemeColors(theme);
36
+ const currency = resolveCurrency(currencyCode ?? spec.currency);
30
37
  const displayValue = formatNumber(value, fmt, false, currency);
31
- let comparisonHtml = '';
32
- if (comparison) {
33
- const compVal = comparison.value ?? 0;
34
- const compLabel = comparison.label ?? '';
38
+ const comparisonSpec = spec.comparison;
39
+ let comparison;
40
+ if (comparisonSpec) {
41
+ const compVal = comparisonSpec.value ?? 0;
35
42
  const isPositive = compVal >= 0;
36
- const arrow = isPositive ? '↑' : '↓';
37
- const compColor = isPositive ? COLORS.POSITIVE_GREEN : COLORS.ERROR_RED;
38
- const compDisplay = formatNumber(compVal, comparison.format, true, currency);
43
+ comparison = {
44
+ arrow: isPositive ? '↑' : '↓',
45
+ color: isPositive ? COLORS.POSITIVE_GREEN : COLORS.ERROR_RED,
46
+ display: formatNumber(compVal, comparisonSpec.format, true, currency),
47
+ label: comparisonSpec.label ?? '',
48
+ };
49
+ }
50
+ return { value, label, title, showTitleHeader, displayValue, comparison };
51
+ }
52
+ function escapeHtml(text) {
53
+ return text
54
+ .replace(/&/g, '&amp;')
55
+ .replace(/</g, '&lt;')
56
+ .replace(/>/g, '&gt;')
57
+ .replace(/"/g, '&quot;')
58
+ .replace(/'/g, '&#39;');
59
+ }
60
+ /**
61
+ * Render a big_value as a dashboard grid-item fragment.
62
+ *
63
+ * Throws ValidationError on non-numeric `value` (matches generateBigValue).
64
+ */
65
+ export function renderBigValue(spec, ctx = {}) {
66
+ const state = resolveBigValueState(spec, ctx.currencyCode);
67
+ const colSpan = ctx.colSpan ?? 4;
68
+ const anchorAttr = ctx.anchorId ? ` id="${ctx.anchorId}"` : '';
69
+ let comparisonHtml = '';
70
+ if (state.comparison) {
71
+ const c = state.comparison;
72
+ comparisonHtml = `
73
+ <div style="display: flex; align-items: center; gap: 4px; margin-top: 4px;">
74
+ <span style="color: ${c.color}; font-size: 12px;">${c.arrow} ${c.display}</span>
75
+ <span style="color: var(--text-muted); font-size: 10px;">${escapeHtml(c.label)}</span>
76
+ </div>`;
77
+ }
78
+ const titleHtml = state.showTitleHeader
79
+ ? `<h3 class="chart-title">${escapeHtml(state.title.toUpperCase())}</h3>`
80
+ : '';
81
+ return `
82
+ <div class="grid-item"${anchorAttr} style="--col-span: ${colSpan}; grid-column: span ${colSpan};">
83
+ ${titleHtml}<div class="big-value">${escapeHtml(state.displayValue)}</div>
84
+ <div class="label">${escapeHtml(state.label)}</div>${comparisonHtml}
85
+ </div>`;
86
+ }
87
+ /**
88
+ * Generate a standalone big_value HTML document.
89
+ */
90
+ function generateBigValue(spec) {
91
+ const theme = (spec.theme ?? 'light');
92
+ const state = resolveBigValueState(spec);
93
+ const colors = getThemeColors(theme);
94
+ let comparisonHtml = '';
95
+ if (state.comparison) {
96
+ const c = state.comparison;
39
97
  comparisonHtml = `
40
98
  <div style="display: flex; align-items: center; gap: 4px; margin-top: 8px;">
41
- <span style="color: ${compColor}; font-size: 14px;">${arrow} ${compDisplay}</span>
42
- <span style="color: ${colors.textSecondary}; font-size: 12px;">${compLabel}</span>
99
+ <span style="color: ${c.color}; font-size: 14px;">${c.arrow} ${c.display}</span>
100
+ <span style="color: ${colors.textSecondary}; font-size: 12px;">${c.label}</span>
43
101
  </div>`;
44
102
  }
45
- const titleUpper = title.toUpperCase();
46
- const titleHtml = title ? `<h2>${titleUpper}</h2>` : '';
103
+ const titleHtml = state.showTitleHeader
104
+ ? `<h2>${state.title.toUpperCase()}</h2>`
105
+ : '';
47
106
  return `<!DOCTYPE html>
48
107
  <html lang="en">
49
108
  <head>
50
109
  <meta charset="utf-8">
51
- <title>${title}</title>
110
+ <title>${state.title}</title>
52
111
  <style>
53
112
  * { box-sizing: border-box; }
54
113
  html, body {
@@ -70,8 +129,8 @@ function generateBigValue(spec) {
70
129
  <body>
71
130
  <div class="container">
72
131
  ${titleHtml}
73
- <div class="big-value">${displayValue}</div>
74
- <div class="label">${label}</div>
132
+ <div class="big-value">${state.displayValue}</div>
133
+ <div class="label">${state.label}</div>
75
134
  ${comparisonHtml}
76
135
  </div>
77
136
  </body>
@@ -1,9 +1,32 @@
1
1
  /**
2
2
  * Delta/change indicator component
3
+ *
4
+ * Provides two rendering paths that share the same business rules:
5
+ * - generateDelta(spec) – standalone HTML document
6
+ * - renderDelta(spec, ctx) – dashboard grid-item fragment
7
+ *
8
+ * Direction/color resolution (including the neutral case) lives in the shared
9
+ * resolveDeltaState() helper so the two paths cannot drift.
3
10
  */
4
11
  import type { ComponentSpec } from '../types.js';
5
12
  /**
6
- * Generate a delta/change indicator
13
+ * Optional dashboard rendering context.
14
+ */
15
+ export interface DeltaRenderContext {
16
+ colSpan?: number | undefined;
17
+ anchorId?: string | undefined;
18
+ /** Currency code from frontmatter; falls back to spec.currency */
19
+ currencyCode?: string | undefined;
20
+ }
21
+ /**
22
+ * Render a delta as a dashboard grid-item fragment.
23
+ *
24
+ * Uses the dashboard's CSS `--text-muted` token for the neutral color so the
25
+ * value blends with theme-aware muted text.
26
+ */
27
+ export declare function renderDelta(spec: Record<string, unknown>, ctx?: DeltaRenderContext): string;
28
+ /**
29
+ * Generate a standalone delta HTML document.
7
30
  */
8
31
  declare function generateDelta(spec: ComponentSpec): string;
9
32
  export { generateDelta };
@@ -1,36 +1,41 @@
1
1
  /**
2
2
  * Delta/change indicator component
3
+ *
4
+ * Provides two rendering paths that share the same business rules:
5
+ * - generateDelta(spec) – standalone HTML document
6
+ * - renderDelta(spec, ctx) – dashboard grid-item fragment
7
+ *
8
+ * Direction/color resolution (including the neutral case) lives in the shared
9
+ * resolveDeltaState() helper so the two paths cannot drift.
3
10
  */
4
11
  import { COLORS, FONT_STACK, getThemeColors } from '../core/themes.js';
5
12
  import { formatNumber, inferFormat, resolveCurrency } from '../core/formatting.js';
6
13
  import { registerComponent } from './registry.js';
7
14
  /**
8
- * Generate a delta/change indicator
15
+ * Resolve the display state for a delta spec.
16
+ *
17
+ * Neutral handling: when `value === neutralIs` (default 0), the indicator
18
+ * uses a right-arrow and a muted color — never green/red. This matches user
19
+ * expectations that "no change" is not a positive or negative signal.
9
20
  */
10
- function generateDelta(spec) {
21
+ function resolveDeltaState(spec, neutralColor, currencyCode) {
11
22
  const value = typeof spec.value === 'number' ? spec.value : 0;
12
23
  const specLabel = spec.label;
13
24
  const specTitle = spec.title;
14
- const theme = (spec.theme ?? 'light');
15
25
  const neutralIs = spec.neutralIs ?? 0;
16
26
  const positiveIsGood = spec.positiveIsGood !== false; // Default true
17
- // If only title is provided (no label), use title as the label below
18
- // If both provided, title becomes H2 header and label goes below
19
27
  const label = specLabel ?? specTitle ?? '';
20
- const showTitleHeader = specTitle && specLabel;
28
+ const showTitleHeader = Boolean(specTitle && specLabel);
21
29
  const title = showTitleHeader ? specTitle : '';
22
- // Auto-infer format from label if not specified
23
30
  const fmt = spec.format ?? inferFormat(label, value);
24
- const currency = resolveCurrency(spec.currency);
25
- const colors = getThemeColors(theme);
26
- // Determine direction and color
31
+ const currency = resolveCurrency(currencyCode ?? spec.currency);
27
32
  const isPositive = value > neutralIs;
28
33
  const isNegative = value < neutralIs;
29
34
  const isNeutral = value === neutralIs;
30
35
  let color;
31
36
  let arrow;
32
37
  if (isNeutral) {
33
- color = colors.textSecondary;
38
+ color = neutralColor;
34
39
  arrow = '→';
35
40
  }
36
41
  else if ((isPositive && positiveIsGood) || (isNegative && !positiveIsGood)) {
@@ -42,13 +47,54 @@ function generateDelta(spec) {
42
47
  arrow = isPositive ? '↑' : '↓';
43
48
  }
44
49
  const displayValue = formatNumber(value, fmt, true, currency);
45
- const titleUpper = title.toUpperCase();
46
- const titleHtml = title ? `<h2>${titleUpper}</h2>` : '';
50
+ return { label, title, showTitleHeader, arrow, color, isNeutral, displayValue };
51
+ }
52
+ function escapeHtml(text) {
53
+ return text
54
+ .replace(/&/g, '&amp;')
55
+ .replace(/</g, '&lt;')
56
+ .replace(/>/g, '&gt;')
57
+ .replace(/"/g, '&quot;')
58
+ .replace(/'/g, '&#39;');
59
+ }
60
+ /**
61
+ * Render a delta as a dashboard grid-item fragment.
62
+ *
63
+ * Uses the dashboard's CSS `--text-muted` token for the neutral color so the
64
+ * value blends with theme-aware muted text.
65
+ */
66
+ export function renderDelta(spec, ctx = {}) {
67
+ // Dashboard uses a CSS variable for the muted color so theme switching works.
68
+ const state = resolveDeltaState(spec, 'var(--text-muted)', ctx.currencyCode);
69
+ const colSpan = ctx.colSpan ?? 4;
70
+ const anchorAttr = ctx.anchorId ? ` id="${ctx.anchorId}"` : '';
71
+ const titleHtml = state.showTitleHeader
72
+ ? `<h3 class="chart-title">${escapeHtml(state.title.toUpperCase())}</h3>`
73
+ : '';
74
+ return `
75
+ <div class="grid-item"${anchorAttr} style="--col-span: ${colSpan}; grid-column: span ${colSpan};">
76
+ ${titleHtml}<div class="delta">
77
+ <span class="arrow" style="color: ${state.color};">${state.arrow}</span>
78
+ <span class="value" style="color: ${state.color};">${escapeHtml(state.displayValue)}</span>
79
+ </div>
80
+ <div class="label">${escapeHtml(state.label)}</div>
81
+ </div>`;
82
+ }
83
+ /**
84
+ * Generate a standalone delta HTML document.
85
+ */
86
+ function generateDelta(spec) {
87
+ const theme = (spec.theme ?? 'light');
88
+ const colors = getThemeColors(theme);
89
+ const state = resolveDeltaState(spec, colors.textSecondary);
90
+ const titleHtml = state.showTitleHeader
91
+ ? `<h2>${state.title.toUpperCase()}</h2>`
92
+ : '';
47
93
  return `<!DOCTYPE html>
48
94
  <html lang="en">
49
95
  <head>
50
96
  <meta charset="utf-8">
51
- <title>${title}</title>
97
+ <title>${state.title}</title>
52
98
  <style>
53
99
  * { box-sizing: border-box; }
54
100
  html, body {
@@ -70,10 +116,10 @@ function generateDelta(spec) {
70
116
  <div class="container">
71
117
  ${titleHtml}
72
118
  <div class="delta">
73
- <span class="arrow" style="color: ${color};">${arrow}</span>
74
- <span class="value" style="color: ${color};">${displayValue}</span>
119
+ <span class="arrow" style="color: ${state.color};">${state.arrow}</span>
120
+ <span class="value" style="color: ${state.color};">${state.displayValue}</span>
75
121
  </div>
76
- <div class="label">${label}</div>
122
+ <div class="label">${state.label}</div>
77
123
  </div>
78
124
  </body>
79
125
  </html>`;
@@ -0,0 +1,69 @@
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
+ import type { FormatType } from '../types.js';
10
+ export interface TableInteractivity {
11
+ sortable: boolean;
12
+ filter: boolean;
13
+ }
14
+ /**
15
+ * Read sortable/filter options from a spec. Sort defaults ON, filter defaults OFF.
16
+ */
17
+ export declare function parseTableInteractivity(spec: Record<string, unknown>): TableInteractivity;
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 declare function sortableHeaderAttrs(colIdx: number, sortable: boolean): string;
23
+ /**
24
+ * Generate `data-sort="..."` for a cell so numeric sort doesn't depend on the
25
+ * rendered text (which may be formatted, contain SVG sparklines, etc.).
26
+ *
27
+ * When the column declares a `format`, the sort key is derived from that
28
+ * declaration — `date` → epoch ms, `duration` → seconds, numeric formats →
29
+ * raw number. This is the preferred path because format removes ambiguity
30
+ * (e.g. is `"5m"` 5 minutes or 5 million dollars?).
31
+ *
32
+ * For untyped columns, two unambiguous string shapes still get a numeric
33
+ * key as a courtesy: ISO-8601 dates (`YYYY-MM-DD`) and duration strings
34
+ * that include a seconds segment (`10s`, `1m7s`, `1h2m3s`). Anything else —
35
+ * including bare `5m` or `1h`, which is ambiguous (5 minutes or 5 million?) —
36
+ * falls back to locale string sort. Columns that explicitly declare
37
+ * `fmt: "duration"` still get lenient parsing of `5m`/`1h`; the strict form
38
+ * only applies to the untyped courtesy path. Pre-formatted currency strings
39
+ * are intentionally not parsed — pass numeric values and declare
40
+ * `format: "currency_auto"` (etc.) instead.
41
+ */
42
+ export declare function cellSortAttr(value: unknown, format?: FormatType): string;
43
+ /**
44
+ * Build the filter toolbar (global search input when `filter: true`).
45
+ */
46
+ export interface TableChrome {
47
+ toolbarHtml: string;
48
+ }
49
+ export declare function buildTableChrome(tableId: string, interactivity: TableInteractivity, colors: {
50
+ text: string;
51
+ textSecondary: string;
52
+ border: string;
53
+ background: string;
54
+ }): TableChrome;
55
+ /**
56
+ * Per-table init call. Invoked after the HTML is in the DOM.
57
+ */
58
+ export declare function tableInitCall(tableId: string): string;
59
+ /**
60
+ * Shared JS that defines `window.mvizTableInit`. Include once per output document.
61
+ * Responsible for sort clicks and the global filter input.
62
+ */
63
+ export declare function sharedTableScript(): string;
64
+ /**
65
+ * Shared CSS for sort indicators. Appended once per standalone document or
66
+ * injected once into dashboard mode alongside the shared script.
67
+ */
68
+ export declare function sharedTableCss(): string;
69
+ //# sourceMappingURL=table-interactivity.d.ts.map
@@ -0,0 +1,216 @@
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
+ import { formatSortKey, parseDurationString, parseIsoDateString, } from '../core/formatting.js';
10
+ /**
11
+ * Read sortable/filter options from a spec. Sort defaults ON, filter defaults OFF.
12
+ */
13
+ export function parseTableInteractivity(spec) {
14
+ return {
15
+ sortable: spec.sortable !== false,
16
+ filter: spec.filter === true,
17
+ };
18
+ }
19
+ /**
20
+ * Generate an HTML attribute string for a sortable `<th>`.
21
+ * `colIdx` maps to the visual column index (including the row-number column if present).
22
+ */
23
+ export function sortableHeaderAttrs(colIdx, sortable) {
24
+ if (!sortable)
25
+ return '';
26
+ return ` class="sortable" data-col="${colIdx}"`;
27
+ }
28
+ /**
29
+ * Generate `data-sort="..."` for a cell so numeric sort doesn't depend on the
30
+ * rendered text (which may be formatted, contain SVG sparklines, etc.).
31
+ *
32
+ * When the column declares a `format`, the sort key is derived from that
33
+ * declaration — `date` → epoch ms, `duration` → seconds, numeric formats →
34
+ * raw number. This is the preferred path because format removes ambiguity
35
+ * (e.g. is `"5m"` 5 minutes or 5 million dollars?).
36
+ *
37
+ * For untyped columns, two unambiguous string shapes still get a numeric
38
+ * key as a courtesy: ISO-8601 dates (`YYYY-MM-DD`) and duration strings
39
+ * that include a seconds segment (`10s`, `1m7s`, `1h2m3s`). Anything else —
40
+ * including bare `5m` or `1h`, which is ambiguous (5 minutes or 5 million?) —
41
+ * falls back to locale string sort. Columns that explicitly declare
42
+ * `fmt: "duration"` still get lenient parsing of `5m`/`1h`; the strict form
43
+ * only applies to the untyped courtesy path. Pre-formatted currency strings
44
+ * are intentionally not parsed — pass numeric values and declare
45
+ * `format: "currency_auto"` (etc.) instead.
46
+ */
47
+ export function cellSortAttr(value, format) {
48
+ if (value === null || value === undefined)
49
+ return '';
50
+ // Format-driven sort (preferred when declared).
51
+ if (format) {
52
+ const key = formatSortKey(value, format);
53
+ if (key !== null)
54
+ return ` data-sort="${key}"`;
55
+ // Format declared but value can't be coerced — fall through to the
56
+ // untyped heuristics so sort still does *something* sensible.
57
+ }
58
+ if (typeof value === 'number' && !isNaN(value)) {
59
+ return ` data-sort="${value}"`;
60
+ }
61
+ if (typeof value === 'string') {
62
+ const n = parseFloat(value);
63
+ if (!isNaN(n) && String(n) === value.trim()) {
64
+ return ` data-sort="${n}"`;
65
+ }
66
+ const dateKey = parseIsoDateString(value);
67
+ if (dateKey !== null)
68
+ return ` data-sort="${dateKey}"`;
69
+ // Strict: require a seconds segment so bare "5m" / "1h" don't get parsed
70
+ // as durations. Those shapes are ambiguous in an untyped column; declare
71
+ // `fmt: "duration"` to opt into lenient parsing.
72
+ const durKey = parseDurationString(value, { requireSeconds: true });
73
+ if (durKey !== null)
74
+ return ` data-sort="${durKey}"`;
75
+ return ` data-sort="${escapeAttr(value)}"`;
76
+ }
77
+ // Objects/arrays (e.g., sparkline data) are not sortable; omit the attribute.
78
+ return '';
79
+ }
80
+ function escapeAttr(s) {
81
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
82
+ }
83
+ export function buildTableChrome(tableId, interactivity, colors) {
84
+ if (!interactivity.filter) {
85
+ return { toolbarHtml: '' };
86
+ }
87
+ return {
88
+ toolbarHtml: `<div class="mviz-table-toolbar" style="margin-bottom: 6px;">` +
89
+ `<input type="search" class="mviz-filter-global" data-table="${tableId}" placeholder="Filter by any value…" ` +
90
+ `style="font-family: inherit; font-size: 11px; padding: 4px 8px; width: 240px; max-width: 100%; ` +
91
+ `border: 1px solid ${colors.border}; border-radius: 3px; background: ${colors.background}; color: ${colors.text};" />` +
92
+ `</div>`,
93
+ };
94
+ }
95
+ /**
96
+ * Per-table init call. Invoked after the HTML is in the DOM.
97
+ */
98
+ export function tableInitCall(tableId) {
99
+ return `if (window.mvizTableInit) window.mvizTableInit(${JSON.stringify(tableId)});`;
100
+ }
101
+ /**
102
+ * Shared JS that defines `window.mvizTableInit`. Include once per output document.
103
+ * Responsible for sort clicks and the global filter input.
104
+ */
105
+ export function sharedTableScript() {
106
+ return `(function() {
107
+ if (window.mvizTableInit) return;
108
+ function compareCells(a, b) {
109
+ var an = parseFloat(a), bn = parseFloat(b);
110
+ if (!isNaN(an) && !isNaN(bn)) return an - bn;
111
+ return String(a).localeCompare(String(b));
112
+ }
113
+ function getSortKey(cell) {
114
+ var v = cell.getAttribute('data-sort');
115
+ return v != null ? v : cell.textContent.trim();
116
+ }
117
+ window.mvizTableInit = function(tableId) {
118
+ var table = document.getElementById(tableId);
119
+ if (!table) return;
120
+ var thead = table.tHead;
121
+ var tbody = table.tBodies[0];
122
+ if (!thead || !tbody) return;
123
+ var origRows = Array.prototype.slice.call(tbody.rows);
124
+ var visibility = {};
125
+ origRows.forEach(function(r, i) { visibility[i] = true; });
126
+
127
+ function applyVisibility() {
128
+ origRows.forEach(function(r, i) { r.style.display = visibility[i] ? '' : 'none'; });
129
+ }
130
+
131
+ // ---- Sort ----
132
+ // Shared active sort state (held at the table level, not per-header) so that
133
+ // clicking header A, then B, then A again always starts A at ascending. A
134
+ // per-header closure variable would resume A from its previous state even
135
+ // though the indicator was cleared, producing a confusing cycle.
136
+ var sortableHeaders = thead.querySelectorAll('th.sortable');
137
+ var activeTh = null;
138
+ var activeState = 0; // 0 none, 1 asc, -1 desc
139
+ sortableHeaders.forEach(function(th) {
140
+ th.style.cursor = 'pointer';
141
+ th.style.userSelect = 'none';
142
+ th.addEventListener('click', function() {
143
+ var colIdx = parseInt(th.getAttribute('data-col'), 10);
144
+ if (isNaN(colIdx)) return;
145
+ if (activeTh === th) {
146
+ // Same column clicked again — cycle asc → desc → none.
147
+ activeState = activeState === 1 ? -1 : activeState === -1 ? 0 : 1;
148
+ } else {
149
+ // New column — reset the old one, start new one at ascending.
150
+ if (activeTh) {
151
+ activeTh.removeAttribute('data-sort-dir');
152
+ var prevInd = activeTh.querySelector('.mviz-sort-ind');
153
+ if (prevInd) prevInd.textContent = '';
154
+ }
155
+ activeTh = th;
156
+ activeState = 1;
157
+ }
158
+ var ind = th.querySelector('.mviz-sort-ind');
159
+ if (activeState === 0) {
160
+ th.removeAttribute('data-sort-dir');
161
+ if (ind) ind.textContent = '';
162
+ activeTh = null;
163
+ origRows.forEach(function(r) { tbody.appendChild(r); });
164
+ applyVisibility();
165
+ return;
166
+ }
167
+ th.setAttribute('data-sort-dir', activeState === 1 ? 'asc' : 'desc');
168
+ if (ind) ind.textContent = activeState === 1 ? '▲' : '▼';
169
+ var sorted = origRows.slice().sort(function(a, b) {
170
+ var av = getSortKey(a.cells[colIdx]);
171
+ var bv = getSortKey(b.cells[colIdx]);
172
+ var cmp = compareCells(av, bv);
173
+ return activeState === 1 ? cmp : -cmp;
174
+ });
175
+ sorted.forEach(function(r) { tbody.appendChild(r); });
176
+ applyVisibility();
177
+ });
178
+ });
179
+
180
+ // ---- Global filter ----
181
+ var container = table.parentElement;
182
+ var globalInput = container && container.querySelector('.mviz-filter-global[data-table="' + tableId + '"]');
183
+ if (globalInput) {
184
+ globalInput.addEventListener('input', function() {
185
+ var q = globalInput.value.toLowerCase();
186
+ origRows.forEach(function(r, i) {
187
+ visibility[i] = !q || r.textContent.toLowerCase().indexOf(q) !== -1;
188
+ });
189
+ applyVisibility();
190
+ });
191
+ }
192
+ };
193
+ })();`;
194
+ }
195
+ /**
196
+ * Shared CSS for sort indicators. Appended once per standalone document or
197
+ * injected once into dashboard mode alongside the shared script.
198
+ */
199
+ export function sharedTableCss() {
200
+ return `th.sortable { cursor: pointer; }
201
+ th.sortable[data-sort-dir] { color: inherit; }
202
+ /*
203
+ * Reserve the arrow's width always so adding ▲/▼ on sort doesn't widen the
204
+ * column and push the rest of the header row around.
205
+ */
206
+ .mviz-sort-ind {
207
+ display: inline-block;
208
+ min-width: 0.9em;
209
+ margin-left: 2px;
210
+ font-size: 9px;
211
+ opacity: 0.7;
212
+ text-align: left;
213
+ white-space: nowrap;
214
+ }`;
215
+ }
216
+ //# sourceMappingURL=table-interactivity.js.map