mviz 1.4.3 → 1.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -166,7 +166,7 @@ mviz is available as a Claude skill for use in Claude.ai, Claude Code, and the C
166
166
  Build the skill bundle:
167
167
  ```bash
168
168
  npm run build
169
- python3 build_skill.py -o mviz.skill
169
+ python3 build_skill_compact.py -o mviz.skill
170
170
  ```
171
171
 
172
172
  ## License
@@ -5,6 +5,7 @@ import { FONT_SIZE_XXS, LEGEND_ITEM_WIDTH, LEGEND_ITEM_HEIGHT, LEGEND_ITEM_GAP,
5
5
  import { wrapHtml } from '../core/serializer.js';
6
6
  import { registerChart, registerOptions } from './registry.js';
7
7
  import { inferFormat, getAxisFormatterJs, inferAxisType } from '../core/formatting.js';
8
+ import { validateTimeSeriesSorted, generateErrorHtml } from '../core/validation.js';
8
9
  /**
9
10
  * Build ECharts options for an area chart
10
11
  */
@@ -127,7 +128,15 @@ export function buildAreaOptions(spec) {
127
128
  * Generate an area chart
128
129
  */
129
130
  function generateAreaChart(spec) {
131
+ const data = (spec.data ?? []);
132
+ const x = spec.x ?? 'name';
133
+ const theme = (spec.theme ?? 'light');
130
134
  const height = typeof spec.height === 'number' ? spec.height : DEFAULT_CHART_HEIGHT;
135
+ // Validate time series data is sorted
136
+ const validation = validateTimeSeriesSorted(data, x);
137
+ if (!validation.isValid) {
138
+ return generateErrorHtml(validation.errorMessage, theme, '100%', height);
139
+ }
131
140
  return wrapHtml('chart', buildAreaOptions(spec), '', '100%', height);
132
141
  }
133
142
  // Register the chart
@@ -5,6 +5,7 @@ import { BAR_MAX_WIDTH, LEGEND_ITEM_WIDTH, LEGEND_ITEM_HEIGHT, LEGEND_ITEM_GAP,
5
5
  import { wrapHtml } from '../core/serializer.js';
6
6
  import { registerChart, registerOptions } from './registry.js';
7
7
  import { inferFormat, getLabelFormatterJs, getAxisFormatterJs, inferAxisType } from '../core/formatting.js';
8
+ import { validateTimeSeriesSorted, generateErrorHtml } from '../core/validation.js';
8
9
  /**
9
10
  * Build ECharts options for a bar chart
10
11
  */
@@ -181,7 +182,17 @@ export function buildBarOptions(spec) {
181
182
  * Generate a bar chart
182
183
  */
183
184
  function generateBarChart(spec) {
185
+ const data = (spec.data ?? []);
186
+ const x = spec.x ?? 'name';
187
+ const theme = (spec.theme ?? 'light');
184
188
  const height = typeof spec.height === 'number' ? spec.height : DEFAULT_CHART_HEIGHT;
189
+ // Validate time series data is sorted (only for non-horizontal bars)
190
+ if (!spec.horizontal) {
191
+ const validation = validateTimeSeriesSorted(data, x);
192
+ if (!validation.isValid) {
193
+ return generateErrorHtml(validation.errorMessage, theme, '100%', height);
194
+ }
195
+ }
185
196
  return wrapHtml('chart', buildBarOptions(spec), '', '100%', height);
186
197
  }
187
198
  // Register the chart
@@ -5,6 +5,7 @@ import { FONT_SIZE_TINY, DEFAULT_CHART_HEIGHT, getThemeColors, getPalette, } fro
5
5
  import { wrapHtml } from '../core/serializer.js';
6
6
  import { registerChart, registerOptions } from './registry.js';
7
7
  import { inferFormat, getAxisFormatterJs, inferAxisType } from '../core/formatting.js';
8
+ import { validateTimeSeriesSorted, generateErrorHtml } from '../core/validation.js';
8
9
  /**
9
10
  * Build ECharts options for a line chart
10
11
  */
@@ -124,7 +125,15 @@ export function buildLineOptions(spec) {
124
125
  * Generate a line chart
125
126
  */
126
127
  function generateLineChart(spec) {
128
+ const data = (spec.data ?? []);
129
+ const x = spec.x ?? 'name';
130
+ const theme = (spec.theme ?? 'light');
127
131
  const height = typeof spec.height === 'number' ? spec.height : DEFAULT_CHART_HEIGHT;
132
+ // Validate time series data is sorted
133
+ const validation = validateTimeSeriesSorted(data, x);
134
+ if (!validation.isValid) {
135
+ return generateErrorHtml(validation.errorMessage, theme, '100%', height);
136
+ }
128
137
  return wrapHtml('chart', buildLineOptions(spec), '', '100%', height);
129
138
  }
130
139
  // Register the chart
@@ -2,6 +2,7 @@
2
2
  * Theme definitions and utilities for mviz
3
3
  */
4
4
  import type { Theme, ThemeColors, ChartSpec } from '../types.js';
5
+ export declare const ERROR_RED = "#bc1200";
5
6
  export declare const COLORS: {
6
7
  readonly PRIMARY_BLUE: "#0777b3";
7
8
  readonly SECONDARY_ORANGE: "#bd4e35";
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Theme definitions and utilities for mviz
3
3
  */
4
+ // Standalone color exports for direct use
5
+ export const ERROR_RED = '#bc1200';
4
6
  // Color constants (mdsinabox theme)
5
7
  export const COLORS = {
6
8
  PRIMARY_BLUE: '#0777b3',
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Data validation utilities for mviz charts
3
+ */
4
+ import type { DataPoint, Theme } from '../types.js';
5
+ /**
6
+ * Result of time series validation
7
+ */
8
+ export interface TimeSeriesValidation {
9
+ isValid: boolean;
10
+ errorMessage?: string;
11
+ }
12
+ /**
13
+ * Check if time series data is sorted in ascending order.
14
+ * Returns validation result with error message if unsorted.
15
+ */
16
+ export declare function validateTimeSeriesSorted(data: DataPoint[], xField: string): TimeSeriesValidation;
17
+ /**
18
+ * Generate an error HTML component to display instead of a chart
19
+ * when validation fails.
20
+ */
21
+ export declare function generateErrorHtml(errorMessage: string, theme: Theme, width: string, height: number): string;
22
+ //# sourceMappingURL=validation.d.ts.map
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Data validation utilities for mviz charts
3
+ */
4
+ import { FONT_STACK, getThemeColors, ERROR_RED } from './themes.js';
5
+ import { inferAxisType } from './formatting.js';
6
+ /**
7
+ * Check if time series data is sorted in ascending order.
8
+ * Returns validation result with error message if unsorted.
9
+ */
10
+ export function validateTimeSeriesSorted(data, xField) {
11
+ if (data.length < 2) {
12
+ return { isValid: true };
13
+ }
14
+ const xValues = data.map((d) => d[xField]);
15
+ const axisType = inferAxisType(xValues);
16
+ // Only validate if this is a time axis
17
+ if (axisType !== 'time') {
18
+ return { isValid: true };
19
+ }
20
+ // Convert values to timestamps for comparison
21
+ const timestamps = xValues.map((v) => {
22
+ if (typeof v === 'number')
23
+ return v;
24
+ if (typeof v === 'string') {
25
+ const parsed = Date.parse(v);
26
+ return isNaN(parsed) ? 0 : parsed;
27
+ }
28
+ if (v instanceof Date)
29
+ return v.getTime();
30
+ return 0;
31
+ });
32
+ // Check if sorted in ascending order
33
+ for (let i = 1; i < timestamps.length; i++) {
34
+ if (timestamps[i] < timestamps[i - 1]) {
35
+ return {
36
+ isValid: false,
37
+ errorMessage: `Time series axis is not sorted chronologically. Sort your data before charting.`,
38
+ };
39
+ }
40
+ }
41
+ return { isValid: true };
42
+ }
43
+ /**
44
+ * Generate an error HTML component to display instead of a chart
45
+ * when validation fails.
46
+ */
47
+ export function generateErrorHtml(errorMessage, theme, width, height) {
48
+ const colors = getThemeColors(theme);
49
+ return `<!DOCTYPE html>
50
+ <html lang="en">
51
+ <head>
52
+ <meta charset="UTF-8">
53
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
54
+ <style>
55
+ * { box-sizing: border-box; }
56
+ html, body {
57
+ margin: 0;
58
+ padding: 0;
59
+ width: 100%;
60
+ height: 100%;
61
+ background-color: ${colors.background};
62
+ font-family: ${FONT_STACK};
63
+ }
64
+ .error-container {
65
+ width: ${width};
66
+ height: ${height}px;
67
+ display: flex;
68
+ align-items: center;
69
+ justify-content: center;
70
+ padding: 24px;
71
+ }
72
+ .error-box {
73
+ border: 2px solid ${ERROR_RED};
74
+ border-radius: 8px;
75
+ padding: 20px 24px;
76
+ background-color: ${theme === 'light' ? '#fff5f5' : '#2d1f1f'};
77
+ max-width: 500px;
78
+ }
79
+ .error-title {
80
+ color: ${ERROR_RED};
81
+ font-size: 14px;
82
+ font-weight: 700;
83
+ margin: 0 0 8px 0;
84
+ display: flex;
85
+ align-items: center;
86
+ gap: 8px;
87
+ }
88
+ .error-icon {
89
+ font-size: 18px;
90
+ }
91
+ .error-message {
92
+ color: ${colors.text};
93
+ font-size: 13px;
94
+ line-height: 1.5;
95
+ margin: 0;
96
+ }
97
+ </style>
98
+ </head>
99
+ <body>
100
+ <div class="error-container">
101
+ <div class="error-box">
102
+ <p class="error-title"><span class="error-icon">⚠</span> Chart Error</p>
103
+ <p class="error-message">${escapeHtml(errorMessage)}</p>
104
+ </div>
105
+ </div>
106
+ </body>
107
+ </html>`;
108
+ }
109
+ /**
110
+ * Escape HTML special characters
111
+ */
112
+ function escapeHtml(text) {
113
+ return text
114
+ .replace(/&/g, '&amp;')
115
+ .replace(/</g, '&lt;')
116
+ .replace(/>/g, '&gt;')
117
+ .replace(/"/g, '&quot;')
118
+ .replace(/'/g, '&#39;');
119
+ }
120
+ //# sourceMappingURL=validation.js.map
@@ -11,6 +11,7 @@ import { parseCsv } from './csv.js';
11
11
  import { getOptionsBuilder } from '../charts/index.js';
12
12
  import { serializeOption } from '../core/serializer.js';
13
13
  import { formatNumber, inferFormat } from '../core/formatting.js';
14
+ import { validateTimeSeriesSorted } from '../core/validation.js';
14
15
  import { COLORS, GRID_TOTAL_COLUMNS, DEFAULT_SIZES, autoSizeChart, getHeatmapColors, getThemeColors, ALERT_ICONS, } from '../core/themes.js';
15
16
  import { formatCell, computeHeatmapRanges, computeDumbbellRanges, } from '../components/table.js';
16
17
  // Height per row unit (compact for print) - must match Python's ROW_HEIGHT_PX
@@ -122,6 +123,30 @@ function renderEchartsComponent(compType, spec, chartId, colSpan, itemHeight, an
122
123
  return { html: null, script: null };
123
124
  }
124
125
  const chartHeight = itemHeight - (title ? 24 : 0);
126
+ // Validate time series data for line, bar (non-horizontal), and area charts
127
+ const timeSeriesTypes = ['line', 'area'];
128
+ const isBarNonHorizontal = compType === 'bar' && !spec.horizontal;
129
+ if (timeSeriesTypes.includes(compType) || isBarNonHorizontal) {
130
+ const data = (spec.data ?? []);
131
+ const xField = spec.x ?? 'name';
132
+ const validation = validateTimeSeriesSorted(data, xField);
133
+ if (!validation.isValid) {
134
+ const anchorAttr = anchorId ? ` id="${anchorId}"` : '';
135
+ const errorContent = `
136
+ <div style="border: 2px solid #bc1200; border-radius: 8px; padding: 20px 24px; background-color: #fff5f5; max-width: 500px; margin: auto;">
137
+ <p style="color: #bc1200; font-size: 14px; font-weight: 700; margin: 0 0 8px 0;">⚠ Chart Not Rendered</p>
138
+ <p style="color: #231f20; font-size: 13px; line-height: 1.5; margin: 0;">Time series axis is not sorted chronologically. Sort your data before charting.</p>
139
+ </div>`;
140
+ const html = `
141
+ <div class="grid-item"${anchorAttr} style="--col-span: ${colSpan}; grid-column: span ${colSpan};">
142
+ ${titleHtml}
143
+ <div style="width: 100%; height: ${chartHeight}px; display: flex; align-items: center; justify-content: center;">
144
+ ${errorContent}
145
+ </div>
146
+ </div>`;
147
+ return { html, script: null };
148
+ }
149
+ }
125
150
  // Generate light theme options
126
151
  const specLight = { ...spec, theme: 'light', height: chartHeight };
127
152
  const optionLight = builder(specLight);
@@ -785,9 +810,11 @@ export function parseMarkdownToDashboard(markdown, baseTheme = 'light', _baseDir
785
810
  const builder = getOptionsBuilder(compType);
786
811
  if (builder) {
787
812
  const { html, script } = renderEchartsComponent(compType, spec, chartId, colSpan, itemHeight, anchorId);
788
- if (html && script) {
813
+ if (html) {
789
814
  rowItems.push(html);
790
- chartScripts.push(script);
815
+ if (script) {
816
+ chartScripts.push(script);
817
+ }
791
818
  }
792
819
  else {
793
820
  rowItems.push(`<div class="grid-item"><p>Error rendering ${compType}</p></div>`);
@@ -55,6 +55,7 @@ export function generateDashboardCss() {
55
55
  margin: 0 auto;
56
56
  padding: 16px;
57
57
  position: relative;
58
+ overflow-x: hidden;
58
59
  }
59
60
  .title-row {
60
61
  display: flex;
@@ -144,6 +145,7 @@ export function generateDashboardCss() {
144
145
  @media (max-width: 480px) {
145
146
  .row { grid-template-columns: 1fr; }
146
147
  .row > * { grid-column: span 1; }
148
+ .grid-item { overflow-x: auto; -webkit-overflow-scrolling: touch; }
147
149
  }
148
150
  @media print {
149
151
  .dashboard { max-width: 100%; padding: 0; }
@@ -600,6 +602,7 @@ export function generateTestHarnessCss() {
600
602
  max-width: 720px;
601
603
  margin: 0 auto;
602
604
  padding: 16px;
605
+ overflow-x: hidden;
603
606
  }
604
607
  .red-line {
605
608
  width: 100%;
@@ -644,6 +647,7 @@ export function generateTestHarnessCss() {
644
647
  @media (max-width: 480px) {
645
648
  .row { grid-template-columns: 1fr; }
646
649
  .row > * { grid-column: span 1; }
650
+ .grid-item { overflow-x: auto; -webkit-overflow-scrolling: touch; }
647
651
  }
648
652
 
649
653
  /* Grid items */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mviz",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
4
4
  "description": "Generate clean, data-focused charts and dashboards from compact JSON specs",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",