Ray1ee01 commited on
Commit
3983ff4
·
verified ·
1 Parent(s): 52e6e0b

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/type29_small_multiples_line_graphs/small_multiples_line_graphs_plain_chart_01.js ADDED
@@ -0,0 +1,1017 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "small multiples of line graphs",
5
+ "chart_name": "small_multiples_line_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": 1000,
16
+ "min_width": 760,
17
+ "has_title": 1,
18
+ "background": "light",
19
+ "icon_mark": "none",
20
+ "icon_label": "none",
21
+ "has_x_axis": "yes",
22
+ "has_y_axis": "yes"
23
+ }
24
+ REQUIREMENTS_END
25
+ */
26
+
27
+ function makeChart(containerSelector, data) {
28
+ const jsonData = data || {};
29
+ const chartData = (jsonData.data && jsonData.data.data) || [];
30
+ const dataColumns = chartUtils.schema.columns(jsonData);
31
+ const colorResolver = chartUtils.color.resolver(jsonData);
32
+ const chartUtilsFormatSample = chartUtils.format.autoText;
33
+ const chartUtilsTextSample = chartUtils.text.estimate;
34
+ const chartUtilsStandard = {
35
+ schema: chartUtils.schema,
36
+ format: chartUtils.format,
37
+ text: chartUtils.text,
38
+ color: chartUtils.color,
39
+ legendLayout: chartUtils.legend.layout,
40
+ legendDraw: chartUtils.legend.draw,
41
+ random: chartUtils.random.generator(jsonData, "dev-wz-style")
42
+ };
43
+ const standardChannels = chartUtils.schema.channels(jsonData, {
44
+ x: { fallbackIndex: 0 },
45
+ y: { fallbackIndex: 1 },
46
+ y2: { fallbackIndex: 2 },
47
+ y3: { fallbackIndex: 3 },
48
+ size: { fallbackIndex: 2 },
49
+ group: { fallbackIndex: 2 },
50
+ group2: { fallbackIndex: 3 },
51
+ group3: { fallbackIndex: 4 }
52
+ });
53
+ const variables = jsonData.variables || {};
54
+ const typography = jsonData.typography || {
55
+ label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
56
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
57
+ title: { font_family: "Georgia", font_size: "16px", font_weight: "bold" }
58
+ };
59
+ const sourceColors = jsonData.colors || {};
60
+ const colorField = sourceColors.field || {};
61
+ const colorOther = sourceColors.other || {};
62
+
63
+ d3.select(containerSelector).html("");
64
+
65
+ const xColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
66
+ const yColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
67
+ const groupColumn = chartUtils.schema.channel(jsonData, "group", { fallbackIndex: 2 }).raw || {};
68
+ const xField = xColumn.name || "date";
69
+ const yField = yColumn.name || "value";
70
+ const groupField = groupColumn.name || "group";
71
+ const yUnit = yColumn.unit && yColumn.unit !== "none" ? yColumn.unit : "";
72
+
73
+ const width = Math.max(760, Number(variables.width) || 760);
74
+ const navy = "#111a42";
75
+ const accentBlue = colorOther.primary || "#1287c9";
76
+ const mutedText = "#4a5568";
77
+ const axisText = "#26334f";
78
+ const canvasColor = "#f7fbff";
79
+ const cardFill = "#ffffff";
80
+ const linePalette = ["#1287c9", "#d79b00", "#7444c6", "#329546", "#e65a45", "#2763c9", "#089a9c", "#af5b28"];
81
+ const headerPalette = ["#eaf7ff", "#fff7dc", "#f4ecff", "#eef8ee", "#fff0ec", "#edf2ff", "#e9faf8", "#fff3e8"];
82
+ const labelTypography = typography.label || {};
83
+ const titleTypography = typography.title || labelTypography;
84
+ const valueTypography = typography.annotation || labelTypography;
85
+ const labelFamily = labelTypography.font_family || "Arial";
86
+ const titleFamily = titleTypography.font_family || "Georgia";
87
+ const valueFamily = valueTypography.font_family || labelFamily;
88
+ const labelWeight = labelTypography.font_weight || "normal";
89
+ const titleWeight = titleTypography.font_weight || "700";
90
+ const valueWeight = valueTypography.font_weight || "600";
91
+ const baseLabelSize = parseFloat(labelTypography.font_size) || 11;
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 readableName(value) {
100
+ return String(value || "")
101
+ .replace(/_/g, " ")
102
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
103
+ .replace(/\s+/g, " ")
104
+ .trim();
105
+ }
106
+
107
+ function cleanText(value) {
108
+ return String(value || "")
109
+ .replace(/\*\*/g, "")
110
+ .replace(/\s+/g, " ")
111
+ .trim();
112
+ }
113
+
114
+ function parseTemporalDate(value) {
115
+ if (value instanceof Date && Number.isFinite(value.getTime())) return value;
116
+ const text = String(value ?? "").trim();
117
+ if (!text) return null;
118
+
119
+ let match = text.match(/^(-?\d{4})$/);
120
+ if (match) return new Date(Date.UTC(Number(match[1]), 0, 1));
121
+
122
+ match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
123
+ if (match) return new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3] || 1)));
124
+
125
+ match = text.match(/^Q([1-4])\s+(-?\d{4})$/i) || text.match(/^(-?\d{4})\s+Q([1-4])$/i);
126
+ if (match) {
127
+ const year = match[1].length === 4 ? Number(match[1]) : Number(match[2]);
128
+ const quarter = match[1].length === 4 ? Number(match[2]) : Number(match[1]);
129
+ return new Date(Date.UTC(year, (quarter - 1) * 3, 1));
130
+ }
131
+
132
+ const season = text.match(/^(spring|summer|fall|autumn|winter)\s+(-?\d{4})$/i);
133
+ if (season) {
134
+ const monthBySeason = { spring: 2, summer: 5, fall: 8, autumn: 8, winter: 11 };
135
+ return new Date(Date.UTC(Number(season[2]), monthBySeason[season[1].toLowerCase()], 1));
136
+ }
137
+
138
+ const parsed = new Date(text);
139
+ if (Number.isFinite(parsed.getTime())) {
140
+ return new Date(Date.UTC(parsed.getUTCFullYear(), parsed.getUTCMonth(), parsed.getUTCDate()));
141
+ }
142
+ return null;
143
+ }
144
+
145
+ function formatNumber(value) {
146
+ const numericValue = Number(value);
147
+ if (!Number.isFinite(numericValue)) return String(value ?? "");
148
+ const absValue = Math.abs(numericValue);
149
+ if (absValue >= 1000000000) return `${d3.format(".3~g")(numericValue / 1000000000)}B`;
150
+ if (absValue >= 1000000) return `${d3.format(".3~g")(numericValue / 1000000)}M`;
151
+ if (absValue >= 1000) return `${d3.format(".3~g")(numericValue / 1000)}k`;
152
+ return d3.format(",.4~g")(numericValue);
153
+ }
154
+
155
+ function formatWithUnit(value, unit) {
156
+ const formatted = formatNumber(value);
157
+ if (!unit) return formatted;
158
+ if (unit === "$") return `$${formatted}`;
159
+ if (unit === "%") return `${formatted}%`;
160
+ return `${formatted} ${unit}`;
161
+ }
162
+
163
+ function wrapText(text, maxChars, maxLines) {
164
+ const words = String(text || "").split(/\s+/).filter(Boolean);
165
+ if (!words.length) return [String(text || "")];
166
+ const lines = [];
167
+ let current = "";
168
+ words.forEach(word => {
169
+ if (lines.length >= maxLines) return;
170
+ const next = current ? `${current} ${word}` : word;
171
+ if (next.length <= maxChars) {
172
+ current = next;
173
+ return;
174
+ }
175
+ if (current) lines.push(current);
176
+ if (lines.length >= maxLines) return;
177
+ current = word;
178
+ });
179
+ if (current && lines.length < maxLines) lines.push(current);
180
+ return lines.length ? lines : [String(text || "")];
181
+ }
182
+
183
+ function selectEvenTicks(values, maxTicks) {
184
+ if (values.length <= maxTicks) return values;
185
+ const selected = [];
186
+ for (let i = 0; i < maxTicks; i += 1) {
187
+ const index = Math.round(i * (values.length - 1) / (maxTicks - 1));
188
+ const value = values[index];
189
+ if (!selected.includes(value)) selected.push(value);
190
+ }
191
+ return selected;
192
+ }
193
+
194
+ function splitTitle(title, maxChars) {
195
+ const clean = cleanText(title);
196
+ const words = clean.split(/\s+/).filter(Boolean);
197
+ if (!words.length) return ["", ""];
198
+ if (clean.length <= maxChars || words.length === 1) return [clean, ""];
199
+
200
+ const prepositions = new Set(["per", "in", "with", "of", "by", "from", "across", "for", "after", "before"]);
201
+ let semanticSplit = -1;
202
+ let bestDistance = Infinity;
203
+ const midpoint = clean.length / 2;
204
+ for (let i = 2; i < words.length - 1; i += 1) {
205
+ const first = words.slice(0, i).join(" ");
206
+ const secondFirst = words[i].toLowerCase();
207
+ const second = words.slice(i).join(" ");
208
+ if (!prepositions.has(secondFirst) || second.length < 9 || first.length > maxChars + 8) continue;
209
+ const distance = Math.abs(first.length - midpoint);
210
+ if (distance < bestDistance) {
211
+ semanticSplit = i;
212
+ bestDistance = distance;
213
+ }
214
+ }
215
+ if (semanticSplit > 0) return [words.slice(0, semanticSplit).join(" "), words.slice(semanticSplit).join(" ")];
216
+
217
+ let best = null;
218
+ let bestScore = Infinity;
219
+ for (let i = 1; i < words.length; i += 1) {
220
+ const first = words.slice(0, i).join(" ");
221
+ const second = words.slice(i).join(" ");
222
+ const longest = Math.max(first.length, second.length);
223
+ const overrun = Math.max(0, longest - maxChars);
224
+ const balance = Math.abs(first.length - second.length);
225
+ const tinySecondLinePenalty = second.length < 9 ? 24 : 0;
226
+ const score = balance + overrun * 5 + tinySecondLinePenalty;
227
+ if (score < bestScore) {
228
+ best = [first, second];
229
+ bestScore = score;
230
+ }
231
+ }
232
+ return best || [clean, ""];
233
+ }
234
+
235
+ function groupInitial(group) {
236
+ const words = String(group || "").trim().split(/\s+/).filter(Boolean);
237
+ if (!words.length) return "";
238
+ if (words.length === 1) return words[0].slice(0, 1).toUpperCase();
239
+ return words.slice(0, 2).map(word => word.slice(0, 1).toUpperCase()).join("");
240
+ }
241
+
242
+ function fitFont(text, desiredSize, maxWidth, minSize) {
243
+ const estimated = String(text || "").length * desiredSize * 0.56;
244
+ if (estimated <= maxWidth) return desiredSize;
245
+ return Math.max(minSize, Math.floor(desiredSize * maxWidth / Math.max(1, estimated)));
246
+ }
247
+
248
+ function semanticKey(group) {
249
+ const text = String(group || "").toLowerCase();
250
+ if (/\bcar\b|auto|vehicle|driving/.test(text)) return "car";
251
+ if (/transit|bus|rail|train|metro/.test(text)) return "transit";
252
+ if (/bicycle|bike|cycling/.test(text)) return "bicycle";
253
+ if (/walk|foot|pedestrian/.test(text)) return "walking";
254
+ return "";
255
+ }
256
+
257
+ function semanticColor(group, index) {
258
+ const key = semanticKey(group);
259
+ const semantic = {
260
+ car: "#1086c8",
261
+ transit: "#d99a00",
262
+ bicycle: "#7742c2",
263
+ walking: "#3c9a43"
264
+ };
265
+ if (semantic[key]) return semantic[key];
266
+ if (colorField[group]) return colorField[group];
267
+ return linePalette[index % linePalette.length];
268
+ }
269
+
270
+ function drawTransportIcon(parent, kind, color, size, initial) {
271
+ const s = size / 40;
272
+ const g = parent.append("g")
273
+ .attr("class", `reference-icon-mark reference-icon-${kind || "generic"}`)
274
+ .attr("transform", `scale(${s})`);
275
+
276
+ if (kind === "car") {
277
+ g.append("path")
278
+ .attr("d", "M -14 5 L -10 -5 C -9 -8 -6 -10 -2 -10 L 6 -10 C 9 -10 12 -8 13 -5 L 16 5 Z")
279
+ .attr("fill", "none").attr("stroke", color).attr("stroke-width", 3).attr("stroke-linejoin", "round");
280
+ g.append("line").attr("x1", -7).attr("x2", 9).attr("y1", -2).attr("y2", -2).attr("stroke", color).attr("stroke-width", 3).attr("stroke-linecap", "round");
281
+ [-9, 10].forEach(x => g.append("circle").attr("cx", x).attr("cy", 7).attr("r", 4).attr("fill", "none").attr("stroke", color).attr("stroke-width", 3));
282
+ return;
283
+ }
284
+ if (kind === "transit") {
285
+ g.append("rect").attr("x", -12).attr("y", -13).attr("width", 24).attr("height", 27).attr("rx", 4)
286
+ .attr("fill", "none").attr("stroke", color).attr("stroke-width", 3);
287
+ g.append("line").attr("x1", -7).attr("x2", 7).attr("y1", -3).attr("y2", -3).attr("stroke", color).attr("stroke-width", 3).attr("stroke-linecap", "round");
288
+ g.append("line").attr("x1", -8).attr("x2", 8).attr("y1", 12).attr("y2", 12).attr("stroke", color).attr("stroke-width", 3).attr("stroke-linecap", "round");
289
+ [-7, 7].forEach(x => g.append("circle").attr("cx", x).attr("cy", 6).attr("r", 2.3).attr("fill", color));
290
+ return;
291
+ }
292
+ if (kind === "bicycle") {
293
+ [-10, 10].forEach(x => g.append("circle").attr("cx", x).attr("cy", 8).attr("r", 7).attr("fill", "none").attr("stroke", color).attr("stroke-width", 3));
294
+ g.append("path").attr("d", "M -10 8 L -2 -4 L 5 8 L -10 8 M -2 -4 L 6 -4 L 10 8")
295
+ .attr("fill", "none").attr("stroke", color).attr("stroke-width", 3).attr("stroke-linecap", "round").attr("stroke-linejoin", "round");
296
+ g.append("line").attr("x1", -2).attr("x2", -4).attr("y1", -4).attr("y2", -10).attr("stroke", color).attr("stroke-width", 3).attr("stroke-linecap", "round");
297
+ return;
298
+ }
299
+ if (kind === "walking") {
300
+ g.append("circle").attr("cx", 0).attr("cy", -13).attr("r", 3.2).attr("fill", color);
301
+ g.append("path").attr("d", "M 0 -8 L -3 1 L -8 9 M -2 0 L 7 2 M 1 -5 L 6 -11 M 1 0 L 7 10")
302
+ .attr("fill", "none").attr("stroke", color).attr("stroke-width", 3.4).attr("stroke-linecap", "round").attr("stroke-linejoin", "round");
303
+ return;
304
+ }
305
+
306
+ g.append("polyline")
307
+ .attr("points", "-13,8 -6,2 -1,5 5,-5 13,-10")
308
+ .attr("fill", "none")
309
+ .attr("stroke", color)
310
+ .attr("stroke-width", 3.2)
311
+ .attr("stroke-linecap", "round")
312
+ .attr("stroke-linejoin", "round");
313
+ g.append("polyline")
314
+ .attr("points", "6,-10 13,-10 13,-3")
315
+ .attr("fill", "none")
316
+ .attr("stroke", color)
317
+ .attr("stroke-width", 3.2)
318
+ .attr("stroke-linecap", "round")
319
+ .attr("stroke-linejoin", "round");
320
+ if (initial) {
321
+ g.append("text")
322
+ .attr("x", 0)
323
+ .attr("y", 18)
324
+ .attr("text-anchor", "middle")
325
+ .style("font-family", labelFamily)
326
+ .style("font-size", "9px")
327
+ .style("font-weight", 800)
328
+ .style("fill", color)
329
+ .text(initial.slice(0, 2));
330
+ }
331
+ }
332
+
333
+ function drawIconSlot(parent, group, color, x, y, radius) {
334
+ const kind = semanticKey(group);
335
+ const slot = parent.append("g")
336
+ .attr("class", "reference-card-icon-slot")
337
+ .attr("data-group", group)
338
+ .attr("data-icon-kind", kind || "generic")
339
+ .attr("transform", `translate(${x}, ${y})`);
340
+ slot.append("circle").attr("r", radius).attr("fill", color);
341
+ slot.append("circle").attr("r", radius - 3).attr("fill", "none").attr("stroke", "#ffffff").attr("stroke-opacity", 0.35).attr("stroke-width", 1.2);
342
+ const mark = slot.append("g").attr("transform", "translate(0, 0)");
343
+ if (kind) {
344
+ drawTransportIcon(mark, kind, "#ffffff", radius * 1.45, "");
345
+ } else {
346
+ mark.append("text")
347
+ .attr("class", "reference-card-icon-text")
348
+ .attr("x", 0)
349
+ .attr("y", 7)
350
+ .attr("text-anchor", "middle")
351
+ .style("font-family", labelFamily)
352
+ .style("font-size", `${Math.max(13, Math.min(18, radius * 0.78))}px`)
353
+ .style("font-weight", 800)
354
+ .style("fill", "#ffffff")
355
+ .text(groupInitial(group));
356
+ }
357
+ return slot;
358
+ }
359
+
360
+ function drawHeroDivider(svg, centerX, y) {
361
+ const hero = svg.append("g")
362
+ .attr("class", "reference-hero-icon-slot")
363
+ .attr("data-slot", "title-divider")
364
+ .attr("transform", `translate(${centerX}, ${y})`);
365
+ hero.append("line")
366
+ .attr("class", "reference-hero-rule")
367
+ .attr("x1", -122)
368
+ .attr("x2", -30)
369
+ .attr("y1", 0)
370
+ .attr("y2", 0)
371
+ .attr("stroke", accentBlue)
372
+ .attr("stroke-width", 2)
373
+ .attr("stroke-opacity", 0.72);
374
+ hero.append("line")
375
+ .attr("class", "reference-hero-rule")
376
+ .attr("x1", 30)
377
+ .attr("x2", 122)
378
+ .attr("y1", 0)
379
+ .attr("y2", 0)
380
+ .attr("stroke", accentBlue)
381
+ .attr("stroke-width", 2)
382
+ .attr("stroke-opacity", 0.72);
383
+ drawTransportIcon(hero, "car", accentBlue, 34, "");
384
+ }
385
+
386
+ function drawTrendArrow(parent, delta, color, x, y, size) {
387
+ const g = parent.append("g")
388
+ .attr("class", "reference-trend-arrow-icon-slot")
389
+ .attr("transform", `translate(${x}, ${y})`);
390
+ g.append("circle").attr("r", size / 2).attr("fill", color);
391
+ const path = delta < 0 ? "M -7 -5 L 0 2 L 6 -4 M 0 2 L 0 -7" : "M -7 5 L 0 -2 L 6 4 M 0 -2 L 0 7";
392
+ g.append("path")
393
+ .attr("d", path)
394
+ .attr("transform", "rotate(45)")
395
+ .attr("fill", "none")
396
+ .attr("stroke", "#ffffff")
397
+ .attr("stroke-width", 3)
398
+ .attr("stroke-linecap", "round")
399
+ .attr("stroke-linejoin", "round");
400
+ }
401
+
402
+ function drawFooterDecoration(parent, x, y, widthValue, heightValue, color) {
403
+ const decor = parent.append("g")
404
+ .attr("class", "reference-footer-decorative-image-slot")
405
+ .attr("data-slot", "footer-city-motion")
406
+ .attr("transform", `translate(${x}, ${y})`);
407
+ decor.append("line")
408
+ .attr("x1", 0)
409
+ .attr("x2", widthValue)
410
+ .attr("y1", heightValue - 14)
411
+ .attr("y2", heightValue - 14)
412
+ .attr("stroke", rgba(color, 0.32))
413
+ .attr("stroke-width", 2);
414
+ const buildings = [
415
+ [8, 25, 20, 48], [34, 10, 16, 63], [58, 31, 18, 42],
416
+ [84, 18, 22, 55], [114, 29, 16, 44], [136, 8, 24, 65]
417
+ ];
418
+ buildings.forEach(([bx, by, bw, bh], i) => {
419
+ decor.append("rect")
420
+ .attr("x", bx)
421
+ .attr("y", by)
422
+ .attr("width", bw)
423
+ .attr("height", bh)
424
+ .attr("rx", 2)
425
+ .attr("fill", rgba(color, i % 2 ? 0.12 : 0.18));
426
+ [0, 1, 2].forEach(row => {
427
+ decor.append("line")
428
+ .attr("x1", bx + 4)
429
+ .attr("x2", bx + bw - 4)
430
+ .attr("y1", by + 8 + row * 11)
431
+ .attr("y2", by + 8 + row * 11)
432
+ .attr("stroke", rgba(color, 0.18))
433
+ .attr("stroke-width", 1);
434
+ });
435
+ });
436
+ const car = decor.append("g")
437
+ .attr("class", "reference-footer-car-slot")
438
+ .attr("transform", `translate(${widthValue - 78}, ${heightValue - 23})`);
439
+ car.append("path")
440
+ .attr("d", "M 0 6 L 8 -8 C 10 -12 15 -14 23 -14 L 47 -14 C 55 -14 61 -10 64 -3 L 70 6 Z")
441
+ .attr("fill", color);
442
+ car.append("rect").attr("x", 6).attr("y", 0).attr("width", 62).attr("height", 14).attr("rx", 5).attr("fill", color);
443
+ [-1, 58].forEach(cx => {
444
+ car.append("circle").attr("cx", cx + 12).attr("cy", 15).attr("r", 8).attr("fill", "#243044");
445
+ car.append("circle").attr("cx", cx + 12).attr("cy", 15).attr("r", 4).attr("fill", "#d9edfa");
446
+ });
447
+ car.append("path").attr("d", "M 24 -10 L 36 -10 L 42 0 L 17 0 Z").attr("fill", "#bfe6fb");
448
+ [0, 1, 2].forEach(i => {
449
+ decor.append("line")
450
+ .attr("x1", widthValue - 150 - i * 18)
451
+ .attr("x2", widthValue - 98 - i * 8)
452
+ .attr("y1", heightValue - 34 + i * 10)
453
+ .attr("y2", heightValue - 34 + i * 10)
454
+ .attr("stroke", rgba(color, 0.35))
455
+ .attr("stroke-width", 2)
456
+ .attr("stroke-linecap", "round");
457
+ });
458
+ }
459
+
460
+ function percentChange(firstValue, lastValue) {
461
+ if (!Number.isFinite(firstValue) || !Number.isFinite(lastValue)) return null;
462
+ if (firstValue === 0) return null;
463
+ return (lastValue - firstValue) / Math.abs(firstValue) * 100;
464
+ }
465
+
466
+ const parsedRows = chartData.map((d, index) => {
467
+ const date = parseTemporalDate(d[xField]);
468
+ const value = Number(d[yField]);
469
+ const group = String(d[groupField] ?? "");
470
+ return {
471
+ ...d,
472
+ __index: index,
473
+ __date: date,
474
+ __time: date ? date.getTime() : NaN,
475
+ __xLabel: String(d[xField] ?? ""),
476
+ __value: value,
477
+ __group: group
478
+ };
479
+ }).filter(d => d.__date && Number.isFinite(d.__time) && Number.isFinite(d.__value) && d.__group !== "");
480
+
481
+ if (!parsedRows.length) return;
482
+
483
+ const groups = [];
484
+ parsedRows.forEach(d => {
485
+ if (!groups.includes(d.__group)) groups.push(d.__group);
486
+ });
487
+ if (!groups.length) return;
488
+
489
+ const xLabelByTime = new Map();
490
+ parsedRows.forEach(d => {
491
+ if (!xLabelByTime.has(d.__time)) xLabelByTime.set(d.__time, d.__xLabel);
492
+ });
493
+
494
+ const groupSeries = groups.map(group => {
495
+ const byTime = new Map();
496
+ parsedRows.filter(d => d.__group === group).forEach(d => {
497
+ if (!byTime.has(d.__time)) {
498
+ byTime.set(d.__time, { date: d.__date, time: d.__time, label: d.__xLabel, valueSum: 0, count: 0 });
499
+ }
500
+ const entry = byTime.get(d.__time);
501
+ entry.valueSum += d.__value;
502
+ entry.count += 1;
503
+ });
504
+ const values = Array.from(byTime.values())
505
+ .map(d => ({ date: d.date, time: d.time, label: d.label, value: d.valueSum / d.count }))
506
+ .sort((a, b) => a.time - b.time);
507
+ return { group, values };
508
+ }).filter(d => d.values.length);
509
+
510
+ if (!groupSeries.length) return;
511
+
512
+ const xValues = Array.from(new Set(parsedRows.map(d => d.__time)))
513
+ .sort((a, b) => a - b)
514
+ .map(time => new Date(time));
515
+ const allValues = groupSeries.flatMap(series => series.values.map(d => d.value));
516
+ let yMin = d3.min(allValues);
517
+ let yMax = d3.max(allValues);
518
+ if (!Number.isFinite(yMin) || !Number.isFinite(yMax)) return;
519
+ const rawYMin = yMin;
520
+ const rawYMax = yMax;
521
+ const scaleText = `${yField} ${yColumn.description || ""} ${yUnit}`.toLowerCase();
522
+ const boundedScore = rawYMin >= 0 && rawYMax <= 10 && /(rating|score|index|scale|rate)/.test(scaleText);
523
+ if (boundedScore) {
524
+ yMin = Math.max(0, Math.floor(rawYMin));
525
+ yMax = Math.min(100, Math.ceil(rawYMax));
526
+ if (rawYMax <= 10) yMax = Math.min(10, yMax);
527
+ if (yMax - yMin < 4) {
528
+ const mid = (yMin + yMax) / 2;
529
+ yMin = Math.max(0, Math.floor(mid - 2));
530
+ yMax = Math.ceil(mid + 2);
531
+ }
532
+ } else if (yMin === yMax) {
533
+ const pad = Math.max(1, Math.abs(yMin) * 0.1);
534
+ yMin -= pad;
535
+ yMax += pad;
536
+ } else {
537
+ const pad = (yMax - yMin) * 0.1;
538
+ yMin -= pad;
539
+ yMax += pad;
540
+ if (rawYMin >= 0) yMin = Math.max(0, yMin);
541
+ if (rawYMax <= 0) yMax = Math.min(0, yMax);
542
+ if (yMin > 0 && (yMax - yMin) / Math.max(1, Math.abs(yMax)) > 0.55) yMin = 0;
543
+ }
544
+
545
+ const cols = groupSeries.length <= 1 ? 1 : 2;
546
+ const rows = Math.ceil(groupSeries.length / cols);
547
+ const sidePad = 44;
548
+ const gapX = variables.has_spacing === false ? 20 : 28;
549
+ const gapY = variables.has_spacing === false ? 24 : 30;
550
+ const cardWidth = (width - sidePad * 2 - gapX * (cols - 1)) / cols;
551
+ const cardHeight = Math.max(340, Math.min(380, cardWidth * 1.02));
552
+ const titleText = cleanText(jsonData.titles?.main_title || jsonData.metadata?.title || jsonData.title || `${readableName(yField)} by ${readableName(groupField)}`);
553
+ const subtitleText = cleanText(jsonData.titles?.sub_title || jsonData.metadata?.description || jsonData.metadata?.main_insight || yColumn.description || `${readableName(yField)} across ${readableName(groupField)} over time.`);
554
+ const titleParts = splitTitle(titleText || `${readableName(yField)} by ${readableName(groupField)}`, Math.max(22, Math.floor(width / 21)));
555
+ const titleTopFont = fitFont(titleParts[0], Math.max(24, Math.min(34, width * 0.044)), width - 120, 19);
556
+ const titleMainFont = fitFont(titleParts[1] || titleParts[0], Math.max(38, Math.min(58, width * 0.074)), width - 110, 28);
557
+ const subtitleLines = wrapText(subtitleText, Math.max(38, Math.floor((width - 170) / 10.6)), 3);
558
+ const heroTop = 52;
559
+ const titleTopY = heroTop + 28;
560
+ const titleMainY = titleTopY + titleTopFont + 42;
561
+ const dividerY = titleMainY + 36;
562
+ const subtitleY = dividerY + 50;
563
+ const cardTop = subtitleY + Math.max(56, subtitleLines.length * 28 + 40);
564
+ const footerH = 116;
565
+ const footerY = cardTop + rows * cardHeight + Math.max(0, rows - 1) * gapY + 34;
566
+ const height = Math.ceil(footerY + footerH + 34);
567
+ const plot = { left: 52, right: 26, top: 120, bottom: 88 };
568
+ const innerWidth = Math.max(160, cardWidth - plot.left - plot.right);
569
+ const innerHeight = Math.max(130, cardHeight - plot.top - plot.bottom);
570
+ const tickCount = innerWidth < 210 ? 3 : 4;
571
+ const xTicks = selectEvenTicks(xValues, tickCount);
572
+
573
+ const xScale = d3.scaleUtc().domain(d3.extent(xValues)).range([0, innerWidth]);
574
+ const yScale = d3.scaleLinear().domain([yMin, yMax]).range([innerHeight, 0]);
575
+ const yTicks = yScale.ticks(innerHeight < 145 ? 4 : 5);
576
+
577
+ const colorScale = d3.scaleOrdinal()
578
+ .domain(groups)
579
+ .range(groups.map((group, index) => semanticColor(group, index)));
580
+
581
+ function formatXTick(date) {
582
+ const label = xLabelByTime.get(date.getTime());
583
+ if (label) return label;
584
+ return d3.utcFormat("%Y")(date);
585
+ }
586
+
587
+ const scaleNote = rawYMin >= 0
588
+ ? "All panels use a shared y-scale starting at zero where the source values allow."
589
+ : "All panels use a shared y-scale from the source values.";
590
+
591
+ const line = d3.line()
592
+ .defined(d => Number.isFinite(d.value))
593
+ .x(d => xScale(d.date))
594
+ .y(d => yScale(d.value))
595
+ .curve(d3.curveMonotoneX);
596
+
597
+ const strongestSeries = groupSeries.reduce((best, series) => {
598
+ const first = series.values[0];
599
+ const last = series.values[series.values.length - 1];
600
+ const delta = first && last ? Math.abs(last.value - first.value) : 0;
601
+ if (!best || delta > best.delta) return { series, delta };
602
+ return best;
603
+ }, null)?.series || groupSeries[0];
604
+ const strongestFirst = strongestSeries.values[0];
605
+ const strongestLast = strongestSeries.values[strongestSeries.values.length - 1];
606
+ const strongestDelta = strongestFirst && strongestLast ? strongestLast.value - strongestFirst.value : 0;
607
+ const strongestDirection = strongestDelta < 0 ? "declined the most" : strongestDelta > 0 ? "changed upward the most" : "remained the most stable";
608
+ const takeaway = `${readableName(strongestSeries.group)} ${strongestDirection} from ${strongestFirst?.label || ""} to ${strongestLast?.label || ""}.`;
609
+
610
+ const svg = d3.select(containerSelector)
611
+ .append("svg")
612
+ .attr("width", width)
613
+ .attr("height", height)
614
+ .attr("viewBox", `0 0 ${width} ${height}`)
615
+ .attr("style", "max-width: 100%; height: auto;")
616
+ .attr("xmlns", "http://www.w3.org/2000/svg")
617
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
618
+
619
+ const defs = svg.append("defs");
620
+ const shadow = defs.append("filter")
621
+ .attr("id", "small_multiples_line_01_card_shadow")
622
+ .attr("x", "-15%")
623
+ .attr("y", "-13%")
624
+ .attr("width", "130%")
625
+ .attr("height", "132%");
626
+ shadow.append("feDropShadow")
627
+ .attr("dx", 0)
628
+ .attr("dy", 5)
629
+ .attr("stdDeviation", 6)
630
+ .attr("flood-color", "#15345a")
631
+ .attr("flood-opacity", 0.12);
632
+
633
+ const cardGradient = defs.append("linearGradient")
634
+ .attr("id", "small_multiples_line_01_card_soft_gradient")
635
+ .attr("x1", "0%").attr("y1", "0%").attr("x2", "100%").attr("y2", "100%");
636
+ cardGradient.append("stop").attr("offset", "0%").attr("stop-color", "#ffffff");
637
+ cardGradient.append("stop").attr("offset", "100%").attr("stop-color", "#f8fbff");
638
+
639
+ svg.append("rect")
640
+ .attr("class", "chart-canvas reference-layout-canvas")
641
+ .attr("x", 0)
642
+ .attr("y", 0)
643
+ .attr("width", width)
644
+ .attr("height", height)
645
+ .attr("rx", 16)
646
+ .attr("ry", 16)
647
+ .attr("fill", canvasColor);
648
+
649
+ svg.append("text")
650
+ .attr("class", "reference-title-kicker")
651
+ .attr("x", width / 2)
652
+ .attr("y", titleTopY)
653
+ .attr("text-anchor", "middle")
654
+ .style("font-family", titleFamily)
655
+ .style("font-size", `${titleTopFont}px`)
656
+ .style("font-weight", 800)
657
+ .style("letter-spacing", "0px")
658
+ .style("fill", navy)
659
+ .text(titleParts[0]);
660
+
661
+ if (titleParts[1]) {
662
+ svg.append("text")
663
+ .attr("class", "reference-title-main")
664
+ .attr("x", width / 2)
665
+ .attr("y", titleMainY)
666
+ .attr("text-anchor", "middle")
667
+ .style("font-family", titleFamily)
668
+ .style("font-size", `${titleMainFont}px`)
669
+ .style("font-weight", titleWeight)
670
+ .style("letter-spacing", "0px")
671
+ .style("fill", accentBlue)
672
+ .text(titleParts[1]);
673
+ }
674
+
675
+ drawHeroDivider(svg, width / 2, dividerY);
676
+
677
+ const subtitle = svg.append("text")
678
+ .attr("class", "reference-subtitle")
679
+ .attr("x", width / 2)
680
+ .attr("y", subtitleY)
681
+ .attr("text-anchor", "middle")
682
+ .style("font-family", labelFamily)
683
+ .style("font-size", `${Math.max(17, Math.min(23, baseLabelSize * 1.7))}px`)
684
+ .style("font-weight", 400)
685
+ .style("font-style", "italic")
686
+ .style("fill", mutedText);
687
+ subtitleLines.forEach((lineText, lineIndex) => {
688
+ subtitle.append("tspan")
689
+ .attr("x", width / 2)
690
+ .attr("dy", lineIndex === 0 ? 0 : 28)
691
+ .text(lineText);
692
+ });
693
+
694
+ groupSeries.forEach((series, index) => {
695
+ const row = Math.floor(index / cols);
696
+ const col = index % cols;
697
+ const panelX = cols === 1
698
+ ? (width - cardWidth) / 2
699
+ : sidePad + col * (cardWidth + gapX);
700
+ const panelY = cardTop + row * (cardHeight + gapY);
701
+ const seriesColor = colorScale(series.group);
702
+ const headerFill = headerPalette[index % headerPalette.length];
703
+ const panel = svg.append("g")
704
+ .attr("class", "subplot reference-card")
705
+ .attr("data-group", series.group)
706
+ .attr("data-row", row)
707
+ .attr("data-col", col)
708
+ .attr("transform", `translate(${panelX}, ${panelY})`);
709
+
710
+ panel.append("rect")
711
+ .attr("class", "subplot-background subplot-card reference-card-shell")
712
+ .attr("width", cardWidth)
713
+ .attr("height", cardHeight)
714
+ .attr("rx", 18)
715
+ .attr("ry", 18)
716
+ .attr("fill", "url(#small_multiples_line_01_card_soft_gradient)")
717
+ .attr("stroke", rgba(seriesColor, 0.2))
718
+ .attr("stroke-width", 1.2)
719
+ .style("filter", "url(#small_multiples_line_01_card_shadow)");
720
+
721
+ panel.append("rect")
722
+ .attr("class", "reference-card-color-wash")
723
+ .attr("x", 1)
724
+ .attr("y", 1)
725
+ .attr("width", cardWidth - 2)
726
+ .attr("height", cardHeight - 2)
727
+ .attr("rx", 17)
728
+ .attr("ry", 17)
729
+ .attr("fill", headerFill)
730
+ .attr("opacity", 0.48);
731
+
732
+ drawIconSlot(panel, series.group, seriesColor, 46, 48, 28);
733
+
734
+ const titleLines = wrapText(readableName(series.group), Math.max(11, Math.floor((cardWidth - 110) / 8.2)), 2);
735
+ const title = panel.append("text")
736
+ .attr("class", "label group-label reference-card-title")
737
+ .attr("data-group", series.group)
738
+ .attr("x", 88)
739
+ .attr("y", 42)
740
+ .attr("text-anchor", "start")
741
+ .style("font-family", titleFamily)
742
+ .style("font-size", `${Math.max(18, Math.min(26, baseLabelSize * 2.0))}px`)
743
+ .style("font-weight", 800)
744
+ .style("fill", navy);
745
+ titleLines.forEach((lineText, lineIndex) => {
746
+ title.append("tspan")
747
+ .attr("x", 88)
748
+ .attr("dy", lineIndex === 0 ? 0 : 25)
749
+ .text(lineText);
750
+ });
751
+
752
+ panel.append("line")
753
+ .attr("class", "reference-series-key")
754
+ .attr("x1", 88)
755
+ .attr("x2", 122)
756
+ .attr("y1", 78)
757
+ .attr("y2", 78)
758
+ .attr("stroke", seriesColor)
759
+ .attr("stroke-width", 3)
760
+ .attr("stroke-linecap", "round");
761
+ const unitLabel = `${readableName(yField)}${yUnit ? ` (${yUnit})` : ""}`;
762
+ const unitText = panel.append("text")
763
+ .attr("class", "reference-unit-label")
764
+ .attr("x", 132)
765
+ .attr("y", 82)
766
+ .style("font-family", labelFamily)
767
+ .style("font-size", `${Math.max(10, Math.min(13, baseLabelSize * 1.05))}px`)
768
+ .style("font-weight", 400)
769
+ .style("fill", mutedText);
770
+ wrapText(unitLabel, Math.max(20, Math.floor((cardWidth - 142) / 6.1)), 2).forEach((lineText, lineIndex) => {
771
+ unitText.append("tspan")
772
+ .attr("x", 132)
773
+ .attr("dy", lineIndex === 0 ? 0 : 14)
774
+ .text(lineText);
775
+ });
776
+
777
+ const chart = panel.append("g")
778
+ .attr("class", "panel-plot reference-card-plot")
779
+ .attr("transform", `translate(${plot.left}, ${plot.top})`);
780
+
781
+ yTicks.forEach(tick => {
782
+ chart.append("line")
783
+ .attr("class", "gridline")
784
+ .attr("x1", 0)
785
+ .attr("x2", innerWidth)
786
+ .attr("y1", yScale(tick))
787
+ .attr("y2", yScale(tick))
788
+ .attr("stroke", "#d7dde7")
789
+ .attr("stroke-dasharray", "5 4")
790
+ .attr("stroke-width", 1);
791
+ chart.append("text")
792
+ .attr("class", "axis-tick y-axis-tick")
793
+ .attr("x", -9)
794
+ .attr("y", yScale(tick))
795
+ .attr("text-anchor", "end")
796
+ .attr("dominant-baseline", "middle")
797
+ .style("font-family", labelFamily)
798
+ .style("font-size", `${Math.max(10, Math.min(baseLabelSize, 12))}px`)
799
+ .style("font-weight", labelWeight)
800
+ .style("fill", axisText)
801
+ .text(formatNumber(tick));
802
+ });
803
+
804
+ chart.append("line")
805
+ .attr("class", "axis-line x-axis-line")
806
+ .attr("x1", 0)
807
+ .attr("x2", innerWidth)
808
+ .attr("y1", innerHeight)
809
+ .attr("y2", innerHeight)
810
+ .attr("stroke", "#23324c")
811
+ .attr("stroke-width", 1.2);
812
+
813
+ xTicks.forEach(tick => {
814
+ chart.append("line")
815
+ .attr("class", "axis-tick-line")
816
+ .attr("x1", xScale(tick))
817
+ .attr("x2", xScale(tick))
818
+ .attr("y1", innerHeight)
819
+ .attr("y2", innerHeight + 5)
820
+ .attr("stroke", "#23324c")
821
+ .attr("stroke-width", 1);
822
+ chart.append("text")
823
+ .attr("class", "axis-tick x-axis-tick")
824
+ .attr("x", xScale(tick))
825
+ .attr("y", innerHeight + 24)
826
+ .attr("text-anchor", "middle")
827
+ .style("font-family", labelFamily)
828
+ .style("font-size", `${Math.max(10, Math.min(baseLabelSize, 12))}px`)
829
+ .style("font-weight", labelWeight)
830
+ .style("fill", axisText)
831
+ .text(formatXTick(tick));
832
+ });
833
+
834
+ chart.append("path")
835
+ .datum(series.values)
836
+ .attr("class", "line mark")
837
+ .attr("data-group", series.group)
838
+ .attr("data-count", series.values.length)
839
+ .attr("data-latest-value", series.values[series.values.length - 1]?.value ?? "")
840
+ .attr("fill", "none")
841
+ .attr("stroke", seriesColor)
842
+ .attr("stroke-width", variables.has_stroke === false ? 3.0 : 3.8)
843
+ .attr("stroke-linecap", "round")
844
+ .attr("stroke-linejoin", "round")
845
+ .attr("d", line);
846
+
847
+ chart.selectAll(".point")
848
+ .data(series.values)
849
+ .enter()
850
+ .append("circle")
851
+ .attr("class", "point mark")
852
+ .attr("data-group", series.group)
853
+ .attr("data-time", d => d.time)
854
+ .attr("data-label", d => d.label)
855
+ .attr("data-value", d => d.value)
856
+ .attr("cx", d => xScale(d.date))
857
+ .attr("cy", d => yScale(d.value))
858
+ .attr("r", 4.6)
859
+ .attr("fill", cardFill)
860
+ .attr("stroke", seriesColor)
861
+ .attr("stroke-width", 2.4);
862
+
863
+ const firstPoint = series.values[0];
864
+ const lastPoint = series.values[series.values.length - 1];
865
+ const delta = lastPoint && firstPoint ? lastPoint.value - firstPoint.value : 0;
866
+ const latestText = lastPoint ? formatWithUnit(lastPoint.value, yUnit) : "";
867
+ if (lastPoint) {
868
+ const latestFontSize = Math.max(12, Math.min(baseValueSize * 1.25, 16));
869
+ const pillWidth = Math.max(54, latestText.length * latestFontSize * 0.62 + 18);
870
+ const labelX = Math.max(pillWidth / 2, Math.min(innerWidth - pillWidth / 2, xScale(lastPoint.date)));
871
+ const labelY = Math.max(16, Math.min(innerHeight - 20, yScale(lastPoint.value) - 18));
872
+ chart.append("rect")
873
+ .attr("class", "latest-value-pill reference-latest-value-pill")
874
+ .attr("data-group", series.group)
875
+ .attr("x", labelX - pillWidth / 2)
876
+ .attr("y", labelY - latestFontSize)
877
+ .attr("width", pillWidth)
878
+ .attr("height", latestFontSize + 10)
879
+ .attr("rx", 8)
880
+ .attr("ry", 8)
881
+ .attr("fill", seriesColor);
882
+ chart.append("path")
883
+ .attr("class", "reference-latest-value-pointer")
884
+ .attr("d", `M ${labelX - 5},${labelY + 9} L ${labelX + 5},${labelY + 9} L ${labelX},${labelY + 17} Z`)
885
+ .attr("fill", seriesColor);
886
+ chart.append("text")
887
+ .attr("class", "value latest-value reference-latest-value")
888
+ .attr("data-group", series.group)
889
+ .attr("data-time", lastPoint.time)
890
+ .attr("data-label", lastPoint.label)
891
+ .attr("data-value", lastPoint.value)
892
+ .attr("x", labelX)
893
+ .attr("y", labelY)
894
+ .attr("text-anchor", "middle")
895
+ .style("font-family", valueFamily)
896
+ .style("font-size", `${latestFontSize}px`)
897
+ .style("font-weight", 800)
898
+ .style("fill", "#ffffff")
899
+ .text(latestText);
900
+ }
901
+
902
+ const percent = firstPoint && lastPoint ? percentChange(firstPoint.value, lastPoint.value) : null;
903
+ const trendText = percent === null
904
+ ? `${delta >= 0 ? "+" : "-"}${formatNumber(Math.abs(delta))}`
905
+ : `${percent >= 0 ? "+" : ""}${d3.format(".0f")(percent)}%`;
906
+ const trendX = cardWidth - 142;
907
+ const trendY = cardHeight - 76;
908
+ panel.append("rect")
909
+ .attr("class", "reference-trend-badge")
910
+ .attr("data-group", series.group)
911
+ .attr("x", trendX)
912
+ .attr("y", trendY)
913
+ .attr("width", 118)
914
+ .attr("height", 52)
915
+ .attr("rx", 10)
916
+ .attr("ry", 10)
917
+ .attr("fill", rgba(seriesColor, 0.12))
918
+ .attr("stroke", rgba(seriesColor, 0.26))
919
+ .attr("stroke-width", 1);
920
+ drawTrendArrow(panel, delta, seriesColor, trendX + 26, trendY + 25, 29);
921
+ panel.append("text")
922
+ .attr("class", "reference-trend-percent")
923
+ .attr("data-group", series.group)
924
+ .attr("data-first-value", firstPoint?.value ?? "")
925
+ .attr("data-last-value", lastPoint?.value ?? "")
926
+ .attr("x", trendX + 49)
927
+ .attr("y", trendY + 23)
928
+ .style("font-family", valueFamily)
929
+ .style("font-size", `${Math.max(17, Math.min(baseValueSize * 1.9, 22))}px`)
930
+ .style("font-weight", 800)
931
+ .style("fill", seriesColor)
932
+ .text(trendText);
933
+ panel.append("text")
934
+ .attr("class", "reference-trend-period")
935
+ .attr("x", trendX + 49)
936
+ .attr("y", trendY + 42)
937
+ .style("font-family", labelFamily)
938
+ .style("font-size", `${Math.max(10, Math.min(baseLabelSize, 12))}px`)
939
+ .style("font-weight", labelWeight)
940
+ .style("fill", navy)
941
+ .text(`${firstPoint?.label || ""}-${lastPoint?.label || ""}`);
942
+ });
943
+
944
+ const footer = svg.append("g")
945
+ .attr("class", "reference-footer-note")
946
+ .attr("transform", `translate(${sidePad}, ${footerY})`);
947
+ footer.append("rect")
948
+ .attr("class", "reference-footer-box")
949
+ .attr("x", 0)
950
+ .attr("y", 0)
951
+ .attr("width", width - sidePad * 2)
952
+ .attr("height", footerH)
953
+ .attr("rx", 14)
954
+ .attr("ry", 14)
955
+ .attr("fill", "#ffffff")
956
+ .attr("stroke", rgba(accentBlue, 0.28))
957
+ .attr("stroke-width", 1.2);
958
+
959
+ const footerIcon = footer.append("g")
960
+ .attr("class", "reference-footer-icon-slot")
961
+ .attr("data-slot", "takeaway-chart-icon")
962
+ .attr("transform", `translate(48, ${footerH / 2})`);
963
+ footerIcon.append("circle").attr("r", 35).attr("fill", accentBlue);
964
+ [0, 1, 2].forEach(i => {
965
+ footerIcon.append("rect")
966
+ .attr("x", -16 + i * 12)
967
+ .attr("y", 12 - i * 9)
968
+ .attr("width", 8)
969
+ .attr("height", 16 + i * 9)
970
+ .attr("fill", "#ffffff")
971
+ .attr("opacity", 0.94);
972
+ });
973
+ footerIcon.append("path")
974
+ .attr("d", "M -18 0 L -3 -12 L 7 -4 L 20 -22")
975
+ .attr("fill", "none")
976
+ .attr("stroke", "#ffffff")
977
+ .attr("stroke-width", 4)
978
+ .attr("stroke-linecap", "round")
979
+ .attr("stroke-linejoin", "round");
980
+ footerIcon.append("path")
981
+ .attr("d", "M 20 -22 L 10 -20 L 20 -22 L 19 -12")
982
+ .attr("fill", "none")
983
+ .attr("stroke", "#ffffff")
984
+ .attr("stroke-width", 4)
985
+ .attr("stroke-linecap", "round")
986
+ .attr("stroke-linejoin", "round");
987
+
988
+ footer.append("text")
989
+ .attr("class", "reference-footer-title")
990
+ .attr("x", 98)
991
+ .attr("y", 36)
992
+ .style("font-family", titleFamily)
993
+ .style("font-size", `${Math.max(18, Math.min(24, baseLabelSize * 2))}px`)
994
+ .style("font-weight", 800)
995
+ .style("fill", navy)
996
+ .text("Key Takeaway");
997
+
998
+ const footerLines = wrapText(`${takeaway} ${scaleNote}`, Math.max(38, Math.floor((width - 360) / 7.5)), 3);
999
+ const footerText = footer.append("text")
1000
+ .attr("class", "scale-note reference-scale-note")
1001
+ .attr("x", 98)
1002
+ .attr("y", 62)
1003
+ .style("font-family", labelFamily)
1004
+ .style("font-size", `${Math.max(12, Math.min(baseLabelSize * 1.18, 15))}px`)
1005
+ .style("font-weight", 400)
1006
+ .style("fill", mutedText);
1007
+ footerLines.forEach((lineText, lineIndex) => {
1008
+ footerText.append("tspan")
1009
+ .attr("x", 98)
1010
+ .attr("dy", lineIndex === 0 ? 0 : 19)
1011
+ .text(lineText);
1012
+ });
1013
+
1014
+ drawFooterDecoration(footer, width - sidePad * 2 - 242, 18, 216, 82, accentBlue);
1015
+
1016
+ return svg.node();
1017
+ }