eleventy-plugin-uncharted 0.8.0 → 0.9.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,503 @@
1
+ import { slugify, escapeHtml, getLabelKey, getSeriesNames, renderDownloadLink } from '../utils.js';
2
+ import { formatNumber } from '../formatters.js';
3
+ import { getAxisMax, getAxisMin, getAxisTitle, getAxisFormat, getRotateLabels } from '../config.js';
4
+
5
+ /**
6
+ * Parse an x-axis value as a numeric timestamp
7
+ * Supports: numeric years (1971, 2024) or ISO dates (2024-02-17)
8
+ * @param {string|number} value - Raw x value
9
+ * @returns {number|null} - Numeric value for positioning, or null if invalid
10
+ */
11
+ function parseXValue(value) {
12
+ if (value === null || value === undefined || value === '') return null;
13
+
14
+ // If it's already a number, use as-is (year or numeric value)
15
+ if (typeof value === 'number') return value;
16
+
17
+ const str = String(value).trim();
18
+
19
+ // Check if it's a pure integer (year like 1971, 2024)
20
+ if (/^\d{4}$/.test(str)) {
21
+ return parseInt(str, 10);
22
+ }
23
+
24
+ // Check if it looks like a number
25
+ if (/^-?\d+(\.\d+)?$/.test(str)) {
26
+ return parseFloat(str);
27
+ }
28
+
29
+ // Try parsing as ISO date
30
+ const timestamp = Date.parse(str);
31
+ if (!isNaN(timestamp)) {
32
+ return timestamp;
33
+ }
34
+
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * Detect if x values are date timestamps (vs numeric years)
40
+ * @param {number[]} values - Parsed x values
41
+ * @returns {boolean} - True if values appear to be timestamps
42
+ */
43
+ function isDateTimestamp(values) {
44
+ if (values.length === 0) return false;
45
+ // Timestamps are typically > 1e9 (around year 2001 in seconds, or 1970 in ms)
46
+ // Years are typically < 3000
47
+ const avg = values.reduce((a, b) => a + b, 0) / values.length;
48
+ return avg > 100000; // If average is > 100000, likely timestamps
49
+ }
50
+
51
+ /**
52
+ * Calculate nice interval for axis ticks
53
+ * @param {number} range - Data range
54
+ * @param {boolean} isDate - Whether values are date timestamps
55
+ * @returns {number} - Nice interval value
56
+ */
57
+ function getNiceInterval(range, isDate) {
58
+ if (isDate) {
59
+ // Date ranges in milliseconds
60
+ const MS_DAY = 86400000;
61
+ const MS_WEEK = MS_DAY * 7;
62
+ const MS_MONTH = MS_DAY * 30;
63
+ const MS_YEAR = MS_DAY * 365;
64
+
65
+ if (range > MS_YEAR * 50) return MS_YEAR * 10; // Decades
66
+ if (range > MS_YEAR * 10) return MS_YEAR * 5; // 5 years
67
+ if (range > MS_YEAR * 2) return MS_YEAR; // Years
68
+ if (range > MS_MONTH * 6) return MS_MONTH * 3; // Quarters
69
+ if (range > MS_MONTH * 2) return MS_MONTH; // Months
70
+ if (range > MS_WEEK * 2) return MS_WEEK; // Weeks
71
+ return MS_DAY; // Days
72
+ }
73
+
74
+ // Numeric ranges (years) - minimum interval of 1
75
+ const magnitude = Math.pow(10, Math.floor(Math.log10(range)));
76
+ const normalized = range / magnitude;
77
+
78
+ let interval;
79
+ if (normalized <= 2) interval = magnitude / 5;
80
+ else if (normalized <= 5) interval = magnitude / 2;
81
+ else interval = magnitude;
82
+
83
+ return Math.max(1, interval);
84
+ }
85
+
86
+ /**
87
+ * Round value down to interval boundary
88
+ * For dates, aligns to calendar boundaries (month starts)
89
+ * @param {number} value - Value to round
90
+ * @param {number} interval - Interval size
91
+ * @param {boolean} isDate - Whether value is a date timestamp
92
+ * @returns {number} - Rounded value
93
+ */
94
+ function floorToInterval(value, interval, isDate) {
95
+ if (!isDate) {
96
+ return Math.floor(value / interval) * interval;
97
+ }
98
+
99
+ // For dates, align to calendar month boundaries
100
+ const date = new Date(value);
101
+ const MS_DAY = 86400000;
102
+ const MS_WEEK = MS_DAY * 7;
103
+ const MS_MONTH = MS_DAY * 30;
104
+
105
+ if (interval <= MS_WEEK) {
106
+ // Weekly or daily: just floor to interval from epoch
107
+ return Math.floor(value / interval) * interval;
108
+ }
109
+
110
+ // Monthly or longer: align to start of month
111
+ const year = date.getFullYear();
112
+ const month = date.getMonth();
113
+
114
+ // Calculate months per interval
115
+ const monthsPerInterval = Math.round(interval / MS_MONTH);
116
+
117
+ // Floor month to interval boundary
118
+ const flooredMonth = Math.floor(month / monthsPerInterval) * monthsPerInterval;
119
+
120
+ return Date.UTC(year, flooredMonth, 1);
121
+ }
122
+
123
+ /**
124
+ * Get next interval value for dates (calendar-aware)
125
+ * @param {number} value - Current value
126
+ * @param {number} interval - Interval size
127
+ * @param {boolean} isDate - Whether value is a date timestamp
128
+ * @returns {number} - Next interval value
129
+ */
130
+ function nextInterval(value, interval, isDate) {
131
+ if (!isDate) {
132
+ return value + interval;
133
+ }
134
+
135
+ const MS_DAY = 86400000;
136
+ const MS_WEEK = MS_DAY * 7;
137
+ const MS_MONTH = MS_DAY * 30;
138
+
139
+ if (interval <= MS_WEEK) {
140
+ return value + interval;
141
+ }
142
+
143
+ // Monthly or longer: add months
144
+ const date = new Date(value);
145
+ const monthsPerInterval = Math.round(interval / MS_MONTH);
146
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + monthsPerInterval, 1);
147
+ }
148
+
149
+ /**
150
+ * Format x-axis tick label
151
+ * @param {number} value - Tick value
152
+ * @param {boolean} isDate - Whether value is a date timestamp
153
+ * @param {number} range - Total range for context
154
+ * @returns {string} - Formatted label
155
+ */
156
+ function formatXLabel(value, isDate, range) {
157
+ if (!isDate) {
158
+ // Numeric years - just return as integer
159
+ return String(Math.round(value));
160
+ }
161
+
162
+ // Date formatting
163
+ const date = new Date(value);
164
+ const MS_YEAR = 86400000 * 365;
165
+ const MS_MONTH = 86400000 * 30;
166
+
167
+ if (range > MS_YEAR * 2) {
168
+ // Multi-year range: just show year
169
+ return String(date.getFullYear());
170
+ }
171
+
172
+ if (range > MS_MONTH * 2) {
173
+ // Months range: show MMM over YYYY (two lines)
174
+ const month = date.toLocaleDateString('en-US', { month: 'short' });
175
+ const year = date.getFullYear();
176
+ return `${month}<br>${year}`;
177
+ }
178
+
179
+ // Days/weeks range: show MMM D
180
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
181
+ }
182
+
183
+ /**
184
+ * Generate axis ticks at regular intervals
185
+ * @param {number} min - Data minimum
186
+ * @param {number} max - Data maximum
187
+ * @param {boolean} isDate - Whether values are date timestamps
188
+ * @returns {Array<{value: number, label: string}>} - Tick objects
189
+ */
190
+ function getAxisTicks(min, max, isDate) {
191
+ const range = max - min;
192
+ if (range <= 0) return [{ value: min, label: formatXLabel(min, isDate, range) }];
193
+
194
+ const interval = getNiceInterval(range, isDate);
195
+ const ticks = [];
196
+
197
+ // Start at first interval boundary at or before min
198
+ let tick = floorToInterval(min, interval, isDate);
199
+
200
+ // If first tick is too far before min, start at next interval
201
+ if (tick < min - interval * 0.1) {
202
+ tick = nextInterval(tick, interval, isDate);
203
+ }
204
+
205
+ while (tick <= max + interval * 0.1) {
206
+ ticks.push({
207
+ value: tick,
208
+ label: formatXLabel(tick, isDate, range)
209
+ });
210
+ tick = nextInterval(tick, interval, isDate);
211
+ }
212
+
213
+ // Ensure we have at least start and end
214
+ if (ticks.length === 0) {
215
+ return [
216
+ { value: min, label: formatXLabel(min, isDate, range) },
217
+ { value: max, label: formatXLabel(max, isDate, range) }
218
+ ];
219
+ }
220
+
221
+ return ticks;
222
+ }
223
+
224
+ /**
225
+ * Render a time-series line chart (continuous X axis with proportional positioning)
226
+ * @param {Object} config - Chart configuration (normalized)
227
+ * @param {string} config.title - Chart title
228
+ * @param {string} [config.subtitle] - Chart subtitle
229
+ * @param {Object[]} config.data - Chart data with x column and value columns
230
+ * @param {Object} [config.x] - X-axis configuration { column, min, max, title }
231
+ * @param {Object} [config.y] - Y-axis configuration { max, min, format, columns }
232
+ * @param {string[]} [config.legend] - Legend labels (defaults to series names)
233
+ * @param {boolean} [config.animate] - Enable animations
234
+ * @param {Object} [config._columns] - Resolved column mappings
235
+ * @returns {string} - HTML string
236
+ */
237
+ export function renderTimeseries(config) {
238
+ const { title, subtitle, data, legend, legendTitle, animate, format, id, downloadData, downloadDataUrl, dots: showDots = false, icons, _columns } = config;
239
+
240
+ if (!data || data.length === 0) {
241
+ return `<!-- Timeseries chart: no data provided -->`;
242
+ }
243
+
244
+ // Get x column (first column or specified)
245
+ const keys = Object.keys(data[0]);
246
+ const xColumn = config.x?.column ?? _columns?.x ?? keys[0];
247
+
248
+ // Get y series keys
249
+ let seriesKeys;
250
+ if (_columns?.values?.length > 0) {
251
+ seriesKeys = _columns.values;
252
+ } else if (config.y?.columns) {
253
+ const cols = config.y.columns;
254
+ seriesKeys = typeof cols === 'object' && !Array.isArray(cols)
255
+ ? Object.keys(cols)
256
+ : Array.isArray(cols) ? cols : [cols];
257
+ } else {
258
+ // Default: all columns except x column
259
+ seriesKeys = keys.filter(k => k !== xColumn);
260
+ }
261
+
262
+ // Build legend labels
263
+ const yLabels = _columns?.yLabels || {};
264
+ const yColumnsLabels = config.y?.columns && typeof config.y.columns === 'object' && !Array.isArray(config.y.columns)
265
+ ? config.y.columns
266
+ : {};
267
+ const getSeriesLabel = (key, index) => {
268
+ if (yLabels[key]) return yLabels[key];
269
+ if (yColumnsLabels[key]) return yColumnsLabels[key];
270
+ if (Array.isArray(legend)) return legend[index] ?? key;
271
+ return key;
272
+ };
273
+
274
+ // Helper to get icon for a series
275
+ const getSeriesIcon = (key) => {
276
+ if (!icons) return null;
277
+ if (typeof icons === 'string') return icons;
278
+ return icons[key] ?? null;
279
+ };
280
+
281
+ const animateClass = animate ? ' chart-animate' : '';
282
+
283
+ // Get format configs
284
+ const xFormat = getAxisFormat(config, 'x');
285
+ const yFormat = getAxisFormat(config, 'y');
286
+
287
+ // Parse x values and collect data points per series
288
+ const seriesData = new Map();
289
+ seriesKeys.forEach(key => seriesData.set(key, []));
290
+
291
+ // Track all x values for range calculation
292
+ const allXValues = [];
293
+
294
+ data.forEach((row, rowIndex) => {
295
+ const xRaw = row[xColumn];
296
+ const xVal = parseXValue(xRaw);
297
+ if (xVal === null) return;
298
+
299
+ allXValues.push(xVal);
300
+
301
+ seriesKeys.forEach(key => {
302
+ const yRaw = row[key];
303
+ if (yRaw === null || yRaw === undefined || yRaw === '') return;
304
+ const yVal = typeof yRaw === 'number' ? yRaw : parseFloat(yRaw);
305
+ if (isNaN(yVal)) return;
306
+
307
+ seriesData.get(key).push({
308
+ x: xVal,
309
+ y: yVal,
310
+ label: String(xRaw),
311
+ rowIndex
312
+ });
313
+ });
314
+ });
315
+
316
+ // Sort each series by x value
317
+ seriesData.forEach(points => points.sort((a, b) => a.x - b.x));
318
+
319
+ // Calculate x range
320
+ const dataMinX = Math.min(...allXValues);
321
+ const dataMaxX = Math.max(...allXValues);
322
+ const isDate = isDateTimestamp(allXValues);
323
+
324
+ // Get preliminary range for tick calculation
325
+ const prelimMinX = getAxisMin(config, 'x') ?? dataMinX;
326
+ const prelimMaxX = getAxisMax(config, 'x') ?? dataMaxX;
327
+
328
+ // Get x-axis ticks based on data range
329
+ const xTicks = getAxisTicks(prelimMinX, prelimMaxX, isDate);
330
+
331
+ // Extend range to include tick boundaries so labels align properly
332
+ const tickMin = xTicks.length > 0 ? Math.min(...xTicks.map(t => t.value)) : prelimMinX;
333
+ const tickMax = xTicks.length > 0 ? Math.max(...xTicks.map(t => t.value)) : prelimMaxX;
334
+ const calcMinX = Math.min(prelimMinX, tickMin);
335
+ const calcMaxX = Math.max(prelimMaxX, tickMax);
336
+ const rangeX = calcMaxX - calcMinX;
337
+
338
+ // Calculate y range
339
+ const allYValues = [];
340
+ seriesData.forEach(points => {
341
+ points.forEach(p => allYValues.push(p.y));
342
+ });
343
+
344
+ const dataMaxY = allYValues.length > 0 ? Math.max(...allYValues) : 0;
345
+ const dataMinY = allYValues.length > 0 ? Math.min(...allYValues) : 0;
346
+
347
+ const maxValue = getAxisMax(config, 'y') ?? config.max ?? dataMaxY;
348
+ const minValue = getAxisMin(config, 'y') ?? config.min ?? (dataMinY < 0 ? dataMinY : 0);
349
+ const rangeY = maxValue - minValue;
350
+ const hasNegativeY = minValue < 0;
351
+
352
+ // Calculate zero position for y-axis line
353
+ const zeroPctY = hasNegativeY ? ((0 - minValue) / rangeY) * 100 : 0;
354
+
355
+ // Axis titles
356
+ const xAxisTitle = getAxisTitle(config, 'x', '');
357
+ const yAxisTitle = getAxisTitle(config, 'y', '');
358
+
359
+ // Count unique x values for scroll width calculation
360
+ const uniqueXCount = new Set(allXValues).size;
361
+
362
+ const negativeClass = hasNegativeY ? ' has-negative-y' : '';
363
+ const idClass = id ? ` chart-${id}` : '';
364
+ const dotsClass = !showDots ? ' no-dots' : '';
365
+ let html = `<figure class="chart chart-timeseries${animateClass}${negativeClass}${idClass}${dotsClass}">`;
366
+
367
+ if (title) {
368
+ html += `<figcaption class="chart-title">${escapeHtml(title)}`;
369
+ if (subtitle) {
370
+ html += `<span class="chart-subtitle">${escapeHtml(subtitle)}</span>`;
371
+ }
372
+ html += `</figcaption>`;
373
+ }
374
+
375
+ html += `<div class="chart-body">`;
376
+
377
+ // Y-axis
378
+ const yAxisStyle = hasNegativeY ? ` style="--zero-position: ${zeroPctY.toFixed(2)}%"` : '';
379
+ html += `<div class="chart-y-axis"${yAxisStyle}>`;
380
+ html += `<span class="axis-label">${formatNumber(maxValue, yFormat) || maxValue}</span>`;
381
+ const midLabelY = hasNegativeY ? 0 : Math.round((maxValue + minValue) / 2);
382
+ html += `<span class="axis-label">${formatNumber(midLabelY, yFormat) || midLabelY}</span>`;
383
+ html += `<span class="axis-label">${formatNumber(minValue, yFormat) || minValue}</span>`;
384
+ if (yAxisTitle) {
385
+ html += `<span class="axis-title">${escapeHtml(yAxisTitle)}</span>`;
386
+ }
387
+ html += `</div>`;
388
+
389
+ // Scroll wrapper (scrolls when content overflows)
390
+ html += `<div class="chart-scroll">`;
391
+
392
+ // Container with zero position and point count for sizing
393
+ const containerStyles = [];
394
+ if (hasNegativeY) containerStyles.push(`--zero-position: ${zeroPctY.toFixed(2)}%`);
395
+ if (showDots) containerStyles.push(`--point-count: ${uniqueXCount}`);
396
+ const containerStyle = containerStyles.length > 0 ? ` style="${containerStyles.join('; ')}"` : '';
397
+ html += `<div class="timeseries-container"${containerStyle}>`;
398
+ html += `<div class="dot-area">`;
399
+ html += `<div class="dot-field">`;
400
+
401
+ // Render line segments for each series
402
+ let segIndex = 0;
403
+ seriesKeys.forEach((key, seriesIdx) => {
404
+ const points = seriesData.get(key);
405
+ if (points.length < 2) return;
406
+
407
+ const colorClass = `chart-color-${seriesIdx + 1}`;
408
+ const seriesClass = `chart-series-${slugify(key)}`;
409
+
410
+ for (let i = 0; i < points.length - 1; i++) {
411
+ const p1 = points[i];
412
+ const p2 = points[i + 1];
413
+
414
+ const x1 = rangeX > 0 ? ((p1.x - calcMinX) / rangeX) * 100 : 0;
415
+ const y1 = rangeY > 0 ? ((p1.y - minValue) / rangeY) * 100 : 0;
416
+ const x2 = rangeX > 0 ? ((p2.x - calcMinX) / rangeX) * 100 : 0;
417
+ const y2 = rangeY > 0 ? ((p2.y - minValue) / rangeY) * 100 : 0;
418
+
419
+ html += `<div class="chart-line-segment ${colorClass} ${seriesClass}" `;
420
+ html += `style="--x1: ${x1.toFixed(2)}; --y1: ${y1.toFixed(2)}; --x2: ${x2.toFixed(2)}; --y2: ${y2.toFixed(2)}; --seg-index: ${segIndex}">`;
421
+ html += `</div>`;
422
+ segIndex++;
423
+ }
424
+ });
425
+
426
+ // Always render dots for hover/tooltips (CSS handles visibility)
427
+ // Only show icons inside dots when dots are visible
428
+ {
429
+ let dotIndex = 0;
430
+ seriesKeys.forEach((key, seriesIdx) => {
431
+ const points = seriesData.get(key);
432
+ const colorClass = `chart-color-${seriesIdx + 1}`;
433
+ const seriesClass = `chart-series-${slugify(key)}`;
434
+ const seriesLabel = getSeriesLabel(key, seriesIdx);
435
+ const icon = showDots ? getSeriesIcon(key) : null;
436
+ const iconClass = icon ? ' has-icon' : '';
437
+
438
+ points.forEach(p => {
439
+ const xPct = rangeX > 0 ? ((p.x - calcMinX) / rangeX) * 100 : 0;
440
+ const yPct = rangeY > 0 ? ((p.y - minValue) / rangeY) * 100 : 0;
441
+
442
+ const tooltipText = `${seriesLabel}: ${formatNumber(p.y, yFormat) || p.y} (${p.label})`;
443
+
444
+ html += `<div class="dot ${colorClass} ${seriesClass}${iconClass}" `;
445
+ html += `style="--x: ${xPct.toFixed(2)}%; --value: ${yPct.toFixed(2)}%; --dot-index: ${dotIndex}" `;
446
+ html += `title="${escapeHtml(tooltipText)}"`;
447
+ html += `>`;
448
+ if (icon) {
449
+ html += `<i class="${escapeHtml(icon)}"></i>`;
450
+ }
451
+ html += `</div>`;
452
+ dotIndex++;
453
+ });
454
+ });
455
+ }
456
+
457
+ html += `</div>`; // close dot-field
458
+ html += `</div>`; // close dot-area
459
+
460
+ // X-axis with interval-based ticks
461
+ html += `<div class="chart-x-axis timeseries-x-axis">`;
462
+ xTicks.forEach(tick => {
463
+ // Use decimal factor (0-1) so CSS can apply proper inset calculation
464
+ const xFactor = rangeX > 0 ? (tick.value - calcMinX) / rangeX : 0;
465
+ // Label may contain <br> for two-line formatting, so don't escape
466
+ html += `<span class="axis-label" style="--x: ${xFactor.toFixed(4)}">${tick.label}</span>`;
467
+ });
468
+ if (xAxisTitle) {
469
+ html += `<span class="axis-title">${escapeHtml(xAxisTitle)}</span>`;
470
+ }
471
+ html += `</div>`;
472
+
473
+ html += `</div>`; // close timeseries-container
474
+ html += `</div>`; // close chart-scroll
475
+ html += `</div>`; // close chart-body
476
+
477
+ // Legend
478
+ const showLegend = config.legend !== false && (seriesKeys.length > 0 || legendTitle);
479
+ if (showLegend) {
480
+ if (legendTitle) {
481
+ html += `<span class="chart-legend-title">${escapeHtml(legendTitle)}</span>`;
482
+ }
483
+ html += `<div class="chart-legend">`;
484
+ seriesKeys.forEach((key, i) => {
485
+ const label = getSeriesLabel(key, i);
486
+ const colorClass = `chart-color-${i + 1}`;
487
+ const seriesClass = `chart-series-${slugify(key)}`;
488
+ const icon = getSeriesIcon(key);
489
+ const iconClass = icon ? ' has-icon' : '';
490
+ html += `<span class="chart-legend-item ${colorClass} ${seriesClass}${iconClass}">`;
491
+ if (icon) {
492
+ html += `<i class="${escapeHtml(icon)}"></i>`;
493
+ }
494
+ html += `${escapeHtml(label)}</span>`;
495
+ });
496
+ html += `</div>`;
497
+ }
498
+
499
+ html += renderDownloadLink(downloadDataUrl, downloadData);
500
+ html += `</figure>`;
501
+
502
+ return html;
503
+ }