mviz 1.6.6 → 1.7.0-pre.0

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 (41) hide show
  1. package/dist/charts/area.js +8 -37
  2. package/dist/charts/bar.js +8 -27
  3. package/dist/charts/combo.js +17 -41
  4. package/dist/charts/line.js +6 -28
  5. package/dist/cli.js +15 -4
  6. package/dist/components/big_value.d.ts +23 -1
  7. package/dist/components/big_value.js +84 -25
  8. package/dist/components/delta.d.ts +24 -1
  9. package/dist/components/delta.js +63 -17
  10. package/dist/components/table-interactivity.d.ts +17 -1
  11. package/dist/components/table-interactivity.js +34 -1
  12. package/dist/components/table.js +1 -1
  13. package/dist/core/chart-helpers.d.ts +59 -5
  14. package/dist/core/chart-helpers.js +84 -5
  15. package/dist/core/formatting.d.ts +33 -0
  16. package/dist/core/formatting.js +135 -0
  17. package/dist/core/fragment.d.ts +28 -0
  18. package/dist/core/fragment.js +658 -0
  19. package/dist/core/linter.d.ts +3 -0
  20. package/dist/core/linter.js +110 -47
  21. package/dist/core/serializer.d.ts +3 -1
  22. package/dist/core/serializer.js +3 -2
  23. package/dist/layout/block-loader.d.ts +31 -0
  24. package/dist/layout/block-loader.js +143 -0
  25. package/dist/layout/dispatcher.d.ts +7 -2
  26. package/dist/layout/dispatcher.js +12 -16
  27. package/dist/layout/layout-resolver.d.ts +33 -0
  28. package/dist/layout/layout-resolver.js +73 -0
  29. package/dist/layout/markdown-parser.d.ts +34 -0
  30. package/dist/layout/markdown-parser.js +401 -0
  31. package/dist/layout/parser-types.d.ts +123 -0
  32. package/dist/layout/parser-types.js +11 -0
  33. package/dist/layout/parser.d.ts +31 -22
  34. package/dist/layout/parser.js +124 -1069
  35. package/dist/layout/renderer.d.ts +36 -0
  36. package/dist/layout/renderer.js +473 -0
  37. package/dist/layout/templates.d.ts +2 -2
  38. package/dist/layout/templates.js +74 -35
  39. package/dist/types.d.ts +1 -1
  40. package/package.json +1 -1
  41. package/schema/mviz.v1.schema.json +359 -33
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Area chart generator
3
3
  */
4
- import { FONT_SIZE_XXS, LEGEND_ITEM_WIDTH, LEGEND_ITEM_HEIGHT, LEGEND_ITEM_GAP, DEFAULT_CHART_HEIGHT, getThemeColors, getPaletteWithCustom, } from '../core/themes.js';
4
+ import { DEFAULT_CHART_HEIGHT, getThemeColors, getPaletteWithCustom, } from '../core/themes.js';
5
5
  import { wrapHtml } from '../core/serializer.js';
6
6
  import { registerChart, registerOptions } from './registry.js';
7
- import { inferFormat, getAxisFormatterJs, inferAxisType, resolveCurrency, shouldPctMultiply, collectNumericFieldValues, } from '../core/formatting.js';
7
+ import { inferAxisType } from '../core/formatting.js';
8
+ import { buildValueAxisFormatContext, buildXAxisLabelConfig, applyValueAxisRange, buildMultiSeriesLegend, } from '../core/chart-helpers.js';
8
9
  /**
9
10
  * Build ECharts options for an area chart
10
11
  */
@@ -19,30 +20,12 @@ export function buildAreaOptions(spec) {
19
20
  const yKeys = Array.isArray(y) ? y : [y];
20
21
  const categories = data.map((d) => d[x]);
21
22
  const palette = getPaletteWithCustom(theme, spec.customTheme);
22
- // Infer format for value axis based on first y field name
23
- const firstYKey = yKeys[0] ?? 'value';
24
- const sampleValue = data.length > 0 && data[0] ? data[0][firstYKey] ?? 0 : 0;
25
- const valueFormat = spec.format ?? inferFormat(firstYKey, sampleValue);
26
- const currency = resolveCurrency(spec.currency);
27
- const pctMultiply = shouldPctMultiply(valueFormat, collectNumericFieldValues(data, yKeys));
28
- const axisFormatter = getAxisFormatterJs(valueFormat, currency.symbol, currency.locale, pctMultiply);
23
+ // Resolve value-axis format/currency/formatters
24
+ const { axisFormatter } = buildValueAxisFormatContext(spec, data, yKeys);
29
25
  // Detect x-axis type: time, value, or category
30
26
  const xAxisType = spec.xAxisType ?? inferAxisType(categories);
31
27
  // Get axis label config based on axis type
32
- const xAxisLabelConfig = xAxisType === 'category'
33
- ? { interval: 0, rotate: 45, overflow: 'truncate', ellipsis: '...', width: 100 }
34
- : xAxisType === 'time'
35
- ? {
36
- hideOverlap: true,
37
- formatter: {
38
- _js_: `function(value) {
39
- var d = new Date(value);
40
- var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
41
- return months[d.getMonth()] + ' ' + d.getDate();
42
- }`,
43
- },
44
- }
45
- : { hideOverlap: true };
28
+ const xAxisLabelConfig = buildXAxisLabelConfig(xAxisType);
46
29
  const option = {
47
30
  backgroundColor: 'transparent',
48
31
  animation: false,
@@ -84,22 +67,10 @@ export function buildAreaOptions(spec) {
84
67
  series: [],
85
68
  };
86
69
  // Apply yMin/yMax
87
- if (spec.yMin !== undefined) {
88
- option.yAxis.min = spec.yMin;
89
- }
90
- if (spec.yMax !== undefined) {
91
- option.yAxis.max = spec.yMax;
92
- }
70
+ applyValueAxisRange(option.yAxis, spec);
93
71
  // Add legend for multi-series
94
72
  if (yKeys.length > 1) {
95
- option.legend = {
96
- data: yKeys,
97
- top: 0,
98
- textStyle: { color: colors.textSecondary, fontSize: FONT_SIZE_XXS },
99
- itemWidth: LEGEND_ITEM_WIDTH,
100
- itemHeight: LEGEND_ITEM_HEIGHT,
101
- itemGap: LEGEND_ITEM_GAP,
102
- };
73
+ option.legend = buildMultiSeriesLegend(yKeys, colors);
103
74
  }
104
75
  // Build series
105
76
  for (let i = 0; i < yKeys.length; i++) {
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Bar chart generator
3
3
  */
4
- import { BAR_MAX_WIDTH, LEGEND_ITEM_WIDTH, LEGEND_ITEM_HEIGHT, LEGEND_ITEM_GAP, FONT_SIZE_TINY, FONT_SIZE_XXS, DEFAULT_CHART_HEIGHT, getThemeColors, getPaletteWithCustom, } from '../core/themes.js';
4
+ import { BAR_MAX_WIDTH, FONT_SIZE_TINY, FONT_SIZE_XXS, DEFAULT_CHART_HEIGHT, getThemeColors, getPaletteWithCustom, } from '../core/themes.js';
5
5
  import { wrapHtml } from '../core/serializer.js';
6
6
  import { registerChart, registerOptions } from './registry.js';
7
- import { inferFormat, getLabelFormatterJs, getAxisFormatterJs, inferAxisType, resolveCurrency, shouldPctMultiply, collectNumericFieldValues, } from '../core/formatting.js';
7
+ import { inferAxisType } from '../core/formatting.js';
8
+ import { buildValueAxisFormatContext, buildXAxisLabelConfig, applyValueAxisRange, buildMultiSeriesLegend, } from '../core/chart-helpers.js';
8
9
  /**
9
10
  * Build ECharts options for a bar chart
10
11
  */
@@ -19,14 +20,8 @@ export function buildBarOptions(spec) {
19
20
  const yKeys = Array.isArray(y) ? y : [y];
20
21
  const categories = data.map((d) => d[x]);
21
22
  const palette = getPaletteWithCustom(theme, spec.customTheme);
22
- // Infer format for value axis based on first y field name
23
- const firstYKey = yKeys[0] ?? 'value';
24
- const sampleValue = data.length > 0 && data[0] ? data[0][firstYKey] ?? 0 : 0;
25
- const valueFormat = spec.format ?? inferFormat(firstYKey, sampleValue);
26
- const currency = resolveCurrency(spec.currency);
27
- const pctMultiply = shouldPctMultiply(valueFormat, collectNumericFieldValues(data, yKeys));
28
- const labelFormatter = getLabelFormatterJs(valueFormat, currency.symbol, currency.locale, pctMultiply);
29
- const axisFormatter = getAxisFormatterJs(valueFormat, currency.symbol, currency.locale, pctMultiply);
23
+ // Resolve value-axis format/currency/formatters
24
+ const { axisFormatter, labelFormatter } = buildValueAxisFormatContext(spec, data, yKeys, { withLabelFormatter: true });
30
25
  // Detect category axis type (this is x-axis for vertical, y-axis for horizontal)
31
26
  const categoryAxisType = spec.xAxisType ?? inferAxisType(categories);
32
27
  // For horizontal bars, x-axis is always 'value'; for vertical it's the category axis type
@@ -34,9 +29,7 @@ export function buildBarOptions(spec) {
34
29
  // Determine if labels should be shown (based on category axis type, not x-axis for horizontal)
35
30
  const showLabels = yKeys.length === 1 && !stacked && data.length <= 12 && categoryAxisType === 'category';
36
31
  // Get axis label config based on axis type
37
- const xAxisLabelConfig = xAxisType === 'category'
38
- ? { interval: 0, rotate: 45, overflow: 'truncate', ellipsis: '...', width: 100 }
39
- : { hideOverlap: true };
32
+ const xAxisLabelConfig = buildXAxisLabelConfig(xAxisType);
40
33
  const option = {
41
34
  backgroundColor: 'transparent',
42
35
  animation: false,
@@ -111,12 +104,7 @@ export function buildBarOptions(spec) {
111
104
  };
112
105
  // Apply yMin/yMax to value axis
113
106
  const valueAxis = horizontal ? 'xAxis' : 'yAxis';
114
- if (spec.yMin !== undefined) {
115
- option[valueAxis].min = spec.yMin;
116
- }
117
- if (spec.yMax !== undefined) {
118
- option[valueAxis].max = spec.yMax;
119
- }
107
+ applyValueAxisRange(option[valueAxis], spec);
120
108
  // Adjust y-axis gridlines based on x-axis category label length
121
109
  // Long category labels (rotated 45°) take up vertical space at bottom
122
110
  const chartHeight = typeof spec.height === 'number' ? spec.height : DEFAULT_CHART_HEIGHT;
@@ -137,14 +125,7 @@ export function buildBarOptions(spec) {
137
125
  }
138
126
  // Add legend for multi-series
139
127
  if (yKeys.length > 1) {
140
- option.legend = {
141
- data: yKeys,
142
- top: 0,
143
- textStyle: { color: colors.textSecondary, fontSize: FONT_SIZE_XXS },
144
- itemWidth: LEGEND_ITEM_WIDTH,
145
- itemHeight: LEGEND_ITEM_HEIGHT,
146
- itemGap: LEGEND_ITEM_GAP,
147
- };
128
+ option.legend = buildMultiSeriesLegend(yKeys, colors);
148
129
  }
149
130
  // Use lighter label color for dark theme for better visibility
150
131
  const labelColor = theme === 'dark' ? colors.text : colors.textSecondary;
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Combo chart generator (bar + line with optional dual axis)
3
3
  */
4
- import { FONT_SIZE_XXS, BAR_MAX_WIDTH, LEGEND_ITEM_WIDTH, LEGEND_ITEM_HEIGHT, LEGEND_ITEM_GAP, getThemeColors, getPaletteWithCustom, } from '../core/themes.js';
4
+ import { BAR_MAX_WIDTH, getThemeColors, getPaletteWithCustom, } from '../core/themes.js';
5
5
  import { wrapHtml } from '../core/serializer.js';
6
- import { inferAxisType, inferFormat, getAxisFormatterJs, resolveCurrency, shouldPctMultiply, collectNumericFieldValues, } from '../core/formatting.js';
6
+ import { inferAxisType, inferFormat } from '../core/formatting.js';
7
+ import { buildValueAxisFormatContext, buildXAxisLabelConfig, applyValueAxisRange, buildMultiSeriesLegend, } from '../core/chart-helpers.js';
7
8
  import { registerChart, registerOptions } from './registry.js';
8
9
  /**
9
10
  * Build ECharts options for a combo chart (bar + line with optional dual axis)
@@ -20,34 +21,21 @@ export function buildComboOptions(spec) {
20
21
  const barKeys = Array.isArray(barFields) ? barFields : barFields ? [barFields] : [];
21
22
  const lineKeys = Array.isArray(lineFields) ? lineFields : lineFields ? [lineFields] : [];
22
23
  const categories = data.map((d) => d[x]);
23
- // Infer format for primary y-axis (bars)
24
- const barSample = data.length > 0 && data[0] && barKeys[0] ? data[0][barKeys[0]] ?? 0 : 0;
25
- const primaryFormat = spec.format ?? inferFormat(barKeys[0] ?? 'value', barSample);
26
- const currency = resolveCurrency(spec.currency);
27
- const primaryPctMultiply = shouldPctMultiply(primaryFormat, collectNumericFieldValues(data, barKeys));
28
- const primaryAxisFormatter = getAxisFormatterJs(primaryFormat, currency.symbol, currency.locale, primaryPctMultiply);
29
- // Infer format for secondary y-axis (line if dual axis)
30
- const lineSample = data.length > 0 && data[0] && lineKeys[0] ? data[0][lineKeys[0]] ?? 0 : 0;
31
- const secondaryFormat = spec.secondaryFormat ?? inferFormat(lineKeys[0] ?? 'value', lineSample);
32
- const secondaryPctMultiply = shouldPctMultiply(secondaryFormat, collectNumericFieldValues(data, lineKeys));
33
- const secondaryAxisFormatter = getAxisFormatterJs(secondaryFormat, currency.symbol, currency.locale, secondaryPctMultiply);
24
+ // Resolve format/currency/formatters for primary y-axis (bars) and secondary (line).
25
+ //
26
+ // The secondary axis must NOT inherit spec.format (which is the primary/bar
27
+ // format), so we resolve it explicitly here from spec.secondaryFormat or by
28
+ // inferring from the line field, then pass it as an explicit override.
29
+ const { axisFormatter: primaryAxisFormatter } = buildValueAxisFormatContext(spec, data, barKeys);
30
+ const lineSample = data.length > 0 && data[0] && lineKeys[0]
31
+ ? data[0][lineKeys[0]] ?? 0
32
+ : 0;
33
+ const resolvedSecondaryFormat = spec.secondaryFormat ?? inferFormat(lineKeys[0] ?? 'value', lineSample);
34
+ const { axisFormatter: secondaryAxisFormatter } = buildValueAxisFormatContext(spec, data, lineKeys, { formatOverride: resolvedSecondaryFormat });
34
35
  // Detect x-axis type
35
36
  const xAxisType = inferAxisType(categories);
36
37
  // Get axis label config based on axis type
37
- const xAxisLabelConfig = xAxisType === 'category'
38
- ? { interval: 0, rotate: 45, overflow: 'truncate', ellipsis: '...', width: 100 }
39
- : xAxisType === 'time'
40
- ? {
41
- hideOverlap: true,
42
- formatter: {
43
- _js_: `function(value) {
44
- var d = new Date(value);
45
- var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
46
- return months[d.getMonth()] + ' ' + d.getDate();
47
- }`,
48
- },
49
- }
50
- : { hideOverlap: true };
38
+ const xAxisLabelConfig = buildXAxisLabelConfig(xAxisType);
51
39
  const series = [];
52
40
  // Add bar series
53
41
  barKeys.forEach((key, i) => {
@@ -96,12 +84,7 @@ export function buildComboOptions(spec) {
96
84
  },
97
85
  ];
98
86
  // Apply yMin/yMax to primary y-axis
99
- if (spec.yMin !== undefined) {
100
- yAxis[0].min = spec.yMin;
101
- }
102
- if (spec.yMax !== undefined) {
103
- yAxis[0].max = spec.yMax;
104
- }
87
+ applyValueAxisRange(yAxis[0], spec);
105
88
  if (dualAxis) {
106
89
  yAxis.push({
107
90
  type: 'value',
@@ -130,14 +113,7 @@ export function buildComboOptions(spec) {
130
113
  bottom: '8%',
131
114
  containLabel: true,
132
115
  },
133
- legend: {
134
- data: [...barKeys, ...lineKeys],
135
- top: 0,
136
- textStyle: { color: colors.textSecondary, fontSize: FONT_SIZE_XXS },
137
- itemWidth: LEGEND_ITEM_WIDTH,
138
- itemHeight: LEGEND_ITEM_HEIGHT,
139
- itemGap: LEGEND_ITEM_GAP,
140
- },
116
+ legend: buildMultiSeriesLegend([...barKeys, ...lineKeys], colors),
141
117
  xAxis: {
142
118
  type: xAxisType,
143
119
  data: xAxisType === 'category' ? categories.map(String) : undefined,
@@ -4,7 +4,8 @@
4
4
  import { FONT_SIZE_TINY, DEFAULT_CHART_HEIGHT, getThemeColors, getPaletteWithCustom, } from '../core/themes.js';
5
5
  import { wrapHtml } from '../core/serializer.js';
6
6
  import { registerChart, registerOptions } from './registry.js';
7
- import { inferFormat, getAxisFormatterJs, inferAxisType, resolveCurrency, shouldPctMultiply, collectNumericFieldValues, } from '../core/formatting.js';
7
+ import { inferAxisType } from '../core/formatting.js';
8
+ import { buildValueAxisFormatContext, buildXAxisLabelConfig, applyValueAxisRange, } from '../core/chart-helpers.js';
8
9
  /**
9
10
  * Build ECharts options for a line chart
10
11
  */
@@ -18,33 +19,15 @@ export function buildLineOptions(spec) {
18
19
  const yKeys = Array.isArray(y) ? y : [y];
19
20
  const categories = data.map((d) => d[x]);
20
21
  const palette = getPaletteWithCustom(theme, spec.customTheme);
21
- // Infer format for value axis based on first y field name
22
- const firstYKey = yKeys[0] ?? 'value';
23
- const sampleValue = data.length > 0 && data[0] ? data[0][firstYKey] ?? 0 : 0;
24
- const valueFormat = spec.format ?? inferFormat(firstYKey, sampleValue);
25
- const currency = resolveCurrency(spec.currency);
26
- const pctMultiply = shouldPctMultiply(valueFormat, collectNumericFieldValues(data, yKeys));
27
- const axisFormatter = getAxisFormatterJs(valueFormat, currency.symbol, currency.locale, pctMultiply);
22
+ // Resolve value-axis format/currency/formatters
23
+ const { axisFormatter } = buildValueAxisFormatContext(spec, data, yKeys);
28
24
  // Detect x-axis type: time, value, or category
29
25
  // User can override with xAxisType in spec
30
26
  const xAxisType = spec.xAxisType ?? inferAxisType(categories);
31
27
  // More right margin needed for end labels on multi-series
32
28
  const hasEndLabels = yKeys.length > 1;
33
29
  // Get axis label config based on axis type
34
- const xAxisLabelConfig = xAxisType === 'category'
35
- ? { interval: 0, rotate: 45, overflow: 'truncate', ellipsis: '...', width: 100 }
36
- : xAxisType === 'time'
37
- ? {
38
- hideOverlap: true,
39
- formatter: {
40
- _js_: `function(value) {
41
- var d = new Date(value);
42
- var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
43
- return months[d.getMonth()] + ' ' + d.getDate();
44
- }`,
45
- },
46
- }
47
- : { hideOverlap: true };
30
+ const xAxisLabelConfig = buildXAxisLabelConfig(xAxisType);
48
31
  const option = {
49
32
  backgroundColor: 'transparent',
50
33
  animation: false,
@@ -87,12 +70,7 @@ export function buildLineOptions(spec) {
87
70
  series: [],
88
71
  };
89
72
  // Apply yMin/yMax
90
- if (spec.yMin !== undefined) {
91
- option.yAxis.min = spec.yMin;
92
- }
93
- if (spec.yMax !== undefined) {
94
- option.yAxis.max = spec.yMax;
95
- }
73
+ applyValueAxisRange(option.yAxis, spec);
96
74
  // Build series
97
75
  for (let i = 0; i < yKeys.length; i++) {
98
76
  const key = yKeys[i];
package/dist/cli.js CHANGED
@@ -110,6 +110,8 @@ async function main() {
110
110
  console.log(' --lint, -l Validate input without generating output');
111
111
  console.log(' --theme, -t <file> Load custom theme from YAML file');
112
112
  console.log(' --allow-errors Continue generating output even with lint errors');
113
+ console.log(' --embed Strip page chrome (red bar, title, theme toggle) for embedding');
114
+ console.log(' --fragment Emit an HTML fragment (no DOCTYPE/html/head/body); implies --embed');
113
115
  console.log(' --help, -h Show this help message');
114
116
  console.log('');
115
117
  console.log('Examples:');
@@ -123,9 +125,16 @@ async function main() {
123
125
  // Parse flags
124
126
  const lintOnly = args.includes('--lint') || args.includes('-l');
125
127
  const allowErrors = args.includes('--allow-errors');
128
+ const fragmentFlag = args.includes('--fragment');
129
+ // Fragment mode implies embed — no chrome inside a fragment.
130
+ const embedFlag = fragmentFlag || args.includes('--embed');
126
131
  const { themeFile, remainingArgs: afterTheme } = parseThemeFlag(args);
127
132
  const { outputFile, remainingArgs } = parseOutputFlag(afterTheme);
128
- const filteredArgs = remainingArgs.filter((a) => a !== '--lint' && a !== '-l' && a !== '--allow-errors');
133
+ const filteredArgs = remainingArgs.filter((a) => a !== '--lint' &&
134
+ a !== '-l' &&
135
+ a !== '--allow-errors' &&
136
+ a !== '--embed' &&
137
+ a !== '--fragment');
129
138
  // Load custom theme if specified
130
139
  let customTheme;
131
140
  if (themeFile) {
@@ -168,6 +177,8 @@ async function main() {
168
177
  console.error(' --lint, -l Validate input without generating output');
169
178
  console.error(' --theme, -t <file> Load custom theme from YAML file');
170
179
  console.error(' --allow-errors Continue generating output even with lint errors');
180
+ console.error(' --embed Strip page chrome (red bar, title, theme toggle) for embedding');
181
+ console.error(' --fragment Emit an HTML fragment (no DOCTYPE/html/head/body); implies --embed');
171
182
  console.error(' --help, -h Show this help message');
172
183
  process.exit(1);
173
184
  }
@@ -182,11 +193,11 @@ async function main() {
182
193
  // JSON input - lint then generate
183
194
  const spec = JSON.parse(trimmed);
184
195
  lintSpec(spec, lintMode);
185
- html = await generateChartAsync(spec);
196
+ html = await generateChartAsync(spec, { fragment: fragmentFlag });
186
197
  }
187
198
  else if (trimmed.startsWith('---') || trimmed.includes('```')) {
188
199
  // Markdown input - parser handles linting internally (async for mermaid)
189
- const result = await parseMarkdownToDashboardAsync(input, 'light', baseDir, false, false, customTheme, lintMode);
200
+ const result = await parseMarkdownToDashboardAsync(input, 'light', baseDir, false, false, customTheme, lintMode, embedFlag, fragmentFlag);
190
201
  html = result.html;
191
202
  errors = result.errors;
192
203
  }
@@ -194,7 +205,7 @@ async function main() {
194
205
  // Try JSON anyway - lint then generate
195
206
  const spec = JSON.parse(trimmed);
196
207
  lintSpec(spec, lintMode);
197
- html = await generateChartAsync(spec);
208
+ html = await generateChartAsync(spec, { fragment: fragmentFlag });
198
209
  }
199
210
  // Check for errors (unless --allow-errors is set)
200
211
  if (errors.length > 0 && !allowErrors) {
@@ -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 };