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,182 @@
|
|
|
1
|
+
import { slugify, getSeriesNames, escapeHtml } from '../utils.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render a stacked column chart (vertical)
|
|
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 renderStackedColumn(config) {
|
|
15
|
+
const { title, subtitle, data, max, legend, animate } = config;
|
|
16
|
+
|
|
17
|
+
if (!data || data.length === 0) {
|
|
18
|
+
return `<!-- Stacked column 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
|
+
// Calculate stacked totals for positive and negative values separately
|
|
28
|
+
// Positives stack up from zero, negatives stack down from zero
|
|
29
|
+
let maxPositiveStack = 0;
|
|
30
|
+
let minNegativeStack = 0;
|
|
31
|
+
|
|
32
|
+
data.forEach(row => {
|
|
33
|
+
let positiveSum = 0;
|
|
34
|
+
let negativeSum = 0;
|
|
35
|
+
seriesKeys.forEach(key => {
|
|
36
|
+
const val = row[key];
|
|
37
|
+
const value = typeof val === 'number' ? val : parseFloat(val) || 0;
|
|
38
|
+
if (value >= 0) {
|
|
39
|
+
positiveSum += value;
|
|
40
|
+
} else {
|
|
41
|
+
negativeSum += value;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
maxPositiveStack = Math.max(maxPositiveStack, positiveSum);
|
|
45
|
+
minNegativeStack = Math.min(minNegativeStack, negativeSum);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const hasNegativeY = minNegativeStack < 0;
|
|
49
|
+
const maxValue = max ?? maxPositiveStack;
|
|
50
|
+
const minValue = minNegativeStack;
|
|
51
|
+
const range = maxValue - minValue;
|
|
52
|
+
const zeroPct = hasNegativeY ? ((0 - minValue) / range) * 100 : 0;
|
|
53
|
+
|
|
54
|
+
const negativeClass = hasNegativeY ? ' has-negative-y' : '';
|
|
55
|
+
let html = `<figure class="chart chart-stacked-column${animateClass}${negativeClass}">`;
|
|
56
|
+
|
|
57
|
+
if (title) {
|
|
58
|
+
html += `<figcaption class="chart-title">${escapeHtml(title)}`;
|
|
59
|
+
if (subtitle) {
|
|
60
|
+
html += `<span class="chart-subtitle">${escapeHtml(subtitle)}</span>`;
|
|
61
|
+
}
|
|
62
|
+
html += `</figcaption>`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Legend
|
|
66
|
+
if (seriesKeys.length > 0) {
|
|
67
|
+
html += `<ul class="chart-legend">`;
|
|
68
|
+
seriesKeys.forEach((key, i) => {
|
|
69
|
+
const label = legendLabels[i] ?? key;
|
|
70
|
+
const colorClass = `chart-color-${i + 1}`;
|
|
71
|
+
const seriesClass = `chart-series-${slugify(key)}`;
|
|
72
|
+
html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</li>`;
|
|
73
|
+
});
|
|
74
|
+
html += `</ul>`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
html += `<div class="chart-body">`;
|
|
78
|
+
|
|
79
|
+
// Y-axis with --zero-position for label positioning
|
|
80
|
+
const yAxisStyle = hasNegativeY ? ` style="--zero-position: ${zeroPct.toFixed(2)}%"` : '';
|
|
81
|
+
html += `<div class="chart-y-axis"${yAxisStyle}>`;
|
|
82
|
+
html += `<span class="axis-label">${maxValue}</span>`;
|
|
83
|
+
const midLabelY = hasNegativeY ? 0 : Math.round(maxValue / 2);
|
|
84
|
+
html += `<span class="axis-label">${midLabelY}</span>`;
|
|
85
|
+
html += `<span class="axis-label">${hasNegativeY ? minValue : 0}</span>`;
|
|
86
|
+
html += `</div>`;
|
|
87
|
+
|
|
88
|
+
const columnsStyle = hasNegativeY ? ` style="--zero-position: ${zeroPct.toFixed(2)}%"` : '';
|
|
89
|
+
html += `<div class="chart-columns"${columnsStyle}>`;
|
|
90
|
+
|
|
91
|
+
data.forEach(row => {
|
|
92
|
+
const label = row.label ?? '';
|
|
93
|
+
html += `<div class="column-track" title="${escapeHtml(label)}">`;
|
|
94
|
+
|
|
95
|
+
if (hasNegativeY) {
|
|
96
|
+
// Build segments first to identify stack ends
|
|
97
|
+
const segments = [];
|
|
98
|
+
let positiveBottom = zeroPct;
|
|
99
|
+
let negativeTop = zeroPct;
|
|
100
|
+
let lastPositiveIdx = -1;
|
|
101
|
+
let lastNegativeIdx = -1;
|
|
102
|
+
|
|
103
|
+
seriesKeys.forEach((key, i) => {
|
|
104
|
+
const val = row[key];
|
|
105
|
+
const value = typeof val === 'number' ? val : parseFloat(val) || 0;
|
|
106
|
+
const colorClass = `chart-color-${i + 1}`;
|
|
107
|
+
const seriesClass = `chart-series-${slugify(key)}`;
|
|
108
|
+
const seriesLabel = legendLabels[i] ?? key;
|
|
109
|
+
const segmentHeight = range > 0 ? (Math.abs(value) / range) * 100 : 0;
|
|
110
|
+
|
|
111
|
+
if (value >= 0) {
|
|
112
|
+
segments.push({
|
|
113
|
+
classes: `column-segment ${colorClass} ${seriesClass}`,
|
|
114
|
+
bottom: positiveBottom,
|
|
115
|
+
height: segmentHeight,
|
|
116
|
+
title: `${escapeHtml(seriesLabel)}: ${value}`,
|
|
117
|
+
isNegative: false
|
|
118
|
+
});
|
|
119
|
+
lastPositiveIdx = segments.length - 1;
|
|
120
|
+
positiveBottom += segmentHeight;
|
|
121
|
+
} else {
|
|
122
|
+
negativeTop -= segmentHeight;
|
|
123
|
+
segments.push({
|
|
124
|
+
classes: `column-segment ${colorClass} ${seriesClass} is-negative`,
|
|
125
|
+
bottom: negativeTop,
|
|
126
|
+
height: segmentHeight,
|
|
127
|
+
title: `${escapeHtml(seriesLabel)}: ${value}`,
|
|
128
|
+
isNegative: true
|
|
129
|
+
});
|
|
130
|
+
lastNegativeIdx = segments.length - 1;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Output segments with stack-end class on outermost segments
|
|
135
|
+
segments.forEach((seg, idx) => {
|
|
136
|
+
const endClass = (idx === lastPositiveIdx || idx === lastNegativeIdx) ? ' is-stack-end' : '';
|
|
137
|
+
html += `<div class="${seg.classes}${endClass}" `;
|
|
138
|
+
html += `style="--value-bottom: ${seg.bottom.toFixed(2)}%; --value-height: ${seg.height.toFixed(2)}%" `;
|
|
139
|
+
html += `title="${seg.title}"></div>`;
|
|
140
|
+
});
|
|
141
|
+
} else {
|
|
142
|
+
// Original stacked behavior for positive-only
|
|
143
|
+
const segmentData = [];
|
|
144
|
+
|
|
145
|
+
seriesKeys.forEach((key, i) => {
|
|
146
|
+
const val = row[key];
|
|
147
|
+
const value = typeof val === 'number' ? val : parseFloat(val) || 0;
|
|
148
|
+
const pct = maxValue > 0 ? (value / maxValue) * 100 : 0;
|
|
149
|
+
if (pct > 0) {
|
|
150
|
+
segmentData.push({ key, i, value, pct });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const lastIdx = segmentData.length - 1;
|
|
155
|
+
segmentData.forEach((seg, idx) => {
|
|
156
|
+
const colorClass = `chart-color-${seg.i + 1}`;
|
|
157
|
+
const seriesClass = `chart-series-${slugify(seg.key)}`;
|
|
158
|
+
const seriesLabel = legendLabels[seg.i] ?? seg.key;
|
|
159
|
+
const endClass = idx === lastIdx ? ' is-stack-end' : '';
|
|
160
|
+
html += `<div class="column-segment ${colorClass} ${seriesClass}${endClass}" `;
|
|
161
|
+
html += `style="--value: ${seg.pct.toFixed(2)}%" `;
|
|
162
|
+
html += `title="${escapeHtml(seriesLabel)}: ${seg.value}"></div>`;
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
html += `</div>`;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
html += `</div>`;
|
|
170
|
+
html += `</div>`;
|
|
171
|
+
|
|
172
|
+
// X-axis labels
|
|
173
|
+
html += `<div class="column-labels">`;
|
|
174
|
+
data.forEach(row => {
|
|
175
|
+
const label = row.label ?? '';
|
|
176
|
+
html += `<span class="column-label">${escapeHtml(label)}</span>`;
|
|
177
|
+
});
|
|
178
|
+
html += `</div>`;
|
|
179
|
+
html += `</figure>`;
|
|
180
|
+
|
|
181
|
+
return html;
|
|
182
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slugify a string for use in CSS class names
|
|
3
|
+
* @param {string} str - The string to slugify
|
|
4
|
+
* @returns {string} - Slugified string
|
|
5
|
+
*/
|
|
6
|
+
export function slugify(str) {
|
|
7
|
+
return String(str)
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.trim()
|
|
10
|
+
.replace(/[^\w\s-]/g, '')
|
|
11
|
+
.replace(/[\s_]+/g, '-')
|
|
12
|
+
.replace(/-+/g, '-');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Calculate percentages from values
|
|
17
|
+
* @param {number[]} values - Array of numeric values
|
|
18
|
+
* @param {number} [max] - Optional max value (defaults to sum of values)
|
|
19
|
+
* @returns {number[]} - Array of percentages (0-100)
|
|
20
|
+
*/
|
|
21
|
+
export function calculatePercentages(values, max) {
|
|
22
|
+
const total = max ?? values.reduce((sum, v) => sum + v, 0);
|
|
23
|
+
if (total === 0) return values.map(() => 0);
|
|
24
|
+
return values.map(v => (v / total) * 100);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Extract series names from CSV data (all columns except 'label')
|
|
29
|
+
* @param {Object[]} data - Array of data objects
|
|
30
|
+
* @returns {string[]} - Array of series names
|
|
31
|
+
*/
|
|
32
|
+
export function getSeriesNames(data) {
|
|
33
|
+
if (!data || data.length === 0) return [];
|
|
34
|
+
return Object.keys(data[0]).filter(key => key !== 'label');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Escape HTML entities to prevent XSS
|
|
39
|
+
* @param {string} str - String to escape
|
|
40
|
+
* @returns {string} - Escaped string
|
|
41
|
+
*/
|
|
42
|
+
export function escapeHtml(str) {
|
|
43
|
+
const htmlEntities = {
|
|
44
|
+
'&': '&',
|
|
45
|
+
'<': '<',
|
|
46
|
+
'>': '>',
|
|
47
|
+
'"': '"',
|
|
48
|
+
"'": '''
|
|
49
|
+
};
|
|
50
|
+
return String(str).replace(/[&<>"']/g, char => htmlEntities[char]);
|
|
51
|
+
}
|
|
52
|
+
|