eleventy-plugin-uncharted 0.9.1 → 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 +15 -5
- package/eleventy.config.js +92 -1
- package/package.json +8 -2
- package/src/image/index.js +205 -0
- package/src/image/queue.js +62 -0
- package/src/image/renderer.js +277 -0
- package/src/renderers/bubble.js +5 -6
- package/src/renderers/scatter.js +13 -3
- package/src/renderers/timeseries.js +4 -2
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
|
|
package/eleventy.config.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
+
}
|
package/src/renderers/bubble.js
CHANGED
|
@@ -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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
185
|
+
const sizeLabel = sizeTitle || sizeKey;
|
|
186
|
+
tooltipText += ` — ${sizeLabel}: ${fmtSizeVal}`;
|
|
188
187
|
}
|
|
189
188
|
|
|
190
189
|
// Build style string with size scale
|
package/src/renderers/scatter.js
CHANGED
|
@@ -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
|
|
173
|
-
let
|
|
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
|
-
|
|
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
|
|
180
|
-
|
|
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
|
/**
|