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.
- package/css/uncharted.css +421 -55
- package/package.json +1 -1
- package/src/columns.js +69 -1
- package/src/csv.js +9 -3
- package/src/renderers/bubble.js +269 -0
- package/src/renderers/dot.js +60 -40
- package/src/renderers/index.js +6 -2
- package/src/renderers/line.js +2 -1
- package/src/renderers/timeseries.js +503 -0
|
@@ -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
|
+
}
|