eleventy-plugin-uncharted 0.1.0
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 +203 -0
- package/css/uncharted.css +800 -0
- package/eleventy.config.js +108 -0
- package/package.json +40 -0
- package/src/csv.js +53 -0
- package/src/index.js +3 -0
- package/src/renderers/donut.js +109 -0
- package/src/renderers/dot.js +120 -0
- package/src/renderers/index.js +15 -0
- package/src/renderers/scatter.js +147 -0
- package/src/renderers/stacked-bar.js +89 -0
- package/src/renderers/stacked-column.js +182 -0
- package/src/utils.js +52 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { renderers } from './src/renderers/index.js';
|
|
4
|
+
import { loadCSV } from './src/csv.js';
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Uncharted - Eleventy CSS Charts Plugin
|
|
10
|
+
* @param {Object} eleventyConfig - Eleventy configuration object
|
|
11
|
+
* @param {Object} [options] - Plugin options
|
|
12
|
+
* @param {string} [options.dataDir] - Data directory path (defaults to _data)
|
|
13
|
+
* @param {boolean} [options.animate] - Enable animations globally (individual charts can override)
|
|
14
|
+
* @param {string} [options.cssPath] - Output path for stylesheet (default: '/css/uncharted.css')
|
|
15
|
+
* @param {boolean} [options.injectCss] - Automatically copy and inject CSS (default: true)
|
|
16
|
+
*/
|
|
17
|
+
export default function(eleventyConfig, options = {}) {
|
|
18
|
+
const dataDir = options.dataDir || '_data';
|
|
19
|
+
const globalAnimate = options.animate ?? false;
|
|
20
|
+
const cssPath = options.cssPath || '/css/uncharted.css';
|
|
21
|
+
const injectCss = options.injectCss ?? true;
|
|
22
|
+
|
|
23
|
+
// Automatic CSS handling
|
|
24
|
+
if (injectCss) {
|
|
25
|
+
const cssSource = path.join(__dirname, 'css/uncharted.css');
|
|
26
|
+
|
|
27
|
+
// Copy plugin's CSS to output (strip leading slash for passthrough)
|
|
28
|
+
eleventyConfig.addPassthroughCopy({
|
|
29
|
+
[cssSource]: cssPath.replace(/^\//, '')
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Inject stylesheet link into pages with charts
|
|
33
|
+
eleventyConfig.addTransform('uncharted-css', function(content) {
|
|
34
|
+
const outputPath = this.page.outputPath || '';
|
|
35
|
+
if (!outputPath.endsWith('.html')) return content;
|
|
36
|
+
|
|
37
|
+
const hasCharts = content.includes('class="chart ');
|
|
38
|
+
const hasStylesheet = content.includes('uncharted.css');
|
|
39
|
+
|
|
40
|
+
if (hasCharts && !hasStylesheet) {
|
|
41
|
+
const link = `<link rel="stylesheet" href="${cssPath}">\n `;
|
|
42
|
+
|
|
43
|
+
// Try to inject before first <style> or <link> in <head>
|
|
44
|
+
const headMatch = content.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
|
|
45
|
+
if (headMatch) {
|
|
46
|
+
const firstTagMatch = headMatch[1].match(/^([\s\S]*?)(<(?:style|link)\b)/i);
|
|
47
|
+
if (firstTagMatch) {
|
|
48
|
+
const insertPos = content.indexOf(headMatch[0]) +
|
|
49
|
+
headMatch[0].indexOf(headMatch[1]) +
|
|
50
|
+
firstTagMatch[1].length;
|
|
51
|
+
return content.slice(0, insertPos) + link + content.slice(insertPos);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Fallback: after <head>
|
|
56
|
+
return content.replace(/<head([^>]*)>/i, `<head$1>\n ${link}`);
|
|
57
|
+
}
|
|
58
|
+
return content;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
eleventyConfig.addShortcode('chart', function(chartId) {
|
|
63
|
+
// Resolve data directory relative to the current working directory
|
|
64
|
+
const resolvedDataDir = path.resolve(process.cwd(), dataDir);
|
|
65
|
+
|
|
66
|
+
// Look up chart config from page data or global data
|
|
67
|
+
// In Eleventy 3.x, data is available directly on `this` context
|
|
68
|
+
// 1. Page frontmatter charts.{id}
|
|
69
|
+
// 2. Global data charts.{id} (from _data/charts.yaml or similar)
|
|
70
|
+
const pageCharts = this.page?.charts;
|
|
71
|
+
const globalCharts = this.charts || this.ctx?.charts;
|
|
72
|
+
|
|
73
|
+
const chartConfig = pageCharts?.[chartId] || globalCharts?.[chartId];
|
|
74
|
+
|
|
75
|
+
if (!chartConfig) {
|
|
76
|
+
return `<!-- Chart "${chartId}" not found -->`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Validate chart type
|
|
80
|
+
const chartType = chartConfig.type;
|
|
81
|
+
if (!chartType) {
|
|
82
|
+
return `<!-- Chart "${chartId}" has no type specified -->`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const renderer = renderers[chartType];
|
|
86
|
+
if (!renderer) {
|
|
87
|
+
return `<!-- Unknown chart type "${chartType}" for chart "${chartId}" -->`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Load data from CSV file or use inline data
|
|
91
|
+
let data = chartConfig.data;
|
|
92
|
+
if (chartConfig.file && !data) {
|
|
93
|
+
data = loadCSV(chartConfig.file, resolvedDataDir);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!data || data.length === 0) {
|
|
97
|
+
return `<!-- Chart "${chartId}" has no data -->`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Render the chart (chart-specific animate overrides global setting)
|
|
101
|
+
const animate = chartConfig.animate ?? globalAnimate;
|
|
102
|
+
return renderer({
|
|
103
|
+
...chartConfig,
|
|
104
|
+
data,
|
|
105
|
+
animate
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "eleventy-plugin-uncharted",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "An Eleventy plugin that renders CSS-based charts from CSV data using shortcodes",
|
|
5
|
+
"main": "eleventy.config.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./eleventy.config.js",
|
|
9
|
+
"./css": "./css/uncharted.css"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"eleventy.config.js",
|
|
13
|
+
"src/",
|
|
14
|
+
"css/"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"eleventy",
|
|
18
|
+
"eleventy-plugin",
|
|
19
|
+
"charts",
|
|
20
|
+
"css",
|
|
21
|
+
"csv",
|
|
22
|
+
"data-visualization"
|
|
23
|
+
],
|
|
24
|
+
"author": "Sean Lunsford",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/slunsford/uncharted.git"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/slunsford/uncharted#readme",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/slunsford/uncharted/issues"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@11ty/eleventy": ">=2.0.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@11ty/eleventy": "^3.0.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/csv.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse CSV content into array of objects
|
|
6
|
+
* @param {string} content - Raw CSV content
|
|
7
|
+
* @returns {Object[]} - Array of row objects with header keys
|
|
8
|
+
*/
|
|
9
|
+
export function parseCSV(content) {
|
|
10
|
+
const lines = content
|
|
11
|
+
.trim()
|
|
12
|
+
.split('\n')
|
|
13
|
+
.filter(line => !line.startsWith('#') && line.trim() !== '');
|
|
14
|
+
|
|
15
|
+
if (lines.length < 2) return [];
|
|
16
|
+
|
|
17
|
+
const headers = lines[0].split(',').map(h => h.trim());
|
|
18
|
+
const rows = [];
|
|
19
|
+
|
|
20
|
+
for (let i = 1; i < lines.length; i++) {
|
|
21
|
+
const values = lines[i].split(',').map(v => v.trim());
|
|
22
|
+
const row = {};
|
|
23
|
+
|
|
24
|
+
headers.forEach((header, index) => {
|
|
25
|
+
const value = values[index] ?? '';
|
|
26
|
+
// Try to parse as number, keep as string if not numeric
|
|
27
|
+
const num = parseFloat(value);
|
|
28
|
+
row[header] = isNaN(num) ? value : num;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
rows.push(row);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return rows;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Load and parse a CSV file
|
|
39
|
+
* @param {string} filePath - Path to CSV file (relative to data directory)
|
|
40
|
+
* @param {string} dataDir - Base data directory path
|
|
41
|
+
* @returns {Object[]} - Parsed CSV data
|
|
42
|
+
*/
|
|
43
|
+
export function loadCSV(filePath, dataDir) {
|
|
44
|
+
const fullPath = path.join(dataDir, filePath);
|
|
45
|
+
|
|
46
|
+
if (!fs.existsSync(fullPath)) {
|
|
47
|
+
console.warn(`[uncharted] CSV file not found: ${fullPath}`);
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
52
|
+
return parseCSV(content);
|
|
53
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { slugify, escapeHtml } from '../utils.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render a donut/pie chart using conic-gradient
|
|
5
|
+
* @param {Object} config - Chart configuration
|
|
6
|
+
* @param {string} config.title - Chart title
|
|
7
|
+
* @param {string} [config.subtitle] - Chart subtitle
|
|
8
|
+
* @param {Object[]} config.data - Chart data (with label and value properties)
|
|
9
|
+
* @param {string[]} [config.legend] - Legend labels (defaults to data labels)
|
|
10
|
+
* @param {Object} [config.center] - Center content options
|
|
11
|
+
* @param {string|number} [config.center.value] - Value to show in center (use "total" for auto-calculated)
|
|
12
|
+
* @param {string} [config.center.label] - Label below the value
|
|
13
|
+
* @param {boolean} [config.animate] - Enable animations
|
|
14
|
+
* @returns {string} - HTML string
|
|
15
|
+
*/
|
|
16
|
+
export function renderDonut(config) {
|
|
17
|
+
const { title, subtitle, data, legend, center, animate } = config;
|
|
18
|
+
|
|
19
|
+
if (!data || data.length === 0) {
|
|
20
|
+
return `<!-- Donut chart: no data provided -->`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const animateClass = animate ? ' chart-animate' : '';
|
|
24
|
+
|
|
25
|
+
// Extract values - support both {label, value} format and series format
|
|
26
|
+
let segments = [];
|
|
27
|
+
if (data[0].value !== undefined) {
|
|
28
|
+
// Direct {label, value} format
|
|
29
|
+
segments = data.map(item => ({
|
|
30
|
+
label: item.label,
|
|
31
|
+
value: typeof item.value === 'number' ? item.value : parseFloat(item.value) || 0
|
|
32
|
+
}));
|
|
33
|
+
} else {
|
|
34
|
+
// Series format - first row only for donut
|
|
35
|
+
const seriesNames = Object.keys(data[0]).filter(k => k !== 'label');
|
|
36
|
+
segments = seriesNames.map(name => ({
|
|
37
|
+
label: name,
|
|
38
|
+
value: typeof data[0][name] === 'number' ? data[0][name] : parseFloat(data[0][name]) || 0
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const total = segments.reduce((sum, s) => sum + s.value, 0);
|
|
43
|
+
if (total === 0) {
|
|
44
|
+
return `<!-- Donut chart: total is zero -->`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Build conic-gradient stops
|
|
48
|
+
let currentAngle = 0;
|
|
49
|
+
const gradientStops = [];
|
|
50
|
+
|
|
51
|
+
segments.forEach((segment, i) => {
|
|
52
|
+
const percentage = (segment.value / total) * 100;
|
|
53
|
+
const startAngle = currentAngle;
|
|
54
|
+
const endAngle = currentAngle + percentage;
|
|
55
|
+
|
|
56
|
+
gradientStops.push(`var(--chart-color-${i + 1}) ${startAngle.toFixed(2)}% ${endAngle.toFixed(2)}%`);
|
|
57
|
+
currentAngle = endAngle;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const gradient = `conic-gradient(${gradientStops.join(', ')})`;
|
|
61
|
+
|
|
62
|
+
let html = `<figure class="chart chart-donut${animateClass}">`;
|
|
63
|
+
|
|
64
|
+
if (title) {
|
|
65
|
+
html += `<figcaption class="chart-title">${escapeHtml(title)}`;
|
|
66
|
+
if (subtitle) {
|
|
67
|
+
html += `<span class="chart-subtitle">${escapeHtml(subtitle)}</span>`;
|
|
68
|
+
}
|
|
69
|
+
html += `</figcaption>`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Donut visual
|
|
73
|
+
html += `<div class="donut-container">`;
|
|
74
|
+
html += `<div class="donut-ring" style="background: ${gradient}"></div>`;
|
|
75
|
+
html += `<div class="donut-center">`;
|
|
76
|
+
|
|
77
|
+
// Center content (optional)
|
|
78
|
+
if (center) {
|
|
79
|
+
const centerValue = center.value === 'total' ? total : center.value;
|
|
80
|
+
if (centerValue !== undefined) {
|
|
81
|
+
html += `<span class="donut-value">${escapeHtml(String(centerValue))}</span>`;
|
|
82
|
+
}
|
|
83
|
+
if (center.label) {
|
|
84
|
+
html += `<span class="donut-label">${escapeHtml(center.label)}</span>`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
html += `</div>`;
|
|
89
|
+
html += `</div>`;
|
|
90
|
+
|
|
91
|
+
// Legend with percentages
|
|
92
|
+
const legendLabels = legend ?? segments.map(s => s.label);
|
|
93
|
+
html += `<ul class="chart-legend">`;
|
|
94
|
+
segments.forEach((segment, i) => {
|
|
95
|
+
const label = legendLabels[i] ?? segment.label;
|
|
96
|
+
const percentage = ((segment.value / total) * 100).toFixed(1);
|
|
97
|
+
const colorClass = `chart-color-${i + 1}`;
|
|
98
|
+
const seriesClass = `chart-series-${slugify(segment.label)}`;
|
|
99
|
+
html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">`;
|
|
100
|
+
html += `<span class="legend-label">${escapeHtml(label)}</span>`;
|
|
101
|
+
html += `<span class="legend-value">${percentage}%</span>`;
|
|
102
|
+
html += `</li>`;
|
|
103
|
+
});
|
|
104
|
+
html += `</ul>`;
|
|
105
|
+
|
|
106
|
+
html += `</figure>`;
|
|
107
|
+
|
|
108
|
+
return html;
|
|
109
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { slugify, escapeHtml, getSeriesNames } from '../utils.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render a categorical dot chart (columns with dots at different Y positions)
|
|
5
|
+
* Like atlas-wrapped's adoption chart - discrete X axis, continuous Y axis
|
|
6
|
+
* @param {Object} config - Chart configuration
|
|
7
|
+
* @param {string} config.title - Chart title
|
|
8
|
+
* @param {string} [config.subtitle] - Chart subtitle
|
|
9
|
+
* @param {Object[]} config.data - Chart data with label column and value columns
|
|
10
|
+
* @param {number} [config.max] - Maximum Y value (defaults to max in data)
|
|
11
|
+
* @param {string[]} [config.legend] - Legend labels (defaults to series names)
|
|
12
|
+
* @param {boolean} [config.animate] - Enable animations
|
|
13
|
+
* @returns {string} - HTML string
|
|
14
|
+
*/
|
|
15
|
+
export function renderDot(config) {
|
|
16
|
+
const { title, subtitle, data, max, legend, animate } = config;
|
|
17
|
+
|
|
18
|
+
if (!data || data.length === 0) {
|
|
19
|
+
return `<!-- Dot chart: no data provided -->`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Get series keys from data columns (excluding 'label')
|
|
23
|
+
const seriesKeys = getSeriesNames(data);
|
|
24
|
+
const legendLabels = legend ?? seriesKeys;
|
|
25
|
+
const animateClass = animate ? ' chart-animate' : '';
|
|
26
|
+
|
|
27
|
+
// Calculate min and max values for Y scaling
|
|
28
|
+
const allValues = data.flatMap(row =>
|
|
29
|
+
seriesKeys.map(key => {
|
|
30
|
+
const val = row[key];
|
|
31
|
+
return typeof val === 'number' ? val : parseFloat(val) || 0;
|
|
32
|
+
})
|
|
33
|
+
);
|
|
34
|
+
const dataMax = Math.max(...allValues);
|
|
35
|
+
const dataMin = Math.min(...allValues);
|
|
36
|
+
const maxValue = max ?? dataMax;
|
|
37
|
+
const minValue = dataMin < 0 ? dataMin : 0;
|
|
38
|
+
const range = maxValue - minValue;
|
|
39
|
+
const hasNegativeY = minValue < 0;
|
|
40
|
+
|
|
41
|
+
// Calculate zero position for axis line
|
|
42
|
+
const zeroPct = hasNegativeY ? ((0 - minValue) / range) * 100 : 0;
|
|
43
|
+
|
|
44
|
+
const negativeClass = hasNegativeY ? ' has-negative-y' : '';
|
|
45
|
+
let html = `<figure class="chart chart-dot${animateClass}${negativeClass}">`;
|
|
46
|
+
|
|
47
|
+
if (title) {
|
|
48
|
+
html += `<figcaption class="chart-title">${escapeHtml(title)}`;
|
|
49
|
+
if (subtitle) {
|
|
50
|
+
html += `<span class="chart-subtitle">${escapeHtml(subtitle)}</span>`;
|
|
51
|
+
}
|
|
52
|
+
html += `</figcaption>`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Legend
|
|
56
|
+
if (seriesKeys.length > 0) {
|
|
57
|
+
html += `<ul class="chart-legend">`;
|
|
58
|
+
seriesKeys.forEach((key, i) => {
|
|
59
|
+
const label = legendLabels[i] ?? key;
|
|
60
|
+
const colorClass = `chart-color-${i + 1}`;
|
|
61
|
+
const seriesClass = `chart-series-${slugify(key)}`;
|
|
62
|
+
html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</li>`;
|
|
63
|
+
});
|
|
64
|
+
html += `</ul>`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
html += `<div class="chart-body">`;
|
|
68
|
+
|
|
69
|
+
// Y-axis
|
|
70
|
+
const yAxisStyle = hasNegativeY ? ` style="--zero-position: ${zeroPct.toFixed(2)}%"` : '';
|
|
71
|
+
html += `<div class="chart-y-axis"${yAxisStyle}>`;
|
|
72
|
+
html += `<span class="axis-label">${maxValue}</span>`;
|
|
73
|
+
const midLabelY = hasNegativeY ? 0 : Math.round((maxValue + minValue) / 2);
|
|
74
|
+
html += `<span class="axis-label">${midLabelY}</span>`;
|
|
75
|
+
html += `<span class="axis-label">${minValue}</span>`;
|
|
76
|
+
html += `</div>`;
|
|
77
|
+
|
|
78
|
+
const zeroStyle = hasNegativeY ? ` style="--zero-position: ${zeroPct.toFixed(2)}%"` : '';
|
|
79
|
+
html += `<div class="dot-chart"${zeroStyle}>`;
|
|
80
|
+
html += `<div class="dot-field">`;
|
|
81
|
+
|
|
82
|
+
// Each row becomes a column with dots for each series
|
|
83
|
+
data.forEach(row => {
|
|
84
|
+
const label = row.label ?? '';
|
|
85
|
+
|
|
86
|
+
html += `<div class="dot-col">`;
|
|
87
|
+
|
|
88
|
+
seriesKeys.forEach((key, i) => {
|
|
89
|
+
const val = row[key];
|
|
90
|
+
const value = typeof val === 'number' ? val : parseFloat(val) || 0;
|
|
91
|
+
const yPct = range > 0 ? ((value - minValue) / range) * 100 : 0;
|
|
92
|
+
const colorClass = `chart-color-${i + 1}`;
|
|
93
|
+
const seriesClass = `chart-series-${slugify(key)}`;
|
|
94
|
+
const tooltipLabel = legendLabels[i] ?? key;
|
|
95
|
+
|
|
96
|
+
html += `<div class="dot ${colorClass} ${seriesClass}" `;
|
|
97
|
+
html += `style="--value: ${yPct.toFixed(2)}%" `;
|
|
98
|
+
html += `title="${escapeHtml(label)}: ${value} ${escapeHtml(tooltipLabel)}"`;
|
|
99
|
+
html += `></div>`;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
html += `</div>`;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
html += `</div>`;
|
|
106
|
+
html += `</div>`;
|
|
107
|
+
html += `</div>`;
|
|
108
|
+
|
|
109
|
+
// X-axis labels
|
|
110
|
+
html += `<div class="dot-labels">`;
|
|
111
|
+
data.forEach(row => {
|
|
112
|
+
const label = row.label ?? '';
|
|
113
|
+
html += `<span class="dot-label">${escapeHtml(label)}</span>`;
|
|
114
|
+
});
|
|
115
|
+
html += `</div>`;
|
|
116
|
+
|
|
117
|
+
html += `</figure>`;
|
|
118
|
+
|
|
119
|
+
return html;
|
|
120
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { renderStackedBar } from './stacked-bar.js';
|
|
2
|
+
import { renderStackedColumn } from './stacked-column.js';
|
|
3
|
+
import { renderDonut } from './donut.js';
|
|
4
|
+
import { renderDot } from './dot.js';
|
|
5
|
+
import { renderScatter } from './scatter.js';
|
|
6
|
+
|
|
7
|
+
export const renderers = {
|
|
8
|
+
'stacked-bar': renderStackedBar,
|
|
9
|
+
'stacked-column': renderStackedColumn,
|
|
10
|
+
'donut': renderDonut,
|
|
11
|
+
'dot': renderDot,
|
|
12
|
+
'scatter': renderScatter
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export { renderStackedBar, renderStackedColumn, renderDonut, renderDot, renderScatter };
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { slugify, escapeHtml } from '../utils.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render a scatter plot (continuous X and Y axes)
|
|
5
|
+
* @param {Object} config - Chart configuration
|
|
6
|
+
* @param {string} config.title - Chart title
|
|
7
|
+
* @param {string} [config.subtitle] - Chart subtitle
|
|
8
|
+
* @param {Object[]} config.data - Chart data (with label, x, y, and optionally series)
|
|
9
|
+
* @param {number} [config.maxX] - Maximum X value (defaults to max in data)
|
|
10
|
+
* @param {number} [config.maxY] - Maximum Y value (defaults to max in data)
|
|
11
|
+
* @param {string[]} [config.legend] - Legend labels for series
|
|
12
|
+
* @param {boolean} [config.animate] - Enable animations
|
|
13
|
+
* @returns {string} - HTML string
|
|
14
|
+
*/
|
|
15
|
+
export function renderScatter(config) {
|
|
16
|
+
const { title, subtitle, data, maxX, maxY, legend, animate } = config;
|
|
17
|
+
|
|
18
|
+
if (!data || data.length === 0) {
|
|
19
|
+
return `<!-- Scatter chart: no data provided -->`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const animateClass = animate ? ' chart-animate' : '';
|
|
23
|
+
|
|
24
|
+
// Normalize data format
|
|
25
|
+
let dots = [];
|
|
26
|
+
if (data[0].x !== undefined && data[0].y !== undefined) {
|
|
27
|
+
// Direct {label, x, y, series?} format
|
|
28
|
+
dots = data.map(item => ({
|
|
29
|
+
label: item.label ?? '',
|
|
30
|
+
x: typeof item.x === 'number' ? item.x : parseFloat(item.x) || 0,
|
|
31
|
+
y: typeof item.y === 'number' ? item.y : parseFloat(item.y) || 0,
|
|
32
|
+
series: item.series ?? 'default'
|
|
33
|
+
}));
|
|
34
|
+
} else if (data[0].value !== undefined) {
|
|
35
|
+
// Simple {label, value} format - use index as x, value as y
|
|
36
|
+
dots = data.map((item, i) => ({
|
|
37
|
+
label: item.label ?? '',
|
|
38
|
+
x: i,
|
|
39
|
+
y: typeof item.value === 'number' ? item.value : parseFloat(item.value) || 0,
|
|
40
|
+
series: 'default'
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Calculate bounds
|
|
45
|
+
const xValues = dots.map(d => d.x);
|
|
46
|
+
const yValues = dots.map(d => d.y);
|
|
47
|
+
const dataMaxX = Math.max(...xValues);
|
|
48
|
+
const dataMinX = Math.min(...xValues);
|
|
49
|
+
const dataMaxY = Math.max(...yValues);
|
|
50
|
+
const dataMinY = Math.min(...yValues);
|
|
51
|
+
|
|
52
|
+
const calcMaxX = maxX ?? dataMaxX;
|
|
53
|
+
const calcMaxY = maxY ?? dataMaxY;
|
|
54
|
+
const calcMinX = dataMinX < 0 ? dataMinX : 0;
|
|
55
|
+
const calcMinY = dataMinY < 0 ? dataMinY : 0;
|
|
56
|
+
const rangeX = calcMaxX - calcMinX;
|
|
57
|
+
const rangeY = calcMaxY - calcMinY;
|
|
58
|
+
|
|
59
|
+
const hasNegativeX = calcMinX < 0;
|
|
60
|
+
const hasNegativeY = calcMinY < 0;
|
|
61
|
+
|
|
62
|
+
// Calculate zero positions for axis lines
|
|
63
|
+
const zeroPctX = hasNegativeX ? ((0 - calcMinX) / rangeX) * 100 : 0;
|
|
64
|
+
const zeroPctY = hasNegativeY ? ((0 - calcMinY) / rangeY) * 100 : 0;
|
|
65
|
+
|
|
66
|
+
// Get unique series
|
|
67
|
+
const seriesSet = new Set(dots.map(d => d.series));
|
|
68
|
+
const seriesList = Array.from(seriesSet);
|
|
69
|
+
const seriesIndex = new Map(seriesList.map((s, i) => [s, i]));
|
|
70
|
+
|
|
71
|
+
const negativeClasses = (hasNegativeX ? ' has-negative-x' : '') + (hasNegativeY ? ' has-negative-y' : '');
|
|
72
|
+
let html = `<figure class="chart chart-scatter${animateClass}${negativeClasses}">`;
|
|
73
|
+
|
|
74
|
+
if (title) {
|
|
75
|
+
html += `<figcaption class="chart-title">${escapeHtml(title)}`;
|
|
76
|
+
if (subtitle) {
|
|
77
|
+
html += `<span class="chart-subtitle">${escapeHtml(subtitle)}</span>`;
|
|
78
|
+
}
|
|
79
|
+
html += `</figcaption>`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Legend (if multiple series)
|
|
83
|
+
if (seriesList.length > 1 || legend) {
|
|
84
|
+
const legendLabels = legend ?? seriesList;
|
|
85
|
+
html += `<ul class="chart-legend">`;
|
|
86
|
+
seriesList.forEach((series, i) => {
|
|
87
|
+
const label = legendLabels[i] ?? series;
|
|
88
|
+
const colorClass = `chart-color-${i + 1}`;
|
|
89
|
+
const seriesClass = `chart-series-${slugify(series)}`;
|
|
90
|
+
html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</li>`;
|
|
91
|
+
});
|
|
92
|
+
html += `</ul>`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
html += `<div class="chart-body">`;
|
|
96
|
+
|
|
97
|
+
// Y-axis
|
|
98
|
+
const yAxisStyle = hasNegativeY ? ` style="--zero-position-y: ${zeroPctY.toFixed(2)}%"` : '';
|
|
99
|
+
html += `<div class="chart-y-axis"${yAxisStyle}>`;
|
|
100
|
+
html += `<span class="axis-label">${calcMaxY}</span>`;
|
|
101
|
+
const midLabelY = hasNegativeY ? 0 : Math.round((calcMaxY + calcMinY) / 2);
|
|
102
|
+
html += `<span class="axis-label">${midLabelY}</span>`;
|
|
103
|
+
html += `<span class="axis-label">${calcMinY}</span>`;
|
|
104
|
+
html += `</div>`;
|
|
105
|
+
|
|
106
|
+
// Container gets zero position variables for axis line CSS
|
|
107
|
+
const containerStyles = [];
|
|
108
|
+
if (hasNegativeX) containerStyles.push(`--zero-position-x: ${zeroPctX.toFixed(2)}%`);
|
|
109
|
+
if (hasNegativeY) containerStyles.push(`--zero-position-y: ${zeroPctY.toFixed(2)}%`);
|
|
110
|
+
const containerStyle = containerStyles.length > 0 ? ` style="${containerStyles.join('; ')}"` : '';
|
|
111
|
+
html += `<div class="scatter-container"${containerStyle}>`;
|
|
112
|
+
html += `<div class="dot-area">`;
|
|
113
|
+
html += `<div class="dot-field">`;
|
|
114
|
+
|
|
115
|
+
dots.forEach((dot, i) => {
|
|
116
|
+
const xPct = rangeX > 0 ? ((dot.x - calcMinX) / rangeX) * 100 : 0;
|
|
117
|
+
const yPct = rangeY > 0 ? ((dot.y - calcMinY) / rangeY) * 100 : 0;
|
|
118
|
+
const colorIndex = seriesIndex.get(dot.series) + 1;
|
|
119
|
+
const colorClass = `chart-color-${colorIndex}`;
|
|
120
|
+
const seriesClass = `chart-series-${slugify(dot.series)}`;
|
|
121
|
+
const tooltipText = dot.label ? `${dot.label}: (${dot.x}, ${dot.y})` : `(${dot.x}, ${dot.y})`;
|
|
122
|
+
|
|
123
|
+
html += `<div class="dot ${colorClass} ${seriesClass}" `;
|
|
124
|
+
html += `style="--dot-index: ${i}; --x: ${xPct.toFixed(2)}%; --value: ${yPct.toFixed(2)}%" `;
|
|
125
|
+
html += `title="${escapeHtml(tooltipText)}"`;
|
|
126
|
+
html += `></div>`;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
html += `</div>`;
|
|
130
|
+
html += `</div>`;
|
|
131
|
+
|
|
132
|
+
// X-axis
|
|
133
|
+
const xAxisStyle = hasNegativeX ? ` style="--zero-position-x: ${zeroPctX.toFixed(2)}%"` : '';
|
|
134
|
+
html += `<div class="chart-x-axis"${xAxisStyle}>`;
|
|
135
|
+
html += `<span class="axis-label">${calcMinX}</span>`;
|
|
136
|
+
const midLabelX = hasNegativeX ? 0 : Math.round((calcMaxX + calcMinX) / 2);
|
|
137
|
+
html += `<span class="axis-label">${midLabelX}</span>`;
|
|
138
|
+
html += `<span class="axis-label">${calcMaxX}</span>`;
|
|
139
|
+
html += `</div>`;
|
|
140
|
+
|
|
141
|
+
html += `</div>`;
|
|
142
|
+
html += `</div>`;
|
|
143
|
+
|
|
144
|
+
html += `</figure>`;
|
|
145
|
+
|
|
146
|
+
return html;
|
|
147
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { slugify, calculatePercentages, getSeriesNames, escapeHtml } from '../utils.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render a stacked bar chart (horizontal)
|
|
5
|
+
* @param {Object} config - Chart configuration
|
|
6
|
+
* @param {string} config.title - Chart title
|
|
7
|
+
* @param {string} [config.subtitle] - Chart subtitle
|
|
8
|
+
* @param {Object[]} config.data - Chart data
|
|
9
|
+
* @param {number} [config.max] - Maximum value for percentage calculation
|
|
10
|
+
* @param {string[]} [config.legend] - Legend labels (defaults to series names)
|
|
11
|
+
* @param {boolean} [config.animate] - Enable animations
|
|
12
|
+
* @returns {string} - HTML string
|
|
13
|
+
*/
|
|
14
|
+
export function renderStackedBar(config) {
|
|
15
|
+
const { title, subtitle, data, max, legend, animate } = config;
|
|
16
|
+
|
|
17
|
+
if (!data || data.length === 0) {
|
|
18
|
+
return `<!-- Stacked bar chart: no data provided -->`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Get actual data keys from the first row (excluding 'label')
|
|
22
|
+
const seriesKeys = getSeriesNames(data);
|
|
23
|
+
// Use legend for display labels, fall back to data keys
|
|
24
|
+
const legendLabels = legend ?? seriesKeys;
|
|
25
|
+
const animateClass = animate ? ' chart-animate' : '';
|
|
26
|
+
|
|
27
|
+
let html = `<figure class="chart chart-stacked-bar${animateClass}">`;
|
|
28
|
+
|
|
29
|
+
if (title) {
|
|
30
|
+
html += `<figcaption class="chart-title">${escapeHtml(title)}`;
|
|
31
|
+
if (subtitle) {
|
|
32
|
+
html += `<span class="chart-subtitle">${escapeHtml(subtitle)}</span>`;
|
|
33
|
+
}
|
|
34
|
+
html += `</figcaption>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Legend
|
|
38
|
+
if (seriesKeys.length > 0) {
|
|
39
|
+
html += `<ul class="chart-legend">`;
|
|
40
|
+
seriesKeys.forEach((key, i) => {
|
|
41
|
+
const label = legendLabels[i] ?? key;
|
|
42
|
+
const colorClass = `chart-color-${i + 1}`;
|
|
43
|
+
const seriesClass = `chart-series-${slugify(key)}`;
|
|
44
|
+
html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</li>`;
|
|
45
|
+
});
|
|
46
|
+
html += `</ul>`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
html += `<div class="chart-bars">`;
|
|
50
|
+
|
|
51
|
+
data.forEach(row => {
|
|
52
|
+
const label = row.label ?? '';
|
|
53
|
+
const values = seriesKeys.map(key => {
|
|
54
|
+
const val = row[key];
|
|
55
|
+
return typeof val === 'number' ? val : parseFloat(val) || 0;
|
|
56
|
+
});
|
|
57
|
+
const total = values.reduce((sum, v) => sum + v, 0);
|
|
58
|
+
const percentages = calculatePercentages(values, max);
|
|
59
|
+
const seriesLabels = legendLabels ?? seriesKeys;
|
|
60
|
+
|
|
61
|
+
html += `<div class="bar-row">`;
|
|
62
|
+
html += `<span class="bar-label">${escapeHtml(label)}</span>`;
|
|
63
|
+
html += `<div class="bar-track">`;
|
|
64
|
+
html += `<div class="bar-fills" title="${escapeHtml(label)}: ${total}">`;
|
|
65
|
+
|
|
66
|
+
seriesKeys.forEach((key, i) => {
|
|
67
|
+
const pct = percentages[i];
|
|
68
|
+
const value = values[i];
|
|
69
|
+
if (pct > 0) {
|
|
70
|
+
const colorClass = `chart-color-${i + 1}`;
|
|
71
|
+
const seriesClass = `chart-series-${slugify(key)}`;
|
|
72
|
+
const seriesLabel = seriesLabels[i] ?? key;
|
|
73
|
+
html += `<div class="bar-fill ${colorClass} ${seriesClass}" style="--value: ${pct.toFixed(2)}%" title="${escapeHtml(seriesLabel)}: ${value}"></div>`;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
html += `</div>`;
|
|
78
|
+
html += `</div>`;
|
|
79
|
+
|
|
80
|
+
// Show total value
|
|
81
|
+
html += `<span class="bar-value">${total}</span>`;
|
|
82
|
+
html += `</div>`;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
html += `</div>`;
|
|
86
|
+
html += `</figure>`;
|
|
87
|
+
|
|
88
|
+
return html;
|
|
89
|
+
}
|