eleventy-plugin-uncharted 0.9.0 → 1.0.0-beta.1

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/css/uncharted.css CHANGED
@@ -313,6 +313,11 @@
313
313
  background-color: var(--color);
314
314
  }
315
315
 
316
+ .chart-stacked-bar .bar-fill[title]:hover {
317
+ filter: brightness(1.15);
318
+ cursor: default;
319
+ }
320
+
316
321
  .chart-stacked-bar .bar-value {
317
322
  display: flex;
318
323
  align-items: center;
@@ -374,6 +379,11 @@
374
379
  background-color: var(--color);
375
380
  }
376
381
 
382
+ .chart-stacked-column .column-segment[title]:hover {
383
+ filter: brightness(1.15);
384
+ cursor: default;
385
+ }
386
+
377
387
  .chart-stacked-column .column-labels {
378
388
  grid-row: 2;
379
389
  display: flex;
@@ -604,7 +614,7 @@
604
614
  }
605
615
 
606
616
  :is(.chart-dot, .chart-line) .dot[title]:hover {
607
- transform: translate(-50%, 50%) scale(1.3);
617
+ transform: translate(-50%, 50%) scale(1.3) !important;
608
618
  z-index: 1;
609
619
  }
610
620
 
@@ -742,7 +752,7 @@
742
752
  }
743
753
 
744
754
  .chart-bubble .dot[title]:hover {
745
- transform: translate(-50%, 50%) scale(1.3);
755
+ transform: translate(-50%, 50%) scale(1.3) !important;
746
756
  z-index: 1;
747
757
  }
748
758
 
@@ -932,7 +942,7 @@
932
942
  }
933
943
 
934
944
  .chart-scatter .dot[title]:hover {
935
- transform: translate(-50%, 50%) scale(1.3);
945
+ transform: translate(-50%, 50%) scale(1.3) !important;
936
946
  z-index: 1;
937
947
  }
938
948
 
@@ -1038,7 +1048,7 @@
1038
1048
  }
1039
1049
 
1040
1050
  .chart-timeseries .dot[title]:hover {
1041
- transform: translate(-50%, 50%) scale(1.3);
1051
+ transform: translate(-50%, 50%) scale(1.3) !important;
1042
1052
  z-index: 1;
1043
1053
  }
1044
1054
 
@@ -1051,7 +1061,7 @@
1051
1061
 
1052
1062
  :is(.chart-line, .chart-timeseries).no-dots .dot:hover {
1053
1063
  background-color: var(--color);
1054
- transform: translate(-50%, 50%) scale(1.3);
1064
+ transform: translate(-50%, 50%) scale(1.3) !important;
1055
1065
  z-index: 1;
1056
1066
  }
1057
1067
 
@@ -4,6 +4,15 @@ import { renderers } from './src/renderers/index.js';
4
4
  import { loadCSV } from './src/csv.js';
5
5
  import { normalizeConfig } from './src/config.js';
6
6
  import { resolveColumns } from './src/columns.js';
7
+ import {
8
+ normalizeImageOptions,
9
+ queueChartForImage,
10
+ processQueue,
11
+ getImageUrl,
12
+ getStoredImageUrl,
13
+ shouldSkipInDevMode,
14
+ clearImageUrls
15
+ } from './src/image/index.js';
7
16
 
8
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
18
 
@@ -18,6 +27,14 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
27
  * @param {boolean} [options.dataPassthrough] - Copy CSV files to public dataPath (default: false)
19
28
  * @param {string} [options.dataPath] - Public URL path for CSV files (default: '/data/')
20
29
  * @param {boolean|string} [options.downloadData] - Enable download links globally (individual charts can override)
30
+ * @param {Object} [options.image] - Image generation options
31
+ * @param {boolean} [options.image.enabled=false] - Enable PNG image generation
32
+ * @param {string} [options.image.outputDir='/images/charts/'] - Output directory for images
33
+ * @param {number} [options.image.width=800] - Default image width in pixels
34
+ * @param {number} [options.image.height=400] - Default image height in pixels
35
+ * @param {number} [options.image.scale=2] - Device scale factor (2 for retina)
36
+ * @param {string} [options.image.background='#ffffff'] - Default background color
37
+ * @param {boolean} [options.image.skipDev=true] - Skip image generation during --serve/--watch
21
38
  */
22
39
  export default function(eleventyConfig, options = {}) {
23
40
  // Directory config from Eleventy (populated by eleventy.directories event)
@@ -49,6 +66,13 @@ export default function(eleventyConfig, options = {}) {
49
66
  const dataPath = options.dataPath || '/data/';
50
67
  const globalDownloadData = options.downloadData ?? false;
51
68
 
69
+ // Image generation options
70
+ const imageOptions = normalizeImageOptions(options.image);
71
+ const skipImageGeneration = shouldSkipInDevMode(imageOptions);
72
+
73
+ // Clear image URLs at start of each build
74
+ clearImageUrls();
75
+
52
76
  // Automatic CSS handling
53
77
  if (injectCss) {
54
78
  const cssSource = path.join(__dirname, 'css/uncharted.css');
@@ -153,7 +177,8 @@ export default function(eleventyConfig, options = {}) {
153
177
  // Resolve column mappings
154
178
  const columns = resolveColumns(normalizedConfig, data, chartType);
155
179
 
156
- return renderer({
180
+ // Render the chart HTML
181
+ let chartHtml = renderer({
157
182
  ...normalizedConfig,
158
183
  id: chartId,
159
184
  data,
@@ -162,5 +187,71 @@ export default function(eleventyConfig, options = {}) {
162
187
  downloadDataUrl,
163
188
  _columns: columns
164
189
  });
190
+
191
+ // Add aria-label for accessibility
192
+ const altText = chartConfig.alt || chartConfig.title || chartId;
193
+ chartHtml = chartHtml.replace(
194
+ /^<figure([^>]*class="chart[^"]*")/,
195
+ `<figure aria-label="${altText}"$1`
196
+ );
197
+
198
+ // Handle image generation
199
+ const chartImageEnabled = chartConfig.image?.enabled ?? imageOptions.enabled;
200
+ if (chartImageEnabled && !skipImageGeneration && eleventyDirs?.output) {
201
+ // Queue chart for image generation
202
+ queueChartForImage(chartId, chartHtml, chartConfig, imageOptions, eleventyDirs.output);
203
+
204
+ // Add data-chart-image and data-chart-alt attributes to the figure element
205
+ const imageUrl = getImageUrl(chartId, chartConfig, imageOptions);
206
+ chartHtml = chartHtml.replace(
207
+ /^<figure([^>]*aria-label="[^"]*"[^>]*class="chart[^"]*")/,
208
+ `<figure$1 data-chart-image="${imageUrl}" data-chart-alt="${altText}"`
209
+ );
210
+ }
211
+
212
+ return chartHtml;
213
+ });
214
+
215
+ // Shortcode to get chart image URL
216
+ eleventyConfig.addShortcode('chartImageUrl', function(chartId) {
217
+ // First check if we have a stored URL from queueing
218
+ const storedUrl = getStoredImageUrl(chartId);
219
+ if (storedUrl) return storedUrl;
220
+
221
+ // Otherwise, look up chart config and compute URL
222
+ const pageCharts = this.page?.charts;
223
+ const globalCharts = this.charts || this.ctx?.charts;
224
+ const chartConfig = pageCharts?.[chartId] || globalCharts?.[chartId];
225
+
226
+ if (!chartConfig) return '';
227
+
228
+ return getImageUrl(chartId, chartConfig, imageOptions);
229
+ });
230
+
231
+ // Filter to replace chart HTML with image tags (for RSS feeds)
232
+ eleventyConfig.addFilter('chartToImage', function(content, baseUrl = '') {
233
+ if (!content) return content;
234
+
235
+ // Find chart figures with data-chart-image and data-chart-alt attributes and replace with img tags
236
+ return content.replace(
237
+ /<figure[^>]*class="chart[^"]*"[^>]*data-chart-image="([^"]+)"[^>]*data-chart-alt="([^"]+)"[^>]*>[\s\S]*?<\/figure>/g,
238
+ (match, src, alt) => `<img src="${baseUrl}${src}" alt="${alt}">`
239
+ );
240
+ });
241
+
242
+ // Process image queue after build completes
243
+ eleventyConfig.on('eleventy.after', async () => {
244
+ if (!imageOptions.enabled || skipImageGeneration) return;
245
+
246
+ const results = await processQueue(imageOptions);
247
+
248
+ if (results.skipped) return;
249
+
250
+ if (results.success.length > 0) {
251
+ console.log(`[uncharted] Generated ${results.success.length} chart image(s)`);
252
+ }
253
+ if (results.failed.length > 0) {
254
+ console.warn(`[uncharted] Failed to generate ${results.failed.length} chart image(s)`);
255
+ }
165
256
  });
166
257
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eleventy-plugin-uncharted",
3
- "version": "0.9.0",
3
+ "version": "1.0.0-beta.1",
4
4
  "description": "An Eleventy plugin that renders CSS-based charts from CSV data using shortcodes",
5
5
  "main": "eleventy.config.js",
6
6
  "type": "module",
@@ -32,7 +32,13 @@
32
32
  "url": "https://github.com/slunsford/uncharted/issues"
33
33
  },
34
34
  "peerDependencies": {
35
- "@11ty/eleventy": ">=2.0.0 || >=4.0.0-0"
35
+ "@11ty/eleventy": ">=2.0.0 || >=4.0.0-0",
36
+ "puppeteer": ">=21.0.0"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "puppeteer": {
40
+ "optional": true
41
+ }
36
42
  },
37
43
  "devDependencies": {
38
44
  "@11ty/eleventy": "^3.0.0 || ^4.0.0-0"
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Chart Image Generation Module
3
+ * Orchestrates PNG image generation for charts.
4
+ */
5
+
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import { fileURLToPath } from 'url';
9
+ import { queueChart, getAndClearQueue, hasQueuedCharts, getQueueSize, clearQueue } from './queue.js';
10
+ import { renderCharts, isPuppeteerAvailable } from './renderer.js';
11
+
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+
14
+ /**
15
+ * @typedef {Object} ImageOptions
16
+ * @property {boolean} [enabled=false] - Enable image generation
17
+ * @property {string} [outputDir='/images/charts/'] - Output directory for images (URL path)
18
+ * @property {number} [width=800] - Default image width
19
+ * @property {number} [height=400] - Default image height
20
+ * @property {number} [scale=2] - Device scale factor (2 for retina)
21
+ * @property {string} [background='#ffffff'] - Default background color
22
+ * @property {string[]} [stylesheets=[]] - External stylesheet URLs to include (e.g., Font Awesome)
23
+ * @property {boolean} [skipDev=true] - Skip generation during --serve/--watch
24
+ */
25
+
26
+ /** @type {Map<string, string>} Chart ID -> Image URL */
27
+ const imageUrls = new Map();
28
+
29
+ /**
30
+ * Default image options.
31
+ * @type {ImageOptions}
32
+ */
33
+ const defaultOptions = {
34
+ enabled: false,
35
+ outputDir: '/images/charts/',
36
+ width: 800,
37
+ height: 400,
38
+ scale: 2,
39
+ background: '#ffffff',
40
+ stylesheets: [],
41
+ skipDev: true
42
+ };
43
+
44
+ /**
45
+ * Normalize image options by merging defaults.
46
+ * @param {Partial<ImageOptions>} options
47
+ * @returns {ImageOptions}
48
+ */
49
+ export function normalizeImageOptions(options = {}) {
50
+ return { ...defaultOptions, ...options };
51
+ }
52
+
53
+ /**
54
+ * Get the output path for a chart image.
55
+ * @param {string} chartId - Chart identifier
56
+ * @param {Object} chartConfig - Chart configuration
57
+ * @param {ImageOptions} globalOptions - Global image options
58
+ * @param {string} outputDir - Eleventy output directory
59
+ * @returns {string} Absolute file path for the image
60
+ */
61
+ export function getImageOutputPath(chartId, chartConfig, globalOptions, outputDir) {
62
+ const imageConfig = chartConfig.image || {};
63
+ const filename = imageConfig.filename || chartId;
64
+ const dir = globalOptions.outputDir.replace(/^\//, '').replace(/\/$/, '');
65
+
66
+ return path.join(outputDir, dir, `${filename}.png`);
67
+ }
68
+
69
+ /**
70
+ * Get the URL for a chart image.
71
+ * @param {string} chartId - Chart identifier
72
+ * @param {Object} chartConfig - Chart configuration
73
+ * @param {ImageOptions} globalOptions - Global image options
74
+ * @returns {string} URL path to the image
75
+ */
76
+ export function getImageUrl(chartId, chartConfig, globalOptions) {
77
+ const imageConfig = chartConfig.image || {};
78
+ const filename = imageConfig.filename || chartId;
79
+ const dir = globalOptions.outputDir.endsWith('/')
80
+ ? globalOptions.outputDir
81
+ : globalOptions.outputDir + '/';
82
+
83
+ return `${dir}${filename}.png`;
84
+ }
85
+
86
+ /**
87
+ * Store the image URL for a chart (for shortcode lookup).
88
+ * @param {string} chartId
89
+ * @param {string} url
90
+ */
91
+ export function storeImageUrl(chartId, url) {
92
+ imageUrls.set(chartId, url);
93
+ }
94
+
95
+ /**
96
+ * Get the stored image URL for a chart.
97
+ * @param {string} chartId
98
+ * @returns {string|null}
99
+ */
100
+ export function getStoredImageUrl(chartId) {
101
+ return imageUrls.get(chartId) || null;
102
+ }
103
+
104
+ /**
105
+ * Clear all stored image URLs.
106
+ */
107
+ export function clearImageUrls() {
108
+ imageUrls.clear();
109
+ }
110
+
111
+ /**
112
+ * Check if image generation should be skipped (dev mode).
113
+ * @param {ImageOptions} options
114
+ * @returns {boolean}
115
+ */
116
+ export function shouldSkipInDevMode(options) {
117
+ if (!options.skipDev) return false;
118
+
119
+ // Check for common dev mode indicators
120
+ const args = process.argv.join(' ').toLowerCase();
121
+ return args.includes('--serve') || args.includes('--watch');
122
+ }
123
+
124
+ /**
125
+ * Queue a chart for image generation.
126
+ * @param {string} chartId - Chart identifier
127
+ * @param {string} chartHtml - Rendered chart HTML
128
+ * @param {Object} chartConfig - Chart configuration
129
+ * @param {ImageOptions} globalOptions - Global image options
130
+ * @param {string} outputDir - Eleventy output directory
131
+ */
132
+ export function queueChartForImage(chartId, chartHtml, chartConfig, globalOptions, outputDir) {
133
+ const imageConfig = chartConfig.image || {};
134
+
135
+ // Merge global and per-chart config
136
+ const config = {
137
+ width: imageConfig.width || globalOptions.width,
138
+ height: imageConfig.height || globalOptions.height,
139
+ scale: imageConfig.scale || globalOptions.scale,
140
+ background: imageConfig.background || globalOptions.background,
141
+ filename: imageConfig.filename || chartId
142
+ };
143
+
144
+ const outputPath = getImageOutputPath(chartId, chartConfig, globalOptions, outputDir);
145
+ const url = getImageUrl(chartId, chartConfig, globalOptions);
146
+
147
+ // Store URL for shortcode lookup
148
+ storeImageUrl(chartId, url);
149
+
150
+ // Add to queue
151
+ queueChart(chartId, {
152
+ id: chartId,
153
+ html: chartHtml,
154
+ config,
155
+ outputPath
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Process the chart image queue and generate all images.
161
+ * @param {ImageOptions} options - Image options
162
+ * @returns {Promise<{success: string[], failed: string[], skipped: boolean}>}
163
+ */
164
+ export async function processQueue(options) {
165
+ if (!hasQueuedCharts()) {
166
+ return { success: [], failed: [], skipped: false };
167
+ }
168
+
169
+ // Check if Puppeteer is available
170
+ const available = await isPuppeteerAvailable();
171
+ if (!available) {
172
+ const count = getQueueSize();
173
+ clearQueue();
174
+ console.warn(`[uncharted] Puppeteer not installed. Skipped ${count} chart image(s).`);
175
+ return { success: [], failed: [], skipped: true };
176
+ }
177
+
178
+ // Load CSS
179
+ const cssPath = path.join(__dirname, '../../css/uncharted.css');
180
+ let css = '';
181
+ try {
182
+ css = fs.readFileSync(cssPath, 'utf-8');
183
+ } catch (err) {
184
+ console.error('[uncharted] Failed to load CSS for image rendering:', err.message);
185
+ clearQueue();
186
+ return { success: [], failed: [], skipped: true };
187
+ }
188
+
189
+ // Get charts and clear queue
190
+ const charts = getAndClearQueue();
191
+
192
+ // Render all charts
193
+ const results = await renderCharts(charts, css, {
194
+ width: options.width,
195
+ height: options.height,
196
+ scale: options.scale,
197
+ background: options.background,
198
+ stylesheets: options.stylesheets
199
+ });
200
+
201
+ return { ...results, skipped: false };
202
+ }
203
+
204
+ // Re-export queue utilities for direct access if needed
205
+ export { hasQueuedCharts, getQueueSize, clearQueue };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Chart Image Queue
3
+ * Tracks charts that need PNG image generation during the build.
4
+ */
5
+
6
+ /** @type {Map<string, ChartImageData>} */
7
+ const queue = new Map();
8
+
9
+ /**
10
+ * @typedef {Object} ChartImageData
11
+ * @property {string} id - Chart identifier
12
+ * @property {string} html - Rendered chart HTML
13
+ * @property {Object} config - Image configuration
14
+ * @property {number} [config.width] - Image width in pixels
15
+ * @property {number} [config.height] - Image height in pixels
16
+ * @property {number} [config.scale] - Device scale factor (e.g., 2 for retina)
17
+ * @property {string} [config.background] - Background color
18
+ * @property {string} [config.filename] - Custom filename (without extension)
19
+ * @property {string} outputPath - Output file path for the image
20
+ */
21
+
22
+ /**
23
+ * Add a chart to the image generation queue.
24
+ * @param {string} id - Chart identifier
25
+ * @param {ChartImageData} data - Chart data for image generation
26
+ */
27
+ export function queueChart(id, data) {
28
+ queue.set(id, data);
29
+ }
30
+
31
+ /**
32
+ * Get all queued charts and clear the queue.
33
+ * @returns {ChartImageData[]} Array of queued chart data
34
+ */
35
+ export function getAndClearQueue() {
36
+ const charts = Array.from(queue.values());
37
+ queue.clear();
38
+ return charts;
39
+ }
40
+
41
+ /**
42
+ * Check if there are any charts queued for image generation.
43
+ * @returns {boolean}
44
+ */
45
+ export function hasQueuedCharts() {
46
+ return queue.size > 0;
47
+ }
48
+
49
+ /**
50
+ * Get the number of queued charts.
51
+ * @returns {number}
52
+ */
53
+ export function getQueueSize() {
54
+ return queue.size;
55
+ }
56
+
57
+ /**
58
+ * Clear the queue without returning charts.
59
+ */
60
+ export function clearQueue() {
61
+ queue.clear();
62
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Chart Image Renderer
3
+ * Uses Puppeteer to render charts as PNG images.
4
+ */
5
+
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import { createRequire } from 'module';
9
+
10
+ let puppeteer = null;
11
+
12
+ /**
13
+ * Attempt to load Puppeteer dynamically.
14
+ * Tries multiple resolution strategies to find Puppeteer in the consuming project.
15
+ * @returns {Promise<Object|null>} Puppeteer module or null if not available
16
+ */
17
+ async function loadPuppeteer() {
18
+ if (puppeteer !== null) {
19
+ return puppeteer;
20
+ }
21
+
22
+ // Try to resolve from the current working directory (consuming project)
23
+ const require = createRequire(path.join(process.cwd(), 'package.json'));
24
+
25
+ try {
26
+ // Use require.resolve to find puppeteer in the project's node_modules
27
+ const puppeteerPath = require.resolve('puppeteer');
28
+ puppeteer = await import(puppeteerPath);
29
+ return puppeteer;
30
+ } catch (e) {
31
+ // Fallback: try direct import (works if puppeteer is in plugin's deps)
32
+ try {
33
+ puppeteer = await import('puppeteer');
34
+ return puppeteer;
35
+ } catch (e2) {
36
+ puppeteer = false; // Mark as unavailable
37
+ return null;
38
+ }
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Check if Puppeteer is available.
44
+ * @returns {Promise<boolean>}
45
+ */
46
+ export async function isPuppeteerAvailable() {
47
+ const pptr = await loadPuppeteer();
48
+ return pptr !== null && pptr !== false;
49
+ }
50
+
51
+ /**
52
+ * Check if a string is a URL (vs a local file path).
53
+ * @param {string} str
54
+ * @returns {boolean}
55
+ */
56
+ function isUrl(str) {
57
+ return str.startsWith('http://') || str.startsWith('https://') || str.startsWith('//');
58
+ }
59
+
60
+ /**
61
+ * Load stylesheets - URLs become link tags, local files stay as relative paths.
62
+ * Since we render from a temp file in the project root, relative paths work.
63
+ * @param {string[]} stylesheets - Array of URLs or file paths
64
+ * @returns {{links: string, inlined: string}}
65
+ */
66
+ function loadStylesheets(stylesheets) {
67
+ const links = [];
68
+ const inlined = [];
69
+
70
+ for (const stylesheet of stylesheets) {
71
+ if (isUrl(stylesheet)) {
72
+ links.push(` <link rel="stylesheet" href="${stylesheet}">`);
73
+ } else {
74
+ // Local file - keep relative path (works because temp HTML is in project root)
75
+ const filePath = path.resolve(process.cwd(), stylesheet);
76
+ if (fs.existsSync(filePath)) {
77
+ links.push(` <link rel="stylesheet" href="${stylesheet}">`);
78
+ } else {
79
+ console.warn(`[uncharted] Could not find stylesheet "${stylesheet}"`);
80
+ }
81
+ }
82
+ }
83
+
84
+ return {
85
+ links: links.join('\n'),
86
+ inlined: inlined.join('\n\n')
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Build a standalone HTML document for chart rendering.
92
+ * @param {string} chartHtml - The rendered chart HTML
93
+ * @param {string} css - The chart CSS
94
+ * @param {Object} options - Rendering options
95
+ * @param {string} [options.background] - Background color
96
+ * @param {string[]} [options.stylesheets] - URLs or local file paths for additional stylesheets
97
+ * @param {number} [options.height] - Image height in pixels
98
+ * @returns {string} Complete HTML document
99
+ */
100
+ function buildHtmlDocument(chartHtml, css, options = {}) {
101
+ const background = options.background || '#ffffff';
102
+ const stylesheets = options.stylesheets || [];
103
+ const height = options.height || 400;
104
+
105
+ // Remove chart-animate class - animations don't make sense in static images
106
+ chartHtml = chartHtml.replace(/\bchart-animate\b/g, '');
107
+
108
+ // Load stylesheets (URLs as links, local files inlined)
109
+ const { links: stylesheetLinks, inlined: inlinedStyles } = loadStylesheets(stylesheets);
110
+
111
+ return `<!DOCTYPE html>
112
+ <html>
113
+ <head>
114
+ <meta charset="utf-8">
115
+ ${stylesheetLinks}
116
+ <style>
117
+ * { margin: 0; padding: 0; box-sizing: border-box; }
118
+ html, body {
119
+ background: ${background};
120
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
121
+ }
122
+ body {
123
+ height: 100vh;
124
+ padding: 1rem;
125
+ }
126
+ .chart-container {
127
+ width: 100%;
128
+ height: 100%;
129
+ display: flex;
130
+ flex-direction: column;
131
+ }
132
+ .chart-container > .chart:not(.chart-donut):not(.chart-stacked-bar):not(.chart-sankey) {
133
+ flex: 1;
134
+ min-height: 0;
135
+ --chart-height: 100%;
136
+ }
137
+ .chart-container > .chart:not(.chart-donut):not(.chart-stacked-bar):not(.chart-sankey) .chart-body {
138
+ flex: 1;
139
+ min-height: 0;
140
+ }
141
+ /* Stacked bar: use natural height based on row count */
142
+ .chart-stacked-bar .chart-bars {
143
+ grid-auto-rows: auto;
144
+ }
145
+ /* Donut: just center it, don't stretch */
146
+ .chart-container:has(.chart-donut) {
147
+ justify-content: center;
148
+ }
149
+ /* Sankey: use natural height from --height-scale */
150
+ ${css}
151
+
152
+ /* Additional stylesheets (inlined) */
153
+ ${inlinedStyles}
154
+
155
+ /* Hide download link in images */
156
+ .chart-download { display: none; }
157
+ </style>
158
+ </head>
159
+ <body>
160
+ <div class="chart-container">
161
+ ${chartHtml}
162
+ </div>
163
+ </body>
164
+ </html>`;
165
+ }
166
+
167
+ /**
168
+ * Render a single chart to PNG using an existing Puppeteer page.
169
+ * @param {Object} page - Puppeteer page instance
170
+ * @param {Object} chart - Chart data
171
+ * @param {string} chart.html - Chart HTML
172
+ * @param {Object} chart.config - Image configuration
173
+ * @param {string} chart.outputPath - Output file path
174
+ * @param {string} css - Chart CSS content
175
+ * @param {Object} defaults - Default image options
176
+ * @returns {Promise<void>}
177
+ */
178
+ async function renderChart(page, chart, css, defaults) {
179
+ const config = { ...defaults, ...chart.config };
180
+ const width = config.width || 800;
181
+ const height = config.height || 400;
182
+ const scale = config.scale || 2;
183
+ const background = config.background || '#ffffff';
184
+ const stylesheets = config.stylesheets || defaults.stylesheets || [];
185
+
186
+ // Set viewport
187
+ await page.setViewport({
188
+ width,
189
+ height,
190
+ deviceScaleFactor: scale
191
+ });
192
+
193
+ // Build HTML content
194
+ const html = buildHtmlDocument(chart.html, css, { background, stylesheets, height });
195
+
196
+ // Write to temp file so relative paths in CSS resolve correctly
197
+ const tempHtmlPath = path.join(process.cwd(), `.uncharted-temp-${chart.id}.html`);
198
+ fs.writeFileSync(tempHtmlPath, html);
199
+
200
+ try {
201
+ // Navigate to the temp file
202
+ await page.goto(`file://${tempHtmlPath}`, { waitUntil: 'load' });
203
+ if (stylesheets.length > 0) {
204
+ // Wait for fonts to load
205
+ await page.evaluateHandle('document.fonts.ready');
206
+ }
207
+
208
+ // Ensure output directory exists
209
+ const outputDir = path.dirname(chart.outputPath);
210
+ if (!fs.existsSync(outputDir)) {
211
+ fs.mkdirSync(outputDir, { recursive: true });
212
+ }
213
+
214
+ // Take screenshot
215
+ await page.screenshot({
216
+ path: chart.outputPath,
217
+ type: 'png',
218
+ omitBackground: background === 'transparent'
219
+ });
220
+ } finally {
221
+ // Clean up temp file
222
+ fs.unlinkSync(tempHtmlPath);
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Render multiple charts to PNG images.
228
+ * @param {Object[]} charts - Array of chart data objects
229
+ * @param {string} css - Chart CSS content
230
+ * @param {Object} options - Rendering options
231
+ * @param {number} [options.width] - Default image width
232
+ * @param {number} [options.height] - Default image height
233
+ * @param {number} [options.scale] - Default device scale factor
234
+ * @param {string} [options.background] - Default background color
235
+ * @param {string[]} [options.stylesheets] - External stylesheet URLs to include
236
+ * @returns {Promise<{success: string[], failed: string[]}>} Results
237
+ */
238
+ export async function renderCharts(charts, css, options = {}) {
239
+ const pptr = await loadPuppeteer();
240
+
241
+ if (!pptr) {
242
+ console.warn('[uncharted] Puppeteer not installed. Skipping image generation.');
243
+ console.warn('[uncharted] Install puppeteer to enable: npm install puppeteer');
244
+ return { success: [], failed: charts.map(c => c.id) };
245
+ }
246
+
247
+ const results = { success: [], failed: [] };
248
+
249
+ let browser;
250
+ try {
251
+ browser = await pptr.default.launch({
252
+ headless: true,
253
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--allow-file-access-from-files']
254
+ });
255
+
256
+ const page = await browser.newPage();
257
+
258
+ for (const chart of charts) {
259
+ try {
260
+ await renderChart(page, chart, css, options);
261
+ results.success.push(chart.id);
262
+ } catch (err) {
263
+ console.error(`[uncharted] Failed to render image for chart "${chart.id}":`, err.message);
264
+ results.failed.push(chart.id);
265
+ }
266
+ }
267
+ } catch (err) {
268
+ console.error('[uncharted] Failed to launch browser:', err.message);
269
+ return { success: [], failed: charts.map(c => c.id) };
270
+ } finally {
271
+ if (browser) {
272
+ await browser.close();
273
+ }
274
+ }
275
+
276
+ return results;
277
+ }
@@ -177,14 +177,13 @@ export function renderBubble(config) {
177
177
  const icon = getSeriesIcon(dot.series);
178
178
  const iconClass = icon ? ' has-icon' : '';
179
179
 
180
- // Build tooltip
181
- let tooltipText = `${category}: ${formatNumber(dot.y, fmtY) || dot.y}`;
182
- if (seriesKey && dot.series !== 'default') {
183
- tooltipText = `${dot.series} - ${tooltipText}`;
184
- }
180
+ // Build tooltip: series: value, plus size if available
181
+ const fmtYVal = formatNumber(dot.y, fmtY) || dot.y;
182
+ let tooltipText = seriesList.length > 1 ? `${dot.series}: ${fmtYVal}` : `${fmtYVal}`;
185
183
  if (sizeKey && dot.rawSize !== null) {
186
184
  const fmtSizeVal = formatNumber(dot.rawSize, fmtSize) || dot.rawSize;
187
- tooltipText += ` [${fmtSizeVal}]`;
185
+ const sizeLabel = sizeTitle || sizeKey;
186
+ tooltipText += ` — ${sizeLabel}: ${fmtSizeVal}`;
188
187
  }
189
188
 
190
189
  // Build style string with size scale
@@ -22,7 +22,7 @@ export function renderDot(config) {
22
22
  if (chartType === 'dot') {
23
23
  console.warn(
24
24
  '[uncharted] Chart type "dot" is deprecated. ' +
25
- 'Migrate to "line" with showLines: false, or use "bubble" for sized dots.'
25
+ 'Migrate to "line" with lines: false, or use "bubble" for sized dots.'
26
26
  );
27
27
  }
28
28
 
@@ -1,6 +1,6 @@
1
1
  import { renderDot } from './dot.js';
2
2
 
3
3
  export function renderLine(config) {
4
- const connectDots = config.showLines !== false; // default true
4
+ const connectDots = config.lines !== false; // default true
5
5
  return renderDot({ ...config, connectDots, chartType: 'line' });
6
6
  }
@@ -169,12 +169,22 @@ export function renderScatter(config) {
169
169
  const icon = getSeriesIcon(dot.series);
170
170
  const iconClass = icon ? ' has-icon' : '';
171
171
 
172
- // Build tooltip with optional size value
173
- let tooltipText = dot.label ? `${dot.label}: (${fmtXVal}, ${fmtYVal})` : `(${fmtXVal}, ${fmtYVal})`;
172
+ // Build tooltip with series, axis titles, and optional size
173
+ let tooltipParts = [];
174
+ if (dot.label && seriesList.length > 1) {
175
+ tooltipParts.push(`${dot.label} (${dot.series})`);
176
+ } else if (dot.label) {
177
+ tooltipParts.push(dot.label);
178
+ } else if (seriesList.length > 1) {
179
+ tooltipParts.push(dot.series);
180
+ }
181
+ tooltipParts.push(`${xAxisTitle}: ${fmtXVal}, ${yAxisTitle}: ${fmtYVal}`);
174
182
  if (sizeKey && dot.rawSize !== null) {
175
183
  const fmtSizeVal = formatNumber(dot.rawSize, fmtSize) || dot.rawSize;
176
- tooltipText += ` [${fmtSizeVal}]`;
184
+ const sizeLabel = sizeTitle || sizeKey;
185
+ tooltipParts.push(`${sizeLabel}: ${fmtSizeVal}`);
177
186
  }
187
+ const tooltipText = tooltipParts.join(' — ');
178
188
 
179
189
  // Build style string with optional size scale
180
190
  let styleStr = `--dot-index: ${i}; --x: ${xPct.toFixed(2)}%; --value: ${yPct.toFixed(2)}%`;
@@ -176,8 +176,10 @@ function formatXLabel(value, isDate, range) {
176
176
  return `${month}<br>${year}`;
177
177
  }
178
178
 
179
- // Days/weeks range: show MMM D
180
- return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
179
+ // Days/weeks range: show D MMM
180
+ const day = date.getDate();
181
+ const month = date.toLocaleDateString('en-US', { month: 'short' });
182
+ return `${day} ${month}`;
181
183
  }
182
184
 
183
185
  /**