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.
- package/README.md +8 -8
- package/dist/charts/area.js +8 -36
- package/dist/charts/bar.js +8 -26
- package/dist/charts/bubble.js +9 -7
- package/dist/charts/combo.js +17 -39
- package/dist/charts/dumbbell.js +15 -14
- package/dist/charts/funnel.js +12 -7
- package/dist/charts/heatmap.js +8 -6
- package/dist/charts/line.js +6 -27
- package/dist/charts/scatter.js +7 -5
- package/dist/cli.js +4 -3
- package/dist/components/big_value.d.ts +23 -1
- package/dist/components/big_value.js +84 -25
- package/dist/components/delta.d.ts +24 -1
- package/dist/components/delta.js +63 -17
- package/dist/components/table-interactivity.d.ts +69 -0
- package/dist/components/table-interactivity.js +216 -0
- package/dist/components/table.d.ts +6 -1
- package/dist/components/table.js +53 -12
- package/dist/core/chart-helpers.d.ts +59 -5
- package/dist/core/chart-helpers.js +84 -5
- package/dist/core/formatting.d.ts +61 -4
- package/dist/core/formatting.js +216 -17
- package/dist/core/lint-rules/registry.d.ts +4 -2
- package/dist/core/lint-rules/registry.js +6 -1
- package/dist/core/lint-rules/rules/index.d.ts +1 -0
- package/dist/core/lint-rules/rules/index.js +1 -0
- package/dist/core/lint-rules/rules/pct-scalar-gt-one.d.ts +13 -0
- package/dist/core/lint-rules/rules/pct-scalar-gt-one.js +46 -0
- package/dist/core/lint-rules/types.d.ts +12 -0
- package/dist/core/linter.d.ts +10 -2
- package/dist/core/linter.js +60 -12
- package/dist/layout/block-loader.d.ts +31 -0
- package/dist/layout/block-loader.js +143 -0
- package/dist/layout/layout-resolver.d.ts +33 -0
- package/dist/layout/layout-resolver.js +73 -0
- package/dist/layout/markdown-parser.d.ts +34 -0
- package/dist/layout/markdown-parser.js +395 -0
- package/dist/layout/parser-types.d.ts +116 -0
- package/dist/layout/parser-types.js +11 -0
- package/dist/layout/parser.d.ts +31 -22
- package/dist/layout/parser.js +118 -1006
- package/dist/layout/renderer.d.ts +33 -0
- package/dist/layout/renderer.js +450 -0
- package/dist/types.d.ts +1 -1
- package/package.json +6 -6
- 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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
38
|
+
const comparisonSpec = spec.comparison;
|
|
39
|
+
let comparison;
|
|
40
|
+
if (comparisonSpec) {
|
|
41
|
+
const compVal = comparisonSpec.value ?? 0;
|
|
35
42
|
const isPositive = compVal >= 0;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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, '&')
|
|
55
|
+
.replace(/</g, '<')
|
|
56
|
+
.replace(/>/g, '>')
|
|
57
|
+
.replace(/"/g, '"')
|
|
58
|
+
.replace(/'/g, ''');
|
|
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: ${
|
|
42
|
-
<span style="color: ${colors.textSecondary}; font-size: 12px;">${
|
|
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
|
|
46
|
-
|
|
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
|
-
*
|
|
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 };
|
package/dist/components/delta.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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 =
|
|
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
|
-
|
|
46
|
-
|
|
50
|
+
return { label, title, showTitleHeader, arrow, color, isNeutral, displayValue };
|
|
51
|
+
}
|
|
52
|
+
function escapeHtml(text) {
|
|
53
|
+
return text
|
|
54
|
+
.replace(/&/g, '&')
|
|
55
|
+
.replace(/</g, '<')
|
|
56
|
+
.replace(/>/g, '>')
|
|
57
|
+
.replace(/"/g, '"')
|
|
58
|
+
.replace(/'/g, ''');
|
|
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, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
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
|