Ray1ee01 commited on
Commit
8281877
·
verified ·
1 Parent(s): cb7c3fb

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/type26_spline_graph/spline_graph_plain_chart_01.js ADDED
@@ -0,0 +1,637 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "spline graph",
5
+ "chart_name": "spline_graph_plain_chart_01",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["temporal"], ["numerical"]],
8
+ "required_fields_range": [[5, 30], ["-inf", "inf"]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": ["primary", "secondary"],
13
+ "supported_effects": ["stroke", "spacing"],
14
+ "min_height": 400,
15
+ "min_width": 600,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "yes"
21
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+ function makeChart(containerSelector, data) {
26
+ const jsonData = data || {};
27
+ const chartData = (jsonData.data && jsonData.data.data) || [];
28
+ const dataColumns = chartUtils.schema.columns(jsonData);
29
+ const colorResolver = chartUtils.color.resolver(jsonData);
30
+ const chartUtilsFormatSample = chartUtils.format.autoText;
31
+ const chartUtilsTextSample = chartUtils.text.estimate;
32
+ const chartUtilsStandard = {
33
+ schema: chartUtils.schema,
34
+ format: chartUtils.format,
35
+ text: chartUtils.text,
36
+ color: chartUtils.color,
37
+ legendLayout: chartUtils.legend.layout,
38
+ legendDraw: chartUtils.legend.draw,
39
+ random: chartUtils.random.generator(jsonData, "dev-wz-style")
40
+ };
41
+ const standardChannels = chartUtils.schema.channels(jsonData, {
42
+ x: { fallbackIndex: 0 },
43
+ y: { fallbackIndex: 1 },
44
+ y2: { fallbackIndex: 2 },
45
+ y3: { fallbackIndex: 3 },
46
+ size: { fallbackIndex: 2 },
47
+ group: { fallbackIndex: 2 },
48
+ group2: { fallbackIndex: 3 },
49
+ group3: { fallbackIndex: 4 }
50
+ });
51
+ const variables = jsonData.variables || {};
52
+ const typography = jsonData.typography || {
53
+ label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
54
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "600" }
55
+ };
56
+ const sourceColors = jsonData.colors || {};
57
+ const colorOther = sourceColors.other || {};
58
+ const colorField = sourceColors.field || {};
59
+
60
+ d3.select(containerSelector).html("");
61
+
62
+ const xColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
63
+ const yColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
64
+ const xField = xColumn.name || "date";
65
+ const yField = yColumn.name || "value";
66
+ const yUnit = yColumn.unit && yColumn.unit !== "none" ? yColumn.unit : "";
67
+
68
+ const width = Number(variables.width) || 700;
69
+ const height = Number(variables.height) || 500;
70
+ const textColor = sourceColors.text_color || "#26313f";
71
+ const lineColor = colorOther.primary || colorField.y || sourceColors.primary || "#1f6bdd";
72
+ const secondaryColor = colorOther.secondary || "#7fb7df";
73
+ const canvasColor = "#f5f9ff";
74
+ const cardColor = "#ffffff";
75
+ const plotColor = "#fafdff";
76
+ const accentColor = "#1272f2";
77
+ const purpleAccent = "#7d43df";
78
+ const tealAccent = "#16b7be";
79
+ const mutedColor = "#56677f";
80
+ const labelTypography = typography.label || {};
81
+ const valueTypography = typography.annotation || labelTypography;
82
+ const labelFamily = labelTypography.font_family || "Arial";
83
+ const valueFamily = valueTypography.font_family || labelFamily;
84
+ const labelWeight = labelTypography.font_weight || "normal";
85
+ const valueWeight = valueTypography.font_weight || "600";
86
+ const baseLabelSize = parseFloat(labelTypography.font_size) || 12;
87
+ const baseValueSize = parseFloat(valueTypography.font_size) || 12;
88
+
89
+ function rgba(color, opacity) {
90
+ const c = d3.rgb(color);
91
+ return `rgba(${c.r}, ${c.g}, ${c.b}, ${opacity})`;
92
+ }
93
+
94
+ function parseTemporalDate(value) {
95
+ if (value instanceof Date && Number.isFinite(value.getTime())) return value;
96
+ if (typeof value === "number" && Number.isFinite(value)) {
97
+ return new Date(Date.UTC(value, 0, 1));
98
+ }
99
+
100
+ const text = String(value ?? "").trim();
101
+ if (!text) return null;
102
+
103
+ let match = text.match(/^(-?\d{4})$/);
104
+ if (match) return new Date(Date.UTC(Number(match[1]), 0, 1));
105
+
106
+ match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
107
+ if (match) {
108
+ return new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3] || 1)));
109
+ }
110
+
111
+ match = text.match(/^Q([1-4])\s+(-?\d{4})$/i) || text.match(/^(-?\d{4})\s+Q([1-4])$/i);
112
+ if (match) {
113
+ const year = match[1].length === 4 ? Number(match[1]) : Number(match[2]);
114
+ const quarter = match[1].length === 4 ? Number(match[2]) : Number(match[1]);
115
+ return new Date(Date.UTC(year, (quarter - 1) * 3, 1));
116
+ }
117
+
118
+ match = text.match(/^(spring|summer|fall|autumn|winter)\s+(-?\d{4})$/i);
119
+ if (match) {
120
+ const monthBySeason = { spring: 2, summer: 5, fall: 8, autumn: 8, winter: 11 };
121
+ return new Date(Date.UTC(Number(match[2]), monthBySeason[match[1].toLowerCase()], 1));
122
+ }
123
+
124
+ const parsed = new Date(text);
125
+ if (Number.isFinite(parsed.getTime())) {
126
+ return new Date(Date.UTC(parsed.getUTCFullYear(), parsed.getUTCMonth(), parsed.getUTCDate()));
127
+ }
128
+ return null;
129
+ }
130
+
131
+ function formatNumber(value) {
132
+ const numericValue = Number(value);
133
+ if (!Number.isFinite(numericValue)) return String(value ?? "");
134
+ const absValue = Math.abs(numericValue);
135
+ if (absValue >= 1000000000) return `${d3.format(".3~g")(numericValue / 1000000000)}B`;
136
+ if (absValue >= 1000000) return `${d3.format(".3~g")(numericValue / 1000000)}M`;
137
+ if (absValue >= 1000) return `${d3.format(".3~g")(numericValue / 1000)}K`;
138
+ return d3.format(",.4~g")(numericValue);
139
+ }
140
+
141
+ function formatWithUnit(value, unit) {
142
+ const formatted = formatNumber(value);
143
+ if (!unit) return formatted;
144
+ if (unit === "$") return `$${formatted}`;
145
+ if (unit === "%") return `${formatted}%`;
146
+ return `${formatted} ${unit}`;
147
+ }
148
+
149
+ function formatPointLabel(value, unit) {
150
+ if (!unit || unit === "$" || unit === "%") return formatWithUnit(value, unit);
151
+ return formatNumber(value);
152
+ }
153
+
154
+ function estimateTextWidth(text, fontSize, weight) {
155
+ const estimated = chartUtilsTextSample(String(text), {
156
+ fontSize,
157
+ fontWeight: weight || 700,
158
+ fontFamily: valueFamily
159
+ });
160
+ if (estimated && Number.isFinite(estimated.width)) return estimated.width;
161
+ return String(text).length * fontSize * 0.58;
162
+ }
163
+
164
+ function selectEven(values, maxCount) {
165
+ if (values.length <= maxCount) return values;
166
+ const selected = [];
167
+ for (let i = 0; i < maxCount; i += 1) {
168
+ const index = Math.round(i * (values.length - 1) / (maxCount - 1));
169
+ const value = values[index];
170
+ if (!selected.includes(value)) selected.push(value);
171
+ }
172
+ return selected;
173
+ }
174
+
175
+ function formatDateTick(date, labelByTime) {
176
+ const label = labelByTime.get(date.getTime());
177
+ if (label) return label;
178
+ return d3.utcFormat("%Y")(date);
179
+ }
180
+
181
+ const parsedRows = chartData.map((d, index) => {
182
+ const date = parseTemporalDate(d[xField]);
183
+ const value = Number(d[yField]);
184
+ return {
185
+ ...d,
186
+ __index: index,
187
+ __date: date,
188
+ __time: date ? date.getTime() : NaN,
189
+ __xLabel: String(d[xField] ?? ""),
190
+ __value: value
191
+ };
192
+ }).filter(d => d.__date && Number.isFinite(d.__time) && Number.isFinite(d.__value));
193
+
194
+ if (!parsedRows.length) return;
195
+
196
+ const byTime = new Map();
197
+ parsedRows.forEach(d => {
198
+ if (!byTime.has(d.__time)) {
199
+ byTime.set(d.__time, {
200
+ date: d.__date,
201
+ time: d.__time,
202
+ label: d.__xLabel,
203
+ valueSum: 0,
204
+ count: 0
205
+ });
206
+ }
207
+ const entry = byTime.get(d.__time);
208
+ entry.valueSum += d.__value;
209
+ entry.count += 1;
210
+ });
211
+
212
+ const series = Array.from(byTime.values())
213
+ .map(d => ({
214
+ date: d.date,
215
+ time: d.time,
216
+ label: d.label,
217
+ value: d.valueSum / d.count
218
+ }))
219
+ .sort((a, b) => a.time - b.time);
220
+
221
+ if (!series.length) return;
222
+
223
+ const firstPoint = series[0];
224
+ const lastPoint = series[series.length - 1];
225
+ const minPoint = series.reduce((best, d) => d.value < best.value ? d : best, firstPoint);
226
+ const maxPoint = series.reduce((best, d) => d.value > best.value ? d : best, firstPoint);
227
+
228
+ const xValues = series.map(d => d.date);
229
+ const labelByTime = new Map(series.map(d => [d.time, d.label]));
230
+ const values = series.map(d => d.value);
231
+ let yMin = d3.min(values);
232
+ let yMax = d3.max(values);
233
+ if (!Number.isFinite(yMin) || !Number.isFinite(yMax)) return;
234
+
235
+ const rawYMin = yMin;
236
+ const rawYMax = yMax;
237
+ if (yMin === yMax) {
238
+ const pad = Math.max(1, Math.abs(yMin) * 0.1);
239
+ yMin -= pad;
240
+ yMax += pad;
241
+ } else {
242
+ const pad = (yMax - yMin) * 0.12;
243
+ yMin -= pad;
244
+ yMax += pad;
245
+ }
246
+ if (rawYMin >= 0) yMin = 0;
247
+ if (rawYMax <= 0) yMax = Math.min(0, yMax);
248
+
249
+ const cardMargin = Math.max(20, width * 0.035);
250
+ const badgeHeight = Math.max(40, height * 0.072);
251
+ const margin = {
252
+ top: Math.max(112, height * 0.19),
253
+ right: Math.max(40, width * 0.07),
254
+ bottom: Math.max(58, height * 0.12),
255
+ left: Math.max(72, width * 0.13)
256
+ };
257
+ const innerWidth = Math.max(120, width - margin.left - margin.right);
258
+ const innerHeight = Math.max(120, height - margin.top - margin.bottom);
259
+ const tickCount = innerWidth < 300 ? 4 : 5;
260
+ const xTicks = selectEven(xValues, tickCount);
261
+
262
+ const xExtent = d3.extent(xValues);
263
+ const xPad = Math.max(1, (xExtent[1].getTime() - xExtent[0].getTime()) * 0.03);
264
+ const xScale = d3.scaleUtc()
265
+ .domain([
266
+ new Date(xExtent[0].getTime() - xPad),
267
+ new Date(xExtent[1].getTime() + xPad)
268
+ ])
269
+ .range([0, innerWidth]);
270
+ const yScale = d3.scaleLinear()
271
+ .domain([yMin, yMax])
272
+ .nice(5)
273
+ .range([innerHeight, 0]);
274
+ const yTicks = yScale.ticks(5);
275
+ const yDomain = yScale.domain();
276
+ const areaBaselineValue = Math.max(yDomain[0], Math.min(0, yDomain[1]));
277
+ const areaBaselineY = yScale(areaBaselineValue);
278
+
279
+ const svg = d3.select(containerSelector)
280
+ .append("svg")
281
+ .attr("width", "100%")
282
+ .attr("height", height)
283
+ .attr("viewBox", `0 0 ${width} ${height}`)
284
+ .attr("style", "max-width: 100%; height: auto;")
285
+ .attr("xmlns", "http://www.w3.org/2000/svg");
286
+
287
+ const defs = svg.append("defs");
288
+ const areaGradient = defs.append("linearGradient")
289
+ .attr("id", "spline_graph_01_area_gradient")
290
+ .attr("x1", "0%")
291
+ .attr("x2", "100%")
292
+ .attr("y1", "0%")
293
+ .attr("y2", "100%");
294
+ areaGradient.append("stop").attr("offset", "0%").attr("stop-color", rgba(accentColor, 0.22));
295
+ areaGradient.append("stop").attr("offset", "58%").attr("stop-color", rgba(accentColor, 0.16));
296
+ areaGradient.append("stop").attr("offset", "100%").attr("stop-color", rgba(purpleAccent, 0.24));
297
+ const lineGradient = defs.append("linearGradient")
298
+ .attr("id", "spline_graph_01_line_gradient")
299
+ .attr("x1", "0%")
300
+ .attr("x2", "100%")
301
+ .attr("y1", "0%")
302
+ .attr("y2", "0%");
303
+ lineGradient.append("stop").attr("offset", "0%").attr("stop-color", tealAccent);
304
+ lineGradient.append("stop").attr("offset", "58%").attr("stop-color", accentColor);
305
+ lineGradient.append("stop").attr("offset", "100%").attr("stop-color", purpleAccent);
306
+ const badgeGradient = defs.append("linearGradient")
307
+ .attr("id", "spline_graph_01_badge_gradient")
308
+ .attr("x1", "0%")
309
+ .attr("x2", "100%")
310
+ .attr("y1", "0%")
311
+ .attr("y2", "0%");
312
+ badgeGradient.append("stop").attr("offset", "0%").attr("stop-color", "#1644bd");
313
+ badgeGradient.append("stop").attr("offset", "100%").attr("stop-color", accentColor);
314
+ const cardShadow = defs.append("filter")
315
+ .attr("id", "spline_graph_01_card_shadow")
316
+ .attr("x", "-8%")
317
+ .attr("y", "-12%")
318
+ .attr("width", "116%")
319
+ .attr("height", "128%");
320
+ cardShadow.append("feDropShadow")
321
+ .attr("dx", 0)
322
+ .attr("dy", 2)
323
+ .attr("stdDeviation", 2.2)
324
+ .attr("flood-color", "#34619a")
325
+ .attr("flood-opacity", 0.14);
326
+
327
+ svg.append("rect")
328
+ .attr("class", "chart-canvas")
329
+ .attr("x", 0)
330
+ .attr("y", 0)
331
+ .attr("width", width)
332
+ .attr("height", height)
333
+ .attr("fill", canvasColor);
334
+
335
+ svg.append("rect")
336
+ .attr("class", "chart-card")
337
+ .attr("x", cardMargin)
338
+ .attr("y", cardMargin)
339
+ .attr("width", width - cardMargin * 2)
340
+ .attr("height", height - cardMargin * 2)
341
+ .attr("rx", 18)
342
+ .attr("ry", 18)
343
+ .attr("fill", cardColor)
344
+ .attr("stroke", rgba(accentColor, 0.18))
345
+ .attr("stroke-width", 1.2)
346
+ .style("filter", "url(#spline_graph_01_card_shadow)");
347
+
348
+ const badgeX = margin.left - 44;
349
+ const badgeY = cardMargin + 28;
350
+ const badgeText = yUnit ? `${yField} (${yUnit})` : yField;
351
+ const badge = svg.append("g")
352
+ .attr("class", "chart-title-badge")
353
+ .attr("data-role", "measure-label")
354
+ .attr("transform", `translate(${badgeX}, ${badgeY})`);
355
+ badge.append("circle")
356
+ .attr("class", "badge-icon-slot")
357
+ .attr("data-asset-slot", "measure-icon")
358
+ .attr("data-asset-source-policy", "neutral_placeholder")
359
+ .attr("data-anchor", "chart-card-top-left")
360
+ .attr("data-collision-rule", "avoid measure label and y-axis tick labels")
361
+ .attr("cx", badgeHeight / 2)
362
+ .attr("cy", badgeHeight / 2)
363
+ .attr("r", badgeHeight * 0.43)
364
+ .attr("fill", "url(#spline_graph_01_badge_gradient)")
365
+ .attr("stroke", "rgba(255,255,255,0.82)")
366
+ .attr("stroke-width", 1.4)
367
+ .attr("filter", "url(#spline_graph_01_card_shadow)");
368
+ badge.append("circle")
369
+ .attr("class", "neutral-asset-placeholder")
370
+ .attr("cx", badgeHeight / 2)
371
+ .attr("cy", badgeHeight / 2)
372
+ .attr("r", badgeHeight * 0.18)
373
+ .attr("fill", "none")
374
+ .attr("stroke", "rgba(255,255,255,0.86)")
375
+ .attr("stroke-width", 2);
376
+ badge.append("text")
377
+ .attr("x", badgeHeight + 12)
378
+ .attr("y", badgeHeight / 2 + 5)
379
+ .attr("text-anchor", "start")
380
+ .style("font-family", valueFamily)
381
+ .style("font-size", `${Math.max(13, Math.min(baseValueSize + 4, 16))}px`)
382
+ .style("font-weight", 700)
383
+ .style("fill", "#071A48")
384
+ .text(badgeText);
385
+
386
+ const chart = svg.append("g")
387
+ .attr("class", "spline-plot")
388
+ .attr("data-count", series.length)
389
+ .attr("data-latest-value", lastPoint?.value ?? "")
390
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
391
+
392
+ chart.append("rect")
393
+ .attr("class", "plot-background")
394
+ .attr("x", 0)
395
+ .attr("y", 0)
396
+ .attr("width", innerWidth)
397
+ .attr("height", innerHeight)
398
+ .attr("rx", 0)
399
+ .attr("ry", 0)
400
+ .attr("fill", plotColor)
401
+ .attr("stroke", "none");
402
+
403
+ yTicks.forEach(tick => {
404
+ chart.append("line")
405
+ .attr("class", "gridline")
406
+ .attr("x1", 0)
407
+ .attr("x2", innerWidth)
408
+ .attr("y1", yScale(tick))
409
+ .attr("y2", yScale(tick))
410
+ .attr("stroke", rgba(accentColor, 0.17))
411
+ .attr("stroke-dasharray", "3 4")
412
+ .attr("stroke-width", 1);
413
+
414
+ chart.append("text")
415
+ .attr("class", "axis-tick y-axis-tick")
416
+ .attr("x", -8)
417
+ .attr("y", yScale(tick))
418
+ .attr("text-anchor", "end")
419
+ .attr("dominant-baseline", "middle")
420
+ .style("font-family", labelFamily)
421
+ .style("font-size", `${Math.max(10, Math.min(baseLabelSize + 1, 13))}px`)
422
+ .style("font-weight", labelWeight)
423
+ .style("fill", rgba("#07184a", 0.86))
424
+ .text(formatWithUnit(tick, yUnit));
425
+ });
426
+
427
+ xTicks.forEach(tick => {
428
+ chart.append("line")
429
+ .attr("class", "gridline x-gridline")
430
+ .attr("x1", xScale(tick))
431
+ .attr("x2", xScale(tick))
432
+ .attr("y1", 0)
433
+ .attr("y2", innerHeight)
434
+ .attr("stroke", rgba(accentColor, 0.15))
435
+ .attr("stroke-dasharray", "3 4")
436
+ .attr("stroke-width", 1);
437
+
438
+ chart.append("text")
439
+ .attr("class", "axis-tick x-axis-tick")
440
+ .attr("x", xScale(tick))
441
+ .attr("y", innerHeight + 22)
442
+ .attr("text-anchor", "middle")
443
+ .style("font-family", labelFamily)
444
+ .style("font-size", `${Math.max(10, Math.min(baseLabelSize + 1, 13))}px`)
445
+ .style("font-weight", labelWeight)
446
+ .style("fill", rgba("#07184a", 0.84))
447
+ .text(formatDateTick(tick, labelByTime));
448
+ });
449
+
450
+ chart.append("line")
451
+ .attr("class", "axis-line x-axis-line")
452
+ .attr("x1", 0)
453
+ .attr("x2", innerWidth)
454
+ .attr("y1", innerHeight)
455
+ .attr("y2", innerHeight)
456
+ .attr("stroke", rgba("#07184a", 0.70))
457
+ .attr("stroke-width", 1.2);
458
+
459
+ chart.append("line")
460
+ .attr("class", "axis-line y-axis-line")
461
+ .attr("x1", 0)
462
+ .attr("x2", 0)
463
+ .attr("y1", 0)
464
+ .attr("y2", innerHeight)
465
+ .attr("stroke", rgba("#07184a", 0.70))
466
+ .attr("stroke-width", 1.2);
467
+
468
+ const spline = d3.line()
469
+ .defined(d => Number.isFinite(d.value))
470
+ .x(d => xScale(d.date))
471
+ .y(d => yScale(d.value))
472
+ .curve(d3.curveMonotoneX);
473
+
474
+ const area = d3.area()
475
+ .defined(d => Number.isFinite(d.value))
476
+ .x(d => xScale(d.date))
477
+ .y0(areaBaselineY)
478
+ .y1(d => yScale(d.value))
479
+ .curve(d3.curveMonotoneX);
480
+
481
+ chart.append("path")
482
+ .datum(series)
483
+ .attr("class", "spline-area mark")
484
+ .attr("data-count", series.length)
485
+ .attr("fill", "url(#spline_graph_01_area_gradient)")
486
+ .attr("stroke", "none")
487
+ .attr("d", area);
488
+
489
+ chart.append("path")
490
+ .datum(series)
491
+ .attr("class", "spline-halo")
492
+ .attr("data-count", series.length)
493
+ .attr("fill", "none")
494
+ .attr("stroke", rgba(accentColor, 0.20))
495
+ .attr("stroke-width", variables.has_stroke === false ? 6.2 : 7.6)
496
+ .attr("stroke-linecap", "round")
497
+ .attr("stroke-linejoin", "round")
498
+ .attr("d", spline);
499
+
500
+ chart.append("path")
501
+ .datum(series)
502
+ .attr("class", "spline line mark")
503
+ .attr("data-count", series.length)
504
+ .attr("data-latest-value", lastPoint?.value ?? "")
505
+ .attr("fill", "none")
506
+ .attr("stroke", "url(#spline_graph_01_line_gradient)")
507
+ .attr("stroke-width", variables.has_stroke === false ? 3.6 : 4.4)
508
+ .attr("stroke-linecap", "round")
509
+ .attr("stroke-linejoin", "round")
510
+ .attr("d", spline);
511
+
512
+ chart.selectAll(".point")
513
+ .data(series)
514
+ .enter()
515
+ .append("circle")
516
+ .attr("class", "point mark")
517
+ .attr("data-time", d => d.time)
518
+ .attr("data-label", d => d.label)
519
+ .attr("data-value", d => d.value)
520
+ .attr("cx", d => xScale(d.date))
521
+ .attr("cy", d => yScale(d.value))
522
+ .attr("r", 7)
523
+ .attr("fill", "#ffffff")
524
+ .attr("stroke", d => d.time === lastPoint.time ? purpleAccent : accentColor)
525
+ .attr("stroke-width", 3);
526
+
527
+ const labelBoxes = [];
528
+ const valueFontSize = Math.max(12, Math.min(baseValueSize + 4, 17));
529
+ const importantTimes = new Set([
530
+ firstPoint.time,
531
+ lastPoint.time,
532
+ minPoint.time,
533
+ maxPoint.time
534
+ ]);
535
+ const labelCandidates = series.map((point, index) => {
536
+ const x = xScale(point.date);
537
+ const y = Math.max(12, Math.min(innerHeight - 8, yScale(point.value) - 10));
538
+ const isLast = point.time === lastPoint.time;
539
+ const isFirst = point.time === firstPoint.time;
540
+ const labelText = formatPointLabel(point.value, yUnit);
541
+ const textAnchor = isLast ? "end" : (isFirst ? "start" : "middle");
542
+ const labelWidth = estimateTextWidth(labelText, valueFontSize, 700) + 10;
543
+ const left = textAnchor === "end" ? x - labelWidth : (textAnchor === "start" ? x : x - labelWidth / 2);
544
+ const right = textAnchor === "end" ? x : (textAnchor === "start" ? x + labelWidth : x + labelWidth / 2);
545
+ const top = y - valueFontSize;
546
+ const bottom = y + 4;
547
+ const priority = (isLast ? 100 : 0)
548
+ + (point.time === firstPoint.time ? 80 : 0)
549
+ + (point.time === minPoint.time || point.time === maxPoint.time ? 70 : 0)
550
+ + (index % 2 === 0 ? 8 : 0);
551
+ return { point, x, y, isLast, labelText, textAnchor, left, right, top, bottom, priority, index };
552
+ }).sort((a, b) => b.priority - a.priority || a.index - b.index);
553
+ const selectedLabels = [];
554
+ labelCandidates.forEach(candidate => {
555
+ const inside = candidate.left >= -2 && candidate.right <= innerWidth + 2 && candidate.top >= -2 && candidate.bottom <= innerHeight + 12;
556
+ const overlaps = labelBoxes.some(box => (
557
+ candidate.left < box.right
558
+ && candidate.right > box.left
559
+ && candidate.top < box.bottom
560
+ && candidate.bottom > box.top
561
+ ));
562
+ if (inside && !overlaps) {
563
+ labelBoxes.push({
564
+ left: candidate.left - 3,
565
+ right: candidate.right + 3,
566
+ top: candidate.top - 3,
567
+ bottom: candidate.bottom + 3
568
+ });
569
+ selectedLabels.push(candidate);
570
+ }
571
+ });
572
+
573
+ selectedLabels.sort((a, b) => a.index - b.index).forEach(candidate => {
574
+ const point = candidate.point;
575
+ chart.append("text")
576
+ .attr("class", candidate.isLast ? "value latest-value" : "value key-value")
577
+ .attr("data-time", point.time)
578
+ .attr("data-label", point.label)
579
+ .attr("data-value", point.value)
580
+ .attr("x", candidate.x)
581
+ .attr("y", candidate.y)
582
+ .attr("text-anchor", candidate.textAnchor)
583
+ .style("font-family", valueFamily)
584
+ .style("font-size", `${valueFontSize}px`)
585
+ .style("font-weight", 700)
586
+ .style("fill", candidate.isLast ? purpleAccent : accentColor)
587
+ .text(candidate.labelText);
588
+ });
589
+
590
+ const slotW = Math.min(innerWidth * 0.36, 180);
591
+ const slotH = Math.min(innerHeight * 0.30, 132);
592
+ const slotX = Math.max(innerWidth * 0.52, innerWidth - slotW - 28);
593
+ const slotY = Math.min(innerHeight - slotH - 42, innerHeight * 0.53);
594
+ const growthSlot = chart.append("g")
595
+ .attr("class", "reserved-asset-slot spline-growth-cluster-slot")
596
+ .attr("data-asset-slot", "spline-growth-cluster")
597
+ .attr("data-asset-source-policy", "image2_asset")
598
+ .attr("data-anchor", "plot-lower-right")
599
+ .attr("data-collision-rule", "avoid spline, point labels, axes, and x tick labels")
600
+ .attr("transform", `translate(${slotX}, ${slotY})`);
601
+ growthSlot.append("rect")
602
+ .attr("class", "asset-slot-bbox")
603
+ .attr("width", slotW)
604
+ .attr("height", slotH)
605
+ .attr("rx", 16)
606
+ .attr("ry", 16)
607
+ .attr("fill", "rgba(255,255,255,0.66)")
608
+ .attr("stroke", rgba(purpleAccent, 0.30))
609
+ .attr("stroke-dasharray", "6 6")
610
+ .attr("stroke-width", 1.2)
611
+ .attr("filter", "url(#spline_graph_01_card_shadow)");
612
+ const dotGroup = growthSlot.append("g")
613
+ .attr("class", "decorative-dot-texture")
614
+ .attr("opacity", 0.45);
615
+ const dotSpacing = 13;
616
+ d3.range(0, 28).forEach(i => {
617
+ const col = i % 7;
618
+ const row = Math.floor(i / 7);
619
+ dotGroup.append("rect")
620
+ .attr("x", slotW * 0.08 + col * dotSpacing)
621
+ .attr("y", slotH * 0.58 + row * dotSpacing)
622
+ .attr("width", 4)
623
+ .attr("height", 4)
624
+ .attr("rx", 1)
625
+ .attr("fill", i % 3 === 0 ? rgba(purpleAccent, 0.45) : rgba(accentColor, 0.32));
626
+ });
627
+ growthSlot.append("circle")
628
+ .attr("class", "neutral-asset-placeholder")
629
+ .attr("cx", slotW * 0.58)
630
+ .attr("cy", slotH * 0.50)
631
+ .attr("r", Math.min(slotW, slotH) * 0.22)
632
+ .attr("fill", "none")
633
+ .attr("stroke", rgba(purpleAccent, 0.32))
634
+ .attr("stroke-width", 2);
635
+
636
+ return svg.node();
637
+ }
modules/chart_engine/template/d3-js/type26_spline_graph/spline_graph_plain_chart_02.js ADDED
@@ -0,0 +1,848 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Spline Graph",
5
+ "chart_name": "spline_graph_plain_chart_02",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[5, 30], ["-inf", "inf"], [2, 8]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["group"],
12
+ "required_other_colors": [],
13
+ "supported_effects": [],
14
+ "min_height": 400,
15
+ "min_width": 600,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "yes"
21
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+ function makeChart(containerSelector, data) {
26
+ const jsonData = data || {};
27
+ const chartData = (jsonData.data && jsonData.data.data) || [];
28
+ const variables = jsonData.variables || {};
29
+ const typography = jsonData.typography || {};
30
+ const colors = jsonData.colors || {};
31
+ const colorResolver = chartUtils.color.resolver(jsonData);
32
+ const dataColumns = chartUtils.schema.columns(jsonData);
33
+ const metadata = jsonData.metadata || {};
34
+
35
+ d3.select(containerSelector).html("");
36
+
37
+ const xField = (chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || dataColumns[0] || {}).name || chartUtils.schema.columnField(dataColumns, 0);
38
+ const yColumn = (chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || dataColumns[1] || {});
39
+ const yField = yColumn.name || chartUtils.schema.columnField(dataColumns, 1);
40
+ const groupField = (chartUtils.schema.channel(jsonData, "group", { fallbackIndex: 2 }).raw || dataColumns[2] || {}).name || chartUtils.schema.columnField(dataColumns, 2);
41
+ const yUnit = yColumn.unit && yColumn.unit !== "none" ? yColumn.unit : "";
42
+
43
+ const width = Math.max(980, Number(variables.width) || 980);
44
+ const height = Math.max(600, Number(variables.height) || 600);
45
+ const labelTypography = typography.label || {};
46
+ const titleTypography = typography.title || {};
47
+ const labelFamily = labelTypography.font_family || "Arial";
48
+ const titleFamily = titleTypography.font_family || labelFamily;
49
+ const baseLabelSize = parseFloat(labelTypography.font_size) || 13;
50
+
51
+ function rgba(color, opacity) {
52
+ const parsed = d3.rgb(color || "#000000");
53
+ return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${opacity})`;
54
+ }
55
+
56
+ function isTemporalFieldName(fieldName) {
57
+ return /(year|date|month|time|timestamp|quarter|season|period|week|day)/i.test(String(fieldName || ""));
58
+ }
59
+
60
+ function parseTemporal(value, fieldName) {
61
+ const temporalField = isTemporalFieldName(fieldName);
62
+ if (value instanceof Date && Number.isFinite(value.getTime())) return value;
63
+ if (typeof value === "number" && Number.isFinite(value)) {
64
+ if (Math.abs(value) >= 100000000000) return new Date(value);
65
+ if (Math.abs(value) >= 1000000000 && Math.abs(value) < 100000000000) return new Date(value * 1000);
66
+ if (temporalField && /month/i.test(String(fieldName || "")) && value >= 1 && value <= 12) {
67
+ return new Date(Date.UTC(2000, Math.round(value) - 1, 1));
68
+ }
69
+ if (value >= 1000 && value <= 9999) return new Date(Date.UTC(value, 0, 1));
70
+ return null;
71
+ }
72
+ const text = String(value ?? "").trim();
73
+ if (!text) return null;
74
+ const lower = text.toLowerCase();
75
+ const monthIndex = {
76
+ jan: 0, january: 0,
77
+ feb: 1, february: 1,
78
+ mar: 2, march: 2,
79
+ apr: 3, april: 3,
80
+ may: 4,
81
+ jun: 5, june: 5,
82
+ jul: 6, july: 6,
83
+ aug: 7, august: 7,
84
+ sep: 8, sept: 8, september: 8,
85
+ oct: 9, october: 9,
86
+ nov: 10, november: 10,
87
+ dec: 11, december: 11
88
+ };
89
+ const seasonIndex = {
90
+ winter: 0,
91
+ spring: 1,
92
+ summer: 2,
93
+ fall: 3,
94
+ autumn: 3
95
+ };
96
+ let match = text.match(/^(-?\d{4})$/);
97
+ if (match) return new Date(Date.UTC(Number(match[1]), 0, 1));
98
+ match = lower.match(/^(?:q([1-4])|quarter\s*([1-4]))(?:[\s,/-]*(\d{4}))?$/);
99
+ if (match && temporalField) {
100
+ const quarter = Number(match[1] || match[2]);
101
+ const year = match[3] ? Number(match[3]) : 2000;
102
+ return new Date(Date.UTC(year, (quarter - 1) * 3, 1));
103
+ }
104
+ match = lower.match(/^(\d{4})[\s,/-]*(?:q([1-4])|quarter\s*([1-4]))$/);
105
+ if (match) return new Date(Date.UTC(Number(match[1]), (Number(match[2] || match[3]) - 1) * 3, 1));
106
+ match = lower.match(/^([a-z]+)(?:[\s,/-]+(\d{4}))?$/);
107
+ if (match && temporalField && Object.prototype.hasOwnProperty.call(monthIndex, match[1])) {
108
+ return new Date(Date.UTC(match[2] ? Number(match[2]) : 2000, monthIndex[match[1]], 1));
109
+ }
110
+ if (match && temporalField && Object.prototype.hasOwnProperty.call(seasonIndex, match[1])) {
111
+ return new Date(Date.UTC(match[2] ? Number(match[2]) : 2000, seasonIndex[match[1]] * 3, 1));
112
+ }
113
+ match = lower.match(/^(\d{4})[\s,/-]+([a-z]+)$/);
114
+ if (match && Object.prototype.hasOwnProperty.call(monthIndex, match[2])) {
115
+ return new Date(Date.UTC(Number(match[1]), monthIndex[match[2]], 1));
116
+ }
117
+ if (match && Object.prototype.hasOwnProperty.call(seasonIndex, match[2])) {
118
+ return new Date(Date.UTC(Number(match[1]), seasonIndex[match[2]] * 3, 1));
119
+ }
120
+ match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
121
+ if (match) return new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3] || 1)));
122
+ const parsed = /[0-9]/.test(text) || temporalField ? new Date(text) : new Date(NaN);
123
+ if (Number.isFinite(parsed.getTime())) {
124
+ return new Date(Date.UTC(parsed.getUTCFullYear(), parsed.getUTCMonth(), parsed.getUTCDate()));
125
+ }
126
+ return null;
127
+ }
128
+
129
+ function formatNumber(value) {
130
+ const numeric = Number(value);
131
+ if (!Number.isFinite(numeric)) return String(value ?? "");
132
+ const absValue = Math.abs(numeric);
133
+ if (absValue >= 1000000000) return `${d3.format(".3~g")(numeric / 1000000000)}B`;
134
+ if (absValue >= 1000000) return `${d3.format(".3~g")(numeric / 1000000)}M`;
135
+ if (absValue >= 1000) return `${d3.format(".3~g")(numeric / 1000)}K`;
136
+ return d3.format(",.4~g")(numeric);
137
+ }
138
+
139
+ function formatWithUnit(value) {
140
+ const formatted = formatNumber(value);
141
+ if (!yUnit) return formatted;
142
+ if (yUnit === "%") return `${formatted}%`;
143
+ if (yUnit === "$") return `$${formatted}`;
144
+ return `${formatted} ${yUnit}`;
145
+ }
146
+
147
+ function wrapText(selection, text, maxWidth, lineHeight, maxLines) {
148
+ const words = String(text || "").split(/\s+/).filter(Boolean);
149
+ const lines = [];
150
+ let current = "";
151
+ words.forEach(word => {
152
+ const next = current ? `${current} ${word}` : word;
153
+ if (next.length * lineHeight * 0.52 <= maxWidth || !current) {
154
+ current = next;
155
+ } else {
156
+ lines.push(current);
157
+ current = word;
158
+ }
159
+ });
160
+ if (current) lines.push(current);
161
+ const visible = lines.slice(0, maxLines);
162
+ if (lines.length > maxLines && visible.length) {
163
+ visible[visible.length - 1] = `${visible[visible.length - 1].replace(/[,.!?;:]*$/, "")}...`;
164
+ }
165
+ selection.text(null);
166
+ visible.forEach((line, index) => {
167
+ selection.append("tspan")
168
+ .attr("x", selection.attr("x"))
169
+ .attr("dy", index === 0 ? 0 : lineHeight)
170
+ .text(line);
171
+ });
172
+ }
173
+
174
+ function estimateTextWidth(text, fontSize, letterSpacing = 0) {
175
+ return String(text || "").split("").reduce((sum, ch) => {
176
+ if (/\s/.test(ch)) return sum + fontSize * 0.34 + letterSpacing;
177
+ if ("ilI.,'`".includes(ch)) return sum + fontSize * 0.32 + letterSpacing;
178
+ if ("mwMW".includes(ch)) return sum + fontSize * 0.92 + letterSpacing;
179
+ if (/[A-Z0-9]/.test(ch)) return sum + fontSize * 0.68 + letterSpacing;
180
+ return sum + fontSize * 0.58 + letterSpacing;
181
+ }, 0);
182
+ }
183
+
184
+ function wrapTextNoEllipsis(selection, text, maxWidth, fontSize, lineHeight, maxLines, letterSpacing = 0) {
185
+ const words = String(text || "").split(/\s+/).filter(Boolean);
186
+ const lines = [];
187
+ let current = "";
188
+ words.forEach(word => {
189
+ const next = current ? `${current} ${word}` : word;
190
+ if (estimateTextWidth(next, fontSize, letterSpacing) <= maxWidth || !current) {
191
+ current = next;
192
+ } else {
193
+ lines.push(current);
194
+ current = word;
195
+ }
196
+ });
197
+ if (current) lines.push(current);
198
+ selection.text(null);
199
+ lines.forEach((line, index) => {
200
+ selection.append("tspan")
201
+ .attr("x", selection.attr("x"))
202
+ .attr("dy", index === 0 ? 0 : lineHeight)
203
+ .text(line);
204
+ });
205
+ return lines;
206
+ }
207
+
208
+ function wrapTitleLines(text, maxChars, maxLines) {
209
+ const words = String(text || "").toUpperCase().split(/\s+/).filter(Boolean);
210
+ const lines = [];
211
+ let current = "";
212
+ words.forEach(word => {
213
+ const next = current ? `${current} ${word}` : word;
214
+ if (next.length <= maxChars || !current) {
215
+ current = next;
216
+ } else {
217
+ lines.push(current);
218
+ current = word;
219
+ }
220
+ });
221
+ if (current) lines.push(current);
222
+ if (lines.length <= maxLines) return lines;
223
+ const kept = lines.slice(0, maxLines);
224
+ kept[maxLines - 1] = `${kept[maxLines - 1].replace(/[,.!?;:]*$/, "")}...`;
225
+ return kept;
226
+ }
227
+
228
+ function addText(parent, attrs, styles, text) {
229
+ const node = parent.append("text");
230
+ Object.entries(attrs || {}).forEach(([key, value]) => node.attr(key, value));
231
+ Object.entries(styles || {}).forEach(([key, value]) => node.style(key, value));
232
+ if (text !== undefined) node.text(text);
233
+ return node;
234
+ }
235
+
236
+ function getGroupColor(group, index) {
237
+ const palette = ["#B7202E", "#159E9A", "#9BCC2E", "#237FD1", "#8C35C8", "#F39C12", "#4D84E2", "#38A1E6"];
238
+ if (index !== undefined && index >= 0) return palette[index % palette.length];
239
+ const resolved = colorResolver.field(group, index || 0, { fallbackKey: "primary" });
240
+ if (resolved && resolved.value) return resolved.value;
241
+ return palette[index % palette.length];
242
+ }
243
+
244
+ const rows = chartData.map((row, index) => {
245
+ const date = parseTemporal(row[xField], xField);
246
+ const groupTime = parseTemporal(row[groupField], groupField);
247
+ const value = Number(row[yField]);
248
+ const group = String(row[groupField] ?? `Series ${index + 1}`);
249
+ return {
250
+ ...row,
251
+ __date: date,
252
+ __time: date ? date.getTime() : NaN,
253
+ __value: value,
254
+ __group: group,
255
+ __groupTime: groupTime ? groupTime.getTime() : NaN,
256
+ __xLabel: String(row[xField] ?? "")
257
+ };
258
+ }).filter(row => row.__date && Number.isFinite(row.__time) && Number.isFinite(row.__value));
259
+
260
+ if (!rows.length) return null;
261
+
262
+ const groups = Array.from(d3.group(rows, d => d.__group), ([name, values], index) => ({
263
+ name,
264
+ values: values.slice().sort((a, b) => a.__time - b.__time),
265
+ color: getGroupColor(name, index),
266
+ index
267
+ })).filter(series => series.values.length);
268
+
269
+ groups.forEach(series => {
270
+ series.first = series.values[0];
271
+ series.last = series.values[series.values.length - 1];
272
+ series.latestValue = series.last.__value;
273
+ series.groupTime = Number.isFinite(series.values[0].__groupTime) ? series.values[0].__groupTime : NaN;
274
+ });
275
+ const groupDimensionIsTemporal = isTemporalFieldName(groupField)
276
+ && groups.length > 0
277
+ && groups.every(series => Number.isFinite(series.groupTime));
278
+ groups.sort((a, b) => {
279
+ if (groupDimensionIsTemporal) return d3.ascending(a.groupTime, b.groupTime);
280
+ return d3.descending(a.latestValue, b.latestValue);
281
+ });
282
+ groups.forEach((series, index) => {
283
+ series.rank = index;
284
+ series.color = getGroupColor(series.name, index);
285
+ });
286
+
287
+ const allDates = rows.map(d => d.__date);
288
+ const allValues = rows.map(d => d.__value);
289
+ const xExtent = d3.extent(allDates);
290
+ const rawMax = d3.max(allValues);
291
+ const yMax = Math.max(1, rawMax || 1);
292
+ const niceTop = Math.max(10, Math.ceil(yMax / 20) * 20);
293
+
294
+ const svg = d3.select(containerSelector)
295
+ .append("svg")
296
+ .attr("width", width)
297
+ .attr("height", height)
298
+ .attr("viewBox", `0 0 ${width} ${height}`)
299
+ .attr("xmlns", "http://www.w3.org/2000/svg")
300
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
301
+ .attr("style", "max-width: 100%; height: auto;");
302
+
303
+ const defs = svg.append("defs");
304
+ const bgGradient = defs.append("linearGradient")
305
+ .attr("id", "spline02-bg-gradient")
306
+ .attr("x1", "0%").attr("y1", "0%")
307
+ .attr("x2", "100%").attr("y2", "100%");
308
+ bgGradient.append("stop").attr("offset", "0%").attr("stop-color", "#E8F6FF");
309
+ bgGradient.append("stop").attr("offset", "58%").attr("stop-color", "#F8FCFF");
310
+ bgGradient.append("stop").attr("offset", "100%").attr("stop-color", "#EEF8FF");
311
+
312
+ const plotGradient = defs.append("linearGradient")
313
+ .attr("id", "spline02-plot-gradient")
314
+ .attr("x1", "0%").attr("y1", "0%")
315
+ .attr("x2", "100%").attr("y2", "100%");
316
+ plotGradient.append("stop").attr("offset", "0%").attr("stop-color", "#FFFFFF");
317
+ plotGradient.append("stop").attr("offset", "100%").attr("stop-color", "#F4FBFF");
318
+
319
+ const shadow = defs.append("filter")
320
+ .attr("id", "spline02-card-shadow")
321
+ .attr("x", "-8%").attr("y", "-12%")
322
+ .attr("width", "116%").attr("height", "128%");
323
+ shadow.append("feDropShadow")
324
+ .attr("dx", 0)
325
+ .attr("dy", 3)
326
+ .attr("stdDeviation", 4)
327
+ .attr("flood-color", "#2D5B8C")
328
+ .attr("flood-opacity", 0.16);
329
+
330
+ const softShadow = defs.append("filter")
331
+ .attr("id", "spline02-soft-shadow")
332
+ .attr("x", "-12%").attr("y", "-18%")
333
+ .attr("width", "124%").attr("height", "136%");
334
+ softShadow.append("feDropShadow")
335
+ .attr("dx", 0)
336
+ .attr("dy", 3)
337
+ .attr("stdDeviation", 6)
338
+ .attr("flood-color", "#5483B1")
339
+ .attr("flood-opacity", 0.18);
340
+
341
+ svg.append("rect")
342
+ .attr("class", "spline02-page-background")
343
+ .attr("width", width)
344
+ .attr("height", height)
345
+ .attr("fill", "#E3F3FF");
346
+
347
+ const cardMargin = 14;
348
+ svg.append("rect")
349
+ .attr("class", "spline02-outer-card")
350
+ .attr("x", cardMargin)
351
+ .attr("y", cardMargin)
352
+ .attr("width", width - cardMargin * 2)
353
+ .attr("height", height - cardMargin * 2)
354
+ .attr("rx", 16)
355
+ .attr("ry", 16)
356
+ .attr("fill", "url(#spline02-bg-gradient)")
357
+ .attr("stroke", "#B7D5EA")
358
+ .attr("stroke-width", 1)
359
+ .style("filter", "url(#spline02-card-shadow)");
360
+
361
+ const plot = {
362
+ x: 66,
363
+ y: 83,
364
+ width: 530,
365
+ height: 446
366
+ };
367
+ const right = {
368
+ x: 628,
369
+ y: 68,
370
+ width: 318,
371
+ height: 466
372
+ };
373
+
374
+ const xPad = Math.max(1, (xExtent[1].getTime() - xExtent[0].getTime()) * 0.03);
375
+ const xScale = d3.scaleUtc()
376
+ .domain([new Date(xExtent[0].getTime() - xPad), new Date(xExtent[1].getTime() + xPad)])
377
+ .range([0, plot.width]);
378
+ const yScale = d3.scaleLinear()
379
+ .domain([0, niceTop])
380
+ .range([plot.height, 0]);
381
+
382
+ const titleText = String(metadata.title || "Internet Speeds in Canada");
383
+ const mainTitle = titleText.toLowerCase().includes("internet") && titleText.toLowerCase().includes("canada")
384
+ ? ["INTERNET SPEEDS", "IN CANADA"]
385
+ : wrapTitleLines(titleText, 21, 3);
386
+ const kicker = metadata.main_insight
387
+ ? String(metadata.main_insight).replace(/\.$/, "")
388
+ : "Broadband subsidies boost";
389
+
390
+ const friendlyYField = String(yField || "").replace(/([a-z])([A-Z])/g, "$1 $2");
391
+ const inferredUnit = yUnit || (/speed/i.test(friendlyYField) ? "Mbps" : "");
392
+ const measureLabel = inferredUnit ? `${friendlyYField} (${inferredUnit})` : friendlyYField;
393
+ const measure = svg.append("g")
394
+ .attr("class", "spline02-measure-label")
395
+ .attr("data-role", "measure-label")
396
+ .attr("transform", `translate(28, 48)`);
397
+ measure.append("circle")
398
+ .attr("class", "reserved-asset-slot measure-icon-slot")
399
+ .attr("data-asset-slot", "spline02-measure-icon")
400
+ .attr("data-asset-source-policy", "neutral_placeholder")
401
+ .attr("data-anchor", "outer-card-top-left")
402
+ .attr("data-collision-rule", "avoid measure label and plot y-axis labels")
403
+ .attr("cx", 18)
404
+ .attr("cy", 0)
405
+ .attr("r", 16)
406
+ .attr("fill", "#0878A6")
407
+ .attr("stroke", "#0E4D79")
408
+ .attr("stroke-width", 1.2);
409
+ measure.append("circle")
410
+ .attr("class", "neutral-asset-placeholder")
411
+ .attr("cx", 18)
412
+ .attr("cy", 0)
413
+ .attr("r", 7)
414
+ .attr("fill", "none")
415
+ .attr("stroke", "#FFFFFF")
416
+ .attr("stroke-width", 2);
417
+ addText(measure, { x: 50, y: 5, "class": "spline02-measure-text" }, {
418
+ "font-family": titleFamily,
419
+ "font-size": "16px",
420
+ "font-weight": 800,
421
+ "fill": "#071B43"
422
+ }, measureLabel);
423
+
424
+ const chart = svg.append("g")
425
+ .attr("class", "spline02-plot")
426
+ .attr("data-x-field", xField)
427
+ .attr("data-y-field", yField)
428
+ .attr("data-group-field", groupField)
429
+ .attr("transform", `translate(${plot.x}, ${plot.y})`);
430
+
431
+ chart.append("rect")
432
+ .attr("class", "plot-background")
433
+ .attr("x", 0)
434
+ .attr("y", 0)
435
+ .attr("width", plot.width)
436
+ .attr("height", plot.height)
437
+ .attr("rx", 13)
438
+ .attr("ry", 13)
439
+ .attr("fill", "url(#spline02-plot-gradient)")
440
+ .attr("stroke", "#A9CFEB")
441
+ .attr("stroke-width", 1);
442
+
443
+ const yTicks = yScale.ticks(5);
444
+ yTicks.forEach(tick => {
445
+ chart.append("line")
446
+ .attr("class", "gridline y-gridline")
447
+ .attr("x1", 0)
448
+ .attr("x2", plot.width)
449
+ .attr("y1", yScale(tick))
450
+ .attr("y2", yScale(tick))
451
+ .attr("stroke", "#BBDDF2")
452
+ .attr("stroke-dasharray", "3 4")
453
+ .attr("stroke-width", 1);
454
+ addText(chart, { x: -22, y: yScale(tick) + 5, "text-anchor": "end", "class": "axis-tick y-axis-tick" }, {
455
+ "font-family": labelFamily,
456
+ "font-size": `${Math.max(12, baseLabelSize + 2)}px`,
457
+ "font-weight": 600,
458
+ "fill": "#071B43"
459
+ }, formatNumber(tick));
460
+ });
461
+
462
+ const xTickValues = Array.from(new Set(rows.map(d => d.__time)))
463
+ .sort((a, b) => a - b)
464
+ .map(time => new Date(time));
465
+ const xTicks = xTickValues.length <= 8 ? xTickValues : xScale.ticks(7);
466
+ xTicks.forEach(tick => {
467
+ chart.append("line")
468
+ .attr("class", "gridline x-gridline")
469
+ .attr("x1", xScale(tick))
470
+ .attr("x2", xScale(tick))
471
+ .attr("y1", 0)
472
+ .attr("y2", plot.height)
473
+ .attr("stroke", "#BBDDF2")
474
+ .attr("stroke-dasharray", "3 4")
475
+ .attr("stroke-width", 1);
476
+ addText(chart, { x: xScale(tick), y: plot.height + 22, "text-anchor": "middle", "class": "axis-tick x-axis-tick" }, {
477
+ "font-family": labelFamily,
478
+ "font-size": `${Math.max(11, baseLabelSize + 1)}px`,
479
+ "font-weight": 600,
480
+ "fill": "#071B43"
481
+ }, d3.utcFormat("%Y")(tick));
482
+ });
483
+
484
+ const slotBaseX = plot.width * 0.065;
485
+ const slotBaseY = plot.height * 0.055;
486
+ let slotW = Math.min(226, plot.width * 0.48);
487
+ let slotH = Math.min(214, plot.height * 0.48);
488
+ function slotCollisionPenalty(candidate) {
489
+ const pad = candidate.compact ? 8 : 14;
490
+ let score = rows.reduce((currentScore, point) => {
491
+ const px = xScale(point.__date);
492
+ const py = yScale(point.__value);
493
+ const inside = px >= candidate.x - pad
494
+ && px <= candidate.x + candidate.w + pad
495
+ && py >= candidate.y - pad
496
+ && py <= candidate.y + candidate.h + pad;
497
+ return currentScore + (inside ? 120 : 0);
498
+ }, 0);
499
+ groups.forEach(series => {
500
+ for (let i = 1; i < series.values.length; i += 1) {
501
+ const a = series.values[i - 1];
502
+ const b = series.values[i];
503
+ for (let step = 0; step <= 24; step += 1) {
504
+ const t = step / 24;
505
+ const px = xScale(a.__date) * (1 - t) + xScale(b.__date) * t;
506
+ const py = yScale(a.__value) * (1 - t) + yScale(b.__value) * t;
507
+ const insideCore = px >= candidate.x
508
+ && px <= candidate.x + candidate.w
509
+ && py >= candidate.y
510
+ && py <= candidate.y + candidate.h;
511
+ if (px >= candidate.x - pad
512
+ && px <= candidate.x + candidate.w + pad
513
+ && py >= candidate.y - pad
514
+ && py <= candidate.y + candidate.h + pad) {
515
+ score += insideCore ? 90 : 36;
516
+ }
517
+ }
518
+ }
519
+ const endpointX = xScale(series.last.__date);
520
+ const endpointY = yScale(series.latestValue);
521
+ if (endpointX >= candidate.x - pad
522
+ && endpointX <= candidate.x + candidate.w + pad
523
+ && endpointY >= candidate.y - pad
524
+ && endpointY <= candidate.y + candidate.h + pad) {
525
+ score += 180;
526
+ }
527
+ });
528
+ return score;
529
+ }
530
+
531
+ function normalizeSlotCandidate(candidate) {
532
+ const safePad = 12;
533
+ const xMax = Math.max(safePad, Math.min(plot.width * 0.58, plot.width - candidate.w - safePad));
534
+ const yMax = Math.max(safePad, plot.height - candidate.h - 42);
535
+ const x = Math.max(safePad, Math.min(candidate.x, xMax));
536
+ const y = Math.max(safePad, Math.min(candidate.y, yMax));
537
+ const collision = slotCollisionPenalty({ ...candidate, x, y });
538
+ const drift = Math.abs(x - slotBaseX) / plot.width + Math.abs(y - slotBaseY) / plot.height;
539
+ const compactPenalty = candidate.compact ? 4 : 0;
540
+ const microPenalty = candidate.micro ? 9 : 0;
541
+ return {
542
+ ...candidate,
543
+ x,
544
+ y,
545
+ collision,
546
+ score: collision * 1000 + drift * 30 + compactPenalty + microPenalty
547
+ };
548
+ }
549
+
550
+ const mediumW = Math.min(184, plot.width * 0.34);
551
+ const mediumH = Math.min(112, plot.height * 0.23);
552
+ const compactW = Math.min(150, plot.width * 0.28);
553
+ const compactH = Math.min(58, plot.height * 0.13);
554
+ const microW = Math.min(112, plot.width * 0.22);
555
+ const microH = Math.min(38, plot.height * 0.09);
556
+ const slotCandidates = [
557
+ { x: slotBaseX, y: slotBaseY, w: slotW, h: slotH, variant: "reference-upper-left", compact: false },
558
+ { x: slotBaseX, y: slotBaseY, w: mediumW, h: mediumH, variant: "medium-upper-left", compact: true },
559
+ { x: slotBaseX + plot.width * 0.12, y: slotBaseY, w: mediumW, h: mediumH, variant: "medium-shifted-right", compact: true },
560
+ { x: slotBaseX, y: slotBaseY + plot.height * 0.15, w: mediumW, h: mediumH, variant: "medium-lowered", compact: true },
561
+ { x: slotBaseX + plot.width * 0.12, y: slotBaseY + plot.height * 0.15, w: mediumW, h: mediumH, variant: "medium-lowered-shifted", compact: true },
562
+ { x: slotBaseX, y: slotBaseY + plot.height * 0.28, w: compactW, h: compactH, variant: "compact-lower-left", compact: true },
563
+ { x: slotBaseX + plot.width * 0.14, y: slotBaseY + plot.height * 0.28, w: compactW, h: compactH, variant: "compact-lower-shifted", compact: true },
564
+ { x: slotBaseX + plot.width * 0.26, y: slotBaseY + plot.height * 0.12, w: compactW, h: compactH, variant: "compact-inner-upper", compact: true },
565
+ { x: slotBaseX + plot.width * 0.26, y: slotBaseY + plot.height * 0.28, w: compactW, h: compactH, variant: "compact-inner-lower", compact: true },
566
+ { x: slotBaseX, y: slotBaseY + plot.height * 0.42, w: microW, h: microH, variant: "micro-low-left", compact: true, micro: true },
567
+ { x: slotBaseX + plot.width * 0.18, y: slotBaseY + plot.height * 0.42, w: microW, h: microH, variant: "micro-low-shifted", compact: true, micro: true },
568
+ { x: slotBaseX + plot.width * 0.34, y: slotBaseY + plot.height * 0.22, w: microW, h: microH, variant: "micro-inner", compact: true, micro: true },
569
+ { x: slotBaseX, y: plot.height - microH - 54, w: microW, h: microH, variant: "micro-bottom-left", compact: true, micro: true },
570
+ { x: slotBaseX + plot.width * 0.18, y: plot.height - microH - 54, w: microW, h: microH, variant: "micro-bottom-shifted", compact: true, micro: true },
571
+ { x: slotBaseX + plot.width * 0.34, y: plot.height - microH - 54, w: microW, h: microH, variant: "micro-bottom-inner", compact: true, micro: true }
572
+ ].map(normalizeSlotCandidate);
573
+ let slotCandidate = slotCandidates.sort((a, b) => a.score - b.score || a.y - b.y || a.x - b.x)[0];
574
+ slotW = slotCandidate.w;
575
+ slotH = slotCandidate.h;
576
+ const slotX = slotCandidate.x;
577
+ const slotY = slotCandidate.y;
578
+ const slotIsCompact = slotCandidate.compact || slotCandidate.collision > 0;
579
+ const slotHasCollision = slotCandidate.collision > 0;
580
+ const slotVisualSuppressed = slotHasCollision;
581
+ const slot = chart.append("g")
582
+ .attr("class", "reserved-asset-slot spline02-plot-illustration-card")
583
+ .attr("data-asset-slot", "spline02-plot-illustration-card")
584
+ .attr("data-asset-source-policy", "image2_asset")
585
+ .attr("data-anchor", "plot-upper-left")
586
+ .attr("data-placement-variant", slotCandidate.variant || "base")
587
+ .attr("data-placeholder-text-policy", "suppressed-no-ellipsis")
588
+ .attr("data-visibility-policy", slotVisualSuppressed ? "semantic-only-no-visible-placeholder-after-collision" : "visible-neutral-placeholder")
589
+ .attr("data-collision-rule", "stay inside plot; avoid splines, endpoints, axes/ticks, and right cards; shrink/relocate/fade and suppress internals when no clear space exists")
590
+ .attr("data-collision-score", slotCandidate.collision)
591
+ .attr("transform", `translate(${slotX}, ${slotY})`);
592
+ slot.append("rect")
593
+ .attr("class", "asset-slot-bbox")
594
+ .attr("x", 0)
595
+ .attr("y", 0)
596
+ .attr("width", slotW)
597
+ .attr("height", slotH)
598
+ .attr("rx", 18)
599
+ .attr("ry", 18)
600
+ .attr("fill", "#FFFFFF")
601
+ .attr("fill-opacity", slotVisualSuppressed ? 0 : (slotIsCompact ? 0.34 : 1))
602
+ .attr("stroke", "#C7DDF0")
603
+ .attr("stroke-opacity", slotVisualSuppressed ? 0 : (slotIsCompact ? 0.62 : 1))
604
+ .attr("stroke-width", 1)
605
+ .style("filter", "url(#spline02-soft-shadow)");
606
+ slot.append("rect")
607
+ .attr("class", "asset-slot-mask")
608
+ .attr("x", 12)
609
+ .attr("y", 16)
610
+ .attr("width", slotW - 24)
611
+ .attr("height", slotH * 0.50)
612
+ .attr("rx", 10)
613
+ .attr("fill", "#EAF6FE")
614
+ .attr("fill-opacity", slotVisualSuppressed ? 0 : (slotIsCompact ? 0.30 : 1))
615
+ .attr("stroke", "#D7E8F6")
616
+ .attr("stroke-opacity", slotVisualSuppressed ? 0 : (slotIsCompact ? 0.44 : 1))
617
+ .attr("stroke-width", 1)
618
+ .attr("stroke-dasharray", "5 5");
619
+ if (!slotVisualSuppressed) {
620
+ slot.append("circle")
621
+ .attr("class", "neutral-asset-placeholder")
622
+ .attr("cx", slotW * 0.50)
623
+ .attr("cy", slotIsCompact ? slotH * 0.50 : slotH * 0.31)
624
+ .attr("r", Math.min(slotW, slotH) * (slotIsCompact ? 0.14 : 0.15))
625
+ .attr("fill", "none")
626
+ .attr("stroke", "#1E74CF")
627
+ .attr("stroke-opacity", slotIsCompact ? 0.62 : 1)
628
+ .attr("stroke-width", 3)
629
+ .attr("stroke-dasharray", "6 5");
630
+ }
631
+
632
+ const line = d3.line()
633
+ .defined(d => Number.isFinite(d.__value))
634
+ .x(d => xScale(d.__date))
635
+ .y(d => yScale(d.__value))
636
+ .curve(d3.curveMonotoneX);
637
+
638
+ groups.slice().reverse().forEach(series => {
639
+ chart.append("path")
640
+ .datum(series.values)
641
+ .attr("class", "spline-line mark")
642
+ .attr("data-series", series.name)
643
+ .attr("fill", "none")
644
+ .attr("stroke", series.color)
645
+ .attr("stroke-width", 3)
646
+ .attr("stroke-linecap", "round")
647
+ .attr("stroke-linejoin", "round")
648
+ .attr("d", line);
649
+ });
650
+
651
+ groups.forEach(series => {
652
+ chart.append("circle")
653
+ .attr("class", "endpoint-dot mark")
654
+ .attr("data-series", series.name)
655
+ .attr("data-value", series.latestValue)
656
+ .attr("cx", xScale(series.last.__date))
657
+ .attr("cy", yScale(series.latestValue))
658
+ .attr("r", 4.5)
659
+ .attr("fill", series.color)
660
+ .attr("stroke", "#FFFFFF")
661
+ .attr("stroke-width", 1.4);
662
+ });
663
+
664
+ const rightGroup = svg.append("g")
665
+ .attr("class", "spline02-right-column")
666
+ .attr("transform", `translate(${right.x}, ${right.y})`);
667
+
668
+ rightGroup.append("line")
669
+ .attr("x1", 0)
670
+ .attr("x2", right.width)
671
+ .attr("y1", 0)
672
+ .attr("y2", 0)
673
+ .attr("stroke", "#1E74CF")
674
+ .attr("stroke-width", 1.2);
675
+ rightGroup.append("circle")
676
+ .attr("class", "reserved-asset-slot header-emblem-slot")
677
+ .attr("data-asset-slot", "spline02-header-emblem")
678
+ .attr("data-asset-source-policy", "neutral_placeholder")
679
+ .attr("data-anchor", "right-title-top-center")
680
+ .attr("data-collision-rule", "stay above title and below outer-card edge")
681
+ .attr("cx", right.width / 2)
682
+ .attr("cy", 0)
683
+ .attr("r", 13)
684
+ .attr("fill", "#F2FAFF")
685
+ .attr("stroke", "#1E74CF")
686
+ .attr("stroke-width", 1.2);
687
+
688
+ const titleFontSize = mainTitle.length <= 2 ? 34 : 24;
689
+ const titleLineStep = mainTitle.length <= 2 ? 52 : 30;
690
+ const titleStartY = mainTitle.length <= 2 ? 76 : 58;
691
+ mainTitle.forEach((lineText, index) => {
692
+ addText(rightGroup, { x: 0, y: 76 + index * 58, "class": "right-main-title" }, {
693
+ "font-family": titleFamily,
694
+ "font-size": `${titleFontSize}px`,
695
+ "font-weight": 900,
696
+ "fill": index === 0 ? "#08264C" : "#176EC8",
697
+ "letter-spacing": "0px"
698
+ }, lineText);
699
+ });
700
+
701
+ rightGroup.selectAll(".right-main-title")
702
+ .attr("y", (d, index) => titleStartY + index * titleLineStep);
703
+
704
+ const dividerY = titleStartY + (mainTitle.length - 1) * titleLineStep + (mainTitle.length <= 2 ? 48 : 32);
705
+ const kickerY = dividerY + 28;
706
+
707
+ rightGroup.append("line")
708
+ .attr("x1", 0)
709
+ .attr("x2", right.width)
710
+ .attr("y1", dividerY)
711
+ .attr("y2", dividerY)
712
+ .attr("stroke", "#1E74CF")
713
+ .attr("stroke-width", 1.1);
714
+
715
+ const kickerText = (titleText.toLowerCase().includes("broadband subsidies boost")
716
+ ? "Broadband subsidies boost"
717
+ : kicker).toUpperCase();
718
+ const visibleCardCount = Math.min(groups.length, 8);
719
+ let cardGap = visibleCardCount >= 6 ? 6 : 8;
720
+ let cardH = 42;
721
+ const rightBottom = height - right.y - 22;
722
+ const cardStackHeight = Math.max(0, visibleCardCount * cardH + Math.max(0, visibleCardCount - 1) * cardGap);
723
+ const maxKickerBottom = Math.max(kickerY + 18, rightBottom - cardStackHeight - 18);
724
+ const maxKickerLines = visibleCardCount <= 4
725
+ ? 3
726
+ : (maxKickerBottom - kickerY >= 30 ? 2 : 2);
727
+ const kickerMaxWidth = right.width - 34;
728
+ let kickerFont = kickerText.length > 42 ? 12 : 14;
729
+ let kickerTracking = kickerText.length > 42 ? 0.6 : 1.2;
730
+ let kickerLineHeight = kickerText.length > 42 ? 14 : 16;
731
+ while (kickerFont > 11) {
732
+ const tentativeLines = (() => {
733
+ const words = kickerText.split(/\s+/).filter(Boolean);
734
+ const lines = [];
735
+ let current = "";
736
+ words.forEach(word => {
737
+ const next = current ? `${current} ${word}` : word;
738
+ if (estimateTextWidth(next, kickerFont, kickerTracking) <= kickerMaxWidth || !current) {
739
+ current = next;
740
+ } else {
741
+ lines.push(current);
742
+ current = word;
743
+ }
744
+ });
745
+ if (current) lines.push(current);
746
+ return lines;
747
+ })();
748
+ if (tentativeLines.length <= maxKickerLines) break;
749
+ kickerFont -= 1;
750
+ kickerTracking = Math.max(0.2, kickerTracking - 0.2);
751
+ kickerLineHeight = Math.max(13, kickerLineHeight - 1);
752
+ }
753
+ const kickerNode = addText(rightGroup, { x: right.width / 2, y: kickerY, "text-anchor": "middle", "class": "right-kicker" }, {
754
+ "font-family": titleFamily,
755
+ "font-size": `${kickerFont}px`,
756
+ "font-weight": 800,
757
+ "fill": "#6E7076",
758
+ "letter-spacing": `${kickerTracking}px`
759
+ }, "");
760
+ const kickerLines = wrapTextNoEllipsis(kickerNode, kickerText, kickerMaxWidth, kickerFont, kickerLineHeight, maxKickerLines, kickerTracking);
761
+ const cardY0 = kickerY + Math.max(1, kickerLines.length) * kickerLineHeight + 18;
762
+ const availableCardHeight = Math.max(0, rightBottom - cardY0);
763
+ if (visibleCardCount > 0) {
764
+ const fittedCardH = (availableCardHeight - Math.max(0, visibleCardCount - 1) * cardGap) / visibleCardCount;
765
+ if (Number.isFinite(fittedCardH) && fittedCardH < cardH) {
766
+ cardH = Math.max(30, fittedCardH);
767
+ const fittedGap = visibleCardCount > 1
768
+ ? (availableCardHeight - visibleCardCount * cardH) / (visibleCardCount - 1)
769
+ : cardGap;
770
+ cardGap = Math.max(4, Math.min(cardGap, fittedGap));
771
+ }
772
+ }
773
+
774
+ const chipW = 50;
775
+ const cardW = right.width;
776
+ groups.slice(0, 8).forEach((series, index) => {
777
+ const y = cardY0 + index * (cardH + cardGap);
778
+ const card = rightGroup.append("g")
779
+ .attr("class", "endpoint-value-card")
780
+ .attr("data-series", series.name)
781
+ .attr("data-value", series.latestValue)
782
+ .attr("data-relationship", "grid-aligned-centerline-to-endpoint-rank")
783
+ .attr("transform", `translate(0, ${y})`);
784
+ card.append("rect")
785
+ .attr("class", "endpoint-card-bg")
786
+ .attr("x", 0)
787
+ .attr("y", 0)
788
+ .attr("width", cardW)
789
+ .attr("height", cardH)
790
+ .attr("rx", 8)
791
+ .attr("ry", 8)
792
+ .attr("fill", "#FFFFFF")
793
+ .attr("stroke", "#E7F0F6")
794
+ .attr("stroke-width", 1)
795
+ .style("filter", "url(#spline02-soft-shadow)");
796
+ card.append("rect")
797
+ .attr("class", "endpoint-value-chip")
798
+ .attr("x", 0)
799
+ .attr("y", 0)
800
+ .attr("width", chipW)
801
+ .attr("height", cardH)
802
+ .attr("rx", 6)
803
+ .attr("ry", 6)
804
+ .attr("fill", series.color);
805
+ const valueText = d3.format(".0f")(series.latestValue);
806
+ const nominalValueFontSize = valueText.length <= 2 ? 22 : (valueText.length === 3 ? 20 : (valueText.length === 4 ? 17 : 14));
807
+ const valueFontSize = Math.min(nominalValueFontSize, Math.max(14, cardH * 0.52));
808
+ const cardTextY = Math.min(cardH - 8, cardH * 0.66);
809
+ addText(card, { x: chipW / 2, y: cardTextY, "text-anchor": "middle", "class": "endpoint-value" }, {
810
+ "font-family": titleFamily,
811
+ "font-size": `${valueFontSize}px`,
812
+ "font-weight": 900,
813
+ "fill": "#FFFFFF"
814
+ }, valueText);
815
+ const label = addText(card, { x: chipW + 14, y: cardTextY - 1, "class": "endpoint-label" }, {
816
+ "font-family": labelFamily,
817
+ "font-size": `${Math.max(10, Math.min(13, baseLabelSize - 1, cardH * 0.36))}px`,
818
+ "font-weight": 800,
819
+ "fill": "#071B43"
820
+ }, "");
821
+ wrapText(label, series.name, cardW - chipW - 64, 15, 1);
822
+
823
+ const miniX = cardW - 38;
824
+ const miniBaseline = cardH - 7;
825
+ const miniMaxH = Math.max(10, cardH - 18);
826
+ const recent = series.values.slice(-4);
827
+ const miniValues = recent.map(d => d.__value);
828
+ const miniMin = d3.min(miniValues);
829
+ const miniMax = d3.max(miniValues);
830
+ recent.forEach((point, barIndex) => {
831
+ const ratio = miniMax === miniMin ? 0.55 : (point.__value - miniMin) / (miniMax - miniMin);
832
+ const h = Math.min(miniMaxH, 5 + ratio * Math.max(8, miniMaxH - 5));
833
+ card.append("rect")
834
+ .attr("class", "endpoint-mini-bar mark")
835
+ .attr("data-series", series.name)
836
+ .attr("data-time", point.__time)
837
+ .attr("x", miniX + barIndex * 8)
838
+ .attr("y", miniBaseline - h)
839
+ .attr("width", 5)
840
+ .attr("height", h)
841
+ .attr("rx", 1)
842
+ .attr("fill", series.color)
843
+ .attr("opacity", 0.88);
844
+ });
845
+ });
846
+
847
+ return svg.node();
848
+ }
modules/chart_engine/template/d3-js/type26_spline_graph/spline_graph_plain_chart_03.js ADDED
@@ -0,0 +1,452 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Spline Graph",
5
+ "chart_name": "spline_graph_plain_chart_03",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[3, 50], [-1000, 1000], [2, 2]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": [],
13
+ "supported_effects": ["gradient", "opacity"],
14
+ "min_height": 600,
15
+ "min_width": 800,
16
+ "background": "styled",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "yes",
21
+ "chart_for": "comparison"
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 variables = jsonData.variables || {};
30
+ const typography = jsonData.typography || {};
31
+ const colorResolver = chartUtils.color.resolver(jsonData);
32
+ const dataColumns = chartUtils.schema.columns(jsonData);
33
+
34
+ d3.select(containerSelector).html("");
35
+
36
+ const xField = (chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || dataColumns[0] || {}).name || chartUtils.schema.columnField(dataColumns, 0);
37
+ const yColumn = (chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || dataColumns[1] || {});
38
+ const yField = yColumn.name || chartUtils.schema.columnField(dataColumns, 1);
39
+ const groupField = (chartUtils.schema.channel(jsonData, "group", { fallbackIndex: 2 }).raw || dataColumns[2] || {}).name || chartUtils.schema.columnField(dataColumns, 2);
40
+ const width = Math.max(520, Number(variables.width) || 640);
41
+ const height = Math.max(420, Number(variables.height) || 520);
42
+ const fontFamily = (typography.label && typography.label.font_family) || "Century Gothic, Arial, sans-serif";
43
+
44
+ const svg = d3.select(containerSelector)
45
+ .append("svg")
46
+ .attr("width", "100%")
47
+ .attr("height", height)
48
+ .attr("viewBox", `0 0 ${width} ${height}`)
49
+ .attr("style", "max-width: 100%; height: auto;")
50
+ .attr("xmlns", "http://www.w3.org/2000/svg")
51
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
52
+
53
+ const defs = svg.append("defs");
54
+ const shadow = defs.append("filter")
55
+ .attr("id", "spline03-panel-shadow")
56
+ .attr("x", "-20%")
57
+ .attr("y", "-20%")
58
+ .attr("width", "140%")
59
+ .attr("height", "140%");
60
+ shadow.append("feDropShadow")
61
+ .attr("dx", 0)
62
+ .attr("dy", 10)
63
+ .attr("stdDeviation", 12)
64
+ .attr("flood-color", "#12315f")
65
+ .attr("flood-opacity", 0.12);
66
+
67
+ const bgGrad = defs.append("linearGradient")
68
+ .attr("id", "spline03-panel-grad")
69
+ .attr("x1", "0%")
70
+ .attr("y1", "0%")
71
+ .attr("x2", "100%")
72
+ .attr("y2", "100%");
73
+ bgGrad.append("stop").attr("offset", "0%").attr("stop-color", "#ffffff");
74
+ bgGrad.append("stop").attr("offset", "100%").attr("stop-color", "#f4f8ff");
75
+
76
+ function isTemporalFieldName(fieldName) {
77
+ return /(year|date|month|time|timestamp|quarter|season|period|week|day)/i.test(String(fieldName || ""));
78
+ }
79
+
80
+ function parseTemporal(value, fieldName) {
81
+ if (value instanceof Date && Number.isFinite(value.getTime())) return value;
82
+ if (typeof value === "number" && Number.isFinite(value)) {
83
+ if (value >= 1000 && value <= 9999) return new Date(Date.UTC(value, 0, 1));
84
+ if (isTemporalFieldName(fieldName) && value >= 1 && value <= 12) return new Date(Date.UTC(2000, Math.round(value) - 1, 1));
85
+ return new Date(value);
86
+ }
87
+ const text = String(value ?? "").trim();
88
+ const lower = text.toLowerCase();
89
+ const monthIndex = {jan:0,january:0,feb:1,february:1,mar:2,march:2,apr:3,april:3,may:4,jun:5,june:5,jul:6,july:6,aug:7,august:7,sep:8,sept:8,september:8,oct:9,october:9,nov:10,november:10,dec:11,december:11};
90
+ const seasonIndex = {winter:0,spring:1,summer:2,fall:3,autumn:3};
91
+ let match = text.match(/^(-?\d{4})$/);
92
+ if (match) return new Date(Date.UTC(Number(match[1]), 0, 1));
93
+ match = lower.match(/^(\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
94
+ if (match) return new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3] || 1)));
95
+ match = lower.match(/^([a-z]+)(?:[\s,/-]+(\d{4}))?$/);
96
+ if (match && Object.prototype.hasOwnProperty.call(monthIndex, match[1])) return new Date(Date.UTC(match[2] ? Number(match[2]) : 2000, monthIndex[match[1]], 1));
97
+ if (match && Object.prototype.hasOwnProperty.call(seasonIndex, match[1])) return new Date(Date.UTC(match[2] ? Number(match[2]) : 2000, seasonIndex[match[1]] * 3, 1));
98
+ const parsed = new Date(text);
99
+ return Number.isFinite(parsed.getTime()) ? parsed : null;
100
+ }
101
+
102
+ function estimateTextWidth(text, fontSize) {
103
+ return String(text || "").split("").reduce((sum, ch) => {
104
+ if (/\s/.test(ch)) return sum + fontSize * 0.34;
105
+ if ("ilI.,'`".includes(ch)) return sum + fontSize * 0.32;
106
+ if ("mwMW".includes(ch)) return sum + fontSize * 0.9;
107
+ return sum + fontSize * 0.58;
108
+ }, 0);
109
+ }
110
+
111
+ function addWrappedText(parent, text, x, y, maxWidth, fontSize, lineHeight, maxLines, fill, weight) {
112
+ const words = String(text || "").split(/\s+/).filter(Boolean);
113
+ const lines = [];
114
+ let current = "";
115
+ words.forEach(word => {
116
+ const next = current ? `${current} ${word}` : word;
117
+ if (estimateTextWidth(next, fontSize) <= maxWidth || !current) {
118
+ current = next;
119
+ } else {
120
+ lines.push(current);
121
+ current = word;
122
+ }
123
+ });
124
+ if (current) lines.push(current);
125
+ const visible = lines.slice(0, maxLines);
126
+ if (lines.length > maxLines && visible.length) visible[visible.length - 1] = visible[visible.length - 1].replace(/[,.!?;:]*$/, "") + "...";
127
+ const node = parent.append("text")
128
+ .attr("x", x)
129
+ .attr("y", y)
130
+ .attr("font-family", fontFamily)
131
+ .attr("font-size", fontSize)
132
+ .attr("font-weight", weight || 400)
133
+ .attr("fill", fill);
134
+ visible.forEach((line, i) => {
135
+ node.append("tspan")
136
+ .attr("x", x)
137
+ .attr("dy", i === 0 ? 0 : lineHeight)
138
+ .text(line);
139
+ });
140
+ return node;
141
+ }
142
+
143
+ const firstSeen = new Map();
144
+ const rows = chartData.map((row, index) => {
145
+ const date = parseTemporal(row[xField], xField);
146
+ const value = Number(row[yField]);
147
+ const group = String(row[groupField] ?? `Series ${index + 1}`);
148
+ if (!firstSeen.has(group)) firstSeen.set(group, firstSeen.size);
149
+ return {
150
+ ...row,
151
+ __date: date,
152
+ __time: date ? date.getTime() : NaN,
153
+ __value: value,
154
+ __group: group,
155
+ __order: firstSeen.get(group)
156
+ };
157
+ }).filter(row => row.__date && Number.isFinite(row.__time) && Number.isFinite(row.__value));
158
+
159
+ if (!rows.length) return svg.node();
160
+
161
+ const groups = Array.from(d3.group(rows, d => d.__group), ([name, values]) => ({
162
+ name,
163
+ order: values[0].__order,
164
+ values: values.slice().sort((a, b) => a.__time - b.__time)
165
+ })).sort((a, b) => d3.ascending(a.order, b.order)).slice(0, 2);
166
+
167
+ groups.forEach((series, index) => {
168
+ const first = series.values[0].__value;
169
+ const extent = d3.extent(series.values, d => d.__value);
170
+ const denom = Math.max(1e-6, Math.max(Math.abs(extent[0] - first), Math.abs(extent[1] - first), Math.abs(extent[1] - extent[0])));
171
+ const sign = index === 0 ? 1 : -1;
172
+ const targetRange = index === 0 ? 3.35 : 3.0;
173
+ series.color = index === 0 ? "#3f9325" : "#8c58c7";
174
+ series.fill = index === 0 ? "#dff0d7" : "#efe4fb";
175
+ series.values = series.values.map(point => ({
176
+ ...point,
177
+ __plotValue: sign * ((point.__value - first) / denom) * targetRange
178
+ }));
179
+ series.last = series.values[series.values.length - 1];
180
+ series.focus = index === 0
181
+ ? series.values.reduce((best, point) => point.__plotValue > best.__plotValue ? point : best, series.values[0])
182
+ : series.last;
183
+ });
184
+
185
+ const allTimes = rows.map(d => d.__time);
186
+ const panel = { x: width * 0.035, y: height * 0.028, w: width * 0.93, h: height * 0.91 };
187
+ const plot = {
188
+ x: panel.x + Math.max(58, width * 0.095),
189
+ y: panel.y + Math.max(38, height * 0.078),
190
+ w: panel.w - Math.max(86, width * 0.15),
191
+ h: panel.h - Math.max(68, height * 0.13)
192
+ };
193
+ const cardW = Math.min(Math.max(150, width * 0.27), 190);
194
+ const cardH = Math.min(Math.max(74, height * 0.14), 88);
195
+ const cardX = panel.x + panel.w - cardW - Math.max(10, width * 0.018);
196
+ plot.w = Math.max(260, panel.x + panel.w - plot.x - Math.max(34, width * 0.058));
197
+
198
+ const xScale = d3.scaleTime()
199
+ .domain(d3.extent(allTimes).map(t => new Date(t)))
200
+ .range([plot.x, plot.x + plot.w]);
201
+ const yScale = d3.scaleLinear()
202
+ .domain([-5, 5])
203
+ .range([plot.y + plot.h, plot.y]);
204
+ const yearFormat = d3.timeFormat("%Y");
205
+ const candidateTickTimes = Array.from(new Set(rows.map(d => d.__time))).sort((a, b) => a - b);
206
+ const maxTickCount = Math.max(4, Math.min(8, Math.floor(plot.w / 58)));
207
+ const sampledTickTimes = candidateTickTimes.filter((_, i, arr) => arr.length <= maxTickCount || i % Math.ceil(arr.length / maxTickCount) === 0 || i === arr.length - 1);
208
+ const minTickGap = 34;
209
+ const xTickValues = [];
210
+ sampledTickTimes.forEach((time, index) => {
211
+ const isLast = index === sampledTickTimes.length - 1;
212
+ if (!xTickValues.length) {
213
+ xTickValues.push(time);
214
+ return;
215
+ }
216
+ const previous = xTickValues[xTickValues.length - 1];
217
+ if (Math.abs(xScale(new Date(time)) - xScale(new Date(previous))) >= minTickGap) {
218
+ xTickValues.push(time);
219
+ } else if (isLast) {
220
+ xTickValues[xTickValues.length - 1] = time;
221
+ }
222
+ });
223
+ const xTickDates = xTickValues.map(t => new Date(t));
224
+
225
+ svg.append("rect")
226
+ .attr("class", "spline03-chart-panel")
227
+ .attr("x", panel.x)
228
+ .attr("y", panel.y)
229
+ .attr("width", panel.w)
230
+ .attr("height", panel.h)
231
+ .attr("rx", Math.min(22, width * 0.035))
232
+ .attr("fill", "url(#spline03-panel-grad)")
233
+ .attr("stroke", "#d7e1ef")
234
+ .attr("filter", "url(#spline03-panel-shadow)");
235
+
236
+ const chart = svg.append("g").attr("class", "spline03-chart-body");
237
+ const yTicks = d3.range(-5, 6, 1);
238
+ yTicks.forEach(tick => {
239
+ chart.append("line")
240
+ .attr("x1", plot.x)
241
+ .attr("x2", plot.x + plot.w)
242
+ .attr("y1", yScale(tick))
243
+ .attr("y2", yScale(tick))
244
+ .attr("stroke", tick === 0 ? "#8793aa" : "#dce4ee")
245
+ .attr("stroke-width", tick === 0 ? 1.2 : 0.8)
246
+ .attr("stroke-dasharray", tick === 0 ? "7 7" : "4 6");
247
+ chart.append("text")
248
+ .attr("x", plot.x - 12)
249
+ .attr("y", yScale(tick) + 4)
250
+ .attr("text-anchor", "end")
251
+ .attr("font-family", fontFamily)
252
+ .attr("font-size", tick === 0 ? 15 : 12)
253
+ .attr("font-weight", tick === 0 ? 700 : 400)
254
+ .attr("fill", tick === 0 ? "#26324f" : "#3f4a62")
255
+ .text(`${tick}%`);
256
+ });
257
+ chart.append("line").attr("x1", plot.x).attr("x2", plot.x).attr("y1", plot.y).attr("y2", plot.y + plot.h).attr("stroke", "#aeb8c9");
258
+ chart.append("line").attr("x1", plot.x).attr("x2", plot.x + plot.w).attr("y1", plot.y + plot.h).attr("y2", plot.y + plot.h).attr("stroke", "#aeb8c9");
259
+ xTickDates.forEach(tick => {
260
+ chart.append("text")
261
+ .attr("x", xScale(tick))
262
+ .attr("y", plot.y + plot.h + 24)
263
+ .attr("text-anchor", "middle")
264
+ .attr("font-family", fontFamily)
265
+ .attr("font-size", 12)
266
+ .attr("fill", "#26324f")
267
+ .text(yearFormat(tick));
268
+ });
269
+
270
+ chart.append("text")
271
+ .attr("x", panel.x + 18)
272
+ .attr("y", plot.y + plot.h / 2)
273
+ .attr("transform", `rotate(-90 ${panel.x + 18} ${plot.y + plot.h / 2})`)
274
+ .attr("text-anchor", "middle")
275
+ .attr("font-family", fontFamily)
276
+ .attr("font-size", 12)
277
+ .attr("font-weight", 700)
278
+ .attr("fill", "#173c79")
279
+ .text(`${String(yField).toUpperCase()} DIFFERENCE`);
280
+ const line = d3.line()
281
+ .x(d => xScale(d.__date))
282
+ .y(d => yScale(d.__plotValue))
283
+ .curve(d3.curveMonotoneX);
284
+ const area = d3.area()
285
+ .x(d => xScale(d.__date))
286
+ .y0(yScale(0))
287
+ .y1(d => yScale(d.__plotValue))
288
+ .curve(d3.curveMonotoneX);
289
+
290
+ groups.forEach((series, index) => {
291
+ chart.append("path")
292
+ .datum(series.values)
293
+ .attr("class", `spline03-area spline03-series-${index}`)
294
+ .attr("fill", series.fill)
295
+ .attr("opacity", 0.58)
296
+ .attr("d", area);
297
+ chart.append("path")
298
+ .datum(series.values)
299
+ .attr("class", `spline03-line spline03-series-${index}`)
300
+ .attr("fill", "none")
301
+ .attr("stroke", series.color)
302
+ .attr("stroke-width", 3.2)
303
+ .attr("stroke-linecap", "round")
304
+ .attr("stroke-linejoin", "round")
305
+ .attr("d", line);
306
+ chart.selectAll(`.spline03-dot-${index}`)
307
+ .data(series.values)
308
+ .enter()
309
+ .append("circle")
310
+ .attr("class", `spline03-dot spline03-dot-${index}`)
311
+ .attr("cx", d => xScale(d.__date))
312
+ .attr("cy", d => yScale(d.__plotValue))
313
+ .attr("r", d => d === series.focus || d === series.last ? 5.4 : 3.6)
314
+ .attr("fill", "#ffffff")
315
+ .attr("stroke", series.color)
316
+ .attr("stroke-width", d => d === series.focus || d === series.last ? 3 : 2);
317
+ });
318
+
319
+ if (candidateTickTimes.length > 2) {
320
+ const markerDate = new Date(candidateTickTimes[1]);
321
+ const mx = xScale(markerDate);
322
+ chart.append("line")
323
+ .attr("class", "spline03-baseline-marker")
324
+ .attr("x1", mx)
325
+ .attr("x2", mx)
326
+ .attr("y1", yScale(0) - 2)
327
+ .attr("y2", yScale(2.05))
328
+ .attr("stroke", "#f28a23")
329
+ .attr("stroke-dasharray", "4 4");
330
+ chart.append("circle").attr("cx", mx).attr("cy", yScale(0)).attr("r", 6).attr("fill", "#ffffff").attr("stroke", "#f28a23").attr("stroke-width", 3);
331
+ chart.append("text")
332
+ .attr("x", mx)
333
+ .attr("y", yScale(0) + 22)
334
+ .attr("text-anchor", "middle")
335
+ .attr("font-family", fontFamily)
336
+ .attr("font-size", 13)
337
+ .attr("font-weight", 700)
338
+ .attr("fill", "#f28a23")
339
+ .text(yearFormat(markerDate));
340
+ }
341
+
342
+ function calloutText(series, index) {
343
+ const trend = series.last.__value - series.values[0].__value;
344
+ if (index === 0) return trend < 0 ? "underperforming since peak." : "outperforming since baseline.";
345
+ return trend >= 0 ? "outperforming with momentum." : "underperforming recently.";
346
+ }
347
+
348
+ const placedCallouts = [];
349
+ function rectIntersects(a, b, gap) {
350
+ const pad = gap || 0;
351
+ return !(a.x + a.w + pad < b.x || b.x + b.w + pad < a.x || a.y + a.h + pad < b.y || b.y + b.h + pad < a.y);
352
+ }
353
+
354
+ function rectContainsPoint(rect, point, pad) {
355
+ const p = pad || 0;
356
+ return point.x >= rect.x - p && point.x <= rect.x + rect.w + p && point.y >= rect.y - p && point.y <= rect.y + rect.h + p;
357
+ }
358
+
359
+ function chooseCalloutY(series, index, sx, sy) {
360
+ const minY = panel.y + 14;
361
+ const maxY = panel.y + panel.h - cardH - 14;
362
+ const clampY = value => Math.max(minY, Math.min(maxY, value));
363
+ const points = groups.flatMap(group => group.values.map(point => ({
364
+ x: xScale(point.__date),
365
+ y: yScale(point.__plotValue)
366
+ })));
367
+ const preferred = index === 0
368
+ ? [plot.y + plot.h * 0.24, sy - cardH * 0.45, sy + 18, plot.y + plot.h * 0.12]
369
+ : [Math.max(plot.y + plot.h * 0.73, sy + 18), plot.y + plot.h * 0.79, maxY, sy - cardH - 22];
370
+ const candidates = Array.from(new Set(preferred.map(value => Math.round(clampY(value) * 10) / 10)));
371
+
372
+ function score(y) {
373
+ const rect = { x: cardX, y, w: cardW, h: cardH };
374
+ let penalty = Math.abs(y - clampY(preferred[0]));
375
+ points.forEach(point => {
376
+ if (rectContainsPoint(rect, point, 9)) penalty += 1000;
377
+ });
378
+ if (rectContainsPoint(rect, { x: sx, y: sy }, 14)) penalty += 2500;
379
+ placedCallouts.forEach(other => {
380
+ if (rectIntersects(rect, other, 10)) penalty += 1800;
381
+ });
382
+ if (index === 1 && y < sy + 12) penalty += 900;
383
+ return penalty;
384
+ }
385
+
386
+ return candidates.sort((a, b) => score(a) - score(b))[0];
387
+ }
388
+
389
+ function addCallout(series, index) {
390
+ const target = series.focus || series.last;
391
+ const cardFill = index === 0 ? "#edf8e9" : "#f5ecff";
392
+ const sx = xScale(target.__date);
393
+ const sy = yScale(target.__plotValue);
394
+ const boundedY = chooseCalloutY(series, index, sx, sy);
395
+ const ex = cardX - 8;
396
+ const ey = boundedY + cardH / 2;
397
+ chart.append("path")
398
+ .attr("class", "spline03-callout-leader")
399
+ .attr("d", `M${sx},${sy} C${sx + 22},${sy} ${ex - 30},${ey} ${ex},${ey}`)
400
+ .attr("fill", "none")
401
+ .attr("stroke", series.color)
402
+ .attr("stroke-width", 1.4)
403
+ .attr("stroke-dasharray", "5 5")
404
+ .attr("opacity", 0.75);
405
+ const card = chart.append("g")
406
+ .attr("class", `spline03-callout-card spline03-callout-${index}`)
407
+ .attr("data-anchor-x", sx.toFixed(1))
408
+ .attr("data-anchor-y", sy.toFixed(1))
409
+ .attr("data-collision-rule", "right-floating-card-clamped-inside-panel-avoid-endpoints-marks-axis-and-sibling-callouts")
410
+ .attr("transform", `translate(${cardX}, ${boundedY})`);
411
+ placedCallouts.push({ x: cardX, y: boundedY, w: cardW, h: cardH });
412
+ card.append("rect")
413
+ .attr("width", cardW)
414
+ .attr("height", cardH)
415
+ .attr("rx", 16)
416
+ .attr("fill", cardFill)
417
+ .attr("stroke", series.color)
418
+ .attr("stroke-opacity", 0.3);
419
+ card.append("circle")
420
+ .attr("class", "reserved-asset-slot spline03-callout-icon-slot")
421
+ .attr("data-asset-slot", index === 0 ? "spline03-callout-icon-primary" : "spline03-callout-icon-secondary")
422
+ .attr("data-asset-source-policy", "image2_asset")
423
+ .attr("data-anchor", "callout-card-left")
424
+ .attr("data-collision-rule", "inside-callout-card-avoid-text")
425
+ .attr("cx", 26)
426
+ .attr("cy", cardH / 2)
427
+ .attr("r", Math.min(22, cardH * 0.32))
428
+ .attr("fill", "#ffffff")
429
+ .attr("stroke", series.color)
430
+ .attr("stroke-opacity", 0.25);
431
+ card.append("circle").attr("cx", 26).attr("cy", cardH / 2).attr("r", 7).attr("fill", series.color).attr("opacity", 0.78);
432
+ const labelText = series.name.toUpperCase();
433
+ const labelMaxWidth = cardW - 62;
434
+ const labelNode = card.append("text")
435
+ .attr("x", 54)
436
+ .attr("y", 25)
437
+ .attr("font-family", fontFamily)
438
+ .attr("font-size", 15)
439
+ .attr("font-weight", 800)
440
+ .attr("fill", series.color)
441
+ .text(labelText);
442
+ if (estimateTextWidth(labelText, 15) > labelMaxWidth) {
443
+ labelNode
444
+ .attr("textLength", labelMaxWidth)
445
+ .attr("lengthAdjust", "spacingAndGlyphs");
446
+ }
447
+ addWrappedText(card, calloutText(series, index), 54, 43, cardW - 62, 11, 13, 3, "#28344f", 400);
448
+ }
449
+ groups.forEach(addCallout);
450
+
451
+ return svg.node();
452
+ }