Ray1ee01 commited on
Commit
40cdcb2
·
verified ·
1 Parent(s): 066ffa3

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/type30_small_multiples_spline_graphs/small_multiples_line_graphs_plain_chart_01.js ADDED
@@ -0,0 +1,582 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "small multiples of spline graphs",
5
+ "chart_name": "small_multiples_spline_graphs_plain_chart_01",
6
+ "is_composite": false,
7
+ "required_fields": ["x", "y", "group"],
8
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
9
+ "required_fields_range": [[5, 30], ["-inf", "inf"], [2, 8]],
10
+ "required_fields_icons": [],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": ["group"],
13
+ "required_other_colors": ["primary"],
14
+ "supported_effects": ["stroke", "spacing"],
15
+ "min_height": 600,
16
+ "min_width": 800,
17
+ "background": "light",
18
+ "icon_mark": "none",
19
+ "icon_label": "none",
20
+ "has_x_axis": "yes",
21
+ "has_y_axis": "yes"
22
+ }
23
+ REQUIREMENTS_END
24
+ */
25
+
26
+ function makeChart(containerSelector, data) {
27
+ const jsonData = data || {};
28
+ const chartData = (jsonData.data && jsonData.data.data) || [];
29
+ const dataColumns = chartUtils.schema.columns(jsonData);
30
+ const colorResolver = chartUtils.color.resolver(jsonData);
31
+ const chartUtilsFormatSample = chartUtils.format.autoText;
32
+ const chartUtilsTextSample = chartUtils.text.estimate;
33
+ const chartUtilsStandard = {
34
+ schema: chartUtils.schema,
35
+ format: chartUtils.format,
36
+ text: chartUtils.text,
37
+ color: chartUtils.color,
38
+ legendLayout: chartUtils.legend.layout,
39
+ legendDraw: chartUtils.legend.draw,
40
+ random: chartUtils.random.generator(jsonData, "dev-wz-style")
41
+ };
42
+ const standardChannels = chartUtils.schema.channels(jsonData, {
43
+ x: { fallbackIndex: 0 },
44
+ y: { fallbackIndex: 1 },
45
+ y2: { fallbackIndex: 2 },
46
+ y3: { fallbackIndex: 3 },
47
+ size: { fallbackIndex: 2 },
48
+ group: { fallbackIndex: 2 },
49
+ group2: { fallbackIndex: 3 },
50
+ group3: { fallbackIndex: 4 }
51
+ });
52
+ const variables = jsonData.variables || {};
53
+ const typography = jsonData.typography || {
54
+ label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
55
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
56
+ title: { font_family: "Arial", font_size: "14px", font_weight: "bold" }
57
+ };
58
+ const sourceColors = jsonData.colors || {};
59
+ const colorField = sourceColors.field || {};
60
+ const colorOther = sourceColors.other || {};
61
+
62
+ d3.select(containerSelector).html("");
63
+
64
+ const xColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
65
+ const yColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
66
+ const groupColumn = chartUtils.schema.channel(jsonData, "group", { fallbackIndex: 2 }).raw || {};
67
+ const xField = xColumn.name || "date";
68
+ const yField = yColumn.name || "value";
69
+ const groupField = groupColumn.name || "group";
70
+ const yUnit = yColumn.unit && yColumn.unit !== "none" ? yColumn.unit : "";
71
+
72
+ const width = Number(variables.width) || 800;
73
+ const height = Number(variables.height) || 620;
74
+ const textColor = sourceColors.text_color || "#243041";
75
+ const primaryColor = colorOther.primary || "#2f7db7";
76
+ const secondaryColor = colorOther.secondary || "#6d9b45";
77
+ const canvasColor = "#f6fbf7";
78
+ const panelColor = "#fffef8";
79
+ const cardColor = "#ffffff";
80
+ const splinePalette = ["#f19a22", "#6fa843", "#2f7db7", "#d5533d", "#7f69b1", "#2d9c9c", "#c97a9b", "#5a6f8f"];
81
+ const labelTypography = typography.label || {};
82
+ const titleTypography = typography.title || labelTypography;
83
+ const valueTypography = typography.annotation || labelTypography;
84
+ const labelFamily = labelTypography.font_family || "Arial";
85
+ const titleFamily = titleTypography.font_family || labelFamily;
86
+ const valueFamily = valueTypography.font_family || labelFamily;
87
+ const labelWeight = labelTypography.font_weight || "normal";
88
+ const titleWeight = titleTypography.font_weight || "700";
89
+ const valueWeight = valueTypography.font_weight || "600";
90
+ const baseLabelSize = parseFloat(labelTypography.font_size) || 11;
91
+ const baseTitleSize = parseFloat(titleTypography.font_size) || 13;
92
+ const baseValueSize = parseFloat(valueTypography.font_size) || 11;
93
+
94
+ function rgba(color, opacity) {
95
+ const c = d3.rgb(color);
96
+ return `rgba(${c.r}, ${c.g}, ${c.b}, ${opacity})`;
97
+ }
98
+
99
+ function parseTemporalDate(value) {
100
+ if (value instanceof Date && Number.isFinite(value.getTime())) return value;
101
+ const text = String(value ?? "").trim();
102
+ if (!text) return null;
103
+
104
+ let match = text.match(/^(-?\d{4})$/);
105
+ if (match) return new Date(Date.UTC(Number(match[1]), 0, 1));
106
+
107
+ match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
108
+ if (match) {
109
+ return new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3] || 1)));
110
+ }
111
+
112
+ match = text.match(/^Q([1-4])\s+(-?\d{4})$/i) || text.match(/^(-?\d{4})\s+Q([1-4])$/i);
113
+ if (match) {
114
+ const year = match[1].length === 4 ? Number(match[1]) : Number(match[2]);
115
+ const quarter = match[1].length === 4 ? Number(match[2]) : Number(match[1]);
116
+ return new Date(Date.UTC(year, (quarter - 1) * 3, 1));
117
+ }
118
+
119
+ const season = text.match(/^(spring|summer|fall|autumn|winter)\s+(-?\d{4})$/i);
120
+ if (season) {
121
+ const monthBySeason = { spring: 2, summer: 5, fall: 8, autumn: 8, winter: 11 };
122
+ return new Date(Date.UTC(Number(season[2]), monthBySeason[season[1].toLowerCase()], 1));
123
+ }
124
+
125
+ const parsed = new Date(text);
126
+ if (Number.isFinite(parsed.getTime())) {
127
+ return new Date(Date.UTC(parsed.getUTCFullYear(), parsed.getUTCMonth(), parsed.getUTCDate()));
128
+ }
129
+ return null;
130
+ }
131
+
132
+ function formatNumber(value) {
133
+ const numericValue = Number(value);
134
+ if (!Number.isFinite(numericValue)) return String(value ?? "");
135
+ const absValue = Math.abs(numericValue);
136
+ if (absValue >= 1000000000) return `${d3.format(".3~g")(numericValue / 1000000000)}B`;
137
+ if (absValue >= 1000000) return `${d3.format(".3~g")(numericValue / 1000000)}M`;
138
+ if (absValue >= 1000) return `${d3.format(".3~g")(numericValue / 1000)}K`;
139
+ return d3.format(",.4~g")(numericValue);
140
+ }
141
+
142
+ function formatWithUnit(value, unit) {
143
+ const formatted = formatNumber(value);
144
+ if (!unit) return formatted;
145
+ if (unit === "$") return `$${formatted}`;
146
+ if (unit === "%") return `${formatted}%`;
147
+ return `${formatted} ${unit}`;
148
+ }
149
+
150
+ function wrapText(text, maxChars) {
151
+ const words = String(text || "").split(/\s+/).filter(Boolean);
152
+ if (!words.length) return [String(text || "")];
153
+ const lines = [];
154
+ let current = "";
155
+ words.forEach(word => {
156
+ const next = current ? `${current} ${word}` : word;
157
+ if (next.length <= maxChars) {
158
+ current = next;
159
+ return;
160
+ }
161
+ if (current) lines.push(current);
162
+ if (word.length <= maxChars) {
163
+ current = word;
164
+ return;
165
+ }
166
+ let chunk = "";
167
+ for (const char of word) {
168
+ if ((chunk + char).length > maxChars) {
169
+ lines.push(`${chunk}-`);
170
+ chunk = char;
171
+ } else {
172
+ chunk += char;
173
+ }
174
+ }
175
+ current = chunk;
176
+ });
177
+ if (current) lines.push(current);
178
+ return lines.length ? lines : [String(text || "")];
179
+ }
180
+
181
+ function selectEvenTicks(values, maxTicks) {
182
+ if (values.length <= maxTicks) return values;
183
+ const selected = [];
184
+ for (let i = 0; i < maxTicks; i += 1) {
185
+ const index = Math.round(i * (values.length - 1) / (maxTicks - 1));
186
+ const value = values[index];
187
+ if (!selected.includes(value)) selected.push(value);
188
+ }
189
+ return selected;
190
+ }
191
+
192
+ const parsedRows = chartData.map((d, index) => {
193
+ const date = parseTemporalDate(d[xField]);
194
+ const value = Number(d[yField]);
195
+ const group = String(d[groupField] ?? "");
196
+ return {
197
+ ...d,
198
+ __index: index,
199
+ __date: date,
200
+ __time: date ? date.getTime() : NaN,
201
+ __xLabel: String(d[xField] ?? ""),
202
+ __value: value,
203
+ __group: group
204
+ };
205
+ }).filter(d => d.__date && Number.isFinite(d.__time) && Number.isFinite(d.__value) && d.__group !== "");
206
+
207
+ if (!parsedRows.length) return;
208
+
209
+ const groups = [];
210
+ parsedRows.forEach(d => {
211
+ if (!groups.includes(d.__group)) groups.push(d.__group);
212
+ });
213
+ if (!groups.length) return;
214
+
215
+ const xLabelByTime = new Map();
216
+ parsedRows.forEach(d => {
217
+ if (!xLabelByTime.has(d.__time)) xLabelByTime.set(d.__time, d.__xLabel);
218
+ });
219
+
220
+ const groupSeries = groups.map(group => {
221
+ const byTime = new Map();
222
+ parsedRows.filter(d => d.__group === group).forEach(d => {
223
+ if (!byTime.has(d.__time)) {
224
+ byTime.set(d.__time, {
225
+ date: d.__date,
226
+ time: d.__time,
227
+ label: d.__xLabel,
228
+ valueSum: 0,
229
+ count: 0
230
+ });
231
+ }
232
+ const entry = byTime.get(d.__time);
233
+ entry.valueSum += d.__value;
234
+ entry.count += 1;
235
+ });
236
+ const values = Array.from(byTime.values())
237
+ .map(d => ({
238
+ date: d.date,
239
+ time: d.time,
240
+ label: d.label,
241
+ value: d.valueSum / d.count
242
+ }))
243
+ .sort((a, b) => a.time - b.time);
244
+ return { group, values };
245
+ }).filter(d => d.values.length);
246
+
247
+ if (!groupSeries.length) return;
248
+
249
+ const xValues = Array.from(new Set(parsedRows.map(d => d.__time)))
250
+ .sort((a, b) => a - b)
251
+ .map(time => new Date(time));
252
+ const allValues = groupSeries.flatMap(series => series.values.map(d => d.value));
253
+ let yMin = d3.min(allValues);
254
+ let yMax = d3.max(allValues);
255
+ if (!Number.isFinite(yMin) || !Number.isFinite(yMax)) return;
256
+ const rawYMin = yMin;
257
+ const rawYMax = yMax;
258
+ if (yMin === yMax) {
259
+ const pad = Math.max(1, Math.abs(yMin) * 0.1);
260
+ yMin -= pad;
261
+ yMax += pad;
262
+ } else {
263
+ const pad = (yMax - yMin) * 0.1;
264
+ yMin -= pad;
265
+ yMax += pad;
266
+ }
267
+ if (rawYMin >= 0) yMin = Math.max(0, yMin);
268
+ if (rawYMax <= 0) yMax = Math.min(0, yMax);
269
+ if (yMin > 0 && (yMax - yMin) / Math.max(1, Math.abs(yMax)) > 0.55) yMin = 0;
270
+
271
+ const cols = groupSeries.length <= 2 ? groupSeries.length : (groupSeries.length <= 4 ? 2 : 3);
272
+ const rows = Math.ceil(groupSeries.length / cols);
273
+ const outer = { top: 20, right: 22, bottom: 28, left: 22 };
274
+ const gapX = variables.has_spacing === false ? 14 : 20;
275
+ const gapY = variables.has_spacing === false ? 18 : 24;
276
+ const panelWidth = (width - outer.left - outer.right - gapX * (cols - 1)) / cols;
277
+ const panelHeight = (height - outer.top - outer.bottom - gapY * (rows - 1)) / rows;
278
+ const plot = {
279
+ left: Math.min(50, Math.max(40, panelWidth * 0.18)),
280
+ right: 14,
281
+ top: 52,
282
+ bottom: 36
283
+ };
284
+ const innerWidth = Math.max(80, panelWidth - plot.left - plot.right);
285
+ const innerHeight = Math.max(70, panelHeight - plot.top - plot.bottom);
286
+ const tickCount = innerWidth < 145 ? 2 : (innerWidth < 230 ? 3 : 4);
287
+ const xTicks = selectEvenTicks(xValues, tickCount);
288
+
289
+ const xScale = d3.scaleUtc()
290
+ .domain(d3.extent(xValues))
291
+ .range([0, innerWidth]);
292
+ const yScale = d3.scaleLinear()
293
+ .domain([yMin, yMax])
294
+ .nice(4)
295
+ .range([innerHeight, 0]);
296
+ const yTicks = yScale.ticks(innerHeight < 95 ? 3 : 4);
297
+ const yDomain = yScale.domain();
298
+ const areaBaselineValue = Math.max(yDomain[0], Math.min(0, yDomain[1]));
299
+ const areaBaselineY = yScale(areaBaselineValue);
300
+
301
+ const palette = d3.schemeTableau10 || d3.schemeCategory10;
302
+ const colorScale = d3.scaleOrdinal()
303
+ .domain(groups)
304
+ .range(groups.map((group, index) => {
305
+ if (colorField[group]) return colorField[group];
306
+ return splinePalette[index % splinePalette.length] || palette[index % palette.length] || primaryColor;
307
+ }));
308
+
309
+ function formatXTick(date) {
310
+ const label = xLabelByTime.get(date.getTime());
311
+ if (label) return label;
312
+ return d3.utcFormat("%Y")(date);
313
+ }
314
+
315
+ const yScaleNote = rawYMin >= 0
316
+ ? "Shared y-scale starts at zero for nonnegative data."
317
+ : "All panels use a shared y-scale from the original values.";
318
+
319
+ const spline = d3.line()
320
+ .defined(d => Number.isFinite(d.value))
321
+ .x(d => xScale(d.date))
322
+ .y(d => yScale(d.value))
323
+ .curve(d3.curveMonotoneX);
324
+
325
+ const area = d3.area()
326
+ .defined(d => Number.isFinite(d.value))
327
+ .x(d => xScale(d.date))
328
+ .y0(areaBaselineY)
329
+ .y1(d => yScale(d.value))
330
+ .curve(d3.curveMonotoneX);
331
+
332
+ const svg = d3.select(containerSelector)
333
+ .append("svg")
334
+ .attr("width", "100%")
335
+ .attr("height", height)
336
+ .attr("viewBox", `0 0 ${width} ${height}`)
337
+ .attr("style", "max-width: 100%; height: auto;")
338
+ .attr("xmlns", "http://www.w3.org/2000/svg");
339
+
340
+ const defs = svg.append("defs");
341
+ const panelGradient = defs.append("linearGradient")
342
+ .attr("id", "small_multiples_spline_01_panel_gradient")
343
+ .attr("x1", "0%")
344
+ .attr("x2", "0%")
345
+ .attr("y1", "0%")
346
+ .attr("y2", "100%");
347
+ panelGradient.append("stop").attr("offset", "0%").attr("stop-color", "#fffefb");
348
+ panelGradient.append("stop").attr("offset", "100%").attr("stop-color", panelColor);
349
+ const cardShadow = defs.append("filter")
350
+ .attr("id", "small_multiples_spline_01_card_shadow")
351
+ .attr("x", "-12%")
352
+ .attr("y", "-18%")
353
+ .attr("width", "124%")
354
+ .attr("height", "140%");
355
+ cardShadow.append("feDropShadow")
356
+ .attr("dx", 0)
357
+ .attr("dy", 1.4)
358
+ .attr("stdDeviation", 1.5)
359
+ .attr("flood-color", "#456351")
360
+ .attr("flood-opacity", 0.12);
361
+
362
+ svg.append("rect")
363
+ .attr("class", "chart-canvas")
364
+ .attr("x", 0)
365
+ .attr("y", 0)
366
+ .attr("width", width)
367
+ .attr("height", height)
368
+ .attr("fill", canvasColor);
369
+
370
+ svg.append("rect")
371
+ .attr("class", "chart-style-panel")
372
+ .attr("x", 9)
373
+ .attr("y", 9)
374
+ .attr("width", width - 18)
375
+ .attr("height", height - 18)
376
+ .attr("rx", 12)
377
+ .attr("ry", 12)
378
+ .attr("fill", "url(#small_multiples_spline_01_panel_gradient)")
379
+ .attr("stroke", "rgba(111, 168, 67, 0.28)")
380
+ .attr("stroke-width", 1.1);
381
+
382
+ groupSeries.forEach((series, index) => {
383
+ const row = Math.floor(index / cols);
384
+ const col = index % cols;
385
+ const panelX = outer.left + col * (panelWidth + gapX);
386
+ const panelY = outer.top + row * (panelHeight + gapY);
387
+ const panel = svg.append("g")
388
+ .attr("class", "subplot")
389
+ .attr("data-group", series.group)
390
+ .attr("transform", `translate(${panelX}, ${panelY})`);
391
+
392
+ panel.append("rect")
393
+ .attr("class", "subplot-background subplot-card")
394
+ .attr("width", panelWidth)
395
+ .attr("height", panelHeight)
396
+ .attr("rx", 10)
397
+ .attr("ry", 10)
398
+ .attr("fill", cardColor)
399
+ .attr("stroke", rgba(colorScale(series.group), 0.22))
400
+ .attr("stroke-width", 0.9)
401
+ .style("filter", "url(#small_multiples_spline_01_card_shadow)");
402
+
403
+ panel.append("rect")
404
+ .attr("class", "subplot-accent")
405
+ .attr("x", 0)
406
+ .attr("y", 0)
407
+ .attr("width", panelWidth)
408
+ .attr("height", 30)
409
+ .attr("rx", 10)
410
+ .attr("ry", 10)
411
+ .attr("fill", rgba(colorScale(series.group), 0.14))
412
+ .attr("stroke", rgba(colorScale(series.group), 0.36))
413
+ .attr("stroke-width", 0.8);
414
+
415
+ const titleLines = wrapText(series.group, Math.max(9, Math.floor((panelWidth - 20) / 7.2)));
416
+ panel.append("circle")
417
+ .attr("class", "group-swatch")
418
+ .attr("cx", 21)
419
+ .attr("cy", 15)
420
+ .attr("r", 6)
421
+ .attr("fill", colorScale(series.group))
422
+ .attr("opacity", 0.94);
423
+
424
+ const title = panel.append("text")
425
+ .attr("class", "label group-label")
426
+ .attr("data-group", series.group)
427
+ .attr("x", 34)
428
+ .attr("y", 19)
429
+ .attr("text-anchor", "start")
430
+ .style("font-family", titleFamily)
431
+ .style("font-size", `${Math.max(10, Math.min(baseTitleSize, 13))}px`)
432
+ .style("font-weight", titleWeight)
433
+ .style("fill", textColor);
434
+ titleLines.slice(0, 3).forEach((lineText, lineIndex) => {
435
+ title.append("tspan")
436
+ .attr("x", 34)
437
+ .attr("dy", lineIndex === 0 ? 0 : 12)
438
+ .text(lineText);
439
+ });
440
+
441
+ const chart = panel.append("g")
442
+ .attr("class", "panel-plot")
443
+ .attr("transform", `translate(${plot.left}, ${plot.top})`);
444
+
445
+ yTicks.forEach(tick => {
446
+ chart.append("line")
447
+ .attr("class", "gridline")
448
+ .attr("x1", 0)
449
+ .attr("x2", innerWidth)
450
+ .attr("y1", yScale(tick))
451
+ .attr("y2", yScale(tick))
452
+ .attr("stroke", rgba(secondaryColor, 0.16))
453
+ .attr("stroke-width", 1);
454
+ chart.append("text")
455
+ .attr("class", "axis-tick y-axis-tick")
456
+ .attr("x", -7)
457
+ .attr("y", yScale(tick))
458
+ .attr("text-anchor", "end")
459
+ .attr("dominant-baseline", "middle")
460
+ .style("font-family", labelFamily)
461
+ .style("font-size", `${Math.max(8.5, Math.min(baseLabelSize, 10))}px`)
462
+ .style("font-weight", labelWeight)
463
+ .style("fill", rgba(textColor, 0.62))
464
+ .text(formatWithUnit(tick, yUnit));
465
+ });
466
+
467
+ chart.append("line")
468
+ .attr("class", "axis-line x-axis-line")
469
+ .attr("x1", 0)
470
+ .attr("x2", innerWidth)
471
+ .attr("y1", innerHeight)
472
+ .attr("y2", innerHeight)
473
+ .attr("stroke", rgba(textColor, 0.28))
474
+ .attr("stroke-width", 1);
475
+
476
+ xTicks.forEach(tick => {
477
+ chart.append("line")
478
+ .attr("class", "axis-tick-line")
479
+ .attr("x1", xScale(tick))
480
+ .attr("x2", xScale(tick))
481
+ .attr("y1", innerHeight)
482
+ .attr("y2", innerHeight + 4)
483
+ .attr("stroke", rgba(textColor, 0.36))
484
+ .attr("stroke-width", 1);
485
+ chart.append("text")
486
+ .attr("class", "axis-tick x-axis-tick")
487
+ .attr("x", xScale(tick))
488
+ .attr("y", innerHeight + 17)
489
+ .attr("text-anchor", "middle")
490
+ .style("font-family", labelFamily)
491
+ .style("font-size", `${Math.max(8.2, Math.min(baseLabelSize, 9.5))}px`)
492
+ .style("font-weight", labelWeight)
493
+ .style("fill", rgba(textColor, 0.72))
494
+ .text(formatXTick(tick));
495
+ });
496
+
497
+ chart.append("path")
498
+ .datum(series.values)
499
+ .attr("class", "spline-area mark")
500
+ .attr("data-group", series.group)
501
+ .attr("data-count", series.values.length)
502
+ .attr("fill", rgba(colorScale(series.group), 0.16))
503
+ .attr("stroke", "none")
504
+ .attr("d", area);
505
+
506
+ chart.append("path")
507
+ .datum(series.values)
508
+ .attr("class", "spline line mark")
509
+ .attr("data-group", series.group)
510
+ .attr("data-count", series.values.length)
511
+ .attr("data-latest-value", series.values[series.values.length - 1]?.value ?? "")
512
+ .attr("fill", "none")
513
+ .attr("stroke", colorScale(series.group))
514
+ .attr("stroke-width", variables.has_stroke === false ? 2.6 : 3.2)
515
+ .attr("stroke-linecap", "round")
516
+ .attr("stroke-linejoin", "round")
517
+ .attr("d", spline);
518
+
519
+ chart.selectAll(".point")
520
+ .data(series.values)
521
+ .enter()
522
+ .append("circle")
523
+ .attr("class", "point mark")
524
+ .attr("data-group", series.group)
525
+ .attr("data-time", d => d.time)
526
+ .attr("data-label", d => d.label)
527
+ .attr("data-value", d => d.value)
528
+ .attr("cx", d => xScale(d.date))
529
+ .attr("cy", d => yScale(d.value))
530
+ .attr("r", 3)
531
+ .attr("fill", "#ffffff")
532
+ .attr("stroke", colorScale(series.group))
533
+ .attr("stroke-width", 1.7);
534
+
535
+ const lastPoint = series.values[series.values.length - 1];
536
+ if (lastPoint) {
537
+ const latestText = formatWithUnit(lastPoint.value, yUnit);
538
+ const latestFontSize = Math.max(8.5, Math.min(baseValueSize, 10.5));
539
+ const latestY = Math.max(9, Math.min(innerHeight - 5, yScale(lastPoint.value) - 8));
540
+ const pillWidth = Math.max(34, latestText.length * latestFontSize * 0.58 + 12);
541
+ chart.append("rect")
542
+ .attr("class", "latest-value-pill")
543
+ .attr("data-group", series.group)
544
+ .attr("x", innerWidth - pillWidth)
545
+ .attr("y", latestY - latestFontSize + 1)
546
+ .attr("width", pillWidth)
547
+ .attr("height", latestFontSize + 8)
548
+ .attr("rx", 7)
549
+ .attr("ry", 7)
550
+ .attr("fill", "#fffdf8")
551
+ .attr("stroke", rgba(colorScale(series.group), 0.32))
552
+ .attr("stroke-width", 0.9);
553
+ chart.append("text")
554
+ .attr("class", "value latest-value")
555
+ .attr("data-group", series.group)
556
+ .attr("data-time", lastPoint.time)
557
+ .attr("data-label", lastPoint.label)
558
+ .attr("data-value", lastPoint.value)
559
+ .attr("x", innerWidth)
560
+ .attr("y", latestY)
561
+ .attr("text-anchor", "end")
562
+ .style("font-family", valueFamily)
563
+ .style("font-size", `${latestFontSize}px`)
564
+ .style("font-weight", valueWeight)
565
+ .style("fill", colorScale(series.group))
566
+ .text(latestText);
567
+ }
568
+ });
569
+
570
+ svg.append("text")
571
+ .attr("class", "scale-note")
572
+ .attr("x", width / 2)
573
+ .attr("y", height - 13)
574
+ .attr("text-anchor", "middle")
575
+ .style("font-family", valueFamily)
576
+ .style("font-size", `${Math.max(8, Math.min(baseValueSize, 10))}px`)
577
+ .style("font-style", "italic")
578
+ .style("fill", rgba(textColor, 0.58))
579
+ .text(yScaleNote);
580
+
581
+ return svg.node();
582
+ }