mviz 1.6.6 → 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.
@@ -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];
@@ -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>`;