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

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/type25_line_graph/line_graph_plain_chart_01.js ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Line Graph",
5
+ "chart_name": "line_graph_plain_chart_01",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[5, 30], ["-inf", "inf"], [2, 10]],
9
+ "required_fields_icons": ["group"],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["group"],
12
+ "required_other_colors": [],
13
+ "supported_effects": [],
14
+ "min_height": 400,
15
+ "min_width": 800,
16
+ "background": "light",
17
+ "icon_mark": "overlay",
18
+ "icon_label": "side",
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
+ // 提取数据
28
+ const jsonData = data;
29
+ const chartData = jsonData.data.data;
30
+ const variables = jsonData.variables;
31
+ const typography = jsonData.typography;
32
+ const colors = jsonData.colors || {};
33
+ const colorResolver = chartUtils.color.resolver(jsonData);
34
+ const dataColumns = chartUtils.schema.columns(jsonData);
35
+ const images = jsonData.images || {};
36
+
37
+ // 清空容器
38
+ d3.select(containerSelector).html("");
39
+
40
+ // 获取字段名
41
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
42
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
43
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
44
+
45
+
46
+ // 设置尺寸和边距
47
+ const width = variables.width;
48
+ const height = variables.height;
49
+ const margin = { top: 20, right: 30, bottom: 40, left: 50 };
50
+
51
+ // 创建SVG
52
+ const svg = d3.select(containerSelector)
53
+ .append("svg")
54
+ .attr("width", "100%")
55
+ .attr("height", height)
56
+ .attr("viewBox", `0 0 ${width} ${height}`)
57
+ .attr("style", "max-width: 100%; height: auto;")
58
+ .attr("xmlns", "http://www.w3.org/2000/svg")
59
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
60
+
61
+ // 创建图表区域
62
+ const chartWidth = width - margin.left - margin.right;
63
+ const chartHeight = height - margin.top - margin.bottom;
64
+
65
+ // X轴文本的高度
66
+ const xAxisTextHeight = 30;
67
+
68
+ const g = svg.append("g")
69
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
70
+
71
+ // 获取唯一的组值
72
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
73
+
74
+ const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, chartWidth);
75
+
76
+ // 创建y轴比例尺 - 使用数据的实际范围
77
+ const yMin = d3.min(chartData, d => +d[yField]);
78
+ const yMax = d3.max(chartData, d => +d[yField]);
79
+
80
+ // 为了美观,稍微扩展Y轴范围
81
+ const yPadding = (yMax - yMin) * 0.3;
82
+ const yDomainMax = yMax + yPadding;
83
+ const yDomainMin = Math.min(0, yMin - yPadding);
84
+
85
+ const yScale = d3.scaleLinear()
86
+ .domain([yDomainMin, yDomainMax])
87
+ .range([chartHeight, 0]);
88
+
89
+ // 创建颜色比例尺
90
+ const colorScale = colorResolver.scale(groups, { palette: "category10" });
91
+
92
+ // 获取实际的Y轴刻度 - 减少刻度数量
93
+ const yTicks = yScale.ticks(5); // 保持5个刻度
94
+ const maxYTick = yTicks[yTicks.length - 1]; // 最大的Y轴刻度值
95
+
96
+ // 计算最大Y刻度的位置
97
+ const maxYTickPosition = yScale(maxYTick);
98
+
99
+ // 添加条纹背景 - 使用更合适的时间间隔
100
+
101
+ // 为每个X轴刻度创建条纹背景,使条纹以刻度为中心
102
+ for (let i = 0; i < xTicks.length - 1; i++) {
103
+ // 获取相邻两个刻度
104
+ const currentTick = xTicks[i];
105
+ const nextTick = xTicks[i + 1];
106
+
107
+ // 计算当前刻度和下一个刻度的位置
108
+ const x1 = xScale(currentTick);
109
+ const x2 = xScale(nextTick);
110
+
111
+ // 每隔一个刻度添加浅色背景
112
+ if (i % 2 === 0) {
113
+ g.append("rect")
114
+ .attr("x", x1)
115
+ .attr("y", maxYTickPosition) // 从最大Y刻度开始
116
+ .attr("width", x2 - x1)
117
+ .attr("height", chartHeight - maxYTickPosition + xAxisTextHeight) // 延伸到X轴文本下方
118
+ .attr("fill", "#ececec")
119
+ .attr("class", "background")
120
+ .attr("opacity", 0.8);
121
+ }
122
+ }
123
+
124
+
125
+ // 将条纹背景移到最底层
126
+ g.selectAll("rect").lower();
127
+
128
+ // 添加图标水印(如果有)
129
+ if (images && images.field) {
130
+ // 创建一个滤镜使图像黑白化并变淡
131
+ const defs = svg.append("defs");
132
+
133
+ // 添加淡灰色滤镜 - 修改参数使颜色变淡
134
+ const lightGrayFilter = defs.append("filter")
135
+ .attr("id", "lightgray");
136
+
137
+ // 先转为灰度
138
+ lightGrayFilter.append("feColorMatrix")
139
+ .attr("type", "matrix")
140
+ .attr("values", "0.3333 0.3333 0.3333 0 0 0.3333 0.3333 0.3333 0 0 0.3333 0.3333 0.3333 0 0 0 0 0 1 0");
141
+
142
+ // 再提亮颜色 - 使用亮度组件
143
+ lightGrayFilter.append("feComponentTransfer")
144
+ .append("feFuncR")
145
+ .attr("type", "linear")
146
+ .attr("slope", "0.6")
147
+ .attr("intercept", "0.4");
148
+
149
+ lightGrayFilter.append("feComponentTransfer")
150
+ .append("feFuncG")
151
+ .attr("type", "linear")
152
+ .attr("slope", "0.6")
153
+ .attr("intercept", "0.4");
154
+
155
+ lightGrayFilter.append("feComponentTransfer")
156
+ .append("feFuncB")
157
+ .attr("type", "linear")
158
+ .attr("slope", "0.6")
159
+ .attr("intercept", "0.4");
160
+
161
+ // 计算每个组的线条中心位置
162
+ const groupCenters = new Map();
163
+
164
+ // 按组分组数据
165
+ const groupedData = d3.group(chartData, d => d[groupField]);
166
+
167
+ groupedData.forEach((values, group) => {
168
+ // 确保数据按日期排序
169
+ values.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
170
+
171
+ // 计算该组线条的中心点
172
+ const xPoints = values.map(d => xScale(chartUtils.format.parseDate(d[xField])));
173
+ const yPoints = values.map(d => yScale(+d[yField]));
174
+
175
+ // 找到线条的中间点
176
+ const midIndex = Math.floor(values.length / 2);
177
+ const centerX = xPoints[midIndex];
178
+ const centerY = yPoints[midIndex];
179
+
180
+ // 存储中心点
181
+ groupCenters.set(group, { x: centerX, y: centerY });
182
+ });
183
+
184
+ // 添加组图标水印 - 均匀分布在X轴上,Y位置对应X位置处的线条值
185
+ groups.forEach((group, groupIndex) => {
186
+ if (images.field[group]) {
187
+ const values = groupedData.get(group);
188
+ if (!values || values.length === 0) return;
189
+
190
+ // 确保数据按日期排序
191
+ values.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
192
+
193
+ const iconSize = 120;
194
+
195
+ // 计算X轴位置 - 将X轴均匀分成组数量的区域,但避开边缘
196
+ // 使用更窄的区域,避开起止点标签
197
+ const usableWidth = chartWidth * 0.7; // 使用70%的图表宽度
198
+ const margin = (chartWidth - usableWidth) / 2; // 两侧边距
199
+
200
+ const sectionWidth = usableWidth / groups.length;
201
+ const xPos = margin + sectionWidth * (groupIndex + 0.5); // 区域中心点
202
+
203
+ // 找到最接近xPos的数据点
204
+ // 首先将xPos转换回日期域
205
+ const xDate = xScale.invert(xPos);
206
+
207
+ // 找到最接近该日期的数据点
208
+ let closestPoint = values[0];
209
+ let minDistance = Math.abs(chartUtils.format.parseDate(closestPoint[xField]) - xDate);
210
+
211
+ for (let i = 1; i < values.length; i++) {
212
+ const distance = Math.abs(chartUtils.format.parseDate(values[i][xField]) - xDate);
213
+ if (distance < minDistance) {
214
+ minDistance = distance;
215
+ closestPoint = values[i];
216
+ }
217
+ }
218
+
219
+ // 使用最接近点的Y值
220
+ const yPos = yScale(closestPoint[yField]);
221
+
222
+ const watermark = g.append("image")
223
+ .attr("x", xPos - iconSize / 2) // 水印中心与区域中心对齐
224
+ .attr("y", yPos - iconSize / 2) // 使用对应X位置处的Y值
225
+ .attr("width", iconSize)
226
+ .attr("height", iconSize)
227
+ .attr("href", images.field[group])
228
+ .attr("opacity", 1) // 保持完全不透明
229
+ .attr("preserveAspectRatio", "xMidYMid meet")
230
+ .attr("filter", "url(#lightgray)"); // 使用淡灰色滤镜
231
+
232
+ // 确保水印在条纹背景上方,线条下方
233
+ watermark.lower();
234
+ g.selectAll("rect").lower();
235
+ }
236
+ });
237
+ }
238
+
239
+ // 添加水平网格线
240
+ yTicks.forEach(tick => {
241
+ g.append("line")
242
+ .attr("x1", 0)
243
+ .attr("y1", yScale(tick))
244
+ .attr("x2", chartWidth)
245
+ .attr("y2", yScale(tick))
246
+ .attr("class", "background")
247
+ .attr("stroke", "#e0e0e0")
248
+ .attr("stroke-width", 1)
249
+ .attr("stroke-dasharray", "2,2");
250
+ });
251
+
252
+ // 按组分组数据
253
+ const groupedData = d3.group(chartData, d => d[groupField]);
254
+
255
+ // 定义线条粗细
256
+ const lineWidth = 4;
257
+
258
+ // 创建线条生成器
259
+ const line = d3.line()
260
+ .x(d => xScale(chartUtils.format.parseDate(d[xField])))
261
+ .y(d => yScale(+d[yField]))
262
+ .curve(d3.curveLinear);
263
+
264
+ // 绘制每个组的线条
265
+ groupedData.forEach((values, group) => {
266
+ // 确保数据按日期排序
267
+ values.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
268
+
269
+ const color = colorScale(group);
270
+
271
+ // 绘制线条
272
+ g.append("path")
273
+ .datum(values)
274
+ .attr("fill", "none")
275
+ .attr("stroke", color)
276
+ .attr("stroke-width", lineWidth)
277
+ .attr("d", line);
278
+
279
+ // 添加数据点 - 根据是否为起止点使用不同样式
280
+ values.forEach((d, i) => {
281
+ const isEndpoint = i === 0 || i === values.length - 1;
282
+
283
+ if (isEndpoint) {
284
+ // 起止点:白色填充,带有颜色描边
285
+ g.append("circle")
286
+ .attr("cx", xScale(chartUtils.format.parseDate(d[xField])))
287
+ .attr("cy", yScale(+d[yField]))
288
+ .attr("r", lineWidth * 1.2)
289
+ .attr("fill", "#fff")
290
+ .attr("stroke", color)
291
+ .attr("stroke-width", lineWidth);
292
+ } else {
293
+ // 中间点:实心颜色填充,无描边
294
+ g.append("circle")
295
+ .attr("cx", xScale(chartUtils.format.parseDate(d[xField])))
296
+ .attr("cy", yScale(+d[yField]))
297
+ .attr("r", lineWidth)
298
+ .attr("fill", color)
299
+ .attr("stroke", "none");
300
+ }
301
+ });
302
+
303
+ // 添加起点和终点标注 - 简化为直接文本
304
+ const firstPoint = values[0];
305
+ const lastPoint = values[values.length - 1];
306
+
307
+ // 添加起点标注
308
+ addDataLabel(firstPoint, true);
309
+
310
+ // 添加终点标注
311
+ addDataLabel(lastPoint, false);
312
+ });
313
+
314
+ // 添加X轴文本 - 放置在条纹背景的中间
315
+ for (let i = 0; i < xTicks.length - 1; i++) {
316
+ // 获取相邻两个刻度
317
+ const currentTick = xTicks[i];
318
+ const nextTick = xTicks[i + 1];
319
+
320
+ // 计算当前刻度和下一个刻度的位置
321
+ const x1 = xScale(currentTick);
322
+ const x2 = xScale(nextTick);
323
+
324
+ // 计算中点位置
325
+ const midX = (x1 + x2) / 2;
326
+
327
+ g.append("text")
328
+ .attr("x", midX)
329
+ .attr("y", chartHeight + 20)
330
+ .attr("text-anchor", "middle")
331
+ .attr("fill", "#666")
332
+ .style("font-size", "12px")
333
+ .text(xFormat(currentTick));
334
+ }
335
+
336
+
337
+ // 添加Y轴文本
338
+ yTicks.forEach(tick => {
339
+ g.append("text")
340
+ .attr("x", -20)
341
+ .attr("y", yScale(tick))
342
+ .attr("text-anchor", "end")
343
+ .attr("dominant-baseline", "middle")
344
+ .attr("fill", "#666")
345
+ .style("font-size", "12px")
346
+ .text(chartUtils.format.fixed(tick).text);
347
+ });
348
+
349
+ // 添加图例 - 整体居中,放在最大Y轴刻度上方
350
+ const legendGroup = g.append("g");
351
+
352
+ const legendSize = chartUtils.legend.draw(legendGroup, groups, colors, {
353
+ x: 0,
354
+ y: 0,
355
+ fontSize: 14,
356
+ fontWeight: "bold",
357
+ align: "left",
358
+ maxWidth: chartWidth,
359
+ shape: "circle",
360
+ });
361
+
362
+ // 居中legend
363
+ legendGroup.attr("transform", `translate(${(chartWidth - legendSize.width) / 2}, ${maxYTickPosition - 50 - legendSize.height/2})`);
364
+
365
+ // 添加数据标注函数 - 文本放在线条上方,不加粗
366
+ function addDataLabel(point, isStart) {
367
+ const x = xScale(chartUtils.format.parseDate(point[xField]));
368
+ const y = yScale(+point[yField]);
369
+
370
+ // 添加文本 - 放在数据点上方,使用黑色
371
+ g.append("text")
372
+ .attr("x", x + (isStart ? -10 : 10))
373
+ .attr("y", y) // 放在数据点上方
374
+ .attr("text-anchor", isStart ? "end" : "start")
375
+ .attr("dominant-baseline", "middle")
376
+ .attr("fill", "#000") // 黑色文本
377
+ .attr("font-weight", "normal") // 移除加粗
378
+ .style("font-size", "12px")
379
+ .text(chartUtils.format.fixed(+point[yField], 2).text);
380
+ }
381
+
382
+ return svg.node();
383
+ }
modules/chart_engine/template/d3-js/type25_line_graph/line_graph_plain_chart_02.js ADDED
@@ -0,0 +1,567 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Line Graph",
5
+ "chart_name": "line_graph_plain_chart_02",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[3, 30], ["-inf", "inf"], [2, 6]],
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": 800,
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
+ // 提取数据
27
+ const jsonData = data;
28
+ const chartData = jsonData.data.data;
29
+ const variables = jsonData.variables;
30
+ const typography = jsonData.typography;
31
+ const colors = jsonData.colors || {};
32
+ const colorResolver = chartUtils.color.resolver(jsonData);
33
+ const dataColumns = chartUtils.schema.columns(jsonData);
34
+ const images = jsonData.images || {};
35
+
36
+ // 清空容器
37
+ d3.select(containerSelector).html("");
38
+
39
+ // 获取字段名
40
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
41
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
42
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
43
+
44
+ // 设置尺寸和边距
45
+ const width = variables.width;
46
+ const height = variables.height;
47
+ const margin = { top: 60, right: 30, bottom: 60, left: 60 };
48
+
49
+ // 创建SVG
50
+ const svg = d3.select(containerSelector)
51
+ .append("svg")
52
+ .attr("width", "100%")
53
+ .attr("height", height)
54
+ .attr("viewBox", `0 0 ${width} ${height}`)
55
+ .attr("style", "max-width: 100%; height: auto;")
56
+ .attr("xmlns", "http://www.w3.org/2000/svg")
57
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
58
+
59
+ // 创建图表区域
60
+ const chartWidth = width - margin.left - margin.right;
61
+ const chartHeight = height - margin.top - margin.bottom;
62
+
63
+ const g = svg.append("g")
64
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
65
+
66
+ // 获取唯一的组值
67
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
68
+
69
+ // 按组分组数据
70
+ const groupedData = d3.group(chartData, d => d[groupField]);
71
+
72
+ const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, chartWidth);
73
+
74
+ // 创建y轴比例尺 - 使用数据的实际范围
75
+ const yMin = d3.min(chartData, d => +d[yField]);
76
+ const yMax = d3.max(chartData, d => +d[yField]);
77
+
78
+ // 为了美观,稍微扩展Y轴范围
79
+ const yPadding = (yMax - yMin) * 0.3;
80
+ const yDomainMax = yMax + yPadding;
81
+ const yDomainMin = Math.min(0, yMin - yPadding);
82
+
83
+ const yScale = d3.scaleLinear()
84
+ .domain([yDomainMin, yDomainMax])
85
+ .range([chartHeight, 0]);
86
+
87
+ // 创建颜色比例尺 - 使用提供的颜色或默认颜色
88
+ const colorScale = d => colorResolver.field(d, groups.indexOf(d), { palette: "category10" }).value;
89
+
90
+ // 获取实际的Y轴刻度
91
+ const yTicks = yScale.ticks(5);
92
+
93
+ // 添加水平网格线 - 包括刻度之间的额外网格线
94
+ // 首先添加主要刻度的网格线
95
+ yTicks.forEach(tick => {
96
+ g.append("line")
97
+ .attr("x1", 0)
98
+ .attr("y1", yScale(tick))
99
+ .attr("x2", chartWidth)
100
+ .attr("y2", yScale(tick))
101
+ .attr("stroke", "#dddddd")
102
+ .attr("stroke-width", 1)
103
+ .attr("class", "background");
104
+ });
105
+
106
+ // 添加刻度之间的额外网格线
107
+ if (yTicks.length > 1) {
108
+ for (let i = 0; i < yTicks.length - 1; i++) {
109
+ const currentTick = yTicks[i];
110
+ const nextTick = yTicks[i + 1];
111
+ const midValue = (currentTick + nextTick) / 2;
112
+
113
+ g.append("line")
114
+ .attr("x1", 0)
115
+ .attr("y1", yScale(midValue))
116
+ .attr("x2", chartWidth)
117
+ .attr("y2", yScale(midValue))
118
+ .attr("stroke", "#dddddd")
119
+ .attr("stroke-width", 1)
120
+ .attr("class", "background");
121
+ }
122
+ }
123
+
124
+ // 添加Y轴刻度文本 - 保留0位小数,不带百分号
125
+ yTicks.forEach(tick => {
126
+ g.append("text")
127
+ .attr("x", -10)
128
+ .attr("y", yScale(tick))
129
+ .attr("text-anchor", "end")
130
+ .attr("dominant-baseline", "middle")
131
+ .attr("fill", "#666")
132
+ .style("font-size", "14px")
133
+ .text(chartUtils.format.integer(tick).text); // 四舍五入到整数,不带百分号
134
+ });
135
+
136
+ // 添加X轴刻度文本
137
+ xTicks.forEach(tick => {
138
+
139
+ g.append("text")
140
+ .attr("x", xScale(tick))
141
+ .attr("y", chartHeight + 20)
142
+ .attr("text-anchor", "middle")
143
+ .attr("fill", "#666")
144
+ .style("font-size", "14px")
145
+ .text(xFormat(tick));
146
+ });
147
+
148
+ // 添加X轴线
149
+ g.append("line")
150
+ .attr("x1", 0)
151
+ .attr("y1", chartHeight)
152
+ .attr("x2", chartWidth)
153
+ .attr("y2", chartHeight)
154
+ .attr("stroke", "#aaa")
155
+ .attr("stroke-width", 1);
156
+
157
+ // 定义线条粗细
158
+ const lineWidth = 4;
159
+
160
+ // 创建线条生成器
161
+ const line = d3.line()
162
+ .x(d => xScale(chartUtils.format.parseDate(d[xField])))
163
+ .y(d => yScale(+d[yField]))
164
+ .curve(d3.curveLinear); // 改为折线
165
+
166
+ // 收集需要标注的数据点 - 按起点/终点/中点分类
167
+ const startPoints = [];
168
+ const middlePoints = [];
169
+ const endPoints = [];
170
+
171
+ groupedData.forEach((values, group) => {
172
+ // 确保数据按日期排序
173
+ values.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
174
+
175
+ const color = colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
176
+
177
+ // 绘制线条
178
+ g.append("path")
179
+ .datum(values)
180
+ .attr("fill", "none")
181
+ .attr("stroke", color)
182
+ .attr("stroke-width", lineWidth)
183
+ .attr("d", line);
184
+
185
+ // 找出中间的一个X位置
186
+ const middleIndex = Math.floor(values.length / 2);
187
+
188
+ // 收集需要标注的点
189
+ values.forEach((d, i) => {
190
+ if (i === 0) {
191
+ // 起点
192
+ startPoints.push({
193
+ x: xScale(chartUtils.format.parseDate(d[xField])),
194
+ y: yScale(+d[yField]),
195
+ value: d[yField],
196
+ color: color,
197
+ group: group,
198
+ point: d
199
+ });
200
+ } else if (i === values.length - 1) {
201
+ // 终点
202
+ endPoints.push({
203
+ x: xScale(chartUtils.format.parseDate(d[xField])),
204
+ y: yScale(+d[yField]),
205
+ value: d[yField],
206
+ color: color,
207
+ group: group,
208
+ point: d
209
+ });
210
+ } else if (i === middleIndex) {
211
+ // 中点
212
+ middlePoints.push({
213
+ x: xScale(chartUtils.format.parseDate(d[xField])),
214
+ y: yScale(+d[yField]),
215
+ value: d[yField],
216
+ color: color,
217
+ group: group,
218
+ point: d
219
+ });
220
+ }
221
+ });
222
+ });
223
+
224
+ // 分别对起点、中点和终点应用动态规划 - 添加g参数
225
+ const startLabelPositions = placeLabelsDP(startPoints, yTicks.map(tick => yScale(tick)), "起点", chartHeight, chartWidth, g);
226
+ const middleLabelPositions = placeLabelsDP(middlePoints, yTicks.map(tick => yScale(tick)), "中点", chartHeight, chartWidth, g);
227
+ const endLabelPositions = placeLabelsDP(endPoints, yTicks.map(tick => yScale(tick)), "终点", chartHeight, chartWidth, g);
228
+
229
+ // 绘制标签函数 - 保持multiple_line_graph_10的样式
230
+ function drawLabels(labelPositions) {
231
+ labelPositions.forEach(placement => {
232
+ const point = placement.point;
233
+ const labelY = placement.labelY;
234
+
235
+ // 计算标签文本和宽度
236
+ const labelText = chartUtils.format.autoText(point.value);
237
+ const labelWidth = chartUtils.text.measure(null, labelText, { fontSize: 14, fontWeight: "bold" }).width + 10;
238
+ const labelHeight = 24;
239
+
240
+ // 添加圆角矩形背景
241
+ g.append("rect")
242
+ .attr("x", point.x - labelWidth / 2)
243
+ .attr("y", labelY + labelHeight / 2)
244
+ .attr("width", labelWidth)
245
+ .attr("height", labelHeight)
246
+ .attr("rx", 5)
247
+ .attr("ry", 5)
248
+ .attr("fill", point.color);
249
+
250
+ // 添加文本 - 白色粗体
251
+ g.append("text")
252
+ .attr("x", point.x)
253
+ .attr("y", labelY + labelHeight)
254
+ .attr("text-anchor", "middle")
255
+ .attr("dominant-baseline", "middle")
256
+ .attr("fill", "#fff")
257
+ .attr("font-weight", "bold")
258
+ .style("font-size", "14px")
259
+ .text(labelText);
260
+ });
261
+ }
262
+
263
+ // 绘制所有标签
264
+ drawLabels(startLabelPositions);
265
+ drawLabels(middleLabelPositions);
266
+ drawLabels(endLabelPositions);
267
+
268
+ // 添加图例 - 整体居中,向上移动
269
+ const legendGroup = g.append("g");
270
+
271
+ const legendSize = chartUtils.legend.draw(legendGroup, groups, colors, {
272
+ x: 0,
273
+ y: 0,
274
+ fontSize: 14,
275
+ fontWeight: "bold",
276
+ align: "left",
277
+ maxWidth: chartWidth,
278
+ shape: "line",
279
+ });
280
+
281
+ const maxYTickPosition = yScale(yTicks[yTicks.length - 1]);
282
+
283
+ // 居中legend
284
+ legendGroup.attr("transform", `translate(${(chartWidth - legendSize.width) / 2}, ${maxYTickPosition - 50 - legendSize.height/2})`);
285
+
286
+ return svg.node();
287
+ }
288
+
289
+ // 动态规划标签放置算法 - 添加g参数
290
+ function placeLabelsDP(points, avoidYPositions = [], debugName = "", chartHeight, chartWidth, g) {
291
+ // 每个格点的高度(像素)
292
+ const GRID_SIZE = 3;
293
+ // 圆点周围的保护区域(格点数)
294
+ const PROTECTION_RADIUS = 3;
295
+ // 标签高度(格点数)
296
+ const LABEL_HEIGHT = 10;
297
+
298
+ // 离散化Y坐标,创建格点系统
299
+ const minY = 0;
300
+ const maxY = chartHeight; // 使用传入的chartHeight
301
+ const gridCount = Math.ceil((maxY - minY) / GRID_SIZE);
302
+
303
+ // 按照Y坐标排序点
304
+ points.sort((a, b) => a.y - b.y);
305
+
306
+ // 创建格点占用标记
307
+ const occupied = new Array(gridCount).fill(false);
308
+ // 存储占用原因,用于可视化
309
+ const occupiedReason = new Array(gridCount).fill("");
310
+
311
+ // 标记圆点周围的保护区域为已占用
312
+ points.forEach((point, idx) => {
313
+ const gridY = Math.floor(point.y / GRID_SIZE) - 3;
314
+ for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
315
+ occupied[i] = true;
316
+ occupiedReason[i] = `数据点${idx + 1}保护区`;
317
+ }
318
+ });
319
+
320
+
321
+ // 定义状态: dp[i][j] 表示前i个点,第i个点的标签放在第j个格点时的最小代价
322
+ const n = points.length;
323
+ const dp = Array(n).fill().map(() => Array(gridCount).fill(Infinity));
324
+ const prev = Array(n).fill().map(() => Array(gridCount).fill(-1));
325
+
326
+ // 初始条件:第一个点的标签放置
327
+ const firstPointGridY = Math.floor(points[0].y / GRID_SIZE);
328
+
329
+ // 尝试将第一个点的标签放在可行的位置
330
+ for (let j = 0; j < gridCount; j++) {
331
+ // 检查是否可行(不在保护区域内且有足够空间放置标签)
332
+ if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
333
+ // 检查标签是否会与其他标签重叠
334
+ let canPlace = true;
335
+ for (let k = 0; k < LABEL_HEIGHT; k++) {
336
+ if (j + k < gridCount && occupied[j + k]) {
337
+ canPlace = false;
338
+ break;
339
+ }
340
+ }
341
+
342
+ if (canPlace) {
343
+ // 计算代价:与圆点位置的距离
344
+ const cost = Math.abs(j - firstPointGridY);
345
+ dp[0][j] = cost;
346
+ }
347
+ }
348
+ }
349
+
350
+ // 填充dp表
351
+ for (let i = 1; i < n; i++) {
352
+ const pointGridY = Math.floor(points[i].y / GRID_SIZE);
353
+
354
+ for (let j = 0; j < gridCount; j++) {
355
+ // 检查当前位置是否可行
356
+ if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
357
+ // 检查标签是否会与其他标签重叠
358
+ let canPlace = true;
359
+ for (let k = 0; k < LABEL_HEIGHT; k++) {
360
+ if (j + k < gridCount && occupied[j + k]) {
361
+ canPlace = false;
362
+ break;
363
+ }
364
+ }
365
+
366
+ if (canPlace) {
367
+ // 根据上一个点的标签位置计算当前最小代价
368
+ for (let k = 0; k + LABEL_HEIGHT <= j; k++) {
369
+ if (dp[i-1][k] !== Infinity) {
370
+
371
+ // 当前标签的代价
372
+ const curCost = Math.abs(j - pointGridY);
373
+ const totalCost = dp[i-1][k] + curCost;
374
+
375
+ if (totalCost < dp[i][j]) {
376
+ dp[i][j] = totalCost;
377
+ prev[i][j] = k; // 记录前驱
378
+ }
379
+ }
380
+ }
381
+ }
382
+ }
383
+ }
384
+ }
385
+
386
+ // 找出最后一个点的最优放置位置
387
+ let minCost = Infinity;
388
+ let bestPos = -1;
389
+
390
+ for (let j = 0; j < gridCount; j++) {
391
+ if (dp[n-1][j] < minCost) {
392
+ minCost = dp[n-1][j];
393
+ bestPos = j;
394
+ }
395
+ }
396
+
397
+ // 存储最终选择的标签位置(格点索引)
398
+ const selectedGridPositions = [];
399
+
400
+ // 回溯构建结果
401
+ const labelPositions = [];
402
+ if (bestPos !== -1) {
403
+ // 从后向前回溯
404
+ let pos = bestPos;
405
+
406
+ for (let i = n - 1; i >= 0; i--) {
407
+ labelPositions.unshift({
408
+ point: points[i],
409
+ labelY: pos * GRID_SIZE
410
+ });
411
+
412
+ // 记录选择的位置
413
+ selectedGridPositions.unshift(pos);
414
+
415
+ pos = prev[i][pos];
416
+ }
417
+ } else {
418
+ // 无可行解,退化为简单方案处理
419
+ let lastY = 0;
420
+ for (let i = 0; i < n; i++) {
421
+ const point = points[i];
422
+ // 确保标签不超过下一个圆点的位置
423
+ let maxY = chartHeight;
424
+ if (i < n - 1) {
425
+ maxY = points[i+1].y;
426
+ }
427
+
428
+ const labelY = Math.min(Math.max(point.y + 20, lastY + 25), maxY - 5);
429
+
430
+ labelPositions.push({
431
+ point: point,
432
+ labelY: labelY
433
+ });
434
+
435
+ lastY = labelY;
436
+ }
437
+ }
438
+
439
+ const debug = false;
440
+
441
+ // 添加格子系统可视化
442
+ if (debug && points.length > 0) {
443
+ // 确定可视化的位置 - 使用数据点的X位置
444
+ const mainPointX = points.length > 0 ? points[0].x : 0; // 使用该组第一个点的X位置
445
+ const visX = mainPointX;
446
+ const visY = 10; // 从图表顶部开始
447
+ const visWidth = 50; // 减小宽度以免干扰图表
448
+
449
+ // 创建一个组用于添加可视化元素,并在需要时应用剪切
450
+ const visGroup = g.append("g");
451
+
452
+ // 限制可视化高度,避免太长
453
+ const maxVisHeight = chartHeight - 20;
454
+ const visibleGridCount = Math.min(gridCount, Math.floor(maxVisHeight / GRID_SIZE));
455
+
456
+ // 添加半透明背景
457
+ visGroup.append("rect")
458
+ .attr("x", visX - 5)
459
+ .attr("y", visY - 15)
460
+ .attr("width", visWidth + 10)
461
+ .attr("height", visibleGridCount * GRID_SIZE + 50)
462
+ .attr("fill", "#fff")
463
+ .attr("opacity", 0.8)
464
+ .attr("rx", 5);
465
+
466
+ // 添加标题
467
+ visGroup.append("text")
468
+ .attr("x", visX + visWidth/2)
469
+ .attr("y", visY - 5)
470
+ .attr("text-anchor", "middle")
471
+ .attr("fill", "#333")
472
+ .style("font-size", "10px")
473
+ .text(debugName + "标签位置");
474
+
475
+ // 绘制每个格子
476
+ for (let i = 0; i < visibleGridCount; i++) {
477
+ // 计算格子的颜色
478
+ let fillColor = "#ddd"; // 默认颜色 - 可用格子
479
+
480
+ if (occupied[i]) {
481
+ fillColor = "#ffcccc"; // 不可用格子 - 红色
482
+ }
483
+
484
+ // 标记最终选择的位置
485
+ const isSelected = selectedGridPositions.some(pos => {
486
+ // 检查格子是否在标签范围内
487
+ for (let j = 0; j < LABEL_HEIGHT; j++) {
488
+ if (pos + j === i) return true;
489
+ }
490
+ return false;
491
+ });
492
+
493
+ if (isSelected) {
494
+ fillColor = "#99ff99"; // 选中的格子 - 绿色
495
+ }
496
+
497
+ // 绘制格子
498
+ visGroup.append("rect")
499
+ .attr("x", visX)
500
+ .attr("y", visY + i * GRID_SIZE)
501
+ .attr("width", visWidth)
502
+ .attr("height", GRID_SIZE)
503
+ .attr("fill", fillColor)
504
+ .attr("stroke", "#ccc")
505
+ .attr("stroke-width", 0.2);
506
+ }
507
+
508
+ // 添加数据点位置标记 - 只显示该组的点
509
+ points.forEach((point, idx) => {
510
+ const gridY = Math.floor(point.y / GRID_SIZE);
511
+
512
+ // 仅当点位置在可视范围内时添加
513
+ if (gridY < visibleGridCount) {
514
+ // 绘制数据点位置线
515
+ visGroup.append("line")
516
+ .attr("x1", visX - 3)
517
+ .attr("y1", visY + gridY * GRID_SIZE + GRID_SIZE/2)
518
+ .attr("x2", visX + visWidth)
519
+ .attr("y2", visY + gridY * GRID_SIZE + GRID_SIZE/2)
520
+ .attr("stroke", point.color)
521
+ .attr("stroke-width", 1)
522
+ .attr("stroke-dasharray", "2,1");
523
+
524
+ // 添加简短的数据点标识
525
+ visGroup.append("text")
526
+ .attr("x", visX - 5)
527
+ .attr("y", visY + gridY * GRID_SIZE + GRID_SIZE/2)
528
+ .attr("text-anchor", "end")
529
+ .attr("dominant-baseline", "middle")
530
+ .attr("fill", point.color)
531
+ .style("font-size", "8px")
532
+ .text("点" + (idx + 1));
533
+ }
534
+ });
535
+
536
+ // 在底部添加简化的图例
537
+ const legendY = visY + visibleGridCount * GRID_SIZE + 5;
538
+ const legendItems = [
539
+ { label: "可用", color: "#ddd" },
540
+ { label: "占用", color: "#ffcccc" },
541
+ { label: "选中", color: "#99ff99" }
542
+ ];
543
+
544
+ legendItems.forEach((item, idx) => {
545
+ // 水平放置图例项
546
+ const itemX = visX + idx * (visWidth / 3);
547
+
548
+ visGroup.append("rect")
549
+ .attr("x", itemX)
550
+ .attr("y", legendY)
551
+ .attr("width", 6)
552
+ .attr("height", 6)
553
+ .attr("fill", item.color);
554
+
555
+ visGroup.append("text")
556
+ .attr("x", itemX + 8)
557
+ .attr("y", legendY + 3)
558
+ .attr("text-anchor", "start")
559
+ .attr("dominant-baseline", "middle")
560
+ .attr("fill", "#333")
561
+ .style("font-size", "6px")
562
+ .text(item.label);
563
+ });
564
+ }
565
+
566
+ return labelPositions;
567
+ }
modules/chart_engine/template/d3-js/type25_line_graph/line_graph_plain_chart_03.js ADDED
@@ -0,0 +1,754 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Line Graph",
5
+ "chart_name": "line_graph_plain_chart_03",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[5, 30], ["-inf", "inf"], [2, 7]],
9
+ "required_fields_icons": ["group"],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["group"],
12
+ "required_other_colors": [],
13
+ "supported_effects": [],
14
+ "min_height": 400,
15
+ "min_width": 800,
16
+ "background": "dark",
17
+ "icon_mark": "side",
18
+ "icon_label": "side",
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.data;
28
+ const variables = jsonData.variables;
29
+ const typography = jsonData.typography;
30
+ const colorResolver = chartUtils.color.resolver(jsonData);
31
+ const dataColumns = chartUtils.schema.columns(jsonData);
32
+ const images = jsonData.images || {};
33
+
34
+ const sourceTitle = (jsonData.titles && jsonData.titles.main_title) || jsonData.metadata?.title || "";
35
+ const sourceSubtitle = (jsonData.titles && jsonData.titles.sub_title) || jsonData.metadata?.description || "";
36
+ const sourceTitleMatch = String(sourceTitle).match(/\s+v(?:s\.?|ersus)\s+/i);
37
+ if (jsonData.titles && sourceTitleMatch) {
38
+ const lead = sourceTitle.slice(0, sourceTitleMatch.index).trim().toUpperCase();
39
+ const focus = sourceTitle.slice(sourceTitleMatch.index + sourceTitleMatch[0].length).trim().toUpperCase();
40
+ jsonData.titles.main_title = `${lead} vs. ${focus}`;
41
+ jsonData.titles.sub_title = String(sourceSubtitle).replace(/^"|"$/g, "");
42
+ }
43
+
44
+ d3.select(containerSelector).html("");
45
+
46
+ const width = variables.width;
47
+ const height = variables.height;
48
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
49
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
50
+ const yColumn = chartUtils.schema.column(dataColumns, 1, { fallbackKey: yField });
51
+ const yLabel = yColumn.rawLabel || yField;
52
+ const yUnit = yColumn.unit || "";
53
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
54
+
55
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
56
+ const parsedRows = chartData
57
+ .map(d => ({
58
+ raw: d,
59
+ x: chartUtils.format.parseDate(d[xField]),
60
+ y: +d[yField],
61
+ group: d[groupField]
62
+ }))
63
+ .filter(d => Number.isFinite(d.x.getTime()) && Number.isFinite(d.y));
64
+
65
+ const groupSeries = groups.map(group => {
66
+ const values = parsedRows
67
+ .filter(d => d.group === group)
68
+ .sort((a, b) => a.x - b.x);
69
+ return { group, values };
70
+ }).filter(series => series.values.length > 0);
71
+
72
+ const getColor = (group) => colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
73
+ const brighten = (hex, amount) => d3.color(hex).brighter(amount).formatHex();
74
+ const darken = (hex, amount) => d3.color(hex).darker(amount).formatHex();
75
+ const textFont = typography.label.font_family || "Arial";
76
+
77
+ const svg = d3.select(containerSelector)
78
+ .append("svg")
79
+ .attr("width", "100%")
80
+ .attr("height", height)
81
+ .attr("viewBox", `0 0 ${width} ${height}`)
82
+ .attr("style", "max-width: 100%; height: auto;")
83
+ .attr("xmlns", "http://www.w3.org/2000/svg")
84
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
85
+
86
+ const defs = svg.append("defs");
87
+
88
+ svg.append("style").text(`
89
+ svg > rect:first-of-type { fill: #011222 !important; }
90
+ image.image[data-type="image"] { display: none !important; }
91
+ .text[data-type="title"] {
92
+ display: none !important;
93
+ }
94
+ .text[data-type="title"] text:nth-of-type(1) {
95
+ fill: #f7fbff !important;
96
+ font-weight: 900 !important;
97
+ letter-spacing: 0 !important;
98
+ }
99
+ .text[data-type="title"] text:nth-of-type(2) {
100
+ fill: #a8fbf4 !important;
101
+ font-weight: 900 !important;
102
+ letter-spacing: 0 !important;
103
+ text-transform: uppercase;
104
+ }
105
+ .text[data-type="title"] text:nth-of-type(3) {
106
+ fill: #d7e5ee !important;
107
+ font-weight: 500 !important;
108
+ }
109
+ .line03-internal-title { display: inline !important; }
110
+ `);
111
+
112
+ const bg = defs.append("radialGradient")
113
+ .attr("id", "line03-bg")
114
+ .attr("cx", "58%")
115
+ .attr("cy", "20%")
116
+ .attr("r", "90%");
117
+ bg.append("stop").attr("offset", "0%").attr("stop-color", "#073e62");
118
+ bg.append("stop").attr("offset", "52%").attr("stop-color", "#021f35");
119
+ bg.append("stop").attr("offset", "100%").attr("stop-color", "#011222");
120
+
121
+ const cyanGlow = defs.append("filter")
122
+ .attr("id", "line03-soft-glow")
123
+ .attr("x", "-30%")
124
+ .attr("y", "-30%")
125
+ .attr("width", "160%")
126
+ .attr("height", "160%");
127
+ cyanGlow.append("feGaussianBlur").attr("stdDeviation", 2.2).attr("result", "blur");
128
+ const merge = cyanGlow.append("feMerge");
129
+ merge.append("feMergeNode").attr("in", "blur");
130
+ merge.append("feMergeNode").attr("in", "SourceGraphic");
131
+
132
+ const contentLayer = svg.append("g")
133
+ .attr("class", "line03-content-layer")
134
+ .attr("data-layout", "full-poster-composition")
135
+ .attr("transform", "translate(-500,-1230)");
136
+
137
+ const poster = {
138
+ x: -50,
139
+ y: -43,
140
+ width: width + 720,
141
+ height: height + 1260
142
+ };
143
+
144
+ contentLayer.append("rect")
145
+ .attr("class", "line03-background-extension")
146
+ .attr("x", poster.x)
147
+ .attr("y", poster.y)
148
+ .attr("width", poster.width)
149
+ .attr("height", poster.height)
150
+ .attr("fill", "url(#line03-bg)");
151
+
152
+ contentLayer.append("rect")
153
+ .attr("class", "line03-poster-vignette")
154
+ .attr("x", poster.x + 8)
155
+ .attr("y", poster.y + 8)
156
+ .attr("width", poster.width - 16)
157
+ .attr("height", poster.height - 16)
158
+ .attr("rx", 18)
159
+ .attr("fill", "rgba(4,36,62,0.20)")
160
+ .attr("stroke", "rgba(99,221,232,0.10)");
161
+
162
+ const titleText = (jsonData.titles && jsonData.titles.main_title) || jsonData.metadata?.title || "";
163
+ const subtitleText = (jsonData.titles && jsonData.titles.sub_title) || jsonData.metadata?.description || "";
164
+ const titleMatch = String(titleText).match(/\s+v(?:s\.?|ersus)\s+/i);
165
+ const titleLead = titleMatch ? titleText.slice(0, titleMatch.index).trim() : titleText;
166
+ const titleFocus = titleMatch ? titleText.slice(titleMatch.index + titleMatch[0].length).trim() : "";
167
+ const titleMaxWidth = poster.width - 110;
168
+ const estimateTitleSize = (text, base, min) => Math.max(min, Math.min(base, titleMaxWidth / Math.max(1, String(text).length * 0.58)));
169
+ const focusLine = titleFocus ? `VS. ${String(titleFocus).toUpperCase()}` : "";
170
+
171
+ contentLayer.append("text")
172
+ .attr("class", "line03-internal-title line03-poster-title line03-title-lead")
173
+ .attr("x", poster.x + 72)
174
+ .attr("y", -320)
175
+ .style("font-family", textFont)
176
+ .style("font-size", `${estimateTitleSize(titleLead, focusLine ? 68 : 58, focusLine ? 42 : 28)}px`)
177
+ .style("font-weight", 900)
178
+ .style("fill", "#f7fbff")
179
+ .style("letter-spacing", "0")
180
+ .text(String(titleLead).toUpperCase());
181
+
182
+ if (focusLine) {
183
+ contentLayer.append("text")
184
+ .attr("class", "line03-internal-title line03-poster-title line03-title-focus")
185
+ .attr("x", poster.x + 72)
186
+ .attr("y", -220)
187
+ .style("font-family", textFont)
188
+ .style("font-size", `${estimateTitleSize(focusLine, 96, 58)}px`)
189
+ .style("font-weight", 900)
190
+ .style("fill", "#a8fbf4")
191
+ .style("letter-spacing", "0")
192
+ .text(focusLine);
193
+ }
194
+
195
+ contentLayer.append("line")
196
+ .attr("class", "line03-internal-title")
197
+ .attr("x1", poster.x + 88)
198
+ .attr("x2", poster.x + poster.width - 88)
199
+ .attr("y1", -130)
200
+ .attr("y2", -130)
201
+ .attr("stroke", "rgba(213,242,248,0.28)")
202
+ .attr("stroke-width", 1.2);
203
+
204
+ contentLayer.append("g")
205
+ .attr("class", "line03-internal-title reserved-asset-slot title-divider-slot")
206
+ .attr("data-asset-slot", "line03-title-divider-icon")
207
+ .attr("data-asset-source-policy", "neutral-placeholder")
208
+ .attr("data-anchor", "title-divider-center")
209
+ .attr("data-collision-rule", "between-title-and-subtitle-only")
210
+ .attr("data-bbox", `${poster.x + poster.width / 2 - 12},-142,24,24`)
211
+ .attr("data-no-embedded-raster", "true")
212
+ .attr("data-no-copied-reference-art", "true")
213
+ .append("circle")
214
+ .attr("cx", poster.x + poster.width / 2)
215
+ .attr("cy", -130)
216
+ .attr("r", 12)
217
+ .attr("fill", "#061f33")
218
+ .attr("stroke", "rgba(213,242,248,0.35)");
219
+
220
+ addWrappedText(contentLayer, String(subtitleText).replace(/^"|"$/g, ""), poster.x + poster.width / 2, -75, poster.width - 300, 27, 2, {
221
+ fontSize: "23px",
222
+ fontWeight: "500",
223
+ fill: "#d7e5ee",
224
+ textAnchor: "middle"
225
+ }).attr("class", "line03-internal-title line03-poster-subtitle");
226
+
227
+ const cardsTop = 65;
228
+ const cardGap = 26;
229
+ const cardWidth = (poster.width - 116 - cardGap) / 2;
230
+ const cardHeight = 180;
231
+
232
+ function addWrappedText(group, text, x, y, maxWidth, lineHeight, maxLines, attrs = {}) {
233
+ const words = String(text || "").split(/\s+/).filter(Boolean);
234
+ let line = [];
235
+ let lineNo = 0;
236
+ let tspan = group.append("text")
237
+ .attr("x", x)
238
+ .attr("y", y)
239
+ .attr("text-anchor", attrs.textAnchor || "start")
240
+ .style("font-family", textFont)
241
+ .style("font-size", attrs.fontSize || "12px")
242
+ .style("font-weight", attrs.fontWeight || "400")
243
+ .style("fill", attrs.fill || "#eaf9ff")
244
+ .style("letter-spacing", attrs.letterSpacing || "0");
245
+
246
+ let current = tspan.append("tspan").attr("x", x).attr("dy", 0);
247
+ for (const word of words) {
248
+ line.push(word);
249
+ current.text(line.join(" "));
250
+ if (current.node().getComputedTextLength() > maxWidth && line.length > 1) {
251
+ line.pop();
252
+ current.text(line.join(" "));
253
+ line = [word];
254
+ lineNo += 1;
255
+ if (lineNo >= maxLines) {
256
+ current.text(current.text().replace(/\s+\S*$/, "") + "...");
257
+ break;
258
+ }
259
+ current = tspan.append("tspan")
260
+ .attr("x", x)
261
+ .attr("dy", lineHeight)
262
+ .text(word);
263
+ }
264
+ }
265
+ return tspan;
266
+ }
267
+
268
+ function endpointText(value) {
269
+ const abs = Math.abs(value);
270
+ if (String(yUnit).toLowerCase().includes("m") || String(yLabel).toLowerCase().includes("million")) {
271
+ const rounded = abs < 0.5 ? 0 : Math.round(value);
272
+ return `${rounded}M`;
273
+ }
274
+ const rounded = abs >= 10 ? Math.round(value) : Math.round(value * 10) / 10;
275
+ return String(rounded);
276
+ }
277
+
278
+ function trendText(series) {
279
+ const first = series.values[0].y;
280
+ const last = series.values[series.values.length - 1].y;
281
+ const lastPhrase = Math.abs(last) < 0.5 && (String(yUnit).toLowerCase().includes("m") || String(yLabel).toLowerCase().includes("million"))
282
+ ? "near zero"
283
+ : endpointText(last);
284
+ if (last < first * 0.65) return `Profits steadily decline, reaching ${lastPhrase} by ${series.values[series.values.length - 1].raw[xField]}.`;
285
+ if (last > first * 1.35) return `Profits rise consistently, reaching ${endpointText(last)} by ${series.values[series.values.length - 1].raw[xField]}.`;
286
+ return `Profits remain near ${endpointText(last)} by ${series.values[series.values.length - 1].raw[xField]}.`;
287
+ }
288
+
289
+ function addIconSlot(parent, groupName, x, y, size) {
290
+ const slot = parent.append("g")
291
+ .attr("class", "reserved-asset-slot group-icon-slot")
292
+ .attr("data-asset-slot", `line03-group-icon-${String(groupName).toLowerCase().replace(/[^a-z0-9]+/g, "-")}`)
293
+ .attr("data-asset-source-policy", images.field && images.field[groupName] ? "input-provided" : "neutral-placeholder")
294
+ .attr("data-anchor", "top-metric-card")
295
+ .attr("data-collision-rule", "stay-inside-card-left-medallion")
296
+ .attr("data-bbox", `${x},${y},${size},${size}`)
297
+ .attr("data-no-copied-reference-art", "true");
298
+
299
+ if (!(images.field && images.field[groupName])) {
300
+ slot.attr("data-no-embedded-raster", "true");
301
+ }
302
+
303
+ slot.append("circle")
304
+ .attr("cx", x + size / 2)
305
+ .attr("cy", y + size / 2)
306
+ .attr("r", size / 2)
307
+ .attr("fill", "#eef8f5")
308
+ .attr("stroke", "rgba(255,255,255,0.75)")
309
+ .attr("stroke-width", 1.5);
310
+
311
+ if (images.field && images.field[groupName]) {
312
+ slot.append("clipPath")
313
+ .attr("id", `line03-icon-clip-${String(groupName).replace(/[^a-zA-Z0-9]/g, "-")}`)
314
+ .append("circle")
315
+ .attr("cx", x + size / 2)
316
+ .attr("cy", y + size / 2)
317
+ .attr("r", size / 2 - 3);
318
+ slot.append("image")
319
+ .attr("x", x + 3)
320
+ .attr("y", y + 3)
321
+ .attr("width", size - 6)
322
+ .attr("height", size - 6)
323
+ .attr("data-input-provided-asset", "true")
324
+ .attr("data-no-copied-reference-art", "true")
325
+ .attr("preserveAspectRatio", "xMidYMid slice")
326
+ .attr("clip-path", `url(#line03-icon-clip-${String(groupName).replace(/[^a-zA-Z0-9]/g, "-")})`)
327
+ .attr("xlink:href", images.field[groupName]);
328
+ } else {
329
+ slot.append("circle")
330
+ .attr("cx", x + size / 2)
331
+ .attr("cy", y + size / 2)
332
+ .attr("r", 8)
333
+ .attr("fill", "rgba(6,40,62,0.28)");
334
+ }
335
+ }
336
+
337
+ groupSeries.slice(0, 2).forEach((series, i) => {
338
+ const group = series.group;
339
+ const color = getColor(group);
340
+ const x = 24 + i * (cardWidth + cardGap);
341
+ const groupLabel = String(group).toUpperCase();
342
+ const groupFontSize = Math.max(15, Math.min(26, (cardWidth - 152) / Math.max(1, groupLabel.length * 0.62)));
343
+ const card = contentLayer.append("g")
344
+ .attr("class", "line03-metric-card")
345
+ .attr("data-series", group)
346
+ .attr("data-relationship", "independent-header-card")
347
+ .attr("data-anchor", "header-safe-area");
348
+
349
+ card.append("rect")
350
+ .attr("x", x)
351
+ .attr("y", cardsTop)
352
+ .attr("width", cardWidth)
353
+ .attr("height", cardHeight)
354
+ .attr("rx", 7)
355
+ .attr("fill", i === 0 ? "rgba(0,78,98,0.44)" : "rgba(45,25,88,0.42)")
356
+ .attr("stroke", color)
357
+ .attr("stroke-width", 1.2)
358
+ .attr("opacity", 0.95);
359
+
360
+ addIconSlot(card, group, x + 16, cardsTop + 26, 92);
361
+
362
+ card.append("text")
363
+ .attr("x", x + 124)
364
+ .attr("y", cardsTop + 49)
365
+ .style("font-family", textFont)
366
+ .style("font-size", `${groupFontSize}px`)
367
+ .style("font-weight", 800)
368
+ .style("fill", brighten(color, 0.55))
369
+ .style("letter-spacing", "0.03em")
370
+ .text(groupLabel);
371
+
372
+ addWrappedText(card, trendText(series), x + 124, cardsTop + 84, cardWidth - 144, 24, 2, {
373
+ fontSize: "20px",
374
+ fill: "#f4fbff"
375
+ });
376
+ });
377
+
378
+ const plot = {
379
+ x: poster.x + 84,
380
+ y: 405,
381
+ width: poster.width - 156,
382
+ height: 1065
383
+ };
384
+ const labelReserve = groupSeries.length > 2 ? 150 : 118;
385
+ const plotWidth = plot.width - labelReserve;
386
+ const plotHeight = plot.height;
387
+ const xExtent = d3.extent(parsedRows, d => d.x);
388
+ const yMinData = d3.min(parsedRows, d => d.y);
389
+ const yMaxData = d3.max(parsedRows, d => d.y);
390
+ const yMin = Math.min(0, yMinData);
391
+ const yMax = Math.ceil((yMaxData * 1.4) / 5) * 5;
392
+
393
+ const xScale = d3.scaleTime().domain(xExtent).range([0, plotWidth]);
394
+ const yScale = d3.scaleLinear().domain([yMin, yMax]).nice(5).range([plotHeight, 0]);
395
+ const uniqueDates = [...new Map(parsedRows.map(d => [+d.x, d.x])).values()].sort((a, b) => a - b);
396
+ const xTicks = uniqueDates.length <= 12
397
+ ? uniqueDates
398
+ : uniqueDates.filter((_, i) => i % Math.ceil(uniqueDates.length / 10) === 0 || i === uniqueDates.length - 1);
399
+ const yTicks = yScale.ticks(5).filter(d => d >= yMin && d <= yMax);
400
+ const xFormat = d3.timeFormat("%Y");
401
+ const yZero = yScale(0);
402
+
403
+ const chart = contentLayer.append("g")
404
+ .attr("class", "line03-plot")
405
+ .attr("data-relationship", "wide-chart-panel-below-header-cards")
406
+ .attr("transform", `translate(${plot.x},${plot.y})`);
407
+
408
+ chart.append("text")
409
+ .attr("x", -20)
410
+ .attr("y", -14)
411
+ .style("font-family", textFont)
412
+ .style("font-size", "13px")
413
+ .style("font-weight", 600)
414
+ .style("fill", "#eefbff")
415
+ .text(`${yLabel}${yUnit && !String(yLabel).includes(yUnit) ? ` (${String(yUnit).toLowerCase() === "$m" ? "millions" : yUnit})` : ""}`);
416
+
417
+ chart.append("rect")
418
+ .attr("class", "reserved-asset-slot plot-scenic-commercial")
419
+ .attr("data-asset-slot", "line03-plot-commercial-scenic-art")
420
+ .attr("data-asset-source-policy", "image2-generated")
421
+ .attr("data-anchor", "lower-left-under-commercial-series")
422
+ .attr("data-collision-rule", "behind-lines-and-below-data-labels")
423
+ .attr("data-bbox", `${8},${plotHeight * 0.58},${plotWidth * 0.36},${plotHeight * 0.32}`)
424
+ .attr("data-no-embedded-raster", "true")
425
+ .attr("data-no-copied-reference-art", "true")
426
+ .attr("x", 8)
427
+ .attr("y", plotHeight * 0.58)
428
+ .attr("width", plotWidth * 0.36)
429
+ .attr("height", plotHeight * 0.32)
430
+ .attr("fill", "rgba(28,112,130,0.075)")
431
+ .attr("stroke", "rgba(70,210,218,0.13)")
432
+ .attr("stroke-dasharray", "4 8")
433
+ .attr("rx", 18);
434
+
435
+ chart.append("rect")
436
+ .attr("class", "reserved-asset-slot plot-scenic-ecotourism")
437
+ .attr("data-asset-slot", "line03-plot-ecotourism-scenic-art")
438
+ .attr("data-asset-source-policy", "image2-generated")
439
+ .attr("data-anchor", "lower-right-under-ecotourism-area")
440
+ .attr("data-collision-rule", "behind-lines-and-below-data-labels")
441
+ .attr("data-bbox", `${plotWidth * 0.48},${plotHeight * 0.42},${plotWidth * 0.48},${plotHeight * 0.40}`)
442
+ .attr("data-no-embedded-raster", "true")
443
+ .attr("data-no-copied-reference-art", "true")
444
+ .attr("x", plotWidth * 0.48)
445
+ .attr("y", plotHeight * 0.42)
446
+ .attr("width", plotWidth * 0.48)
447
+ .attr("height", plotHeight * 0.40)
448
+ .attr("fill", "rgba(86,56,142,0.075)")
449
+ .attr("stroke", "rgba(184,119,255,0.13)")
450
+ .attr("stroke-dasharray", "4 8")
451
+ .attr("rx", 20);
452
+
453
+ chart.selectAll("line.grid-y")
454
+ .data(yTicks)
455
+ .enter()
456
+ .append("line")
457
+ .attr("class", "grid-y")
458
+ .attr("x1", 0)
459
+ .attr("x2", plotWidth)
460
+ .attr("y1", d => yScale(d))
461
+ .attr("y2", d => yScale(d))
462
+ .attr("stroke", "rgba(190,218,232,0.18)")
463
+ .attr("stroke-dasharray", "1.5 3");
464
+
465
+ chart.selectAll("line.grid-x")
466
+ .data(xTicks)
467
+ .enter()
468
+ .append("line")
469
+ .attr("class", "grid-x")
470
+ .attr("x1", d => xScale(d))
471
+ .attr("x2", d => xScale(d))
472
+ .attr("y1", 0)
473
+ .attr("y2", plotHeight)
474
+ .attr("stroke", "rgba(190,218,232,0.14)")
475
+ .attr("stroke-dasharray", "1.5 3");
476
+
477
+ chart.append("line")
478
+ .attr("x1", 0)
479
+ .attr("x2", 0)
480
+ .attr("y1", 0)
481
+ .attr("y2", plotHeight)
482
+ .attr("stroke", "#eff8ff")
483
+ .attr("stroke-width", 1.1);
484
+
485
+ chart.append("line")
486
+ .attr("x1", 0)
487
+ .attr("x2", plotWidth)
488
+ .attr("y1", yZero)
489
+ .attr("y2", yZero)
490
+ .attr("stroke", "#eff8ff")
491
+ .attr("stroke-width", 1.1);
492
+
493
+ const area = d3.area()
494
+ .curve(d3.curveMonotoneX)
495
+ .x(d => xScale(d.x))
496
+ .y0(yZero)
497
+ .y1(d => yScale(d.y));
498
+
499
+ const line = d3.line()
500
+ .curve(d3.curveMonotoneX)
501
+ .x(d => xScale(d.x))
502
+ .y(d => yScale(d.y));
503
+
504
+ groupSeries.forEach((series, i) => {
505
+ const color = getColor(series.group);
506
+ const gradientId = `line03-area-${i}`;
507
+ const grad = defs.append("linearGradient")
508
+ .attr("id", gradientId)
509
+ .attr("x1", "0%")
510
+ .attr("x2", "0%")
511
+ .attr("y1", "0%")
512
+ .attr("y2", "100%");
513
+ grad.append("stop").attr("offset", "0%").attr("stop-color", color).attr("stop-opacity", 0.5);
514
+ grad.append("stop").attr("offset", "72%").attr("stop-color", color).attr("stop-opacity", 0.22);
515
+ grad.append("stop").attr("offset", "100%").attr("stop-color", color).attr("stop-opacity", 0.06);
516
+
517
+ chart.append("path")
518
+ .datum(series.values)
519
+ .attr("class", "line03-area")
520
+ .attr("data-series", series.group)
521
+ .attr("fill", `url(#${gradientId})`)
522
+ .attr("d", area)
523
+ .attr("opacity", 0.78);
524
+ });
525
+
526
+ groupSeries.forEach(series => {
527
+ const color = getColor(series.group);
528
+ chart.append("path")
529
+ .datum(series.values)
530
+ .attr("class", "line03-line-halo")
531
+ .attr("data-series", series.group)
532
+ .attr("fill", "none")
533
+ .attr("stroke", darken(color, 1.15))
534
+ .attr("stroke-width", 7)
535
+ .attr("stroke-linecap", "round")
536
+ .attr("stroke-linejoin", "round")
537
+ .attr("opacity", 0.9)
538
+ .attr("d", line);
539
+
540
+ chart.append("path")
541
+ .datum(series.values)
542
+ .attr("class", "line03-line")
543
+ .attr("data-series", series.group)
544
+ .attr("fill", "none")
545
+ .attr("stroke", brighten(color, 0.45))
546
+ .attr("stroke-width", 4)
547
+ .attr("stroke-linecap", "round")
548
+ .attr("stroke-linejoin", "round")
549
+ .attr("filter", "url(#line03-soft-glow)")
550
+ .attr("d", line);
551
+ });
552
+
553
+ chart.selectAll("text.y-tick")
554
+ .data(yTicks)
555
+ .enter()
556
+ .append("text")
557
+ .attr("class", "y-tick")
558
+ .attr("x", -9)
559
+ .attr("y", d => yScale(d) + 4)
560
+ .attr("text-anchor", "end")
561
+ .style("font-family", textFont)
562
+ .style("font-size", "13px")
563
+ .style("fill", "#f3f7fb")
564
+ .text(d => d);
565
+
566
+ chart.selectAll("text.x-tick")
567
+ .data(xTicks)
568
+ .enter()
569
+ .append("text")
570
+ .attr("class", "x-tick")
571
+ .attr("x", d => xScale(d))
572
+ .attr("y", plotHeight + 22)
573
+ .attr("text-anchor", "middle")
574
+ .style("font-family", textFont)
575
+ .style("font-size", "12px")
576
+ .style("fill", "#f3f7fb")
577
+ .text(d => xFormat(d));
578
+
579
+ chart.append("text")
580
+ .attr("x", plotWidth / 2)
581
+ .attr("y", plotHeight + 48)
582
+ .attr("text-anchor", "middle")
583
+ .style("font-family", textFont)
584
+ .style("font-size", "15px")
585
+ .style("font-weight", 700)
586
+ .style("fill", "#ffffff")
587
+ .text(xField);
588
+
589
+ const endpointLayouts = (() => {
590
+ const layouts = groupSeries.map(series => {
591
+ const last = series.values[series.values.length - 1];
592
+ return {
593
+ series,
594
+ pointY: yScale(last.y),
595
+ centerY: yScale(last.y)
596
+ };
597
+ }).sort((a, b) => a.centerY - b.centerY);
598
+
599
+ const minGap = Math.max(72, Math.min(96, plotHeight / Math.max(1, layouts.length) * 0.78));
600
+ layouts.forEach((layout, i) => {
601
+ const minYForIndex = 28 + i * minGap;
602
+ layout.centerY = Math.max(layout.centerY, minYForIndex);
603
+ });
604
+ for (let i = layouts.length - 2; i >= 0; i--) {
605
+ layouts[i].centerY = Math.min(layouts[i].centerY, layouts[i + 1].centerY - minGap);
606
+ }
607
+ layouts.forEach(layout => {
608
+ layout.centerY = Math.max(28, Math.min(plotHeight - 44, layout.centerY));
609
+ });
610
+
611
+ const byGroup = new Map();
612
+ layouts.forEach(layout => byGroup.set(layout.series.group, layout));
613
+ return byGroup;
614
+ })();
615
+
616
+ function addEndpointBadge(series) {
617
+ const color = getColor(series.group);
618
+ const last = series.values[series.values.length - 1];
619
+ const pointX = xScale(last.x);
620
+ const pointY = yScale(last.y);
621
+ const badgeW = groupSeries.length > 2 ? 64 : 76;
622
+ const badgeH = 42;
623
+ const badgeX = pointX + 30;
624
+ const layout = endpointLayouts.get(series.group) || { centerY: pointY };
625
+ const badgeY = layout.centerY - badgeH / 2;
626
+ const labelY = badgeY + badgeH + 18;
627
+ const badgeText = endpointText(last.y);
628
+
629
+ chart.append("circle")
630
+ .attr("class", "line03-endpoint-dot")
631
+ .attr("data-series", series.group)
632
+ .attr("cx", pointX)
633
+ .attr("cy", pointY)
634
+ .attr("r", 7)
635
+ .attr("fill", "#f4f5ff")
636
+ .attr("stroke", brighten(color, 0.35))
637
+ .attr("stroke-width", 4);
638
+
639
+ chart.append("path")
640
+ .attr("class", "line03-endpoint-leader")
641
+ .attr("data-series", series.group)
642
+ .attr("d", `M${pointX + 7},${pointY} L${badgeX - 6},${badgeY + badgeH / 2}`)
643
+ .attr("fill", "none")
644
+ .attr("stroke", color)
645
+ .attr("stroke-width", 2)
646
+ .attr("opacity", 0.92);
647
+
648
+ const badge = chart.append("g")
649
+ .attr("class", "line03-endpoint-badge")
650
+ .attr("data-series", series.group)
651
+ .attr("data-relationship", "leader-connected-to-terminal-point")
652
+ .attr("data-alignment-basis", "leader endpoint from final data point to badge left edge");
653
+
654
+ badge.append("rect")
655
+ .attr("x", badgeX)
656
+ .attr("y", badgeY)
657
+ .attr("width", badgeW)
658
+ .attr("height", badgeH)
659
+ .attr("rx", 5)
660
+ .attr("fill", d3.color(color).copy({ opacity: 0.62 }).formatRgb())
661
+ .attr("stroke", brighten(color, 0.45))
662
+ .attr("stroke-width", 1.2);
663
+
664
+ badge.append("text")
665
+ .attr("x", badgeX + badgeW / 2)
666
+ .attr("y", badgeY + 29)
667
+ .attr("text-anchor", "middle")
668
+ .style("font-family", textFont)
669
+ .style("font-size", groupSeries.length > 2 ? "19px" : "25px")
670
+ .style("font-weight", 900)
671
+ .style("fill", "#f7f5ff")
672
+ .text(badgeText);
673
+
674
+ addWrappedText(badge, `${series.group} ${yLabel}`, badgeX + 1, labelY, labelReserve - 8, 14, groupSeries.length > 2 ? 2 : 3, {
675
+ fontSize: groupSeries.length > 2 ? "10px" : "12px",
676
+ fill: "#dff6ff"
677
+ });
678
+ }
679
+
680
+ groupSeries.forEach(addEndpointBadge);
681
+
682
+ const legendBand = contentLayer.append("g")
683
+ .attr("class", "line03-bottom-legend-band")
684
+ .attr("data-relationship", "chart-owned-bottom-legend")
685
+ .attr("data-alignment-basis", "independent bottom band below plot, not a narrative takeaway");
686
+
687
+ const legendY = 1588;
688
+ const legendHeight = groupSeries.length > 2 ? 150 : 106;
689
+ legendBand.append("rect")
690
+ .attr("x", poster.x + 40)
691
+ .attr("y", legendY)
692
+ .attr("width", poster.width - 80)
693
+ .attr("height", legendHeight)
694
+ .attr("rx", 12)
695
+ .attr("fill", "rgba(7,48,76,0.58)")
696
+ .attr("stroke", "rgba(108,224,234,0.22)")
697
+ .attr("stroke-width", 1.4);
698
+
699
+ legendBand.append("text")
700
+ .attr("x", poster.x + 92)
701
+ .attr("y", legendY + 42)
702
+ .style("font-family", textFont)
703
+ .style("font-size", "29px")
704
+ .style("font-weight", 900)
705
+ .style("fill", "#f6fbff")
706
+ .text("LEGEND");
707
+
708
+ legendBand.append("text")
709
+ .attr("x", poster.x + 92)
710
+ .attr("y", legendY + 72)
711
+ .style("font-family", textFont)
712
+ .style("font-size", "16px")
713
+ .style("font-weight", 500)
714
+ .style("fill", "#bfe4ef")
715
+ .text(`${yLabel}${yUnit ? ` by ${xField}` : ""}`);
716
+
717
+ legendBand.append("line")
718
+ .attr("x1", poster.x + 500)
719
+ .attr("x2", poster.x + 500)
720
+ .attr("y1", legendY + 24)
721
+ .attr("y2", legendY + legendHeight - 24)
722
+ .attr("stroke", "rgba(213,242,248,0.28)")
723
+ .attr("stroke-width", 1.2);
724
+
725
+ const legendItemX = poster.x + 532;
726
+ const legendItemY = legendY + 32;
727
+ groupSeries.slice(0, 7).forEach((series, i) => {
728
+ const color = getColor(series.group);
729
+ const row = groupSeries.length <= 2 ? i : i;
730
+ const x = legendItemX;
731
+ const y = legendItemY + row * (groupSeries.length <= 2 ? 30 : 24);
732
+ const item = legendBand.append("g")
733
+ .attr("class", "line03-data-legend-item")
734
+ .attr("data-series", series.group);
735
+ item.append("line")
736
+ .attr("x1", x)
737
+ .attr("x2", x + 48)
738
+ .attr("y1", y)
739
+ .attr("y2", y)
740
+ .attr("stroke", brighten(color, 0.42))
741
+ .attr("stroke-width", 7)
742
+ .attr("stroke-linecap", "round");
743
+ item.append("text")
744
+ .attr("x", x + 68)
745
+ .attr("y", y + 6)
746
+ .style("font-family", textFont)
747
+ .style("font-size", groupSeries.length > 2 ? "13px" : "15px")
748
+ .style("font-weight", 500)
749
+ .style("fill", "#edf7fb")
750
+ .text(groupSeries.length > 2 ? String(series.group) : `${series.group} ${yLabel}`);
751
+ });
752
+
753
+ return svg.node();
754
+ }