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.
@@ -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,3 @@
1
+ export { renderers } from './renderers/index.js';
2
+ export { loadCSV, parseCSV } from './csv.js';
3
+ export { slugify, calculatePercentages, getSeriesNames, escapeHtml } from './utils.js';
@@ -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
+ }