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,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
+ '&': '&amp;',
45
+ '<': '&lt;',
46
+ '>': '&gt;',
47
+ '"': '&quot;',
48
+ "'": '&#39;'
49
+ };
50
+ return String(str).replace(/[&<>"']/g, char => htmlEntities[char]);
51
+ }
52
+