mviz 1.6.2 → 1.6.4

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.
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Client-side PNG export for ECharts charts.
3
+ *
4
+ * Generates JavaScript that adds a right-click context menu to each chart,
5
+ * allowing users to save high-resolution PNGs in three presets:
6
+ * - Square: 1080 x 1080
7
+ * - Portrait: 1080 x 1350 (4:5)
8
+ * - Landscape: 1920 x 1080 (16:9)
9
+ *
10
+ * The chart reflows to fill the full target canvas, with the title
11
+ * drawn above. Text scales proportionally via pixelRatio.
12
+ */
13
+ /**
14
+ * Return the CSS for the export context menu.
15
+ */
16
+ export declare function generateExportCss(): string;
17
+ /**
18
+ * Return the JavaScript that powers chart PNG export.
19
+ *
20
+ * Injected once into the page. Relies on `window.chartInstances`
21
+ * and `window.chartOptions` being populated by the renderer.
22
+ */
23
+ export declare function generateExportJs(): string;
24
+ //# sourceMappingURL=export.d.ts.map
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Client-side PNG export for ECharts charts.
3
+ *
4
+ * Generates JavaScript that adds a right-click context menu to each chart,
5
+ * allowing users to save high-resolution PNGs in three presets:
6
+ * - Square: 1080 x 1080
7
+ * - Portrait: 1080 x 1350 (4:5)
8
+ * - Landscape: 1920 x 1080 (16:9)
9
+ *
10
+ * The chart reflows to fill the full target canvas, with the title
11
+ * drawn above. Text scales proportionally via pixelRatio.
12
+ */
13
+ import { COLORS } from './themes.js';
14
+ /** Logical base width for export — matches typical mobile screen width. */
15
+ const LOGICAL_BASE = 360;
16
+ /** Title layout in logical pixels (scale with pixelRatio for output). */
17
+ const TITLE_FONT_SIZE = 14;
18
+ const TITLE_PADDING_TOP = 12;
19
+ const TITLE_PADDING_BOTTOM = 8;
20
+ const TITLE_PADDING_LEFT = 12;
21
+ const EXPORT_PRESETS = {
22
+ square: { width: 1080, height: 1080, label: 'Square (1080 × 1080)' },
23
+ portrait: { width: 1080, height: 1350, label: 'Portrait (1080 × 1350)' },
24
+ landscape: { width: 1920, height: 1080, label: 'Landscape (1920 × 1080)' },
25
+ };
26
+ /**
27
+ * Return the CSS for the export context menu.
28
+ */
29
+ export function generateExportCss() {
30
+ return `
31
+ .mviz-export-menu {
32
+ position: fixed;
33
+ z-index: 10000;
34
+ background: ${COLORS.WHITE};
35
+ border: 1px solid ${COLORS.GRAY_300};
36
+ border-radius: 4px;
37
+ box-shadow: 0 2px 8px rgba(0,0,0,0.18);
38
+ padding: 4px 0;
39
+ min-width: 200px;
40
+ font-family: var(--font-family, 'Helvetica Neue', Helvetica, Arial, sans-serif);
41
+ font-size: 12px;
42
+ color: ${COLORS.TEXT_DARK};
43
+ }
44
+ body.theme-dark .mviz-export-menu {
45
+ background: ${COLORS.GRAY_800};
46
+ border-color: ${COLORS.GRAY_600};
47
+ color: ${COLORS.GRAY_200};
48
+ }
49
+ .mviz-export-menu-header {
50
+ padding: 6px 12px 4px;
51
+ font-size: 10px;
52
+ font-weight: 700;
53
+ text-transform: uppercase;
54
+ letter-spacing: 0.05em;
55
+ color: ${COLORS.GRAY_500};
56
+ }
57
+ body.theme-dark .mviz-export-menu-header { color: ${COLORS.GRAY_600}; }
58
+ .mviz-export-menu-item {
59
+ padding: 6px 12px;
60
+ cursor: pointer;
61
+ }
62
+ .mviz-export-menu-item:hover {
63
+ background: ${COLORS.GRAY_100};
64
+ }
65
+ body.theme-dark .mviz-export-menu-item:hover {
66
+ background: ${COLORS.GRID_DARK};
67
+ }
68
+ @media print {
69
+ .mviz-export-menu { display: none; }
70
+ }`;
71
+ }
72
+ /**
73
+ * Return the JavaScript that powers chart PNG export.
74
+ *
75
+ * Injected once into the page. Relies on `window.chartInstances`
76
+ * and `window.chartOptions` being populated by the renderer.
77
+ */
78
+ export function generateExportJs() {
79
+ const presets = JSON.stringify(EXPORT_PRESETS);
80
+ // Inject TypeScript constants into the JS runtime
81
+ const config = JSON.stringify({
82
+ LOGICAL_BASE,
83
+ TITLE_FONT_SIZE,
84
+ TITLE_PADDING_TOP,
85
+ TITLE_PADDING_BOTTOM,
86
+ TITLE_PADDING_LEFT,
87
+ BG_LIGHT: COLORS.BG_LIGHT,
88
+ BG_DARK: COLORS.DARK,
89
+ TEXT_LIGHT: COLORS.TEXT_DARK,
90
+ TEXT_DARK: COLORS.GRAY_200,
91
+ });
92
+ return `
93
+ // --- mviz PNG export ---
94
+ (function() {
95
+ var PRESETS = ${presets};
96
+ var CFG = ${config};
97
+ var menu = null;
98
+
99
+ function removeMenu() {
100
+ if (menu && menu.parentNode) { menu.parentNode.removeChild(menu); menu = null; }
101
+ }
102
+
103
+ function isDarkTheme() {
104
+ return document.body.classList.contains('theme-dark');
105
+ }
106
+
107
+ function getChartBg() {
108
+ var bg = getComputedStyle(document.body).backgroundColor;
109
+ return (bg && bg !== 'rgba(0, 0, 0, 0)') ? bg : (isDarkTheme() ? CFG.BG_DARK : CFG.BG_LIGHT);
110
+ }
111
+
112
+ function getChartTitle(chartEl) {
113
+ var gridItem = chartEl.closest('.grid-item');
114
+ if (gridItem) {
115
+ var h3 = gridItem.querySelector('.chart-title');
116
+ if (h3) return h3.textContent.trim();
117
+ }
118
+ return '';
119
+ }
120
+
121
+ function slugify(text) {
122
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'chart';
123
+ }
124
+
125
+ function renderChart(chartId, drawW, drawH) {
126
+ var tmpDiv = document.createElement('div');
127
+ tmpDiv.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:' + drawW + 'px;height:' + drawH + 'px;';
128
+ document.body.appendChild(tmpDiv);
129
+
130
+ var tmpChart = echarts.init(tmpDiv, null, {renderer: 'canvas', width: drawW, height: drawH});
131
+ var currentTheme = isDarkTheme() ? 'dark' : 'light';
132
+ var opts = window.chartOptions && window.chartOptions[chartId];
133
+ var instance = window.chartInstances[chartId];
134
+ tmpChart.setOption(opts ? opts[currentTheme] : instance.getOption());
135
+
136
+ return { div: tmpDiv, chart: tmpChart };
137
+ }
138
+
139
+ function getThemeFont() {
140
+ var fontFamily = getComputedStyle(document.body).fontFamily;
141
+ return fontFamily || '"Helvetica Neue", Helvetica, Arial, sans-serif';
142
+ }
143
+
144
+ function drawTitle(ctx, title, pixelRatio) {
145
+ ctx.fillStyle = isDarkTheme() ? CFG.TEXT_DARK : CFG.TEXT_LIGHT;
146
+ ctx.font = 'bold ' + Math.round(CFG.TITLE_FONT_SIZE * pixelRatio) + 'px ' + getThemeFont();
147
+ ctx.textAlign = 'left';
148
+ ctx.textBaseline = 'top';
149
+ ctx.fillText(
150
+ title.toUpperCase(),
151
+ Math.round(CFG.TITLE_PADDING_LEFT * pixelRatio),
152
+ Math.round(CFG.TITLE_PADDING_TOP * pixelRatio)
153
+ );
154
+ }
155
+
156
+ function triggerDownload(canvas, filename) {
157
+ var a = document.createElement('a');
158
+ a.href = canvas.toDataURL('image/png');
159
+ a.download = filename;
160
+ a.click();
161
+ }
162
+
163
+ function exportChart(chartId, presetKey) {
164
+ if (!window.chartInstances || !window.chartInstances[chartId]) return;
165
+
166
+ var preset = PRESETS[presetKey];
167
+ var targetW = preset.width;
168
+ var targetH = preset.height;
169
+ var pixelRatio = targetW / CFG.LOGICAL_BASE;
170
+ var logicalH = Math.round(targetH / pixelRatio);
171
+
172
+ // Reserve space for title
173
+ var chartEl = document.getElementById(chartId);
174
+ var title = getChartTitle(chartEl);
175
+ var titleLogicalH = title ? (CFG.TITLE_PADDING_TOP + CFG.TITLE_FONT_SIZE + CFG.TITLE_PADDING_BOTTOM) : 0;
176
+
177
+ // ECharts reflows to fill its container — give it the full space
178
+ var tmp = renderChart(chartId, CFG.LOGICAL_BASE, logicalH - titleLogicalH);
179
+
180
+ // Wait a frame for render, then composite
181
+ setTimeout(function() {
182
+ var chartDataUrl = tmp.chart.getDataURL({type: 'png', pixelRatio: pixelRatio, backgroundColor: 'transparent'});
183
+ var titlePxH = Math.round(titleLogicalH * pixelRatio);
184
+
185
+ var canvas = document.createElement('canvas');
186
+ canvas.width = targetW;
187
+ canvas.height = targetH;
188
+ var ctx = canvas.getContext('2d');
189
+
190
+ ctx.fillStyle = getChartBg();
191
+ ctx.fillRect(0, 0, targetW, targetH);
192
+
193
+ if (title) { drawTitle(ctx, title, pixelRatio); }
194
+
195
+ var img = new Image();
196
+ img.onload = function() {
197
+ ctx.drawImage(img, 0, titlePxH, targetW, targetH - titlePxH);
198
+ triggerDownload(canvas, slugify(title || 'chart') + '-' + presetKey + '.png');
199
+ tmp.chart.dispose();
200
+ document.body.removeChild(tmp.div);
201
+ };
202
+ img.src = chartDataUrl;
203
+ }, 100);
204
+ }
205
+
206
+ function showMenu(e, chartId) {
207
+ e.preventDefault();
208
+ removeMenu();
209
+
210
+ menu = document.createElement('div');
211
+ menu.className = 'mviz-export-menu';
212
+
213
+ var header = document.createElement('div');
214
+ header.className = 'mviz-export-menu-header';
215
+ header.textContent = 'Save as PNG';
216
+ menu.appendChild(header);
217
+
218
+ var keys = ['square', 'portrait', 'landscape'];
219
+ for (var i = 0; i < keys.length; i++) {
220
+ (function(key) {
221
+ var item = document.createElement('div');
222
+ item.className = 'mviz-export-menu-item';
223
+ item.textContent = PRESETS[key].label;
224
+ item.addEventListener('click', function() {
225
+ removeMenu();
226
+ exportChart(chartId, key);
227
+ });
228
+ menu.appendChild(item);
229
+ })(keys[i]);
230
+ }
231
+
232
+ // Position near cursor, clamped to viewport
233
+ var x = e.clientX;
234
+ var y = e.clientY;
235
+ document.body.appendChild(menu);
236
+ var mw = menu.offsetWidth;
237
+ var mh = menu.offsetHeight;
238
+ if (x + mw > window.innerWidth) x = window.innerWidth - mw - 4;
239
+ if (y + mh > window.innerHeight) y = window.innerHeight - mh - 4;
240
+ menu.style.left = x + 'px';
241
+ menu.style.top = y + 'px';
242
+ }
243
+
244
+ // Dismiss on click elsewhere
245
+ document.addEventListener('click', removeMenu);
246
+ document.addEventListener('contextmenu', function(e) {
247
+ // Only show for chart containers that have an ECharts instance
248
+ var target = e.target;
249
+ var el = target.closest ? target.closest('[id]') : null;
250
+ while (el) {
251
+ if (window.chartInstances && window.chartInstances[el.id]) {
252
+ showMenu(e, el.id);
253
+ return;
254
+ }
255
+ el = el.parentElement ? el.parentElement.closest('[id]') : null;
256
+ }
257
+ // Not a chart — let default context menu through
258
+ removeMenu();
259
+ });
260
+ })();`;
261
+ }
262
+ //# sourceMappingURL=export.js.map
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * JSON serialization with JavaScript function embedding for ECharts
3
3
  */
4
+ import { generateExportCss, generateExportJs } from './export.js';
4
5
  /**
5
6
  * Special markers for JS functions that need to be embedded in JSON
6
7
  */
@@ -61,6 +62,8 @@ export function createJsFunction(code) {
61
62
  */
62
63
  export function wrapHtml(chartId, option, css, width, height) {
63
64
  const optionJson = serializeOption(option);
65
+ const exportCss = generateExportCss();
66
+ const exportJs = generateExportJs();
64
67
  return `<!DOCTYPE html>
65
68
  <html lang="en">
66
69
  <head>
@@ -69,15 +72,22 @@ export function wrapHtml(chartId, option, css, width, height) {
69
72
  <script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
70
73
  <style>
71
74
  ${css}
75
+ ${exportCss}
72
76
  </style>
73
77
  </head>
74
78
  <body>
75
79
  <div id="${chartId}" style="width: ${width}; height: ${height}px;"></div>
76
80
  <script>
77
- var chart = echarts.init(document.getElementById('${chartId}'), null, {renderer: 'svg'});
78
81
  var option = ${optionJson};
82
+ var chart = echarts.init(document.getElementById('${chartId}'), null, {renderer: 'svg'});
83
+ window.chartInstances = window.chartInstances || {};
84
+ window.chartOptions = window.chartOptions || {};
85
+ window.chartInstances['${chartId}'] = chart;
86
+ window.chartOptions['${chartId}'] = { light: option, dark: option };
79
87
  chart.setOption(option);
80
88
  window.addEventListener('resize', function() { chart.resize(); });
89
+
90
+ ${exportJs}
81
91
  </script>
82
92
  </body>
83
93
  </html>`;
@@ -193,11 +193,15 @@ export function getPalette(theme = 'light') {
193
193
  * Custom theme can specify which base theme to extend.
194
194
  */
195
195
  export function getThemeColorsWithCustom(theme = 'light', custom) {
196
- const baseTheme = custom?.extends ?? theme;
197
- const baseColors = getThemeColors(baseTheme);
198
- if (!custom?.colors) {
199
- return baseColors;
196
+ // Only apply custom color overrides when the requested theme matches
197
+ // the theme the custom colors were designed for (custom.extends).
198
+ // For the opposite theme, use standard defaults so dark mode isn't
199
+ // broken by light-mode custom colors (and vice versa).
200
+ const customExtends = custom?.extends ?? 'light';
201
+ if (!custom?.colors || theme !== customExtends) {
202
+ return getThemeColors(theme);
200
203
  }
204
+ const baseColors = getThemeColors(customExtends);
201
205
  return { ...baseColors, ...custom.colors };
202
206
  }
203
207
  /**
@@ -249,10 +253,11 @@ export function getTooltipConfig(theme = 'light') {
249
253
  export function getBaseOption(spec) {
250
254
  const theme = spec.theme ?? 'light';
251
255
  const colors = getThemeColors(theme);
256
+ const fonts = getFontsWithCustom(spec.customTheme);
252
257
  return {
253
258
  backgroundColor: 'transparent',
254
259
  textStyle: {
255
- fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
260
+ fontFamily: fonts.family,
256
261
  color: colors.text,
257
262
  },
258
263
  title: spec.title
@@ -12,7 +12,7 @@ import { getOptionsBuilder } from '../charts/index.js';
12
12
  import { serializeOption } from '../core/serializer.js';
13
13
  import { formatNumber, inferFormat, resolveCurrency } from '../core/formatting.js';
14
14
  import { lintSpec } from '../core/linter.js';
15
- import { COLORS, GRID_TOTAL_COLUMNS, DEFAULT_SIZES, autoSizeChart, getHeatmapColors, getThemeColors, getThemeColorsWithCustom, ALERT_ICONS, } from '../core/themes.js';
15
+ import { COLORS, GRID_TOTAL_COLUMNS, DEFAULT_SIZES, autoSizeChart, getHeatmapColors, getThemeColors, getThemeColorsWithCustom, getFontsWithCustom, ALERT_ICONS, } from '../core/themes.js';
16
16
  import { renderMermaid, renderMermaidAscii } from 'beautiful-mermaid';
17
17
  import { formatCell, computeHeatmapRanges, computeDumbbellRanges, } from '../components/table.js';
18
18
  // Height per row unit (compact for print) - must match Python's ROW_HEIGHT_PX
@@ -139,16 +139,25 @@ function renderEchartsComponent(compType, spec, chartId, colSpan, itemHeight, an
139
139
  }
140
140
  const chartHeight = itemHeight - (title ? 24 : 0);
141
141
  // Note: Time series validation is handled by the linter when specs are parsed
142
+ // Resolve custom font for ECharts textStyle injection
143
+ const fontFamily = getFontsWithCustom(customTheme).family;
144
+ function applyFont(option) {
145
+ option.textStyle = { ...(option.textStyle ?? {}), fontFamily };
146
+ }
142
147
  // Generate light theme options
143
148
  const specLight = { ...spec, theme: 'light', height: chartHeight, customTheme };
144
149
  const optionLight = builder(specLight);
145
150
  if (!optionLight) {
146
151
  return { html: null, script: null };
147
152
  }
153
+ applyFont(optionLight);
148
154
  const optionJsonLight = serializeOption(optionLight);
149
155
  // Generate dark theme options
150
156
  const specDark = { ...spec, theme: 'dark', height: chartHeight, customTheme };
151
157
  const optionDark = builder(specDark);
158
+ if (optionDark) {
159
+ applyFont(optionDark);
160
+ }
152
161
  const optionJsonDark = optionDark ? serializeOption(optionDark) : null;
153
162
  const anchorAttr = anchorId ? ` id="${anchorId}"` : '';
154
163
  const html = `
@@ -2,6 +2,7 @@
2
2
  * HTML templates for dashboard generation.
3
3
  */
4
4
  import { ECHARTS_CDN, PALETTE, COLORS, getThemeColors, getThemeColorsWithCustom, getPaletteWithCustom, getFontsWithCustom, getLinkColor, PRINT_PORTRAIT_WIDTH, PRINT_LANDSCAPE_WIDTH, } from '../core/themes.js';
5
+ import { generateExportCss, generateExportJs } from '../core/export.js';
5
6
  /**
6
7
  * Escape HTML special characters
7
8
  */
@@ -102,7 +103,7 @@ export function generateDashboardCss(customTheme, orientation = 'portrait') {
102
103
  margin-bottom: 8px;
103
104
  }
104
105
  .page-title {
105
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
106
+ font-family: var(--font-family);
106
107
  font-size: 18px;
107
108
  font-weight: 900;
108
109
  margin-bottom: 12px;
@@ -118,7 +119,7 @@ export function generateDashboardCss(customTheme, orientation = 'portrait') {
118
119
  border-top: 1px solid var(--grid);
119
120
  }
120
121
  .section-title {
121
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
122
+ font-family: var(--font-family);
122
123
  font-size: 14px;
123
124
  font-weight: 900;
124
125
  letter-spacing: 0.05em;
@@ -127,7 +128,7 @@ export function generateDashboardCss(customTheme, orientation = 'portrait') {
127
128
  margin-bottom: 16px;
128
129
  }
129
130
  .chart-title {
130
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: 700;
131
+ font-family: var(--font-family); font-weight: 700;
131
132
  font-size: 10px;
132
133
  font-weight: bold;
133
134
  color: var(--text);
@@ -156,6 +157,7 @@ export function generateDashboardCss(customTheme, orientation = 'portrait') {
156
157
  .grid-item { break-inside: avoid; page-break-inside: avoid; }
157
158
  .dashboard-section { break-inside: avoid; page-break-inside: avoid; }
158
159
  .dashboard-section.page-break { page-break-before: always; }
160
+ .section-title { break-after: avoid; page-break-after: avoid; }
159
161
  .data-table { break-inside: avoid; page-break-inside: avoid; }
160
162
  .theme-toggle { display: none; }
161
163
  .title-row { break-after: avoid; page-break-after: avoid; }
@@ -171,7 +173,7 @@ export function generateDashboardCss(customTheme, orientation = 'portrait') {
171
173
  transition: background 0.3s;
172
174
  }
173
175
  .big-value {
174
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: 700;
176
+ font-family: var(--font-family); font-weight: 700;
175
177
  font-size: 28px;
176
178
  font-weight: bold;
177
179
  color: var(--text);
@@ -186,7 +188,7 @@ export function generateDashboardCss(customTheme, orientation = 'portrait') {
186
188
  font-size: 16px;
187
189
  }
188
190
  .delta .value {
189
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
191
+ font-family: var(--font-family);
190
192
  font-size: 16px;
191
193
  font-weight: bold;
192
194
  }
@@ -243,7 +245,7 @@ export function generateDashboardCss(customTheme, orientation = 'portrait') {
243
245
  width: 100%; border-collapse: collapse; font-size: 11px;
244
246
  }
245
247
  .data-table th {
246
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 9px; font-weight: bold;
248
+ font-family: var(--font-family); font-size: 9px; font-weight: bold;
247
249
  color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.03em;
248
250
  padding: 6px 8px; border-bottom: 1px solid var(--text); text-align: left;
249
251
  }
@@ -289,7 +291,7 @@ export function generateDashboardCss(customTheme, orientation = 'portrait') {
289
291
  margin-bottom: 4px;
290
292
  }
291
293
  .inline-header-text {
292
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
294
+ font-family: var(--font-family);
293
295
  font-size: 11px;
294
296
  font-weight: 700;
295
297
  color: var(--text);
@@ -318,7 +320,7 @@ export function generateDashboardCss(customTheme, orientation = 'portrait') {
318
320
  .pct-bar-value {
319
321
  font-size: 11px;
320
322
  color: var(--text);
321
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
323
+ font-family: var(--font-family);
322
324
  font-weight: 700;
323
325
  min-width: 32px;
324
326
  flex-shrink: 0;
@@ -339,7 +341,7 @@ export function generateDashboardCss(customTheme, orientation = 'portrait') {
339
341
  padding: 4px 8px;
340
342
  border-radius: 4px;
341
343
  font-size: 11px;
342
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
344
+ font-family: var(--font-family);
343
345
  white-space: nowrap;
344
346
  opacity: 0;
345
347
  visibility: hidden;
@@ -360,6 +362,8 @@ export function generateDashboardHtml(pageTitle, componentsHtml, chartScripts, t
360
362
  const initialThemeClass = theme === 'dark' ? 'theme-dark' : 'theme-light';
361
363
  const continuousClass = continuous ? ' dashboard-continuous' : '';
362
364
  const css = generateDashboardCss(customTheme, orientation);
365
+ const exportCss = generateExportCss();
366
+ const exportJs = generateExportJs();
363
367
  return `<!DOCTYPE html>
364
368
  <html lang="en">
365
369
  <head>
@@ -369,6 +373,7 @@ export function generateDashboardHtml(pageTitle, componentsHtml, chartScripts, t
369
373
  <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
370
374
  ${extraScripts}
371
375
  <style>${css}
376
+ ${exportCss}
372
377
  </style>
373
378
  </head>
374
379
  <body class="${initialThemeClass}">
@@ -416,6 +421,8 @@ export function generateDashboardHtml(pageTitle, componentsHtml, chartScripts, t
416
421
  }
417
422
  });
418
423
  });
424
+
425
+ ${exportJs}
419
426
  </script>
420
427
  </body>
421
428
  </html>`;
@@ -441,6 +448,8 @@ export function generateTestHarnessCss() {
441
448
  --grid: ${colorsLight.border};
442
449
  --red: ${colorsLight.accent};
443
450
  --link: ${linkColor};
451
+ --font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
452
+ --font-mono: 'Consolas', 'Monaco', monospace;
444
453
  }
445
454
  body.theme-dark {
446
455
  --bg: ${colorsDark.background};
@@ -453,7 +462,7 @@ export function generateTestHarnessCss() {
453
462
  }
454
463
 
455
464
  body {
456
- font-family: 'Inter', sans-serif;
465
+ font-family: var(--font-family);
457
466
  background: var(--bg);
458
467
  color: var(--text);
459
468
  transition: background-color 0.3s, color 0.3s;
@@ -598,7 +607,7 @@ export function generateTestHarnessCss() {
598
607
  margin-bottom: 40px;
599
608
  }
600
609
  .section-title {
601
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
610
+ font-family: var(--font-family);
602
611
  font-size: 14px;
603
612
  font-weight: 900;
604
613
  letter-spacing: 0.05em;
@@ -628,7 +637,7 @@ export function generateTestHarnessCss() {
628
637
  margin-bottom: 8px;
629
638
  }
630
639
  .page-title {
631
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
640
+ font-family: var(--font-family);
632
641
  font-size: 18px;
633
642
  font-weight: 900;
634
643
  margin-bottom: 12px;
@@ -644,7 +653,7 @@ export function generateTestHarnessCss() {
644
653
  border-top: 1px solid var(--grid);
645
654
  }
646
655
  .chart-title {
647
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: 700;
656
+ font-family: var(--font-family); font-weight: 700;
648
657
  font-size: 10px;
649
658
  font-weight: bold;
650
659
  color: var(--text);
@@ -682,7 +691,7 @@ export function generateTestHarnessCss() {
682
691
 
683
692
  /* Component styles */
684
693
  .big-value {
685
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: 700;
694
+ font-family: var(--font-family); font-weight: 700;
686
695
  font-size: 28px;
687
696
  font-weight: bold;
688
697
  color: var(--text);
@@ -697,7 +706,7 @@ export function generateTestHarnessCss() {
697
706
  font-size: 16px;
698
707
  }
699
708
  .delta .value {
700
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
709
+ font-family: var(--font-family);
701
710
  font-size: 16px;
702
711
  font-weight: bold;
703
712
  }
@@ -751,7 +760,7 @@ export function generateTestHarnessCss() {
751
760
  width: 100%; border-collapse: collapse; font-size: 11px;
752
761
  }
753
762
  .data-table th {
754
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 9px; font-weight: bold;
763
+ font-family: var(--font-family); font-size: 9px; font-weight: bold;
755
764
  color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.03em;
756
765
  padding: 6px 8px; border-bottom: 1px solid var(--text); text-align: left;
757
766
  }
@@ -778,7 +787,7 @@ export function generateTestHarnessCss() {
778
787
  margin-bottom: 4px;
779
788
  }
780
789
  .inline-header-text {
781
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
790
+ font-family: var(--font-family);
782
791
  font-size: 11px;
783
792
  font-weight: 700;
784
793
  color: var(--text);
@@ -808,7 +817,7 @@ export function generateTestHarnessCss() {
808
817
  .pct-bar-value {
809
818
  font-size: 11px;
810
819
  color: var(--text);
811
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
820
+ font-family: var(--font-family);
812
821
  font-weight: 700;
813
822
  min-width: 32px;
814
823
  flex-shrink: 0;
@@ -830,7 +839,7 @@ export function generateTestHarnessCss() {
830
839
  padding: 4px 8px;
831
840
  border-radius: 4px;
832
841
  font-size: 11px;
833
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
842
+ font-family: var(--font-family);
834
843
  white-space: nowrap;
835
844
  opacity: 0;
836
845
  visibility: hidden;
@@ -874,6 +883,7 @@ export function generateTestHarnessCss() {
874
883
  .grid-item { break-inside: avoid; page-break-inside: avoid; }
875
884
  .dashboard-section { break-inside: avoid; page-break-inside: avoid; }
876
885
  .dashboard-section.page-break { page-break-before: always; }
886
+ .section-title { break-after: avoid; page-break-after: avoid; }
877
887
  .data-table { break-inside: avoid; page-break-inside: avoid; }
878
888
  header { display: none; }
879
889
  .annotation-bar { display: none; }
@@ -974,6 +984,8 @@ export function generateTestHarnessHtml(pageTitle, navItems, totalCharts, typesC
974
984
  const initialThemeClass = theme === 'dark' ? 'theme-dark' : 'theme-light';
975
985
  const css = generateTestHarnessCss();
976
986
  const sparklineJs = generateSparklineTooltipJs();
987
+ const exportCss = generateExportCss();
988
+ const exportJs = generateExportJs();
977
989
  return `<!DOCTYPE html>
978
990
  <html lang="en">
979
991
  <head>
@@ -983,6 +995,7 @@ export function generateTestHarnessHtml(pageTitle, navItems, totalCharts, typesC
983
995
  <script src="${ECHARTS_CDN}"></script>
984
996
  <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
985
997
  <style>${css}
998
+ ${exportCss}
986
999
  </style>
987
1000
  </head>
988
1001
  <body class="${initialThemeClass}">
@@ -1036,6 +1049,8 @@ export function generateTestHarnessHtml(pageTitle, navItems, totalCharts, typesC
1036
1049
  ${chartScripts}
1037
1050
 
1038
1051
  ${sparklineJs}
1052
+
1053
+ ${exportJs}
1039
1054
  </script>
1040
1055
  </body>
1041
1056
  </html>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mviz",
3
- "version": "1.6.2",
3
+ "version": "1.6.4",
4
4
  "description": "A chart & report builder designed for use by AI.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -83,6 +83,6 @@
83
83
  "dependencies": {
84
84
  "ajv": "^8.17.1",
85
85
  "ajv-formats": "^3.0.1",
86
- "beautiful-mermaid": "^0.1.3"
86
+ "beautiful-mermaid": "^1.1.3"
87
87
  }
88
88
  }