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.
- package/dist/charts/area.js +8 -37
- package/dist/charts/bar.js +8 -27
- package/dist/charts/combo.js +17 -41
- package/dist/charts/line.js +6 -28
- 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 +17 -1
- package/dist/components/table-interactivity.js +34 -1
- package/dist/components/table.js +1 -1
- package/dist/core/chart-helpers.d.ts +59 -5
- package/dist/core/chart-helpers.js +84 -5
- package/dist/core/formatting.d.ts +33 -0
- package/dist/core/formatting.js +135 -0
- package/dist/core/linter.d.ts +3 -0
- package/dist/core/linter.js +52 -8
- 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 +30 -21
- package/dist/layout/parser.js +116 -1067
- 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 +1 -1
- package/schema/mviz.v1.schema.json +359 -33
package/dist/charts/area.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Area chart generator
|
|
3
3
|
*/
|
|
4
|
-
import {
|
|
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 {
|
|
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
|
-
//
|
|
23
|
-
const
|
|
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
|
|
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
|
-
|
|
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++) {
|
package/dist/charts/bar.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Bar chart generator
|
|
3
3
|
*/
|
|
4
|
-
import { BAR_MAX_WIDTH,
|
|
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 {
|
|
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
|
-
//
|
|
23
|
-
const
|
|
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
|
|
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
|
-
|
|
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;
|
package/dist/charts/combo.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Combo chart generator (bar + line with optional dual axis)
|
|
3
3
|
*/
|
|
4
|
-
import {
|
|
4
|
+
import { BAR_MAX_WIDTH, getThemeColors, getPaletteWithCustom, } from '../core/themes.js';
|
|
5
5
|
import { wrapHtml } from '../core/serializer.js';
|
|
6
|
-
import { inferAxisType, inferFormat
|
|
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
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const primaryAxisFormatter =
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
const secondaryAxisFormatter =
|
|
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
|
|
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
|
-
|
|
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,
|
package/dist/charts/line.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
22
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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>`;
|