Ray1ee01 commited on
Commit
f340f17
·
verified ·
1 Parent(s): f193cdb

Upload folder using huggingface_hub

Browse files
Files changed (34) hide show
  1. modules/chart_engine/template/d3-js/line_graph/bump_chart_01.js +469 -0
  2. modules/chart_engine/template/d3-js/line_graph/bump_chart_02.js +617 -0
  3. modules/chart_engine/template/d3-js/line_graph/bump_chart_03.js +650 -0
  4. modules/chart_engine/template/d3-js/line_graph/bump_chart_04.js +662 -0
  5. modules/chart_engine/template/d3-js/line_graph/bump_chart_05.js +497 -0
  6. modules/chart_engine/template/d3-js/line_graph/bump_chart_06.js +476 -0
  7. modules/chart_engine/template/d3-js/line_graph/bump_chart_07.js +491 -0
  8. modules/chart_engine/template/d3-js/line_graph/bump_chart_09.js +415 -0
  9. modules/chart_engine/template/d3-js/line_graph/bump_chart_10.js +482 -0
  10. modules/chart_engine/template/d3-js/line_graph/bump_chart_11.js +541 -0
  11. modules/chart_engine/template/d3-js/line_graph/bump_chart_12.js +561 -0
  12. modules/chart_engine/template/d3-js/line_graph/bump_chart_13.js +439 -0
  13. modules/chart_engine/template/d3-js/line_graph/bump_chart_14.js +455 -0
  14. modules/chart_engine/template/d3-js/line_graph/line_chart_01.js +486 -0
  15. modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_01.js +655 -0
  16. modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_03.js +593 -0
  17. modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_06.js +550 -0
  18. modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_07.js +894 -0
  19. modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_08.js +343 -0
  20. modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_09.js +383 -0
  21. modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_10.js +563 -0
  22. modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_11.js +563 -0
  23. modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_12.js +466 -0
  24. modules/chart_engine/template/d3-js/line_graph/multiple_spline_graph_01.js +377 -0
  25. modules/chart_engine/template/d3-js/line_graph/multiple_spline_graph_02.js +377 -0
  26. modules/chart_engine/template/d3-js/line_graph/multiple_step_line_graph_01.js +563 -0
  27. modules/chart_engine/template/d3-js/line_graph/new_line_chart_01.js +344 -0
  28. modules/chart_engine/template/d3-js/line_graph/new_line_chart_02.js +332 -0
  29. modules/chart_engine/template/d3-js/line_graph/slope_chart_01.js +550 -0
  30. modules/chart_engine/template/d3-js/line_graph/slope_chart_02.js +551 -0
  31. modules/chart_engine/template/d3-js/line_graph/small_multiple_line_graph_01.js +564 -0
  32. modules/chart_engine/template/d3-js/line_graph/small_multiple_step_line_graph_01.js +288 -0
  33. modules/chart_engine/template/d3-js/line_graph/spline_graph_01.js +491 -0
  34. modules/chart_engine/template/d3-js/line_graph/step_line_graph_01.js +590 -0
modules/chart_engine/template/d3-js/line_graph/bump_chart_01.js ADDED
@@ -0,0 +1,469 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Bump Chart",
5
+ "chart_name": "bump_chart_01",
6
+ "is_composite": false,
7
+ "chart_for": "comparison",
8
+ "required_fields": ["x", "y", "group", "group2"],
9
+ "hierarchy":["group2"],
10
+ "required_fields_type": [["categorical"], ["numerical"], ["categorical"], ["categorical"]],
11
+ "required_fields_range": [
12
+ [2, 30],
13
+ [0, "inf"],
14
+ [2, 2],
15
+ [1, 10]
16
+ ],
17
+ "required_fields_icons": [],
18
+ "required_other_icons": [],
19
+ "required_fields_colors": ["group"],
20
+ "required_other_colors": [],
21
+ "supported_effects": ["spacing"],
22
+ "min_height": 400,
23
+ "min_width": 400,
24
+ "background": "no",
25
+ "icon_mark": "none",
26
+ "icon_label": "none",
27
+ "has_x_axis": "no",
28
+ "has_y_axis": "no"
29
+ }
30
+ REQUIREMENTS_END
31
+ */
32
+
33
+ // 水平分离组条形图 (左右独立排序,固定宽度条,新标签布局,带连接线)
34
+ function makeChart(containerSelector, data) {
35
+ const colorResolver = chartUtils.color.resolver(data);
36
+ // ---------- 1. 数据与配置 ----------
37
+ const jsonData = data;
38
+ const chartData = jsonData.data.data;
39
+ const variables = jsonData.variables || {};
40
+ const typography = jsonData.typography || {
41
+ label: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
42
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
43
+ };
44
+ const colors = jsonData.colors || {
45
+ text_color: "#FFFFFF", // 条内标签默认白色
46
+ field: {},
47
+ available_colors: ["#3366cc", "#dc3912", "#ff9900", "#109618", "#990099"]
48
+ };
49
+ const dataColumns = chartUtils.schema.columns(jsonData);
50
+
51
+ variables.has_spacing = variables.has_spacing !== undefined ? variables.has_spacing : true;
52
+
53
+ // 清空容器 & 创建文本测量上下文
54
+ d3.select(containerSelector).html("");
55
+ const canvas = document.createElement('canvas');
56
+ const context = canvas.getContext('2d');
57
+ function measureTextWidth(text, fontFamily, fontSize, fontWeight) {
58
+ return chartUtils.text.measure(null, text, { fontFamily: fontFamily, fontSize: fontSize, fontWeight: fontWeight }).width;
59
+ }
60
+
61
+ // ---------- 2. 尺寸与边距 ----------
62
+ const width = variables.width || 600;
63
+ const height = variables.height || 600;
64
+ const margin = { top: 90, right: 30, bottom: 60, left: 30 };
65
+ const innerWidth = width - margin.left - margin.right;
66
+ const innerHeight = height - margin.top - margin.bottom;
67
+
68
+ // 布局: 左右图表区域和中心间隙 (35% / 30% / 35%)
69
+ const leftChartWidthRatio = 0.35;
70
+ const rightChartWidthRatio = 0.35;
71
+ // const centerGapRatio = 0.3; // 中心间隙宽度由左右宽度确定
72
+ const leftChartWidth = innerWidth * leftChartWidthRatio;
73
+ const rightChartWidth = innerWidth * rightChartWidthRatio;
74
+ const rightChartStartX = innerWidth - rightChartWidth;
75
+
76
+ // ---------- 3. 字段提取 ----------
77
+ const xChannel = chartUtils.schema.channel(jsonData, "x"); // 类别字段 (如:国家)
78
+ const yChannel = chartUtils.schema.channel(jsonData, "y"); // 数值字段
79
+
80
+
81
+
82
+ const categoryField = xChannel.key;
83
+ const valueField = yChannel.key;
84
+ const conditionField = chartUtils.schema.channel(jsonData, "group").key;
85
+ const colorField = chartUtils.schema.channel(jsonData, "group2").key;
86
+ let valueUnit = yChannel.unit;
87
+
88
+ // ---------- 4. 数据处理与排序 ----------
89
+ const conditions = [...new Set(chartData.map(d => d[conditionField]))];
90
+ if (conditions.length !== 2) {
91
+ console.error(`条件字段 '${conditionField}' 必须有2个唯一值`);
92
+ return;
93
+ }
94
+ const conditionLeft = conditions[0];
95
+ const conditionRight = conditions[1];
96
+
97
+ const colorGroups = [...new Set(chartData.map(d => d[colorField]))];
98
+
99
+ // 预处理数据,聚合左右两侧的值
100
+ const processedData = {};
101
+ chartData.forEach(d => {
102
+ const cat = d[categoryField];
103
+ if (!processedData[cat]) {
104
+ processedData[cat] = { leftValue: null, rightValue: null, colorGroup: null };
105
+ }
106
+ if (d[conditionField] === conditionLeft) {
107
+ processedData[cat].leftValue = +d[valueField] || 0;
108
+ } else if (d[conditionField] === conditionRight) {
109
+ processedData[cat].rightValue = +d[valueField] || 0;
110
+ }
111
+ processedData[cat].colorGroup = d[colorField]; // 假设同一类别的颜色组一致
112
+ });
113
+
114
+ // 基础数据数组,包含所有处理后的类别
115
+ const baseData = Object.entries(processedData)
116
+ .map(([category, values]) => ({ category, ...values }));
117
+
118
+ // 分别过滤和排序左右两侧的数据
119
+ const sortedDataLeft = baseData
120
+ .filter(d => d.leftValue !== null)
121
+ .sort((a, b) => b.leftValue - a.leftValue);
122
+
123
+ const sortedDataRight = baseData
124
+ .filter(d => d.rightValue !== null)
125
+ .sort((a, b) => b.rightValue - a.rightValue);
126
+
127
+ const sortedCategoriesLeft = sortedDataLeft.map(d => d.category);
128
+ const sortedCategoriesRight = sortedDataRight.map(d => d.category);
129
+
130
+ // ---------- 5. 比例尺与常量定义 ----------
131
+ const maxCount = Math.max(sortedDataLeft.length, sortedDataRight.length); // Y轴基于最大条目数
132
+
133
+ // 统一Y轴比例尺
134
+ const unifiedYDomain = d3.range(maxCount);
135
+ const yScale = d3.scaleBand()
136
+ .domain(unifiedYDomain)
137
+ .range([0, innerHeight])
138
+ .padding(variables.has_spacing ? 0.2 : 0.15);
139
+
140
+ // 颜色比例尺
141
+ const colorScale = colorResolver.scale(colorGroups, { palette: "category10" });
142
+
143
+ // 条形尺寸与位置常量
144
+ const barHeight = yScale.bandwidth();
145
+ const barRadius = barHeight / 2;
146
+ const fixedBarPixelWidth = leftChartWidth * 0.98; // 固定条形宽度
147
+ const leftBarLeftEdge = 0; // 左侧条形左边缘
148
+ const leftBarRightEdge = leftBarLeftEdge + fixedBarPixelWidth; // 左侧条形右边缘
149
+ const rightBarRightEdge = innerWidth; // 右侧条形右边缘
150
+ const rightBarLeftEdge = rightBarRightEdge - fixedBarPixelWidth; // 右侧条形左边缘
151
+
152
+ // ---------- 6. SVG 容器与主分组 ----------
153
+ const svg = d3.select(containerSelector)
154
+ .append("svg")
155
+ .attr("width", "100%")
156
+ .attr("height", height)
157
+ .attr("viewBox", `0 0 ${width} ${height}`)
158
+ .attr("style", `max-width: 100%; height: auto; background-color: ${colorResolver.background({ fallback: 'transparent' }).value};`)
159
+ .attr("xmlns", "http://www.w3.org/2000/svg");
160
+
161
+ const g = svg.append("g")
162
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
163
+
164
+ // ---------- 7. 字体大小动态计算辅助函数 ----------
165
+ function calculateScaledFontSize(initialSize, scaleFactor, minSize = 6) {
166
+ return Math.max(minSize, initialSize * scaleFactor);
167
+ }
168
+
169
+ // ---------- 8. 标题与图例定位及绘制 (仅当有数据时) ----------
170
+ const initialHeaderFontSize = parseFloat(typography.label?.font_size || 16);
171
+ const headerFontFamily = typography.label?.font_family || "Arial";
172
+ const headerFontWeight = typography.label?.font_weight || "bold";
173
+ let finalHeaderFontSize = initialHeaderFontSize;
174
+ let headerY = -margin.top + initialHeaderFontSize; // 无数据时的默认标题Y位置
175
+
176
+ const initialLegendFontSize = parseFloat(typography.label?.font_size || 12);
177
+ const legendFontWeight = typography.label?.font_weight || "normal";
178
+ const legendFontFamily = typography.label?.font_family || "Arial";
179
+ const legendColor = colorResolver.text({ fallback: "#333333" }).value;
180
+ let legendSquareSize = 12;
181
+ const legendItemPadding = 8;
182
+ const legendColumnPadding = 20;
183
+ let finalLegendFontSize = initialLegendFontSize;
184
+ let legendY = headerY - finalLegendFontSize - 5; // 无数据时的默认图例Y位置
185
+ let legendStartX = 0;
186
+ const relevantColorGroups = [...new Set(baseData.map(d => d.colorGroup).filter(Boolean))]; // 有效的颜色分组
187
+
188
+ if (maxCount > 0 && barHeight > 0) { // 确保有数据且条形高度有效
189
+ // --- 标题尺寸与位置计算 ---
190
+ let leftHeaderWidth = measureTextWidth(conditionLeft, headerFontFamily, initialHeaderFontSize, headerFontWeight);
191
+ let rightHeaderWidth = measureTextWidth(conditionRight, headerFontFamily, initialHeaderFontSize, headerFontWeight);
192
+ let headerScaleFactor = 1;
193
+ if (leftHeaderWidth > leftChartWidth || rightHeaderWidth > rightChartWidth) {
194
+ const scaleLeft = leftHeaderWidth > leftChartWidth ? leftChartWidth / leftHeaderWidth : 1;
195
+ const scaleRight = rightHeaderWidth > rightChartWidth ? rightChartWidth / rightHeaderWidth : 1;
196
+ headerScaleFactor = Math.min(scaleLeft, scaleRight) * 0.98; // 统一缩小比例
197
+ }
198
+ finalHeaderFontSize = calculateScaledFontSize(initialHeaderFontSize, headerScaleFactor);
199
+ const headerPaddingBottom = 10;
200
+ headerY = yScale(0) - headerPaddingBottom; // 标题在第一个条形上方
201
+
202
+ // --- 图例尺寸与位置计算 ---
203
+ if (relevantColorGroups.length > 0) {
204
+ let totalLegendWidth = 0;
205
+ relevantColorGroups.forEach((cg) => {
206
+ const textWidth = measureTextWidth(cg, legendFontFamily, initialLegendFontSize, legendFontWeight);
207
+ totalLegendWidth += legendSquareSize + legendItemPadding + textWidth + legendColumnPadding;
208
+ });
209
+ totalLegendWidth -= legendColumnPadding;
210
+
211
+ let legendScaleFactor = 1;
212
+ if (totalLegendWidth > innerWidth) {
213
+ legendScaleFactor = (innerWidth / totalLegendWidth) * 0.98; // 统一缩小比例
214
+ }
215
+ finalLegendFontSize = calculateScaledFontSize(initialLegendFontSize, legendScaleFactor);
216
+ legendSquareSize *= legendScaleFactor;
217
+
218
+ // 重新计算最终图例宽度
219
+ let finalLegendWidth = 0;
220
+ relevantColorGroups.forEach((cg) => {
221
+ const textWidth = measureTextWidth(cg, legendFontFamily, finalLegendFontSize, legendFontWeight);
222
+ finalLegendWidth += legendSquareSize + legendItemPadding + textWidth + legendColumnPadding;
223
+ });
224
+ finalLegendWidth -= legendColumnPadding;
225
+
226
+ legendStartX = (innerWidth - finalLegendWidth) / 2; // 水平居中
227
+ const legendPaddingBottom = 5;
228
+ legendY = headerY - finalHeaderFontSize - legendPaddingBottom; // 图例在标题上方
229
+
230
+ chartUtils.legend.draw(g, relevantColorGroups, {
231
+ color: cg => colorScale(cg),
232
+ colorResolver,
233
+ colors,
234
+ }, {
235
+ x: legendStartX,
236
+ y: legendY,
237
+ maxWidth: innerWidth,
238
+ fitToWidth: true,
239
+ minFontSize: 8,
240
+ markerShape: "rect",
241
+ markerSize: legendSquareSize,
242
+ labelGap: legendItemPadding,
243
+ itemGap: legendColumnPadding,
244
+ fontSize: finalLegendFontSize,
245
+ fontFamily: legendFontFamily,
246
+ fontWeight: legendFontWeight,
247
+ textColor: legendColor,
248
+ });
249
+ } // end if relevantColorGroups
250
+
251
+ // --- 绘制标题 ---
252
+ g.append("text")
253
+ .attr("class", "header-left")
254
+ .attr("x", 0)
255
+ .attr("y", headerY)
256
+ .attr("text-anchor", "start")
257
+ .style("font-family", headerFontFamily)
258
+ .style("font-size", `${finalHeaderFontSize}px`)
259
+ .style("font-weight", headerFontWeight)
260
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
261
+ .text(conditionLeft);
262
+
263
+ g.append("text")
264
+ .attr("class", "header-right")
265
+ .attr("x", innerWidth)
266
+ .attr("y", headerY)
267
+ .attr("text-anchor", "end")
268
+ .style("font-family", headerFontFamily)
269
+ .style("font-size", `${finalHeaderFontSize}px`)
270
+ .style("font-weight", headerFontWeight)
271
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
272
+ .text(conditionRight);
273
+ } // end if maxCount > 0
274
+
275
+ // ---------- 9. 条形、连接线、标签 ----------
276
+
277
+ // 辅助函数:生成圆角矩形路径
278
+ function roundedRectPath(x, y, width, height, radius) {
279
+ const r = Math.min(radius, height / 2);
280
+ if (width <= 0 || height <= 0) return ""; // 避免无效路径
281
+ return `M${x + r},${y} H${x + width - r} A${r},${r} 0 0 1 ${x + width},${y + r} V${y + height - r} A${r},${r} 0 0 1 ${x + width - r},${y + height} H${x + r} A${r},${r} 0 0 1 ${x},${y + height - r} V${y + r} A${r},${r} 0 0 1 ${x + r},${y}`;
282
+ }
283
+
284
+ // --- 计算条内标签最终字体大小 ---
285
+ const labelPadding = 8;
286
+ const initialLabelFontSize = calculateScaledFontSize(parseFloat(typography.annotation?.font_size || 12), 1);
287
+ // 初始目标字体大小:基于条高,但不超过20px
288
+ const targetLabelFontSize = barHeight > 0 ? Math.min(20, Math.max(barHeight * 0.6, initialLabelFontSize)) : initialLabelFontSize;
289
+ const labelFontWeight = typography.annotation?.font_weight || "normal";
290
+ const labelFontFamily = typography.annotation?.font_family || "Arial";
291
+ const labelColor = colorResolver.text({ fallback: "#FFFFFF" }).value;
292
+
293
+ let finalLabelFontSize = targetLabelFontSize;
294
+ let maxLeftValueWidth = 0; // 用于左侧标签对齐
295
+ let maxRightValueWidth = 0; // 用于右侧标签对齐
296
+
297
+ if (maxCount > 0 && barHeight > 0) { // 仅当有数据和有效条高时计算缩放
298
+ let maxRequiredWidthLeft = 0;
299
+ sortedDataLeft.forEach(d => {
300
+ const valueText = chartUtils.format.fixed(d.leftValue, 0, { unit: valueUnit }).text;
301
+ const valueWidth = measureTextWidth(valueText, labelFontFamily, targetLabelFontSize, labelFontWeight);
302
+ const nameWidth = measureTextWidth(d.category, labelFontFamily, targetLabelFontSize, labelFontWeight);
303
+ maxRequiredWidthLeft = Math.max(maxRequiredWidthLeft, labelPadding + valueWidth + labelPadding + nameWidth + labelPadding);
304
+ });
305
+
306
+ let maxRequiredWidthRight = 0;
307
+ sortedDataRight.forEach(d => {
308
+ const valueText = chartUtils.format.fixed(d.rightValue, 0, { unit: valueUnit }).text;
309
+ const valueWidth = measureTextWidth(valueText, labelFontFamily, targetLabelFontSize, labelFontWeight);
310
+ const nameWidth = measureTextWidth(d.category, labelFontFamily, targetLabelFontSize, labelFontWeight);
311
+ maxRequiredWidthRight = Math.max(maxRequiredWidthRight, labelPadding + nameWidth + labelPadding + valueWidth + labelPadding);
312
+ });
313
+
314
+ let labelScaleFactor = 1;
315
+ if (maxRequiredWidthLeft > fixedBarPixelWidth) {
316
+ labelScaleFactor = Math.min(labelScaleFactor, (fixedBarPixelWidth / maxRequiredWidthLeft) * 0.98);
317
+ }
318
+ if (maxRequiredWidthRight > fixedBarPixelWidth) {
319
+ labelScaleFactor = Math.min(labelScaleFactor, (fixedBarPixelWidth / maxRequiredWidthRight) * 0.98);
320
+ }
321
+ finalLabelFontSize = calculateScaledFontSize(targetLabelFontSize, labelScaleFactor);
322
+
323
+ // 重新计算用于对齐的最大宽度
324
+ sortedDataLeft.forEach(d => {
325
+ const text = chartUtils.format.fixed(d.leftValue, 0, { unit: valueUnit }).text;
326
+ maxLeftValueWidth = Math.max(maxLeftValueWidth, measureTextWidth(text, labelFontFamily, finalLabelFontSize, labelFontWeight));
327
+ });
328
+ sortedDataRight.forEach(d => {
329
+ const text = chartUtils.format.fixed(d.rightValue, 0, { unit: valueUnit }).text;
330
+ maxRightValueWidth = Math.max(maxRightValueWidth, measureTextWidth(text, labelFontFamily, finalLabelFontSize, labelFontWeight));
331
+ });
332
+ }
333
+
334
+ // --- 绘制连接线 ---
335
+ if (maxCount > 0 && barHeight > 0) {
336
+ g.append("g")
337
+ .attr("class", "connectors")
338
+ .selectAll("path.connector")
339
+ .data(baseData.filter(d => d.leftValue !== null && d.rightValue !== null)) // 仅连接两侧都有数据的类别
340
+ .enter()
341
+ .append("path")
342
+ .attr("class", "connector")
343
+ .attr("d", d => {
344
+ const category = d.category;
345
+ const indexLeft = sortedCategoriesLeft.indexOf(category);
346
+ const indexRight = sortedCategoriesRight.indexOf(category);
347
+ if (indexLeft === -1 || indexRight === -1) return null; // 确保类别在两侧都存在
348
+
349
+ const yPosLeft = yScale(indexLeft);
350
+ const yPosRight = yScale(indexRight);
351
+ if (yPosLeft === undefined || yPosRight === undefined) return null;
352
+
353
+ // 连接点位于条形主体与圆角的切点
354
+ const leftConnectX = leftBarLeftEdge + fixedBarPixelWidth - barRadius;
355
+ const rightConnectX = rightBarLeftEdge + barRadius;
356
+ return `M${leftConnectX},${yPosLeft} L${rightConnectX},${yPosRight} L${rightConnectX},${yPosRight + barHeight} L${leftConnectX},${yPosLeft + barHeight} Z`;
357
+ })
358
+ .attr("fill", d => colorScale(d.colorGroup));
359
+ }
360
+
361
+ // --- 绘制左侧条形与标签 ---
362
+ if (maxCount > 0 && barHeight > 0) {
363
+ sortedDataLeft.forEach((d) => {
364
+ const category = d.category;
365
+ const index = sortedCategoriesLeft.indexOf(category);
366
+ const yPos = yScale(index);
367
+ if (yPos === undefined || index === -1) {
368
+ console.warn(`左侧类别 '${category}' 在Y轴上无位置`);
369
+ return;
370
+ }
371
+ const color = colorScale(d.colorGroup);
372
+ const leftVal = d.leftValue;
373
+
374
+ // 绘制条形
375
+ g.append("path")
376
+ .attr("class", "bar-left")
377
+ .attr("d", roundedRectPath(leftBarLeftEdge, yPos, fixedBarPixelWidth, barHeight, barRadius))
378
+ .attr("fill", color);
379
+
380
+ // 绘制标签 (如果字体够大)
381
+ if (finalLabelFontSize >= 6) {
382
+ const leftValueText = chartUtils.format.fixed(leftVal, 0, { unit: valueUnit }).text;
383
+ const leftNameText = category;
384
+
385
+ // 值标签 (左对齐)
386
+ g.append("text")
387
+ .attr("class", "label-left-value")
388
+ .attr("x", leftBarLeftEdge + labelPadding)
389
+ .attr("y", yPos + barHeight / 2)
390
+ .attr("dominant-baseline", "middle")
391
+ .attr("text-anchor", "start")
392
+ .style("font-family", labelFontFamily)
393
+ .style("font-size", `${finalLabelFontSize}px`)
394
+ .style("font-weight", labelFontWeight)
395
+ .style("fill", labelColor)
396
+ .text(leftValueText);
397
+
398
+ // 类别标签 (左对齐,在值标签右侧)
399
+ g.append("text")
400
+ .attr("class", "label-left-name")
401
+ .attr("x", leftBarLeftEdge + labelPadding + maxLeftValueWidth + labelPadding) // 基于最大值宽度定位
402
+ .attr("y", yPos + barHeight / 2)
403
+ .attr("dominant-baseline", "middle")
404
+ .attr("text-anchor", "start")
405
+ .style("font-family", labelFontFamily)
406
+ .style("font-size", `${finalLabelFontSize}px`)
407
+ .style("font-weight", labelFontWeight)
408
+ .style("fill", labelColor)
409
+ .text(leftNameText);
410
+ }
411
+ });
412
+ }
413
+
414
+ // --- 绘制右侧条形与标签 ---
415
+ if (maxCount > 0 && barHeight > 0) {
416
+ sortedDataRight.forEach((d) => {
417
+ const category = d.category;
418
+ const index = sortedCategoriesRight.indexOf(category);
419
+ const yPos = yScale(index);
420
+ if (yPos === undefined || index === -1) {
421
+ console.warn(`右侧类别 '${category}' 在Y轴上无位置`);
422
+ return;
423
+ }
424
+ const color = colorScale(d.colorGroup);
425
+ const rightVal = d.rightValue;
426
+
427
+ // 绘制条形
428
+ g.append("path")
429
+ .attr("class", "bar-right")
430
+ .attr("d", roundedRectPath(rightBarLeftEdge, yPos, fixedBarPixelWidth, barHeight, barRadius))
431
+ .attr("fill", color);
432
+
433
+ // 绘制标签 (如果字体够大)
434
+ if (finalLabelFontSize >= 6) {
435
+ const rightValueText = chartUtils.format.fixed(rightVal, 0, { unit: valueUnit }).text;
436
+ const rightNameText = category;
437
+
438
+ // 值标签 (右对齐)
439
+ g.append("text")
440
+ .attr("class", "label-right-value")
441
+ .attr("x", rightBarRightEdge - labelPadding)
442
+ .attr("y", yPos + barHeight / 2)
443
+ .attr("dominant-baseline", "middle")
444
+ .attr("text-anchor", "end")
445
+ .style("font-family", labelFontFamily)
446
+ .style("font-size", `${finalLabelFontSize}px`)
447
+ .style("font-weight", labelFontWeight)
448
+ .style("fill", labelColor)
449
+ .text(rightValueText);
450
+
451
+ // 类别标签 (右对齐,在值标签左侧)
452
+ g.append("text")
453
+ .attr("class", "label-right-name")
454
+ .attr("x", rightBarRightEdge - labelPadding - maxRightValueWidth - labelPadding) // 基于最大值宽度定位
455
+ .attr("y", yPos + barHeight / 2)
456
+ .attr("dominant-baseline", "middle")
457
+ .attr("text-anchor", "end")
458
+ .style("font-family", labelFontFamily)
459
+ .style("font-size", `${finalLabelFontSize}px`)
460
+ .style("font-weight", labelFontWeight)
461
+ .style("fill", labelColor)
462
+ .text(rightNameText);
463
+ }
464
+ });
465
+ }
466
+
467
+ // ---------- 11. 返回 SVG 节点 ----------
468
+ return svg.node();
469
+ }
modules/chart_engine/template/d3-js/line_graph/bump_chart_02.js ADDED
@@ -0,0 +1,617 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Bump Chart",
5
+ "chart_name": "bump_chart_02",
6
+ "is_composite": false,
7
+ "chart_for": "comparison",
8
+ "required_fields": ["x", "y", "group"],
9
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
10
+ "required_fields_range": [
11
+ [2, 12],
12
+ [0, "inf"],
13
+ [3, 10]
14
+ ],
15
+ "required_fields_icons": [],
16
+ "required_other_icons": [],
17
+ "required_fields_colors": ["group"],
18
+ "required_other_colors": [],
19
+ "supported_effects": ["spacing"],
20
+ "min_height": 400,
21
+ "min_width": 400,
22
+ "background": "dark",
23
+ "icon_mark": "none",
24
+ "icon_label": "none",
25
+ "has_x_axis": "no",
26
+ "has_y_axis": "no"
27
+ }
28
+ REQUIREMENTS_END
29
+ */
30
+
31
+ function makeChart(containerSelector, data) {
32
+ const jsonData = data || {};
33
+ const sourceRows = jsonData.data?.data || [];
34
+ const variables = jsonData.variables || {};
35
+ const typography = jsonData.typography || {};
36
+ const dataColumns = chartUtils.schema.columns(jsonData);
37
+ const colorResolver = chartUtils.color.resolver(jsonData);
38
+ const titles = jsonData.titles || {};
39
+ const metadata = jsonData.metadata || {};
40
+
41
+ d3.select(containerSelector).html("");
42
+
43
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
44
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
45
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
46
+ const yColumn = dataColumns[1] || {};
47
+ const yUnit = yColumn.unit === "none" ? "" : (yColumn.unit || "");
48
+
49
+ const width = Math.max(620, Number(variables.width) || 650);
50
+ const height = Math.max(900, Number(variables.height) || 975);
51
+ const bgTop = "#06111f";
52
+ const bgBottom = "#0b243d";
53
+ const panel = "#0d2238";
54
+ const panel2 = "#102b49";
55
+ const stroke = "#1f4268";
56
+ const text = "#eef7ff";
57
+ const muted = "#8fa8c5";
58
+ const grid = "#244b72";
59
+ const titleBlue = "#b8c9ff";
60
+ const font = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
61
+ const titleFont = typography.title?.font_family || font;
62
+
63
+ const svg = d3.select(containerSelector)
64
+ .append("svg")
65
+ .attr("width", "100%")
66
+ .attr("height", height)
67
+ .attr("viewBox", `0 0 ${width} ${height}`)
68
+ .attr("style", "max-width: 100%; height: auto;")
69
+ .attr("xmlns", "http://www.w3.org/2000/svg")
70
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
71
+ .attr("class", "bump-chart-02-reference");
72
+
73
+ const defs = svg.append("defs");
74
+ const bgGrad = defs.append("linearGradient")
75
+ .attr("id", "bump-chart-02-bg")
76
+ .attr("x1", "0%").attr("x2", "0%")
77
+ .attr("y1", "0%").attr("y2", "100%");
78
+ bgGrad.append("stop").attr("offset", "0%").attr("stop-color", bgTop);
79
+ bgGrad.append("stop").attr("offset", "100%").attr("stop-color", bgBottom);
80
+
81
+ const glow = defs.append("filter")
82
+ .attr("id", "bump-chart-02-glow")
83
+ .attr("x", "-25%").attr("y", "-25%")
84
+ .attr("width", "150%").attr("height", "150%");
85
+ glow.append("feDropShadow")
86
+ .attr("dx", 0)
87
+ .attr("dy", 7)
88
+ .attr("stdDeviation", 9)
89
+ .attr("flood-color", "#020812")
90
+ .attr("flood-opacity", 0.45);
91
+
92
+ svg.append("rect")
93
+ .attr("width", width)
94
+ .attr("height", height)
95
+ .attr("fill", "url(#bump-chart-02-bg)");
96
+
97
+ function humanize(value) {
98
+ return String(value || "")
99
+ .replace(/_/g, " ")
100
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
101
+ .replace(/\s+/g, " ")
102
+ .trim();
103
+ }
104
+
105
+ function formatValue(value, unit) {
106
+ const n = Number(value);
107
+ if (!Number.isFinite(n)) return "";
108
+ let out;
109
+ const abs = Math.abs(n);
110
+ if (abs >= 1000000) out = d3.format(".3~s")(n);
111
+ else if (abs >= 1000) out = d3.format(".3~s")(n);
112
+ else if (abs >= 100) out = d3.format(".0f")(n);
113
+ else if (abs >= 10) out = d3.format(".1f")(n).replace(/\.0$/, "");
114
+ else out = d3.format(".2f")(n).replace(/\.?0+$/, "");
115
+ if (unit === "%") return `${out}%`;
116
+ if (/^[$€£¥]/.test(unit || "")) return `${unit}${out}`;
117
+ return unit ? `${out}${unit}` : out;
118
+ }
119
+
120
+ function compactLabel(value, maxChars) {
121
+ const clean = humanize(value);
122
+ if (clean.length <= maxChars) return clean;
123
+ const words = clean.split(" ");
124
+ if (words.length > 1) {
125
+ const firstLast = `${words[0]} ${words[words.length - 1]}`;
126
+ if (firstLast.length <= maxChars) return firstLast;
127
+ }
128
+ return `${clean.slice(0, Math.max(4, maxChars - 2))}.`;
129
+ }
130
+
131
+ function wrapLines(value, maxChars, maxLines) {
132
+ const words = humanize(value).split(/\s+/).filter(Boolean);
133
+ const lines = [];
134
+ let cur = "";
135
+ words.forEach(word => {
136
+ const next = cur ? `${cur} ${word}` : word;
137
+ if (next.length <= maxChars || !cur) cur = next;
138
+ else {
139
+ lines.push(cur);
140
+ cur = word;
141
+ }
142
+ });
143
+ if (cur) lines.push(cur);
144
+ if (lines.length <= maxLines) return lines;
145
+ return [...lines.slice(0, maxLines - 1), compactLabel(lines.slice(maxLines - 1).join(" "), maxChars)];
146
+ }
147
+
148
+ function appendWrappedText(selection, lines, x, lineHeight) {
149
+ selection.selectAll("tspan")
150
+ .data(lines)
151
+ .enter()
152
+ .append("tspan")
153
+ .attr("x", x)
154
+ .attr("dy", (d, i) => i === 0 ? 0 : lineHeight)
155
+ .text(d => d);
156
+ }
157
+
158
+ function parseX(value) {
159
+ if (value instanceof Date) return value.getTime();
160
+ const numeric = Number(value);
161
+ if (Number.isFinite(numeric)) return numeric;
162
+ const parsed = Date.parse(value);
163
+ return Number.isFinite(parsed) ? parsed : String(value);
164
+ }
165
+
166
+ const rows = sourceRows
167
+ .map((d, i) => ({
168
+ raw: d,
169
+ sourceIndex: i,
170
+ xRaw: d[xField],
171
+ xKey: String(d[xField]),
172
+ xSort: parseX(d[xField]),
173
+ y: Number(d[yField]),
174
+ group: String(d[groupField])
175
+ }))
176
+ .filter(d => d.group && d.xKey && Number.isFinite(d.y));
177
+
178
+ if (!xField || !yField || !groupField || !rows.length) {
179
+ svg.append("text")
180
+ .attr("x", width / 2)
181
+ .attr("y", height / 2)
182
+ .attr("text-anchor", "middle")
183
+ .attr("fill", text)
184
+ .style("font-family", font)
185
+ .style("font-size", "18px")
186
+ .text("Missing bump chart data");
187
+ return svg.node();
188
+ }
189
+
190
+ const xValues = Array.from(new Map(rows
191
+ .sort((a, b) => d3.ascending(a.xSort, b.xSort))
192
+ .map(d => [d.xKey, d.xRaw])).entries())
193
+ .map(([key, raw]) => ({ key, raw, sort: parseX(raw) }));
194
+
195
+ const byGroup = d3.group(rows, d => d.group);
196
+ const groups = Array.from(byGroup.keys())
197
+ .sort((a, b) => {
198
+ const aLast = byGroup.get(a).find(d => d.xKey === xValues[xValues.length - 1].key)?.y ?? d3.mean(byGroup.get(a), d => d.y);
199
+ const bLast = byGroup.get(b).find(d => d.xKey === xValues[xValues.length - 1].key)?.y ?? d3.mean(byGroup.get(b), d => d.y);
200
+ return d3.descending(aLast, bLast) || d3.ascending(a, b);
201
+ })
202
+ .slice(0, 10);
203
+
204
+ const palette = ["#4f8cff", "#9b5de5", "#2dd4bf", "#f9c74f", "#f37272", "#43aa8b", "#f8961e", "#577590", "#90be6d", "#f15bb5"];
205
+ function colorFor(group, index) {
206
+ return palette[index % palette.length];
207
+ }
208
+
209
+ const outer = 24;
210
+ const cardX = 34;
211
+ const cardY = 28;
212
+ const cardW = width - cardX * 2;
213
+ const cardH = height - cardY - outer;
214
+ svg.append("rect")
215
+ .attr("class", "bump-main-panel")
216
+ .attr("x", cardX)
217
+ .attr("y", cardY)
218
+ .attr("width", cardW)
219
+ .attr("height", cardH)
220
+ .attr("rx", 24)
221
+ .attr("fill", "rgba(7, 22, 39, 0.02)")
222
+ .attr("stroke", "none");
223
+
224
+ const header = svg.append("g")
225
+ .attr("class", "bump-header")
226
+ .attr("data-reserved-slot", "header_title_art_slot")
227
+ .attr("data-slot-anchor", "top-left-header")
228
+ .attr("data-collision-rule", "avoid-year-chips-and-mini-panels")
229
+ .attr("transform", `translate(${cardX + 28},${cardY + 50})`);
230
+
231
+ const titleText = titles.main_title || metadata.title || `${humanize(groupField)} over ${humanize(xField)}`;
232
+ const headlineText = /short passes/i.test(titleText)
233
+ ? "for Short Passes Over Time"
234
+ : titleText.replace(/^Higher\s+/i, "");
235
+ const titleLines = wrapLines(headlineText, headlineText.length > 42 ? 24 : 30, 2);
236
+ header.append("text")
237
+ .attr("class", "title-kicker")
238
+ .attr("x", 0)
239
+ .attr("y", 0)
240
+ .attr("fill", "#ffffff")
241
+ .attr("opacity", 0.94)
242
+ .style("font-family", font)
243
+ .style("font-size", "13px")
244
+ .style("font-weight", 800)
245
+ .style("letter-spacing", "1.8px")
246
+ .text("HIGHER COMPLETION PERCENTAGES".slice(0, Math.max(14, Math.min(32, titleText.length))));
247
+
248
+ const title = header.append("text")
249
+ .attr("class", "main-title")
250
+ .attr("x", 0)
251
+ .attr("y", 28)
252
+ .style("font-family", titleFont)
253
+ .style("font-size", titleLines.length > 1 ? "34px" : "42px")
254
+ .style("font-weight", 900);
255
+ if (/short passes/i.test(headlineText)) {
256
+ title.append("tspan").attr("fill", "#ffffff").text("for ");
257
+ title.append("tspan").attr("fill", "#77a0ff").text("Short Passes");
258
+ title.append("tspan").attr("fill", "#ffffff").text(" Over Time");
259
+ } else {
260
+ title.selectAll("tspan")
261
+ .data(titleLines)
262
+ .enter()
263
+ .append("tspan")
264
+ .attr("x", 0)
265
+ .attr("dy", (d, i) => i === 0 ? 0 : 39)
266
+ .attr("fill", titleBlue)
267
+ .text(d => d);
268
+ }
269
+
270
+ header.append("line")
271
+ .attr("class", "title-divider")
272
+ .attr("x1", 0)
273
+ .attr("x2", 66)
274
+ .attr("y1", 74)
275
+ .attr("y2", 74)
276
+ .attr("stroke", "#69a0ff")
277
+ .attr("stroke-width", 3)
278
+ .attr("stroke-linecap", "round");
279
+
280
+ const subtitle = titles.sub_title || metadata.description || `Values by ${humanize(groupField)} over time.`;
281
+ const subtitleNode = header.append("text")
282
+ .attr("class", "subtitle")
283
+ .attr("x", 0)
284
+ .attr("y", 104)
285
+ .attr("fill", muted)
286
+ .style("font-family", font)
287
+ .style("font-size", "17px");
288
+ subtitleNode.selectAll("tspan")
289
+ .data(wrapLines(subtitle.replace(/\*\*/g, ""), 39, 2))
290
+ .enter()
291
+ .append("tspan")
292
+ .attr("x", 0)
293
+ .attr("dy", (d, i) => i === 0 ? 0 : 16)
294
+ .text(d => d);
295
+
296
+ const chartX = cardX + 164;
297
+ const chartW = cardW - 155;
298
+ const chipsY = cardY + 222;
299
+ const rowTop = cardY + 302;
300
+ const rowGap = 18;
301
+ const maxRows = Math.min(groups.length, 5);
302
+ const infoH = 86;
303
+ const available = height - rowTop - infoH - 74;
304
+ const rowH = Math.max(78, Math.min(150, (available - rowGap * (maxRows - 1)) / maxRows));
305
+ const rowW = chartW;
306
+ const xScale = d3.scalePoint()
307
+ .domain(xValues.map(d => d.key))
308
+ .range([50, rowW - 40])
309
+ .padding(0.16);
310
+
311
+ const chipLayer = svg.append("g")
312
+ .attr("class", "year-chip-row")
313
+ .attr("transform", `translate(${chartX},${chipsY})`);
314
+ chipLayer.append("rect")
315
+ .attr("class", "year-chip-rail")
316
+ .attr("x", 0)
317
+ .attr("y", -21)
318
+ .attr("width", rowW)
319
+ .attr("height", 42)
320
+ .attr("rx", 16)
321
+ .attr("fill", "#102844")
322
+ .attr("stroke", "#2b527c")
323
+ .attr("opacity", 0.9);
324
+ chipLayer.selectAll(".year-chip-divider")
325
+ .data(xValues.slice(1))
326
+ .enter()
327
+ .append("line")
328
+ .attr("class", "year-chip-divider")
329
+ .attr("x1", d => (xScale(d.key) + xScale(xValues[xValues.findIndex(v => v.key === d.key) - 1].key)) / 2)
330
+ .attr("x2", d => (xScale(d.key) + xScale(xValues[xValues.findIndex(v => v.key === d.key) - 1].key)) / 2)
331
+ .attr("y1", -15)
332
+ .attr("y2", 15)
333
+ .attr("stroke", "#27496e")
334
+ .attr("opacity", 0.4);
335
+ chipLayer.selectAll(".year-chip")
336
+ .data(xValues)
337
+ .enter()
338
+ .append("g")
339
+ .attr("class", "year-chip")
340
+ .attr("transform", d => `translate(${xScale(d.key)},0)`)
341
+ .each(function(d) {
342
+ const g = d3.select(this);
343
+ g.append("text")
344
+ .attr("text-anchor", "middle")
345
+ .attr("y", 4)
346
+ .attr("fill", "#ffffff")
347
+ .style("font-family", font)
348
+ .style("font-size", "14px")
349
+ .style("font-weight", 500)
350
+ .text(d.raw);
351
+ });
352
+
353
+ function rowDomainAndTicks(series) {
354
+ const vals = series.map(d => d.y).filter(Number.isFinite);
355
+ const extent = d3.extent(vals);
356
+ if (!extent || !Number.isFinite(extent[0]) || !Number.isFinite(extent[1])) {
357
+ return { domain: [0, 1], ticks: [1, 0.5, 0] };
358
+ }
359
+ if (extent[0] >= 0 && extent[1] <= 100) {
360
+ const step = 20;
361
+ let high = Math.ceil((extent[1] + step * 0.25) / step) * step;
362
+ high = Math.max(step * 2, high);
363
+ let low = high - step * 2;
364
+ while (extent[0] < low && low > 0) low -= step;
365
+ if (low === high) low = Math.max(0, high - step * 2);
366
+ const mid = (high + low) / 2;
367
+ return { domain: [low, high], ticks: [high, mid, low] };
368
+ }
369
+ const pad = Math.max(1, (extent[1] - extent[0]) * 0.18);
370
+ const scale = d3.scaleLinear().domain([extent[0] - pad, extent[1] + pad]).nice(3);
371
+ const ticks = scale.ticks(3).slice(0, 3).sort((a, b) => d3.descending(a, b));
372
+ return { domain: scale.domain(), ticks };
373
+ }
374
+
375
+ const rowLayer = svg.append("g").attr("class", "bump-row-layer");
376
+ if (maxRows > 1) {
377
+ svg.append("line")
378
+ .attr("class", "left-icon-rail")
379
+ .attr("x1", cardX + 42)
380
+ .attr("x2", cardX + 42)
381
+ .attr("y1", rowTop + rowH / 2)
382
+ .attr("y2", rowTop + (maxRows - 1) * (rowH + rowGap) + rowH / 2)
383
+ .attr("stroke", "#27496e")
384
+ .attr("stroke-width", 2)
385
+ .attr("opacity", 0.8);
386
+ }
387
+ const finishSlot = svg.append("g")
388
+ .attr("class", "left-finish-marker-slot")
389
+ .attr("data-reserved-slot", "left_finish_marker_slot")
390
+ .attr("data-asset-slot", "left_finish_marker_slot")
391
+ .attr("data-slot-anchor", "upper-left-before-first-row")
392
+ .attr("data-collision-rule", "avoid-header-subtitle-year-rail-and-first-row-label")
393
+ .attr("transform", `translate(${cardX + 3},${rowTop - 82})`);
394
+ finishSlot.append("path")
395
+ .attr("d", "M0,12 L104,0 L132,19 L104,38 L0,50 Z")
396
+ .attr("fill", "#c7435b")
397
+ .attr("opacity", 0.42)
398
+ .attr("stroke", "#ff8aa0")
399
+ .attr("stroke-width", 1);
400
+ finishSlot.append("rect")
401
+ .attr("x", 34)
402
+ .attr("y", 46)
403
+ .attr("width", 9)
404
+ .attr("height", 42)
405
+ .attr("rx", 3)
406
+ .attr("fill", "#d1a23b")
407
+ .attr("opacity", 0.38);
408
+
409
+ groups.slice(0, maxRows).forEach((group, index) => {
410
+ const color = colorFor(group, index);
411
+ const rowY = rowTop + index * (rowH + rowGap);
412
+ const rowGroup = rowLayer.append("g")
413
+ .attr("class", "series-row")
414
+ .attr("data-group", group)
415
+ .attr("transform", `translate(${chartX},${rowY})`);
416
+
417
+ rowGroup.append("rect")
418
+ .attr("class", "series-card")
419
+ .attr("x", 0)
420
+ .attr("y", 0)
421
+ .attr("width", rowW)
422
+ .attr("height", rowH)
423
+ .attr("rx", 13)
424
+ .attr("fill", panel)
425
+ .attr("stroke", stroke);
426
+
427
+ rowGroup.append("rect")
428
+ .attr("x", 0)
429
+ .attr("y", 0)
430
+ .attr("width", rowW)
431
+ .attr("height", rowH)
432
+ .attr("rx", 13)
433
+ .attr("fill", color)
434
+ .attr("opacity", 0.08);
435
+
436
+ const icon = svg.append("g")
437
+ .attr("class", "group-icon-slot")
438
+ .attr("data-reserved-slot", `group_icon_slot_${index + 1}`)
439
+ .attr("data-asset-slot", "group_icon_slot_N")
440
+ .attr("data-slot-anchor", "row-centerline-left")
441
+ .attr("data-collision-rule", "inside-left-label-column")
442
+ .attr("transform", `translate(${cardX + 42},${rowY + rowH / 2})`);
443
+ icon.append("circle")
444
+ .attr("r", 26)
445
+ .attr("fill", color)
446
+ .attr("opacity", 0.95);
447
+ icon.append("circle")
448
+ .attr("r", 11)
449
+ .attr("fill", "none")
450
+ .attr("stroke", "#ffffff")
451
+ .attr("stroke-width", 2)
452
+ .attr("opacity", 0.82);
453
+ svg.append("text")
454
+ .attr("class", "left-group-label")
455
+ .attr("x", cardX + 86)
456
+ .attr("y", rowY + rowH / 2 - 3)
457
+ .attr("fill", color)
458
+ .style("font-family", font)
459
+ .style("font-size", "14px")
460
+ .style("font-weight", 900)
461
+ .text(compactLabel(group, 12).toUpperCase());
462
+ const desc = svg.append("text")
463
+ .attr("class", "left-group-description")
464
+ .attr("x", cardX + 86)
465
+ .attr("y", rowY + rowH / 2 + 15)
466
+ .attr("fill", muted)
467
+ .style("font-family", font)
468
+ .style("font-size", "10px");
469
+ const descText = /pass/i.test(titleText)
470
+ ? `${humanize(group)} passes completion ${yUnit || ""}`
471
+ : `${humanize(group)} ${humanize(yField).toLowerCase()} ${yUnit || ""}`;
472
+ appendWrappedText(desc, wrapLines(descText, 15, 2), cardX + 86, 13);
473
+
474
+ const series = xValues.map(x => {
475
+ const found = byGroup.get(group).find(d => d.xKey === x.key);
476
+ return found || { xKey: x.key, xRaw: x.raw, y: null, group };
477
+ }).filter(d => Number.isFinite(d.y));
478
+ const rowGuide = rowDomainAndTicks(series);
479
+ const localY = d3.scaleLinear()
480
+ .domain(rowGuide.domain)
481
+ .range([rowH - 24, 26]);
482
+ const line = d3.line()
483
+ .defined(d => Number.isFinite(d.y))
484
+ .x(d => xScale(d.xKey))
485
+ .y(d => localY(d.y))
486
+ .curve(d3.curveMonotoneX);
487
+
488
+ rowGroup.selectAll(".v-grid")
489
+ .data(xValues)
490
+ .enter()
491
+ .append("line")
492
+ .attr("class", "v-grid")
493
+ .attr("x1", d => xScale(d.key))
494
+ .attr("x2", d => xScale(d.key))
495
+ .attr("y1", 18)
496
+ .attr("y2", rowH - 20)
497
+ .attr("stroke", grid)
498
+ .attr("opacity", 0.32)
499
+ .attr("stroke-dasharray", "3 5");
500
+
501
+ rowGroup.selectAll(".row-y-tick")
502
+ .data(rowGuide.ticks)
503
+ .enter()
504
+ .append("text")
505
+ .attr("class", "row-y-tick")
506
+ .attr("x", 10)
507
+ .attr("y", d => localY(d) + 4)
508
+ .attr("fill", "#6f88a7")
509
+ .style("font-family", font)
510
+ .style("font-size", "12px")
511
+ .text(d => formatValue(d, ""));
512
+
513
+ rowGroup.append("path")
514
+ .datum(series)
515
+ .attr("class", "series-line")
516
+ .attr("fill", "none")
517
+ .attr("stroke", color)
518
+ .attr("stroke-width", 4)
519
+ .attr("d", line);
520
+
521
+ rowGroup.selectAll(".series-point")
522
+ .data(series)
523
+ .enter()
524
+ .append("circle")
525
+ .attr("class", "series-point")
526
+ .attr("cx", d => xScale(d.xKey))
527
+ .attr("cy", d => localY(d.y))
528
+ .attr("r", 7.5)
529
+ .attr("fill", color)
530
+ .attr("stroke", "#dbeafe")
531
+ .attr("stroke-width", 2);
532
+
533
+ rowGroup.selectAll(".value-label")
534
+ .data(series)
535
+ .enter()
536
+ .append("text")
537
+ .attr("class", "value-label")
538
+ .attr("x", d => xScale(d.xKey))
539
+ .attr("y", d => localY(d.y) + (localY(d.y) < 36 ? 18 : -12))
540
+ .attr("text-anchor", "middle")
541
+ .attr("fill", "#cfe0f7")
542
+ .style("font-family", font)
543
+ .style("font-size", "12px")
544
+ .style("font-weight", 800)
545
+ .text(d => formatValue(d.y, ""));
546
+
547
+ });
548
+
549
+ const infoY = rowTop + maxRows * rowH + (maxRows - 1) * rowGap + 28;
550
+ const info = svg.append("g")
551
+ .attr("class", "bottom-info-card")
552
+ .attr("transform", `translate(${cardX + 14},${infoY})`);
553
+ info.append("rect")
554
+ .attr("width", cardW - 28)
555
+ .attr("height", infoH)
556
+ .attr("rx", 14)
557
+ .attr("fill", panel2)
558
+ .attr("stroke", stroke)
559
+ .attr("opacity", 0.94);
560
+ const infoIcon = info.append("g")
561
+ .attr("class", "bottom-info-icon-slot")
562
+ .attr("data-reserved-slot", "bottom_info_icon_slot")
563
+ .attr("data-asset-slot", "bottom_info_icon_slot")
564
+ .attr("data-slot-anchor", "bottom-card-left")
565
+ .attr("data-collision-rule", "avoid-bottom-card-text")
566
+ .attr("transform", "translate(54,43)");
567
+ infoIcon.append("circle")
568
+ .attr("r", 20)
569
+ .attr("fill", "#244b72");
570
+ infoIcon.append("path")
571
+ .attr("d", "M-8,7 L-8,-1 L-2,-1 L-2,-9 L4,-9 L4,7 M-14,7 L14,7")
572
+ .attr("fill", "none")
573
+ .attr("stroke", "#dbeafe")
574
+ .attr("stroke-width", 2)
575
+ .attr("stroke-linecap", "round");
576
+
577
+ const lastKey = xValues[xValues.length - 1].key;
578
+ const leader = groups.map(group => byGroup.get(group).find(d => d.xKey === lastKey))
579
+ .filter(Boolean)
580
+ .sort((a, b) => d3.descending(a.y, b.y))[0];
581
+ const topGroup = leader ? humanize(leader.group) : humanize(groups[0]);
582
+ const firstInfoLine = /pass/i.test(titleText)
583
+ ? `${topGroup} passes have the highest completion rates`
584
+ : `${topGroup} leads at ${leader ? formatValue(leader.y, yUnit) : ""} ${leader ? `in ${leader.xRaw}` : ""}.`;
585
+ const remainingGroups = groups.filter(g => g !== (leader && leader.group));
586
+ function trendPhrase(group) {
587
+ const vals = xValues.map(x => byGroup.get(group).find(d => d.xKey === x.key)?.y).filter(Number.isFinite);
588
+ const diffs = vals.slice(1).map((v, i) => v - vals[i]);
589
+ if (diffs.length && diffs.every(d => d >= -0.01)) return `${humanize(group)} shows steady improvement`;
590
+ if (diffs.some(d => d > 0) && diffs.some(d => d < 0)) return `${humanize(group)} remains more variable`;
591
+ if (diffs.length && diffs.every(d => d <= 0.01)) return `${humanize(group)} trends lower`;
592
+ return `${humanize(group)} remains lower`;
593
+ }
594
+ const secondInfoLine = remainingGroups.length >= 2
595
+ ? `while ${trendPhrase(remainingGroups[0])} and ${trendPhrase(remainingGroups[1])}.`
596
+ : `across ${xValues.length} time points.`;
597
+ const infoTitle = info.append("text")
598
+ .attr("class", "bottom-info-title")
599
+ .attr("x", 104)
600
+ .attr("y", 30)
601
+ .attr("fill", "#78a0ff")
602
+ .style("font-family", font)
603
+ .style("font-size", "14px")
604
+ .style("font-weight", 800);
605
+ appendWrappedText(infoTitle, wrapLines(firstInfoLine, 48, 1), 104, 17);
606
+ const infoSubtitle = info.append("text")
607
+ .attr("class", "bottom-info-subtitle")
608
+ .attr("x", 104)
609
+ .attr("y", 52)
610
+ .attr("fill", "#ffffff")
611
+ .style("font-family", font)
612
+ .style("font-size", "11px")
613
+ .style("font-weight", 400);
614
+ appendWrappedText(infoSubtitle, wrapLines(secondInfoLine, 58, 2), 104, 15);
615
+
616
+ return svg.node();
617
+ }
modules/chart_engine/template/d3-js/line_graph/bump_chart_03.js ADDED
@@ -0,0 +1,650 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Bump Chart",
5
+ "chart_name": "bump_chart_03",
6
+ "is_composite": false,
7
+ "chart_for": "comparison",
8
+ "required_fields": ["x", "y", "group"],
9
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
10
+ "required_fields_range": [
11
+ [2, 12],
12
+ [0, "inf"],
13
+ [3, 10]
14
+ ],
15
+ "required_fields_icons": [],
16
+ "required_other_icons": [],
17
+ "required_fields_colors": ["group"],
18
+ "required_other_colors": [],
19
+ "supported_effects": ["spacing"],
20
+ "min_height": 400,
21
+ "min_width": 400,
22
+ "background": "dark",
23
+ "icon_mark": "none",
24
+ "icon_label": "none",
25
+ "has_x_axis": "no",
26
+ "has_y_axis": "no"
27
+ }
28
+ REQUIREMENTS_END
29
+ */
30
+
31
+ function makeChart(containerSelector, data) {
32
+ const jsonData = data || {};
33
+ const sourceRows = jsonData.data?.data || [];
34
+ const variables = jsonData.variables || {};
35
+ const typography = jsonData.typography || {};
36
+ const dataColumns = chartUtils.schema.columns(jsonData);
37
+ const titles = jsonData.titles || {};
38
+ const metadata = jsonData.metadata || {};
39
+
40
+ d3.select(containerSelector).html("");
41
+
42
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
43
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
44
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
45
+ const yColumn = dataColumns[1] || {};
46
+ const yUnit = yColumn.unit === "none" ? "" : (yColumn.unit || "");
47
+
48
+ const width = Math.max(620, Number(variables.width) || 650);
49
+ const height = Math.max(900, Number(variables.height) || 975);
50
+ const font = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
51
+ const titleFont = typography.title?.font_family || font;
52
+
53
+ const colors = {
54
+ bgTop: "#061024",
55
+ bgMid: "#061a32",
56
+ bgBottom: "#081427",
57
+ panel: "#0a1a31",
58
+ panel2: "#0d223d",
59
+ stroke: "#24395f",
60
+ text: "#f5f7ff",
61
+ muted: "#b5c1d8",
62
+ rail: "#0e203d"
63
+ };
64
+
65
+ const svg = d3.select(containerSelector)
66
+ .append("svg")
67
+ .attr("width", "100%")
68
+ .attr("height", height)
69
+ .attr("viewBox", `0 0 ${width} ${height}`)
70
+ .attr("style", "max-width: 100%; height: auto;")
71
+ .attr("xmlns", "http://www.w3.org/2000/svg")
72
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
73
+ .attr("class", "bump-chart-03-reference");
74
+
75
+ const defs = svg.append("defs");
76
+ const bg = defs.append("radialGradient")
77
+ .attr("id", "bump-chart-03-bg")
78
+ .attr("cx", "50%")
79
+ .attr("cy", "30%")
80
+ .attr("r", "80%");
81
+ bg.append("stop").attr("offset", "0%").attr("stop-color", colors.bgMid);
82
+ bg.append("stop").attr("offset", "62%").attr("stop-color", colors.bgTop);
83
+ bg.append("stop").attr("offset", "100%").attr("stop-color", colors.bgBottom);
84
+
85
+ const glow = defs.append("filter")
86
+ .attr("id", "bump-chart-03-glow")
87
+ .attr("x", "-30%")
88
+ .attr("y", "-30%")
89
+ .attr("width", "160%")
90
+ .attr("height", "160%");
91
+ glow.append("feDropShadow")
92
+ .attr("dx", 0)
93
+ .attr("dy", 8)
94
+ .attr("stdDeviation", 10)
95
+ .attr("flood-color", "#020915")
96
+ .attr("flood-opacity", 0.48);
97
+
98
+ svg.append("rect")
99
+ .attr("width", width)
100
+ .attr("height", height)
101
+ .attr("fill", "url(#bump-chart-03-bg)");
102
+
103
+ function humanize(value) {
104
+ return String(value || "")
105
+ .replace(/_/g, " ")
106
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
107
+ .replace(/\s+/g, " ")
108
+ .trim();
109
+ }
110
+
111
+ function compactLabel(value, maxChars) {
112
+ const clean = humanize(value);
113
+ if (clean.length <= maxChars) return clean;
114
+ const words = clean.split(/\s+/);
115
+ if (words.length > 1) {
116
+ const firstLast = `${words[0]} ${words[words.length - 1]}`;
117
+ if (firstLast.length <= maxChars) return firstLast;
118
+ }
119
+ return `${clean.slice(0, Math.max(4, maxChars - 1))}.`;
120
+ }
121
+
122
+ function wrapLines(value, maxChars, maxLines) {
123
+ const words = humanize(value).split(/\s+/).filter(Boolean);
124
+ const lines = [];
125
+ let cur = "";
126
+ words.forEach(word => {
127
+ const next = cur ? `${cur} ${word}` : word;
128
+ if (next.length <= maxChars || !cur) cur = next;
129
+ else {
130
+ lines.push(cur);
131
+ cur = word;
132
+ }
133
+ });
134
+ if (cur) lines.push(cur);
135
+ if (lines.length <= maxLines) return lines;
136
+ return [...lines.slice(0, maxLines - 1), compactLabel(lines.slice(maxLines - 1).join(" "), maxChars)];
137
+ }
138
+
139
+ function appendTspans(textNode, lines, x, lineHeight) {
140
+ textNode.selectAll("tspan")
141
+ .data(lines)
142
+ .enter()
143
+ .append("tspan")
144
+ .attr("x", x)
145
+ .attr("dy", (d, i) => i === 0 ? 0 : lineHeight)
146
+ .text(d => d);
147
+ }
148
+
149
+ function parseX(value) {
150
+ if (value instanceof Date) return value.getTime();
151
+ const n = Number(value);
152
+ if (Number.isFinite(n)) return n;
153
+ const parsed = Date.parse(value);
154
+ return Number.isFinite(parsed) ? parsed : String(value);
155
+ }
156
+
157
+ function formatValue(value, unit) {
158
+ const n = Number(value);
159
+ if (!Number.isFinite(n)) return "";
160
+ let out;
161
+ const abs = Math.abs(n);
162
+ if (abs >= 1000000) out = d3.format(".3~s")(n);
163
+ else if (abs >= 1000) out = d3.format(".3~s")(n);
164
+ else if (abs >= 100) out = d3.format(".0f")(n);
165
+ else if (abs >= 10) out = d3.format(".1f")(n).replace(/\.0$/, "");
166
+ else out = d3.format(".2f")(n).replace(/\.?0+$/, "");
167
+ if (unit === "%") return `${out}%`;
168
+ if (/^[$€£¥]/.test(unit || "")) return `${unit}${out}`;
169
+ return unit ? `${out}${unit}` : out;
170
+ }
171
+
172
+ const rows = sourceRows.map((d, i) => ({
173
+ raw: d,
174
+ sourceIndex: i,
175
+ xRaw: d[xField],
176
+ xKey: String(d[xField]),
177
+ xSort: parseX(d[xField]),
178
+ y: Number(d[yField]),
179
+ group: String(d[groupField])
180
+ })).filter(d => d.group && d.xKey && Number.isFinite(d.y));
181
+
182
+ if (!xField || !yField || !groupField || !rows.length) {
183
+ svg.append("text")
184
+ .attr("x", width / 2)
185
+ .attr("y", height / 2)
186
+ .attr("text-anchor", "middle")
187
+ .attr("fill", colors.text)
188
+ .style("font-family", font)
189
+ .style("font-size", "18px")
190
+ .text("Missing bump chart data");
191
+ return svg.node();
192
+ }
193
+
194
+ const xValues = Array.from(new Map(rows
195
+ .sort((a, b) => d3.ascending(a.xSort, b.xSort))
196
+ .map(d => [d.xKey, d.xRaw])).entries())
197
+ .map(([key, raw]) => ({ key, raw, sort: parseX(raw) }));
198
+
199
+ const byGroup = d3.group(rows, d => d.group);
200
+ const lastKey = xValues[xValues.length - 1].key;
201
+ const groups = Array.from(byGroup.keys())
202
+ .sort((a, b) => {
203
+ const aLast = byGroup.get(a).find(d => d.xKey === lastKey)?.y ?? d3.mean(byGroup.get(a), d => d.y);
204
+ const bLast = byGroup.get(b).find(d => d.xKey === lastKey)?.y ?? d3.mean(byGroup.get(b), d => d.y);
205
+ return d3.descending(aLast, bLast) || d3.ascending(a, b);
206
+ });
207
+ const maxRows = Math.min(groups.length, 6);
208
+ const visibleGroups = groups.slice(0, maxRows);
209
+
210
+ const palette = ["#ffd22e", "#3c93ff", "#22d4d6", "#a56bff", "#ff6b6b", "#78c850"];
211
+ function colorFor(index) {
212
+ return palette[index % palette.length];
213
+ }
214
+
215
+ const titleText = titles.main_title || metadata.title || `${humanize(groupField)} over ${humanize(xField)}`;
216
+ const subtitleText = titles.sub_title || metadata.description || `Values by ${humanize(groupField)} over ${humanize(xField)}.`;
217
+
218
+ const headerX = 40;
219
+ const headerY = 56;
220
+ const header = svg.append("g")
221
+ .attr("class", "bump03-header")
222
+ .attr("data-reserved-slot", "header_title_art_slot")
223
+ .attr("data-slot-anchor", "top-left-header")
224
+ .attr("data-collision-rule", "avoid-header-illustration-year-rail-and-rows")
225
+ .attr("transform", `translate(${headerX},${headerY})`);
226
+
227
+ const kickerWords = humanize(titleText).split(/\s+/).slice(0, 3).join(" ").toUpperCase();
228
+ const kicker = header.append("g")
229
+ .attr("class", "header-kicker-pill");
230
+ kicker.append("rect")
231
+ .attr("x", 0)
232
+ .attr("y", 0)
233
+ .attr("width", 280)
234
+ .attr("height", 34)
235
+ .attr("rx", 17)
236
+ .attr("fill", "#273179")
237
+ .attr("stroke", "#6d61d7")
238
+ .attr("opacity", 0.95);
239
+ kicker.append("g")
240
+ .attr("data-asset-slot", "header_reaction_icon_slots")
241
+ .attr("data-slot-anchor", "inside-kicker-left")
242
+ .attr("data-collision-rule", "inside-kicker")
243
+ .attr("transform", "translate(22,17)")
244
+ .call(g => {
245
+ g.append("circle").attr("r", 9).attr("fill", "#ffffff").attr("opacity", 0.95);
246
+ g.append("path").attr("d", "M-5,0 H5 M0,-5 V5").attr("stroke", "#273179").attr("stroke-width", 2).attr("stroke-linecap", "round");
247
+ });
248
+ const kickerLabel = compactLabel(kickerWords, 28).toUpperCase();
249
+ kicker.append("text")
250
+ .attr("x", 48)
251
+ .attr("y", 22)
252
+ .attr("fill", "#f5f7ff")
253
+ .style("font-family", font)
254
+ .style("font-size", kickerLabel.length > 24 ? "10px" : "13px")
255
+ .style("font-weight", 800)
256
+ .style("letter-spacing", "0.6px")
257
+ .text(kickerLabel);
258
+
259
+ const titleMatch = humanize(titleText).match(/\bin\s+(.+)/i);
260
+ const headline = titleMatch ? `in ${titleMatch[1]}` : humanize(titleText);
261
+ let titleLines = wrapLines(headline, 22, 2);
262
+ if (/^in Educational Gaming Apps$/i.test(headline)) {
263
+ titleLines = ["in Educational", "Gaming Apps"];
264
+ }
265
+ const longHeadline = titleLines.some(line => line.length > 18);
266
+ const titleNode = header.append("text")
267
+ .attr("class", "main-title")
268
+ .attr("x", 0)
269
+ .attr("y", 98)
270
+ .style("font-family", titleFont)
271
+ .style("font-size", titleLines.length > 1 ? (longHeadline ? "34px" : "44px") : "46px")
272
+ .style("font-weight", 900);
273
+ titleLines.forEach((line, i) => {
274
+ const t = titleNode.append("tspan")
275
+ .attr("x", 0)
276
+ .attr("dy", i === 0 ? 0 : (longHeadline ? 39 : 49));
277
+ if (/gaming|apps|market|rates|ratios|volume|transport|climate/i.test(line) && !/^in educational$/i.test(line)) {
278
+ t.attr("fill", "#ffe051");
279
+ } else {
280
+ t.attr("fill", "#ffffff");
281
+ }
282
+ t.text(line);
283
+ });
284
+
285
+ const subY = 142 + Math.max(0, titleLines.length - 1) * 45;
286
+ const subtitle = header.append("text")
287
+ .attr("class", "subtitle")
288
+ .attr("x", 0)
289
+ .attr("y", subY)
290
+ .attr("fill", "#dce2ee")
291
+ .style("font-family", font)
292
+ .style("font-size", "16px");
293
+ appendTspans(subtitle, wrapLines(subtitleText.replace(/\*\*/g, ""), 46, 2), 0, 22);
294
+
295
+ const headerArt = svg.append("g")
296
+ .attr("class", "header-game-controller-slot")
297
+ .attr("data-reserved-slot", "header_game_controller_slot")
298
+ .attr("data-asset-slot", "header_game_controller_slot")
299
+ .attr("data-slot-anchor", "top-right-header")
300
+ .attr("data-collision-rule", "avoid-title-subtitle-and-year-rail")
301
+ .attr("transform", `translate(${longHeadline ? width - 82 : width - 132},${headerY + 112}) scale(${longHeadline ? 0.72 : 1})`);
302
+ headerArt.append("circle")
303
+ .attr("r", 70)
304
+ .attr("fill", "#244ed9")
305
+ .attr("stroke", "#4d71ff")
306
+ .attr("opacity", 0.82);
307
+ headerArt.append("path")
308
+ .attr("d", "M-42,18 C-40,-8 -24,-18 -2,-10 C16,-18 36,-10 42,18 C47,36 34,44 20,34 L10,25 H-10 L-20,34 C-34,44 -47,36 -42,18 Z")
309
+ .attr("fill", "#eef4ff")
310
+ .attr("opacity", 0.92);
311
+ headerArt.append("path")
312
+ .attr("d", "M-25,12 H-8 M-16,3 V21 M18,9 h1 M31,14 h1 M24,22 h1")
313
+ .attr("stroke", "#20335f")
314
+ .attr("stroke-width", 4)
315
+ .attr("stroke-linecap", "round");
316
+ [
317
+ [-62, -84, "#7e6dff"],
318
+ [-8, -92, "#ff5f7e"],
319
+ [58, -78, "#ffd34a"]
320
+ ].forEach((d, i) => {
321
+ const icon = headerArt.append("g")
322
+ .attr("class", "header-reaction-icon-slot")
323
+ .attr("data-asset-slot", "header_reaction_icon_slots")
324
+ .attr("data-slot-anchor", "around-header-illustration")
325
+ .attr("data-collision-rule", "avoid-title-and-canvas-edge")
326
+ .attr("transform", `translate(${d[0]},${d[1]})`);
327
+ icon.append("circle").attr("r", 17).attr("fill", d[2]).attr("opacity", 0.9);
328
+ icon.append("text")
329
+ .attr("text-anchor", "middle")
330
+ .attr("y", 6)
331
+ .attr("fill", "#ffffff")
332
+ .style("font-family", font)
333
+ .style("font-size", "18px")
334
+ .style("font-weight", 900)
335
+ .text(i === 0 ? "+" : i === 1 ? "" : "1");
336
+ if (i === 1) {
337
+ icon.append("path").attr("d", "M-8,-2 C-8,-10 2,-12 0,-2 C4,-12 14,-10 12,-2 C10,6 0,12 0,12 C0,12 -10,6 -8,-2 Z").attr("fill", "#ffffff").attr("opacity", 0.9);
338
+ }
339
+ });
340
+
341
+ const chartX = 40;
342
+ const chartRight = width - 40;
343
+ const yearY = 322;
344
+ const rowTop = 382;
345
+ const rowGap = maxRows >= 6 ? 11 : 13;
346
+ const bottomH = 72;
347
+ const bottomGap = 24;
348
+ const availableH = height - rowTop - bottomH - bottomGap - 30;
349
+ const rowH = Math.max(62, Math.min(116, (availableH - rowGap * (maxRows - 1)) / maxRows));
350
+ const rowW = chartRight - chartX;
351
+ const leftBlockW = Math.min(88, Math.max(68, rowW * 0.16));
352
+ const rightSlotW = Math.min(72, Math.max(52, rowW * 0.12));
353
+ const plotX = leftBlockW + 82;
354
+ const plotW = rowW - plotX - rightSlotW - 14;
355
+
356
+ const xScale = d3.scalePoint()
357
+ .domain(xValues.map(d => d.key))
358
+ .range([plotX + 24, plotX + plotW - 18])
359
+ .padding(0.05);
360
+
361
+ const yearRail = svg.append("g")
362
+ .attr("class", "year-rail")
363
+ .attr("transform", `translate(${chartX},${yearY})`);
364
+ yearRail.append("rect")
365
+ .attr("x", 0)
366
+ .attr("y", 0)
367
+ .attr("width", rowW)
368
+ .attr("height", 50)
369
+ .attr("rx", 8)
370
+ .attr("fill", "rgba(13, 30, 55, 0.86)")
371
+ .attr("stroke", colors.stroke);
372
+ yearRail.append("text")
373
+ .attr("x", 20)
374
+ .attr("y", 31)
375
+ .attr("fill", "#ffffff")
376
+ .style("font-family", font)
377
+ .style("font-size", "14px")
378
+ .style("font-weight", 900)
379
+ .text("YEAR");
380
+
381
+ const allPoints = visibleGroups.flatMap(g => byGroup.get(g));
382
+ const peak = allPoints.reduce((best, d) => !best || d.y > best.y ? d : best, null);
383
+ const peakKey = peak?.xKey || xValues[Math.floor(xValues.length / 2)].key;
384
+ yearRail.selectAll(".year-label")
385
+ .data(xValues)
386
+ .enter()
387
+ .append("text")
388
+ .attr("class", "year-label")
389
+ .attr("x", d => xScale(d.key))
390
+ .attr("y", 32)
391
+ .attr("text-anchor", "middle")
392
+ .attr("fill", d => d.key === peakKey ? "#ffe051" : "#ffffff")
393
+ .style("font-family", font)
394
+ .style("font-size", "16px")
395
+ .style("font-weight", d => d.key === peakKey ? 900 : 500)
396
+ .text(d => d.raw);
397
+
398
+ const rowLayer = svg.append("g").attr("class", "bump03-row-layer");
399
+ visibleGroups.forEach((group, index) => {
400
+ const color = colorFor(index);
401
+ const rowY = rowTop + index * (rowH + rowGap);
402
+ const row = rowLayer.append("g")
403
+ .attr("class", "series-row")
404
+ .attr("data-group", group)
405
+ .attr("transform", `translate(${chartX},${rowY})`);
406
+
407
+ row.append("rect")
408
+ .attr("class", "series-card")
409
+ .attr("x", 0)
410
+ .attr("y", 0)
411
+ .attr("width", rowW)
412
+ .attr("height", rowH)
413
+ .attr("rx", 10)
414
+ .attr("fill", colors.panel)
415
+ .attr("stroke", colors.stroke)
416
+ .attr("filter", index === 0 ? "url(#bump-chart-03-glow)" : null);
417
+ row.append("rect")
418
+ .attr("x", 0)
419
+ .attr("y", 0)
420
+ .attr("width", rowW)
421
+ .attr("height", rowH)
422
+ .attr("rx", 10)
423
+ .attr("fill", color)
424
+ .attr("opacity", 0.06);
425
+ row.append("rect")
426
+ .attr("class", "left-rank-block")
427
+ .attr("x", 0)
428
+ .attr("y", 0)
429
+ .attr("width", leftBlockW)
430
+ .attr("height", rowH)
431
+ .attr("rx", 10)
432
+ .attr("fill", color)
433
+ .attr("opacity", 0.16)
434
+ .attr("stroke", color)
435
+ .attr("stroke-width", 1.2);
436
+ row.append("rect")
437
+ .attr("x", leftBlockW - 10)
438
+ .attr("y", 0)
439
+ .attr("width", 12)
440
+ .attr("height", rowH)
441
+ .attr("fill", colors.panel)
442
+ .attr("opacity", 0.86);
443
+
444
+ const leftIcon = row.append("g")
445
+ .attr("class", "row-left-icon-slot")
446
+ .attr("data-reserved-slot", `row_left_icon_slot_${index + 1}`)
447
+ .attr("data-asset-slot", "row_left_icon_slot_N")
448
+ .attr("data-slot-anchor", "inside-left-rank-block")
449
+ .attr("data-collision-rule", "avoid-rank-label")
450
+ .attr("transform", `translate(${leftBlockW / 2},${Math.max(28, rowH * 0.32)})`);
451
+ leftIcon.append("circle").attr("r", Math.min(18, rowH * 0.18)).attr("fill", color).attr("opacity", 0.85);
452
+ leftIcon.append("path")
453
+ .attr("d", "M-8,3 H8 M0,-8 V8")
454
+ .attr("stroke", "#ffffff")
455
+ .attr("stroke-width", 2)
456
+ .attr("stroke-linecap", "round")
457
+ .attr("opacity", 0.9);
458
+
459
+ row.append("text")
460
+ .attr("class", "rank-label")
461
+ .attr("x", leftBlockW / 2)
462
+ .attr("y", rowH - 28)
463
+ .attr("text-anchor", "middle")
464
+ .attr("fill", color)
465
+ .style("font-family", font)
466
+ .style("font-size", rowH < 80 ? "18px" : "24px")
467
+ .style("font-weight", 900)
468
+ .text(compactLabel(group, 10).replace(/\s.*$/, ""));
469
+ row.append("text")
470
+ .attr("x", leftBlockW / 2)
471
+ .attr("y", rowH - 10)
472
+ .attr("text-anchor", "middle")
473
+ .attr("fill", "#ffffff")
474
+ .style("font-family", font)
475
+ .style("font-size", rowH < 80 ? "12px" : "15px")
476
+ .style("font-weight", 700)
477
+ .text(humanize(group).split(/\s+/).slice(-1)[0] || "Group");
478
+
479
+ row.append("rect")
480
+ .attr("class", "rank-number-pill")
481
+ .attr("x", leftBlockW + 10)
482
+ .attr("y", rowH / 2 - 15)
483
+ .attr("width", 30)
484
+ .attr("height", 30)
485
+ .attr("rx", 8)
486
+ .attr("fill", "#0c172b")
487
+ .attr("stroke", colors.stroke);
488
+ row.append("text")
489
+ .attr("x", leftBlockW + 25)
490
+ .attr("y", rowH / 2 + 7)
491
+ .attr("text-anchor", "middle")
492
+ .attr("fill", "#ffffff")
493
+ .style("font-family", font)
494
+ .style("font-size", "18px")
495
+ .style("font-weight", 800)
496
+ .text(index + 1);
497
+
498
+ const series = xValues.map(x => {
499
+ const found = byGroup.get(group).find(d => d.xKey === x.key);
500
+ return found || { xKey: x.key, xRaw: x.raw, y: null, group };
501
+ }).filter(d => Number.isFinite(d.y));
502
+ const extent = d3.extent(series, d => d.y);
503
+ const pad = Math.max(1, (extent[1] - extent[0]) * 0.28);
504
+ const yScale = d3.scaleLinear()
505
+ .domain([extent[0] - pad, extent[1] + pad])
506
+ .range([rowH * 0.68, rowH * 0.30]);
507
+ const line = d3.line()
508
+ .x(d => xScale(d.xKey))
509
+ .y(d => yScale(d.y))
510
+ .curve(d3.curveMonotoneX);
511
+
512
+ row.selectAll(".v-grid")
513
+ .data(xValues)
514
+ .enter()
515
+ .append("line")
516
+ .attr("class", "v-grid")
517
+ .attr("x1", d => xScale(d.key))
518
+ .attr("x2", d => xScale(d.key))
519
+ .attr("y1", rowH * 0.30)
520
+ .attr("y2", rowH * 0.72)
521
+ .attr("stroke", "#27405f")
522
+ .attr("stroke-width", 1)
523
+ .attr("opacity", 0.32);
524
+
525
+ const peakPoint = series.reduce((best, d) => !best || d.y > best.y ? d : best, null);
526
+ const peakX = peakPoint ? xScale(peakPoint.xKey) : xScale(peakKey);
527
+ row.append("line")
528
+ .attr("class", "peak-leader")
529
+ .attr("x1", peakX)
530
+ .attr("x2", peakX)
531
+ .attr("y1", rowH * 0.34)
532
+ .attr("y2", rowH * 0.77)
533
+ .attr("stroke", color)
534
+ .attr("stroke-width", 1.5)
535
+ .attr("stroke-dasharray", "5 6")
536
+ .attr("opacity", 0.92);
537
+
538
+ row.append("path")
539
+ .datum(series)
540
+ .attr("class", "series-line")
541
+ .attr("fill", "none")
542
+ .attr("stroke", color)
543
+ .attr("stroke-width", rowH < 80 ? 4 : 5)
544
+ .attr("stroke-linecap", "round")
545
+ .attr("stroke-linejoin", "round")
546
+ .attr("d", line);
547
+
548
+ row.selectAll(".point")
549
+ .data(series)
550
+ .enter()
551
+ .append("circle")
552
+ .attr("class", "point")
553
+ .attr("cx", d => xScale(d.xKey))
554
+ .attr("cy", d => yScale(d.y))
555
+ .attr("r", d => d === peakPoint ? (rowH < 80 ? 9 : 11) : (rowH < 80 ? 6 : 7))
556
+ .attr("fill", color)
557
+ .attr("stroke", "#ffffff")
558
+ .attr("stroke-width", d => d === peakPoint ? 3 : 2);
559
+
560
+ row.selectAll(".value-label")
561
+ .data(series)
562
+ .enter()
563
+ .append("text")
564
+ .attr("class", "value-label")
565
+ .attr("x", d => xScale(d.xKey))
566
+ .attr("y", d => yScale(d.y) - (d === peakPoint ? 16 : 12))
567
+ .attr("text-anchor", "middle")
568
+ .attr("fill", d => d === peakPoint ? color : "#ffffff")
569
+ .style("font-family", font)
570
+ .style("font-size", d => d === peakPoint ? (rowH < 80 ? "14px" : "18px") : (rowH < 80 ? "11px" : "15px"))
571
+ .style("font-weight", 900)
572
+ .text(d => formatValue(d.y, yUnit));
573
+
574
+ const rightIcon = row.append("g")
575
+ .attr("class", "row-right-icon-slot")
576
+ .attr("data-reserved-slot", `row_right_icon_slot_${index + 1}`)
577
+ .attr("data-asset-slot", "row_right_icon_slot_N")
578
+ .attr("data-slot-anchor", "right-row-centerline")
579
+ .attr("data-collision-rule", "avoid-value-labels-and-row-card-edge")
580
+ .attr("transform", `translate(${rowW - rightSlotW / 2},${rowH / 2})`);
581
+ rightIcon.append("circle")
582
+ .attr("r", Math.min(34, rowH * 0.32))
583
+ .attr("fill", "#0d223e")
584
+ .attr("stroke", color)
585
+ .attr("opacity", 0.94);
586
+ rightIcon.append("path")
587
+ .attr("d", "M-12,10 L-12,2 L-4,2 L-4,-7 L4,-7 L4,10 M-18,10 L18,10")
588
+ .attr("fill", "none")
589
+ .attr("stroke", color)
590
+ .attr("stroke-width", 3)
591
+ .attr("stroke-linecap", "round");
592
+ });
593
+
594
+ const bottomY = rowTop + maxRows * rowH + (maxRows - 1) * rowGap + bottomGap;
595
+ const bottom = svg.append("g")
596
+ .attr("class", "bottom-peak-card")
597
+ .attr("transform", `translate(${chartX},${bottomY})`);
598
+ bottom.append("rect")
599
+ .attr("width", rowW)
600
+ .attr("height", bottomH)
601
+ .attr("rx", 10)
602
+ .attr("fill", "rgba(10, 26, 49, 0.9)")
603
+ .attr("stroke", colors.stroke);
604
+ const bottomIcon = bottom.append("g")
605
+ .attr("class", "bottom-peak-icon-slot")
606
+ .attr("data-reserved-slot", "bottom_peak_icon_slot")
607
+ .attr("data-asset-slot", "bottom_peak_icon_slot")
608
+ .attr("data-slot-anchor", "bottom-card-left")
609
+ .attr("data-collision-rule", "avoid-bottom-card-text")
610
+ .attr("transform", "translate(46,36)");
611
+ bottomIcon.append("circle")
612
+ .attr("r", 24)
613
+ .attr("fill", "#111f39")
614
+ .attr("stroke", "#ffd22e");
615
+ bottomIcon.append("path")
616
+ .attr("d", "M-7,8 H7 M-4,8 V0 C-10,-6 -5,-13 0,-13 C5,-13 10,-6 4,0 V8")
617
+ .attr("fill", "none")
618
+ .attr("stroke", "#ffd22e")
619
+ .attr("stroke-width", 2.2)
620
+ .attr("stroke-linecap", "round")
621
+ .attr("stroke-linejoin", "round");
622
+
623
+ const peakYear = peak?.xRaw || xValues[Math.floor(xValues.length / 2)].raw;
624
+ const subjectText = /student engagement/i.test(titleText)
625
+ ? "student engagement"
626
+ : humanize(yField).toLowerCase();
627
+ const groupNoun = humanize(groupField).toLowerCase();
628
+ const pluralGroupNoun = /s$/.test(groupNoun) ? groupNoun : `${groupNoun}s`;
629
+ const bottomText = `${peakYear} was the peak year for ${subjectText} across ${pluralGroupNoun}.`;
630
+ const bottomTextNode = bottom.append("text")
631
+ .attr("x", 92)
632
+ .attr("y", 30)
633
+ .attr("fill", "#ffffff")
634
+ .style("font-family", font)
635
+ .style("font-size", "16px")
636
+ .style("font-weight", 500);
637
+ if (String(peakYear).length && bottomText.includes("peak year")) {
638
+ bottomTextNode.append("tspan").attr("x", 92).text(`${peakYear} was the `);
639
+ bottomTextNode.append("tspan").attr("fill", "#ffe051").attr("font-weight", 900).text("peak year");
640
+ bottomTextNode.append("tspan").text(` for ${subjectText}`);
641
+ bottomTextNode.append("tspan")
642
+ .attr("x", 92)
643
+ .attr("dy", 22)
644
+ .text(`across ${pluralGroupNoun}.`);
645
+ } else {
646
+ appendTspans(bottomTextNode, wrapLines(bottomText, 54, 2), 92, 22);
647
+ }
648
+
649
+ return svg.node();
650
+ }
modules/chart_engine/template/d3-js/line_graph/bump_chart_04.js ADDED
@@ -0,0 +1,662 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Bump Chart",
5
+ "chart_name": "bump_chart_04",
6
+ "is_composite": false,
7
+ "chart_for": "comparison",
8
+ "required_fields": ["x", "y", "group"],
9
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
10
+ "required_fields_range": [
11
+ [2, 12],
12
+ [0, "inf"],
13
+ [4, 10]
14
+ ],
15
+ "required_fields_icons": [],
16
+ "required_other_icons": [],
17
+ "required_fields_colors": ["group"],
18
+ "required_other_colors": [],
19
+ "supported_effects": ["spacing"],
20
+ "min_height": 400,
21
+ "min_width": 400,
22
+ "background": "dark",
23
+ "icon_mark": "none",
24
+ "icon_label": "none",
25
+ "has_x_axis": "no",
26
+ "has_y_axis": "no"
27
+ }
28
+ REQUIREMENTS_END
29
+ */
30
+
31
+ function makeChart(containerSelector, data) {
32
+ const jsonData = data || {};
33
+ const sourceRows = jsonData.data?.data || [];
34
+ const variables = jsonData.variables || {};
35
+ const typography = jsonData.typography || {};
36
+ const titles = jsonData.titles || {};
37
+ const metadata = jsonData.metadata || {};
38
+ const dataColumns = chartUtils.schema.columns(jsonData);
39
+
40
+ d3.select(containerSelector).html("");
41
+
42
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
43
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
44
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
45
+ const yColumn = dataColumns[1] || {};
46
+ const yUnit = yColumn.unit === "none" ? "" : (yColumn.unit || "");
47
+
48
+ const width = Math.max(620, Number(variables.width) || 650);
49
+ const height = Math.max(900, Number(variables.height) || 975);
50
+ const font = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
51
+ const titleFont = typography.title?.font_family || font;
52
+
53
+ const colors = {
54
+ bg0: "#061022",
55
+ bg1: "#08203a",
56
+ bg2: "#020914",
57
+ panel: "#07192e",
58
+ panelSoft: "#0d2943",
59
+ stroke: "#2d4868",
60
+ text: "#f4f8ff",
61
+ muted: "#d4deeb",
62
+ rail: "#112640",
63
+ railStroke: "#517190",
64
+ teal: "#35dbc9",
65
+ green: "#7ee66e",
66
+ cyan: "#27c7e9"
67
+ };
68
+ const palette = ["#38d8c5", "#a65bed", "#ffbd2e", "#27ceb1", "#5aa7ff", "#f26d7d", "#86df6d"];
69
+
70
+ const svg = d3.select(containerSelector)
71
+ .append("svg")
72
+ .attr("width", "100%")
73
+ .attr("height", height)
74
+ .attr("viewBox", `0 0 ${width} ${height}`)
75
+ .attr("style", "max-width: 100%; height: auto;")
76
+ .attr("xmlns", "http://www.w3.org/2000/svg")
77
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
78
+ .attr("class", "bump-chart-04-reference-layout");
79
+
80
+ const defs = svg.append("defs");
81
+ const bg = defs.append("radialGradient")
82
+ .attr("id", "bump04-bg")
83
+ .attr("cx", "50%")
84
+ .attr("cy", "31%")
85
+ .attr("r", "82%");
86
+ bg.append("stop").attr("offset", "0%").attr("stop-color", colors.bg1);
87
+ bg.append("stop").attr("offset", "58%").attr("stop-color", colors.bg0);
88
+ bg.append("stop").attr("offset", "100%").attr("stop-color", colors.bg2);
89
+
90
+ const shadow = defs.append("filter")
91
+ .attr("id", "bump04-card-shadow")
92
+ .attr("x", "-20%")
93
+ .attr("y", "-20%")
94
+ .attr("width", "140%")
95
+ .attr("height", "140%");
96
+ shadow.append("feDropShadow")
97
+ .attr("dx", 0)
98
+ .attr("dy", 8)
99
+ .attr("stdDeviation", 12)
100
+ .attr("flood-color", "#000713")
101
+ .attr("flood-opacity", 0.34);
102
+
103
+ const glow = defs.append("filter")
104
+ .attr("id", "bump04-soft-glow")
105
+ .attr("x", "-40%")
106
+ .attr("y", "-40%")
107
+ .attr("width", "180%")
108
+ .attr("height", "180%");
109
+ glow.append("feGaussianBlur").attr("stdDeviation", 3).attr("result", "blur");
110
+ const merge = glow.append("feMerge");
111
+ merge.append("feMergeNode").attr("in", "blur");
112
+ merge.append("feMergeNode").attr("in", "SourceGraphic");
113
+
114
+ svg.append("rect")
115
+ .attr("width", width)
116
+ .attr("height", height)
117
+ .attr("fill", "url(#bump04-bg)");
118
+
119
+ function humanize(value) {
120
+ return String(value ?? "")
121
+ .replace(/_/g, " ")
122
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
123
+ .replace(/\s+/g, " ")
124
+ .trim();
125
+ }
126
+
127
+ function compactLabel(value, maxChars) {
128
+ const clean = humanize(value);
129
+ if (clean.length <= maxChars) return clean;
130
+ const words = clean.split(/\s+/);
131
+ if (words.length > 1) {
132
+ const initials = words.map(w => w[0]).join("").toUpperCase();
133
+ if (initials.length >= 2 && initials.length <= maxChars) return initials;
134
+ const firstLast = `${words[0]} ${words[words.length - 1]}`;
135
+ if (firstLast.length <= maxChars) return firstLast;
136
+ }
137
+ return `${clean.slice(0, Math.max(3, maxChars - 1))}.`;
138
+ }
139
+
140
+ function initials(value) {
141
+ const words = humanize(value).split(/\s+/).filter(Boolean);
142
+ return words.slice(0, 2).map(w => w[0]).join("").toUpperCase() || "?";
143
+ }
144
+
145
+ function wrapWords(value, maxChars, maxLines) {
146
+ const words = humanize(value).split(/\s+/).filter(Boolean);
147
+ const lines = [];
148
+ let cur = "";
149
+ words.forEach(word => {
150
+ const next = cur ? `${cur} ${word}` : word;
151
+ if (next.length <= maxChars || !cur) {
152
+ cur = next;
153
+ } else {
154
+ lines.push(cur);
155
+ cur = word;
156
+ }
157
+ });
158
+ if (cur) lines.push(cur);
159
+ if (lines.length <= maxLines) return lines;
160
+ const kept = lines.slice(0, maxLines - 1);
161
+ kept.push(compactLabel(lines.slice(maxLines - 1).join(" "), maxChars));
162
+ return kept;
163
+ }
164
+
165
+ function appendLines(node, lines, x, y, lineHeight) {
166
+ lines.forEach((line, i) => {
167
+ node.append("tspan")
168
+ .attr("x", x)
169
+ .attr("y", y + i * lineHeight)
170
+ .text(line);
171
+ });
172
+ }
173
+
174
+ function parseX(value, fallback) {
175
+ if (value instanceof Date) return value.getTime();
176
+ const asNumber = Number(value);
177
+ if (Number.isFinite(asNumber)) return asNumber;
178
+ const parsed = Date.parse(value);
179
+ if (Number.isFinite(parsed)) return parsed;
180
+ return fallback;
181
+ }
182
+
183
+ function formatX(value) {
184
+ if (value instanceof Date) return String(value.getFullYear());
185
+ const clean = humanize(value);
186
+ return clean.replace(/\.0$/, "");
187
+ }
188
+
189
+ function formatValue(value, unit, options = {}) {
190
+ const n = Number(value);
191
+ if (!Number.isFinite(n)) return "";
192
+ const includeUnit = options.includeUnit === true;
193
+ let out;
194
+ const abs = Math.abs(n);
195
+ if (abs >= 1000) out = d3.format(".3~s")(n);
196
+ else if (abs >= 100) out = d3.format(".1f")(n).replace(/\.0$/, "");
197
+ else if (abs >= 10) out = d3.format(".1f")(n).replace(/\.0$/, "");
198
+ else out = d3.format(".2f")(n).replace(/\.?0+$/, "");
199
+ if (!includeUnit || unit === "%") return `${out}`;
200
+ if (/^[$€£¥]/.test(unit || "")) return `${unit}${out}`;
201
+ if ((unit || "").length <= 2) return `${out}${unit}`;
202
+ return `${out}`;
203
+ }
204
+
205
+ const rows = sourceRows.map((d, i) => ({
206
+ raw: d,
207
+ sourceIndex: i,
208
+ xRaw: d[xField],
209
+ xKey: String(d[xField]),
210
+ xSort: parseX(d[xField], i),
211
+ y: Number(d[yField]),
212
+ group: String(d[groupField] ?? "")
213
+ })).filter(d => d.group && d.xKey && Number.isFinite(d.y));
214
+
215
+ if (!xField || !yField || !groupField || !rows.length) {
216
+ svg.append("text")
217
+ .attr("x", width / 2)
218
+ .attr("y", height / 2)
219
+ .attr("text-anchor", "middle")
220
+ .attr("fill", colors.text)
221
+ .style("font-family", font)
222
+ .style("font-size", "18px")
223
+ .text("Missing bump chart data");
224
+ return svg.node();
225
+ }
226
+
227
+ const xValues = Array.from(new Map(rows
228
+ .sort((a, b) => d3.ascending(a.xSort, b.xSort))
229
+ .map(d => [d.xKey, { key: d.xKey, raw: d.xRaw, sort: d.xSort }])).values());
230
+ const xIndex = new Map(xValues.map((d, i) => [d.key, i]));
231
+ const xSortByKey = new Map(xValues.map(d => [d.key, d.sort]));
232
+ const byGroup = d3.group(rows, d => d.group);
233
+ const lastKey = xValues[xValues.length - 1]?.key;
234
+ const groups = Array.from(byGroup.keys()).sort((a, b) => {
235
+ const aRows = byGroup.get(a) || [];
236
+ const bRows = byGroup.get(b) || [];
237
+ const aLast = aRows.find(d => d.xKey === lastKey)?.y ?? d3.mean(aRows, d => d.y);
238
+ const bLast = bRows.find(d => d.xKey === lastKey)?.y ?? d3.mean(bRows, d => d.y);
239
+ return d3.descending(aLast, bLast) || d3.ascending(a, b);
240
+ });
241
+ const visibleGroups = groups.slice(0, Math.min(6, groups.length));
242
+
243
+ function colorFor(index) {
244
+ return palette[index % palette.length];
245
+ }
246
+
247
+ function buildTitleParts(title) {
248
+ const raw = humanize(title).replace(/\*\*/g, "");
249
+ const acrossMatch = raw.match(/^(.*?)\s+Across\s+(.+)$/i);
250
+ if (acrossMatch) {
251
+ const before = acrossMatch[1].trim();
252
+ const words = before.split(/\s+/);
253
+ return {
254
+ line1: words.slice(0, 2).join(" ").toUpperCase(),
255
+ highlightLines: wrapWords(words.slice(2).join(" "), 24, 2).map(d => d.toUpperCase()),
256
+ pill: `ACROSS ${acrossMatch[2]}`.toUpperCase()
257
+ };
258
+ }
259
+ const words = raw.split(/\s+/);
260
+ return {
261
+ line1: words.slice(0, Math.min(3, words.length)).join(" ").toUpperCase(),
262
+ highlightLines: wrapWords(words.slice(Math.min(3, words.length)).join(" "), 22, 2).map(d => d.toUpperCase()).filter(Boolean),
263
+ pill: humanize(groupField).toUpperCase()
264
+ };
265
+ }
266
+
267
+ const titleText = titles.main_title || metadata.title || `${humanize(yField)} by ${humanize(groupField)}`;
268
+ const subtitleText = titles.sub_title || metadata.description || `${humanize(yField)} over ${humanize(xField)}.`;
269
+ const titleParts = buildTitleParts(titleText);
270
+
271
+ const headerArt = svg.append("g")
272
+ .attr("class", "header-topic-art-slot")
273
+ .attr("data-reserved-slot", "header_topic_art_slot")
274
+ .attr("data-asset-slot", "header_topic_art_slot")
275
+ .attr("data-slot-anchor", "left-header-safe-area")
276
+ .attr("data-asset-source-policy", "image2_asset")
277
+ .attr("data-collision-rule", "avoid-title-subtitle-year-rail-and-canvas-edge")
278
+ .attr("transform", "translate(36,46)");
279
+ headerArt.append("rect")
280
+ .attr("width", 150)
281
+ .attr("height", 156)
282
+ .attr("rx", 18)
283
+ .attr("fill", "#0e2a43")
284
+ .attr("opacity", 0.26)
285
+ .attr("stroke", "#2fd6c7")
286
+ .attr("stroke-dasharray", "7 8")
287
+ .attr("stroke-width", 1.4);
288
+ headerArt.selectAll(".slot-orbit")
289
+ .data([[38, 42, 24], [92, 68, 34], [55, 112, 18], [116, 118, 22]])
290
+ .enter()
291
+ .append("circle")
292
+ .attr("class", "slot-orbit")
293
+ .attr("cx", d => d[0])
294
+ .attr("cy", d => d[1])
295
+ .attr("r", d => d[2])
296
+ .attr("fill", "none")
297
+ .attr("stroke", "#35dbc9")
298
+ .attr("opacity", 0.42)
299
+ .attr("stroke-width", 3);
300
+
301
+ [[104, 24], [74, 32], [24, 76]].forEach(([x, y], i) => {
302
+ const star = svg.append("g")
303
+ .attr("class", "header-spark")
304
+ .attr("transform", `translate(${x},${y})`);
305
+ star.append("path")
306
+ .attr("d", "M0,-9 L2,-2 L9,0 L2,2 L0,9 L-2,2 L-9,0 L-2,-2 Z")
307
+ .attr("fill", i === 1 ? "#ffffff" : "#6cf2d0")
308
+ .attr("opacity", i === 1 ? 0.92 : 0.8);
309
+ });
310
+
311
+ const titleX = 205;
312
+ const titleY = 45;
313
+ const highlightCount = Math.max(1, titleParts.highlightLines.length);
314
+ const maxHighlightLen = d3.max(titleParts.highlightLines, d => d.length) || 0;
315
+ const highlightFitSize = maxHighlightLen
316
+ ? Math.floor((width - titleX - 18) / Math.max(1, maxHighlightLen * 0.63))
317
+ : 44;
318
+ const highlightSize = Math.max(28, Math.min(maxHighlightLen > 23 ? 32 : maxHighlightLen > 18 ? 38 : 44, highlightFitSize));
319
+ const firstLineFitSize = Math.floor((width - titleX - 18) / Math.max(1, titleParts.line1.length * 0.62));
320
+ svg.append("text")
321
+ .attr("class", "title-line-one")
322
+ .attr("x", titleX)
323
+ .attr("y", titleY)
324
+ .attr("fill", colors.text)
325
+ .style("font-family", titleFont)
326
+ .style("font-size", `${Math.max(21, Math.min(titleParts.line1.length > 24 ? 23 : 29, firstLineFitSize))}px`)
327
+ .style("font-weight", 900)
328
+ .style("letter-spacing", "0px")
329
+ .text(titleParts.line1);
330
+
331
+ const highlight = svg.append("text")
332
+ .attr("class", "title-highlight")
333
+ .attr("x", titleX)
334
+ .attr("fill", colors.green)
335
+ .style("font-family", titleFont)
336
+ .style("font-size", `${highlightSize}px`)
337
+ .style("font-weight", 900)
338
+ .style("letter-spacing", "0px");
339
+ titleParts.highlightLines.forEach((line, i) => {
340
+ highlight.append("tspan")
341
+ .attr("x", titleX)
342
+ .attr("y", titleY + 48 + i * (highlightSize + 3))
343
+ .attr("fill", i % 2 ? colors.cyan : colors.green)
344
+ .text(line);
345
+ });
346
+
347
+ const pillY = titleY + 54 + highlightCount * (highlightSize + 3);
348
+ const pillText = compactLabel(titleParts.pill, 28).toUpperCase();
349
+ const pillW = Math.min(width - titleX - 24, Math.max(240, pillText.length * 14 + 34));
350
+ const pill = svg.append("g")
351
+ .attr("class", "title-scope-pill")
352
+ .attr("transform", `translate(${titleX},${pillY})`);
353
+ pill.append("rect")
354
+ .attr("width", pillW)
355
+ .attr("height", 34)
356
+ .attr("rx", 18)
357
+ .attr("fill", "#0a4f73")
358
+ .attr("opacity", 0.76);
359
+ pill.append("text")
360
+ .attr("x", pillW / 2)
361
+ .attr("y", 24)
362
+ .attr("text-anchor", "middle")
363
+ .attr("fill", "#ffffff")
364
+ .style("font-family", font)
365
+ .style("font-size", pillText.length > 23 ? "17px" : "20px")
366
+ .style("font-weight", 900)
367
+ .style("letter-spacing", "0px")
368
+ .text(pillText);
369
+
370
+ const subtitleY = pillY + 52;
371
+ const subtitle = svg.append("text")
372
+ .attr("class", "subtitle")
373
+ .attr("x", titleX)
374
+ .attr("fill", colors.muted)
375
+ .style("font-family", font)
376
+ .style("font-size", "15px")
377
+ .style("font-weight", 500);
378
+ appendLines(subtitle, wrapWords(subtitleText, 47, 2), titleX, subtitleY, 20);
379
+
380
+ const headerBottom = subtitleY + 42;
381
+ const chartX = 27;
382
+ const chartRight = width - 27;
383
+ const rowW = chartRight - chartX;
384
+ const yearY = Math.max(246, headerBottom + 16);
385
+ const railX = chartX + 112;
386
+ const railW = rowW - 118;
387
+ const rowTop = yearY + 46;
388
+ const rowGap = visibleGroups.length >= 6 ? 10 : 14;
389
+ const bottomMargin = 124;
390
+ const rowH = Math.max(76, Math.min(118, (height - rowTop - bottomMargin - rowGap * (visibleGroups.length - 1)) / Math.max(1, visibleGroups.length)));
391
+ const leftRankW = 62;
392
+ const iconW = 92;
393
+ const plotX = leftRankW + iconW + 25;
394
+ const endpointW = 92;
395
+ const plotW = rowW - plotX - endpointW - 16;
396
+
397
+ const xDomain = xValues.map(d => d.key);
398
+ const linearX = xValues.length > 1 && xValues.every(d => Number.isFinite(d.sort)) && new Set(xValues.map(d => d.sort)).size === xValues.length;
399
+ const xPointScale = d3.scalePoint()
400
+ .domain(xDomain)
401
+ .range([plotX + 6, plotX + plotW - 6])
402
+ .padding(0);
403
+ const railPointScale = d3.scalePoint()
404
+ .domain(xDomain)
405
+ .range([railX + 34 - chartX, railX + railW - 34 - chartX])
406
+ .padding(0);
407
+ const xLinearScale = d3.scaleLinear()
408
+ .domain(d3.extent(xValues, d => d.sort))
409
+ .range([plotX + 6, plotX + plotW - 6]);
410
+ const railLinearScale = d3.scaleLinear()
411
+ .domain(d3.extent(xValues, d => d.sort))
412
+ .range([railX + 34 - chartX, railX + railW - 34 - chartX]);
413
+ function xPosByKey(key) {
414
+ return linearX ? xLinearScale(xSortByKey.get(key)) : xPointScale(key);
415
+ }
416
+ function railPosByTick(tick) {
417
+ return linearX ? railLinearScale(tick.sort) : railPointScale(tick.key);
418
+ }
419
+ function buildDisplayTicks() {
420
+ if (!linearX || xValues.length > 7) return xValues;
421
+ const numericYears = xValues.every(d => Number.isInteger(Number(d.raw)) && Number(d.raw) >= 1000 && Number(d.raw) <= 9999);
422
+ if (!numericYears) return xValues;
423
+ const out = [xValues[0]];
424
+ for (let i = 1; i < xValues.length; i += 1) {
425
+ const prev = Number(xValues[i - 1].raw);
426
+ const cur = Number(xValues[i].raw);
427
+ if (i > 1 && i < xValues.length - 1 && cur - prev >= 4) {
428
+ const mid = prev + Math.round((cur - prev) / 2);
429
+ out.push({ key: `tick-${mid}`, raw: mid, sort: mid, generatedTick: true });
430
+ }
431
+ out.push(xValues[i]);
432
+ }
433
+ return out.length <= 8 ? out : xValues;
434
+ }
435
+ const displayTicks = buildDisplayTicks();
436
+
437
+ const yearRail = svg.append("g")
438
+ .attr("class", "year-rail")
439
+ .attr("transform", `translate(${chartX},${yearY})`);
440
+ yearRail.append("rect")
441
+ .attr("x", railX - chartX)
442
+ .attr("y", 0)
443
+ .attr("width", railW)
444
+ .attr("height", 34)
445
+ .attr("rx", 17)
446
+ .attr("fill", "rgba(22, 43, 65, 0.88)")
447
+ .attr("stroke", colors.railStroke)
448
+ .attr("stroke-width", 1.2);
449
+ yearRail.selectAll(".year-label")
450
+ .data(displayTicks)
451
+ .enter()
452
+ .append("text")
453
+ .attr("class", "year-label")
454
+ .attr("x", d => railPosByTick(d))
455
+ .attr("y", 23)
456
+ .attr("text-anchor", "middle")
457
+ .attr("fill", "#ffffff")
458
+ .style("font-family", font)
459
+ .style("font-size", displayTicks.length > 7 ? "11px" : displayTicks.length > 6 ? "13px" : "15px")
460
+ .style("font-weight", 800)
461
+ .text(d => formatX(d.raw));
462
+
463
+ const rowLayer = svg.append("g")
464
+ .attr("class", "bump04-row-layer")
465
+ .attr("data-overlap-avoid", "title subtitle header_topic_art_slot year_rail");
466
+
467
+ visibleGroups.forEach((group, index) => {
468
+ const color = colorFor(index);
469
+ const rowY = rowTop + index * (rowH + rowGap);
470
+ const safeGroup = group.replace(/[^a-zA-Z0-9_-]/g, "-");
471
+ const areaGradientId = `bump04-area-${index}`;
472
+ const areaGradient = defs.append("linearGradient")
473
+ .attr("id", areaGradientId)
474
+ .attr("x1", "0%")
475
+ .attr("x2", "0%")
476
+ .attr("y1", "0%")
477
+ .attr("y2", "100%");
478
+ areaGradient.append("stop").attr("offset", "0%").attr("stop-color", color).attr("stop-opacity", 0.44);
479
+ areaGradient.append("stop").attr("offset", "100%").attr("stop-color", color).attr("stop-opacity", 0.05);
480
+
481
+ const row = rowLayer.append("g")
482
+ .attr("class", "series-row-card")
483
+ .attr("data-group", group)
484
+ .attr("data-row-index", index)
485
+ .attr("transform", `translate(${chartX},${rowY})`);
486
+
487
+ row.append("rect")
488
+ .attr("class", "row-card-bg")
489
+ .attr("x", 0)
490
+ .attr("y", 0)
491
+ .attr("width", rowW)
492
+ .attr("height", rowH)
493
+ .attr("rx", 9)
494
+ .attr("fill", "rgba(6, 24, 43, 0.84)")
495
+ .attr("stroke", colors.stroke)
496
+ .attr("filter", index === 0 ? "url(#bump04-card-shadow)" : null);
497
+
498
+ row.append("rect")
499
+ .attr("x", 0)
500
+ .attr("y", 0)
501
+ .attr("width", rowW)
502
+ .attr("height", rowH)
503
+ .attr("rx", 9)
504
+ .attr("fill", color)
505
+ .attr("opacity", 0.035);
506
+
507
+ const rank = row.append("g")
508
+ .attr("class", "rank-circle")
509
+ .attr("transform", `translate(${leftRankW / 2},${rowH / 2})`);
510
+ rank.append("circle")
511
+ .attr("r", Math.min(27, rowH * 0.31))
512
+ .attr("fill", color)
513
+ .attr("opacity", 0.48)
514
+ .attr("stroke", color)
515
+ .attr("stroke-width", 3);
516
+ rank.append("circle")
517
+ .attr("r", Math.min(21, rowH * 0.24))
518
+ .attr("fill", "#153654")
519
+ .attr("opacity", 0.76);
520
+ rank.append("text")
521
+ .attr("text-anchor", "middle")
522
+ .attr("y", 11)
523
+ .attr("fill", "#ffffff")
524
+ .style("font-family", font)
525
+ .style("font-size", rowH < 90 ? "24px" : "30px")
526
+ .style("font-weight", 900)
527
+ .text(index + 1);
528
+
529
+ const icon = row.append("g")
530
+ .attr("class", "row-group-icon-slot")
531
+ .attr("data-reserved-slot", `row_group_icon_slot_${index + 1}`)
532
+ .attr("data-asset-slot", "row_group_icon_slot_N")
533
+ .attr("data-group", group)
534
+ .attr("data-slot-anchor", "row-left-icon-column")
535
+ .attr("data-asset-source-policy", "retrieval_asset")
536
+ .attr("data-collision-rule", "avoid-rank-circle-group-label-plot")
537
+ .attr("transform", `translate(${leftRankW + 48},${rowH * 0.38})`);
538
+ icon.append("rect")
539
+ .attr("x", -24)
540
+ .attr("y", -23)
541
+ .attr("width", 48)
542
+ .attr("height", 46)
543
+ .attr("rx", 14)
544
+ .attr("fill", color)
545
+ .attr("opacity", 0.18)
546
+ .attr("stroke", color)
547
+ .attr("stroke-dasharray", "4 5");
548
+ icon.append("text")
549
+ .attr("text-anchor", "middle")
550
+ .attr("y", 8)
551
+ .attr("fill", color)
552
+ .style("font-family", font)
553
+ .style("font-size", "19px")
554
+ .style("font-weight", 900)
555
+ .text(initials(group));
556
+
557
+ row.append("text")
558
+ .attr("class", "group-label")
559
+ .attr("x", leftRankW + 48)
560
+ .attr("y", rowH - 20)
561
+ .attr("text-anchor", "middle")
562
+ .attr("fill", color)
563
+ .style("font-family", font)
564
+ .style("font-size", humanize(group).length > 12 ? "13px" : "16px")
565
+ .style("font-weight", 900)
566
+ .text(compactLabel(group, 13));
567
+
568
+ row.selectAll(".vertical-guide")
569
+ .data(displayTicks)
570
+ .enter()
571
+ .append("line")
572
+ .attr("class", "vertical-guide")
573
+ .attr("x1", d => linearX ? xLinearScale(d.sort) : xPointScale(d.key))
574
+ .attr("x2", d => linearX ? xLinearScale(d.sort) : xPointScale(d.key))
575
+ .attr("y1", 14)
576
+ .attr("y2", rowH - 16)
577
+ .attr("stroke", "#41607c")
578
+ .attr("stroke-width", 1)
579
+ .attr("stroke-dasharray", "3 4")
580
+ .attr("opacity", 0.28);
581
+
582
+ const series = xValues.map(x => {
583
+ const found = (byGroup.get(group) || []).find(d => d.xKey === x.key);
584
+ return found || { xKey: x.key, xRaw: x.raw, y: null, group };
585
+ }).filter(d => Number.isFinite(d.y));
586
+ const extent = d3.extent(series, d => d.y);
587
+ const delta = Math.max(1, (extent[1] - extent[0]) || Math.abs(extent[0]) * 0.05 || 1);
588
+ const yScale = d3.scaleLinear()
589
+ .domain([extent[0] - delta * 0.18, extent[1] + delta * 0.34])
590
+ .range([rowH - 38, 28]);
591
+ const line = d3.line()
592
+ .x(d => xPosByKey(d.xKey))
593
+ .y(d => yScale(d.y))
594
+ .curve(d3.curveMonotoneX);
595
+ const area = d3.area()
596
+ .x(d => xPosByKey(d.xKey))
597
+ .y0(rowH - 16)
598
+ .y1(d => yScale(d.y))
599
+ .curve(d3.curveMonotoneX);
600
+
601
+ row.append("path")
602
+ .datum(series)
603
+ .attr("class", "series-area")
604
+ .attr("fill", `url(#${areaGradientId})`)
605
+ .attr("d", area);
606
+ row.append("path")
607
+ .datum(series)
608
+ .attr("class", "series-line")
609
+ .attr("fill", "none")
610
+ .attr("stroke", color)
611
+ .attr("stroke-width", rowH < 90 ? 3.5 : 4.5)
612
+ .attr("stroke-linecap", "round")
613
+ .attr("stroke-linejoin", "round")
614
+ .attr("filter", "url(#bump04-soft-glow)")
615
+ .attr("d", line);
616
+
617
+ row.selectAll(".point")
618
+ .data(series)
619
+ .enter()
620
+ .append("circle")
621
+ .attr("class", "point")
622
+ .attr("cx", d => xPosByKey(d.xKey))
623
+ .attr("cy", d => yScale(d.y))
624
+ .attr("r", rowH < 90 ? 5.8 : 6.8)
625
+ .attr("fill", color)
626
+ .attr("stroke", "#ffffff")
627
+ .attr("stroke-width", 2.2);
628
+
629
+ const showEvery = xValues.length > 7 ? Math.ceil(xValues.length / 6) : 1;
630
+ row.selectAll(".point-label")
631
+ .data(series.filter(d => {
632
+ const idx = xIndex.get(d.xKey) || 0;
633
+ return idx === 0 || idx === xValues.length - 1 || idx % showEvery === 0;
634
+ }))
635
+ .enter()
636
+ .append("text")
637
+ .attr("class", "point-label")
638
+ .attr("x", d => xPosByKey(d.xKey))
639
+ .attr("y", d => Math.max(18, yScale(d.y) - 13))
640
+ .attr("text-anchor", "middle")
641
+ .attr("fill", "#ffffff")
642
+ .style("font-family", font)
643
+ .style("font-size", rowH < 90 ? "12px" : "14px")
644
+ .style("font-weight", 800)
645
+ .text(d => formatValue(d.y, yUnit));
646
+
647
+ const lastPoint = series[series.length - 1];
648
+ const endpointText = formatValue(lastPoint?.y, yUnit, { includeUnit: true });
649
+ row.append("text")
650
+ .attr("class", "endpoint-value")
651
+ .attr("x", rowW - 16)
652
+ .attr("y", rowH / 2 + 14)
653
+ .attr("text-anchor", "end")
654
+ .attr("fill", color)
655
+ .style("font-family", font)
656
+ .style("font-size", endpointText.length > 5 ? "26px" : "31px")
657
+ .style("font-weight", 900)
658
+ .text(endpointText);
659
+ });
660
+
661
+ return svg.node();
662
+ }
modules/chart_engine/template/d3-js/line_graph/bump_chart_05.js ADDED
@@ -0,0 +1,497 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Bump Chart",
5
+ "chart_name": "bump_plain_chart_01",
6
+ "is_composite": false,
7
+ "chart_for": "comparison",
8
+ "required_fields": ["x", "y", "group"],
9
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
10
+ "required_fields_range": [
11
+ [2, 12],
12
+ [0, "inf"],
13
+ [4, 10]
14
+ ],
15
+ "required_fields_icons": [],
16
+ "required_other_icons": [],
17
+ "required_fields_colors": ["group"],
18
+ "required_other_colors": [],
19
+ "supported_effects": ["spacing"],
20
+ "min_height": 400,
21
+ "min_width": 400,
22
+ "background": "no",
23
+ "icon_mark": "none",
24
+ "icon_label": "none",
25
+ "has_x_axis": "no",
26
+ "has_y_axis": "no"
27
+ }
28
+ REQUIREMENTS_END
29
+ */
30
+
31
+
32
+
33
+ function makeChart(containerSelector, data) {
34
+ // 提取数据
35
+ const jsonData = data;
36
+ const chartData = jsonData.data.data;
37
+ const variables = jsonData.variables;
38
+ const typography = jsonData.typography;
39
+ const colors = jsonData.colors || {};
40
+ const colorResolver = chartUtils.color.resolver(jsonData);
41
+ const dataColumns = chartUtils.schema.columns(jsonData);
42
+ const images = jsonData.images || {};
43
+ // 获取颜色
44
+ const getColor = (group) => {
45
+ return colorResolver.field(group, 0, { fallbackKey: "primary", fallback: "#888" }).value;
46
+ };
47
+ const withOpacity = (color, opacity) => {
48
+ const parsed = d3.color(color);
49
+ if (!parsed) return color;
50
+ parsed.opacity = opacity;
51
+ return parsed.formatRgb();
52
+ };
53
+
54
+ // 清空容器
55
+ d3.select(containerSelector).html("");
56
+
57
+ // 设置尺寸和边距 - 左侧为参考图中的排行卡片和标签列留空间
58
+ const width = variables.width;
59
+ const height = variables.height;
60
+ const margin = {
61
+ top: Math.max(76, height * 0.15),
62
+ right: Math.max(28, width * 0.06),
63
+ bottom: Math.max(34, height * 0.06),
64
+ left: Math.max(176, width * 0.31)
65
+ };
66
+ const innerWidth = width - margin.left - margin.right;
67
+ const innerHeight = height - margin.top - margin.bottom;
68
+
69
+ // 获取字段名
70
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
71
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
72
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
73
+
74
+ // 获取唯一的组值和X值
75
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
76
+ const xValues = [...new Set(chartData.map(d => d[xField]))]
77
+ .sort((a, b) => chartUtils.format.parseDate(a) - chartUtils.format.parseDate(b));
78
+
79
+ // 创建SVG
80
+ const svg = d3.select(containerSelector)
81
+ .append("svg")
82
+ .attr("width", "100%")
83
+ .attr("height", height)
84
+ .attr("viewBox", `0 0 ${width} ${height}`)
85
+ .attr("style", "max-width: 100%; height: auto;")
86
+ .attr("xmlns", "http://www.w3.org/2000/svg")
87
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
88
+
89
+ const defs = svg.append("defs");
90
+ const bgGradient = defs.append("linearGradient")
91
+ .attr("id", "bump-plain-01-bg")
92
+ .attr("x1", "0%")
93
+ .attr("y1", "0%")
94
+ .attr("x2", "0%")
95
+ .attr("y2", "100%");
96
+ bgGradient.append("stop").attr("offset", "0%").attr("stop-color", "#F5FAFE");
97
+ bgGradient.append("stop").attr("offset", "100%").attr("stop-color", "#FFFFFF");
98
+ svg.append("rect")
99
+ .attr("class", "bump-plain-01-chart-surface")
100
+ .attr("x", 0)
101
+ .attr("y", 0)
102
+ .attr("width", width)
103
+ .attr("height", height)
104
+ .attr("fill", "url(#bump-plain-01-bg)");
105
+
106
+ const rowShadow = defs.append("filter")
107
+ .attr("id", "bump-plain-01-row-shadow")
108
+ .attr("x", "-6%")
109
+ .attr("y", "-12%")
110
+ .attr("width", "112%")
111
+ .attr("height", "130%");
112
+ rowShadow.append("feDropShadow")
113
+ .attr("dx", 0)
114
+ .attr("dy", 2)
115
+ .attr("stdDeviation", 3)
116
+ .attr("flood-color", "#1E4B76")
117
+ .attr("flood-opacity", 0.08);
118
+
119
+ svg.append("rect")
120
+ .attr("class", "bump-plain-header-art-slot")
121
+ .attr("data-asset-slot", "header_water_illustration_slot")
122
+ .attr("data-reserved-slot", "header_water_illustration_slot")
123
+ .attr("data-anchor", "top_right_header")
124
+ .attr("data-collision-rule", "avoid title subtitle year chips and chart rows")
125
+ .attr("data-asset-source-policy", "image2_or_retrieval_reserved_slot_no_raster")
126
+ .attr("data-bbox", `${width * 0.65},10,${width * 0.26},${Math.max(48, margin.top - 24)}`)
127
+ .attr("data-no-embedded-raster", "true")
128
+ .attr("data-no-copied-reference-art", "true")
129
+ .attr("x", width * 0.65)
130
+ .attr("y", 10)
131
+ .attr("width", width * 0.26)
132
+ .attr("height", Math.max(48, margin.top - 24))
133
+ .attr("rx", 12)
134
+ .attr("fill", "none")
135
+ .attr("stroke", "none");
136
+
137
+ // 创建图表组
138
+ const g = svg.append("g")
139
+ .attr("transform", `translate(${margin.left},${margin.top})`);
140
+
141
+ const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, innerWidth);
142
+
143
+ // 为每个组分配固定位置 (1到groups.length)
144
+ const fixedGroupPositions = {};
145
+ groups.forEach((group, i) => {
146
+ fixedGroupPositions[group] = i + 1;
147
+ });
148
+
149
+ // 1. 计算每个x下各group的排名
150
+ // 构建排名数据结构:{ group1: [{x, rank}], group2: ... }
151
+ const rankData = {};
152
+
153
+ // 初始化每个group的数据结构
154
+ groups.forEach(group => {
155
+ rankData[group] = [];
156
+ });
157
+
158
+ // 为每个时间点和每个组创建排名数据
159
+ xValues.forEach(x => {
160
+ // 取出该x下所有group的数据
161
+ const items = chartData.filter(d => d[xField] === x);
162
+
163
+ // 记录该时间点存在的组
164
+ const existingGroups = items.map(d => d[groupField]);
165
+
166
+ // 按yField降序排序(y越大排名越高)
167
+ items.sort((a, b) => b[yField] - a[yField]);
168
+
169
+ // 为有数据的组计算排名
170
+ const rankByGroup = {};
171
+ items.forEach((d, i) => {
172
+ rankByGroup[d[groupField]] = i + 1; // 排名从1开始
173
+ });
174
+
175
+ // 为每个组添加排名数据,无论是否存在
176
+ groups.forEach(group => {
177
+ if (existingGroups.includes(group)) {
178
+ // 该组在该时间点有数据
179
+ const dataItem = items.find(d => d[groupField] === group);
180
+ rankData[group].push({
181
+ x: x,
182
+ rank: rankByGroup[group],
183
+ value: dataItem[yField],
184
+ missing: false
185
+ });
186
+ } else {
187
+ // 该组在该时间点没有数据
188
+
189
+ // 查找该组最近的有效排名
190
+ let nearestRank = null;
191
+ // 先尝试找前一个时间点的排名
192
+ const groupData = rankData[group];
193
+ if (groupData && groupData.length > 0) {
194
+ const lastValidPoint = [...groupData].reverse().find(d => !d.missing);
195
+ if (lastValidPoint) {
196
+ nearestRank = lastValidPoint.rank;
197
+ }
198
+ }
199
+
200
+ // 如果找不到有效的历史排名,使用固定位置
201
+ if (nearestRank === null) {
202
+ nearestRank = fixedGroupPositions[group];
203
+ }
204
+
205
+ rankData[group].push({
206
+ x: x,
207
+ rank: nearestRank, // 使用最近的有效排名或固定位置
208
+ value: null,
209
+ missing: true
210
+ });
211
+ }
212
+ });
213
+ });
214
+
215
+ // 对每个组的数据按x值排序
216
+ groups.forEach(group => {
217
+ rankData[group].sort((a, b) => {
218
+ const dateA = chartUtils.format.parseDate(a.x);
219
+ const dateB = chartUtils.format.parseDate(b.x);
220
+ return dateA - dateB;
221
+ });
222
+ });
223
+
224
+ // 2. y轴比例尺:排名(1在顶部)
225
+ const yScale = d3.scaleLinear()
226
+ .domain([1, groups.length])
227
+ .range([0, innerHeight]);
228
+
229
+ const yValues = chartData.map(d => +d[yField]).filter(d => d !== null && d !== undefined && !Number.isNaN(d));
230
+ const yMin = Math.min(...yValues);
231
+ const yMax = Math.max(...yValues);
232
+ const rowStep = groups.length > 1 ? innerHeight / (groups.length - 1) : innerHeight;
233
+ const rowBandHeight = Math.max(42, Math.min(72, rowStep * 0.70));
234
+ const rowX = -margin.left + Math.max(22, width * 0.035);
235
+ const rowW = width - Math.max(44, width * 0.07);
236
+ const tabW = Math.max(38, Math.min(52, rowBandHeight * 0.72));
237
+ const cardGap = 7;
238
+ const cardW = Math.max(108, Math.min(132, margin.left * 0.62));
239
+ const labelCardX = rowX + tabW + cardGap;
240
+ const plotStartGap = Math.max(70, labelCardX + cardW + 54);
241
+ const plotEnd = innerWidth - Math.max(12, width * 0.02);
242
+ const shiftedXScale = xScale.copy().range([plotStartGap, plotEnd]);
243
+ const nodeR = Math.max(9, Math.min(17, rowBandHeight * 0.22, (plotEnd - plotStartGap) / Math.max(18, xValues.length * 5.2)));
244
+ const labelFont = Math.max(8, Math.min(11, nodeR * 0.72));
245
+
246
+ const truncateToWidth = (text, maxWidth, fontSize, fontWeight = "700") => {
247
+ const value = String(text);
248
+ if (chartUtils.text.measure(g, value, { fontSize, fontWeight }).width <= maxWidth) {
249
+ return value;
250
+ }
251
+ let trimmed = value;
252
+ while (trimmed.length > 3 && chartUtils.text.measure(g, `${trimmed}...`, { fontSize, fontWeight }).width > maxWidth) {
253
+ trimmed = trimmed.slice(0, -1);
254
+ }
255
+ return `${trimmed}...`;
256
+ };
257
+
258
+ groups.forEach((group, i) => {
259
+ const rowY = yScale(fixedGroupPositions[group]);
260
+ const color = getColor(group);
261
+ g.append("rect")
262
+ .attr("class", "bump-plain-row-panel")
263
+ .attr("data-group", group)
264
+ .attr("x", rowX)
265
+ .attr("y", rowY - rowBandHeight / 2)
266
+ .attr("width", rowW)
267
+ .attr("height", rowBandHeight)
268
+ .attr("rx", 11)
269
+ .attr("fill", i % 2 === 0 ? "#FFFFFF" : "#F3F8FD")
270
+ .attr("stroke", "#D8EAF6")
271
+ .attr("stroke-width", 1)
272
+ .attr("filter", "url(#bump-plain-01-row-shadow)");
273
+
274
+ g.append("rect")
275
+ .attr("class", "bump-plain-rank-tab")
276
+ .attr("data-group", group)
277
+ .attr("x", rowX)
278
+ .attr("y", rowY - rowBandHeight / 2)
279
+ .attr("width", tabW)
280
+ .attr("height", rowBandHeight)
281
+ .attr("rx", 9)
282
+ .attr("fill", color);
283
+ g.append("rect")
284
+ .attr("x", rowX + tabW - 10)
285
+ .attr("y", rowY - rowBandHeight / 2)
286
+ .attr("width", 10)
287
+ .attr("height", rowBandHeight)
288
+ .attr("fill", color);
289
+ g.append("text")
290
+ .attr("class", "bump-plain-rank-label")
291
+ .attr("x", rowX + tabW / 2)
292
+ .attr("y", rowY + 6)
293
+ .attr("text-anchor", "middle")
294
+ .attr("font-size", Math.max(18, rowBandHeight * 0.36))
295
+ .attr("font-weight", 800)
296
+ .attr("fill", "#FFFFFF")
297
+ .text(i + 1);
298
+
299
+ g.append("rect")
300
+ .attr("class", "bump-plain-label-card")
301
+ .attr("data-group", group)
302
+ .attr("x", labelCardX)
303
+ .attr("y", rowY - rowBandHeight / 2 + 6)
304
+ .attr("width", cardW)
305
+ .attr("height", rowBandHeight - 12)
306
+ .attr("rx", 8)
307
+ .attr("fill", withOpacity(color, 0.10))
308
+ .attr("stroke", withOpacity(color, 0.28))
309
+ .attr("stroke-width", 1);
310
+ const slotR = Math.max(13, Math.min(19, rowBandHeight * 0.24));
311
+ const slotCx = labelCardX + 17 + slotR;
312
+ const slotId = `group_icon_slot_${i + 1}`;
313
+ g.append("circle")
314
+ .attr("class", "bump-plain-group-icon-slot")
315
+ .attr("data-asset-slot", slotId)
316
+ .attr("data-reserved-slot", "group_icon_slot")
317
+ .attr("data-group", group)
318
+ .attr("data-anchor", "row_label_card_centerline")
319
+ .attr("data-collision-rule", "stay inside label card and avoid group label and latest value text")
320
+ .attr("data-asset-source-policy", "image2_or_retrieval_reserved_slot_no_raster")
321
+ .attr("data-bbox", `${slotCx - slotR},${rowY - slotR},${slotR * 2},${slotR * 2}`)
322
+ .attr("data-no-embedded-raster", "true")
323
+ .attr("data-no-copied-reference-art", "true")
324
+ .attr("cx", slotCx)
325
+ .attr("cy", rowY)
326
+ .attr("r", slotR)
327
+ .attr("fill", withOpacity(color, 0.08))
328
+ .attr("stroke", withOpacity(color, 0.34))
329
+ .attr("stroke-width", 1.2)
330
+ .attr("stroke-dasharray", "3 3");
331
+ g.append("text")
332
+ .attr("class", "bump-plain-group-label")
333
+ .attr("x", slotCx + slotR + 9)
334
+ .attr("y", rowY - 4)
335
+ .attr("font-size", Math.max(11, Math.min(13, rowBandHeight * 0.19)))
336
+ .attr("font-weight", 800)
337
+ .attr("fill", "#173A5E")
338
+ .text(truncateToWidth(group, cardW - (slotCx - labelCardX) - slotR - 18, 13, "800"));
339
+ const latest = [...rankData[group]].reverse().find(d => !d.missing);
340
+ if (latest) {
341
+ g.append("text")
342
+ .attr("class", "bump-plain-card-value")
343
+ .attr("x", slotCx + slotR + 9)
344
+ .attr("y", rowY + 15)
345
+ .attr("font-size", Math.max(9, Math.min(11, rowBandHeight * 0.16)))
346
+ .attr("font-weight", 700)
347
+ .attr("fill", withOpacity("#173A5E", 0.72))
348
+ .text(`${xFormat(chartUtils.format.parseDate(latest.x))}: ${chartUtils.format.number(latest.value).text}`);
349
+ }
350
+ });
351
+
352
+ // 3. 绘制线条
353
+ groups.forEach(group => {
354
+ const groupRanks = rankData[group];
355
+
356
+ // 使用定义的线条生成器来创建中断的线条
357
+ const lineGenerator = d3.line()
358
+ .x(d => shiftedXScale(chartUtils.format.parseDate(d.x)))
359
+ .y(d => yScale(d.rank))
360
+ .curve(d3.curveMonotoneX)
361
+ .defined(d => !d.missing); // 只在有效数据点之间绘制线条
362
+
363
+ g.append("path")
364
+ .datum(groupRanks)
365
+ .attr("class", "bump-plain-connector")
366
+ .attr("data-group", group)
367
+ .attr("fill", "none")
368
+ .attr("stroke", getColor(group))
369
+ .attr("stroke-width", 2.6)
370
+ .attr("stroke-linecap", "round")
371
+ .attr("stroke-linejoin", "round")
372
+ .attr("opacity", 0.82)
373
+ .attr("d", lineGenerator);
374
+
375
+ const radiusScale = d3.scaleLinear()
376
+ .domain([yMin, yMax])
377
+ .range([nodeR * 0.78, nodeR * 1.08]);
378
+
379
+ // 为每个组生成一个安全的CSS类名
380
+ const safeGroupId = `group-${groups.indexOf(group)}`;
381
+
382
+ // 绘制每个点的圆点 - 只绘制非缺失的点
383
+ g.selectAll(`.dot-${safeGroupId}`)
384
+ .data(groupRanks.filter(d => !d.missing))
385
+ .enter()
386
+ .append("circle")
387
+ .attr("class", `bump-plain-node dot-${safeGroupId}`)
388
+ .attr("data-group", group)
389
+ .attr("data-x", d => d.x)
390
+ .attr("data-value", d => d.value)
391
+ .attr("cx", d => shiftedXScale(chartUtils.format.parseDate(d.x)))
392
+ .attr("cy", d => yScale(d.rank))
393
+ .attr("r", d => radiusScale(d.value))
394
+ .attr("fill", getColor(group))
395
+ .attr("stroke", "#fff")
396
+ .attr("stroke-width", 2.4);
397
+
398
+ // 添加数值标签 - 只为非缺失的点添加
399
+ g.selectAll(`.label-${safeGroupId}`)
400
+ .data(groupRanks.filter(d => !d.missing))
401
+ .enter()
402
+ .append("text")
403
+ .attr("class", `bump-plain-value-label label-${safeGroupId}`)
404
+ .attr("x", d => shiftedXScale(chartUtils.format.parseDate(d.x)))
405
+ .attr("y", d => yScale(d.rank))
406
+ .attr("text-anchor", "middle")
407
+ .attr("dy", "0.36em")
408
+ .attr("font-size", labelFont)
409
+ .attr("fill", () => chartUtils.color.brightness(getColor(group)) > 0.62 ? "#173A5E" : "#FFFFFF")
410
+ .attr("font-weight", 800)
411
+ .text(d => chartUtils.format.number(d.value, { maximumFractionDigits: 0 }).text);
412
+ });
413
+
414
+ // 优化时间标签避免重叠
415
+ const optimizeTimeLabels = (ticks, format, availableWidth) => {
416
+ if (ticks.length <= 1) return ticks;
417
+
418
+ // 计算所有标签的宽度
419
+ const calculateLabelWidth = (tick) => {
420
+ return chartUtils.text.measure(g, format(tick), { fontSize: 14 }).width + 20; // 添加一些边距
421
+ };
422
+
423
+ const tickWidths = ticks.map(tick => calculateLabelWidth(tick));
424
+
425
+ // 检查是否会重叠
426
+ let willOverlap = false;
427
+ let positions = ticks.map(t => xScale(t));
428
+
429
+ for (let i = 0; i < positions.length - 1; i++) {
430
+ const gap = positions[i+1] - positions[i];
431
+ if (gap < (tickWidths[i] + tickWidths[i+1]) / 2) {
432
+ willOverlap = true;
433
+ break;
434
+ }
435
+ }
436
+
437
+ // 如果不会重叠,显示所有标签
438
+ if (!willOverlap) {
439
+ return ticks;
440
+ }
441
+
442
+ // 计算可以显示的标签数量
443
+ const averageWidth = tickWidths.reduce((a, b) => a + b, 0) / ticks.length;
444
+ let maxLabels = Math.floor(availableWidth / averageWidth);
445
+
446
+ // 确保至少显示首尾标签
447
+ if (maxLabels <= 2) {
448
+ return [ticks[0], ticks[ticks.length - 1]];
449
+ }
450
+
451
+ // 均匀选择标签
452
+ const step = Math.ceil(ticks.length / maxLabels);
453
+ const optimizedTicks = [];
454
+
455
+ for (let i = 0; i < ticks.length; i += step) {
456
+ optimizedTicks.push(ticks[i]);
457
+ }
458
+
459
+ // 确保包含最后一个标签
460
+ if (optimizedTicks[optimizedTicks.length - 1] !== ticks[ticks.length - 1]) {
461
+ optimizedTicks.push(ticks[ticks.length - 1]);
462
+ }
463
+
464
+ return optimizedTicks;
465
+ };
466
+
467
+ // 优化时间标签
468
+ const optimizedXTicks = optimizeTimeLabels(xTicks, xFormat, innerWidth);
469
+
470
+ // 添加顶部时间标签
471
+ optimizedXTicks.forEach(tick => {
472
+ g.append("text")
473
+ .attr("class", "bump-plain-year-chip")
474
+ .attr("x", shiftedXScale(tick))
475
+ .attr("y", -36)
476
+ .attr("text-anchor", "middle")
477
+ .attr("font-size", 11)
478
+ .attr("font-weight", 800)
479
+ .attr("fill", "#FFFFFF")
480
+ .text(xFormat(tick));
481
+ });
482
+
483
+ optimizedXTicks.forEach(tick => {
484
+ const label = xFormat(tick);
485
+ const chipW = Math.max(32, chartUtils.text.measure(g, label, { fontSize: 11, fontWeight: "800" }).width + 14);
486
+ g.insert("rect", ".bump-plain-year-chip")
487
+ .attr("class", "bump-plain-year-chip-bg")
488
+ .attr("x", shiftedXScale(tick) - chipW / 2)
489
+ .attr("y", -51)
490
+ .attr("width", chipW)
491
+ .attr("height", 22)
492
+ .attr("rx", 7)
493
+ .attr("fill", "#143E70");
494
+ });
495
+
496
+ return svg.node();
497
+ }
modules/chart_engine/template/d3-js/line_graph/bump_chart_06.js ADDED
@@ -0,0 +1,476 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Bump Chart",
5
+ "chart_name": "bump_chart_06",
6
+ "is_composite": false,
7
+ "chart_for": "comparison",
8
+ "required_fields": ["x", "y", "group"],
9
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
10
+ "required_fields_range": [
11
+ [4, 12],
12
+ [0, "inf"],
13
+ [3, 10]
14
+ ],
15
+ "required_fields_icons": ["group"],
16
+ "required_other_icons": [],
17
+ "required_fields_colors": ["group"],
18
+ "required_other_colors": [],
19
+ "supported_effects": ["spacing"],
20
+ "min_height": 400,
21
+ "min_width": 400,
22
+ "background": "no",
23
+ "icon_mark": "none",
24
+ "icon_label": "none",
25
+ "has_x_axis": "no",
26
+ "has_y_axis": "no"
27
+ }
28
+ REQUIREMENTS_END
29
+ */
30
+
31
+
32
+
33
+ function makeChart(containerSelector, data) {
34
+ // Reference-targeted rank-card bump chart for bump_chart_06.
35
+ const jsonData = data;
36
+ const chartData = jsonData.data.data;
37
+ const variables = jsonData.variables;
38
+ const colorResolver = chartUtils.color.resolver(jsonData);
39
+ const dataColumns = chartUtils.schema.columns(jsonData);
40
+ let colorByGroup = {};
41
+ const resolveInputColor = (group) => {
42
+ return colorResolver.field(group, 0, { fallbackKey: "primary", fallback: "#888" }).value || "#356FAE";
43
+ };
44
+ const getColor = (group) => {
45
+ return colorByGroup[group] || resolveInputColor(group);
46
+ };
47
+
48
+ d3.select(containerSelector).html("");
49
+
50
+ const width = variables.width;
51
+ const height = variables.height;
52
+ const margin = {
53
+ top: Math.max(70, Math.min(92, height * 0.16)),
54
+ right: Math.max(24, Math.min(42, width * 0.06)),
55
+ bottom: Math.max(22, Math.min(40, height * 0.07)),
56
+ left: Math.max(210, Math.min(265, width * 0.43))
57
+ };
58
+ const innerWidth = width - margin.left - margin.right;
59
+ const innerHeight = height - margin.top - margin.bottom;
60
+
61
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
62
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
63
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
64
+
65
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
66
+ const xValues = [...new Set(chartData.map(d => d[xField]))]
67
+ .sort((a, b) => chartUtils.format.parseDate(a) - chartUtils.format.parseDate(b));
68
+
69
+ const svg = d3.select(containerSelector)
70
+ .append("svg")
71
+ .attr("width", "100%")
72
+ .attr("height", height)
73
+ .attr("viewBox", `0 0 ${width} ${height}`)
74
+ .attr("style", "max-width: 100%; height: auto;")
75
+ .attr("xmlns", "http://www.w3.org/2000/svg")
76
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
77
+
78
+ svg.append("rect")
79
+ .attr("class", "bump06-chart-bg")
80
+ .attr("width", width)
81
+ .attr("height", height)
82
+ .attr("fill", "#EAF6FB");
83
+
84
+ const defs = svg.append("defs");
85
+ const rowShadow = defs.append("filter")
86
+ .attr("id", "bump06-row-shadow")
87
+ .attr("x", "-8%")
88
+ .attr("y", "-24%")
89
+ .attr("width", "116%")
90
+ .attr("height", "160%");
91
+ rowShadow.append("feDropShadow")
92
+ .attr("dx", 0)
93
+ .attr("dy", 5)
94
+ .attr("stdDeviation", 7)
95
+ .attr("flood-color", "#0A3152")
96
+ .attr("flood-opacity", 0.10);
97
+
98
+ defs.append("clipPath")
99
+ .attr("id", "bump06-card-clip")
100
+ .append("rect")
101
+ .attr("x", 12)
102
+ .attr("y", margin.top - 48)
103
+ .attr("width", Math.max(1, width - 24))
104
+ .attr("height", Math.max(1, height - margin.top - margin.bottom + 96))
105
+ .attr("rx", 16);
106
+
107
+ const g = svg.append("g")
108
+ .attr("transform", `translate(${margin.left},${margin.top})`);
109
+
110
+ const axisInfo = createXAxisScaleAndTicks(chartData, xField, 0, innerWidth);
111
+ const xScale = d3.scalePoint()
112
+ .domain(xValues)
113
+ .range([0, innerWidth])
114
+ .padding(0.08);
115
+ const xTicks = xValues;
116
+ const xFormat = (tick) => axisInfo.xFormat(chartUtils.format.parseDate(tick));
117
+
118
+ const rankData = {};
119
+ groups.forEach(group => {
120
+ rankData[group] = [];
121
+ });
122
+
123
+ xValues.forEach(x => {
124
+ const items = chartData.filter(d => d[xField] === x);
125
+ items.sort((a, b) => b[yField] - a[yField]);
126
+
127
+ const groupsWithData = new Set(items.map(d => d[groupField]));
128
+ items.forEach((d, i) => {
129
+ rankData[d[groupField]].push({
130
+ x: d[xField],
131
+ rank: i + 1,
132
+ value: d[yField],
133
+ hasData: true
134
+ });
135
+ });
136
+
137
+ groups.forEach(group => {
138
+ if (!groupsWithData.has(group)) {
139
+ const existingEntries = rankData[group];
140
+ const lastRank = existingEntries.length > 0 ? existingEntries[existingEntries.length - 1].rank : groups.length;
141
+ rankData[group].push({
142
+ x: x,
143
+ rank: lastRank,
144
+ value: null,
145
+ hasData: false
146
+ });
147
+ }
148
+ });
149
+ });
150
+
151
+ groups.forEach(group => {
152
+ rankData[group].sort((a, b) => {
153
+ return chartUtils.format.parseDate(a.x) - chartUtils.format.parseDate(b.x);
154
+ });
155
+ });
156
+
157
+ const yScale = d3.scaleLinear()
158
+ .domain([1, groups.length])
159
+ .range([0, innerHeight]);
160
+
161
+ const initialRankByGroup = {};
162
+ groups.forEach(group => {
163
+ const first = rankData[group].find(d => d.hasData) || rankData[group][0];
164
+ initialRankByGroup[group] = first ? first.rank : groups.indexOf(group) + 1;
165
+ });
166
+ const labelOrder = [...groups].sort((a, b) => initialRankByGroup[a] - initialRankByGroup[b]);
167
+ const fallbackPalette = ["#159447", "#D21D1D", "#FF7A18", "#164A91", "#F4C400", "#49BBD2", "#8B64B4", "#2B8F73", "#D94F94", "#6A7A24"];
168
+ const resolvedColors = groups.map(group => resolveInputColor(group).toLowerCase());
169
+ const hasDuplicateColors = new Set(resolvedColors).size < resolvedColors.length;
170
+ labelOrder.forEach((group, i) => {
171
+ colorByGroup[group] = hasDuplicateColors ? fallbackPalette[i % fallbackPalette.length] : resolveInputColor(group);
172
+ });
173
+
174
+ const rowStep = groups.length > 1 ? innerHeight / (groups.length - 1) : innerHeight;
175
+ const rowBandHeight = Math.max(54, Math.min(88, rowStep * 0.76));
176
+ const rowBandX = -margin.left + Math.max(12, width * 0.018);
177
+ const rowBandW = width - Math.max(24, width * 0.036);
178
+ const tabW = Math.max(28, Math.min(44, rowBandHeight * 0.48));
179
+ const iconR = Math.max(16, Math.min(29, rowBandHeight * 0.34));
180
+ const iconX = rowBandX + tabW + iconR + Math.max(18, width * 0.025);
181
+ const labelX = iconX + iconR + Math.max(22, width * 0.034);
182
+ const labelMaxWidth = Math.max(78, margin.left - (labelX + margin.left) - 16);
183
+
184
+ const measureText = (text, fontSize, fontWeight = "700") => {
185
+ return chartUtils.text.measure(g, String(text), { fontSize, fontWeight }).width;
186
+ };
187
+
188
+ const truncateToWidth = (text, maxWidth, fontSize, fontWeight = "800") => {
189
+ const value = String(text);
190
+ if (measureText(value, fontSize, fontWeight) <= maxWidth) return value;
191
+ let trimmed = value;
192
+ while (trimmed.length > 3 && measureText(`${trimmed}...`, fontSize, fontWeight) > maxWidth) {
193
+ trimmed = trimmed.slice(0, -1);
194
+ }
195
+ return `${trimmed}...`;
196
+ };
197
+
198
+ const wrapToLines = (text, maxWidth, fontSize, fontWeight = "800", maxLines = 2) => {
199
+ const words = String(text).split(/\s+/).filter(Boolean);
200
+ const lines = [];
201
+ let current = "";
202
+ words.forEach(word => {
203
+ const candidate = current ? `${current} ${word}` : word;
204
+ if (measureText(candidate, fontSize, fontWeight) <= maxWidth || !current) {
205
+ current = candidate;
206
+ } else {
207
+ lines.push(current);
208
+ current = word;
209
+ }
210
+ });
211
+ if (current) lines.push(current);
212
+ if (lines.length <= maxLines) return lines;
213
+ const clipped = lines.slice(0, maxLines);
214
+ clipped[maxLines - 1] = truncateToWidth(clipped[maxLines - 1], maxWidth, fontSize, fontWeight);
215
+ return clipped;
216
+ };
217
+
218
+ const optimizeTimeLabels = (ticks, format, availableWidth) => {
219
+ if (ticks.length <= 1) return ticks;
220
+ const calculateLabelWidth = (tick) => {
221
+ return chartUtils.text.measure(g, format(tick), { fontSize: 13, fontWeight: "800" }).width + 20;
222
+ };
223
+ const tickWidths = ticks.map(tick => calculateLabelWidth(tick));
224
+ let willOverlap = false;
225
+ let positions = ticks.map(t => xScale(t));
226
+ for (let i = 0; i < positions.length - 1; i++) {
227
+ const gap = positions[i+1] - positions[i];
228
+ if (gap < (tickWidths[i] + tickWidths[i+1]) / 2) {
229
+ willOverlap = true;
230
+ break;
231
+ }
232
+ }
233
+ if (!willOverlap) return ticks;
234
+ const averageWidth = tickWidths.reduce((a, b) => a + b, 0) / ticks.length;
235
+ let maxLabels = Math.floor(availableWidth / averageWidth);
236
+ if (maxLabels <= 2) return [ticks[0], ticks[ticks.length - 1]];
237
+ const step = Math.ceil(ticks.length / maxLabels);
238
+ const optimizedTicks = [];
239
+ for (let i = 0; i < ticks.length; i += step) {
240
+ optimizedTicks.push(ticks[i]);
241
+ }
242
+ if (optimizedTicks[optimizedTicks.length - 1] !== ticks[ticks.length - 1]) {
243
+ optimizedTicks.push(ticks[ticks.length - 1]);
244
+ }
245
+ return optimizedTicks;
246
+ };
247
+
248
+ const enforceTickGap = (ticks, minGap) => {
249
+ const kept = [];
250
+ ticks.forEach((tick, index) => {
251
+ const currentX = xScale(tick);
252
+ if (!kept.length) {
253
+ kept.push(tick);
254
+ return;
255
+ }
256
+ const prevX = xScale(kept[kept.length - 1]);
257
+ if (currentX - prevX >= minGap) {
258
+ kept.push(tick);
259
+ } else if (index === ticks.length - 1) {
260
+ kept[kept.length - 1] = tick;
261
+ }
262
+ });
263
+ return kept;
264
+ };
265
+
266
+ const optimizedXTicks = optimizeTimeLabels(xTicks, xFormat, innerWidth);
267
+ const visibleXTicks = enforceTickGap(optimizedXTicks, Math.max(34, width * 0.075));
268
+
269
+ labelOrder.forEach((group, i) => {
270
+ const rowY = yScale(initialRankByGroup[group]);
271
+ const color = getColor(group);
272
+ g.append("rect")
273
+ .attr("class", "bump06-row-card")
274
+ .attr("x", rowBandX)
275
+ .attr("y", rowY - rowBandHeight / 2)
276
+ .attr("width", rowBandW)
277
+ .attr("height", rowBandHeight)
278
+ .attr("rx", Math.min(18, rowBandHeight * 0.24))
279
+ .attr("fill", i % 2 === 0 ? "#FFFFFF" : "#F9FCFF")
280
+ .attr("filter", "url(#bump06-row-shadow)");
281
+
282
+ g.append("rect")
283
+ .attr("class", "bump06-rank-tab")
284
+ .attr("x", rowBandX)
285
+ .attr("y", rowY - rowBandHeight / 2 + 3)
286
+ .attr("width", tabW)
287
+ .attr("height", rowBandHeight - 6)
288
+ .attr("rx", Math.min(13, (rowBandHeight - 6) / 2))
289
+ .attr("fill", color);
290
+
291
+ g.append("text")
292
+ .attr("class", "bump06-rank-label")
293
+ .attr("x", rowBandX + tabW / 2)
294
+ .attr("y", rowY + 7)
295
+ .attr("text-anchor", "middle")
296
+ .attr("font-family", "Arial, Helvetica, sans-serif")
297
+ .attr("font-weight", "900")
298
+ .attr("font-size", Math.max(18, Math.min(29, rowBandHeight * 0.38)))
299
+ .attr("fill", "#FFFFFF")
300
+ .text(i + 1);
301
+
302
+ const slot = g.append("g")
303
+ .attr("class", "reserved-asset-slot bump-group-icon-slot")
304
+ .attr("data-asset-slot", "group-icon")
305
+ .attr("data-asset-source-policy", jsonData.images && jsonData.images.field && jsonData.images.field[group] ? "input_provided" : "neutral_placeholder")
306
+ .attr("data-anchor", `row-${i + 1}-center`)
307
+ .attr("data-collision-rule", "stay inside row card and left of plotted bump lines")
308
+ .attr("transform", `translate(${iconX},${rowY})`);
309
+ slot.append("circle")
310
+ .attr("r", iconR)
311
+ .attr("fill", "#EEF6FF")
312
+ .attr("stroke", "#FFFFFF")
313
+ .attr("stroke-width", 3);
314
+ if (jsonData.images && jsonData.images.field && jsonData.images.field[group]) {
315
+ slot.append("image")
316
+ .attr("class", "input-provided-group-icon")
317
+ .attr("data-asset-slot", "group-icon-image")
318
+ .attr("data-asset-source-policy", "input_provided")
319
+ .attr("xlink:href", jsonData.images.field[group])
320
+ .attr("x", -iconR * 0.78)
321
+ .attr("y", -iconR * 0.78)
322
+ .attr("width", iconR * 1.56)
323
+ .attr("height", iconR * 1.56)
324
+ .attr("preserveAspectRatio", "xMidYMid meet");
325
+ } else {
326
+ slot.append("circle")
327
+ .attr("r", iconR * 0.58)
328
+ .attr("fill", "none")
329
+ .attr("stroke", color)
330
+ .attr("stroke-width", 2)
331
+ .attr("opacity", 0.30);
332
+ slot.append("text")
333
+ .attr("text-anchor", "middle")
334
+ .attr("dy", "0.35em")
335
+ .attr("font-family", "Arial, Helvetica, sans-serif")
336
+ .attr("font-weight", "900")
337
+ .attr("font-size", Math.max(9, iconR * 0.52))
338
+ .attr("fill", color)
339
+ .text(String(group).trim().slice(0, 1).toUpperCase());
340
+ }
341
+
342
+ const labelFontSize = Math.max(12, Math.min(18, rowBandHeight * 0.23));
343
+ const labelLines = wrapToLines(group, labelMaxWidth, labelFontSize, "900", rowBandHeight > 64 ? 2 : 1);
344
+ const label = g.append("text")
345
+ .attr("class", "bump06-group-label")
346
+ .attr("x", labelX)
347
+ .attr("y", rowY - ((labelLines.length - 1) * labelFontSize * 0.56))
348
+ .attr("text-anchor", "start")
349
+ .attr("dominant-baseline", "middle")
350
+ .attr("font-family", "Arial, Helvetica, sans-serif")
351
+ .attr("font-weight", "900")
352
+ .attr("font-size", labelFontSize)
353
+ .attr("fill", "#0C2B55");
354
+ labelLines.forEach((line, lineIndex) => {
355
+ label.append("tspan")
356
+ .attr("x", labelX)
357
+ .attr("dy", lineIndex === 0 ? 0 : labelFontSize * 1.08)
358
+ .text(line);
359
+ });
360
+
361
+ });
362
+
363
+ const axisY = -62;
364
+ const railX = xScale(visibleXTicks[0]) - Math.max(34, width * 0.07);
365
+ const railW = xScale(visibleXTicks[visibleXTicks.length - 1]) - xScale(visibleXTicks[0]) + Math.max(68, width * 0.14);
366
+ g.append("rect")
367
+ .attr("class", "bump06-time-rail")
368
+ .attr("x", railX)
369
+ .attr("y", axisY - 18)
370
+ .attr("width", railW)
371
+ .attr("height", 36)
372
+ .attr("rx", 18)
373
+ .attr("fill", "#102C55");
374
+
375
+ visibleXTicks.forEach(tick => {
376
+ const tickX = xScale(tick);
377
+ g.append("line")
378
+ .attr("class", "bump06-vertical-guide")
379
+ .attr("x1", tickX)
380
+ .attr("x2", tickX)
381
+ .attr("y1", axisY + 20)
382
+ .attr("y2", innerHeight + rowBandHeight / 2)
383
+ .attr("stroke", "#C8D7E4")
384
+ .attr("stroke-width", 1.2)
385
+ .attr("stroke-dasharray", "4 5")
386
+ .attr("opacity", 0.95);
387
+
388
+ g.append("text")
389
+ .attr("x", tickX)
390
+ .attr("y", axisY + 6)
391
+ .attr("text-anchor", "middle")
392
+ .attr("font-family", "Arial, Helvetica, sans-serif")
393
+ .attr("font-weight", "900")
394
+ .attr("font-size", Math.max(12, Math.min(18, width * 0.027)))
395
+ .attr("fill", "#FFFFFF")
396
+ .text(xFormat(tick));
397
+ });
398
+
399
+ const allValues = chartData
400
+ .filter(d => d[yField] !== null && d[yField] !== undefined)
401
+ .map(d => +d[yField]);
402
+ const yMin = Math.min(...allValues);
403
+ const yMax = Math.max(...allValues);
404
+ const radiusScale = d3.scaleLinear()
405
+ .domain([yMin, yMax])
406
+ .range([
407
+ Math.max(5, Math.min(8, rowBandHeight * 0.10)),
408
+ Math.max(7, Math.min(11, rowBandHeight * 0.14))
409
+ ]);
410
+
411
+ groups.forEach(group => {
412
+ const groupRanks = rankData[group];
413
+ const validSegments = [];
414
+ let currentSegment = [];
415
+ groupRanks.forEach((d, i) => {
416
+ if (d.hasData) {
417
+ currentSegment.push(d);
418
+ } else if (currentSegment.length > 0) {
419
+ validSegments.push(currentSegment);
420
+ currentSegment = [];
421
+ }
422
+ });
423
+ if (currentSegment.length > 0) {
424
+ validSegments.push(currentSegment);
425
+ }
426
+
427
+ validSegments.forEach(segment => {
428
+ if (segment.length > 1) {
429
+ g.append("path")
430
+ .datum(segment)
431
+ .attr("fill", "none")
432
+ .attr("stroke", getColor(group))
433
+ .attr("stroke-width", Math.max(3, Math.min(4.5, rowBandHeight * 0.055)))
434
+ .attr("stroke-linecap", "round")
435
+ .attr("stroke-linejoin", "round")
436
+ .attr("opacity", 0.98)
437
+ .attr("d", d3.line()
438
+ .x(d => xScale(d.x))
439
+ .y(d => yScale(d.rank))
440
+ );
441
+ }
442
+ });
443
+
444
+ groupRanks.forEach(d => {
445
+ if (d.hasData) {
446
+ const x = xScale(d.x);
447
+ const y = yScale(d.rank);
448
+ const r = radiusScale(+d.value);
449
+ g.append("circle")
450
+ .attr("class", "bump06-value-node")
451
+ .attr("data-group", group)
452
+ .attr("data-x", d.x)
453
+ .attr("cx", x)
454
+ .attr("cy", y)
455
+ .attr("r", r)
456
+ .attr("fill", getColor(group))
457
+ .attr("stroke", "#fff")
458
+ .attr("stroke-width", 2.4);
459
+
460
+ const labelY = y - r - Math.max(5, rowBandHeight * 0.055);
461
+ g.append("text")
462
+ .attr("class", "bump06-value-label")
463
+ .attr("x", x)
464
+ .attr("y", labelY)
465
+ .attr("text-anchor", "middle")
466
+ .attr("font-family", "Arial, Helvetica, sans-serif")
467
+ .attr("font-weight", "900")
468
+ .attr("font-size", Math.max(10, Math.min(16, rowBandHeight * 0.19)))
469
+ .attr("fill", getColor(group))
470
+ .text(chartUtils.format.autoText(+d.value));
471
+ }
472
+ });
473
+ });
474
+
475
+ return svg.node();
476
+ }
modules/chart_engine/template/d3-js/line_graph/bump_chart_07.js ADDED
@@ -0,0 +1,491 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Bump Chart",
5
+ "chart_name": "bump_chart_07",
6
+ "is_composite": false,
7
+ "chart_for": "comparison",
8
+ "required_fields": ["x", "y", "group"],
9
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
10
+ "required_fields_range": [
11
+ [4, 12],
12
+ [0, "inf"],
13
+ [3, 10]
14
+ ],
15
+ "required_fields_icons": [],
16
+ "required_other_icons": [],
17
+ "required_fields_colors": ["group"],
18
+ "required_other_colors": [],
19
+ "supported_effects": ["spacing"],
20
+ "min_height": 400,
21
+ "min_width": 400,
22
+ "background": "no",
23
+ "icon_mark": "none",
24
+ "icon_label": "none",
25
+ "has_x_axis": "no",
26
+ "has_y_axis": "no"
27
+ }
28
+ REQUIREMENTS_END
29
+ */
30
+
31
+
32
+ function makeChart(containerSelector, data) {
33
+ const jsonData = data;
34
+ const chartData = jsonData.data.data;
35
+ const variables = jsonData.variables;
36
+ const colorResolver = chartUtils.color.resolver(jsonData);
37
+ const dataColumns = chartUtils.schema.columns(jsonData);
38
+ const colorByGroup = {};
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
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
45
+ const xValues = [...new Set(chartData.map(d => d[xField]))]
46
+ .sort((a, b) => chartUtils.format.parseDate(a) - chartUtils.format.parseDate(b));
47
+
48
+ const resolveInputColor = (group) => {
49
+ return colorResolver.field(group, 0, { fallbackKey: "primary", fallback: "#2E7DD7" }).value || "#2E7DD7";
50
+ };
51
+ const getColor = (group) => colorByGroup[group] || resolveInputColor(group);
52
+
53
+ d3.select(containerSelector).html("");
54
+
55
+ const width = variables.width;
56
+ const height = variables.height;
57
+ const margin = {
58
+ top: Math.max(76, Math.min(96, height * 0.15)),
59
+ right: Math.max(24, Math.min(44, width * 0.06)),
60
+ bottom: Math.max(26, Math.min(44, height * 0.07)),
61
+ left: Math.max(220, Math.min(260, width * 0.42))
62
+ };
63
+ const innerWidth = width - margin.left - margin.right;
64
+ const innerHeight = height - margin.top - margin.bottom;
65
+
66
+ const svg = d3.select(containerSelector)
67
+ .append("svg")
68
+ .attr("width", "100%")
69
+ .attr("height", height)
70
+ .attr("viewBox", `0 0 ${width} ${height}`)
71
+ .attr("style", "max-width: 100%; height: auto;")
72
+ .attr("xmlns", "http://www.w3.org/2000/svg")
73
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
74
+
75
+ svg.append("rect")
76
+ .attr("class", "bump07-chart-bg")
77
+ .attr("width", width)
78
+ .attr("height", height)
79
+ .attr("fill", "none");
80
+
81
+ const defs = svg.append("defs");
82
+ const rowShadow = defs.append("filter")
83
+ .attr("id", "bump07-row-shadow")
84
+ .attr("x", "-8%")
85
+ .attr("y", "-20%")
86
+ .attr("width", "116%")
87
+ .attr("height", "150%");
88
+ rowShadow.append("feDropShadow")
89
+ .attr("dx", 0)
90
+ .attr("dy", 4)
91
+ .attr("stdDeviation", 5)
92
+ .attr("flood-color", "#173C5A")
93
+ .attr("flood-opacity", 0.11);
94
+
95
+ const g = svg.append("g")
96
+ .attr("transform", `translate(${margin.left},${margin.top})`);
97
+
98
+ const axisInfo = createXAxisScaleAndTicks(chartData, xField, 0, innerWidth);
99
+ const xScale = d3.scalePoint()
100
+ .domain(xValues)
101
+ .range([0, innerWidth])
102
+ .padding(0.08);
103
+ const xFormat = (tick) => axisInfo.xFormat(chartUtils.format.parseDate(tick));
104
+
105
+ const rankData = {};
106
+ groups.forEach(group => {
107
+ rankData[group] = [];
108
+ });
109
+
110
+ xValues.forEach(x => {
111
+ const items = chartData
112
+ .filter(d => d[xField] === x)
113
+ .sort((a, b) => b[yField] - a[yField]);
114
+ const seen = new Set(items.map(d => d[groupField]));
115
+
116
+ items.forEach((d, i) => {
117
+ rankData[d[groupField]].push({
118
+ x: d[xField],
119
+ rank: i + 1,
120
+ value: +d[yField],
121
+ hasData: true
122
+ });
123
+ });
124
+
125
+ groups.forEach(group => {
126
+ if (!seen.has(group)) {
127
+ const existing = rankData[group];
128
+ const lastRank = existing.length ? existing[existing.length - 1].rank : groups.length;
129
+ rankData[group].push({ x, rank: lastRank, value: null, hasData: false });
130
+ }
131
+ });
132
+ });
133
+
134
+ groups.forEach(group => {
135
+ rankData[group].sort((a, b) => chartUtils.format.parseDate(a.x) - chartUtils.format.parseDate(b.x));
136
+ });
137
+
138
+ const baseRowStep = groups.length > 1 ? innerHeight / (groups.length - 1) : innerHeight;
139
+ const topRowClearance = Math.max(34, Math.min(48, baseRowStep * 0.24));
140
+ const bottomRowInset = Math.max(0, Math.min(16, baseRowStep * 0.06));
141
+ const effectiveRowHeight = Math.max(1, innerHeight - topRowClearance - bottomRowInset);
142
+ const rowStep = groups.length > 1 ? effectiveRowHeight / (groups.length - 1) : effectiveRowHeight;
143
+ const yScale = d3.scaleLinear()
144
+ .domain([1, Math.max(1, groups.length)])
145
+ .range([topRowClearance, innerHeight - bottomRowInset]);
146
+
147
+ const initialRankByGroup = {};
148
+ groups.forEach(group => {
149
+ const first = rankData[group].find(d => d.hasData) || rankData[group][0];
150
+ initialRankByGroup[group] = first ? first.rank : groups.indexOf(group) + 1;
151
+ });
152
+ const labelOrder = [...groups].sort((a, b) => initialRankByGroup[a] - initialRankByGroup[b]);
153
+
154
+ const fallbackPalette = ["#43A333", "#6E47C8", "#2376D9", "#D94D7D", "#FF8618", "#12A89E", "#F4B400", "#4867C8", "#E95AA8", "#3E9B7A"];
155
+ labelOrder.forEach((group, i) => {
156
+ colorByGroup[group] = resolveInputColor(group) || fallbackPalette[i % fallbackPalette.length];
157
+ });
158
+
159
+ const rowBandHeight = Math.max(62, Math.min(96, rowStep * 0.78));
160
+ const rowBandX = -margin.left + Math.max(18, width * 0.035);
161
+ const rowBandW = width - Math.max(34, width * 0.058);
162
+ const badgeR = Math.max(15, Math.min(24, rowBandHeight * 0.26));
163
+ const iconR = Math.max(16, Math.min(26, rowBandHeight * 0.29));
164
+ const badgeX = rowBandX + Math.max(24, rowBandHeight * 0.34);
165
+ const iconX = badgeX + badgeR + iconR + Math.max(15, width * 0.026);
166
+ const dividerX = -Math.max(4, width * 0.012);
167
+ const labelX = iconX + iconR + Math.max(10, width * 0.018);
168
+ const labelMaxWidth = Math.max(86, dividerX - labelX - 10);
169
+
170
+ const measureText = (text, fontSize, fontWeight = "800") => {
171
+ return chartUtils.text.measure(g, String(text), { fontSize, fontWeight }).width;
172
+ };
173
+
174
+ const truncateToWidth = (text, maxWidth, fontSize, fontWeight = "800") => {
175
+ const value = String(text);
176
+ if (measureText(value, fontSize, fontWeight) <= maxWidth) return value;
177
+ let trimmed = value;
178
+ while (trimmed.length > 3 && measureText(`${trimmed}...`, fontSize, fontWeight) > maxWidth) {
179
+ trimmed = trimmed.slice(0, -1);
180
+ }
181
+ return `${trimmed}...`;
182
+ };
183
+
184
+ const splitLabelWords = (text) => String(text)
185
+ .replace(/\//g, "/ ")
186
+ .split(/\s+/)
187
+ .filter(Boolean);
188
+
189
+ const wrapToLines = (text, maxWidth, fontSize, fontWeight = "800", maxLines = 2, options = {}) => {
190
+ const words = options.splitSlash ? splitLabelWords(text) : String(text).split(/\s+/).filter(Boolean);
191
+ const lines = [];
192
+ let current = "";
193
+ words.forEach(word => {
194
+ const candidate = current ? `${current} ${word}` : word;
195
+ if (measureText(candidate, fontSize, fontWeight) <= maxWidth || !current) {
196
+ current = candidate;
197
+ } else {
198
+ lines.push(current);
199
+ current = word;
200
+ }
201
+ });
202
+ if (current) lines.push(current);
203
+ if (lines.length <= maxLines) {
204
+ return options.allowOverflowWords
205
+ ? lines
206
+ : lines.map(line => truncateToWidth(line, maxWidth, fontSize, fontWeight));
207
+ }
208
+ const clipped = lines.slice(0, maxLines);
209
+ if (!options.allowOverflowWords) {
210
+ clipped[maxLines - 1] = truncateToWidth(clipped[maxLines - 1], maxWidth, fontSize, fontWeight);
211
+ return clipped.map(line => truncateToWidth(line, maxWidth, fontSize, fontWeight));
212
+ }
213
+ return clipped;
214
+ };
215
+
216
+ const wrapGroupLabel = (text) => {
217
+ let fontSize = Math.max(10, Math.min(13, rowBandHeight * 0.15));
218
+ let maxLines = 3;
219
+ let lines = wrapToLines(text, labelMaxWidth, fontSize, "900", maxLines, {
220
+ splitSlash: true,
221
+ allowOverflowWords: true
222
+ });
223
+ while ((lines.length > maxLines || lines.some(line => measureText(line, fontSize, "900") > labelMaxWidth)) && fontSize > 9) {
224
+ fontSize -= 0.5;
225
+ lines = wrapToLines(text, labelMaxWidth, fontSize, "900", maxLines, {
226
+ splitSlash: true,
227
+ allowOverflowWords: true
228
+ });
229
+ }
230
+ if (lines.length > maxLines) {
231
+ maxLines = 4;
232
+ lines = wrapToLines(text, labelMaxWidth, fontSize, "900", maxLines, {
233
+ splitSlash: true,
234
+ allowOverflowWords: true
235
+ });
236
+ }
237
+ return { fontSize, lines: lines.slice(0, maxLines) };
238
+ };
239
+
240
+ const visibleXTicks = (() => {
241
+ if (xValues.length <= 6) return xValues;
242
+ const maxLabels = Math.max(2, Math.floor(innerWidth / Math.max(42, width * 0.08)));
243
+ const step = Math.ceil(xValues.length / maxLabels);
244
+ const ticks = xValues.filter((_, i) => i % step === 0);
245
+ if (ticks[ticks.length - 1] !== xValues[xValues.length - 1]) ticks.push(xValues[xValues.length - 1]);
246
+ return ticks;
247
+ })();
248
+
249
+ labelOrder.forEach((group, rowIndex) => {
250
+ const rowY = yScale(initialRankByGroup[group]);
251
+ const color = getColor(group);
252
+ const safeRowTop = rowY - rowBandHeight / 2;
253
+
254
+ g.append("rect")
255
+ .attr("class", "bump07-row-card")
256
+ .attr("x", rowBandX)
257
+ .attr("y", safeRowTop)
258
+ .attr("width", rowBandW)
259
+ .attr("height", rowBandHeight)
260
+ .attr("rx", Math.min(17, rowBandHeight * 0.20))
261
+ .attr("fill", rowIndex % 2 === 0 ? "#FFFFFF" : "#FBFDFF")
262
+ .attr("stroke", "#E5F0F7")
263
+ .attr("stroke-width", 1)
264
+ .attr("filter", "url(#bump07-row-shadow)");
265
+
266
+ g.append("circle")
267
+ .attr("class", "bump07-rank-badge")
268
+ .attr("cx", badgeX)
269
+ .attr("cy", rowY)
270
+ .attr("r", badgeR)
271
+ .attr("fill", color);
272
+
273
+ g.append("text")
274
+ .attr("class", "bump07-rank-label")
275
+ .attr("x", badgeX)
276
+ .attr("y", rowY + Math.max(5, badgeR * 0.32))
277
+ .attr("text-anchor", "middle")
278
+ .attr("font-family", "Arial, Helvetica, sans-serif")
279
+ .attr("font-weight", "900")
280
+ .attr("font-size", Math.max(16, Math.min(24, badgeR * 1.22)))
281
+ .attr("fill", "#FFFFFF")
282
+ .text(groups.indexOf(group) + 1);
283
+
284
+ const slot = g.append("g")
285
+ .attr("class", "reserved-asset-slot bump07-group-icon-slot")
286
+ .attr("data-asset-slot", "group-icon")
287
+ .attr("data-asset-source-policy", jsonData.images && jsonData.images.field && jsonData.images.field[group] ? "input_provided" : "neutral_placeholder")
288
+ .attr("data-anchor", `row-${rowIndex + 1}-center`)
289
+ .attr("data-collision-rule", "stay inside row card, right of rank badge, left of group label and plot")
290
+ .attr("transform", `translate(${iconX},${rowY})`);
291
+ slot.append("circle")
292
+ .attr("r", iconR)
293
+ .attr("fill", "#F2FAFF")
294
+ .attr("stroke", color)
295
+ .attr("stroke-width", 2)
296
+ .attr("opacity", 0.95);
297
+ if (jsonData.images && jsonData.images.field && jsonData.images.field[group]) {
298
+ slot.append("image")
299
+ .attr("class", "input-provided-group-icon")
300
+ .attr("data-asset-slot", "group-icon-image")
301
+ .attr("data-asset-source-policy", "input_provided")
302
+ .attr("xlink:href", jsonData.images.field[group])
303
+ .attr("x", -iconR * 0.72)
304
+ .attr("y", -iconR * 0.72)
305
+ .attr("width", iconR * 1.44)
306
+ .attr("height", iconR * 1.44)
307
+ .attr("preserveAspectRatio", "xMidYMid meet");
308
+ } else {
309
+ slot.append("text")
310
+ .attr("text-anchor", "middle")
311
+ .attr("dy", "0.36em")
312
+ .attr("font-family", "Arial, Helvetica, sans-serif")
313
+ .attr("font-weight", "900")
314
+ .attr("font-size", Math.max(10, iconR * 0.58))
315
+ .attr("fill", color)
316
+ .text(String(group).trim().slice(0, 1).toUpperCase());
317
+ }
318
+
319
+ const labelWrap = wrapGroupLabel(group);
320
+ const labelFontSize = labelWrap.fontSize;
321
+ const labelLines = labelWrap.lines;
322
+ const label = g.append("text")
323
+ .attr("class", "bump07-group-label")
324
+ .attr("x", labelX)
325
+ .attr("y", rowY - ((labelLines.length - 1) * labelFontSize * 0.54))
326
+ .attr("text-anchor", "start")
327
+ .attr("dominant-baseline", "middle")
328
+ .attr("font-family", "Arial, Helvetica, sans-serif")
329
+ .attr("font-weight", "900")
330
+ .attr("font-size", labelFontSize)
331
+ .attr("fill", color);
332
+ labelLines.forEach((line, lineIndex) => {
333
+ label.append("tspan")
334
+ .attr("x", labelX)
335
+ .attr("dy", lineIndex === 0 ? 0 : labelFontSize * 1.08)
336
+ .text(line);
337
+ });
338
+
339
+ g.append("line")
340
+ .attr("class", "bump07-label-divider")
341
+ .attr("x1", dividerX)
342
+ .attr("x2", dividerX)
343
+ .attr("y1", safeRowTop + 8)
344
+ .attr("y2", safeRowTop + rowBandHeight - 8)
345
+ .attr("stroke", color)
346
+ .attr("stroke-width", 3)
347
+ .attr("stroke-linecap", "round");
348
+ });
349
+
350
+ const axisY = -52;
351
+ visibleXTicks.forEach(tick => {
352
+ const tickX = xScale(tick);
353
+ g.append("line")
354
+ .attr("class", "bump07-vertical-guide")
355
+ .attr("x1", tickX)
356
+ .attr("x2", tickX)
357
+ .attr("y1", axisY + 20)
358
+ .attr("y2", innerHeight + rowBandHeight / 2)
359
+ .attr("stroke", "#BFD5E6")
360
+ .attr("stroke-width", 1.2)
361
+ .attr("stroke-dasharray", "3 5")
362
+ .attr("opacity", 0.95);
363
+
364
+ const label = xFormat(tick);
365
+ const tabW = Math.max(44, Math.min(64, measureText(label, 14, "900") + 22));
366
+ g.append("rect")
367
+ .attr("class", "bump07-time-tab")
368
+ .attr("x", tickX - tabW / 2)
369
+ .attr("y", axisY - 18)
370
+ .attr("width", tabW)
371
+ .attr("height", 32)
372
+ .attr("rx", 7)
373
+ .attr("fill", "#082C55");
374
+
375
+ g.append("text")
376
+ .attr("class", "bump07-time-label")
377
+ .attr("x", tickX)
378
+ .attr("y", axisY + 3)
379
+ .attr("text-anchor", "middle")
380
+ .attr("font-family", "Arial, Helvetica, sans-serif")
381
+ .attr("font-weight", "900")
382
+ .attr("font-size", Math.max(12, Math.min(16, width * 0.025)))
383
+ .attr("fill", "#FFFFFF")
384
+ .text(label);
385
+ });
386
+
387
+ const values = chartData
388
+ .filter(d => d[yField] !== null && d[yField] !== undefined)
389
+ .map(d => +d[yField]);
390
+ const valueMin = Math.min(...values);
391
+ const valueMax = Math.max(...values);
392
+ const radiusScale = d3.scaleLinear()
393
+ .domain([valueMin, valueMax])
394
+ .range([Math.max(4.5, rowBandHeight * 0.055), Math.max(6.5, rowBandHeight * 0.078)]);
395
+
396
+ const groupValueExtent = {};
397
+ groups.forEach(group => {
398
+ const groupValues = rankData[group]
399
+ .filter(d => d.hasData && Number.isFinite(+d.value))
400
+ .map(d => +d.value);
401
+ groupValueExtent[group] = d3.extent(groupValues);
402
+ });
403
+ const localSlopeAmplitude = Math.max(10, Math.min(rowBandHeight * 0.24, rowStep * 0.20));
404
+ const pointY = (group, d) => {
405
+ const baseY = yScale(d.rank);
406
+ const extent = groupValueExtent[group] || [0, 0];
407
+ const span = extent[1] - extent[0];
408
+ if (!Number.isFinite(span) || span <= 0) return baseY;
409
+ const normalized = (+d.value - extent[0]) / span;
410
+ return baseY + (0.5 - normalized) * localSlopeAmplitude * 2;
411
+ };
412
+
413
+ groups.forEach(group => {
414
+ const groupRanks = rankData[group].filter(d => d.hasData);
415
+ if (groupRanks.length > 1) {
416
+ g.append("path")
417
+ .datum(groupRanks)
418
+ .attr("class", "bump07-rank-line")
419
+ .attr("fill", "none")
420
+ .attr("stroke", getColor(group))
421
+ .attr("stroke-width", Math.max(3, Math.min(4.2, rowBandHeight * 0.05)))
422
+ .attr("stroke-linecap", "round")
423
+ .attr("stroke-linejoin", "round")
424
+ .attr("opacity", 0.96)
425
+ .attr("d", d3.line()
426
+ .x(d => xScale(d.x))
427
+ .y(d => pointY(group, d))
428
+ );
429
+ }
430
+ });
431
+
432
+ groups.forEach(group => {
433
+ rankData[group].forEach(d => {
434
+ if (!d.hasData) return;
435
+ const x = xScale(d.x);
436
+ const y = pointY(group, d);
437
+ const r = radiusScale(+d.value);
438
+ g.append("circle")
439
+ .attr("class", "bump07-value-node")
440
+ .attr("data-group", group)
441
+ .attr("data-x", d.x)
442
+ .attr("cx", x)
443
+ .attr("cy", y)
444
+ .attr("r", r)
445
+ .attr("fill", getColor(group))
446
+ .attr("stroke", "#FFFFFF")
447
+ .attr("stroke-width", 2);
448
+ });
449
+ });
450
+
451
+ groups.forEach(group => {
452
+ rankData[group].forEach(d => {
453
+ if (!d.hasData) return;
454
+ const x = xScale(d.x);
455
+ const y = pointY(group, d);
456
+ const rowCenterY = yScale(d.rank);
457
+ const r = radiusScale(+d.value);
458
+ const text = chartUtils.format.autoText(+d.value);
459
+ const labelFont = Math.max(10, Math.min(14, rowBandHeight * 0.16));
460
+ const chipW = Math.max(28, measureText(text, labelFont, "900") + 12);
461
+ const chipH = Math.max(20, labelFont + 8);
462
+ let labelY = y - r - chipH / 2 - Math.max(6, rowBandHeight * 0.045);
463
+ const rowTop = rowCenterY - rowBandHeight / 2 + chipH / 2 + 5;
464
+ const rowBottom = rowCenterY + rowBandHeight / 2 - chipH / 2 - 5;
465
+ labelY = Math.max(rowTop, Math.min(rowBottom, labelY));
466
+ let labelX = Math.max(chipW / 2 + 2, Math.min(innerWidth - chipW / 2 - 2, x));
467
+
468
+ g.append("rect")
469
+ .attr("class", "bump07-value-chip")
470
+ .attr("x", labelX - chipW / 2)
471
+ .attr("y", labelY - chipH / 2)
472
+ .attr("width", chipW)
473
+ .attr("height", chipH)
474
+ .attr("rx", 5)
475
+ .attr("fill", getColor(group));
476
+
477
+ g.append("text")
478
+ .attr("class", "bump07-value-label")
479
+ .attr("x", labelX)
480
+ .attr("y", labelY + labelFont * 0.34)
481
+ .attr("text-anchor", "middle")
482
+ .attr("font-family", "Arial, Helvetica, sans-serif")
483
+ .attr("font-weight", "900")
484
+ .attr("font-size", labelFont)
485
+ .attr("fill", "#FFFFFF")
486
+ .text(text);
487
+ });
488
+ });
489
+
490
+ return svg.node();
491
+ }
modules/chart_engine/template/d3-js/line_graph/bump_chart_09.js ADDED
@@ -0,0 +1,415 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Sorted Lines Chart",
5
+ "chart_name": "bump_chart_09",
6
+ "is_composite": false,
7
+ "chart_for": "comparison",
8
+ "required_fields": ["x", "y", "group"],
9
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
10
+ "required_fields_range": [
11
+ [2, 12],
12
+ [0, "inf"],
13
+ [4, 10]
14
+ ],
15
+ "required_fields_icons": ["group"],
16
+ "required_other_icons": [],
17
+ "required_fields_colors": ["group"],
18
+ "required_other_colors": [],
19
+ "supported_effects": ["spacing"],
20
+ "min_height": 400,
21
+ "min_width": 400,
22
+ "background": "no",
23
+ "icon_mark": "none",
24
+ "icon_label": "side",
25
+ "has_x_axis": "no",
26
+ "has_y_axis": "no"
27
+ }
28
+ REQUIREMENTS_END
29
+ */
30
+
31
+ function makeChart(containerSelector, data) {
32
+ const jsonData = data;
33
+ const chartData = jsonData.data.data;
34
+ const variables = jsonData.variables || {};
35
+ const colorResolver = chartUtils.color.resolver(jsonData);
36
+ const images = jsonData.images || { field: {}, other: {} };
37
+
38
+ d3.select(containerSelector).html("");
39
+
40
+ const width = +variables.width || 800;
41
+ const height = +variables.height || 600;
42
+ const timeField = chartUtils.schema.channel(jsonData, "x").key;
43
+ const valueField = chartUtils.schema.channel(jsonData, "y").key;
44
+ const groupField = chartUtils.schema.channel(jsonData, "group").key;
45
+
46
+ const timePoints = [...new Set(chartData.map(d => d[timeField]))]
47
+ .sort((a, b) => {
48
+ const da = chartUtils.format.parseDate(a);
49
+ const db = chartUtils.format.parseDate(b);
50
+ const ta = da instanceof Date ? da.getTime() : +da;
51
+ const tb = db instanceof Date ? db.getTime() : +db;
52
+ if (Number.isFinite(ta) && Number.isFinite(tb) && ta !== tb) return ta - tb;
53
+ return String(a).localeCompare(String(b), undefined, { numeric: true });
54
+ });
55
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
56
+ const valueUnit = chartUtils.schema.channel(jsonData, "y").unit !== "none"
57
+ ? chartUtils.schema.channel(jsonData, "y").unit
58
+ : "";
59
+
60
+ const svg = d3.select(containerSelector)
61
+ .append("svg")
62
+ .attr("width", "100%")
63
+ .attr("height", height)
64
+ .attr("viewBox", `0 0 ${width} ${height}`)
65
+ .attr("style", "max-width: 100%; height: auto;")
66
+ .attr("xmlns", "http://www.w3.org/2000/svg")
67
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
68
+
69
+ const defs = svg.append("defs");
70
+ defs.append("filter")
71
+ .attr("id", "bump09-row-shadow")
72
+ .attr("x", "-8%")
73
+ .attr("y", "-20%")
74
+ .attr("width", "116%")
75
+ .attr("height", "150%")
76
+ .append("feDropShadow")
77
+ .attr("dx", 0)
78
+ .attr("dy", 4)
79
+ .attr("stdDeviation", 6)
80
+ .attr("flood-color", "#0A3152")
81
+ .attr("flood-opacity", 0.11);
82
+
83
+ const headerGradient = defs.append("linearGradient")
84
+ .attr("id", "bump09-header-gradient")
85
+ .attr("x1", "0%")
86
+ .attr("x2", "100%")
87
+ .attr("y1", "0%")
88
+ .attr("y2", "0%");
89
+ headerGradient.append("stop").attr("offset", "0%").attr("stop-color", "#073D78");
90
+ headerGradient.append("stop").attr("offset", "100%").attr("stop-color", "#082A61");
91
+
92
+ const markerGradientIds = {};
93
+ groups.forEach((group, index) => {
94
+ const color = getColor(group, index);
95
+ const c = d3.color(color) || d3.color("#6B45D8");
96
+ const gradient = defs.append("radialGradient")
97
+ .attr("id", `bump09-dot-gradient-${index}`)
98
+ .attr("cx", "34%")
99
+ .attr("cy", "28%")
100
+ .attr("r", "78%");
101
+ gradient.append("stop").attr("offset", "0%").attr("stop-color", c.brighter(0.9).formatHex());
102
+ gradient.append("stop").attr("offset", "62%").attr("stop-color", c.formatHex());
103
+ gradient.append("stop").attr("offset", "100%").attr("stop-color", c.darker(0.35).formatHex());
104
+ markerGradientIds[group] = `bump09-dot-gradient-${index}`;
105
+ });
106
+
107
+ const measureText = (text, fontSize, fontWeight = "700") => {
108
+ return chartUtils.text.measure(svg, String(text), {
109
+ fontFamily: "Arial, Helvetica, sans-serif",
110
+ fontSize,
111
+ fontWeight
112
+ }).width;
113
+ };
114
+
115
+ const fitText = (text, maxWidth, fontSize, fontWeight = "800", minFontSize = 8) => {
116
+ let size = fontSize;
117
+ while (size > minFontSize && measureText(text, size, fontWeight) > maxWidth) {
118
+ size -= 1;
119
+ }
120
+ return size;
121
+ };
122
+
123
+ const ellipsize = (text, maxWidth, fontSize, fontWeight = "800") => {
124
+ const value = String(text);
125
+ if (measureText(value, fontSize, fontWeight) <= maxWidth) return value;
126
+ let clipped = value;
127
+ while (clipped.length > 2 && measureText(`${clipped}...`, fontSize, fontWeight) > maxWidth) {
128
+ clipped = clipped.slice(0, -1);
129
+ }
130
+ return `${clipped}...`;
131
+ };
132
+
133
+ const contrastText = (color) => {
134
+ const c = d3.color(color);
135
+ if (!c) return "#FFFFFF";
136
+ const luminance = (0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b) / 255;
137
+ return luminance > 0.62 ? "#082A61" : "#FFFFFF";
138
+ };
139
+
140
+ function getColor(group, index = 0) {
141
+ const scale = colorResolver.scale(groups, { palette: "category10" });
142
+ return scale(group) || colorResolver.field(group, index, { fallback: "#6B45D8" }).value || "#6B45D8";
143
+ }
144
+
145
+ const rowOuterX = Math.max(6, width * 0.015);
146
+ const rowOuterW = width - rowOuterX * 2;
147
+ const headerTop = Math.max(8, height * 0.02);
148
+ const headerH = Math.max(30, Math.min(42, height * 0.09));
149
+ const headerGap = Math.max(8, Math.min(13, height * 0.025));
150
+ let rowGap = Math.max(6, Math.min(10, height * 0.018));
151
+ const hasUnitNote = Boolean(valueUnit) && height >= 330;
152
+ let unitH = hasUnitNote ? Math.max(26, Math.min(34, height * 0.075)) : 0;
153
+ const bottomPad = Math.max(7, height * 0.018);
154
+ let rowH = (height - headerTop - headerH - headerGap - bottomPad - unitH - rowGap * Math.max(0, groups.length - 1)) / Math.max(1, groups.length);
155
+ if (rowH < 36 && hasUnitNote) {
156
+ unitH = 0;
157
+ rowGap = Math.max(4, rowGap - 2);
158
+ rowH = (height - headerTop - headerH - headerGap - bottomPad - rowGap * Math.max(0, groups.length - 1)) / Math.max(1, groups.length);
159
+ }
160
+ rowH = Math.max(30, Math.min(68, rowH));
161
+
162
+ const maxLabelMeasure = d3.max(groups, group => measureText(group, 14, "900")) || 80;
163
+ const labelColumnW = Math.max(
164
+ Math.min(width * 0.38, maxLabelMeasure + 64),
165
+ Math.min(width * 0.31, 122)
166
+ );
167
+ const plotX0 = rowOuterX + labelColumnW;
168
+ const plotX1 = width - Math.max(22, width * 0.045);
169
+ const xScale = d3.scalePoint()
170
+ .domain(timePoints)
171
+ .range([plotX0, plotX1])
172
+ .padding(0.08);
173
+
174
+ svg.append("g")
175
+ .attr("class", "reserved-asset-slot bump09-header-illustration-policy")
176
+ .attr("data-asset-slot", "header-illustration")
177
+ .attr("data-asset-source-policy", "input_asset_or_image2_asset")
178
+ .attr("data-anchor", "page-header-image-bbox")
179
+ .attr("data-collision-rule", "owned by wrapper; must avoid title/subtitle and chart bbox")
180
+ .attr("data-no-copied-reference-art", "true")
181
+ .attr("opacity", 0);
182
+
183
+ svg.append("rect")
184
+ .attr("class", "bump09-header-band")
185
+ .attr("x", rowOuterX)
186
+ .attr("y", headerTop)
187
+ .attr("width", rowOuterW)
188
+ .attr("height", headerH)
189
+ .attr("rx", Math.min(14, headerH * 0.35))
190
+ .attr("fill", "url(#bump09-header-gradient)");
191
+
192
+ svg.append("text")
193
+ .attr("class", "bump09-country-header")
194
+ .attr("x", rowOuterX + 18)
195
+ .attr("y", headerTop + headerH / 2)
196
+ .attr("dominant-baseline", "middle")
197
+ .attr("text-anchor", "start")
198
+ .attr("font-family", "Arial, Helvetica, sans-serif")
199
+ .attr("font-size", Math.max(11, Math.min(16, headerH * 0.34)))
200
+ .attr("font-weight", "900")
201
+ .attr("fill", "#FFFFFF")
202
+ .text(String(groupField).toUpperCase());
203
+
204
+ timePoints.forEach(timePoint => {
205
+ const label = String(timePoint);
206
+ const labelSize = fitText(label, Math.max(24, (plotX1 - plotX0) / Math.max(1, timePoints.length) - 6), Math.max(11, Math.min(16, headerH * 0.36)), "900", 8);
207
+ svg.append("text")
208
+ .attr("class", "bump09-year-header")
209
+ .attr("x", xScale(timePoint))
210
+ .attr("y", headerTop + headerH / 2)
211
+ .attr("dominant-baseline", "middle")
212
+ .attr("text-anchor", "middle")
213
+ .attr("font-family", "Arial, Helvetica, sans-serif")
214
+ .attr("font-size", labelSize)
215
+ .attr("font-weight", "900")
216
+ .attr("fill", "#FFFFFF")
217
+ .text(label);
218
+ });
219
+
220
+ const values = chartData
221
+ .map(d => +d[valueField])
222
+ .filter(v => Number.isFinite(v));
223
+ const minValue = d3.min(values) || 0;
224
+ const maxValue = d3.max(values) || 1;
225
+ const minColumnGap = timePoints.length > 1
226
+ ? d3.min(timePoints.slice(1).map((d, i) => xScale(d) - xScale(timePoints[i])))
227
+ : plotX1 - plotX0;
228
+ const maxRadius = Math.max(7, Math.min(rowH * 0.32, minColumnGap * 0.29, 24));
229
+ const minRadius = Math.max(5, Math.min(maxRadius * 0.62, rowH * 0.20));
230
+ const radiusScale = d3.scaleSqrt()
231
+ .domain([minValue, maxValue])
232
+ .range([minRadius, maxRadius])
233
+ .clamp(true);
234
+ const rowTop0 = headerTop + headerH + headerGap;
235
+
236
+ groups.forEach((group, index) => {
237
+ const color = getColor(group, index);
238
+ const rowY = rowTop0 + index * (rowH + rowGap);
239
+ const rowCenterY = rowY + rowH / 2;
240
+
241
+ svg.append("rect")
242
+ .attr("class", "bump09-row-card")
243
+ .attr("x", rowOuterX)
244
+ .attr("y", rowY)
245
+ .attr("width", rowOuterW)
246
+ .attr("height", rowH)
247
+ .attr("rx", Math.min(14, rowH * 0.18))
248
+ .attr("fill", index % 2 === 0 ? "#FFFFFF" : "#FBFDFF")
249
+ .attr("filter", "url(#bump09-row-shadow)");
250
+
251
+ svg.append("rect")
252
+ .attr("class", "bump09-row-rail")
253
+ .attr("x", rowOuterX)
254
+ .attr("y", rowY + 2)
255
+ .attr("width", Math.max(5, Math.min(10, width * 0.016)))
256
+ .attr("height", Math.max(1, rowH - 4))
257
+ .attr("rx", Math.min(8, rowH * 0.14))
258
+ .attr("fill", color);
259
+
260
+ const iconR = Math.max(9, Math.min(17, rowH * 0.24));
261
+ const iconX = rowOuterX + Math.max(24, iconR + 17);
262
+ const labelX = iconX + iconR + Math.max(8, width * 0.016);
263
+ const labelMaxW = Math.max(42, plotX0 - labelX - 14);
264
+ const slot = svg.append("g")
265
+ .attr("class", "reserved-asset-slot bump09-group-icon-slot")
266
+ .attr("data-asset-slot", "group-icon")
267
+ .attr("data-asset-source-policy", images.field && images.field[group] ? "input_provided" : "neutral_placeholder")
268
+ .attr("data-anchor", `row-${index + 1}-centerline`)
269
+ .attr("data-collision-rule", "stay inside row card and left of group label/year columns")
270
+ .attr("transform", `translate(${iconX},${rowCenterY})`);
271
+ slot.append("circle")
272
+ .attr("r", iconR)
273
+ .attr("fill", "#F2F7FD")
274
+ .attr("stroke", "#FFFFFF")
275
+ .attr("stroke-width", 2.2)
276
+ .attr("filter", "url(#bump09-row-shadow)");
277
+ if (images.field && images.field[group]) {
278
+ slot.append("image")
279
+ .attr("class", "input-provided-group-icon")
280
+ .attr("data-asset-slot", "group-icon-image")
281
+ .attr("data-asset-source-policy", "input_provided")
282
+ .attr("xlink:href", images.field[group])
283
+ .attr("x", -iconR * 0.82)
284
+ .attr("y", -iconR * 0.82)
285
+ .attr("width", iconR * 1.64)
286
+ .attr("height", iconR * 1.64)
287
+ .attr("preserveAspectRatio", "xMidYMid meet");
288
+ } else {
289
+ slot.append("text")
290
+ .attr("text-anchor", "middle")
291
+ .attr("dominant-baseline", "middle")
292
+ .attr("font-family", "Arial, Helvetica, sans-serif")
293
+ .attr("font-weight", "900")
294
+ .attr("font-size", Math.max(9, iconR * 0.72))
295
+ .attr("fill", color)
296
+ .text(String(group).trim().slice(0, 1).toUpperCase());
297
+ }
298
+
299
+ const baseLabelFont = Math.max(10, Math.min(17, rowH * 0.25));
300
+ const labelFont = fitText(group, labelMaxW, baseLabelFont, "900", 8);
301
+ const labelText = labelFont > 8.5 ? String(group) : ellipsize(group, labelMaxW, labelFont, "900");
302
+ svg.append("text")
303
+ .attr("class", "bump09-group-label")
304
+ .attr("x", labelX)
305
+ .attr("y", rowCenterY)
306
+ .attr("dominant-baseline", "middle")
307
+ .attr("text-anchor", "start")
308
+ .attr("font-family", "Arial, Helvetica, sans-serif")
309
+ .attr("font-size", labelFont)
310
+ .attr("font-weight", "900")
311
+ .attr("fill", "#082A61")
312
+ .text(labelText);
313
+
314
+ const groupData = chartData
315
+ .filter(d => d[groupField] === group)
316
+ .sort((a, b) => timePoints.indexOf(a[timeField]) - timePoints.indexOf(b[timeField]));
317
+ const points = groupData
318
+ .filter(d => timePoints.includes(d[timeField]) && Number.isFinite(+d[valueField]))
319
+ .map(d => ({
320
+ x: xScale(d[timeField]),
321
+ y: rowCenterY,
322
+ value: +d[valueField],
323
+ time: d[timeField]
324
+ }));
325
+
326
+ if (points.length >= 2) {
327
+ svg.append("path")
328
+ .datum(points)
329
+ .attr("class", "bump09-connector-line")
330
+ .attr("fill", "none")
331
+ .attr("stroke", color)
332
+ .attr("stroke-width", Math.max(2, Math.min(3.2, rowH * 0.045)))
333
+ .attr("stroke-linecap", "round")
334
+ .attr("stroke-linejoin", "round")
335
+ .attr("opacity", 0.96)
336
+ .attr("d", d3.line().x(d => d.x).y(d => d.y));
337
+ }
338
+
339
+ points.forEach(point => {
340
+ const r = radiusScale(point.value);
341
+ svg.append("circle")
342
+ .attr("class", "bump09-value-bubble")
343
+ .attr("data-group", group)
344
+ .attr("data-x", point.time)
345
+ .attr("data-value", point.value)
346
+ .attr("cx", point.x)
347
+ .attr("cy", point.y)
348
+ .attr("r", r)
349
+ .attr("fill", `url(#${markerGradientIds[group]})`)
350
+ .attr("stroke", "rgba(255,255,255,0.82)")
351
+ .attr("stroke-width", Math.max(1.2, r * 0.10));
352
+
353
+ const formattedValue = chartUtils.format.compactFixedText(point.value);
354
+ const valueFont = fitText(formattedValue, r * 1.55, Math.max(9, Math.min(18, r * 0.72)), "900", 7);
355
+ svg.append("text")
356
+ .attr("class", "bump09-value-label")
357
+ .attr("x", point.x)
358
+ .attr("y", point.y)
359
+ .attr("dominant-baseline", "middle")
360
+ .attr("text-anchor", "middle")
361
+ .attr("font-family", "Arial, Helvetica, sans-serif")
362
+ .attr("font-size", valueFont)
363
+ .attr("font-weight", "900")
364
+ .attr("fill", contrastText(color))
365
+ .text(formattedValue);
366
+ });
367
+ });
368
+
369
+ if (unitH > 0) {
370
+ const unitLabel = valueUnit === "$m" ? "million USD" : valueUnit;
371
+ const unitText = `All values are in ${unitLabel}.`;
372
+ const maxPillW = Math.min(width * 0.64, width - rowOuterX * 2);
373
+ const pillW = Math.min(maxPillW, Math.max(150, measureText(unitText, 11, "600") + 58));
374
+ const pillX = (width - pillW) / 2;
375
+ const pillY = Math.min(height - unitH - bottomPad / 2, rowTop0 + groups.length * rowH + Math.max(5, rowGap * groups.length));
376
+ const note = svg.append("g")
377
+ .attr("class", "bump09-unit-note")
378
+ .attr("data-chart-owned-region", "unit-note")
379
+ .attr("data-anchor", "bottom-center-below-row-cards")
380
+ .attr("data-collision-rule", "hide or move if it intersects bottom row card");
381
+ note.append("rect")
382
+ .attr("x", pillX)
383
+ .attr("y", pillY)
384
+ .attr("width", pillW)
385
+ .attr("height", unitH)
386
+ .attr("rx", unitH / 2)
387
+ .attr("fill", "#E7F2FB")
388
+ .attr("stroke", "#C9DFF2")
389
+ .attr("stroke-width", 1);
390
+ const iconX = pillX + unitH * 0.55;
391
+ const barBaseY = pillY + unitH * 0.66;
392
+ [0.34, 0.52, 0.76].forEach((scale, i) => {
393
+ note.append("rect")
394
+ .attr("x", iconX + i * 5)
395
+ .attr("y", barBaseY - unitH * scale)
396
+ .attr("width", 3.4)
397
+ .attr("height", unitH * scale)
398
+ .attr("rx", 1)
399
+ .attr("fill", i === 2 ? "#08336F" : "#4B82BC");
400
+ });
401
+ const unitFont = fitText(unitText, pillW - unitH * 1.62, Math.max(9, Math.min(12, unitH * 0.36)), "600", 7);
402
+ note.append("text")
403
+ .attr("x", pillX + unitH * 1.35)
404
+ .attr("y", pillY + unitH / 2)
405
+ .attr("dominant-baseline", "middle")
406
+ .attr("text-anchor", "start")
407
+ .attr("font-family", "Arial, Helvetica, sans-serif")
408
+ .attr("font-size", unitFont)
409
+ .attr("font-weight", "600")
410
+ .attr("fill", "#082A61")
411
+ .text(unitText);
412
+ }
413
+
414
+ return svg.node();
415
+ }
modules/chart_engine/template/d3-js/line_graph/bump_chart_10.js ADDED
@@ -0,0 +1,482 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Sorted Lines Chart",
5
+ "chart_name": "bump_chart_10",
6
+ "is_composite": false,
7
+ "chart_for": "comparison",
8
+ "required_fields": ["x", "y", "group"],
9
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
10
+ "required_fields_range": [
11
+ [2, 12],
12
+ [0, "inf"],
13
+ [4, 10]
14
+ ],
15
+ "required_fields_icons": ["group"],
16
+ "required_other_icons": [],
17
+ "required_fields_colors": ["group"],
18
+ "required_other_colors": [],
19
+ "supported_effects": ["spacing"],
20
+ "min_height": 400,
21
+ "min_width": 400,
22
+ "background": "no",
23
+ "icon_mark": "none",
24
+ "icon_label": "side",
25
+ "has_x_axis": "no",
26
+ "has_y_axis": "no"
27
+ }
28
+ REQUIREMENTS_END
29
+ */
30
+
31
+ function makeChart(containerSelector, data) {
32
+ const jsonData = data;
33
+ const chartData = (jsonData.data && jsonData.data.data) || [];
34
+ const variables = jsonData.variables || {};
35
+ const typography = jsonData.typography || {};
36
+ const images = jsonData.images || { field: {}, other: {} };
37
+ const colorResolver = chartUtils.color.resolver(jsonData);
38
+
39
+ d3.select(containerSelector).html("");
40
+
41
+ const width = variables.width || 650;
42
+ const height = variables.height || 975;
43
+ const timeField = chartUtils.schema.channel(jsonData, "x").key;
44
+ const valueField = chartUtils.schema.channel(jsonData, "y").key;
45
+ const groupField = chartUtils.schema.channel(jsonData, "group").key;
46
+
47
+ function safeText(value) {
48
+ return value == null ? "" : String(value);
49
+ }
50
+
51
+ function measureText(text, fontSize, fontFamily, fontWeight) {
52
+ return chartUtils.text.measure(null, safeText(text), {
53
+ fontFamily: fontFamily,
54
+ fontSize: fontSize,
55
+ fontWeight: fontWeight
56
+ }).width;
57
+ }
58
+
59
+ function fitFont(text, maxWidth, start, family, weight, minSize) {
60
+ let size = start;
61
+ while (size > minSize && measureText(text, size, family, weight) > maxWidth) {
62
+ size -= 1;
63
+ }
64
+ return size;
65
+ }
66
+
67
+ function wrapText(selection, text, maxWidth, maxLines, lineHeight, fontSize, family, weight) {
68
+ const lines = computeWrappedLines(text, maxWidth, fontSize, family, weight);
69
+ const visible = lines.slice(0, maxLines);
70
+ if (lines.length > maxLines && visible.length) {
71
+ let last = visible[visible.length - 1];
72
+ while (last.length > 1 && measureText(`${last}...`, fontSize, family, weight) > maxWidth) {
73
+ last = last.slice(0, -1);
74
+ }
75
+ visible[visible.length - 1] = `${last}...`;
76
+ }
77
+ selection.selectAll("tspan")
78
+ .data(visible)
79
+ .enter()
80
+ .append("tspan")
81
+ .attr("x", selection.attr("x"))
82
+ .attr("dy", (d, i) => i === 0 ? 0 : lineHeight)
83
+ .text(d => d);
84
+ return visible.length;
85
+ }
86
+
87
+ function computeWrappedLines(text, maxWidth, fontSize, family, weight) {
88
+ const words = safeText(text).split(/\s+/).filter(Boolean);
89
+ const lines = [];
90
+ let line = "";
91
+ words.forEach(word => {
92
+ const candidate = line ? `${line} ${word}` : word;
93
+ if (measureText(candidate, fontSize, family, weight) <= maxWidth || !line) {
94
+ line = candidate;
95
+ } else {
96
+ lines.push(line);
97
+ line = word;
98
+ }
99
+ });
100
+ if (line) lines.push(line);
101
+ return lines;
102
+ }
103
+
104
+ function parseTimeValue(value) {
105
+ const parsed = chartUtils.format.parseDate(value);
106
+ if (parsed instanceof Date && !Number.isNaN(parsed.getTime())) return parsed.getTime();
107
+ const numeric = +value;
108
+ return Number.isFinite(numeric) ? numeric : safeText(value);
109
+ }
110
+
111
+ const timePoints = [...new Set(chartData.map(d => d[timeField]))]
112
+ .sort((a, b) => {
113
+ const pa = parseTimeValue(a);
114
+ const pb = parseTimeValue(b);
115
+ if (typeof pa === "number" && typeof pb === "number") return pa - pb;
116
+ return safeText(a).localeCompare(safeText(b));
117
+ });
118
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
119
+ const grouped = new Map(groups.map(group => [
120
+ group,
121
+ chartData.filter(d => d[groupField] === group)
122
+ .sort((a, b) => timePoints.indexOf(a[timeField]) - timePoints.indexOf(b[timeField]))
123
+ ]));
124
+
125
+ const titleFamily = (typography.title && typography.title.font_family) || "Arial, Helvetica, sans-serif";
126
+ const labelFamily = (typography.label && typography.label.font_family) || "Arial, Helvetica, sans-serif";
127
+ const annotationFamily = (typography.annotation && typography.annotation.font_family) || "Arial, Helvetica, sans-serif";
128
+ let titleText = safeText((jsonData.titles && jsonData.titles.main_title) || (jsonData.metadata && jsonData.metadata.title) || "Positive Trends");
129
+ titleText = titleText
130
+ .replace(/^Positive\s+Trends?\s+in\s+/i, "")
131
+ .replace(/^Positive\s+Trends?\s+Across\s+/i, "")
132
+ .trim() || titleText;
133
+ const subtitleText = safeText((jsonData.titles && jsonData.titles.sub_title) || (jsonData.metadata && jsonData.metadata.description) || jsonData.description || "");
134
+
135
+ const rowRamps = [
136
+ ["#f7d9e6", "#f06fa2", "#e94a72", "#842842"],
137
+ ["#98e1e8", "#44c4e3", "#247ed1", "#06477c"],
138
+ ["#ffc39e", "#ff7246", "#e64a2b", "#7d2319"],
139
+ ["#8adfe0", "#35bad5", "#207eb9", "#054a78"],
140
+ ["#ffe39a", "#f7c232", "#c39b20", "#8a6a16"],
141
+ ["#c9d7ff", "#7da0ff", "#4b68d9", "#24317e"],
142
+ ["#e5c8ff", "#bb7ef0", "#8d54cf", "#4e2d80"],
143
+ ["#b7ead0", "#67c88f", "#31935b", "#176238"]
144
+ ];
145
+
146
+ const svg = d3.select(containerSelector)
147
+ .append("svg")
148
+ .attr("width", "100%")
149
+ .attr("height", height)
150
+ .attr("viewBox", `0 0 ${width} ${height}`)
151
+ .attr("style", "max-width: 100%; height: auto;")
152
+ .attr("xmlns", "http://www.w3.org/2000/svg")
153
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
154
+
155
+ const defs = svg.append("defs");
156
+ defs.append("filter")
157
+ .attr("id", "bump10-soft-shadow")
158
+ .attr("x", "-12%")
159
+ .attr("y", "-12%")
160
+ .attr("width", "124%")
161
+ .attr("height", "130%")
162
+ .html('<feDropShadow dx="0" dy="4" stdDeviation="5" flood-color="#1b3327" flood-opacity="0.12"/>');
163
+
164
+ const bg = defs.append("radialGradient")
165
+ .attr("id", "bump10-bg")
166
+ .attr("cx", "72%")
167
+ .attr("cy", "8%")
168
+ .attr("r", "86%");
169
+ bg.append("stop").attr("offset", "0%").attr("stop-color", "#fff8e4");
170
+ bg.append("stop").attr("offset", "54%").attr("stop-color", "#fbfaf6");
171
+ bg.append("stop").attr("offset", "100%").attr("stop-color", "#f6fbfd");
172
+
173
+ svg.append("rect")
174
+ .attr("width", width)
175
+ .attr("height", height)
176
+ .attr("fill", "url(#bump10-bg)");
177
+
178
+ const badge = svg.append("g")
179
+ .attr("class", "bump10-positive-badge")
180
+ .attr("transform", "translate(38,42)");
181
+ badge.append("rect")
182
+ .attr("width", 250)
183
+ .attr("height", 38)
184
+ .attr("rx", 14)
185
+ .attr("fill", "#2f6a37");
186
+ badge.append("text")
187
+ .attr("x", 18)
188
+ .attr("y", 25)
189
+ .attr("font-family", labelFamily)
190
+ .attr("font-size", 18)
191
+ .attr("font-weight", 800)
192
+ .attr("letter-spacing", 2)
193
+ .attr("fill", "#ffffff")
194
+ .text("POSITIVE TRENDS");
195
+ badge.append("rect")
196
+ .attr("class", "reserved-asset-slot bump10-badge-trend-slot")
197
+ .attr("data-asset-slot", "badge-trend-mark")
198
+ .attr("data-slot-policy", "neutral_placeholder_or_image2_asset")
199
+ .attr("data-anchor", "badge-right-center")
200
+ .attr("data-collision-rule", "stay inside badge padding and clear of badge text")
201
+ .attr("x", 216)
202
+ .attr("y", 9)
203
+ .attr("width", 22)
204
+ .attr("height", 20)
205
+ .attr("rx", 5)
206
+ .attr("fill", "none")
207
+ .attr("stroke", "#e8f4de")
208
+ .attr("stroke-width", 2)
209
+ .attr("opacity", 0.95);
210
+
211
+ const titleMaxWidth = 365;
212
+ let titleFont = Math.min(
213
+ fitFont(titleText, 720, 37, titleFamily, 900, 26),
214
+ fitFont(titleText.split(/\s+/).reduce((a, b) => a.length > b.length ? a : b, ""), titleMaxWidth, 37, titleFamily, 900, 26)
215
+ );
216
+ while (titleFont > 24 && computeWrappedLines(titleText, titleMaxWidth, titleFont, titleFamily, 900).length > 2) {
217
+ titleFont -= 1;
218
+ }
219
+ const titleLineHeight = Math.round(titleFont * 1.15);
220
+ const title = svg.append("text")
221
+ .attr("class", "bump10-title")
222
+ .attr("x", 38)
223
+ .attr("y", 132)
224
+ .attr("font-family", titleFamily)
225
+ .attr("font-size", titleFont)
226
+ .attr("font-weight", 900)
227
+ .attr("fill", "#07331d");
228
+ wrapText(title, titleText, titleMaxWidth, 2, titleLineHeight, titleFont, titleFamily, 900);
229
+
230
+ const subtitle = svg.append("text")
231
+ .attr("class", "bump10-subtitle")
232
+ .attr("x", 44)
233
+ .attr("y", 244)
234
+ .attr("font-family", labelFamily)
235
+ .attr("font-size", 17)
236
+ .attr("font-weight", 400)
237
+ .attr("fill", "#5d6267");
238
+ wrapText(subtitle, subtitleText, 350, 3, 25, 17, labelFamily, 400);
239
+
240
+ const headerSlot = svg.append("g")
241
+ .attr("class", "reserved-asset-slot bump10-header-art-slot")
242
+ .attr("data-asset-slot", "header-farm-illustration")
243
+ .attr("data-slot-policy", "input_provided_or_image2_asset")
244
+ .attr("data-asset-source-policy", "input_provided_or_image2_asset")
245
+ .attr("data-anchor", "top-right-header-safe-area")
246
+ .attr("data-bbox", "430,58,220,220")
247
+ .attr("data-mask-policy", "rounded clipped bbox")
248
+ .attr("data-collision-rule", "stay right of title/subtitle and above year rail")
249
+ .attr("transform", "translate(430,58)");
250
+ const headerClipId = `bump10-header-clip-${Math.floor(Math.random() * 1000000)}`;
251
+ defs.append("clipPath")
252
+ .attr("id", headerClipId)
253
+ .append("rect")
254
+ .attr("x", 0)
255
+ .attr("y", 0)
256
+ .attr("width", 220)
257
+ .attr("height", 220)
258
+ .attr("rx", 28);
259
+ headerSlot.append("rect")
260
+ .attr("width", 220)
261
+ .attr("height", 220)
262
+ .attr("rx", 28)
263
+ .attr("fill", "#e8f2da");
264
+ headerSlot.append("path")
265
+ .attr("d", "M0,174 C45,130 84,158 128,122 C164,94 190,106 220,78 L220,220 L0,220 Z")
266
+ .attr("fill", "#7fa843")
267
+ .attr("opacity", 0.62);
268
+ headerSlot.append("path")
269
+ .attr("d", "M0,206 C52,160 94,184 136,148 C168,122 196,132 220,112 L220,220 L0,220 Z")
270
+ .attr("fill", "#d3c95c")
271
+ .attr("opacity", 0.55);
272
+ headerSlot.append("circle").attr("cx", 72).attr("cy", 76).attr("r", 18).attr("fill", "#ecbd5c").attr("opacity", 0.55);
273
+ headerSlot.append("circle").attr("cx", 176).attr("cy", 104).attr("r", 26).attr("fill", "#2f6a37").attr("opacity", 0.75);
274
+ headerSlot.append("circle").attr("cx", 176).attr("cy", 104).attr("r", 12).attr("fill", "#fbfaf6").attr("opacity", 0.88);
275
+
276
+ const chart = svg.append("g").attr("class", "bump10-chart");
277
+ const left = 28;
278
+ const right = 624;
279
+ const rowTop = 366;
280
+ const rowBottom = 886;
281
+ const rowGap = groups.length > 6 ? 10 : 14;
282
+ const rowHeight = Math.max(58, Math.min(92, (rowBottom - rowTop - rowGap * (groups.length - 1)) / Math.max(groups.length, 1)));
283
+ const iconX = left + 36;
284
+ const labelX = left + 82;
285
+ const markerLeft = left + 218;
286
+ const markerRight = right - 50;
287
+ const markerRadius = Math.max(18, Math.min(31, rowHeight * 0.32, (markerRight - markerLeft) / Math.max(timePoints.length * 2.6, 1)));
288
+ const markerCenters = timePoints.map((t, i) => {
289
+ if (timePoints.length <= 1) return (markerLeft + markerRight) / 2;
290
+ return markerLeft + i * ((markerRight - markerLeft) / (timePoints.length - 1));
291
+ });
292
+ const rowCenters = groups.map((g, i) => rowTop + rowHeight / 2 + i * (rowHeight + rowGap));
293
+
294
+ const railY = 318;
295
+ chart.append("line")
296
+ .attr("class", "bump10-year-rail")
297
+ .attr("x1", markerCenters[0] - 50)
298
+ .attr("x2", markerCenters[markerCenters.length - 1] + 50)
299
+ .attr("y1", railY)
300
+ .attr("y2", railY)
301
+ .attr("stroke", "#2c6034")
302
+ .attr("stroke-width", 2)
303
+ .attr("stroke-linecap", "round");
304
+ const yearStep = timePoints.length <= 1 ? 92 : Math.abs(markerCenters[1] - markerCenters[0]);
305
+ const yearPillWidth = Math.max(32, Math.min(68, yearStep - 8));
306
+ const yearFontSize = Math.max(10, Math.min(18, fitFont(safeText(timePoints.reduce((a, b) => safeText(a).length > safeText(b).length ? a : b, "")), yearPillWidth - 8, 18, labelFamily, 800, 10)));
307
+ timePoints.forEach((timePoint, i) => {
308
+ const x = markerCenters[i];
309
+ chart.append("rect")
310
+ .attr("class", "bump10-year-pill")
311
+ .attr("x", x - yearPillWidth / 2)
312
+ .attr("y", railY - 15)
313
+ .attr("width", yearPillWidth)
314
+ .attr("height", 30)
315
+ .attr("rx", Math.min(10, yearPillWidth / 4))
316
+ .attr("fill", "#24562d");
317
+ chart.append("text")
318
+ .attr("class", "bump10-year-label")
319
+ .attr("x", x)
320
+ .attr("y", railY + yearFontSize * 0.36)
321
+ .attr("text-anchor", "middle")
322
+ .attr("font-family", labelFamily)
323
+ .attr("font-size", yearFontSize)
324
+ .attr("font-weight", 800)
325
+ .attr("fill", "#ffffff")
326
+ .text(safeText(timePoint));
327
+ });
328
+
329
+ const finalValues = groups.map(group => {
330
+ const pts = grouped.get(group) || [];
331
+ const last = pts.length ? +pts[pts.length - 1][valueField] : 0;
332
+ return { group, value: Number.isFinite(last) ? last : 0 };
333
+ }).sort((a, b) => b.value - a.value);
334
+ const finalRank = new Map(finalValues.map((d, i) => [d.group, i]));
335
+
336
+ function trendSubtitle(points, group, groupIndex) {
337
+ if (!points || points.length < 2) return "Trend data";
338
+ const first = +points[0][valueField];
339
+ const last = +points[points.length - 1][valueField];
340
+ const delta = last - first;
341
+ const rank = finalRank.get(group) || 0;
342
+ if (rank === 0 && delta > 0) return "Leading with strong trend";
343
+ if (groupIndex === 0 && delta > 3) return "Strong growth";
344
+ if (delta > 8) return "Explosive growth";
345
+ if (delta > 3) return "Steady and consistent";
346
+ if (delta > 0.5) return "Moderate gain";
347
+ if (delta < -3) return "Declining trend";
348
+ return "Stable trend";
349
+ }
350
+
351
+ groups.forEach((group, groupIndex) => {
352
+ const cy = rowCenters[groupIndex];
353
+ const y = cy - rowHeight / 2;
354
+ const points = grouped.get(group) || [];
355
+ const ramp = rowRamps[groupIndex % rowRamps.length];
356
+ const rowGrad = defs.append("linearGradient")
357
+ .attr("id", `bump10-row-grad-${groupIndex}`)
358
+ .attr("x1", "0%")
359
+ .attr("x2", "100%");
360
+ ramp.forEach((color, i) => {
361
+ rowGrad.append("stop")
362
+ .attr("offset", `${(i / Math.max(ramp.length - 1, 1)) * 100}%`)
363
+ .attr("stop-color", color);
364
+ });
365
+
366
+ chart.append("rect")
367
+ .attr("class", "bump10-row-card")
368
+ .attr("data-group", safeText(group))
369
+ .attr("x", left)
370
+ .attr("y", y)
371
+ .attr("width", right - left)
372
+ .attr("height", rowHeight)
373
+ .attr("rx", 22)
374
+ .attr("fill", "#f8f6f1")
375
+ .attr("stroke", "#ece7dc")
376
+ .attr("stroke-width", 1)
377
+ .attr("filter", "url(#bump10-soft-shadow)");
378
+
379
+ const icon = chart.append("g")
380
+ .attr("class", "reserved-asset-slot bump10-row-icon-slot")
381
+ .attr("data-asset-slot", "row-icon")
382
+ .attr("data-slot-policy", "image2_asset_or_neutral_placeholder")
383
+ .attr("data-asset-source-policy", "image2_asset_or_neutral_placeholder")
384
+ .attr("data-anchor", "row-card-left-center")
385
+ .attr("data-bbox", `${iconX - 23},${cy - 23},46,46`)
386
+ .attr("data-mask-policy", "circle")
387
+ .attr("data-collision-rule", "stay inside row card, left of group label, clear of value markers");
388
+ icon.append("circle")
389
+ .attr("cx", iconX)
390
+ .attr("cy", cy)
391
+ .attr("r", 24)
392
+ .attr("fill", ramp[1])
393
+ .attr("opacity", 0.24)
394
+ .attr("stroke", "#ffffff")
395
+ .attr("stroke-width", 2);
396
+ icon.append("text")
397
+ .attr("x", iconX)
398
+ .attr("y", cy + 6)
399
+ .attr("text-anchor", "middle")
400
+ .attr("font-family", labelFamily)
401
+ .attr("font-size", 15)
402
+ .attr("font-weight", 800)
403
+ .attr("fill", "#2f4a38")
404
+ .text(safeText(group).slice(0, 2).toUpperCase());
405
+
406
+ const groupFont = fitFont(group, markerLeft - labelX - 38, 20, labelFamily, 900, 13);
407
+ chart.append("text")
408
+ .attr("class", "bump10-group-label")
409
+ .attr("x", labelX)
410
+ .attr("y", cy - 9)
411
+ .attr("font-family", labelFamily)
412
+ .attr("font-size", groupFont)
413
+ .attr("font-weight", 900)
414
+ .attr("fill", "#07331d")
415
+ .text(safeText(group));
416
+ const trend = chart.append("text")
417
+ .attr("class", "bump10-trend-subtitle")
418
+ .attr("x", labelX)
419
+ .attr("y", cy + 13)
420
+ .attr("font-family", labelFamily)
421
+ .attr("font-size", 12)
422
+ .attr("font-weight", 400)
423
+ .attr("fill", "#5d6267");
424
+ wrapText(trend, trendSubtitle(points, group, groupIndex), markerLeft - labelX - 22, 2, 15, 12, labelFamily, 400);
425
+
426
+ for (let i = 0; i < points.length - 1; i += 1) {
427
+ const currentIndex = timePoints.indexOf(points[i][timeField]);
428
+ const nextIndex = timePoints.indexOf(points[i + 1][timeField]);
429
+ if (currentIndex < 0 || nextIndex < 0) continue;
430
+ const x1 = markerCenters[currentIndex] + markerRadius - 1;
431
+ const x2 = markerCenters[nextIndex] - markerRadius + 1;
432
+ if (x2 <= x1) continue;
433
+ chart.append("line")
434
+ .attr("class", "bump10-connector")
435
+ .attr("data-group", safeText(group))
436
+ .attr("x1", x1)
437
+ .attr("x2", x2)
438
+ .attr("y1", cy)
439
+ .attr("y2", cy)
440
+ .attr("stroke", ramp[Math.min(2, ramp.length - 1)])
441
+ .attr("stroke-width", 3.4)
442
+ .attr("opacity", 0.9)
443
+ .attr("stroke-linecap", "round");
444
+ }
445
+
446
+ points.forEach(point => {
447
+ const timeIndex = timePoints.indexOf(point[timeField]);
448
+ if (timeIndex < 0) return;
449
+ const cx = markerCenters[timeIndex];
450
+ const value = +point[valueField];
451
+ const t = timePoints.length <= 1 ? 0 : timeIndex / (timePoints.length - 1);
452
+ const fill = d3.interpolateRgb(ramp[0], ramp[ramp.length - 1])(t);
453
+ const valueText = chartUtils.format.compactFixedText(Number.isFinite(value) ? value : 0);
454
+ const valueFont = fitFont(valueText, markerRadius * 1.55, 18, annotationFamily, 800, 11);
455
+ const dark = chartUtils.color.brightness(fill, { fallback: "#ffffff" }) < 0.48;
456
+ chart.append("circle")
457
+ .attr("class", "bump10-value-circle")
458
+ .attr("data-group", safeText(group))
459
+ .attr("data-time", safeText(point[timeField]))
460
+ .attr("data-value", Number.isFinite(value) ? value : "")
461
+ .attr("cx", cx)
462
+ .attr("cy", cy)
463
+ .attr("r", markerRadius)
464
+ .attr("fill", fill)
465
+ .attr("stroke", "#ffffff")
466
+ .attr("stroke-width", 2.4)
467
+ .attr("filter", "url(#bump10-soft-shadow)");
468
+ chart.append("text")
469
+ .attr("class", "bump10-value-label")
470
+ .attr("x", cx)
471
+ .attr("y", cy + 6)
472
+ .attr("text-anchor", "middle")
473
+ .attr("font-family", annotationFamily)
474
+ .attr("font-size", valueFont)
475
+ .attr("font-weight", 800)
476
+ .attr("fill", dark ? "#ffffff" : "#1f2d2d")
477
+ .text(valueText);
478
+ });
479
+ });
480
+
481
+ return svg.node();
482
+ }
modules/chart_engine/template/d3-js/line_graph/bump_chart_11.js ADDED
@@ -0,0 +1,541 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Sorted Lines Chart",
5
+ "chart_name": "bump_chart_11",
6
+ "is_composite": false,
7
+ "chart_for": "comparison",
8
+ "required_fields": ["x", "y", "group"],
9
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
10
+ "required_fields_range": [
11
+ [2, 12],
12
+ [0, "inf"],
13
+ [4, 10]
14
+ ],
15
+ "required_fields_icons": [],
16
+ "required_other_icons": [],
17
+ "required_fields_colors": ["group"],
18
+ "required_other_colors": [],
19
+ "supported_effects": ["spacing"],
20
+ "min_height": 400,
21
+ "min_width": 400,
22
+ "background": "no",
23
+ "icon_mark": "none",
24
+ "icon_label": "none",
25
+ "has_x_axis": "no",
26
+ "has_y_axis": "no"
27
+ }
28
+ REQUIREMENTS_END
29
+ */
30
+
31
+ function makeChart(containerSelector, data) {
32
+ const jsonData = data;
33
+ const chartData = (jsonData.data && jsonData.data.data) || [];
34
+ const variables = jsonData.variables || {};
35
+ const typography = jsonData.typography || {};
36
+
37
+ d3.select(containerSelector).html("");
38
+
39
+ const width = variables.width || 650;
40
+ const height = variables.height || 975;
41
+ const timeField = chartUtils.schema.channel(jsonData, "x").key;
42
+ const valueField = chartUtils.schema.channel(jsonData, "y").key;
43
+ const groupField = chartUtils.schema.channel(jsonData, "group").key;
44
+
45
+ function safeText(value) {
46
+ return value == null ? "" : String(value);
47
+ }
48
+
49
+ function measureText(text, fontSize, fontFamily, fontWeight) {
50
+ return chartUtils.text.measure(null, safeText(text), {
51
+ fontFamily,
52
+ fontSize,
53
+ fontWeight
54
+ }).width;
55
+ }
56
+
57
+ function fitFont(text, maxWidth, start, family, weight, minSize) {
58
+ let size = start;
59
+ while (size > minSize && measureText(text, size, family, weight) > maxWidth) {
60
+ size -= 1;
61
+ }
62
+ return size;
63
+ }
64
+
65
+ function computeWrappedLines(text, maxWidth, fontSize, family, weight) {
66
+ const words = safeText(text).split(/\s+/).filter(Boolean);
67
+ const lines = [];
68
+ let line = "";
69
+ words.forEach(word => {
70
+ const candidate = line ? `${line} ${word}` : word;
71
+ if (measureText(candidate, fontSize, family, weight) <= maxWidth || !line) {
72
+ line = candidate;
73
+ } else {
74
+ lines.push(line);
75
+ line = word;
76
+ }
77
+ });
78
+ if (line) lines.push(line);
79
+ return lines;
80
+ }
81
+
82
+ function wrapText(selection, text, maxWidth, maxLines, lineHeight, fontSize, family, weight) {
83
+ const lines = computeWrappedLines(text, maxWidth, fontSize, family, weight);
84
+ const visible = lines.slice(0, maxLines);
85
+ if (lines.length > maxLines && visible.length) {
86
+ let last = visible[visible.length - 1];
87
+ while (last.length > 1 && measureText(`${last}...`, fontSize, family, weight) > maxWidth) {
88
+ last = last.slice(0, -1);
89
+ }
90
+ visible[visible.length - 1] = `${last}...`;
91
+ }
92
+ selection.selectAll("tspan")
93
+ .data(visible)
94
+ .enter()
95
+ .append("tspan")
96
+ .attr("x", selection.attr("x"))
97
+ .attr("dy", (d, i) => i === 0 ? 0 : lineHeight)
98
+ .text(d => d);
99
+ return visible.length;
100
+ }
101
+
102
+ function setWrappedLines(selection, lines, lineHeight) {
103
+ selection.selectAll("tspan")
104
+ .data(lines)
105
+ .enter()
106
+ .append("tspan")
107
+ .attr("x", selection.attr("x"))
108
+ .attr("dy", (d, i) => i === 0 ? 0 : lineHeight)
109
+ .text(d => d);
110
+ return lines.length;
111
+ }
112
+
113
+ function chooseFittingText(candidates, maxWidth, maxLines, startSize, minSize, family, weight) {
114
+ for (const candidate of candidates) {
115
+ for (let size = startSize; size >= minSize; size -= 1) {
116
+ const lines = computeWrappedLines(candidate, maxWidth, size, family, weight);
117
+ if (lines.length <= maxLines) {
118
+ return {
119
+ fontSize: size,
120
+ lineHeight: Math.round(size * 1.34),
121
+ lines
122
+ };
123
+ }
124
+ }
125
+ }
126
+ const fallback = candidates[candidates.length - 1] || "Values";
127
+ const size = minSize;
128
+ return {
129
+ fontSize: size,
130
+ lineHeight: Math.round(size * 1.34),
131
+ lines: computeWrappedLines(fallback, maxWidth, size, family, weight).slice(0, maxLines)
132
+ };
133
+ }
134
+
135
+ function parseTimeValue(value) {
136
+ const parsed = chartUtils.format.parseDate(value);
137
+ if (parsed instanceof Date && !Number.isNaN(parsed.getTime())) return parsed.getTime();
138
+ const numeric = +value;
139
+ return Number.isFinite(numeric) ? numeric : safeText(value);
140
+ }
141
+
142
+ function humanizeFieldName(fieldName) {
143
+ return safeText(fieldName)
144
+ .replace(/[_-]+/g, " ")
145
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
146
+ .replace(/\s+/g, " ")
147
+ .trim()
148
+ .toLowerCase();
149
+ }
150
+
151
+ function hexagonPath(cx, cy, r) {
152
+ const h = r * 0.86;
153
+ return [
154
+ `M${cx - r},${cy}`,
155
+ `L${cx - r * 0.52},${cy - h}`,
156
+ `L${cx + r * 0.52},${cy - h}`,
157
+ `L${cx + r},${cy}`,
158
+ `L${cx + r * 0.52},${cy + h}`,
159
+ `L${cx - r * 0.52},${cy + h}`,
160
+ "Z"
161
+ ].join(" ");
162
+ }
163
+
164
+ const titleFamily = (typography.title && typography.title.font_family) || "Arial, Helvetica, sans-serif";
165
+ const labelFamily = (typography.label && typography.label.font_family) || "Arial, Helvetica, sans-serif";
166
+ const annotationFamily = (typography.annotation && typography.annotation.font_family) || "Arial, Helvetica, sans-serif";
167
+
168
+ const rawTitle = safeText((jsonData.titles && jsonData.titles.main_title) || (jsonData.metadata && jsonData.metadata.title) || "Bicycle Commuting Rising");
169
+ const subtitleText = safeText((jsonData.titles && jsonData.titles.sub_title) || (jsonData.metadata && jsonData.metadata.description) || jsonData.description || "");
170
+
171
+ const timePoints = [...new Set(chartData.map(d => d[timeField]))]
172
+ .sort((a, b) => {
173
+ const pa = parseTimeValue(a);
174
+ const pb = parseTimeValue(b);
175
+ if (typeof pa === "number" && typeof pb === "number") return pa - pb;
176
+ return safeText(a).localeCompare(safeText(b));
177
+ });
178
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
179
+ const grouped = new Map(groups.map(group => [
180
+ group,
181
+ chartData.filter(d => d[groupField] === group)
182
+ .sort((a, b) => timePoints.indexOf(a[timeField]) - timePoints.indexOf(b[timeField]))
183
+ ]));
184
+
185
+ const rowRamps = [
186
+ ["#064c66", "#0b6f88", "#1097b7", "#24bfd2", "#45d3dc"],
187
+ ["#a73c28", "#d45642", "#f37759", "#ff9b67", "#ffd27d"],
188
+ ["#59743d", "#78934a", "#a7b34a", "#d8cc4b", "#f4d75c"],
189
+ ["#0c8746", "#199d55", "#32b85a", "#63cf59", "#8ade5d"],
190
+ ["#29588d", "#2e7eb7", "#4aa7d8", "#73c3df", "#a1dce7"],
191
+ ["#6f3e9f", "#8d57c2", "#aa76df", "#c997ef", "#e5c2f7"],
192
+ ["#b26c22", "#d99031", "#ecb74c", "#f2d071", "#f8df9b"],
193
+ ["#3f7f79", "#48a19a", "#5ac0b0", "#77d9c5", "#a7efe0"]
194
+ ];
195
+
196
+ const svg = d3.select(containerSelector)
197
+ .append("svg")
198
+ .attr("width", "100%")
199
+ .attr("height", height)
200
+ .attr("viewBox", `0 0 ${width} ${height}`)
201
+ .attr("style", "max-width: 100%; height: auto;")
202
+ .attr("xmlns", "http://www.w3.org/2000/svg")
203
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
204
+
205
+ const defs = svg.append("defs");
206
+ const bg = defs.append("radialGradient")
207
+ .attr("id", "bump11-bg")
208
+ .attr("cx", "78%")
209
+ .attr("cy", "12%")
210
+ .attr("r", "96%");
211
+ bg.append("stop").attr("offset", "0%").attr("stop-color", "#eef8fb");
212
+ bg.append("stop").attr("offset", "52%").attr("stop-color", "#f8fcfd");
213
+ bg.append("stop").attr("offset", "100%").attr("stop-color", "#eaf6f8");
214
+
215
+ defs.append("filter")
216
+ .attr("id", "bump11-panel-shadow")
217
+ .attr("x", "-8%")
218
+ .attr("y", "-8%")
219
+ .attr("width", "116%")
220
+ .attr("height", "124%")
221
+ .html('<feDropShadow dx="0" dy="8" stdDeviation="10" flood-color="#8eb5c0" flood-opacity="0.20"/>');
222
+
223
+ svg.append("rect")
224
+ .attr("width", width)
225
+ .attr("height", height)
226
+ .attr("fill", "url(#bump11-bg)");
227
+
228
+ const titleMaxWidth = 382;
229
+ let titleFont = Math.min(
230
+ fitFont(rawTitle, 640, 42, titleFamily, 900, 28),
231
+ fitFont(rawTitle.split(/\s+/).reduce((a, b) => a.length > b.length ? a : b, ""), titleMaxWidth, 42, titleFamily, 900, 28)
232
+ );
233
+ while (titleFont > 28 && computeWrappedLines(rawTitle, titleMaxWidth, titleFont, titleFamily, 900).length > 2) {
234
+ titleFont -= 1;
235
+ }
236
+ const title = svg.append("text")
237
+ .attr("class", "bump11-title")
238
+ .attr("x", 38)
239
+ .attr("y", 80)
240
+ .attr("font-family", titleFamily)
241
+ .attr("font-size", titleFont)
242
+ .attr("font-weight", 900)
243
+ .attr("fill", "#071d35");
244
+ const titleLines = wrapText(title, rawTitle, titleMaxWidth, 2, Math.round(titleFont * 1.14), titleFont, titleFamily, 900);
245
+
246
+ const subtitleY = 80 + Math.max(1, titleLines) * Math.round(titleFont * 1.14) + 28;
247
+ const subtitle = svg.append("text")
248
+ .attr("class", "bump11-subtitle")
249
+ .attr("x", 40)
250
+ .attr("y", subtitleY)
251
+ .attr("font-family", labelFamily)
252
+ .attr("font-size", 18)
253
+ .attr("font-weight", 400)
254
+ .attr("fill", "#41576b");
255
+ wrapText(subtitle, subtitleText, 350, 3, 27, 18, labelFamily, 400);
256
+
257
+ const headerSlot = svg.append("g")
258
+ .attr("class", "reserved-asset-slot bump11-header-art-slot")
259
+ .attr("data-asset-slot", "header-bicycle-city-illustration")
260
+ .attr("data-slot-policy", "input_asset_or_image2_asset")
261
+ .attr("data-asset-source-policy", "input_asset_or_image2_asset")
262
+ .attr("data-anchor", "top-right-header-safe-area")
263
+ .attr("data-bbox", "420,48,198,228")
264
+ .attr("data-mask-policy", "rounded clipped bbox")
265
+ .attr("data-collision-rule", "stay right of title/subtitle and above chart panel")
266
+ .attr("transform", "translate(420,48)");
267
+ headerSlot.append("rect")
268
+ .attr("width", 198)
269
+ .attr("height", 228)
270
+ .attr("rx", 28)
271
+ .attr("fill", "#e3f3f7")
272
+ .attr("opacity", 0.72);
273
+ headerSlot.append("rect")
274
+ .attr("x", 18)
275
+ .attr("y", 162)
276
+ .attr("width", 162)
277
+ .attr("height", 18)
278
+ .attr("rx", 9)
279
+ .attr("fill", "#9ac8d0")
280
+ .attr("opacity", 0.28);
281
+ headerSlot.append("circle")
282
+ .attr("cx", 162)
283
+ .attr("cy", 64)
284
+ .attr("r", 26)
285
+ .attr("fill", "#cceaf0")
286
+ .attr("opacity", 0.82);
287
+ headerSlot.append("circle")
288
+ .attr("cx", 88)
289
+ .attr("cy", 116)
290
+ .attr("r", 48)
291
+ .attr("fill", "#b7dce5")
292
+ .attr("opacity", 0.42);
293
+ headerSlot.append("text")
294
+ .attr("x", 99)
295
+ .attr("y", 198)
296
+ .attr("text-anchor", "middle")
297
+ .attr("font-family", labelFamily)
298
+ .attr("font-size", 12)
299
+ .attr("font-weight", 700)
300
+ .attr("fill", "#568493")
301
+ .attr("opacity", 0.78)
302
+ .text("reserved image slot");
303
+
304
+ const panelX = 16;
305
+ const panelY = 310;
306
+ const panelW = 618;
307
+ const panelH = 560;
308
+ svg.append("rect")
309
+ .attr("class", "bump11-chart-panel")
310
+ .attr("x", panelX)
311
+ .attr("y", panelY)
312
+ .attr("width", panelW)
313
+ .attr("height", panelH)
314
+ .attr("rx", 20)
315
+ .attr("fill", "#ffffff")
316
+ .attr("filter", "url(#bump11-panel-shadow)");
317
+
318
+ const chart = svg.append("g").attr("class", "bump11-chart");
319
+ const labelColumnX = panelX + 74;
320
+ const markerLeft = panelX + 185;
321
+ const markerRight = panelX + panelW - 45;
322
+ const yearY = panelY + 48;
323
+ const rowTop = panelY + 128;
324
+ const rowBottom = panelY + panelH - 58;
325
+ const rowGap = groups.length > 6 ? 10 : 16;
326
+ const rowHeight = Math.max(44, Math.min(100, (rowBottom - rowTop - rowGap * Math.max(groups.length - 1, 0)) / Math.max(groups.length, 1)));
327
+ const denseRows = groups.length > 5 || rowHeight < 68;
328
+ const rowCenters = groups.map((group, i) => rowTop + rowHeight / 2 + i * (rowHeight + rowGap));
329
+ const markerCenters = timePoints.map((timePoint, i) => {
330
+ if (timePoints.length <= 1) return (markerLeft + markerRight) / 2;
331
+ return markerLeft + i * ((markerRight - markerLeft) / (timePoints.length - 1));
332
+ });
333
+ const markerRadius = Math.max(17, Math.min(30, rowHeight * 0.30, (markerRight - markerLeft) / Math.max(timePoints.length * 2.45, 1)));
334
+
335
+ chart.append("line")
336
+ .attr("class", "bump11-year-separator")
337
+ .attr("x1", panelX + 26)
338
+ .attr("x2", panelX + panelW - 26)
339
+ .attr("y1", panelY + 86)
340
+ .attr("y2", panelY + 86)
341
+ .attr("stroke", "#d6e8ee")
342
+ .attr("stroke-width", 1.2);
343
+
344
+ const widestYear = timePoints.reduce((a, b) => safeText(a).length > safeText(b).length ? a : b, "");
345
+ const yearFont = fitFont(widestYear, Math.max(36, (markerRight - markerLeft) / Math.max(timePoints.length, 1) - 8), 18, labelFamily, 800, 11);
346
+ timePoints.forEach((timePoint, i) => {
347
+ chart.append("text")
348
+ .attr("class", "bump11-year-label")
349
+ .attr("x", markerCenters[i])
350
+ .attr("y", yearY)
351
+ .attr("text-anchor", "middle")
352
+ .attr("font-family", labelFamily)
353
+ .attr("font-size", yearFont)
354
+ .attr("font-weight", 800)
355
+ .attr("fill", "#0a6680")
356
+ .text(safeText(timePoint));
357
+ });
358
+
359
+ groups.forEach((group, groupIndex) => {
360
+ const cy = rowCenters[groupIndex];
361
+ const y = cy - rowHeight / 2;
362
+ const points = grouped.get(group) || [];
363
+ const ramp = rowRamps[groupIndex % rowRamps.length];
364
+ const slotRadius = denseRows
365
+ ? Math.max(14, Math.min(22, rowHeight * 0.28))
366
+ : Math.max(21, Math.min(38, rowHeight * 0.36));
367
+ const slotCy = denseRows ? cy : cy - (rowHeight > 68 ? 8 : 0);
368
+
369
+ if (groupIndex > 0) {
370
+ chart.append("line")
371
+ .attr("class", "bump11-row-separator")
372
+ .attr("x1", panelX + 26)
373
+ .attr("x2", panelX + panelW - 26)
374
+ .attr("y1", y - rowGap / 2)
375
+ .attr("y2", y - rowGap / 2)
376
+ .attr("stroke", "#d8eaf0")
377
+ .attr("stroke-width", 1.2);
378
+ }
379
+
380
+ const rowSlot = chart.append("g")
381
+ .attr("class", "reserved-asset-slot bump11-row-city-slot")
382
+ .attr("data-asset-slot", "row-city-thumbnail")
383
+ .attr("data-slot-policy", "image2_asset_or_neutral_placeholder")
384
+ .attr("data-asset-source-policy", "image2_asset_or_neutral_placeholder")
385
+ .attr("data-anchor", "row-left-center")
386
+ .attr("data-bbox", `${labelColumnX - slotRadius},${slotCy - slotRadius},${slotRadius * 2},${slotRadius * 2}`)
387
+ .attr("data-mask-policy", "circle")
388
+ .attr("data-collision-rule", "stay inside row band, clear of group label and first marker column")
389
+ .attr("data-group", safeText(group));
390
+ rowSlot.append("circle")
391
+ .attr("cx", labelColumnX)
392
+ .attr("cy", slotCy)
393
+ .attr("r", slotRadius)
394
+ .attr("fill", ramp[Math.min(1, ramp.length - 1)])
395
+ .attr("opacity", 0.16);
396
+ rowSlot.append("circle")
397
+ .attr("cx", labelColumnX)
398
+ .attr("cy", slotCy)
399
+ .attr("r", slotRadius - 3)
400
+ .attr("fill", "#f5fbfd")
401
+ .attr("stroke", ramp[Math.min(2, ramp.length - 1)])
402
+ .attr("stroke-width", 1.5)
403
+ .attr("opacity", 0.9);
404
+ rowSlot.append("text")
405
+ .attr("x", labelColumnX)
406
+ .attr("y", slotCy + 6)
407
+ .attr("text-anchor", "middle")
408
+ .attr("font-family", labelFamily)
409
+ .attr("font-size", Math.max(12, Math.min(16, slotRadius * 0.43)))
410
+ .attr("font-weight", 800)
411
+ .attr("fill", ramp[Math.min(2, ramp.length - 1)])
412
+ .text(safeText(group).split(/\s+/).map(word => word[0]).join("").slice(0, 3).toUpperCase());
413
+
414
+ const groupFont = denseRows
415
+ ? fitFont(group, markerLeft - (labelColumnX + slotRadius + 10) - 8, 12, labelFamily, 900, 8)
416
+ : fitFont(group, 130, rowHeight > 72 ? 20 : 14, labelFamily, 900, 9);
417
+ const groupText = chart.append("text")
418
+ .attr("class", "bump11-group-label")
419
+ .attr("x", denseRows ? labelColumnX + slotRadius + 10 : labelColumnX)
420
+ .attr("y", denseRows ? cy - groupFont * 0.35 : slotCy + slotRadius + Math.max(15, groupFont))
421
+ .attr("text-anchor", denseRows ? "start" : "middle")
422
+ .attr("font-family", labelFamily)
423
+ .attr("font-size", groupFont)
424
+ .attr("font-weight", 900)
425
+ .attr("fill", "#071d35");
426
+ if (denseRows) {
427
+ wrapText(groupText, group, markerLeft - (labelColumnX + slotRadius + 10) - 8, 2, Math.round(groupFont * 1.1), groupFont, labelFamily, 900);
428
+ } else {
429
+ groupText.text(safeText(group));
430
+ }
431
+
432
+ for (let i = 0; i < points.length - 1; i += 1) {
433
+ const currentIndex = timePoints.indexOf(points[i][timeField]);
434
+ const nextIndex = timePoints.indexOf(points[i + 1][timeField]);
435
+ if (currentIndex < 0 || nextIndex < 0) continue;
436
+ const x1 = markerCenters[currentIndex] + markerRadius * 0.78;
437
+ const x2 = markerCenters[nextIndex] - markerRadius * 0.78;
438
+ if (x2 <= x1) continue;
439
+ chart.append("line")
440
+ .attr("class", "bump11-connector")
441
+ .attr("data-group", safeText(group))
442
+ .attr("x1", x1)
443
+ .attr("x2", x2)
444
+ .attr("y1", cy)
445
+ .attr("y2", cy)
446
+ .attr("stroke", d3.interpolateRgb(ramp[1], ramp[ramp.length - 1])(0.45))
447
+ .attr("stroke-width", 2.4)
448
+ .attr("opacity", 0.88)
449
+ .attr("stroke-linecap", "round");
450
+ }
451
+
452
+ points.forEach(point => {
453
+ const timeIndex = timePoints.indexOf(point[timeField]);
454
+ if (timeIndex < 0) return;
455
+ const cx = markerCenters[timeIndex];
456
+ const value = +point[valueField];
457
+ const t = timePoints.length <= 1 ? 0 : timeIndex / (timePoints.length - 1);
458
+ const fill = d3.interpolateRgb(ramp[0], ramp[ramp.length - 1])(t);
459
+ const valueText = chartUtils.format.compactFixedText(Number.isFinite(value) ? value : 0);
460
+ const valueFont = fitFont(valueText, markerRadius * 1.42, 18, annotationFamily, 800, 10);
461
+ const dark = chartUtils.color.brightness(fill, { fallback: "#ffffff" }) < 0.55;
462
+
463
+ chart.append("path")
464
+ .attr("class", "bump11-value-hex")
465
+ .attr("data-group", safeText(group))
466
+ .attr("data-time", safeText(point[timeField]))
467
+ .attr("data-value", Number.isFinite(value) ? value : "")
468
+ .attr("d", hexagonPath(cx, cy, markerRadius))
469
+ .attr("fill", fill)
470
+ .attr("stroke", "#ffffff")
471
+ .attr("stroke-width", 1.6);
472
+ chart.append("text")
473
+ .attr("class", "bump11-value-label")
474
+ .attr("x", cx)
475
+ .attr("y", cy + valueFont * 0.34)
476
+ .attr("text-anchor", "middle")
477
+ .attr("font-family", annotationFamily)
478
+ .attr("font-size", valueFont)
479
+ .attr("font-weight", 800)
480
+ .attr("fill", dark ? "#ffffff" : "#0b1f2d")
481
+ .text(valueText);
482
+ });
483
+ });
484
+
485
+ const legendY = 905;
486
+ const legendX = 62;
487
+ chart.append("path")
488
+ .attr("class", "bump11-scale-legend-hex")
489
+ .attr("d", hexagonPath(legendX, legendY + 8, 22))
490
+ .attr("fill", "none")
491
+ .attr("stroke", "#0c6d81")
492
+ .attr("stroke-width", 2);
493
+ chart.append("text")
494
+ .attr("x", legendX)
495
+ .attr("y", legendY + 15)
496
+ .attr("text-anchor", "middle")
497
+ .attr("font-family", annotationFamily)
498
+ .attr("font-size", 16)
499
+ .attr("font-weight", 800)
500
+ .attr("fill", "#0c6d81")
501
+ .text("1K");
502
+ const legendText = chart.append("text")
503
+ .attr("class", "bump11-scale-legend-text")
504
+ .attr("x", legendX + 55)
505
+ .attr("y", legendY)
506
+ .attr("font-family", labelFamily)
507
+ .attr("font-size", 16)
508
+ .attr("font-weight", 400)
509
+ .attr("fill", "#33485d");
510
+ const valueColumn = ((jsonData.data && jsonData.data.columns) || []).find(column => column && column.name === valueField) || {};
511
+ const valueDescription = safeText(valueColumn.description || humanizeFieldName(valueField) || "values");
512
+ const valueUnit = safeText(valueColumn.unit || "").trim();
513
+ const normalizedDescription = valueDescription.toLowerCase();
514
+ const normalizedUnit = valueUnit.toLowerCase();
515
+ const descriptionIncludesUnit = valueUnit && (
516
+ normalizedDescription.includes(`(${normalizedUnit})`) ||
517
+ normalizedDescription.includes(normalizedUnit)
518
+ );
519
+ const legendLabel = valueUnit && !descriptionIncludesUnit
520
+ ? `Values represent ${valueDescription} (${valueUnit})`
521
+ : `Values represent ${valueDescription}`;
522
+ const descriptionWithoutParenthetical = valueDescription.replace(/\s*\([^)]*\)\s*$/, "").trim() || valueDescription;
523
+ const compactField = humanizeFieldName(valueField) || "value";
524
+ const compactWithUnit = valueUnit && !compactField.toLowerCase().includes(normalizedUnit)
525
+ ? `${compactField} (${valueUnit})`
526
+ : compactField;
527
+ const legendFit = chooseFittingText([
528
+ legendLabel,
529
+ `Values represent ${descriptionWithoutParenthetical}`,
530
+ `Values represent ${compactWithUnit}`,
531
+ `Values: ${compactWithUnit}`,
532
+ "Values"
533
+ ], 330, 3, 16, 11, labelFamily, 400);
534
+ legendText
535
+ .attr("font-size", legendFit.fontSize)
536
+ .attr("data-legend-text-policy", "fit-no-ellipsis")
537
+ .attr("data-legend-source-field", safeText(valueField));
538
+ setWrappedLines(legendText, legendFit.lines, legendFit.lineHeight);
539
+
540
+ return svg.node();
541
+ }
modules/chart_engine/template/d3-js/line_graph/bump_chart_12.js ADDED
@@ -0,0 +1,561 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Sorted Lines Chart",
5
+ "chart_name": "bump_chart_12",
6
+ "is_composite": false,
7
+ "chart_for": "comparison",
8
+ "required_fields": ["x", "y", "group"],
9
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
10
+ "required_fields_range": [
11
+ [2, 12],
12
+ [0, "inf"],
13
+ [4, 10]
14
+ ],
15
+ "required_fields_icons": ["group"],
16
+ "required_other_icons": [],
17
+ "required_fields_colors": ["group"],
18
+ "required_other_colors": [],
19
+ "supported_effects": ["spacing"],
20
+ "min_height": 400,
21
+ "min_width": 400,
22
+ "background": "no",
23
+ "icon_mark": "none",
24
+ "icon_label": "side",
25
+ "has_x_axis": "no",
26
+ "has_y_axis": "no"
27
+ }
28
+ REQUIREMENTS_END
29
+ */
30
+
31
+ function makeChart(containerSelector, data) {
32
+ const jsonData = data;
33
+ const chartData = (jsonData.data && jsonData.data.data) || [];
34
+ const variables = jsonData.variables || {};
35
+ const typography = jsonData.typography || {};
36
+ const dataColumns = chartUtils.schema.columns(jsonData);
37
+ const colorResolver = chartUtils.color.resolver(jsonData);
38
+
39
+ d3.select(containerSelector).html("");
40
+
41
+ const width = Math.max(620, variables.width || 650);
42
+ const height = Math.max(920, variables.height ? Math.round(variables.height * 1.62) : 975);
43
+ const timeField = chartUtils.schema.channel(jsonData, "x").key;
44
+ const valueField = chartUtils.schema.channel(jsonData, "y").key;
45
+ const groupField = chartUtils.schema.channel(jsonData, "group").key;
46
+
47
+ function safeText(value) {
48
+ return value == null ? "" : String(value);
49
+ }
50
+
51
+ function measureText(text, fontSize, fontFamily, fontWeight) {
52
+ return chartUtils.text.measure(null, safeText(text), {
53
+ fontFamily,
54
+ fontSize,
55
+ fontWeight
56
+ }).width;
57
+ }
58
+
59
+ function fitFont(text, maxWidth, start, family, weight, minSize) {
60
+ let size = start;
61
+ while (size > minSize && measureText(text, size, family, weight) > maxWidth) {
62
+ size -= 1;
63
+ }
64
+ return size;
65
+ }
66
+
67
+ function computeWrappedLines(text, maxWidth, fontSize, family, weight) {
68
+ const words = safeText(text).split(/\s+/).filter(Boolean);
69
+ const lines = [];
70
+ let line = "";
71
+ words.forEach(word => {
72
+ const candidate = line ? `${line} ${word}` : word;
73
+ if (measureText(candidate, fontSize, family, weight) <= maxWidth || !line) {
74
+ line = candidate;
75
+ } else {
76
+ lines.push(line);
77
+ line = word;
78
+ }
79
+ });
80
+ if (line) lines.push(line);
81
+ return lines;
82
+ }
83
+
84
+ function wrapText(selection, text, maxWidth, maxLines, lineHeight, fontSize, family, weight) {
85
+ const lines = computeWrappedLines(text, maxWidth, fontSize, family, weight);
86
+ const visible = lines.slice(0, maxLines);
87
+ if (lines.length > maxLines && visible.length) {
88
+ let last = visible[visible.length - 1];
89
+ while (last.length > 1 && measureText(`${last}...`, fontSize, family, weight) > maxWidth) {
90
+ last = last.slice(0, -1);
91
+ }
92
+ visible[visible.length - 1] = `${last}...`;
93
+ }
94
+ selection.selectAll("tspan")
95
+ .data(visible)
96
+ .enter()
97
+ .append("tspan")
98
+ .attr("x", selection.attr("x"))
99
+ .attr("dy", (d, i) => i === 0 ? 0 : lineHeight)
100
+ .text(d => d);
101
+ return visible.length;
102
+ }
103
+
104
+ function parseTimeValue(value) {
105
+ const parsed = chartUtils.format.parseDate(value);
106
+ if (parsed instanceof Date && !Number.isNaN(parsed.getTime())) return parsed.getTime();
107
+ const numeric = +value;
108
+ return Number.isFinite(numeric) ? numeric : safeText(value);
109
+ }
110
+
111
+ function titleCaseWords(text) {
112
+ return safeText(text)
113
+ .replace(/[_-]+/g, " ")
114
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
115
+ .replace(/\s+/g, " ")
116
+ .trim();
117
+ }
118
+
119
+ function deriveHeaderParts(title) {
120
+ const clean = titleCaseWords(title);
121
+ const match = clean.match(/\s+(for|in|across|among)\s+(.+)$/i);
122
+ if (match && match.index > 8) {
123
+ return {
124
+ headline: clean.slice(0, match.index).trim(),
125
+ ribbon: `${match[1]} ${match[2]}`.trim()
126
+ };
127
+ }
128
+ return {
129
+ headline: clean,
130
+ ribbon: groupDescriptor ? `for ${groupDescriptor}` : "by group"
131
+ };
132
+ }
133
+
134
+ function initials(text) {
135
+ const parts = titleCaseWords(text).split(/\s+/).filter(Boolean);
136
+ return (parts.length ? parts.map(word => word[0]).join("") : safeText(text).slice(0, 2))
137
+ .slice(0, 3)
138
+ .toUpperCase();
139
+ }
140
+
141
+ function compactValue(value) {
142
+ const numeric = +value;
143
+ if (!Number.isFinite(numeric)) return safeText(value);
144
+ const column = dataColumns.find(col => col && col.name === valueField) || {};
145
+ const unit = safeText(column.unit || "").trim();
146
+ const rounded = Math.abs(numeric) >= 100
147
+ ? chartUtils.format.compactFixedText(numeric)
148
+ : chartUtils.format.compactFixedText(numeric).replace(/\\.0$/, "");
149
+ if (unit === "%" && !rounded.includes("%")) return `${rounded}%`;
150
+ return rounded;
151
+ }
152
+
153
+ const titleFamily = (typography.title && typography.title.font_family) || "Arial, Helvetica, sans-serif";
154
+ const labelFamily = (typography.label && typography.label.font_family) || "Arial, Helvetica, sans-serif";
155
+ const annotationFamily = (typography.annotation && typography.annotation.font_family) || "Arial, Helvetica, sans-serif";
156
+ const rawTitle = safeText((jsonData.titles && jsonData.titles.main_title) || (jsonData.metadata && jsonData.metadata.title) || "Increased Graduation Rates");
157
+ const subtitleText = safeText((jsonData.titles && jsonData.titles.sub_title) || (jsonData.metadata && jsonData.metadata.description) || jsonData.description || "");
158
+ const groupDescriptor = titleCaseWords(groupField).replace(/s$/i, "");
159
+ const headerParts = deriveHeaderParts(rawTitle);
160
+
161
+ const timePoints = [...new Set(chartData.map(d => d[timeField]))]
162
+ .sort((a, b) => {
163
+ const pa = parseTimeValue(a);
164
+ const pb = parseTimeValue(b);
165
+ if (typeof pa === "number" && typeof pb === "number") return pa - pb;
166
+ return safeText(a).localeCompare(safeText(b));
167
+ });
168
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
169
+ const grouped = new Map(groups.map(group => [
170
+ group,
171
+ chartData.filter(d => d[groupField] === group)
172
+ .sort((a, b) => timePoints.indexOf(a[timeField]) - timePoints.indexOf(b[timeField]))
173
+ ]));
174
+
175
+ const fallbackPalette = [
176
+ "#57a80a", "#eb2d80", "#1261bd", "#f46610", "#16a79c",
177
+ "#7b62d1", "#d79b19", "#00875f", "#e34f4f", "#4b83d8"
178
+ ];
179
+ const colorScale = colorResolver.scale(groups, { palette: "category10" });
180
+
181
+ function rowColor(group, index) {
182
+ return colorScale(group) || fallbackPalette[index % fallbackPalette.length];
183
+ }
184
+
185
+ const svg = d3.select(containerSelector)
186
+ .append("svg")
187
+ .attr("width", "100%")
188
+ .attr("height", height)
189
+ .attr("viewBox", `0 0 ${width} ${height}`)
190
+ .attr("style", "max-width: 100%; height: auto;")
191
+ .attr("xmlns", "http://www.w3.org/2000/svg")
192
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
193
+
194
+ const defs = svg.append("defs");
195
+ const bg = defs.append("radialGradient")
196
+ .attr("id", "bump12-bg")
197
+ .attr("cx", "50%")
198
+ .attr("cy", "18%")
199
+ .attr("r", "86%");
200
+ bg.append("stop").attr("offset", "0%").attr("stop-color", "#ffffff");
201
+ bg.append("stop").attr("offset", "58%").attr("stop-color", "#f2f8fc");
202
+ bg.append("stop").attr("offset", "100%").attr("stop-color", "#e4f1f8");
203
+
204
+ const railGrad = defs.append("linearGradient")
205
+ .attr("id", "bump12-rail")
206
+ .attr("x1", "0%")
207
+ .attr("x2", "100%")
208
+ .attr("y1", "0%")
209
+ .attr("y2", "0%");
210
+ railGrad.append("stop").attr("offset", "0%").attr("stop-color", "#061b45");
211
+ railGrad.append("stop").attr("offset", "54%").attr("stop-color", "#073b7a");
212
+ railGrad.append("stop").attr("offset", "100%").attr("stop-color", "#061b45");
213
+
214
+ defs.append("filter")
215
+ .attr("id", "bump12-card-shadow")
216
+ .attr("x", "-8%")
217
+ .attr("y", "-18%")
218
+ .attr("width", "116%")
219
+ .attr("height", "140%")
220
+ .html('<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#8baebe" flood-opacity="0.22"/>');
221
+
222
+ defs.append("filter")
223
+ .attr("id", "bump12-marker-shadow")
224
+ .attr("x", "-30%")
225
+ .attr("y", "-30%")
226
+ .attr("width", "160%")
227
+ .attr("height", "160%")
228
+ .html('<feDropShadow dx="0" dy="3" stdDeviation="3" flood-color="#496b80" flood-opacity="0.25"/>');
229
+
230
+ svg.append("rect")
231
+ .attr("width", width)
232
+ .attr("height", height)
233
+ .attr("fill", "url(#bump12-bg)");
234
+
235
+ const headerArt = svg.append("g")
236
+ .attr("class", "reserved-asset-slot bump12-header-art-slot")
237
+ .attr("data-asset-slot", "header-graduation-art")
238
+ .attr("data-slot-policy", "image2_asset")
239
+ .attr("data-asset-source-policy", "image2_asset")
240
+ .attr("data-anchor", "header-safe-area")
241
+ .attr("data-bbox", `${Math.round(width * 0.02)},${Math.round(height * 0.02)},${Math.round(width * 0.96)},${Math.round(height * 0.25)}`)
242
+ .attr("data-mask-policy", "soft clipped header bbox")
243
+ .attr("data-collision-rule", "clear title, ribbon, subtitle, year rail, and row cards");
244
+ headerArt.append("path")
245
+ .attr("d", `M${width * 0.04},${height * 0.08} L${width * 0.18},${height * 0.045} L${width * 0.30},${height * 0.08} L${width * 0.18},${height * 0.125} Z`)
246
+ .attr("fill", "#9bc4e3")
247
+ .attr("opacity", 0.28);
248
+ headerArt.append("circle")
249
+ .attr("cx", width * 0.90)
250
+ .attr("cy", height * 0.10)
251
+ .attr("r", 44)
252
+ .attr("fill", "#9fc8e5")
253
+ .attr("opacity", 0.18);
254
+ headerArt.append("circle")
255
+ .attr("cx", width * 0.94)
256
+ .attr("cy", height * 0.18)
257
+ .attr("r", 30)
258
+ .attr("fill", "#82b8dd")
259
+ .attr("opacity", 0.12);
260
+ [[0.06, 0.19], [0.12, 0.16], [0.78, 0.09], [0.84, 0.16], [0.96, 0.035]].forEach((pt, i) => {
261
+ headerArt.append("path")
262
+ .attr("d", d3.symbol().type(i % 2 ? d3.symbolCircle : d3.symbolDiamond).size(i % 2 ? 22 : 46)())
263
+ .attr("transform", `translate(${width * pt[0]},${height * pt[1]})`)
264
+ .attr("fill", "#6aa3d2")
265
+ .attr("opacity", 0.24);
266
+ });
267
+
268
+ const titleMaxWidth = width * 0.84;
269
+ let titleFont = Math.min(
270
+ fitFont(headerParts.headline.toUpperCase(), titleMaxWidth, 58, titleFamily, 900, 32),
271
+ fitFont(headerParts.headline.split(/\s+/).reduce((a, b) => a.length > b.length ? a : b, "").toUpperCase(), titleMaxWidth, 58, titleFamily, 900, 32)
272
+ );
273
+ let titleLines = computeWrappedLines(headerParts.headline.toUpperCase(), titleMaxWidth, titleFont, titleFamily, 900);
274
+ while (titleFont > 25 && titleLines.length > 3) {
275
+ titleFont -= 1;
276
+ titleLines = computeWrappedLines(headerParts.headline.toUpperCase(), titleMaxWidth, titleFont, titleFamily, 900);
277
+ }
278
+ if (titleLines.length > 3) {
279
+ titleLines = titleLines.slice(0, 3);
280
+ let last = titleLines[2];
281
+ while (last.length > 1 && measureText(`${last}...`, titleFont, titleFamily, 900) > titleMaxWidth) {
282
+ last = last.slice(0, -1);
283
+ }
284
+ titleLines[2] = `${last}...`;
285
+ }
286
+ const title = svg.append("text")
287
+ .attr("class", "bump12-title")
288
+ .attr("x", width / 2)
289
+ .attr("y", 72)
290
+ .attr("text-anchor", "middle")
291
+ .attr("font-family", titleFamily)
292
+ .attr("font-size", titleFont)
293
+ .attr("font-weight", 900)
294
+ .attr("letter-spacing", "0")
295
+ .attr("fill", "#061b45");
296
+ title.selectAll("tspan")
297
+ .data(titleLines)
298
+ .enter()
299
+ .append("tspan")
300
+ .attr("x", width / 2)
301
+ .attr("dy", (d, i) => i === 0 ? 0 : Math.round(titleFont * 1.02))
302
+ .text(d => d);
303
+
304
+ const titleBottom = 72 + Math.max(1, titleLines.length - 1) * Math.round(titleFont * 1.02) + titleFont * 0.24;
305
+ const ribbonY = Math.max(172, titleBottom + 34);
306
+ const ribbonW = Math.min(420, width * 0.66);
307
+ const ribbonH = 38;
308
+ const ribbonX = (width - ribbonW) / 2;
309
+ svg.append("path")
310
+ .attr("class", "bump12-title-ribbon")
311
+ .attr("d", [
312
+ `M${ribbonX + 18},${ribbonY}`,
313
+ `L${ribbonX + ribbonW - 18},${ribbonY}`,
314
+ `L${ribbonX + ribbonW},${ribbonY + ribbonH / 2}`,
315
+ `L${ribbonX + ribbonW - 18},${ribbonY + ribbonH}`,
316
+ `L${ribbonX + 18},${ribbonY + ribbonH}`,
317
+ `L${ribbonX},${ribbonY + ribbonH / 2}`,
318
+ "Z"
319
+ ].join(" "))
320
+ .attr("fill", "#0b63c8");
321
+ svg.append("line")
322
+ .attr("x1", ribbonX - 46)
323
+ .attr("x2", ribbonX - 10)
324
+ .attr("y1", ribbonY + ribbonH / 2)
325
+ .attr("y2", ribbonY + ribbonH / 2)
326
+ .attr("stroke", "#0b63c8")
327
+ .attr("stroke-width", 3)
328
+ .attr("stroke-linecap", "round");
329
+ svg.append("line")
330
+ .attr("x1", ribbonX + ribbonW + 10)
331
+ .attr("x2", ribbonX + ribbonW + 46)
332
+ .attr("y1", ribbonY + ribbonH / 2)
333
+ .attr("y2", ribbonY + ribbonH / 2)
334
+ .attr("stroke", "#0b63c8")
335
+ .attr("stroke-width", 3)
336
+ .attr("stroke-linecap", "round");
337
+
338
+ const ribbonText = headerParts.ribbon.toUpperCase();
339
+ svg.append("text")
340
+ .attr("class", "bump12-ribbon-text")
341
+ .attr("x", width / 2)
342
+ .attr("y", ribbonY + ribbonH / 2 + 8)
343
+ .attr("text-anchor", "middle")
344
+ .attr("font-family", labelFamily)
345
+ .attr("font-size", fitFont(ribbonText, ribbonW - 50, 22, labelFamily, 900, 12))
346
+ .attr("font-weight", 900)
347
+ .attr("fill", "#ffffff")
348
+ .text(ribbonText);
349
+
350
+ const subtitle = svg.append("text")
351
+ .attr("class", "bump12-subtitle")
352
+ .attr("x", width / 2)
353
+ .attr("y", ribbonY + ribbonH + 34)
354
+ .attr("text-anchor", "middle")
355
+ .attr("font-family", labelFamily)
356
+ .attr("font-size", 18)
357
+ .attr("font-weight", 400)
358
+ .attr("fill", "#304254");
359
+ wrapText(subtitle, subtitleText, width * 0.58, 2, 25, 18, labelFamily, 400);
360
+
361
+ const railX = 43;
362
+ const railY = ribbonY + ribbonH + 96;
363
+ const railW = width - railX * 2;
364
+ const railH = 42;
365
+ const rowAreaTop = railY + railH + 28;
366
+ const rowAreaBottom = Math.min(height - 58, rowAreaTop + Math.max(410, height - rowAreaTop - 76));
367
+ const rowGap = groups.length > 6 ? 12 : 16;
368
+ const rowHeight = Math.max(48, Math.min(88, (rowAreaBottom - rowAreaTop - rowGap * Math.max(groups.length - 1, 0)) / Math.max(groups.length, 1)));
369
+ const rowCenters = groups.map((group, i) => rowAreaTop + rowHeight / 2 + i * (rowHeight + rowGap));
370
+ const markerLeft = railX + 230;
371
+ const markerRight = railX + railW - 68;
372
+ const markerCenters = timePoints.map((timePoint, i) => {
373
+ if (timePoints.length <= 1) return (markerLeft + markerRight) / 2;
374
+ return markerLeft + i * ((markerRight - markerLeft) / (timePoints.length - 1));
375
+ });
376
+ const timeGap = timePoints.length > 1 ? (markerRight - markerLeft) / (timePoints.length - 1) : 120;
377
+ const markerRadius = Math.max(18, Math.min(30, rowHeight * 0.34, timeGap * 0.22));
378
+
379
+ svg.append("rect")
380
+ .attr("class", "bump12-year-rail")
381
+ .attr("x", railX)
382
+ .attr("y", railY)
383
+ .attr("width", railW)
384
+ .attr("height", railH)
385
+ .attr("rx", 10)
386
+ .attr("fill", "url(#bump12-rail)")
387
+ .attr("filter", "url(#bump12-marker-shadow)");
388
+ svg.append("text")
389
+ .attr("x", railX + 58)
390
+ .attr("y", railY + railH / 2 + 7)
391
+ .attr("text-anchor", "middle")
392
+ .attr("font-family", labelFamily)
393
+ .attr("font-size", 16)
394
+ .attr("font-weight", 900)
395
+ .attr("fill", "#ffffff")
396
+ .text(safeText(timeField).toUpperCase());
397
+
398
+ const widestYear = timePoints.reduce((a, b) => safeText(a).length > safeText(b).length ? a : b, "");
399
+ const yearFont = fitFont(widestYear, Math.max(34, timeGap - 12), 18, labelFamily, 900, 11);
400
+ timePoints.forEach((timePoint, i) => {
401
+ svg.append("text")
402
+ .attr("class", "bump12-year-label")
403
+ .attr("x", markerCenters[i])
404
+ .attr("y", railY + railH / 2 + 7)
405
+ .attr("text-anchor", "middle")
406
+ .attr("font-family", labelFamily)
407
+ .attr("font-size", yearFont)
408
+ .attr("font-weight", 900)
409
+ .attr("fill", "#ffffff")
410
+ .text(safeText(timePoint));
411
+ });
412
+
413
+ const guideLayer = svg.append("g").attr("class", "bump12-guide-layer");
414
+ markerCenters.forEach((x, i) => {
415
+ guideLayer.append("line")
416
+ .attr("class", "bump12-year-guide")
417
+ .attr("data-time", safeText(timePoints[i]))
418
+ .attr("x1", x)
419
+ .attr("x2", x)
420
+ .attr("y1", railY + railH)
421
+ .attr("y2", rowCenters[rowCenters.length - 1] + rowHeight / 2 - 3)
422
+ .attr("stroke", "#8aa7bf")
423
+ .attr("stroke-width", 1.4)
424
+ .attr("stroke-dasharray", "2 7")
425
+ .attr("stroke-linecap", "round")
426
+ .attr("opacity", 0.72);
427
+ });
428
+
429
+ const chart = svg.append("g").attr("class", "bump12-chart");
430
+ groups.forEach((group, groupIndex) => {
431
+ const color = rowColor(group, groupIndex);
432
+ const cy = rowCenters[groupIndex];
433
+ const y = cy - rowHeight / 2;
434
+ const points = grouped.get(group) || [];
435
+ const card = chart.append("g")
436
+ .attr("class", "bump12-row")
437
+ .attr("data-group", safeText(group));
438
+
439
+ card.append("rect")
440
+ .attr("class", "bump12-row-card")
441
+ .attr("x", railX)
442
+ .attr("y", y)
443
+ .attr("width", railW)
444
+ .attr("height", rowHeight)
445
+ .attr("rx", 12)
446
+ .attr("fill", "#ffffff")
447
+ .attr("stroke", "#dce9f1")
448
+ .attr("stroke-width", 1)
449
+ .attr("filter", "url(#bump12-card-shadow)");
450
+ card.append("path")
451
+ .attr("class", "bump12-row-accent-strip")
452
+ .attr("d", `M${railX},${y + 12} Q${railX},${y} ${railX + 12},${y} L${railX + 12},${y + rowHeight} Q${railX},${y + rowHeight} ${railX},${y + rowHeight - 12} Z`)
453
+ .attr("fill", color);
454
+
455
+ const slotR = Math.max(18, Math.min(31, rowHeight * 0.34));
456
+ const slotX = railX + 56;
457
+ const iconSlot = card.append("g")
458
+ .attr("class", "reserved-asset-slot bump12-row-icon-slot")
459
+ .attr("data-asset-slot", "row-group-icon")
460
+ .attr("data-slot-policy", "input_asset_or_image2_asset_or_neutral_placeholder")
461
+ .attr("data-asset-source-policy", "input_asset_or_image2_asset_or_neutral_placeholder")
462
+ .attr("data-anchor", "row-left-center")
463
+ .attr("data-bbox", `${Math.round(slotX - slotR)},${Math.round(cy - slotR)},${Math.round(slotR * 2)},${Math.round(slotR * 2)}`)
464
+ .attr("data-mask-policy", "circle")
465
+ .attr("data-collision-rule", "inside row card; clear group label and first marker column")
466
+ .attr("data-group", safeText(group));
467
+ iconSlot.append("circle")
468
+ .attr("cx", slotX)
469
+ .attr("cy", cy)
470
+ .attr("r", slotR)
471
+ .attr("fill", "#f8fcff")
472
+ .attr("stroke", "#d3e5ee")
473
+ .attr("stroke-width", 1.4);
474
+ iconSlot.append("circle")
475
+ .attr("cx", slotX)
476
+ .attr("cy", cy)
477
+ .attr("r", slotR - 7)
478
+ .attr("fill", color)
479
+ .attr("opacity", 0.16);
480
+ iconSlot.append("text")
481
+ .attr("x", slotX)
482
+ .attr("y", cy + Math.max(5, slotR * 0.18))
483
+ .attr("text-anchor", "middle")
484
+ .attr("font-family", labelFamily)
485
+ .attr("font-size", Math.max(9, Math.min(14, slotR * 0.48)))
486
+ .attr("font-weight", 900)
487
+ .attr("fill", color)
488
+ .text(initials(group));
489
+
490
+ const labelX = slotX + slotR + 16;
491
+ const labelMax = Math.max(80, markerLeft - labelX - markerRadius - 12);
492
+ const groupFont = fitFont(safeText(group).toUpperCase(), labelMax, rowHeight > 62 ? 20 : 15, labelFamily, 900, 9);
493
+ const groupLabel = card.append("text")
494
+ .attr("class", "bump12-group-label")
495
+ .attr("x", labelX)
496
+ .attr("y", cy + groupFont * 0.34)
497
+ .attr("font-family", labelFamily)
498
+ .attr("font-size", groupFont)
499
+ .attr("font-weight", 900)
500
+ .attr("letter-spacing", "0")
501
+ .attr("fill", "#061b45");
502
+ if (measureText(safeText(group).toUpperCase(), groupFont, labelFamily, 900) > labelMax) {
503
+ groupLabel.attr("y", cy - groupFont * 0.18);
504
+ wrapText(groupLabel, safeText(group).toUpperCase(), labelMax, 2, Math.round(groupFont * 1.05), groupFont, labelFamily, 900);
505
+ } else {
506
+ groupLabel.text(safeText(group).toUpperCase());
507
+ }
508
+
509
+ for (let i = 0; i < points.length - 1; i += 1) {
510
+ const currentIndex = timePoints.indexOf(points[i][timeField]);
511
+ const nextIndex = timePoints.indexOf(points[i + 1][timeField]);
512
+ if (currentIndex < 0 || nextIndex < 0) continue;
513
+ const x1 = markerCenters[currentIndex] + markerRadius - 1;
514
+ const x2 = markerCenters[nextIndex] - markerRadius + 1;
515
+ if (x2 <= x1) continue;
516
+ card.append("line")
517
+ .attr("class", "bump12-connector")
518
+ .attr("data-group", safeText(group))
519
+ .attr("x1", x1)
520
+ .attr("x2", x2)
521
+ .attr("y1", cy)
522
+ .attr("y2", cy)
523
+ .attr("stroke", color)
524
+ .attr("stroke-width", 3.2)
525
+ .attr("opacity", 0.88)
526
+ .attr("stroke-linecap", "round");
527
+ }
528
+
529
+ points.forEach(point => {
530
+ const timeIndex = timePoints.indexOf(point[timeField]);
531
+ if (timeIndex < 0) return;
532
+ const cx = markerCenters[timeIndex];
533
+ const valueText = compactValue(point[valueField]);
534
+ const valueFont = fitFont(valueText, markerRadius * 1.55, 18, annotationFamily, 900, 9);
535
+ card.append("circle")
536
+ .attr("class", "bump12-value-marker")
537
+ .attr("data-group", safeText(group))
538
+ .attr("data-time", safeText(point[timeField]))
539
+ .attr("data-value", Number.isFinite(+point[valueField]) ? +point[valueField] : "")
540
+ .attr("cx", cx)
541
+ .attr("cy", cy)
542
+ .attr("r", markerRadius)
543
+ .attr("fill", "#f8fcfb")
544
+ .attr("stroke", color)
545
+ .attr("stroke-width", 2.4)
546
+ .attr("filter", "url(#bump12-marker-shadow)");
547
+ card.append("text")
548
+ .attr("class", "bump12-value-label")
549
+ .attr("x", cx)
550
+ .attr("y", cy + valueFont * 0.34)
551
+ .attr("text-anchor", "middle")
552
+ .attr("font-family", annotationFamily)
553
+ .attr("font-size", valueFont)
554
+ .attr("font-weight", 900)
555
+ .attr("fill", color)
556
+ .text(valueText);
557
+ });
558
+ });
559
+
560
+ return svg.node();
561
+ }
modules/chart_engine/template/d3-js/line_graph/bump_chart_13.js ADDED
@@ -0,0 +1,439 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Sorted Lines Chart",
5
+ "chart_name": "bump_chart_13",
6
+ "is_composite": false,
7
+ "chart_for": "comparison",
8
+ "required_fields": ["x", "y", "group"],
9
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
10
+ "required_fields_range": [
11
+ [2, 12],
12
+ [0, "inf"],
13
+ [4, 10]
14
+ ],
15
+ "required_fields_icons": [],
16
+ "required_other_icons": [],
17
+ "required_fields_colors": ["group"],
18
+ "required_other_colors": [],
19
+ "supported_effects": ["spacing"],
20
+ "min_height": 400,
21
+ "min_width": 400,
22
+ "background": "no",
23
+ "icon_mark": "none",
24
+ "icon_label": "side",
25
+ "has_x_axis": "no",
26
+ "has_y_axis": "no"
27
+ }
28
+ REQUIREMENTS_END
29
+ */
30
+
31
+ function makeChart(containerSelector, data) {
32
+ const jsonData = data;
33
+ const chartData = (jsonData.data && jsonData.data.data) || [];
34
+ const variables = jsonData.variables || {};
35
+ const typography = jsonData.typography || {};
36
+ const colorResolver = chartUtils.color.resolver(jsonData);
37
+ const dataColumns = chartUtils.schema.columns(jsonData);
38
+
39
+ d3.select(containerSelector).html("");
40
+
41
+ const width = Math.max(600, +variables.width || 600);
42
+ const timeField = chartUtils.schema.channel(jsonData, "x").key;
43
+ const valueField = chartUtils.schema.channel(jsonData, "y").key;
44
+ const groupField = chartUtils.schema.channel(jsonData, "group").key;
45
+ const groups = Array.from(new Set(chartData.map(d => d[groupField])));
46
+ const denseGroupHeight = 610 + Math.max(0, groups.length - 4) * 36;
47
+ const height = Math.max(denseGroupHeight, Math.round((+variables.height || 600) * 1.03));
48
+
49
+ const titleFamily = (typography.title && typography.title.font_family) || "Arial, Helvetica, sans-serif";
50
+ const labelFamily = (typography.label && typography.label.font_family) || "Arial, Helvetica, sans-serif";
51
+ const annotationFamily = (typography.annotation && typography.annotation.font_family) || "Arial, Helvetica, sans-serif";
52
+
53
+ function safeText(value) {
54
+ return value == null ? "" : String(value);
55
+ }
56
+
57
+ function parseTime(value) {
58
+ const parsed = chartUtils.format.parseDate(value);
59
+ if (parsed instanceof Date && !Number.isNaN(parsed.getTime())) return parsed.getTime();
60
+ const numeric = +value;
61
+ if (Number.isFinite(numeric)) return numeric;
62
+ return safeText(value).toLowerCase();
63
+ }
64
+
65
+ function compactValue(value) {
66
+ const numeric = +value;
67
+ if (!Number.isFinite(numeric)) return safeText(value);
68
+ const column = dataColumns.find(col => col && col.name === valueField) || {};
69
+ const unit = safeText(column.unit || "").trim();
70
+ let formatted = chartUtils.format.compactFixedText(numeric).replace(/\.0$/, "");
71
+ if (unit === "%" && !formatted.includes("%")) formatted = `${formatted}%`;
72
+ return formatted;
73
+ }
74
+
75
+ function measure(text, fontSize, fontWeight = "700", fontFamily = labelFamily) {
76
+ return chartUtils.text.measure(null, safeText(text), {
77
+ fontFamily,
78
+ fontSize,
79
+ fontWeight
80
+ }).width;
81
+ }
82
+
83
+ function fitSize(text, maxWidth, start, min, weight = "700", family = labelFamily) {
84
+ let size = start;
85
+ while (size > min && measure(text, size, weight, family) > maxWidth) size -= 1;
86
+ return size;
87
+ }
88
+
89
+ function wrapWords(text, maxWidth, fontSize, fontWeight, maxLines) {
90
+ const words = safeText(text).split(/\s+/).filter(Boolean);
91
+ const lines = [];
92
+ let line = "";
93
+ words.forEach(word => {
94
+ const candidate = line ? `${line} ${word}` : word;
95
+ if (!line || measure(candidate, fontSize, fontWeight) <= maxWidth) {
96
+ line = candidate;
97
+ } else {
98
+ lines.push(line);
99
+ line = word;
100
+ }
101
+ });
102
+ if (line) lines.push(line);
103
+ const visible = lines.slice(0, maxLines);
104
+ if (lines.length > maxLines && visible.length) {
105
+ let last = visible[visible.length - 1];
106
+ while (last.length > 1 && measure(`${last}...`, fontSize, fontWeight) > maxWidth) {
107
+ last = last.slice(0, -1);
108
+ }
109
+ visible[visible.length - 1] = `${last}...`;
110
+ }
111
+ return visible;
112
+ }
113
+
114
+ function hexagonPath(cx, cy, r) {
115
+ const points = d3.range(6).map(i => {
116
+ const angle = Math.PI / 6 + i * Math.PI / 3;
117
+ return [cx + r * Math.cos(angle), cy + r * Math.sin(angle)];
118
+ });
119
+ return d3.line()(points) + "Z";
120
+ }
121
+
122
+ function tint(color, amount = 0.9) {
123
+ const c = d3.color(color) || d3.color("#426BB5");
124
+ const r = Math.round(c.r + (255 - c.r) * amount);
125
+ const g = Math.round(c.g + (255 - c.g) * amount);
126
+ const b = Math.round(c.b + (255 - c.b) * amount);
127
+ return `rgb(${r},${g},${b})`;
128
+ }
129
+
130
+ const timePoints = Array.from(new Set(chartData.map(d => d[timeField])))
131
+ .sort((a, b) => {
132
+ const pa = parseTime(a);
133
+ const pb = parseTime(b);
134
+ if (typeof pa === "number" && typeof pb === "number" && pa !== pb) return pa - pb;
135
+ return safeText(a).localeCompare(safeText(b), undefined, { numeric: true });
136
+ });
137
+ const colorScale = colorResolver.scale(groups, { palette: "category10" });
138
+ const referencePalette = ["#1E5CE6", "#D31678", "#36AF32", "#0FB6CF", "#7B61FF", "#F59E0B"];
139
+ const maxValue = d3.max(chartData, d => +d[valueField]) || 1;
140
+ const valueUnit = (chartUtils.schema.channel(jsonData, "y").unit || "").replace(/^none$/i, "");
141
+
142
+ function groupColor(group, index) {
143
+ const referenceColor = referencePalette[index % referencePalette.length];
144
+ const resolvedField = colorResolver.field(group, index, { fallback: referenceColor });
145
+ let resolved = null;
146
+ if (resolvedField && typeof resolvedField.value === "function") {
147
+ resolved = resolvedField.value(group);
148
+ } else if (resolvedField && resolvedField.value) {
149
+ resolved = resolvedField.value;
150
+ }
151
+ const parsed = d3.color(resolved);
152
+ if (!parsed || index < 4) return referenceColor;
153
+ return parsed.formatHex();
154
+ }
155
+
156
+ const svg = d3.select(containerSelector)
157
+ .append("svg")
158
+ .attr("width", "100%")
159
+ .attr("height", height)
160
+ .attr("viewBox", `0 0 ${width} ${height}`)
161
+ .attr("style", "max-width: 100%; height: auto;")
162
+ .attr("xmlns", "http://www.w3.org/2000/svg")
163
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
164
+
165
+ const defs = svg.append("defs");
166
+ defs.append("filter")
167
+ .attr("id", "bump13-soft-shadow")
168
+ .attr("x", "-8%")
169
+ .attr("y", "-20%")
170
+ .attr("width", "116%")
171
+ .attr("height", "150%")
172
+ .append("feDropShadow")
173
+ .attr("dx", 0)
174
+ .attr("dy", 4)
175
+ .attr("stdDeviation", 6)
176
+ .attr("flood-color", "#0B2E61")
177
+ .attr("flood-opacity", 0.12);
178
+
179
+ const markerGradientIds = {};
180
+ groups.forEach((group, index) => {
181
+ const base = d3.color(groupColor(group, index)) || d3.color("#426BB5");
182
+ const gradient = defs.append("radialGradient")
183
+ .attr("id", `bump13-marker-${index}`)
184
+ .attr("cx", "35%")
185
+ .attr("cy", "28%")
186
+ .attr("r", "78%");
187
+ gradient.append("stop").attr("offset", "0%").attr("stop-color", base.brighter(0.75).formatHex());
188
+ gradient.append("stop").attr("offset", "58%").attr("stop-color", base.formatHex());
189
+ gradient.append("stop").attr("offset", "100%").attr("stop-color", base.darker(0.35).formatHex());
190
+ markerGradientIds[group] = `bump13-marker-${index}`;
191
+ });
192
+
193
+ const outerPad = 14;
194
+ svg.append("rect")
195
+ .attr("class", "bump13-main-panel")
196
+ .attr("data-layout-role", "main-panel")
197
+ .attr("x", outerPad)
198
+ .attr("y", 8)
199
+ .attr("width", width - outerPad * 2)
200
+ .attr("height", height - 18)
201
+ .attr("rx", 22)
202
+ .attr("fill", "#FFFFFF")
203
+ .attr("fill-opacity", 0.94)
204
+ .attr("filter", "url(#bump13-soft-shadow)");
205
+
206
+ const panelX = 30;
207
+ const panelW = width - 60;
208
+ const legendY = 24;
209
+ const legendH = groups.length > 4 ? 72 : 54;
210
+ const yearY = legendY + legendH + 60;
211
+ const rowTop = yearY + 34;
212
+ const noteH = 34;
213
+ const rowGap = Math.max(12, Math.min(18, height * 0.025));
214
+ const minRowH = groups.length > 5 ? 58 : groups.length > 4 ? 64 : 74;
215
+ const rowH = Math.max(minRowH, (height - rowTop - noteH - 28 - rowGap * Math.max(0, groups.length - 1)) / Math.max(1, groups.length));
216
+ const labelW = Math.min(148, Math.max(128, panelW * 0.25));
217
+ const iconSize = Math.min(42, rowH * 0.42);
218
+ const markerX0 = panelX + labelW + 28;
219
+ const markerX1 = panelX + panelW - 28;
220
+ const xScale = d3.scalePoint()
221
+ .domain(timePoints)
222
+ .range([markerX0, markerX1])
223
+ .padding(0.08);
224
+
225
+ const legend = svg.append("g")
226
+ .attr("class", "bump13-legend-strip")
227
+ .attr("data-layout-role", "legend")
228
+ .attr("data-coordinate-rule", "independent-strip-above-year-rail");
229
+ legend.append("rect")
230
+ .attr("x", panelX + 10)
231
+ .attr("y", legendY)
232
+ .attr("width", panelW - 20)
233
+ .attr("height", legendH)
234
+ .attr("rx", 14)
235
+ .attr("fill", "#FFFFFF")
236
+ .attr("stroke", "#EDF2FA")
237
+ .attr("stroke-width", 1)
238
+ .attr("filter", "url(#bump13-soft-shadow)");
239
+
240
+ const legendInnerX = panelX + 32;
241
+ const legendInnerW = panelW - 64;
242
+ const legendItemW = legendInnerW / Math.max(1, Math.min(groups.length, 4));
243
+ groups.forEach((group, index) => {
244
+ const color = groupColor(group, index);
245
+ const col = groups.length <= 4 ? index : index % 4;
246
+ const row = groups.length <= 4 ? 0 : Math.floor(index / 4);
247
+ const itemX = legendInnerX + col * legendItemW;
248
+ const itemY = legendY + 29 + row * 28;
249
+ legend.append("path")
250
+ .attr("d", hexagonPath(itemX + 8, itemY, 8))
251
+ .attr("fill", `url(#${markerGradientIds[group]})`)
252
+ .attr("stroke", d3.color(color).darker(0.25).formatHex())
253
+ .attr("stroke-width", 0.8);
254
+ const label = safeText(group);
255
+ const fontSize = fitSize(label, Math.max(40, legendItemW - 26), 13, 8, "600", labelFamily);
256
+ legend.append("text")
257
+ .attr("x", itemX + 26)
258
+ .attr("y", itemY + 4)
259
+ .attr("dominant-baseline", "middle")
260
+ .attr("font-family", labelFamily)
261
+ .attr("font-size", fontSize)
262
+ .attr("font-weight", 600)
263
+ .attr("fill", "#13233F")
264
+ .text(label);
265
+ });
266
+
267
+ const yearLayer = svg.append("g")
268
+ .attr("class", "bump13-year-rail")
269
+ .attr("data-layout-role", "year-rail")
270
+ .attr("data-coordinate-rule", "grid-aligned-to-marker-columns")
271
+ .attr("data-time-order", "chronological");
272
+ timePoints.forEach(tp => {
273
+ const x = xScale(tp);
274
+ yearLayer.append("text")
275
+ .attr("x", x)
276
+ .attr("y", yearY)
277
+ .attr("text-anchor", "middle")
278
+ .attr("font-family", titleFamily)
279
+ .attr("font-size", 15)
280
+ .attr("font-weight", 800)
281
+ .attr("fill", "#071A45")
282
+ .text(safeText(tp));
283
+ });
284
+
285
+ const rows = svg.append("g")
286
+ .attr("class", "bump13-rows")
287
+ .attr("data-layout-role", "row-bands")
288
+ .attr("data-coordinate-rule", "row-band-centerline-aligned-label-icon-and-markers");
289
+
290
+ const radiusScale = d3.scaleSqrt()
291
+ .domain([0, maxValue])
292
+ .range([7, Math.min(30, rowH * 0.34)])
293
+ .clamp(true);
294
+
295
+ groups.forEach((group, groupIndex) => {
296
+ const color = groupColor(group, groupIndex);
297
+ const rowY = rowTop + groupIndex * (rowH + rowGap);
298
+ const rowCenter = rowY + rowH / 2;
299
+ const groupData = chartData
300
+ .filter(d => d[groupField] === group)
301
+ .sort((a, b) => timePoints.indexOf(a[timeField]) - timePoints.indexOf(b[timeField]));
302
+
303
+ const row = rows.append("g")
304
+ .attr("class", "bump13-row")
305
+ .attr("data-series", safeText(group))
306
+ .attr("data-row-index", groupIndex);
307
+
308
+ row.append("rect")
309
+ .attr("class", "bump13-row-band")
310
+ .attr("x", panelX)
311
+ .attr("y", rowY)
312
+ .attr("width", panelW)
313
+ .attr("height", rowH)
314
+ .attr("rx", 13)
315
+ .attr("fill", tint(color, 0.88))
316
+ .attr("fill-opacity", 0.72);
317
+
318
+ const iconX = panelX + (labelW - iconSize) / 2;
319
+ const iconY = rowY + 10;
320
+ const icon = row.append("g")
321
+ .attr("class", "reserved-asset-slot bump13-group-icon-slot")
322
+ .attr("data-asset-slot", "group-icon")
323
+ .attr("data-slot-policy", "input_asset_or_image2_asset")
324
+ .attr("data-asset-source-policy", "input_asset_or_image2_asset")
325
+ .attr("data-reserved-slot", "group_icon_slot")
326
+ .attr("data-group", safeText(group))
327
+ .attr("data-anchor", "group-row-label-block")
328
+ .attr("data-collision-rule", "stay-inside-row-band-left-of-marker-columns-and-clear-of-group-label")
329
+ .attr("data-bbox", `${iconX.toFixed(2)},${iconY.toFixed(2)},${iconSize.toFixed(2)},${iconSize.toFixed(2)}`)
330
+ .attr("data-bbox-policy", "transform plus local width/height define the visible reserved bbox")
331
+ .attr("data-mask-policy", "downstream asset may replace only within this hex slot")
332
+ .attr("transform", `translate(${iconX},${iconY})`);
333
+ icon.append("path")
334
+ .attr("d", hexagonPath(iconSize / 2, iconSize / 2, iconSize / 2))
335
+ .attr("fill", `url(#${markerGradientIds[group]})`)
336
+ .attr("stroke", d3.color(color).darker(0.35).formatHex())
337
+ .attr("stroke-width", 1.1);
338
+ icon.append("text")
339
+ .attr("x", iconSize / 2)
340
+ .attr("y", iconSize / 2 + 1)
341
+ .attr("text-anchor", "middle")
342
+ .attr("dominant-baseline", "middle")
343
+ .attr("font-family", titleFamily)
344
+ .attr("font-size", Math.max(10, iconSize * 0.32))
345
+ .attr("font-weight", 900)
346
+ .attr("fill", "#FFFFFF")
347
+ .text(safeText(group).slice(0, 1).toUpperCase());
348
+
349
+ const labelX = panelX + labelW / 2;
350
+ const labelMaxW = labelW - 18;
351
+ const labelLines = wrapWords(group, labelMaxW, 11, "800", 2);
352
+ const labelStartY = iconY + iconSize + 15 - (labelLines.length - 1) * 7;
353
+ labelLines.forEach((line, lineIndex) => {
354
+ row.append("text")
355
+ .attr("class", "bump13-group-label")
356
+ .attr("x", labelX)
357
+ .attr("y", labelStartY + lineIndex * 17)
358
+ .attr("dominant-baseline", "middle")
359
+ .attr("text-anchor", "middle")
360
+ .attr("font-family", titleFamily)
361
+ .attr("font-size", fitSize(line, labelMaxW, 11, 8, "800", titleFamily))
362
+ .attr("font-weight", 800)
363
+ .attr("fill", d3.color(color).darker(0.32).formatHex())
364
+ .text(line);
365
+ });
366
+
367
+ const points = groupData.map(d => ({
368
+ datum: d,
369
+ x: xScale(d[timeField]),
370
+ y: rowCenter - Math.min(10, rowH * 0.13),
371
+ value: +d[valueField],
372
+ radius: radiusScale(+d[valueField])
373
+ })).filter(p => Number.isFinite(p.x));
374
+
375
+ if (points.length >= 2) {
376
+ row.append("path")
377
+ .attr("class", "bump13-connector")
378
+ .attr("fill", "none")
379
+ .attr("stroke", color)
380
+ .attr("stroke-width", 1.6)
381
+ .attr("stroke-opacity", 0.92)
382
+ .attr("d", d3.line().x(d => d.x).y(d => d.y)(points));
383
+ }
384
+
385
+ points.forEach(point => {
386
+ row.append("path")
387
+ .attr("class", "bump13-hex-marker")
388
+ .attr("data-year", safeText(point.datum[timeField]))
389
+ .attr("data-value", safeText(point.datum[valueField]))
390
+ .attr("d", hexagonPath(point.x, point.y, point.radius))
391
+ .attr("fill", `url(#${markerGradientIds[group]})`)
392
+ .attr("stroke", d3.color(color).darker(0.28).formatHex())
393
+ .attr("stroke-width", 0.8);
394
+
395
+ row.append("text")
396
+ .attr("class", "bump13-value-label")
397
+ .attr("x", point.x)
398
+ .attr("y", Math.min(rowY + rowH - 11, point.y + point.radius + 17))
399
+ .attr("text-anchor", "middle")
400
+ .attr("dominant-baseline", "middle")
401
+ .attr("font-family", annotationFamily)
402
+ .attr("font-size", 13)
403
+ .attr("font-weight", 700)
404
+ .attr("fill", "#071A45")
405
+ .text(compactValue(point.value));
406
+ });
407
+ });
408
+
409
+ const noteY = Math.min(height - 26, rowTop + groups.length * rowH + Math.max(0, groups.length - 1) * rowGap + 24);
410
+ const note = svg.append("g")
411
+ .attr("class", "bump13-unit-note")
412
+ .attr("data-layout-role", "chart-owned-unit-note")
413
+ .attr("data-coordinate-rule", "bottom-anchored-independent-note");
414
+ note.append("circle")
415
+ .attr("cx", panelX + 12)
416
+ .attr("cy", noteY - 4)
417
+ .attr("r", 8)
418
+ .attr("fill", "#7F8CA3");
419
+ note.append("text")
420
+ .attr("x", panelX + 12)
421
+ .attr("y", noteY - 3)
422
+ .attr("text-anchor", "middle")
423
+ .attr("dominant-baseline", "middle")
424
+ .attr("font-family", annotationFamily)
425
+ .attr("font-size", 11)
426
+ .attr("font-weight", 800)
427
+ .attr("fill", "#FFFFFF")
428
+ .text("i");
429
+ note.append("text")
430
+ .attr("x", panelX + 30)
431
+ .attr("y", noteY)
432
+ .attr("font-family", annotationFamily)
433
+ .attr("font-size", 12)
434
+ .attr("font-style", "italic")
435
+ .attr("fill", "#69758B")
436
+ .text(valueUnit ? `Values represent ${valueField} (${valueUnit}).` : `Values represent ${valueField}.`);
437
+
438
+ return svg.node();
439
+ }
modules/chart_engine/template/d3-js/line_graph/bump_chart_14.js ADDED
@@ -0,0 +1,455 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Sorted Lines Chart",
5
+ "chart_name": "bump_chart_14",
6
+ "is_composite": false,
7
+ "chart_for": "comparison",
8
+ "required_fields": ["x", "y", "group"],
9
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
10
+ "required_fields_range": [
11
+ [2, 12],
12
+ [0, "inf"],
13
+ [4, 10]
14
+ ],
15
+ "required_fields_icons": ["group"],
16
+ "required_other_icons": [],
17
+ "required_fields_colors": ["group"],
18
+ "required_other_colors": [],
19
+ "supported_effects": ["spacing"],
20
+ "min_height": 400,
21
+ "min_width": 400,
22
+ "background": "no",
23
+ "icon_mark": "none",
24
+ "icon_label": "none",
25
+ "has_x_axis": "no",
26
+ "has_y_axis": "no"
27
+ }
28
+ REQUIREMENTS_END
29
+ */
30
+
31
+ function makeChart(containerSelector, data) {
32
+ const jsonData = data;
33
+ const chartData = jsonData.data.data;
34
+ const variables = jsonData.variables || {};
35
+ const typography = jsonData.typography || {
36
+ label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
37
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "bold" }
38
+ };
39
+ const colors = jsonData.colors || { text_color: "#333333" };
40
+ const colorResolver = chartUtils.color.resolver(jsonData);
41
+ const images = jsonData.images || { field: {}, other: {} };
42
+
43
+ d3.select(containerSelector).html("");
44
+
45
+ const width = +variables.width || 800;
46
+ const height = +variables.height || 600;
47
+ const timeField = chartUtils.schema.channel(jsonData, "x").key;
48
+ const valueField = chartUtils.schema.channel(jsonData, "y").key;
49
+ const groupField = chartUtils.schema.channel(jsonData, "group").key;
50
+ const valueChannel = chartUtils.schema.channel(jsonData, "y") || {};
51
+ const valueUnit = valueChannel.unit && valueChannel.unit !== "none" ? valueChannel.unit : "";
52
+
53
+ const timePoints = [...new Set(chartData.map(d => d[timeField]))]
54
+ .sort((a, b) => {
55
+ const da = chartUtils.format.parseDate(a);
56
+ const db = chartUtils.format.parseDate(b);
57
+ const ta = da instanceof Date ? da.getTime() : +da;
58
+ const tb = db instanceof Date ? db.getTime() : +db;
59
+ if (Number.isFinite(ta) && Number.isFinite(tb) && ta !== tb) return ta - tb;
60
+ return String(a).localeCompare(String(b), undefined, { numeric: true });
61
+ });
62
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
63
+
64
+ const svg = d3.select(containerSelector)
65
+ .append("svg")
66
+ .attr("width", "100%")
67
+ .attr("height", height)
68
+ .attr("viewBox", `0 0 ${width} ${height}`)
69
+ .attr("style", "max-width: 100%; height: auto;")
70
+ .attr("xmlns", "http://www.w3.org/2000/svg")
71
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
72
+
73
+ const fontFamily = typography.label.font_family || "Arial, Helvetica, sans-serif";
74
+ const textColor = colorResolver.text({ fallback: colors.text_color || "#17213A" }).value;
75
+ const navy = "#101A3F";
76
+
77
+ const defs = svg.append("defs");
78
+ defs.append("filter")
79
+ .attr("id", "bump14-soft-shadow")
80
+ .attr("x", "-8%")
81
+ .attr("y", "-24%")
82
+ .attr("width", "116%")
83
+ .attr("height", "160%")
84
+ .append("feDropShadow")
85
+ .attr("dx", 0)
86
+ .attr("dy", 5)
87
+ .attr("stdDeviation", 7)
88
+ .attr("flood-color", "#23345D")
89
+ .attr("flood-opacity", 0.13);
90
+
91
+ svg.append("g")
92
+ .attr("class", "reserved-asset-slot bump14-header-primary-slot")
93
+ .attr("data-asset-slot", "header-primary")
94
+ .attr("data-asset-source-policy", images.other && images.other.primary ? "input_provided" : "input_asset_or_image2_asset")
95
+ .attr("data-anchor", "header-left-safe-area")
96
+ .attr("data-bbox", `${Math.round(width * 0.04)},${Math.round(height * 0.02)},${Math.round(width * 0.20)},${Math.round(height * 0.16)}`)
97
+ .attr("data-collision-rule", "owned by header wrapper; avoid title, subtitle, legend, and chart panels")
98
+ .attr("data-no-copied-reference-art", "true")
99
+ .attr("opacity", 0);
100
+
101
+ function measure(text, fontSize, fontWeight = "700") {
102
+ return chartUtils.text.measure(svg, String(text), { fontFamily, fontSize, fontWeight }).width;
103
+ }
104
+
105
+ function fitText(text, maxWidth, fontSize, fontWeight = "700", minSize = 7) {
106
+ let size = fontSize;
107
+ while (size > minSize && measure(text, size, fontWeight) > maxWidth) size -= 1;
108
+ return size;
109
+ }
110
+
111
+ function ellipsize(text, maxWidth, fontSize, fontWeight = "700") {
112
+ const value = String(text);
113
+ if (measure(value, fontSize, fontWeight) <= maxWidth) return value;
114
+ let clipped = value;
115
+ while (clipped.length > 2 && measure(`${clipped}...`, fontSize, fontWeight) > maxWidth) {
116
+ clipped = clipped.slice(0, -1);
117
+ }
118
+ return `${clipped}...`;
119
+ }
120
+
121
+ function getColor(group, index) {
122
+ const scale = colorResolver.scale(groups, { palette: "category10" });
123
+ return scale(group) || colorResolver.field(group, index, { fallback: "#2F6DEB" }).value || "#2F6DEB";
124
+ }
125
+
126
+ function rgba(color, alpha) {
127
+ const c = d3.color(color);
128
+ if (!c) return `rgba(47,109,235,${alpha})`;
129
+ return `rgba(${c.r},${c.g},${c.b},${alpha})`;
130
+ }
131
+
132
+ function circleFill(color, timeIndex) {
133
+ const c = d3.color(color);
134
+ if (!c) return color;
135
+ const n = Math.max(1, timePoints.length - 1);
136
+ const t = timeIndex / n;
137
+ if (t < 0.45) return c.brighter(0.75 - t).formatHex();
138
+ return c.darker(Math.max(0, (t - 0.25) * 0.95)).formatHex();
139
+ }
140
+
141
+ function contrastText(fill) {
142
+ const c = d3.color(fill);
143
+ if (!c) return "#FFFFFF";
144
+ const luminance = (0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b) / 255;
145
+ return luminance > 0.62 ? "#17213A" : "#FFFFFF";
146
+ }
147
+
148
+ function drawGroupIcon(parent, group, x, y, size, color, anchorName) {
149
+ const slot = parent.append("g")
150
+ .attr("class", "bump14-group-icon-slot reserved-asset-slot")
151
+ .attr("data-asset-slot", "group-icon")
152
+ .attr("data-group", String(group))
153
+ .attr("data-asset-source-policy", "neutral_placeholder_or_downstream_asset")
154
+ .attr("data-anchor", anchorName)
155
+ .attr("data-collision-rule", "stay inside legend item or row card bbox")
156
+ .attr("data-no-copied-reference-art", "true")
157
+ .attr("data-no-embedded-raster", "true")
158
+ .attr("transform", `translate(${x},${y})`);
159
+
160
+ slot.append("circle")
161
+ .attr("r", size / 2)
162
+ .attr("fill", "#F6FAFF")
163
+ .attr("stroke", rgba(color, 0.30))
164
+ .attr("stroke-width", 1.4);
165
+
166
+ slot.append("circle")
167
+ .attr("r", Math.max(2.2, size * 0.14))
168
+ .attr("fill", color)
169
+ .attr("opacity", 0.72);
170
+
171
+ slot.append("text")
172
+ .attr("text-anchor", "middle")
173
+ .attr("dominant-baseline", "middle")
174
+ .attr("font-family", fontFamily)
175
+ .attr("font-weight", "900")
176
+ .attr("font-size", Math.max(8, size * 0.42))
177
+ .attr("fill", color)
178
+ .attr("opacity", 0.92)
179
+ .text(String(group).slice(0, 1).toUpperCase());
180
+ }
181
+
182
+ const values = chartData.map(d => +d[valueField]).filter(Number.isFinite);
183
+ const maxValue = d3.max(values) || 1;
184
+ const minValue = d3.min(values) || 0;
185
+
186
+ const outerX = Math.max(20, width * 0.04);
187
+ const outerRight = Math.max(20, width * 0.035);
188
+ const legendX = outerX;
189
+ const legendY = Math.max(8, height * 0.018);
190
+ const legendW = width - outerX - outerRight;
191
+ const legendCols = Math.max(1, Math.min(4, groups.length));
192
+ const legendRows = Math.ceil(groups.length / legendCols);
193
+ const legendRowH = Math.max(34, Math.min(42, height * 0.07));
194
+ const legendH = 16 + legendRows * legendRowH;
195
+ const yearLabelY = legendY + legendH + Math.max(28, height * 0.055);
196
+ const dotY = yearLabelY + 20;
197
+ const rowTop = dotY + Math.max(18, height * 0.035);
198
+ const bottomPad = Math.max(18, height * 0.035);
199
+ const rowGap = Math.max(8, Math.min(15, height * 0.022));
200
+ let rowH = (height - rowTop - bottomPad - rowGap * Math.max(0, groups.length - 1)) / Math.max(1, groups.length);
201
+ rowH = Math.max(34, Math.min(82, rowH));
202
+ const rowChartBottom = rowTop + groups.length * rowH + Math.max(0, groups.length - 1) * rowGap;
203
+
204
+ const labelCardW = Math.max(82, Math.min(118, width * 0.20));
205
+ const plotX0 = outerX + labelCardW + Math.max(25, width * 0.045);
206
+ const plotX1 = width - outerRight - 4;
207
+ const xScale = d3.scalePoint()
208
+ .domain(timePoints)
209
+ .range([plotX0, plotX1])
210
+ .padding(0.03);
211
+ const columnGap = timePoints.length > 1
212
+ ? d3.min(timePoints.slice(1).map((d, i) => xScale(d) - xScale(timePoints[i])))
213
+ : plotX1 - plotX0;
214
+ const maxRadius = Math.max(7, Math.min(rowH * 0.31, columnGap * 0.30, 24));
215
+ const minRadius = Math.max(4.5, Math.min(maxRadius * 0.38, 8));
216
+ const radiusScale = d3.scaleSqrt()
217
+ .domain([Math.min(0, minValue), maxValue])
218
+ .range([minRadius, maxRadius])
219
+ .clamp(true);
220
+
221
+ const legend = svg.append("g")
222
+ .attr("class", "bump14-legend-card")
223
+ .attr("data-coordinate-relationship", "independent_grid_aligned")
224
+ .attr("data-anchor", "below-title-above-year-grid");
225
+
226
+ legend.append("rect")
227
+ .attr("x", legendX)
228
+ .attr("y", legendY)
229
+ .attr("width", legendW)
230
+ .attr("height", legendH)
231
+ .attr("rx", 12)
232
+ .attr("fill", "rgba(255,255,255,0.88)")
233
+ .attr("stroke", "rgba(255,255,255,0.95)")
234
+ .attr("filter", "url(#bump14-soft-shadow)");
235
+
236
+ groups.forEach((group, index) => {
237
+ const color = getColor(group, index);
238
+ const col = index % legendCols;
239
+ const row = Math.floor(index / legendCols);
240
+ const itemW = legendW / legendCols;
241
+ const itemX = legendX + col * itemW + 14;
242
+ const itemY = legendY + 11 + row * legendRowH;
243
+ const iconSize = Math.max(18, Math.min(28, legendRowH * 0.65));
244
+ drawGroupIcon(legend, group, itemX + iconSize / 2, itemY + iconSize / 2, iconSize, color, `legend-item-${index + 1}`);
245
+
246
+ const labelX = itemX + iconSize + 10;
247
+ const labelMaxW = Math.max(30, itemW - iconSize - 28);
248
+ const labelFont = fitText(group, labelMaxW, Math.max(10, Math.min(14, legendRowH * 0.34)), "800", 7);
249
+ legend.append("text")
250
+ .attr("x", labelX)
251
+ .attr("y", itemY + iconSize / 2)
252
+ .attr("dominant-baseline", "middle")
253
+ .attr("text-anchor", "start")
254
+ .attr("font-family", fontFamily)
255
+ .attr("font-size", labelFont)
256
+ .attr("font-weight", "800")
257
+ .attr("fill", navy)
258
+ .text(ellipsize(group, labelMaxW, labelFont, "800"));
259
+
260
+ legend.append("line")
261
+ .attr("x1", labelX)
262
+ .attr("x2", Math.min(labelX + 34, itemX + itemW - 12))
263
+ .attr("y1", itemY + iconSize + 7)
264
+ .attr("y2", itemY + iconSize + 7)
265
+ .attr("stroke", color)
266
+ .attr("stroke-width", 4)
267
+ .attr("stroke-linecap", "round");
268
+ });
269
+
270
+ const axisLayer = svg.append("g")
271
+ .attr("class", "bump14-year-grid")
272
+ .attr("data-coordinate-relationship", "grid_aligned")
273
+ .attr("data-anchor", "chronological-time-scale");
274
+
275
+ timePoints.forEach(timePoint => {
276
+ const x = xScale(timePoint);
277
+ axisLayer.append("line")
278
+ .attr("x1", x)
279
+ .attr("x2", x)
280
+ .attr("y1", dotY + 6)
281
+ .attr("y2", rowChartBottom + 4)
282
+ .attr("stroke", "#B8C7EA")
283
+ .attr("stroke-width", 1)
284
+ .attr("stroke-dasharray", "2 6")
285
+ .attr("opacity", 0.75);
286
+ axisLayer.append("text")
287
+ .attr("x", x)
288
+ .attr("y", yearLabelY)
289
+ .attr("text-anchor", "middle")
290
+ .attr("dominant-baseline", "middle")
291
+ .attr("font-family", fontFamily)
292
+ .attr("font-size", fitText(timePoint, Math.max(24, columnGap - 8), 12, "700", 8))
293
+ .attr("font-weight", "700")
294
+ .attr("fill", navy)
295
+ .text(timePoint);
296
+ axisLayer.append("circle")
297
+ .attr("cx", x)
298
+ .attr("cy", dotY)
299
+ .attr("r", 4.2)
300
+ .attr("fill", "#C8D3F3");
301
+ });
302
+
303
+ const rowLayer = svg.append("g")
304
+ .attr("class", "bump14-row-layer")
305
+ .attr("data-coordinate-relationship", "row_cards_span_matched_to_panels");
306
+
307
+ groups.forEach((group, index) => {
308
+ const color = getColor(group, index);
309
+ const rowY = rowTop + index * (rowH + rowGap);
310
+ const rowCenterY = rowY + rowH / 2;
311
+ const panelX = Math.max(plotX0 - 18, outerX + labelCardW + 12);
312
+ const panelW = Math.min(width - panelX - outerRight, plotX1 - panelX + 22);
313
+
314
+ rowLayer.append("rect")
315
+ .attr("class", "bump14-row-panel")
316
+ .attr("data-group", group)
317
+ .attr("x", panelX)
318
+ .attr("y", rowY + 2)
319
+ .attr("width", panelW)
320
+ .attr("height", rowH - 4)
321
+ .attr("rx", Math.min(13, rowH * 0.18))
322
+ .attr("fill", rgba(color, 0.10))
323
+ .attr("stroke", rgba(color, 0.10));
324
+
325
+ const card = rowLayer.append("g")
326
+ .attr("class", "bump14-left-row-card")
327
+ .attr("data-group", group)
328
+ .attr("data-coordinate-relationship", "span_matched")
329
+ .attr("data-anchor", `group-row-${index + 1}`);
330
+ card.append("rect")
331
+ .attr("x", outerX)
332
+ .attr("y", rowY + 1)
333
+ .attr("width", labelCardW)
334
+ .attr("height", rowH - 2)
335
+ .attr("rx", Math.min(10, rowH * 0.18))
336
+ .attr("fill", "rgba(255,255,255,0.92)")
337
+ .attr("stroke", "rgba(255,255,255,0.90)")
338
+ .attr("filter", "url(#bump14-soft-shadow)");
339
+ card.append("rect")
340
+ .attr("x", outerX)
341
+ .attr("y", rowY + 1)
342
+ .attr("width", Math.max(4, Math.min(7, labelCardW * 0.07)))
343
+ .attr("height", rowH - 2)
344
+ .attr("rx", Math.min(7, rowH * 0.16))
345
+ .attr("fill", color);
346
+
347
+ const iconSize = Math.max(20, Math.min(34, rowH * 0.42));
348
+ const iconY = rowY + Math.max(iconSize * 0.55, rowH * 0.35);
349
+ drawGroupIcon(card, group, outerX + labelCardW / 2, iconY, iconSize, color, `row-card-${index + 1}`);
350
+
351
+ const labelFont = fitText(group, labelCardW - 14, Math.max(10, Math.min(15, rowH * 0.20)), "900", 7);
352
+ card.append("text")
353
+ .attr("x", outerX + labelCardW / 2)
354
+ .attr("y", rowY + rowH * 0.64)
355
+ .attr("dominant-baseline", "middle")
356
+ .attr("text-anchor", "middle")
357
+ .attr("font-family", fontFamily)
358
+ .attr("font-size", labelFont)
359
+ .attr("font-weight", "900")
360
+ .attr("fill", navy)
361
+ .text(ellipsize(group, labelCardW - 14, labelFont, "900"));
362
+ if (valueUnit) {
363
+ card.append("text")
364
+ .attr("x", outerX + labelCardW / 2)
365
+ .attr("y", rowY + rowH * 0.82)
366
+ .attr("dominant-baseline", "middle")
367
+ .attr("text-anchor", "middle")
368
+ .attr("font-family", fontFamily)
369
+ .attr("font-size", Math.max(7, Math.min(10, rowH * 0.14)))
370
+ .attr("font-weight", "500")
371
+ .attr("fill", "#4D5878")
372
+ .text(`(${valueUnit})`);
373
+ }
374
+
375
+ const groupData = chartData
376
+ .filter(d => d[groupField] === group)
377
+ .sort((a, b) => timePoints.indexOf(a[timeField]) - timePoints.indexOf(b[timeField]));
378
+ const finiteGroupValues = groupData
379
+ .map(d => +d[valueField])
380
+ .filter(Number.isFinite);
381
+ const groupMin = d3.min(finiteGroupValues);
382
+ const groupMax = d3.max(finiteGroupValues);
383
+ const rowValueTop = rowY + 2 + maxRadius + 5;
384
+ const rowValueBottom = rowY + rowH - 2 - maxRadius - 5;
385
+ const rowTravel = Math.max(0, rowValueBottom - rowValueTop);
386
+
387
+ function rowLocalY(value) {
388
+ if (!Number.isFinite(value) || !Number.isFinite(groupMin) || !Number.isFinite(groupMax) || groupMax <= groupMin || rowTravel <= 1) {
389
+ return rowCenterY;
390
+ }
391
+ const normalized = (value - groupMin) / (groupMax - groupMin);
392
+ return rowValueBottom - normalized * rowTravel;
393
+ }
394
+
395
+ const points = groupData
396
+ .filter(d => timePoints.includes(d[timeField]) && Number.isFinite(+d[valueField]))
397
+ .map(d => ({
398
+ x: xScale(d[timeField]),
399
+ y: rowLocalY(+d[valueField]),
400
+ value: +d[valueField],
401
+ time: d[timeField]
402
+ }));
403
+
404
+ if (points.length >= 2) {
405
+ rowLayer.append("path")
406
+ .datum(points)
407
+ .attr("class", "bump14-connector-line")
408
+ .attr("data-group", group)
409
+ .attr("data-y-encoding", "row-local-group-normalized-value")
410
+ .attr("fill", "none")
411
+ .attr("stroke", color)
412
+ .attr("stroke-width", Math.max(2.4, Math.min(4.2, rowH * 0.055)))
413
+ .attr("stroke-linecap", "round")
414
+ .attr("stroke-linejoin", "round")
415
+ .attr("opacity", 0.95)
416
+ .attr("d", d3.line().x(d => d.x).y(d => d.y));
417
+ }
418
+
419
+ points.forEach(point => {
420
+ const r = radiusScale(point.value);
421
+ const timeIndex = timePoints.indexOf(point.time);
422
+ const fill = circleFill(color, timeIndex);
423
+ rowLayer.append("circle")
424
+ .attr("class", "bump14-value-circle")
425
+ .attr("data-group", group)
426
+ .attr("data-x", point.time)
427
+ .attr("data-value", point.value)
428
+ .attr("data-y-encoding", "row-local-group-normalized-value")
429
+ .attr("cx", point.x)
430
+ .attr("cy", point.y)
431
+ .attr("r", r)
432
+ .attr("fill", fill)
433
+ .attr("stroke", "rgba(255,255,255,0.90)")
434
+ .attr("stroke-width", Math.max(1.6, r * 0.14))
435
+ .attr("filter", r >= 9 ? "url(#bump14-soft-shadow)" : null);
436
+
437
+ const formattedValue = chartUtils.format.compactFixedText(point.value);
438
+ const valueFont = fitText(formattedValue, r * 1.55, Math.max(7, Math.min(15, r * 0.70)), "800", 6);
439
+ rowLayer.append("text")
440
+ .attr("class", "bump14-value-label")
441
+ .attr("data-anchor", "attached_to_value_circle_center")
442
+ .attr("x", point.x)
443
+ .attr("y", point.y)
444
+ .attr("dominant-baseline", "middle")
445
+ .attr("text-anchor", "middle")
446
+ .attr("font-family", fontFamily)
447
+ .attr("font-size", valueFont)
448
+ .attr("font-weight", "800")
449
+ .attr("fill", contrastText(fill))
450
+ .text(formattedValue);
451
+ });
452
+ });
453
+
454
+ return svg.node();
455
+ }
modules/chart_engine/template/d3-js/line_graph/line_chart_01.js ADDED
@@ -0,0 +1,486 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Vertical Bar Chart",
5
+ "chart_name": "line_chart_01",
6
+ "is_composite": false,
7
+ "required_fields": ["x", "y"],
8
+ "required_fields_type": [["temporal"], ["numerical"]],
9
+ "required_fields_range": [[2, 12], [0, "inf"]],
10
+ "required_fields_icons": [],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": [],
13
+ "required_other_colors": ["primary"],
14
+ "supported_effects": ["shadow", "radius_corner", "gradient", "stroke", "spacing"],
15
+ "min_height": 400,
16
+ "min_width": 400,
17
+ "background": "no",
18
+ "icon_mark": "overlay",
19
+ "icon_label": "side",
20
+ "has_x_axis": "no",
21
+ "has_y_axis": "no"
22
+ }
23
+ REQUIREMENTS_END
24
+ */
25
+
26
+ // 使用D3.js创建简单的垂直条形图
27
+ function makeChart(containerSelector, data) {
28
+ const colorResolver = chartUtils.color.resolver(data);
29
+ // ---------- 1. 数据准备 ----------
30
+ const jsonData = data; // 完整的JSON数据对象
31
+ const chartData = jsonData.data.data || []; // 图表数据
32
+ const variables = jsonData.variables || {}; // 图表配置
33
+ const typography = jsonData.typography || { // 字体设置
34
+ title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
35
+ label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
36
+ description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
37
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
38
+ };
39
+ const colors = jsonData.colors || {
40
+ text_color: "#333333",
41
+ other: { primary: "#73D2C7", secondary: "#4682B4" }
42
+ };
43
+ const images = jsonData.images || { field: {}, other: {} }; // 图片(标志等)
44
+ const dataColumns = chartUtils.schema.columns(jsonData); // 数据列
45
+
46
+ // 设置视觉效果变量
47
+ variables.has_rounded_corners = variables.has_rounded_corners || false;
48
+ variables.has_shadow = variables.has_shadow || false;
49
+ variables.has_gradient = variables.has_gradient || false;
50
+ variables.has_stroke = variables.has_stroke || false;
51
+ variables.has_spacing = variables.has_spacing || false;
52
+
53
+ // 清空容器
54
+ d3.select(containerSelector).html("");
55
+
56
+ // ---------- 2. 尺寸和布局设置 ----------
57
+
58
+ // 设置图表尺寸和边距
59
+ const width = variables.width || 800;
60
+ const height = variables.height || 600;
61
+
62
+ // 边距: 上, 右, 下, 左
63
+ const margin = {
64
+ top: 60, // 柱子上方数值标签的空间
65
+ right: 30,
66
+ bottom: 150, // x轴和图标的空间
67
+ left: 60
68
+ };
69
+
70
+ // 计算实际图表区域尺寸
71
+ const innerWidth = width - margin.left - margin.right;
72
+ const innerHeight = height - margin.top - margin.bottom;
73
+
74
+ // ---------- 3. 提取字段名称和单位 ----------
75
+
76
+ let xField, yField;
77
+ let xUnit = "", yUnit = "";
78
+
79
+ // 安全提取字段名称
80
+ const xChannel = chartUtils.schema.channel(jsonData, "x");
81
+ const yChannel = chartUtils.schema.channel(jsonData, "y");
82
+
83
+ xField = xChannel.key;
84
+ yField = yChannel.key;
85
+
86
+ // 获取字段单位(如果存在)
87
+ if (xChannel.unit) {
88
+ xUnit = xChannel.unit;
89
+ }
90
+
91
+ if (yChannel.unit) {
92
+ yUnit = yChannel.unit;
93
+ }
94
+
95
+ // ---------- 4. 数据处理 ----------
96
+
97
+ // 获取唯一的x轴值
98
+ const xValues = [...new Set(chartData.map(d => d[xField]))];
99
+
100
+ // 创建按y值降序排序的数据
101
+ const sortedData = chartData.map(d => ({
102
+ x: d[xField],
103
+ y: +d[yField] // 确保y是数值类型
104
+ })).sort((a, b) => b.y - a.y);
105
+
106
+ // ---------- 5. 创建SVG容器 ----------
107
+
108
+ const svg = d3.select(containerSelector)
109
+ .append("svg")
110
+ .attr("width", "100%")
111
+ .attr("height", height)
112
+ .attr("viewBox", `0 0 ${width} ${height}`)
113
+ .attr("style", "max-width: 100%; height: auto;")
114
+ .attr("xmlns", "http://www.w3.org/2000/svg")
115
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
116
+
117
+
118
+
119
+ // ---------- 6. 创建视觉效果 ----------
120
+
121
+ const defs = svg.append("defs");
122
+
123
+ // 获取颜色函数
124
+ const getColor = () => colorResolver.other("primary").value;
125
+
126
+ // 如果需要创建阴影滤镜
127
+ if (variables.has_shadow) {
128
+ const filter = defs.append("filter")
129
+ .attr("id", "shadow")
130
+ .attr("filterUnits", "userSpaceOnUse")
131
+ .attr("width", "200%")
132
+ .attr("height", "200%");
133
+
134
+ filter.append("feGaussianBlur")
135
+ .attr("in", "SourceAlpha")
136
+ .attr("stdDeviation", 3);
137
+
138
+ filter.append("feOffset")
139
+ .attr("dx", 2)
140
+ .attr("dy", 2)
141
+ .attr("result", "offsetblur");
142
+
143
+ const feMerge = filter.append("feMerge");
144
+ feMerge.append("feMergeNode");
145
+ feMerge.append("feMergeNode").attr("in", "SourceGraphic");
146
+ }
147
+
148
+ // 如果需要创建渐变
149
+ if (variables.has_gradient) {
150
+ sortedData.forEach((item, idx) => {
151
+ // 添加安全的ID处理
152
+ const safeCategory = typeof item.x === 'string' ?
153
+ item.x.toString().replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() :
154
+ `category-${idx}`;
155
+
156
+ const baseColor = getColor();
157
+ const gradientId = `gradient-${safeCategory}`;
158
+
159
+ const gradient = defs.append("linearGradient")
160
+ .attr("id", gradientId)
161
+ .attr("x1", "0%")
162
+ .attr("y1", "100%")
163
+ .attr("x2", "0%")
164
+ .attr("y2", "0%");
165
+
166
+ gradient.append("stop")
167
+ .attr("offset", "0%")
168
+ .attr("stop-color", colorResolver.variant(baseColor, { mode: "darker", amount: 0.1 }));
169
+
170
+ gradient.append("stop")
171
+ .attr("offset", "100%")
172
+ .attr("stop-color", colorResolver.variant(baseColor, { mode: "brighter", amount: 0.1 }));
173
+ });
174
+ }
175
+
176
+ // ---------- 7. 创建图表 ----------
177
+
178
+ // 创建图表组
179
+ const chart = svg.append("g")
180
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
181
+
182
+ // 创建比例尺
183
+ const xScale = d3.scaleBand()
184
+ .domain(sortedData.map(d => d.x))
185
+ .range([0, innerWidth])
186
+ .padding(variables.has_spacing ? 0.4 : 0.2);
187
+
188
+ const yMax = d3.max(sortedData, d => d.y);
189
+ const yScale = d3.scaleLinear()
190
+ .domain([0, yMax > 0 ? yMax * 1.1 : 1]) // 处理y都为0的情况,并添加10%的填充
191
+ .range([innerHeight, 0]);
192
+
193
+ // *******************************************************************
194
+ // ** 开始修改:预计算标签字体大小和最大行数 **
195
+ // *******************************************************************
196
+ const defaultLabelFontSize = parseFloat(typography.label.font_size || 12);
197
+ const minLabelFontSize = 10; // 最小字体大小
198
+ const currentBarWidth = xScale.bandwidth(); // 使用实际的bar宽度
199
+ let finalLabelFontSize = defaultLabelFontSize;
200
+ let maxLinesNeeded = 1; // 记录所有标签所需的最大行数
201
+ const labelFontFamily = typography.label.font_family || "Arial";
202
+ const labelFontWeight = typography.label.font_weight || "normal";
203
+ const lineHeightFactor = 1.2; // 行高倍数
204
+
205
+ // 文本宽度测量辅助函数 (使用 canvas 提高性能)
206
+ const canvas = document.createElement('canvas');
207
+ const ctx = canvas.getContext('2d');
208
+ function measureTextWidth(text, fontFamily, fontSize, fontWeight) {
209
+ ctx.font = `${fontWeight || 'normal'} ${fontSize}px ${fontFamily || 'Arial'}`;
210
+ return chartUtils.text.contextWidth(ctx, text);
211
+ }
212
+
213
+ // 第一遍计算:确定统一的字体大小
214
+ if (currentBarWidth > 0) { // 确保 barWidth 有效
215
+ let maxRatio = 1;
216
+ sortedData.forEach(d => {
217
+ const labelText = String(d.x); // 确保是字符串
218
+ const textWidth = measureTextWidth(labelText, labelFontFamily, defaultLabelFontSize, labelFontWeight);
219
+ const ratio = textWidth / currentBarWidth;
220
+ if (ratio > maxRatio) {
221
+ maxRatio = ratio;
222
+ }
223
+ });
224
+
225
+ if (maxRatio > 1) {
226
+ finalLabelFontSize = Math.max(minLabelFontSize, Math.floor(defaultLabelFontSize / maxRatio));
227
+ }
228
+
229
+ // 第二遍计算:确定是否需要换行以及最大行数 (使用最终字体大小)
230
+ sortedData.forEach(d => {
231
+ const labelText = String(d.x);
232
+ const textWidth = measureTextWidth(labelText, labelFontFamily, finalLabelFontSize, labelFontWeight);
233
+
234
+ if (textWidth > currentBarWidth) {
235
+ // 模拟换行计算行数
236
+ const words = labelText.split(/\s+/); // 按空格分词
237
+ let currentLine = '';
238
+ let lines = 1;
239
+ let simulationSuccess = false; // 标记模拟是否成功
240
+
241
+ if (words.length > 1) { // 优先按单词换行
242
+ for (let i = 0; i < words.length; i++) {
243
+ const testLine = currentLine ? currentLine + " " + words[i] : words[i];
244
+ const testWidth = measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight);
245
+ if (testWidth > currentBarWidth && currentLine !== '') {
246
+ lines++;
247
+ currentLine = words[i];
248
+ // 检查单个单词是否也超长
249
+ if (measureTextWidth(currentLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > currentBarWidth) {
250
+ // 单个单词超长,需要转字符换行
251
+ simulationSuccess = false;
252
+ break; // 退出单词循环
253
+ }
254
+ } else {
255
+ currentLine = testLine;
256
+ }
257
+ }
258
+ if(currentLine !== '') simulationSuccess = true; // 如果有剩余单词则标记成功
259
+
260
+ }
261
+
262
+ // 如果单词换行不成功或只有一个单词,尝试字符换行
263
+ if(words.length <= 1 || !simulationSuccess) {
264
+ lines = 1; // 重置行数
265
+ const chars = labelText.split('');
266
+ currentLine = '';
267
+ for (let i = 0; i < chars.length; i++) {
268
+ const testLine = currentLine + chars[i];
269
+ if (measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > currentBarWidth && currentLine !== '') {
270
+ lines++;
271
+ currentLine = chars[i];
272
+ } else {
273
+ currentLine += chars[i];
274
+ }
275
+ }
276
+ }
277
+
278
+
279
+ if (lines > maxLinesNeeded) {
280
+ maxLinesNeeded = lines;
281
+ }
282
+ }
283
+ });
284
+ } else {
285
+ // 如果barWidth无效,则使用默认字体大小且不换行
286
+ finalLabelFontSize = defaultLabelFontSize;
287
+ maxLinesNeeded = 1;
288
+ }
289
+ // *******************************************************************
290
+ // ** 结束修改:预计算标签字体大小和最大行数 **
291
+ // *******************************************************************
292
+
293
+ // *******************************************************************
294
+ // ** 开始修改:计算图标的统一Y位置 **
295
+ // *******************************************************************
296
+ const labelStartY = innerHeight + 10; // 标签开始的 Y 位置 (保持和原来一致)
297
+ const lineHeight = finalLabelFontSize * lineHeightFactor; // 行高
298
+ const labelBottomApprox = labelStartY + (maxLinesNeeded - 1) * lineHeight + finalLabelFontSize * 0.71; // 估算最下方标签基线位置
299
+ const iconRadius = 15; // 图标半径
300
+ const iconYPosition = labelBottomApprox + iconRadius + 5; // 在标签下方留出 5px 间距放置图标中心
301
+ const iconBottomY = iconYPosition + iconRadius; // 图标底部的Y坐标
302
+ const barExtensionBuffer = 5; // 柱子比图标底部多延伸的距离
303
+ const barBottomY = iconBottomY + barExtensionBuffer; // 柱子需要延伸到的最终Y坐标
304
+ // *******************************************************************
305
+ // ** 结束修改:计算图标的统一Y位置 **
306
+ // *******************************************************************
307
+
308
+ // 添加延伸到图标的柱子
309
+ chart.selectAll(".bar")
310
+ .data(sortedData)
311
+ .enter()
312
+ .append("rect")
313
+ .attr("class", "bar")
314
+ .attr("x", d => xScale(d.x))
315
+ .attr("y", d => yScale(d.y))
316
+ .attr("width", xScale.bandwidth())
317
+ .attr("height", d => Math.max(0, barBottomY - yScale(d.y)))
318
+ .attr("fill", d => {
319
+ if (variables.has_gradient) {
320
+ const safeCategory = typeof d.x === 'string' ?
321
+ d.x.toString().replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() :
322
+ `category-${sortedData.findIndex(item => item.x === d.x)}`; // 修正: 使用 findIndex 确保唯一性
323
+ return `url(#gradient-${safeCategory})`;
324
+ }
325
+ return getColor();
326
+ })
327
+ .attr("rx", variables.has_rounded_corners ? 4 : 0)
328
+ .attr("ry", variables.has_rounded_corners ? 4 : 0)
329
+ .style("stroke", variables.has_stroke ? "#333" : "none")
330
+ .style("stroke-width", variables.has_stroke ? 1 : 0)
331
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "none")
332
+ .on("mouseover", function() {
333
+ d3.select(this).attr("opacity", 0.8);
334
+ })
335
+ .on("mouseout", function() {
336
+ d3.select(this).attr("opacity", 1);
337
+ });
338
+
339
+ // 在柱子上方添加数值标签
340
+ const defaultAnnotationFontSize = parseFloat(typography.annotation.font_size || 12);
341
+ const minAnnotationFontSize = 6; // 数值标签最小字体
342
+ const annotationFontFamily = typography.annotation.font_family || "Arial";
343
+ const annotationFontWeight = typography.annotation.font_weight || "bold";
344
+
345
+ chart.selectAll(".value-label")
346
+ .data(sortedData)
347
+ .enter()
348
+ .append("text")
349
+ .attr("class", "value-label")
350
+ .attr("x", d => xScale(d.x) + xScale.bandwidth() / 2)
351
+ .attr("y", d => yScale(d.y) - 5)
352
+ .attr("text-anchor", "middle")
353
+ .style("font-family", annotationFontFamily)
354
+ .style("font-weight", annotationFontWeight)
355
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
356
+ .each(function(d) {
357
+ const valueText = chartUtils.format.fixed(d.y, 1, { unit: yUnit }).text;
358
+ const maxWidth = xScale.bandwidth() * 1.1; // 最大允许宽度为柱宽的1.1倍
359
+ let finalValueFontSize = defaultAnnotationFontSize;
360
+
361
+ if (maxWidth > 0) { // 仅当宽度有效时计算
362
+ const textWidth = measureTextWidth(valueText, annotationFontFamily, defaultAnnotationFontSize, annotationFontWeight);
363
+ if (textWidth > maxWidth) {
364
+ finalValueFontSize = Math.max(minAnnotationFontSize, Math.floor(defaultAnnotationFontSize * (maxWidth / textWidth)));
365
+ }
366
+ }
367
+
368
+ d3.select(this)
369
+ .style("font-size", `${finalValueFontSize}px`)
370
+ .text(valueText);
371
+ });
372
+
373
+
374
+ // *******************************************************************
375
+ // ** 开始修改:添加维度标签 (统一字体大小,自动换行) **
376
+ // *******************************************************************
377
+ chart.selectAll(".dimension-label")
378
+ .data(sortedData)
379
+ .enter()
380
+ .append("text")
381
+ .attr("class", "dimension-label")
382
+ .attr("text-anchor", "middle")
383
+ .style("font-family", labelFontFamily)
384
+ .style("font-size", `${finalLabelFontSize}px`) // 应用预计算的字体大小
385
+ .style("font-weight", labelFontWeight)
386
+ .style("fill", "#ffffff") // 使用默认文本颜色
387
+ .each(function(d) {
388
+ const labelText = String(d.x);
389
+ const textWidth = measureTextWidth(labelText, labelFontFamily, finalLabelFontSize, labelFontWeight);
390
+ const textElement = d3.select(this);
391
+ const xPos = xScale(d.x) + xScale.bandwidth() / 2;
392
+ const availableWidth = xScale.bandwidth();
393
+
394
+ if (textWidth > availableWidth && availableWidth > 0) {
395
+ // 需要换行 (重复之前的换行逻辑来渲染)
396
+ const words = labelText.split(/\s+/);
397
+ let lines = [];
398
+ let currentLine = '';
399
+ let simulationSuccess = false;
400
+
401
+ if(words.length > 1){
402
+ for (let i = 0; i < words.length; i++) {
403
+ const testLine = currentLine ? currentLine + " " + words[i] : words[i];
404
+ const testWidth = measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight);
405
+ if (testWidth > availableWidth && currentLine !== '') {
406
+ lines.push(currentLine);
407
+ currentLine = words[i];
408
+ if (measureTextWidth(currentLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > availableWidth) {
409
+ simulationSuccess = false; break;
410
+ }
411
+ } else { currentLine = testLine; }
412
+ }
413
+ if(currentLine !== '') { lines.push(currentLine); simulationSuccess = true; }
414
+ }
415
+ if(words.length <= 1 || !simulationSuccess) {
416
+ lines = [];
417
+ const chars = labelText.split('');
418
+ currentLine = '';
419
+ for (let i = 0; i < chars.length; i++) {
420
+ const testLine = currentLine + chars[i];
421
+ if (measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > availableWidth && currentLine !== '') {
422
+ lines.push(currentLine); currentLine = chars[i];
423
+ } else { currentLine += chars[i]; }
424
+ }
425
+ lines.push(currentLine);
426
+ }
427
+
428
+ lines.forEach((line, i) => {
429
+ textElement.append("tspan")
430
+ .attr("x", xPos)
431
+ .attr("y", labelStartY + i * lineHeight)
432
+ .attr("dy", "0.71em")
433
+ .text(line);
434
+ });
435
+ } else {
436
+ // 不需要换行
437
+ textElement.append("tspan")
438
+ .attr("x", xPos)
439
+ .attr("y", labelStartY)
440
+ .attr("dy", "0.71em")
441
+ .text(labelText);
442
+ }
443
+ });
444
+ // *******************************************************************
445
+ // ** 结束修改:添加维度标签 **
446
+ // *******************************************************************
447
+
448
+
449
+ // *******************************************************************
450
+ // ** 开始修改:添加圆形图标 - 动态调整 Y 位置 **
451
+ // *******************************************************************
452
+ chart.selectAll(".icon-circle")
453
+ .data(sortedData)
454
+ .enter()
455
+ .append("g")
456
+ .attr("class", "icon-circle")
457
+ .attr("transform", d => `translate(${xScale(d.x) + xScale.bandwidth() / 2}, ${iconYPosition})`) // 应用动态Y位置
458
+ .each(function(d) {
459
+ // 添加圆形轮廓
460
+ d3.select(this)
461
+ .append("circle")
462
+ .attr("r", iconRadius) // 使用变量
463
+ .attr("fill", "none") // 无填充
464
+ .attr("stroke", "#2D3748") // 更深灰色的描边
465
+ .attr("stroke-width", 0.1);
466
+
467
+ // 如果有该类别的图像
468
+ if (images && images.field && images.field[d.x]) {
469
+ d3.select(this)
470
+ .append("image")
471
+ .attr("xlink:href", images.field[d.x])
472
+ .attr("x", -12)
473
+ .attr("y", -12)
474
+ .attr("width", 24)
475
+ .attr("preserveAspectRatio","xMidYMid meet")
476
+ .attr("height", 24);
477
+ }
478
+ });
479
+ // *******************************************************************
480
+ // ** 结束修改:添加圆形图标 **
481
+ // *******************************************************************
482
+
483
+
484
+ // 返回SVG节点
485
+ return svg.node();
486
+ }
modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_01.js ADDED
@@ -0,0 +1,655 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Multiple Line Graph",
5
+ "chart_name": "multiple_line_graph_01",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[4, 30], ["-inf", "inf"], [2, 8]],
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": 600,
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
+ d3.select(containerSelector).html("");
35
+
36
+ const width = variables.width || 1024;
37
+ const height = variables.height || 1536;
38
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
39
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
40
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
41
+ const yColumn = dataColumns[1] || {};
42
+ const yUnit = yColumn.unit || chartUtils.schema.channel(jsonData, "y").unit || "";
43
+ const titleText = (jsonData.titles && jsonData.titles.main_title) || (jsonData.metadata && jsonData.metadata.title) || "Multiple Line Trend";
44
+ const subtitleText = (jsonData.titles && jsonData.titles.sub_title) || (jsonData.metadata && jsonData.metadata.description) || "";
45
+ const labelFont = (typography.label && typography.label.font_family) || "Arial, Helvetica, sans-serif";
46
+
47
+ const parseTemporal = (value) => {
48
+ if (value instanceof Date) return value;
49
+ if (typeof value === "number" && Number.isFinite(value)) {
50
+ return new Date(value, 0, 1);
51
+ }
52
+ const asNumber = Number(value);
53
+ if (Number.isFinite(asNumber) && String(value).trim().length <= 4) {
54
+ return new Date(asNumber, 0, 1);
55
+ }
56
+ const parsedByUtils = chartUtils.format.parseDate(value);
57
+ if (parsedByUtils instanceof Date && !Number.isNaN(+parsedByUtils)) return parsedByUtils;
58
+ const parsed = new Date(value);
59
+ if (!Number.isNaN(+parsed)) return parsed;
60
+ return new Date(0);
61
+ };
62
+
63
+ const sanitizeId = (value) => String(value).replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "item";
64
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
65
+ const approxWidth = (text, fontSize, weight) => String(text).length * fontSize * (weight >= 700 ? 0.62 : 0.54);
66
+ const lighten = (hex, amount) => {
67
+ const c = d3.color(hex);
68
+ if (!c) return hex;
69
+ c.r = Math.round(c.r + (255 - c.r) * amount);
70
+ c.g = Math.round(c.g + (255 - c.g) * amount);
71
+ c.b = Math.round(c.b + (255 - c.b) * amount);
72
+ return c.formatHex();
73
+ };
74
+ const darken = (hex, amount) => {
75
+ const c = d3.color(hex);
76
+ if (!c) return hex;
77
+ c.r = Math.round(c.r * (1 - amount));
78
+ c.g = Math.round(c.g * (1 - amount));
79
+ c.b = Math.round(c.b * (1 - amount));
80
+ return c.formatHex();
81
+ };
82
+ const formatX = (date) => {
83
+ if (date.getMonth() === 0 && date.getDate() === 1) return String(date.getFullYear());
84
+ return d3.timeFormat("%b %Y")(date);
85
+ };
86
+ const formatValue = (value) => {
87
+ const num = Number(value);
88
+ const rounded = Math.abs(num) >= 100 ? Math.round(num) : Math.round(num * 10) / 10;
89
+ const display = Number.isInteger(rounded) ? String(rounded) : String(rounded.toFixed(1)).replace(/\.0$/, "");
90
+ return yUnit === "%" ? `${display}%` : display;
91
+ };
92
+ const splitTitle = (title, maxChars) => {
93
+ if (title.includes(",")) {
94
+ const parts = title.split(",");
95
+ const first = `${parts.shift().trim()},`;
96
+ const second = parts.join(",").trim();
97
+ return [first, second].filter(Boolean);
98
+ }
99
+ const words = title.split(/\s+/).filter(Boolean);
100
+ const lines = [];
101
+ let line = "";
102
+ words.forEach((word) => {
103
+ const candidate = line ? `${line} ${word}` : word;
104
+ if (candidate.length > maxChars && line) {
105
+ lines.push(line);
106
+ line = word;
107
+ } else {
108
+ line = candidate;
109
+ }
110
+ });
111
+ if (line) lines.push(line);
112
+ return lines.slice(0, 2);
113
+ };
114
+ const wrapWordsByWidth = (text, fontSize, maxWidth) => {
115
+ const words = String(text || "").split(/\s+/).filter(Boolean);
116
+ const lines = [];
117
+ let line = "";
118
+ words.forEach((word) => {
119
+ const candidate = line ? `${line} ${word}` : word;
120
+ if (approxWidth(candidate, fontSize, 900) > maxWidth && line) {
121
+ lines.push(line);
122
+ line = word;
123
+ } else {
124
+ line = candidate;
125
+ }
126
+ });
127
+ if (line) lines.push(line);
128
+ return lines;
129
+ };
130
+ const wrapText = (selection, text, maxWidth, lineHeight, maxLines) => {
131
+ const fontSize = Number(selection.attr("font-size")) || 18;
132
+ const words = String(text || "").split(/\s+/).filter(Boolean);
133
+ const lines = [];
134
+ let line = "";
135
+ words.forEach((word) => {
136
+ const candidate = line ? `${line} ${word}` : word;
137
+ if (approxWidth(candidate, fontSize, 500) > maxWidth && line) {
138
+ lines.push(line);
139
+ line = word;
140
+ } else {
141
+ line = candidate;
142
+ }
143
+ });
144
+ if (line) lines.push(line);
145
+ const visible = lines.slice(0, maxLines);
146
+ if (lines.length > maxLines && visible.length) {
147
+ visible[visible.length - 1] = `${visible[visible.length - 1].replace(/[.,;:]$/, "")}...`;
148
+ }
149
+ selection.text(null);
150
+ visible.forEach((lineText, i) => {
151
+ selection.append("tspan")
152
+ .attr("x", selection.attr("x"))
153
+ .attr("dy", i === 0 ? 0 : lineHeight)
154
+ .text(lineText);
155
+ });
156
+ return visible.length;
157
+ };
158
+
159
+ const groups = Array.from(new Set(chartData.map((d) => String(d[groupField]))));
160
+ const rowsByGroup = new Map(groups.map((group) => {
161
+ const rows = chartData
162
+ .filter((d) => String(d[groupField]) === group)
163
+ .slice()
164
+ .sort((a, b) => +parseTemporal(a[xField]) - +parseTemporal(b[xField]));
165
+ return [group, rows];
166
+ }));
167
+ const xValues = Array.from(new Set(chartData.map((d) => +parseTemporal(d[xField]))))
168
+ .sort((a, b) => a - b)
169
+ .map((v) => new Date(v));
170
+ const yValues = chartData.map((d) => Number(d[yField])).filter(Number.isFinite);
171
+ const allPositive = d3.min(yValues) >= 0;
172
+ const yMinRaw = d3.min(yValues);
173
+ const yMaxRaw = d3.max(yValues);
174
+ const yMax = allPositive ? Math.ceil((yMaxRaw || 1) / 10) * 10 : yMaxRaw + Math.abs(yMaxRaw - yMinRaw) * 0.12;
175
+ const yMin = allPositive ? 0 : yMinRaw - Math.abs(yMaxRaw - yMinRaw) * 0.12;
176
+
177
+ const getColor = (group, index) => {
178
+ const resolved = colorResolver.field(group, index, { fallbackKey: index === 0 ? "primary" : "secondary" }).value;
179
+ const fallback = ["#38e7b4", "#ff785f", "#9c7cff", "#f5c84c", "#48b7ff", "#8bd45c", "#f08bd8", "#f58d44"][index % 8];
180
+ return resolved || fallback;
181
+ };
182
+
183
+ const svg = d3.select(containerSelector)
184
+ .append("svg")
185
+ .attr("width", "100%")
186
+ .attr("height", height)
187
+ .attr("viewBox", `0 0 ${width} ${height}`)
188
+ .attr("style", "max-width: 100%; height: auto; font-family: Arial, Helvetica, sans-serif;")
189
+ .attr("xmlns", "http://www.w3.org/2000/svg")
190
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
191
+
192
+ const defs = svg.append("defs");
193
+ const bgGradient = defs.append("radialGradient")
194
+ .attr("id", "mlg01-bg-glow")
195
+ .attr("cx", "48%")
196
+ .attr("cy", "48%")
197
+ .attr("r", "82%");
198
+ bgGradient.append("stop").attr("offset", "0%").attr("stop-color", "#064244");
199
+ bgGradient.append("stop").attr("offset", "48%").attr("stop-color", "#042c36");
200
+ bgGradient.append("stop").attr("offset", "100%").attr("stop-color", "#031522");
201
+ const panelGradient = defs.append("linearGradient")
202
+ .attr("id", "mlg01-panel-fill")
203
+ .attr("x1", "0%")
204
+ .attr("x2", "100%")
205
+ .attr("y1", "0%")
206
+ .attr("y2", "100%");
207
+ panelGradient.append("stop").attr("offset", "0%").attr("stop-color", "#082c36").attr("stop-opacity", 0.92);
208
+ panelGradient.append("stop").attr("offset", "100%").attr("stop-color", "#021825").attr("stop-opacity", 0.82);
209
+ const glow = defs.append("filter").attr("id", "mlg01-soft-glow").attr("x", "-30%").attr("y", "-30%").attr("width", "160%").attr("height", "160%");
210
+ glow.append("feGaussianBlur").attr("stdDeviation", "4").attr("result", "blur");
211
+ const merge = glow.append("feMerge");
212
+ merge.append("feMergeNode").attr("in", "blur");
213
+ merge.append("feMergeNode").attr("in", "SourceGraphic");
214
+
215
+ groups.forEach((group, index) => {
216
+ const color = getColor(group, index);
217
+ const id = sanitizeId(group);
218
+ const areaGradient = defs.append("linearGradient")
219
+ .attr("id", `mlg01-area-${id}`)
220
+ .attr("x1", "0%")
221
+ .attr("x2", "0%")
222
+ .attr("y1", "0%")
223
+ .attr("y2", "100%");
224
+ areaGradient.append("stop").attr("offset", "0%").attr("stop-color", color).attr("stop-opacity", 0.38);
225
+ areaGradient.append("stop").attr("offset", "62%").attr("stop-color", color).attr("stop-opacity", 0.16);
226
+ areaGradient.append("stop").attr("offset", "100%").attr("stop-color", color).attr("stop-opacity", 0.02);
227
+ defs.append("clipPath")
228
+ .attr("id", `mlg01-icon-clip-${id}`)
229
+ .append("circle")
230
+ .attr("cx", 0)
231
+ .attr("cy", 0)
232
+ .attr("r", 54);
233
+ });
234
+
235
+ svg.append("rect").attr("width", width).attr("height", height).attr("fill", "url(#mlg01-bg-glow)");
236
+ svg.append("circle").attr("cx", 120).attr("cy", 118).attr("r", 340).attr("fill", "#0b5960").attr("opacity", 0.12);
237
+ svg.append("circle").attr("cx", 920).attr("cy", 1260).attr("r", 420).attr("fill", "#09a881").attr("opacity", 0.06);
238
+
239
+ const headerX = 56;
240
+ const accent = "#4de3a0";
241
+ const white = "#f7f9f8";
242
+ const muted = "#d9e2df";
243
+
244
+ const header = svg.append("g").attr("class", "mlg01-header");
245
+ const badge = header.append("g")
246
+ .attr("class", "mlg01-kicker-badge")
247
+ .attr("transform", `translate(${headerX},62)`);
248
+ badge.append("rect")
249
+ .attr("width", 286)
250
+ .attr("height", 54)
251
+ .attr("rx", 26)
252
+ .attr("fill", "#062a30")
253
+ .attr("stroke", "#0f8077")
254
+ .attr("stroke-width", 1.5)
255
+ .attr("opacity", 0.96);
256
+ const kickerSlot = badge.append("g")
257
+ .attr("data-asset-slot", "kicker-icon")
258
+ .attr("data-no-embedded-raster", "true")
259
+ .attr("data-no-copied-reference-art", "true")
260
+ .attr("data-anchor", "header-badge-left")
261
+ .attr("data-collision-rule", "inside badge before kicker text")
262
+ .attr("transform", "translate(35,27)");
263
+ kickerSlot.append("circle").attr("r", 11).attr("fill", "none").attr("stroke", accent).attr("stroke-width", 2);
264
+ kickerSlot.append("circle").attr("r", 4).attr("fill", accent).attr("opacity", 0.9);
265
+ badge.append("text")
266
+ .attr("x", 62)
267
+ .attr("y", 34)
268
+ .attr("font-family", labelFont)
269
+ .attr("font-size", 22)
270
+ .attr("font-weight", 800)
271
+ .attr("fill", accent)
272
+ .attr("letter-spacing", 1.5)
273
+ .text("SHIFTING TRENDS");
274
+
275
+ let titleFont = titleText.length > 70 ? 46 : titleText.length > 54 ? 52 : 62;
276
+ let titleLines = splitTitle(titleText, titleText.includes(",") ? 34 : 26);
277
+ if (!titleText.includes(",")) {
278
+ titleFont = titleText.length > 58 ? 44 : titleText.length > 45 ? 52 : titleFont;
279
+ titleLines = wrapWordsByWidth(titleText, titleFont, 900);
280
+ while (titleLines.length > 2 && titleFont > 38) {
281
+ titleFont -= 2;
282
+ titleLines = wrapWordsByWidth(titleText, titleFont, 900);
283
+ }
284
+ titleLines = titleLines.slice(0, 2);
285
+ }
286
+ const titleLineGap = titleFont + 10;
287
+ const titleBaseY = 176;
288
+ titleLines.forEach((lineText, i) => {
289
+ header.append("text")
290
+ .attr("x", headerX)
291
+ .attr("y", titleBaseY + i * titleLineGap)
292
+ .attr("font-family", "Arial, Helvetica, sans-serif")
293
+ .attr("font-size", titleFont)
294
+ .attr("font-weight", 900)
295
+ .attr("fill", i === 0 ? white : accent)
296
+ .attr("letter-spacing", 0)
297
+ .attr("filter", "url(#mlg01-soft-glow)")
298
+ .text(lineText);
299
+ });
300
+ const subtitleY = titleBaseY + titleLines.length * titleLineGap + 12;
301
+ const subtitle = header.append("text")
302
+ .attr("x", headerX)
303
+ .attr("y", subtitleY)
304
+ .attr("font-family", labelFont)
305
+ .attr("font-size", 25)
306
+ .attr("font-weight", 400)
307
+ .attr("fill", muted)
308
+ .attr("opacity", 0.95);
309
+ subtitle.text(subtitleText);
310
+ if (approxWidth(subtitleText, 25, 400) > 720) {
311
+ subtitle.attr("textLength", 720).attr("lengthAdjust", "spacingAndGlyphs");
312
+ }
313
+
314
+ const panel = { x: 42, y: 360, w: 706, h: 988 };
315
+ const plot = { x: 98, y: 446, w: 490, h: 820 };
316
+ const cardColumn = { x: 778, y: 360, w: 202, h: 988 };
317
+
318
+ svg.append("rect")
319
+ .attr("class", "mlg01-plot-panel")
320
+ .attr("x", panel.x)
321
+ .attr("y", panel.y)
322
+ .attr("width", panel.w)
323
+ .attr("height", panel.h)
324
+ .attr("rx", 28)
325
+ .attr("fill", "url(#mlg01-panel-fill)")
326
+ .attr("stroke", "#23606c")
327
+ .attr("stroke-width", 1.5)
328
+ .attr("opacity", 0.96);
329
+
330
+ const chart = svg.append("g").attr("class", "mlg01-chart-layer");
331
+ chart.append("text")
332
+ .attr("x", panel.x + 32)
333
+ .attr("y", panel.y + 52)
334
+ .attr("font-family", labelFont)
335
+ .attr("font-size", 22)
336
+ .attr("font-weight", 800)
337
+ .attr("fill", white)
338
+ .attr("letter-spacing", 0.5)
339
+ .text(`${String(yField).replace(/([a-z])([A-Z])/g, "$1 $2").toUpperCase()}${yUnit ? ` (${yUnit})` : ""}`);
340
+
341
+ const xScale = d3.scaleTime()
342
+ .domain(d3.extent(xValues))
343
+ .range([plot.x, plot.x + plot.w]);
344
+ const yScale = d3.scaleLinear()
345
+ .domain([yMin, yMax])
346
+ .nice(5)
347
+ .range([plot.y + plot.h, plot.y]);
348
+ const yTicks = yScale.ticks(6);
349
+ const xTickStep = Math.max(1, Math.ceil(xValues.length / 8));
350
+ const xTicks = xValues.filter((_, i) => i % xTickStep === 0 || i === xValues.length - 1);
351
+
352
+ chart.selectAll("line.mlg01-y-grid")
353
+ .data(yTicks)
354
+ .enter()
355
+ .append("line")
356
+ .attr("class", "mlg01-y-grid")
357
+ .attr("x1", plot.x)
358
+ .attr("x2", plot.x + plot.w)
359
+ .attr("y1", (d) => yScale(d))
360
+ .attr("y2", (d) => yScale(d))
361
+ .attr("stroke", "#ffffff")
362
+ .attr("stroke-width", 1)
363
+ .attr("stroke-dasharray", "4 4")
364
+ .attr("opacity", 0.18);
365
+ chart.selectAll("text.mlg01-y-tick")
366
+ .data(yTicks)
367
+ .enter()
368
+ .append("text")
369
+ .attr("class", "mlg01-y-tick")
370
+ .attr("x", plot.x - 16)
371
+ .attr("y", (d) => yScale(d) + 8)
372
+ .attr("text-anchor", "end")
373
+ .attr("font-family", labelFont)
374
+ .attr("font-size", 26)
375
+ .attr("font-weight", 400)
376
+ .attr("fill", white)
377
+ .attr("opacity", 0.92)
378
+ .text((d) => d);
379
+ chart.selectAll("text.mlg01-x-tick")
380
+ .data(xTicks)
381
+ .enter()
382
+ .append("text")
383
+ .attr("class", "mlg01-x-tick")
384
+ .attr("x", (d) => xScale(d))
385
+ .attr("y", plot.y + plot.h + 34)
386
+ .attr("text-anchor", "middle")
387
+ .attr("font-family", labelFont)
388
+ .attr("font-size", 22)
389
+ .attr("font-weight", 500)
390
+ .attr("fill", white)
391
+ .text((d) => formatX(d));
392
+ chart.append("line")
393
+ .attr("x1", plot.x)
394
+ .attr("x2", plot.x + plot.w + 8)
395
+ .attr("y1", yScale(0))
396
+ .attr("y2", yScale(0))
397
+ .attr("stroke", "#ffffff")
398
+ .attr("stroke-width", 1.2)
399
+ .attr("opacity", 0.75);
400
+
401
+ const area = d3.area()
402
+ .x((d) => xScale(parseTemporal(d[xField])))
403
+ .y0(yScale(Math.max(0, yMin)))
404
+ .y1((d) => yScale(Number(d[yField])))
405
+ .curve(d3.curveMonotoneX);
406
+ const line = d3.line()
407
+ .defined((d) => Number.isFinite(Number(d[yField])))
408
+ .x((d) => xScale(parseTemporal(d[xField])))
409
+ .y((d) => yScale(Number(d[yField])))
410
+ .curve(d3.curveMonotoneX);
411
+
412
+ groups.forEach((group, index) => {
413
+ const rows = rowsByGroup.get(group);
414
+ const color = getColor(group, index);
415
+ const id = sanitizeId(group);
416
+ chart.append("path")
417
+ .datum(rows)
418
+ .attr("class", "mlg01-area")
419
+ .attr("data-series", group)
420
+ .attr("fill", `url(#mlg01-area-${id})`)
421
+ .attr("d", area);
422
+ chart.append("path")
423
+ .datum(rows)
424
+ .attr("class", "mlg01-line")
425
+ .attr("data-series", group)
426
+ .attr("fill", "none")
427
+ .attr("stroke", color)
428
+ .attr("stroke-width", 5)
429
+ .attr("stroke-linecap", "round")
430
+ .attr("stroke-linejoin", "round")
431
+ .attr("filter", "url(#mlg01-soft-glow)")
432
+ .attr("d", line);
433
+ });
434
+
435
+ const lastDate = xValues[xValues.length - 1];
436
+ const finalX = xScale(lastDate);
437
+ chart.append("line")
438
+ .attr("class", "mlg01-final-guide")
439
+ .attr("x1", finalX)
440
+ .attr("x2", finalX)
441
+ .attr("y1", plot.y - 8)
442
+ .attr("y2", plot.y + plot.h)
443
+ .attr("stroke", "#ffffff")
444
+ .attr("stroke-width", 2)
445
+ .attr("stroke-dasharray", "7 8")
446
+ .attr("opacity", 0.92);
447
+ const yearLabel = chart.append("g")
448
+ .attr("class", "mlg01-final-year-label")
449
+ .attr("transform", `translate(${finalX},${plot.y - 40})`);
450
+ yearLabel.append("rect")
451
+ .attr("x", -34)
452
+ .attr("y", -18)
453
+ .attr("width", 68)
454
+ .attr("height", 34)
455
+ .attr("rx", 7)
456
+ .attr("fill", "#f7f8f6");
457
+ yearLabel.append("path").attr("d", "M -8 16 L 8 16 L 0 28 Z").attr("fill", "#f7f8f6");
458
+ yearLabel.append("text")
459
+ .attr("y", 7)
460
+ .attr("text-anchor", "middle")
461
+ .attr("font-family", labelFont)
462
+ .attr("font-size", 24)
463
+ .attr("font-weight", 900)
464
+ .attr("fill", "#111f26")
465
+ .text(formatX(lastDate));
466
+
467
+ const endpointData = groups.map((group, index) => {
468
+ const rows = rowsByGroup.get(group);
469
+ const first = rows[0];
470
+ const last = rows[rows.length - 1];
471
+ const y = yScale(Number(last[yField]));
472
+ const startValue = Number(first[yField]);
473
+ const finalValue = Number(last[yField]);
474
+ return {
475
+ group,
476
+ index,
477
+ color: getColor(group, index),
478
+ first,
479
+ last,
480
+ y,
481
+ targetY: y,
482
+ finalValue,
483
+ delta: finalValue - startValue
484
+ };
485
+ }).sort((a, b) => a.targetY - b.targetY);
486
+ const tagH = groups.length > 3 ? 58 : 82;
487
+ const minGap = tagH + 10;
488
+ endpointData.forEach((d, i) => {
489
+ if (i === 0) {
490
+ d.labelY = clamp(d.targetY, plot.y + tagH / 2, plot.y + plot.h - tagH / 2);
491
+ } else {
492
+ d.labelY = clamp(Math.max(d.targetY, endpointData[i - 1].labelY + minGap), plot.y + tagH / 2, plot.y + plot.h - tagH / 2);
493
+ }
494
+ });
495
+ for (let i = endpointData.length - 2; i >= 0; i -= 1) {
496
+ endpointData[i].labelY = Math.min(endpointData[i].labelY, endpointData[i + 1].labelY - minGap);
497
+ endpointData[i].labelY = clamp(endpointData[i].labelY, plot.y + tagH / 2, plot.y + plot.h - tagH / 2);
498
+ }
499
+ endpointData.forEach((d) => {
500
+ const markerY = yScale(Number(d.last[yField]));
501
+ chart.append("circle")
502
+ .attr("class", "mlg01-endpoint-marker")
503
+ .attr("data-series", d.group)
504
+ .attr("data-x", d.last[xField])
505
+ .attr("data-y", d.last[yField])
506
+ .attr("cx", finalX)
507
+ .attr("cy", markerY)
508
+ .attr("r", 10)
509
+ .attr("fill", d.color)
510
+ .attr("stroke", white)
511
+ .attr("stroke-width", 5);
512
+ const tagX = finalX + 34;
513
+ const valueFont = groups.length > 3 ? 23 : Math.max(26, Math.min(34, 240 / String(formatValue(d.finalValue)).length));
514
+ const baseTagW = groups.length > 3 ? 96 : 92;
515
+ const tagW = Math.max(
516
+ baseTagW,
517
+ approxWidth(formatValue(d.finalValue), valueFont, 900) + 28,
518
+ Math.min(140, approxWidth(d.group, groups.length > 3 ? 11 : 13, 600) + 22)
519
+ );
520
+ const labelFontSize = Math.max(groups.length > 3 ? 9 : 10, Math.min(groups.length > 3 ? 12 : 17, (tagW - 18) / Math.max(5, d.group.length * 0.54)));
521
+ const tag = chart.append("g")
522
+ .attr("class", "mlg01-endpoint-tag")
523
+ .attr("data-series", d.group)
524
+ .attr("data-coordinate-relationship", "leader-connected")
525
+ .attr("data-anchor", "last-chronological-point")
526
+ .attr("transform", `translate(${tagX},${d.labelY})`);
527
+ tag.append("path")
528
+ .attr("d", `M 0 ${clamp(markerY - d.labelY, -tagH / 2 + 12, tagH / 2 - 12)} L 14 -12 L 14 12 Z`)
529
+ .attr("fill", d.color)
530
+ .attr("opacity", 0.95);
531
+ tag.append("rect")
532
+ .attr("x", 14)
533
+ .attr("y", -tagH / 2)
534
+ .attr("width", tagW)
535
+ .attr("height", tagH)
536
+ .attr("rx", 10)
537
+ .attr("fill", d.color)
538
+ .attr("opacity", 0.95);
539
+ tag.append("text")
540
+ .attr("x", 14 + tagW / 2)
541
+ .attr("y", groups.length > 3 ? -4 : -8)
542
+ .attr("text-anchor", "middle")
543
+ .attr("font-family", labelFont)
544
+ .attr("font-size", valueFont)
545
+ .attr("font-weight", 900)
546
+ .attr("fill", white)
547
+ .text(formatValue(d.finalValue));
548
+ tag.append("text")
549
+ .attr("x", 14 + tagW / 2)
550
+ .attr("y", groups.length > 3 ? 20 : 24)
551
+ .attr("text-anchor", "middle")
552
+ .attr("font-family", labelFont)
553
+ .attr("font-size", labelFontSize)
554
+ .attr("font-weight", 600)
555
+ .attr("fill", white)
556
+ .text(d.group);
557
+ });
558
+
559
+ const cardGap = groups.length <= 2 ? 24 : 18;
560
+ const cardH = groups.length <= 2 ? 330 : groups.length === 3 ? 265 : 220;
561
+ const cardStartY = cardColumn.y;
562
+ endpointData
563
+ .slice()
564
+ .sort((a, b) => b.finalValue - a.finalValue)
565
+ .forEach((d, i) => {
566
+ const y = cardStartY + i * (cardH + cardGap);
567
+ if (y + cardH > cardColumn.y + cardColumn.h) return;
568
+ const card = svg.append("g")
569
+ .attr("class", "mlg01-summary-card")
570
+ .attr("data-series", d.group)
571
+ .attr("data-coordinate-relationship", "independent")
572
+ .attr("data-anchor", "right-body-column")
573
+ .attr("transform", `translate(${cardColumn.x},${y})`);
574
+ card.append("rect")
575
+ .attr("width", cardColumn.w)
576
+ .attr("height", cardH)
577
+ .attr("rx", 28)
578
+ .attr("fill", "#061e27")
579
+ .attr("stroke", d.color)
580
+ .attr("stroke-width", 2.2)
581
+ .attr("opacity", 0.9);
582
+ const iconR = groups.length <= 2 ? 66 : 48;
583
+ const iconCx = cardColumn.w / 2;
584
+ const iconCy = groups.length <= 2 ? 86 : 60;
585
+ const iconSlot = card.append("g")
586
+ .attr("data-asset-slot", "group-icon")
587
+ .attr("data-series", d.group)
588
+ .attr("data-no-copied-reference-art", "true")
589
+ .attr("data-anchor", "right-summary-card-top-center")
590
+ .attr("data-collision-rule", "inside card; avoid title plot endpoint tags and sibling cards")
591
+ .attr("transform", `translate(${iconCx},${iconCy})`);
592
+ iconSlot.append("circle")
593
+ .attr("r", iconR)
594
+ .attr("fill", darken(d.color, 0.78))
595
+ .attr("stroke", d.color)
596
+ .attr("stroke-width", 3)
597
+ .attr("opacity", 0.96);
598
+ iconSlot.attr("data-no-embedded-raster", "true");
599
+ iconSlot.append("text")
600
+ .attr("text-anchor", "middle")
601
+ .attr("dominant-baseline", "middle")
602
+ .attr("font-family", labelFont)
603
+ .attr("font-size", iconR * 0.52)
604
+ .attr("font-weight", 900)
605
+ .attr("fill", lighten(d.color, 0.15))
606
+ .text(d.group.split(/\s+/).map((word) => word[0]).join("").slice(0, 2).toUpperCase());
607
+ const titleY = groups.length <= 2 ? 178 : 124;
608
+ const cardTitle = d.group.toUpperCase();
609
+ let cardTitleFont = Math.max(15, Math.min(groups.length <= 2 ? 27 : 20, (cardColumn.w - 26) / Math.max(1, cardTitle.length * 0.62)));
610
+ let cardTitleLines = wrapWordsByWidth(cardTitle, cardTitleFont, cardColumn.w - 28);
611
+ while (cardTitleLines.length > 2 && cardTitleFont > 11) {
612
+ cardTitleFont -= 1;
613
+ cardTitleLines = wrapWordsByWidth(cardTitle, cardTitleFont, cardColumn.w - 28);
614
+ }
615
+ const titleText = card.append("text")
616
+ .attr("x", cardColumn.w / 2)
617
+ .attr("y", titleY)
618
+ .attr("text-anchor", "middle")
619
+ .attr("font-family", labelFont)
620
+ .attr("font-size", cardTitleFont)
621
+ .attr("font-weight", 900)
622
+ .attr("fill", d.color)
623
+ .attr("letter-spacing", 1);
624
+ cardTitleLines.slice(0, 2).forEach((lineText, lineIndex) => {
625
+ titleText.append("tspan")
626
+ .attr("x", cardColumn.w / 2)
627
+ .attr("dy", lineIndex === 0 ? 0 : cardTitleFont + 3)
628
+ .text(lineText);
629
+ });
630
+ const titleBlockOffset = cardTitleLines.length > 1 ? cardTitleFont + 16 : 0;
631
+ const trendWord = d.delta >= 0 ? "Up" : "Down";
632
+ const trendText = `${trendWord} ${formatValue(Math.abs(d.delta))} since ${formatX(parseTemporal(d.first[xField]))}`;
633
+ const finalText = `Final ${formatValue(d.finalValue)}`;
634
+ card.append("text")
635
+ .attr("x", cardColumn.w / 2)
636
+ .attr("y", titleY + titleBlockOffset + (groups.length <= 2 ? 42 : 30))
637
+ .attr("text-anchor", "middle")
638
+ .attr("font-family", labelFont)
639
+ .attr("font-size", groups.length <= 2 ? 20 : 15)
640
+ .attr("font-weight", 700)
641
+ .attr("fill", white)
642
+ .text(finalText);
643
+ const desc = card.append("text")
644
+ .attr("x", cardColumn.w / 2)
645
+ .attr("y", titleY + titleBlockOffset + (groups.length <= 2 ? 78 : 56))
646
+ .attr("text-anchor", "middle")
647
+ .attr("font-family", labelFont)
648
+ .attr("font-size", groups.length <= 2 ? 18 : 13)
649
+ .attr("font-weight", 400)
650
+ .attr("fill", "#d9e4e0");
651
+ wrapText(desc, trendText, cardColumn.w - 30, groups.length <= 2 ? 26 : 19, groups.length <= 2 ? 2 : 1);
652
+ });
653
+
654
+ return svg.node();
655
+ }
modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_03.js ADDED
@@ -0,0 +1,593 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Multiple Line Graph",
5
+ "chart_name": "multiple_line_graph_03",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[5, 30], ["-inf", "inf"], [2, 3]],
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": "dark",
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_dark || {};
32
+ const colorResolver = chartUtils.color.resolver(jsonData);
33
+ const dataColumns = chartUtils.schema.columns(jsonData);
34
+ const images = jsonData.images || {};
35
+ // 清空容器
36
+ d3.select(containerSelector).html("");
37
+
38
+ // 设置尺寸和边距 - 右侧留出更多空间用于标签
39
+ const width = variables.width;
40
+ const height = variables.height;
41
+ const margin = { top: 40, right: 180, bottom: 60, left: 80 };
42
+ const innerWidth = width - margin.left - margin.right;
43
+ const innerHeight = height - margin.top - margin.bottom;
44
+
45
+ // 获取字段名
46
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
47
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
48
+ const yColumn = chartUtils.schema.column(dataColumns, 1, { fallbackKey: yField });
49
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
50
+
51
+ // 获取唯一的组值和X值
52
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
53
+ const xValues = [...new Set(chartData.map(d => d[xField]))]
54
+ .sort((a, b) => chartUtils.format.parseDate(a) - chartUtils.format.parseDate(b));
55
+
56
+ // 创建SVG
57
+ const svg = d3.select(containerSelector)
58
+ .append("svg")
59
+ .attr("width", "100%")
60
+ .attr("height", height)
61
+ .attr("viewBox", `0 0 ${width} ${height}`)
62
+ .attr("style", "max-width: 100%; height: auto;")
63
+ .attr("xmlns", "http://www.w3.org/2000/svg")
64
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
65
+
66
+ // 创建图表组
67
+ const g = svg.append("g")
68
+ .attr("transform", `translate(${margin.left},${margin.top})`);
69
+
70
+ // 确保在创建网格线之前定义渐变
71
+ const defs = svg.append("defs");
72
+
73
+ // 创建一个唯一的ID,避免冲突
74
+ const gradientIdSuffix = chartUtils.random.id(jsonData, "multiple-line-03-grid");
75
+ const horizontalGradientId = `horizontal-grid-gradient-${gradientIdSuffix}`;
76
+ const verticalGradientId = `vertical-grid-gradient-${gradientIdSuffix}`;
77
+
78
+ // 添加水平网格线渐变
79
+ const horizontalGridGradient = defs.append("linearGradient")
80
+ .attr("id", horizontalGradientId)
81
+ .attr("x1", "0%")
82
+ .attr("y1", "0%")
83
+ .attr("x2", "100%")
84
+ .attr("y2", "0%");
85
+
86
+ horizontalGridGradient.append("stop")
87
+ .attr("offset", "0%")
88
+ .attr("stop-color", "#6bc7c5")
89
+ .attr("stop-opacity", 0.2);
90
+
91
+ horizontalGridGradient.append("stop")
92
+ .attr("offset", "100%")
93
+ .attr("stop-color", "#6bc7c5")
94
+ .attr("stop-opacity", 0.05);
95
+
96
+ // 添加垂直网格线渐变
97
+ const verticalGridGradient = defs.append("linearGradient")
98
+ .attr("id", verticalGradientId)
99
+ .attr("x1", "0%")
100
+ .attr("y1", "100%")
101
+ .attr("x2", "0%")
102
+ .attr("y2", "0%");
103
+
104
+ verticalGridGradient.append("stop")
105
+ .attr("offset", "0%")
106
+ .attr("stop-color", "#e8f6fa")
107
+ .attr("stop-opacity", 0.3);
108
+
109
+ verticalGridGradient.append("stop")
110
+ .attr("offset", "100%")
111
+ .attr("stop-color", "#e8f6fa")
112
+ .attr("stop-opacity", 0.1);
113
+
114
+ // 创建比例尺 - 修改为时间比例尺
115
+ const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, innerWidth);
116
+
117
+ // 修改Y轴比例尺,支持负值,并确保最大刻度超过数据最大值
118
+ const yScale = d3.scaleLinear()
119
+ .domain([
120
+ Math.min(0, d3.min(chartData, d => +d[yField]) * 1.1), // 取最小值和0中的较小者
121
+ d3.max(chartData, d => +d[yField]) * 1.3 // 将最大值扩大30%
122
+ ])
123
+ .range([innerHeight, 0]);
124
+
125
+ // 获取颜色
126
+ const getColor = (group) => {
127
+ return colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
128
+ };
129
+
130
+ // 添加网格线 - 使用半透明白色
131
+ // 首先获取Y轴的最大刻度值
132
+ const yTicks = yScale.ticks(6); // 增加刻度数量,确保有足够的刻度
133
+ const maxYTick = yTicks[yTicks.length - 1];
134
+ const maxYPos = yScale(maxYTick);
135
+
136
+ // 绘制水平网格线 - 向左延伸
137
+ const gridExtension = 20; // 网格线向左延伸的距离
138
+
139
+ const horizontalGradient = defs.append("linearGradient")
140
+ .attr("id", "horizontal-grid-gradient")
141
+ .attr("x1", "0%")
142
+ .attr("y1", "0%")
143
+ .attr("x2", "100%")
144
+ .attr("y2", "0%");
145
+
146
+ horizontalGradient.append("stop")
147
+ .attr("offset", "0%")
148
+ .attr("stop-color", "#6bc7c5")
149
+ .attr("stop-opacity", 1);
150
+
151
+ horizontalGradient.append("stop")
152
+ .attr("offset", "100%")
153
+ .attr("stop-color", "#6bc7c5")
154
+ .attr("stop-opacity", 0.2);
155
+
156
+
157
+ // 过滤Y轴刻度,移除最小的刻度(通常是0或负值)
158
+ const filteredYTicks = yTicks.filter(d => d > yTicks[0]);
159
+
160
+ // 创建水平网格渐变矩形,不包括最下方的刻度
161
+ g.selectAll("rect.grid-rect-y")
162
+ .data(filteredYTicks)
163
+ .enter()
164
+ .append("rect")
165
+ .attr("class", "grid-rect-y")
166
+ .attr("class", "background")
167
+ .attr("x", -gridExtension)
168
+ .attr("y", d => yScale(d) - 0.5) // 减去0.5使线条居中
169
+ .attr("width", innerWidth + gridExtension)
170
+ .attr("height", 1)
171
+ .style("fill", `url(#horizontal-grid-gradient)`);
172
+
173
+ const verticalGradient = defs.append("linearGradient")
174
+ .attr("id", "vertical-grid-gradient")
175
+ .attr("x1", "0%")
176
+ .attr("y1", "0%")
177
+ .attr("x2", "0%")
178
+ .attr("y2", "100%");
179
+
180
+ verticalGradient.append("stop")
181
+ .attr("offset", "0%")
182
+ .attr("stop-color", "#6bc7c5")
183
+ .attr("stop-opacity", 0);
184
+
185
+ verticalGradient.append("stop")
186
+ .attr("offset", "100%")
187
+ .attr("stop-color", "#6bc7c5")
188
+ .attr("stop-opacity", 0.4);
189
+ // 绘制垂直网格线
190
+ g.selectAll("rect.grid-rect-x")
191
+ .data(xTicks)
192
+ .enter()
193
+ .append("rect")
194
+ .attr("class", "grid-rect-x")
195
+ .attr("class", "background")
196
+ .attr("x", d => xScale(d) - 0.5)
197
+ .attr("y", 0)
198
+ .attr("width", 1)
199
+ .attr("height", innerHeight + 10)
200
+ .style("fill", `url(#vertical-grid-gradient)`);
201
+
202
+ // 为每个组添加线条渐变
203
+ groups.forEach(group => {
204
+ const gradientId = `line-gradient-${group.replace(/\s+/g, '-').toLowerCase()}`;
205
+ const baseColor = getColor(group);
206
+
207
+ const gradient = defs.append("linearGradient")
208
+ .attr("id", gradientId)
209
+ .attr("x1", "0%")
210
+ .attr("y1", "0%")
211
+ .attr("x2", "0%")
212
+ .attr("y2", "100%");
213
+
214
+ gradient.append("stop")
215
+ .attr("offset", "0%")
216
+ .attr("stop-color", baseColor)
217
+ .attr("stop-opacity", 0.3);
218
+
219
+ gradient.append("stop")
220
+ .attr("offset", "100%")
221
+ .attr("stop-color", baseColor)
222
+ .attr("stop-opacity", 0);
223
+ });
224
+
225
+ const line = d3.line()
226
+ .x(d => xScale(chartUtils.format.parseDate(d[xField])))
227
+ .y(d => yScale(+d[yField]))
228
+
229
+ // 首先收集所有点信息,并分为起点和终点两组
230
+ const startPoints = [];
231
+ const endPoints = [];
232
+
233
+ groups.forEach(group => {
234
+ const groupData = chartData.filter(d => d[groupField] === group);
235
+
236
+ // 绘制线条
237
+ g.append("path")
238
+ .datum(groupData)
239
+ .attr("class", "line")
240
+ .attr("fill", "none")
241
+ .attr("stroke", getColor(group))
242
+ .attr("stroke-width", 4)
243
+ .attr("d", line);
244
+
245
+ // 添加起点和终点的圆点
246
+ const firstPoint = groupData[0];
247
+ const lastPoint = groupData[groupData.length - 1];
248
+
249
+ // 处理起点
250
+ const startX = xScale(chartUtils.format.parseDate(firstPoint[xField]));
251
+ const startY = yScale(+firstPoint[yField]);
252
+
253
+ // 添加圆点
254
+ g.append("circle")
255
+ .attr("cx", startX)
256
+ .attr("cy", startY)
257
+ .attr("r", 4)
258
+ .attr("fill", getColor(group));
259
+
260
+ // 收集起点信息
261
+ startPoints.push({
262
+ x: startX,
263
+ y: startY,
264
+ value: chartUtils.format.integer(firstPoint[yField]).text,
265
+ color: getColor(group),
266
+ group: group
267
+ });
268
+
269
+ // 处理终点
270
+ const endX = xScale(chartUtils.format.parseDate(lastPoint[xField]));
271
+ const endY = yScale(+lastPoint[yField]);
272
+
273
+ // 添加圆点
274
+ g.append("circle")
275
+ .attr("cx", endX)
276
+ .attr("cy", endY)
277
+ .attr("r", 4)
278
+ .attr("fill", getColor(group));
279
+
280
+ // 收集终点信息
281
+ endPoints.push({
282
+ x: endX,
283
+ y: endY,
284
+ value: chartUtils.format.integer(lastPoint[yField]).text,
285
+ color: getColor(group),
286
+ group: group
287
+ });
288
+ });
289
+
290
+ // 绘制X轴
291
+ const xAxis = g.append("g")
292
+ .attr("transform", `translate(0,${innerHeight})`)
293
+ .call(d3.axisBottom(xScale)
294
+ .tickFormat(xFormat) // 使用相同的刻度数量
295
+ );
296
+
297
+ // 设置X轴样式,并下移文本
298
+ xAxis.selectAll("text")
299
+ .style("font-family", typography.label.font_family)
300
+ .style("font-size", typography.label.font_size)
301
+ .style("fill", "#e8f6fa")
302
+ .attr("dy", "1.5em"); // 下移文本
303
+
304
+ // 移除X轴线和刻度
305
+ xAxis.select(".domain").remove();
306
+ xAxis.selectAll(".tick line").remove();
307
+
308
+ // 在X轴刻度下方添加一条水平线
309
+ g.append("line")
310
+ .attr("x1", -20)
311
+ .attr("y1", innerHeight + 40) // 放在X轴刻度文本下方
312
+ .attr("x2", innerWidth + 20)
313
+ .attr("y2", innerHeight + 40) // 保持水平
314
+ .attr("stroke", "#e8f6fa") // 与X轴文本相同的颜色
315
+ .attr("stroke-width", 1);
316
+
317
+ // 绘制Y轴 - 移除B后缀,并调整刻度位置
318
+ const yAxis = g.append("g")
319
+ .call(d3.axisLeft(yScale)
320
+ .tickValues(filteredYTicks) // 使用过滤后的刻度
321
+ .tickSize(0) // 移除刻度线
322
+ );
323
+
324
+ // 移除Y轴线
325
+ yAxis.select(".domain").remove();
326
+ yAxis.selectAll(".tick line").remove();
327
+
328
+ // 手动添加Y轴刻度文本,放在延伸的网格线上方
329
+ yAxis.selectAll(".tick text")
330
+ .attr("x", -gridExtension - 5) // 放在延伸的网格线上方,留出一点间距
331
+ .style("font-family", typography.label.font_family)
332
+ .style("font-size", typography.label.font_size)
333
+ .style("fill", "#6bc7c5") // 白色
334
+ .style("text-anchor", "end")
335
+ .text(d => d);
336
+
337
+ // 添加Y轴编码描述
338
+ // 获取Y轴字段名称
339
+ const yAxisLabel = yColumn.description;
340
+
341
+ // 计算Y轴标签位置 - 在最大刻度下方
342
+ const yAxisLabelX = -margin.left / 2 + 5; // 在Y轴中间位置
343
+ const yAxisLabelY = yScale(yTicks[yTicks.length - 1]) - 30; // 最大刻度上方一点
344
+
345
+ // 添加Y轴描述文本
346
+ const words = yAxisLabel.split(' ');
347
+ let lines = [];
348
+ let currentLine = words[0];
349
+
350
+ // 计算每行文本
351
+ for(let i = 1; i < words.length; i++) {
352
+ const testLine = currentLine + ' ' + words[i];
353
+ const testWidth = chartUtils.text.measure(null, testLine, { fontSize: typography.label.font_size }).width;
354
+
355
+ if(testWidth <= 200) {
356
+ currentLine = testLine;
357
+ } else {
358
+ lines.push(currentLine);
359
+ currentLine = words[i];
360
+ }
361
+ }
362
+ lines.push(currentLine);
363
+
364
+ // 绘制多行文本
365
+ lines.forEach((line, i) => {
366
+ g.append("text")
367
+ .attr("class", "y-axis-label")
368
+ .attr("x", yAxisLabelX)
369
+ .attr("y", yAxisLabelY + i * 20)
370
+ .attr("text-anchor", "start")
371
+ .style("font-family", typography.label.font_family)
372
+ .style("font-size", typography.label.font_size)
373
+ .style("fill", "#6bc7c5")
374
+ .text(line);
375
+ });
376
+
377
+ // 动态规划标签放置算法
378
+ function placeLabelsDP(points, avoidYPositions = []) {
379
+ // 每个格点的高度(像素)
380
+ const GRID_SIZE = 3;
381
+ // 圆点周围的保护区域(格点数)
382
+ const PROTECTION_RADIUS = 3;
383
+ // 标签高度(格点数)
384
+ const LABEL_HEIGHT = 5;
385
+
386
+ // 离散化Y坐标,创建格点系统
387
+ const minY = 0;
388
+ const maxY = innerHeight;
389
+ const gridCount = Math.ceil((maxY - minY) / GRID_SIZE);
390
+
391
+ // 按照Y坐标排序点
392
+ points.sort((a, b) => a.y - b.y);
393
+
394
+ // 创建格点占用标记
395
+ const occupied = new Array(gridCount).fill(false);
396
+
397
+ // 标记圆点周围的保护区域为已占用
398
+ points.forEach(point => {
399
+ const gridY = Math.floor(point.y / GRID_SIZE);
400
+ for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
401
+ occupied[i] = true;
402
+ }
403
+ });
404
+
405
+ // 标记需要避开的Y轴刻度位置为已占用
406
+ avoidYPositions.forEach(yPos => {
407
+ const gridY = Math.floor(yPos / GRID_SIZE);
408
+ // 为每个刻度值添加一些保护区域
409
+ for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
410
+ occupied[i] = true;
411
+ }
412
+ });
413
+
414
+ // 定义状态: dp[i][j] 表示前i个点,第i个点的标签放在第j个格点时的最小代价
415
+ const n = points.length;
416
+ const dp = Array(n).fill().map(() => Array(gridCount).fill(Infinity));
417
+ const prev = Array(n).fill().map(() => Array(gridCount).fill(-1));
418
+
419
+ // 初始条件:第一个点的标签放置
420
+ const firstPointGridY = Math.floor(points[0].y / GRID_SIZE);
421
+
422
+ // 尝试将第一个点的标签放在可行的位置
423
+ for (let j = 0; j < gridCount; j++) {
424
+ // 检查是否可行(不在保护区域内且有足够空间放置标签)
425
+ if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
426
+ // 检查标签是否会与其他标签重叠
427
+ let canPlace = true;
428
+ for (let k = 0; k < LABEL_HEIGHT; k++) {
429
+ if (j + k < gridCount && occupied[j + k]) {
430
+ canPlace = false;
431
+ break;
432
+ }
433
+ }
434
+
435
+ // 确保第一个标签不超过第二个圆点位置(如果有第二个圆点)
436
+ if (n > 1) {
437
+ const nextPointGridY = Math.floor(points[1].y / GRID_SIZE);
438
+ if (j > nextPointGridY) {
439
+ canPlace = false; // 不允许标签越过下一个圆点
440
+ }
441
+ }
442
+
443
+ if (canPlace) {
444
+ // 计算代价:与圆点位置的距离
445
+ const cost = Math.abs(j - firstPointGridY);
446
+ dp[0][j] = cost;
447
+ }
448
+ }
449
+ }
450
+
451
+ // 填充dp表
452
+ for (let i = 1; i < n; i++) {
453
+ const pointGridY = Math.floor(points[i].y / GRID_SIZE);
454
+
455
+ for (let j = 0; j < gridCount; j++) {
456
+ // 检查当前位置是否可行
457
+ if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
458
+ // 检查标签是否会与其他标签重叠
459
+ let canPlace = true;
460
+ for (let k = 0; k < LABEL_HEIGHT; k++) {
461
+ if (j + k < gridCount && occupied[j + k]) {
462
+ canPlace = false;
463
+ break;
464
+ }
465
+ }
466
+
467
+ // 确保当前标签不超过下一个圆点位置(如果有下一个圆点)
468
+ if (i < n - 1) {
469
+ const nextPointGridY = Math.floor(points[i+1].y / GRID_SIZE);
470
+ if (j > nextPointGridY) {
471
+ canPlace = false; // 不允许标签越过下一个圆点
472
+ }
473
+ }
474
+
475
+ // 确保当前标签至少超过上一个圆点位置
476
+ if (i > 0) {
477
+ const prevPointGridY = Math.floor(points[i-1].y / GRID_SIZE);
478
+ if (j < prevPointGridY) {
479
+ canPlace = false; // 不允许标签越过上一个圆点
480
+ }
481
+ }
482
+
483
+ if (canPlace) {
484
+ // 根据上一个点的标签位置计算当前最小代价
485
+ for (let k = 0; k + LABEL_HEIGHT <= j; k++) {
486
+ if (dp[i-1][k] !== Infinity) {
487
+
488
+ // 当前标签的代价
489
+ const curCost = Math.abs(j - pointGridY);
490
+ const totalCost = dp[i-1][k] + curCost;
491
+
492
+ if (totalCost < dp[i][j]) {
493
+ dp[i][j] = totalCost;
494
+ prev[i][j] = k; // 记录前驱
495
+ }
496
+ }
497
+ }
498
+ }
499
+ }
500
+ }
501
+ }
502
+
503
+ // 找出最后一个点的最优放置位置
504
+ let minCost = Infinity;
505
+ let bestPos = -1;
506
+
507
+ for (let j = 0; j < gridCount; j++) {
508
+ if (dp[n-1][j] < minCost) {
509
+ minCost = dp[n-1][j];
510
+ bestPos = j;
511
+ }
512
+ }
513
+
514
+ // 回溯构建结果
515
+ const labelPositions = [];
516
+ if (bestPos !== -1) {
517
+ // 从后向前回溯
518
+ let pos = bestPos;
519
+
520
+ for (let i = n - 1; i >= 0; i--) {
521
+ labelPositions.unshift({
522
+ point: points[i],
523
+ labelY: pos * GRID_SIZE
524
+ });
525
+
526
+ pos = prev[i][pos];
527
+ }
528
+
529
+ } else {
530
+ // 无可行解,退化为简单方案
531
+ let lastY = 0;
532
+ for (let i = 0; i < n; i++) {
533
+ const point = points[i];
534
+ // 确保标签不超过下一个圆点的位置
535
+ let maxY = innerHeight;
536
+ if (i < n - 1) {
537
+ maxY = points[i+1].y;
538
+ }
539
+
540
+ const labelY = Math.min(Math.max(point.y + 20, lastY + 25), maxY - 5);
541
+
542
+ labelPositions.push({
543
+ point: point,
544
+ labelY: labelY
545
+ });
546
+
547
+ lastY = labelY;
548
+ }
549
+ }
550
+
551
+ return labelPositions;
552
+ }
553
+
554
+ // 分别对起点和终点应用动态规划
555
+ // 获取Y轴刻度位置用于避开
556
+ const yTickPositions = filteredYTicks.map(tick => yScale(tick));
557
+ const startLabelPositions = placeLabelsDP(startPoints, yTickPositions); // 起点需要避开Y轴刻度
558
+ const endLabelPositions = placeLabelsDP(endPoints, []); // 终点不需要避开Y轴刻度
559
+
560
+ // 分别处理起点和终点标签
561
+ startLabelPositions.forEach(placement => {
562
+ const {point, labelY} = placement;
563
+
564
+ // 添加起点标签,向左偏移10px
565
+ g.append("text")
566
+ .attr("x", point.x - 20)
567
+ .attr("y", labelY)
568
+ .attr("text-anchor", "middle")
569
+ .attr("dominant-baseline", "hanging")
570
+ .style("font-family", typography.label.font_family)
571
+ .style("font-size", "18px")
572
+ .style("font-weight", "bold")
573
+ .style("fill", point.color)
574
+ .text(point.value);
575
+ });
576
+
577
+ endLabelPositions.forEach(placement => {
578
+ const {point, labelY} = placement;
579
+
580
+ // 添加终点标签,向右偏移10px
581
+ g.append("text")
582
+ .attr("x", point.x + 10)
583
+ .attr("y", labelY)
584
+ .attr("text-anchor", "start")
585
+ .attr("dominant-baseline", "hanging")
586
+ .style("font-family", typography.label.font_family)
587
+ .style("font-size", "18px")
588
+ .style("font-weight", "bold")
589
+ .style("fill", point.color)
590
+ .text(point.value + " " + point.group);
591
+ });
592
+ return svg.node();
593
+ }
modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_06.js ADDED
@@ -0,0 +1,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Multiple Line Graph",
5
+ "chart_name": "multiple_line_graph_06",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[5, 30], [0, "inf"], [2, 8]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["group"],
12
+ "required_other_colors": [],
13
+ "supported_effects": [],
14
+ "min_height": 600,
15
+ "min_width": 800,
16
+ "background": "dark",
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_dark || {};
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 groups = [...new Set(chartData.map(d => d[groupField]))];
46
+
47
+ // 设置尺寸和边距
48
+ const width = variables.width;
49
+ const height = variables.height;
50
+ const margin = { top: 20, right: 60, bottom: 80, left: 40 };
51
+
52
+ // 创建SVG
53
+ const svg = d3.select(containerSelector)
54
+ .append("svg")
55
+ .attr("width", "100%")
56
+ .attr("height", height)
57
+ .attr("viewBox", `0 0 ${width} ${height}`)
58
+ .attr("style", "max-width: 100%; height: auto;")
59
+ .attr("xmlns", "http://www.w3.org/2000/svg")
60
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
61
+
62
+ // 创建图表区域
63
+ const chartWidth = width - margin.left - margin.right;
64
+ const chartHeight = height - margin.top - margin.bottom;
65
+
66
+ const g = svg.append("g")
67
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
68
+
69
+ const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, chartWidth);
70
+
71
+ // 确定全局y轴范围
72
+ const yMin = Math.min(0, d3.min(chartData, d => +d[yField]) * 1.4);
73
+ const yMax = d3.max(chartData, d => +d[yField]) * 1.1;
74
+
75
+ // 创建y轴比例尺
76
+ const yScale = d3.scaleLinear()
77
+ .domain([yMin, yMax])
78
+ .range([chartHeight, 0]);
79
+
80
+ // 添加Y轴线(虚线)
81
+ g.append("line")
82
+ .attr("x1", 0)
83
+ .attr("y1", 0)
84
+ .attr("x2", 0)
85
+ .attr("y2", chartHeight)
86
+ .attr("stroke", "#9badd3")
87
+ .attr("stroke-width", 1)
88
+ .attr("stroke-dasharray", "2,2");
89
+
90
+ // 添加X轴线(虚线)
91
+ g.append("line")
92
+ .attr("x1", 0)
93
+ .attr("y1", chartHeight)
94
+ .attr("x2", chartWidth)
95
+ .attr("y2", chartHeight)
96
+ .attr("stroke", "#9badd3")
97
+ .attr("stroke-width", 1)
98
+ .attr("stroke-dasharray", "2,2");
99
+
100
+ // 添加y轴刻度和标签
101
+ const yTicks = yScale.ticks(5); // 根据实际数据自动生成刻度
102
+
103
+ // 添加y轴刻度和标签
104
+ yTicks.forEach(tick => {
105
+ g.append("text")
106
+ .attr("x", -5)
107
+ .attr("y", yScale(tick))
108
+ .attr("text-anchor", "end")
109
+ .attr("dominant-baseline", "middle")
110
+ .style("font-family", "Arial")
111
+ .style("font-size", "16px")
112
+ .style("fill", "#ffffff")
113
+ .text(tick);
114
+ });
115
+
116
+ // 添加x轴刻度和标签
117
+ xTicks.forEach((tick, i) => {
118
+ // 添加主刻度线,跳过第一个刻度
119
+ if(i !== 0) {
120
+ g.append("line")
121
+ .attr("x1", xScale(tick))
122
+ .attr("y1", chartHeight - 2)
123
+ .attr("x2", xScale(tick))
124
+ .attr("y2", chartHeight + 2)
125
+ .attr("stroke", "#ffffff")
126
+ .attr("stroke-width", 1);
127
+ }
128
+
129
+ // 添加刻度标签
130
+ g.append("text")
131
+ .attr("x", xScale(tick))
132
+ .attr("y", chartHeight + 20)
133
+ .attr("text-anchor", i === 0 ? "start" : i === xTicks.length - 1 ? "end" : "middle")
134
+ .style("font-family", "Arial")
135
+ .style("font-size", "16px")
136
+ .style("fill", "#ffffff")
137
+ .text(xFormat(tick));
138
+ });
139
+
140
+ // 创建线条生成器
141
+ const line = d3.line()
142
+ .x(d => xScale(chartUtils.format.parseDate(d[xField])))
143
+ .y(d => yScale(+d[yField]))
144
+ .curve(d3.curveLinear); // 使用普通折线
145
+
146
+ // 绘制每个组的线条和收集标签点
147
+ const labelPoints = [];
148
+
149
+ groups.forEach(group => {
150
+ // 过滤当前组的数据
151
+ let groupData = chartData.filter(d => d[groupField] === group);
152
+ const color = colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
153
+
154
+ // 确保第一个点从Y轴开始
155
+ const firstDataDate = chartUtils.format.parseDate(groupData[0][xField]);
156
+ const xMin = new Date(xScale.domain()[0]);
157
+ // if (firstDataDate.getTime() > xMin.getTime()) {
158
+ // // 添加Y轴起点(使用第一个数据点的Y值)
159
+ // const firstPoint = {
160
+ // [xField]: xMin.getFullYear().toString(),
161
+ // [yField]: groupData[0][yField],
162
+ // [groupField]: group
163
+ // };
164
+ // groupData = [firstPoint, ...groupData];
165
+ // }
166
+
167
+ // 确保最后一个点精确对应最大X值
168
+ const lastDataDate = chartUtils.format.parseDate(groupData[groupData.length - 1][xField]);
169
+ const xMax = new Date(xScale.domain()[1]);
170
+ if (lastDataDate.getTime() < xMax.getTime()) {
171
+ // 添加最后一个点(使用最后一个数据点的Y值)
172
+ const lastPoint = {
173
+ [xField]: xMax.getFullYear().toString(),
174
+ [yField]: groupData[groupData.length - 1][yField],
175
+ [groupField]: group
176
+ };
177
+ groupData = [...groupData, lastPoint];
178
+ }
179
+
180
+ // 绘制线条
181
+ g.append("path")
182
+ .datum(groupData)
183
+ .attr("fill", "none")
184
+ .attr("stroke", color)
185
+ .attr("stroke-width", 4)
186
+ .attr("d", line);
187
+
188
+ // 收集最终值标签点
189
+ if (groupData.length > 0) {
190
+ const lastPoint = groupData[groupData.length - 1];
191
+
192
+ labelPoints.push({
193
+ x: chartWidth,
194
+ y: yScale(+lastPoint[yField]),
195
+ value: chartUtils.format.integer(lastPoint[yField]).text,
196
+ color: color,
197
+ group: group
198
+ });
199
+ }
200
+ });
201
+
202
+ // 动态规划标签放置算法
203
+ function placeLabelsDP(points, avoidYPositions = []) {
204
+ // 每个格点的高度(像素)
205
+ const GRID_SIZE = 3;
206
+ // 圆点周围的保护区域(格点数)
207
+ const PROTECTION_RADIUS = 3;
208
+ // 标签高度(格点数)
209
+ const LABEL_HEIGHT = 10;
210
+
211
+ // 离散化Y坐标,创建格点系统
212
+ const minY = 0;
213
+ const maxY = innerHeight;
214
+ const gridCount = Math.ceil((maxY - minY) / GRID_SIZE);
215
+
216
+ // 按照Y坐标排序点
217
+ points.sort((a, b) => a.y - b.y);
218
+
219
+ // 创建格点占用标记
220
+ const occupied = new Array(gridCount).fill(false);
221
+
222
+ // 标记圆点周围的保护区域为已占用
223
+ // points.forEach(point => {
224
+ // const gridY = Math.floor(point.y / GRID_SIZE);
225
+ // for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
226
+ // occupied[i] = true;
227
+ // }
228
+ // });
229
+
230
+ // 标记需要避开的Y轴刻度位置为已占用
231
+ avoidYPositions.forEach(yPos => {
232
+ const gridY = Math.floor(yPos / GRID_SIZE);
233
+ // 为每个刻度值添加一些保护区域
234
+ for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
235
+ occupied[i] = true;
236
+ }
237
+ });
238
+
239
+ // 定义状态: dp[i][j] 表示前i个点,第i个点的标签放在第j个格点时的最小代价
240
+ const n = points.length;
241
+ const dp = Array(n).fill().map(() => Array(gridCount).fill(Infinity));
242
+ const prev = Array(n).fill().map(() => Array(gridCount).fill(-1));
243
+
244
+ // 初始条件:第一个点的标签放置
245
+ const firstPointGridY = Math.floor(points[0].y / GRID_SIZE);
246
+
247
+ // 尝试将第一个点的标签放在可行的位置
248
+ for (let j = 0; j < gridCount; j++) {
249
+ // 检查是否可行(不在保护区域内且有足够空间放置标签)
250
+ if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
251
+ // 检查标签是否会与其他标签重叠
252
+ let canPlace = true;
253
+ for (let k = 0; k < LABEL_HEIGHT; k++) {
254
+ if (j + k < gridCount && occupied[j + k]) {
255
+ canPlace = false;
256
+ break;
257
+ }
258
+ }
259
+
260
+ // 确保第一个标签不超过第二个圆点位置(如果有第二个圆点)
261
+ if (n > 1) {
262
+ const nextPointGridY = Math.floor(points[1].y / GRID_SIZE);
263
+ if (j > nextPointGridY) {
264
+ canPlace = false; // 不允许标签越过下一个圆点
265
+ }
266
+ }
267
+
268
+ if (canPlace) {
269
+ // 计算代价:与圆点位置的距离
270
+ const cost = Math.abs(j - firstPointGridY);
271
+ dp[0][j] = cost * cost;
272
+ }
273
+ }
274
+ }
275
+
276
+ // 填充dp表
277
+ for (let i = 1; i < n; i++) {
278
+ const pointGridY = Math.floor(points[i].y / GRID_SIZE);
279
+
280
+ for (let j = 0; j < gridCount; j++) {
281
+ // 检查当前位置是否可行
282
+ if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
283
+ // 检查标签是否会与其他标签重叠
284
+ let canPlace = true;
285
+ for (let k = 0; k < LABEL_HEIGHT; k++) {
286
+ if (j + k < gridCount && occupied[j + k]) {
287
+ canPlace = false;
288
+ break;
289
+ }
290
+ }
291
+
292
+ // 确保当前标签不超过下一个圆点位置(如果有下一个圆点)
293
+ if (i < n - 1) {
294
+ const nextPointGridY = Math.floor(points[i+1].y / GRID_SIZE);
295
+ if (j > nextPointGridY) {
296
+ canPlace = false; // 不允许标签越过下一个圆点
297
+ }
298
+ }
299
+
300
+ // 确保当前标签至少超过上一个圆点位置
301
+ if (i > 0) {
302
+ const prevPointGridY = Math.floor(points[i-1].y / GRID_SIZE);
303
+ if (j < prevPointGridY) {
304
+ canPlace = false; // 不允许标签越过上一个圆点
305
+ }
306
+ }
307
+
308
+ if (canPlace) {
309
+ // 根据上一个点的标签位置计算当前最小代价
310
+ for (let k = 0; k + LABEL_HEIGHT <= j; k++) {
311
+ if (dp[i-1][k] !== Infinity) {
312
+
313
+ // 当前标签的代价
314
+ const curCost = Math.abs(j - pointGridY);
315
+ const totalCost = dp[i-1][k] + curCost * curCost;
316
+
317
+ if (totalCost < dp[i][j]) {
318
+ dp[i][j] = totalCost;
319
+ prev[i][j] = k; // 记录前驱
320
+ }
321
+ }
322
+ }
323
+ }
324
+ }
325
+ }
326
+ }
327
+
328
+ // 找出最后一个点的最优放置位置
329
+ let minCost = Infinity;
330
+ let bestPos = -1;
331
+
332
+ for (let j = 0; j < gridCount; j++) {
333
+ if (dp[n-1][j] < minCost) {
334
+ minCost = dp[n-1][j];
335
+ bestPos = j;
336
+ }
337
+ }
338
+
339
+ // 回溯构建结果
340
+ const labelPositions = [];
341
+ if (bestPos !== -1) {
342
+ // 从后向前回溯
343
+ let pos = bestPos;
344
+
345
+ for (let i = n - 1; i >= 0; i--) {
346
+ labelPositions.unshift({
347
+ point: points[i],
348
+ labelY: pos * GRID_SIZE
349
+ });
350
+
351
+ pos = prev[i][pos];
352
+ }
353
+
354
+ } else {
355
+ // 无可行解,退化为简单方案
356
+ let lastY = 0;
357
+ for (let i = 0; i < n; i++) {
358
+ const point = points[i];
359
+ // 确保标签不超过下一个圆点的位置
360
+ let maxY = innerHeight;
361
+ if (i < n - 1) {
362
+ maxY = points[i+1].y;
363
+ }
364
+
365
+ const labelY = Math.min(Math.max(point.y + 20, lastY + 25), maxY - 5);
366
+
367
+ labelPositions.push({
368
+ point: point,
369
+ labelY: labelY
370
+ });
371
+
372
+ lastY = labelY;
373
+ }
374
+ }
375
+
376
+ return labelPositions;
377
+ }
378
+
379
+ // 使用动态规划算法放置标签
380
+ const labelPositions = placeLabelsDP(labelPoints, []);
381
+
382
+ // 绘制标签
383
+ labelPositions.forEach(pos => {
384
+ g.append("text")
385
+ .attr("x", chartWidth + 5)
386
+ .attr("y", pos.labelY)
387
+ .attr("text-anchor", "start")
388
+ .attr("dominant-baseline", "middle")
389
+ .style("font-family", "Arial")
390
+ .style("font-size", "18px")
391
+ .style("font-weight", "bold")
392
+ .style("fill", pos.point.color)
393
+ .text(pos.point.value);
394
+ });
395
+
396
+ // 添加图例(自适应大小)
397
+ // 计算图例项
398
+ const legendItems = groups;
399
+ const legendPadding = 10;
400
+ const legendItemHeight = 20;
401
+ const legendItemSpacing = 0;
402
+ const legendTextOffset = 10;
403
+ const fontSize = 18;
404
+
405
+ // 计算图例的总高度
406
+ const legendHeight = legendItems.length * legendItemHeight + (legendItems.length - 1) * legendItemSpacing + 2 * legendPadding;
407
+
408
+ // 计算最长文本的宽度
409
+ const maxTextWidth = d3.max(legendItems, item => chartUtils.text.measure(svg, item, { fontFamily: "Arial", fontSize }).width) || 0;
410
+
411
+ // 计算图例的总宽度
412
+ const legendLineWidth = 30;
413
+ const legendWidth = legendLineWidth + legendTextOffset + maxTextWidth + 2 * legendPadding;
414
+
415
+ // 计算图例的最佳位置(避免与数据线交叉)
416
+ function findBestLegendPosition(legendWidth, legendHeight) {
417
+ // 定义可能的位置(右上、右下、左上、左下)
418
+ const positions = [
419
+ { name: "rightTop", x: width - margin.right - legendWidth, y: margin.top + 10 },
420
+ { name: "rightBottom", x: width - margin.right - legendWidth, y: height - margin.bottom - legendHeight },
421
+ { name: "leftTop", x: margin.left + 10, y: margin.top + 10 },
422
+ { name: "leftBottom", x: margin.left + 10, y: height - margin.bottom - legendHeight }
423
+ ];
424
+
425
+ // 创建临时图例用于碰撞检测
426
+ const tempLegend = svg.append("rect")
427
+ .attr("width", legendWidth)
428
+ .attr("height", legendHeight)
429
+ .attr("fill", "none")
430
+ .attr("stroke", "none")
431
+ .style("visibility", "hidden");
432
+
433
+ // 计算每个位置与数据线的交叉程度
434
+ const intersections = positions.map(pos => {
435
+ tempLegend.attr("x", pos.x).attr("y", pos.y);
436
+ let intersectionCount = 0;
437
+
438
+ // 检查每条线与图例的交叉
439
+ groups.forEach(group => {
440
+ const groupData = chartData.filter(d => d[groupField] === group);
441
+
442
+ // 简化:检查每个数据点是否在图例区域内
443
+ for (let i = 0; i < groupData.length; i++) {
444
+ const x = margin.left + xScale(chartUtils.format.parseDate(groupData[i][xField]));
445
+ const y = margin.top + yScale(groupData[i][yField]);
446
+
447
+ if (x >= pos.x && x <= pos.x + legendWidth &&
448
+ y >= pos.y && y <= pos.y + legendHeight) {
449
+ intersectionCount++;
450
+ }
451
+
452
+ // 也检查线段是否穿过图例
453
+ if (i < groupData.length - 1) {
454
+ const nextX = margin.left + xScale(chartUtils.format.parseDate(groupData[i+1][xField]));
455
+ const nextY = margin.top + yScale(groupData[i+1][yField]);
456
+
457
+ // 简化的线段与矩形相交检测
458
+ if (lineIntersectsRectangle(
459
+ x, y, nextX, nextY,
460
+ pos.x, pos.y, pos.x + legendWidth, pos.y + legendHeight
461
+ )) {
462
+ intersectionCount++;
463
+ }
464
+ }
465
+ }
466
+ });
467
+
468
+ return { position: pos, count: intersectionCount };
469
+ });
470
+
471
+ // 移除临时图例
472
+ tempLegend.remove();
473
+
474
+ // 找出交叉最少的位置
475
+ intersections.sort((a, b) => a.count - b.count);
476
+ return intersections[0].position;
477
+ }
478
+
479
+ // 辅助函数:检测线段是否与矩形相交
480
+ function lineIntersectsRectangle(x1, y1, x2, y2, rectX, rectY, rectX2, rectY2) {
481
+ // 检查线段的两个端点是否都在矩形的同一侧
482
+ if ((x1 <= rectX && x2 <= rectX) || // 线段在矩形左侧
483
+ (x1 >= rectX2 && x2 >= rectX2) || // 线段在矩形右侧
484
+ (y1 <= rectY && y2 <= rectY) || // 线段在矩形上方
485
+ (y1 >= rectY2 && y2 >= rectY2)) { // 线段在矩形下方
486
+ return false;
487
+ }
488
+
489
+ // 简化的相交检测
490
+ // 如果线段的斜率使其穿过矩形,则认为相交
491
+ if (x1 !== x2) { // 非垂直线
492
+ const slope = (y2 - y1) / (x2 - x1);
493
+ const yAtRectX = y1 + slope * (rectX - x1);
494
+ const yAtRectX2 = y1 + slope * (rectX2 - x1);
495
+
496
+ if ((yAtRectX >= rectY && yAtRectX <= rectY2) ||
497
+ (yAtRectX2 >= rectY && yAtRectX2 <= rectY2)) {
498
+ return true;
499
+ }
500
+ }
501
+
502
+ if (y1 !== y2) { // 非水平线
503
+ const inverseSlope = (x2 - x1) / (y2 - y1);
504
+ const xAtRectY = x1 + inverseSlope * (rectY - y1);
505
+ const xAtRectY2 = x1 + inverseSlope * (rectY2 - y1);
506
+
507
+ if ((xAtRectY >= rectX && xAtRectY <= rectX2) ||
508
+ (xAtRectY2 >= rectX && xAtRectY2 <= rectX2)) {
509
+ return true;
510
+ }
511
+ }
512
+
513
+ return false;
514
+ }
515
+
516
+ // 找出最佳位置
517
+ const bestPosition = findBestLegendPosition(legendWidth, legendHeight);
518
+ const legendX = bestPosition.x;
519
+ const legendY = bestPosition.y;
520
+
521
+ const legend = svg.append("g")
522
+ .attr("transform", `translate(${legendX}, ${legendY})`);
523
+ legend.append("rect")
524
+ .attr("width", legendWidth)
525
+ .attr("height", legendHeight)
526
+ .attr("fill", "none")
527
+ .attr("stroke", "#d1ddff")
528
+ .attr("stroke-width", 1)
529
+ .attr("stroke-dasharray", "4,4");
530
+ chartUtils.legend.draw(legend, legendItems, {
531
+ color: item => colorResolver.field(item, 0, { fallbackKey: "primary" }).value,
532
+ colorResolver,
533
+ colors,
534
+ }, {
535
+ x: legendPadding,
536
+ y: legendPadding,
537
+ direction: "vertical",
538
+ markerShape: "line",
539
+ markerSize: legendLineWidth,
540
+ strokeWidth: 5,
541
+ labelGap: legendTextOffset,
542
+ rowGap: legendItemSpacing,
543
+ itemHeight: legendItemHeight,
544
+ fontSize: fontSize,
545
+ fontFamily: "Arial",
546
+ textColor: colorResolver.text({ fallback: "#333333" }).value,
547
+ });
548
+
549
+ return svg.node();
550
+ }
modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_07.js ADDED
@@ -0,0 +1,894 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Multiple Line Graph",
5
+ "chart_name": "multiple_line_graph_07",
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
+ // 提取数据
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_dark || {};
32
+ const colorResolver = chartUtils.color.resolver(jsonData);
33
+ const dataColumns = chartUtils.schema.columns(jsonData);
34
+ const images = jsonData.images || {};
35
+ // 清空容器
36
+ d3.select(containerSelector).html("");
37
+
38
+ // 设置尺寸和边距 - 右侧留出更多空间用于标签
39
+ const width = variables.width;
40
+ const height = variables.height;
41
+ const margin = { top: 40, right: 180, bottom: 60, left: 80 };
42
+ const innerWidth = width - margin.left - margin.right;
43
+ const innerHeight = height - margin.top - margin.bottom;
44
+
45
+ // 获取字段名
46
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
47
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
48
+ const yColumn = chartUtils.schema.column(dataColumns, 1, { fallbackKey: yField });
49
+ const yLabel = yColumn.rawLabel || yField;
50
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
51
+
52
+ // 获取唯一的组值和X值
53
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
54
+ const xValues = [...new Set(chartData.map(d => d[xField]))]
55
+ .sort((a, b) => chartUtils.format.parseDate(a) - chartUtils.format.parseDate(b));
56
+
57
+ // 创建比例尺 - 修改为时间比例尺
58
+ const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, innerWidth);
59
+
60
+ // 创建SVG
61
+ const svg = d3.select(containerSelector)
62
+ .append("svg")
63
+ .attr("width", "100%")
64
+ .attr("height", height)
65
+ .attr("viewBox", `0 0 ${width} ${height}`)
66
+ .attr("style", "max-width: 100%; height: auto;")
67
+ .attr("xmlns", "http://www.w3.org/2000/svg")
68
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
69
+
70
+ // 创建图表组
71
+ const g = svg.append("g")
72
+ .attr("transform", `translate(${margin.left},${margin.top})`);
73
+
74
+ // 修改Y轴比例尺,支持负值,并确保最大刻度超过数据最大值
75
+ const yScale = d3.scaleLinear()
76
+ .domain([
77
+ Math.min(0, d3.min(chartData, d => +d[yField]) * 1.1), // 取最小值和0中的较小者
78
+ d3.max(chartData, d => +d[yField]) * 1.3 // 将最大值扩大30%
79
+ ])
80
+ .range([innerHeight, 0]);
81
+
82
+ // 获取颜色
83
+ const getColor = (group) => {
84
+ return colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
85
+ };
86
+
87
+ // 添加网格线 - 使用半透明白色
88
+ // 首先获取Y轴的最大刻度值
89
+ const yTicks = yScale.ticks(6); // 增加刻度数量,确保有足够的刻度
90
+ const maxYTick = yTicks[yTicks.length - 1];
91
+ const maxYPos = yScale(maxYTick);
92
+
93
+ // 绘制水平网格线 - 向左延伸
94
+ const gridExtension = 5; // 网格线向左延伸的距离
95
+
96
+ // 过滤Y轴刻度,移除最小的刻度(通常是0或负值)
97
+ const filteredYTicks = yTicks.filter(d => d > yTicks[0]);
98
+
99
+ // 创建水平网格线,不包括最下方的刻度
100
+ g.selectAll("rect.grid-rect-y")
101
+ .data(filteredYTicks)
102
+ .enter()
103
+ .append("rect")
104
+ .attr("class", "grid-rect-y")
105
+ .attr("class", "background")
106
+ .attr("x", -gridExtension - 30)
107
+ .attr("y", d => yScale(d) - 0.5) // 减去0.5使线条居中
108
+ .attr("width", innerWidth + gridExtension)
109
+ .attr("height", 1)
110
+ .style("fill", "#3f3e40");
111
+
112
+ // 绘制垂直网格线
113
+ g.selectAll("rect.grid-rect-x")
114
+ .data(xTicks)
115
+ .enter()
116
+ .append("rect")
117
+ .attr("class", "grid-rect-x")
118
+ .attr("class", "background")
119
+ .attr("x", d => xScale(d) - 0.5)
120
+ .attr("y", 0)
121
+ .attr("width", 1)
122
+ .attr("height", innerHeight)
123
+ .style("fill", "#3f3e40");
124
+
125
+ const line = d3.line()
126
+ .x(d => xScale(chartUtils.format.parseDate(d[xField])))
127
+ .y(d => yScale(+d[yField]))
128
+
129
+ // 首先收集所有点信息,并分为起点和终点两组
130
+ const startPoints = [];
131
+ const endPoints = [];
132
+
133
+ let endX = 0;
134
+ // 获取最后一个时间点对应的x坐标
135
+ const lastXValue = xValues[xValues.length - 1];
136
+ endX = xScale(chartUtils.format.parseDate(lastXValue));
137
+
138
+ // 创建渐变定义
139
+ const defs = svg.append("defs");
140
+
141
+ // 添加Y轴线 - 与X轴线样式一致
142
+ g.append("line")
143
+ .attr("x1", 0)
144
+ .attr("y1", 0)
145
+ .attr("x2", 0)
146
+ .attr("y2", innerHeight)
147
+ .attr("stroke", "#e8f6fa")
148
+ .attr("stroke-width", 1);
149
+
150
+ // 添加最后一个年份的垂直线 - 与X轴线样式一致
151
+ g.append("line")
152
+ .attr("x1", endX)
153
+ .attr("y1", 0)
154
+ .attr("x2", endX)
155
+ .attr("y2", innerHeight)
156
+ .attr("stroke", "#e8f6fa")
157
+ .attr("stroke-width", 1);
158
+
159
+ // 找出结束值最高的组
160
+ let highestEndGroup = null;
161
+ let highestEndValue = -Infinity;
162
+
163
+ groups.forEach(group => {
164
+ const groupData = chartData.filter(d => d[groupField] === group);
165
+ const lastPoint = groupData[groupData.length - 1];
166
+
167
+ if (+lastPoint[yField] > highestEndValue) {
168
+ highestEndValue = lastPoint[yField];
169
+ highestEndGroup = group;
170
+ }
171
+ });
172
+
173
+ // 为最高值组创建渐变 - 使用更多的小段和插值
174
+ const gradientId = "area-gradient";
175
+
176
+ // 获取最高组的数据
177
+ const highestGroupData = chartData.filter(d => d[groupField] === highestEndGroup);
178
+
179
+ // 计算插值点,使渐变更加平滑 - 直接对x坐标进行插值
180
+ const interpolatedData = [];
181
+ const interpolationCount = 10; // 增加插值点数量,使效果更平滑
182
+
183
+ for (let i = 0; i < highestGroupData.length - 1; i++) {
184
+ const current = highestGroupData[i];
185
+ const next = highestGroupData[i + 1];
186
+
187
+ // 计算当前点和下一个点的x坐标
188
+ const currentX = xScale(chartUtils.format.parseDate(current[xField]));
189
+ const nextX = xScale(chartUtils.format.parseDate(next[xField]));
190
+
191
+ // 计算当前点和下一个点的y值
192
+ const currentY = current[yField];
193
+ const nextY = next[yField];
194
+
195
+ // 添加当前点
196
+ interpolatedData.push({
197
+ x: currentX,
198
+ y: currentY,
199
+ original: current
200
+ });
201
+
202
+ // 在当前点和下一个点之间插入额外的点
203
+ for (let j = 1; j <= interpolationCount; j++) {
204
+ const ratio = j / (interpolationCount + 1);
205
+
206
+ // 直接对x坐标和y值进行线性插值
207
+ const interpolatedX = currentX * (1 - ratio) + nextX * ratio;
208
+ const interpolatedY = currentY * (1 - ratio) + nextY * ratio;
209
+
210
+ interpolatedData.push({
211
+ x: interpolatedX,
212
+ y: interpolatedY,
213
+ // 保存一个引用,用于获取其他属性
214
+ original: current
215
+ });
216
+ }
217
+ }
218
+
219
+ // 添加最后一个点
220
+ interpolatedData.push({
221
+ x: xScale(chartUtils.format.parseDate(highestGroupData[highestGroupData.length - 1][xField])),
222
+ y: highestGroupData[highestGroupData.length - 1][yField],
223
+ original: highestGroupData[highestGroupData.length - 1]
224
+ });
225
+
226
+ // 为每个插值点创建一个渐变
227
+ interpolatedData.forEach((d, i) => {
228
+ const pointGradientId = `${gradientId}-${i}`;
229
+ const pointGradient = defs.append("linearGradient")
230
+ .attr("id", pointGradientId)
231
+ .attr("x1", "0%")
232
+ .attr("y1", "0%")
233
+ .attr("x2", "0%")
234
+ .attr("y2", "100%")
235
+ .attr("gradientUnits", "userSpaceOnUse")
236
+ .attr("gradientTransform", `translate(0, ${yScale(d.y)})`);
237
+
238
+ // 添加渐变色标 - 使用更多的色标点
239
+ pointGradient.append("stop")
240
+ .attr("offset", "0%")
241
+ .attr("stop-color", getColor(highestEndGroup))
242
+ .attr("stop-opacity", 0.7);
243
+
244
+ pointGradient.append("stop")
245
+ .attr("offset", "20%")
246
+ .attr("stop-color", getColor(highestEndGroup))
247
+ .attr("stop-opacity", 0.5);
248
+
249
+ pointGradient.append("stop")
250
+ .attr("offset", "40%")
251
+ .attr("stop-color", getColor(highestEndGroup))
252
+ .attr("stop-opacity", 0.3);
253
+
254
+ pointGradient.append("stop")
255
+ .attr("offset", "60%")
256
+ .attr("stop-color", getColor(highestEndGroup))
257
+ .attr("stop-opacity", 0.2);
258
+
259
+ pointGradient.append("stop")
260
+ .attr("offset", "80%")
261
+ .attr("stop-color", getColor(highestEndGroup))
262
+ .attr("stop-opacity", 0.1);
263
+
264
+ pointGradient.append("stop")
265
+ .attr("offset", "100%")
266
+ .attr("stop-color", getColor(highestEndGroup))
267
+ .attr("stop-opacity", 0);
268
+ });
269
+
270
+ groups.forEach(group => {
271
+ const groupData = chartData.filter(d => d[groupField] === group);
272
+
273
+ // 如果是最高值组,添加面积渐变
274
+ if (group === highestEndGroup) {
275
+ // 创建自定义面积生成器,直接使用x坐标
276
+ const customArea = (segment) => {
277
+ const [p1, p2] = segment;
278
+ return `
279
+ M ${p1.x},${yScale(p1.y)}
280
+ L ${p2.x},${yScale(p2.y)}
281
+ L ${p2.x},${innerHeight}
282
+ L ${p1.x},${innerHeight}
283
+ Z
284
+ `;
285
+ };
286
+
287
+ // 添加面积 - 使用插值后的数据点
288
+ for (let i = 0; i < interpolatedData.length - 1; i++) {
289
+ const segmentData = [interpolatedData[i], interpolatedData[i+1]];
290
+ const segmentGradientId = `${gradientId}-${i}`;
291
+
292
+ g.append("path")
293
+ .attr("class", "area-segment")
294
+ .attr("fill", `url(#${segmentGradientId})`)
295
+ .attr("d", customArea(segmentData))
296
+ .attr("opacity", 0.8);
297
+ }
298
+ }
299
+
300
+ // 绘制线条 - 添加黑色描边
301
+ // 先绘制黑色描边
302
+ g.append("path")
303
+ .datum(groupData)
304
+ .attr("class", "line-stroke")
305
+ .attr("fill", "none")
306
+ .attr("stroke", "#333333")
307
+ .attr("stroke-width", 8) // 比实际线条宽一些
308
+ .attr("d", line);
309
+
310
+ // 再绘制彩色线条
311
+ g.append("path")
312
+ .datum(groupData)
313
+ .attr("class", "line")
314
+ .attr("fill", "none")
315
+ .attr("stroke", getColor(group))
316
+ .attr("stroke-width", 4)
317
+ .attr("d", line);
318
+
319
+ // 添加起点和终点的圆点
320
+ const firstPoint = groupData[0];
321
+ const lastPoint = groupData[groupData.length - 1];
322
+
323
+ // 处理起点 - 添加黑色描边
324
+ const startX = xScale(chartUtils.format.parseDate(firstPoint[xField]));
325
+ const startY = yScale(+firstPoint[yField]);
326
+
327
+ // 添加圆点描边
328
+ g.append("circle")
329
+ .attr("cx", startX)
330
+ .attr("cy", startY)
331
+ .attr("r", 6) // 比实际圆点大一些
332
+ .attr("fill", "#333333");
333
+
334
+ // 添加圆点
335
+ g.append("circle")
336
+ .attr("cx", startX)
337
+ .attr("cy", startY)
338
+ .attr("r", 4)
339
+ .attr("fill", getColor(group));
340
+
341
+ // 收集起点信息
342
+ startPoints.push({
343
+ x: startX,
344
+ y: startY,
345
+ value: chartUtils.format.integer(firstPoint[yField]).text,
346
+ color: getColor(group),
347
+ group: group
348
+ });
349
+
350
+ // 处理终点 - 添加黑色描边
351
+ endX = xScale(chartUtils.format.parseDate(lastPoint[xField]));
352
+ const endY = yScale(+lastPoint[yField]);
353
+
354
+ // 添加圆点描边
355
+ g.append("circle")
356
+ .attr("cx", endX)
357
+ .attr("cy", endY)
358
+ .attr("r", 6) // 比实际圆点大一些
359
+ .attr("fill", "#333333");
360
+
361
+ // 添加圆点
362
+ g.append("circle")
363
+ .attr("cx", endX)
364
+ .attr("cy", endY)
365
+ .attr("r", 4)
366
+ .attr("fill", getColor(group));
367
+
368
+ // 收集终点信息
369
+ endPoints.push({
370
+ x: endX,
371
+ y: endY,
372
+ value: chartUtils.format.integer(lastPoint[yField]).text,
373
+ color: getColor(group),
374
+ group: group
375
+ });
376
+
377
+ });
378
+
379
+ // 绘制X轴 - 使用均匀分布的刻度
380
+ const xAxis = g.append("g")
381
+ .attr("transform", `translate(0,${innerHeight})`)
382
+ .call(d3.axisBottom(xScale)
383
+ .tickFormat(xFormat) // 使用自定义刻度值
384
+ );
385
+
386
+ // 设置X轴样式,并下移文本
387
+ xAxis.selectAll("text")
388
+ .style("font-family", typography.label.font_family)
389
+ .style("font-size", "12px")
390
+ .style("fill", "#ffffff")
391
+ .attr("dy", "0.7em"); // 下移文本
392
+
393
+ // 移除X轴线和刻度
394
+ xAxis.select(".domain").remove();
395
+ xAxis.selectAll(".tick line").remove();
396
+
397
+ // 在X轴刻度下方添加一条水平线
398
+ g.append("line")
399
+ .attr("x1", -20)
400
+ .attr("y1", innerHeight) // 放在X轴刻度文本下方
401
+ .attr("x2", innerWidth + 20)
402
+ .attr("y2", innerHeight) // 保持水平
403
+ .attr("stroke", "#e8f6fa") // 与X轴文本相同的颜色
404
+ .attr("stroke-width", 1);
405
+
406
+
407
+ // 绘制Y轴 - 移除B后缀,并调整刻度位置
408
+ const yAxis = g.append("g")
409
+ .call(d3.axisLeft(yScale)
410
+ .tickValues(filteredYTicks) // 使用过滤后的刻度
411
+ .tickSize(0) // 移除刻度线
412
+ );
413
+
414
+ // 移除Y轴线
415
+ yAxis.select(".domain").remove();
416
+ yAxis.selectAll(".tick line").remove();
417
+
418
+ // 手动添加Y轴刻度文本,放在延伸的网格线上方
419
+ yAxis.selectAll(".tick text")
420
+ .attr("x", -gridExtension - 5) // 放在延伸的网格线上方,留出一点间距
421
+ .attr("dy", -5)
422
+ .style("font-family", typography.label.font_family)
423
+ .style("font-size", "12px")
424
+ .style("fill", "#ffffff") // 白色
425
+ .style("text-anchor", "end")
426
+ .text(d => d);
427
+
428
+ // 添加Y轴标题 - 旋转90度
429
+ g.append("text")
430
+ .attr("transform", "rotate(-90)")
431
+ .attr("y", -margin.left + 20) // 位置调整
432
+ .attr("x", -innerHeight / 2) // 居中
433
+ .attr("dy", "1em")
434
+ .style("text-anchor", "middle")
435
+ .style("font-family", typography.label.font_family)
436
+ .style("font-size", "12px")
437
+ .style("fill", "#ffffff")
438
+ .text(yLabel); // 使用标签或字段名
439
+
440
+
441
+
442
+ // 动态规划标签放置算法
443
+ function placeLabelsDP(points, avoidYPositions = []) {
444
+ // 每个格点的高度(像素)
445
+ const GRID_SIZE = 3;
446
+ // 圆点周围的保护区域(格点数)
447
+ const PROTECTION_RADIUS = 3;
448
+ // 标签高度(格点数)
449
+ const LABEL_HEIGHT = 5;
450
+
451
+ // 离散化Y坐标,创建格点系统
452
+ const minY = 0;
453
+ const maxY = innerHeight;
454
+ const gridCount = Math.ceil((maxY - minY) / GRID_SIZE);
455
+
456
+ // 按照Y坐标排序点
457
+ points.sort((a, b) => a.y - b.y);
458
+
459
+ // 创建格点占用标记
460
+ const occupied = new Array(gridCount).fill(false);
461
+
462
+ // 标记圆点周围的保护区域为已占用
463
+ points.forEach(point => {
464
+ const gridY = Math.floor(point.y / GRID_SIZE);
465
+ for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
466
+ occupied[i] = true;
467
+ }
468
+ });
469
+
470
+ // 标记需要避开的Y轴刻度位置为已占用
471
+ avoidYPositions.forEach(yPos => {
472
+ const gridY = Math.floor(yPos / GRID_SIZE);
473
+ // 为每个刻度值添加一些保护区域
474
+ for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
475
+ occupied[i] = true;
476
+ }
477
+ });
478
+
479
+ // 定义状态: dp[i][j] 表示前i个点,第i个点的标签放在第j个格点时的最小代价
480
+ const n = points.length;
481
+ const dp = Array(n).fill().map(() => Array(gridCount).fill(Infinity));
482
+ const prev = Array(n).fill().map(() => Array(gridCount).fill(-1));
483
+
484
+ // 初始条件:第一个点的标签放置
485
+ const firstPointGridY = Math.floor(points[0].y / GRID_SIZE);
486
+
487
+ // 尝试将第一个点的标签放在可行的位置
488
+ for (let j = 0; j < gridCount; j++) {
489
+ // 检查是否可行(不在保护区域内且有足够空间放置标签)
490
+ if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
491
+ // 检查标签是否会与其他标签重叠
492
+ let canPlace = true;
493
+ for (let k = 0; k < LABEL_HEIGHT; k++) {
494
+ if (j + k < gridCount && occupied[j + k]) {
495
+ canPlace = false;
496
+ break;
497
+ }
498
+ }
499
+
500
+ // 确保第一个标签不超过第二个圆点位置(如果有第二个圆点)
501
+ if (n > 1) {
502
+ const nextPointGridY = Math.floor(points[1].y / GRID_SIZE);
503
+ if (j > nextPointGridY) {
504
+ canPlace = false; // 不允许标签越过下一个圆点
505
+ }
506
+ }
507
+
508
+ if (canPlace) {
509
+ // 计算代价:与圆点位置的距离
510
+ const cost = Math.abs(j - firstPointGridY);
511
+ dp[0][j] = cost;
512
+ }
513
+ }
514
+ }
515
+
516
+ // 填充dp表
517
+ for (let i = 1; i < n; i++) {
518
+ const pointGridY = Math.floor(points[i].y / GRID_SIZE);
519
+
520
+ for (let j = 0; j < gridCount; j++) {
521
+ // 检查当前位置是否可行
522
+ if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
523
+ // 检查标签是否会与其他标签重叠
524
+ let canPlace = true;
525
+ for (let k = 0; k < LABEL_HEIGHT; k++) {
526
+ if (j + k < gridCount && occupied[j + k]) {
527
+ canPlace = false;
528
+ break;
529
+ }
530
+ }
531
+
532
+ // 确保当前标签不超过下一个圆点位置(如果有下一个圆点)
533
+ if (i < n - 1) {
534
+ const nextPointGridY = Math.floor(points[i+1].y / GRID_SIZE);
535
+ if (j > nextPointGridY) {
536
+ canPlace = false; // 不允许标签越过下一个圆点
537
+ }
538
+ }
539
+
540
+ // 确保当前标签至少超过上一个圆点位置
541
+ if (i > 0) {
542
+ const prevPointGridY = Math.floor(points[i-1].y / GRID_SIZE);
543
+ if (j < prevPointGridY) {
544
+ canPlace = false; // 不允许标签越过上一个圆点
545
+ }
546
+ }
547
+
548
+ if (canPlace) {
549
+ // 根据上一个点的标签位置计算当前最小代价
550
+ for (let k = 0; k + LABEL_HEIGHT <= j; k++) {
551
+ if (dp[i-1][k] !== Infinity) {
552
+
553
+ // 当前标签的代价
554
+ const curCost = Math.abs(j - pointGridY);
555
+ const totalCost = dp[i-1][k] + curCost;
556
+
557
+ if (totalCost < dp[i][j]) {
558
+ dp[i][j] = totalCost;
559
+ prev[i][j] = k; // 记录前驱
560
+ }
561
+ }
562
+ }
563
+ }
564
+ }
565
+ }
566
+ }
567
+
568
+ // 找出最后一个点的最优放置位置
569
+ let minCost = Infinity;
570
+ let bestPos = -1;
571
+
572
+ for (let j = 0; j < gridCount; j++) {
573
+ if (dp[n-1][j] < minCost) {
574
+ minCost = dp[n-1][j];
575
+ bestPos = j;
576
+ }
577
+ }
578
+
579
+ // 回溯构建结果
580
+ const labelPositions = [];
581
+ if (bestPos !== -1) {
582
+ // 从后向前回溯
583
+ let pos = bestPos;
584
+
585
+ for (let i = n - 1; i >= 0; i--) {
586
+ labelPositions.unshift({
587
+ point: points[i],
588
+ labelY: pos * GRID_SIZE
589
+ });
590
+
591
+ pos = prev[i][pos];
592
+ }
593
+
594
+ } else {
595
+ // 无可行解,退化为简单方案
596
+ let lastY = 0;
597
+ for (let i = 0; i < n; i++) {
598
+ const point = points[i];
599
+
600
+ const labelY = Math.max(point.y, lastY + 45);
601
+
602
+ labelPositions.push({
603
+ point: point,
604
+ labelY: labelY
605
+ });
606
+
607
+ lastY = labelY;
608
+ }
609
+ }
610
+
611
+ return labelPositions;
612
+ }
613
+
614
+ // 分别对起点和终点应用动态规划
615
+ // 获取Y轴刻度位置用于避开
616
+ const endLabelPositions = placeLabelsDP(endPoints, []); // 终点不需要避开Y轴刻度
617
+
618
+ // 为每个组创建一个图像图标
619
+ endLabelPositions.forEach(placement => {
620
+ const {point, labelY} = placement;
621
+
622
+ // 如果是最高组,特殊处理其标签位置
623
+ if (point.group === highestEndGroup) {
624
+ // 创建10px间隔的网格系统
625
+ const gridSize = 10;
626
+ const gridWidth = Math.ceil(innerWidth / gridSize);
627
+ const gridHeight = Math.ceil(innerHeight / gridSize);
628
+ const grid = Array(gridHeight).fill().map(() => Array(gridWidth).fill(false));
629
+
630
+ // 标记所有折线占用的网格
631
+ groups.forEach(group => {
632
+ if (group !== highestEndGroup) { // 不标记最高组自己的折线
633
+ const groupData = chartData.filter(d => d[groupField] === group);
634
+
635
+ // 标记折线路径
636
+ for (let i = 0; i < groupData.length - 1; i++) {
637
+ const current = groupData[i];
638
+ const next = groupData[i + 1];
639
+
640
+ const x1 = xScale(chartUtils.format.parseDate(current[xField]));
641
+ const y1 = yScale(current[yField]);
642
+ const x2 = xScale(chartUtils.format.parseDate(next[xField]));
643
+ const y2 = yScale(next[yField]);
644
+
645
+ // 使用线性插值标记路径上的点
646
+ const steps = Math.max(
647
+ Math.ceil(Math.abs(x2 - x1) / gridSize),
648
+ Math.ceil(Math.abs(y2 - y1) / gridSize)
649
+ );
650
+
651
+ for (let step = 0; step <= steps; step++) {
652
+ const ratio = step / steps;
653
+ const x = x1 + (x2 - x1) * ratio;
654
+ const y = y1 + (y2 - y1) * ratio;
655
+
656
+ // 标记周围的网格为占用
657
+ const gridX = Math.floor(x / gridSize);
658
+ const gridY = Math.floor(y / gridSize);
659
+ grid[gridY][gridX] = true;
660
+
661
+ // // 添加保护区域
662
+ // for (let dy = -2; dy <= 2; dy++) {
663
+ // for (let dx = -2; dx <= 2; dx++) {
664
+ // const nx = gridX + dx;
665
+ // const ny = gridY + dy;
666
+ // if (nx >= 0 && nx < gridWidth && ny >= 0 && ny < gridHeight) {
667
+ // grid[ny][nx] = true; // 标记为占用
668
+ // }
669
+ // }
670
+ // }
671
+ }
672
+ }
673
+ }
674
+ });
675
+
676
+ // 标记最高组折线上方的区域为占用
677
+ const highestGroupData = chartData.filter(d => d[groupField] === highestEndGroup);
678
+ for (let i = 0; i < highestGroupData.length - 1; i++) {
679
+ const current = highestGroupData[i];
680
+ const next = highestGroupData[i + 1];
681
+
682
+ const x1 = xScale(chartUtils.format.parseDate(current[xField]));
683
+ const y1 = yScale(current[yField]);
684
+ const x2 = xScale(chartUtils.format.parseDate(next[xField]));
685
+ const y2 = yScale(next[yField]);
686
+
687
+ // 使用线性插值标记路径上的点
688
+ const steps = Math.max(
689
+ Math.ceil(Math.abs(x2 - x1) / gridSize),
690
+ Math.ceil(Math.abs(y2 - y1) / gridSize)
691
+ );
692
+
693
+ for (let step = 0; step <= steps; step++) {
694
+ const ratio = step / steps;
695
+ const x = x1 + (x2 - x1) * ratio;
696
+ const y = y1 + (y2 - y1) * ratio;
697
+
698
+ // 标记当前点上方的所有网格为占用
699
+ const gridX = Math.floor(x / gridSize);
700
+ const minY = 0;
701
+ const maxY = Math.ceil(y / gridSize);
702
+
703
+ for (let gridY = minY; gridY <= maxY - 2; gridY++) {
704
+ if (gridX >= 0 && gridX < gridWidth && gridY >= 0 && gridY < gridHeight) {
705
+ grid[gridY][gridX] = true;
706
+ }
707
+ }
708
+ }
709
+ }
710
+
711
+ // 找到最高组折线下方的空位
712
+
713
+ // 计算标签尺寸(网格单位)
714
+ const labelWidth = 4 + Math.ceil(chartUtils.text.measure(null, point.group, { fontSize: 22 }).width / gridSize); // 根据实际文本宽度计算网格单位
715
+ const labelHeight = 4; // 估计标签高度
716
+
717
+ // 找到合适的位置
718
+ let bestX = -1, bestY = -1;
719
+ let bestScore = -Infinity;
720
+
721
+
722
+ // 在折线下方搜索空位
723
+ for (let gridY = 0; gridY < gridHeight - labelHeight; gridY++) {
724
+ for (let gridX = 0; gridX < gridWidth - labelWidth; gridX++) {
725
+ // 检查这个区域是否有足够的空间
726
+ let hasSpace = true;
727
+ for (let dy = 0; dy < labelHeight; dy++) {
728
+ for (let dx = 0; dx < labelWidth; dx++) {
729
+ if (grid[gridY + dy][gridX + dx]) {
730
+ hasSpace = false;
731
+ break;
732
+ }
733
+ }
734
+ if (!hasSpace) break;
735
+ }
736
+
737
+ if (hasSpace) {
738
+ // 优先选择靠上且靠中间的位置
739
+ const centerX = gridWidth / 2;
740
+ const distanceFromCenter = Math.abs(gridX + labelWidth / 2 - centerX);
741
+ const score = -gridY * 0.01 - 2 * distanceFromCenter; // 越靠上gridY越小,越靠近中间distanceFromCenter越小,分数越高
742
+
743
+ if (score > bestScore) {
744
+ bestScore = score;
745
+ bestX = gridX;
746
+ bestY = gridY;
747
+ }
748
+ }
749
+ }
750
+ }
751
+
752
+ // 可视化网格占用情况
753
+ const debugGrid = false; // 设置为true开启可视化,false关闭
754
+
755
+ if (debugGrid) {
756
+ // 创建一个单独的组用于网格可视化
757
+ const gridDebugGroup = g.append("g")
758
+ .attr("class", "grid-debug");
759
+
760
+ // 绘制所有网格单元
761
+ for (let gridY = 0; gridY < gridHeight; gridY++) {
762
+ for (let gridX = 0; gridX < gridWidth; gridX++) {
763
+ // 计算实际坐标
764
+ const x = gridX * gridSize;
765
+ const y = gridY * gridSize;
766
+
767
+ // 绘制网格单元
768
+ gridDebugGroup.append("rect")
769
+ .attr("x", x)
770
+ .attr("y", y)
771
+ .attr("width", gridSize)
772
+ .attr("height", gridSize)
773
+ .attr("fill", grid[gridY][gridX] ? "rgba(255, 0, 0, 0.2)" : "rgba(0, 255, 0, 0.05)")
774
+ .attr("stroke", "rgba(255, 255, 255, 0.1)")
775
+ .attr("stroke-width", 0.5);
776
+ }
777
+ }
778
+
779
+ // 标记最佳位置
780
+ if (bestX >= 0 && bestY >= 0) {
781
+ gridDebugGroup.append("rect")
782
+ .attr("x", bestX * gridSize)
783
+ .attr("y", bestY * gridSize)
784
+ .attr("width", gridSize)
785
+ .attr("height", gridSize)
786
+ .attr("fill", "rgba(0, 0, 255, 0.5)")
787
+ .attr("stroke", "white")
788
+ .attr("stroke-width", 1);
789
+ }
790
+
791
+ }
792
+
793
+ // 如果找到了合适的位置
794
+ if (bestX >= 0 && bestY >= 0) {
795
+ // 创建标签组 - 只用于图标和组名
796
+ const iconGroup = g.append("g")
797
+ .attr("transform", `translate(${bestX * gridSize}, ${bestY * gridSize})`);
798
+
799
+ // 添加图像图标
800
+ if (images && images.field[point.group]) {
801
+ iconGroup.append("image")
802
+ .attr("x", 0)
803
+ .attr("y", 0)
804
+ .attr("width", 40)
805
+ .attr("height", 40)
806
+ .attr("xlink:href", images.field[point.group]);
807
+ } else {
808
+ iconGroup.append("circle")
809
+ .attr("cx", 10)
810
+ .attr("cy", 10)
811
+ .attr("r", 5)
812
+ .attr("fill", point.color);
813
+ }
814
+
815
+ // 添加组名
816
+ iconGroup.append("text")
817
+ .attr("x", 40)
818
+ .attr("y", 25)
819
+ .attr("dominant-baseline", "middle")
820
+ .style("font-family", typography.label.font_family)
821
+ .style("font-size", "22px")
822
+ .style("font-weight", "bold")
823
+ .style("fill", "#f7f7fc")
824
+ .text(point.group);
825
+
826
+ // 创建常规标签组 - 只用于标签值
827
+ // 但位置在右侧,与其他标签一致
828
+ const valueGroup = g.append("g")
829
+ .attr("transform", `translate(${point.x + 15}, ${labelY})`);
830
+
831
+ // 只添加标签值
832
+ valueGroup.append("text")
833
+ .attr("x", 0)
834
+ .attr("y", -10)
835
+ .attr("dominant-baseline", "middle")
836
+ .style("font-family", typography.label.font_family)
837
+ .style("font-size", "24px")
838
+ .style("font-weight", "bold")
839
+ .style("fill", point.color)
840
+ .text(point.value);
841
+
842
+ // 跳过常规标签创建
843
+ return;
844
+ }
845
+ }
846
+
847
+ // 常规标签创建(非最高组或未找到合适位置的最高组)
848
+ // 创建标签组
849
+ const labelGroup = g.append("g")
850
+ .attr("transform", `translate(${point.x + 15}, ${labelY})`);
851
+
852
+ // 第一行:图像图标 + 组名(较小字体)
853
+ // 添加图像图标
854
+ if (images && images.field[point.group]) {
855
+ // 使用图像作为图标
856
+ labelGroup.append("image")
857
+ .attr("x", -10) // 调整位置,使图标居中
858
+ .attr("y", -10) // 调整位置,使图标居中
859
+ .attr("width", 20)
860
+ .attr("height", 20)
861
+ .attr("xlink:href", images.field[point.group]);
862
+ } else {
863
+ // 如果没有图像,使用彩色圆点作为备选
864
+ labelGroup.append("circle")
865
+ .attr("cx", 0)
866
+ .attr("cy", 0)
867
+ .attr("r", 3)
868
+ .attr("fill", point.color);
869
+ }
870
+
871
+ // 添加组名(较小字体)
872
+ labelGroup.append("text")
873
+ .attr("x", 15) // 增加与图标的距离
874
+ .attr("y", 0)
875
+ .attr("dominant-baseline", "middle")
876
+ .style("font-family", typography.label.font_family)
877
+ .style("font-size", "14px") // 较小字体
878
+ .style("font-weight", "normal") // 普通粗细
879
+ .style("fill", point.color) // 使用组的颜色
880
+ .text(point.group);
881
+
882
+ // 第二行:标签值(较大字体)
883
+ labelGroup.append("text")
884
+ .attr("x", 0)
885
+ .attr("y", 14) // 下移到第二行
886
+ .attr("dominant-baseline", "hanging")
887
+ .style("font-family", typography.label.font_family)
888
+ .style("font-size", "18px") // 较大字体
889
+ .style("font-weight", "bold") // 加粗
890
+ .style("fill", point.color) // 使用组的颜色
891
+ .text(point.value);
892
+ });
893
+ return svg.node();
894
+ }
modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_08.js ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Multiple Line Graph",
5
+ "chart_name": "multiple_line_graph_08",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[5, 30], [0, "inf"], [2, 7]],
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": "dark",
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_dark || {};
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 width = variables.width;
41
+ const height = variables.height;
42
+ const margin = { top: 40, right: 60, bottom: 60, left: 0 }; // 左侧边距设为0
43
+ const innerWidth = width - margin.left - margin.right;
44
+ const innerHeight = height - margin.top - margin.bottom;
45
+
46
+ // 获取字段名
47
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
48
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
49
+ const yColumn = chartUtils.schema.column(dataColumns, 1, { fallbackKey: yField });
50
+ const yLabel = yColumn.rawLabel || yField;
51
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
52
+
53
+ // 获取唯一的组值
54
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
55
+
56
+ // 创建SVG
57
+ const svg = d3.select(containerSelector)
58
+ .append("svg")
59
+ .attr("width", "100%")
60
+ .attr("height", height)
61
+ .attr("viewBox", `0 0 ${width} ${height}`)
62
+ .attr("xmlns", "http://www.w3.org/2000/svg")
63
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
64
+
65
+ // 创建图表区域
66
+ const g = svg.append("g")
67
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
68
+
69
+ const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, innerWidth);
70
+
71
+ // const yMin = Math.min(0, d3.min(chartData, d => +d[yField]) * 1.1);
72
+ // const yMax = d3.max(chartData, d => +d[yField]) * 1.1;
73
+ const yMin = d3.min(chartData, d => +d[yField]);
74
+ const yMax = d3.max(chartData, d => +d[yField]);
75
+ const yRange = yMax - yMin;
76
+ const yMinScale = yMin - yRange * 0.2;
77
+ const yMaxScale = yMax + yRange * 0.2;
78
+ const yScale = d3.scaleLinear()
79
+ .domain([yMinScale, yMaxScale])
80
+ .range([innerHeight, 0]);
81
+
82
+ // 使用数据JSON中的颜色
83
+ // 获取颜色
84
+ const getColor = (group) => {
85
+ return colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
86
+ };
87
+
88
+ // 创建线条生成器 - 改为折线
89
+ const line = d3.line()
90
+ .x(d => xScale(chartUtils.format.parseDate(d[xField])))
91
+ .y(d => yScale(+d[yField]))
92
+ .curve(d3.curveLinear); // 使用折线而不是曲线
93
+
94
+ // 按组分组数据
95
+ const groupedData = groups.map(group => {
96
+ return {
97
+ group: group,
98
+ values: chartData.filter(d => d[groupField] === group),
99
+ color: getColor(group) // 获取该组的颜色
100
+ };
101
+ });
102
+
103
+ // 绘制线条
104
+ groupedData.forEach(gd => {
105
+ // 添加阴影效果
106
+ // 首先创建一个滤镜
107
+ const filterId = `shadow-${gd.group.replace(/\s+/g, '-')}`;
108
+ const filter = svg.append("defs")
109
+ .append("filter")
110
+ .attr("id", filterId)
111
+ .attr("x", "-20%")
112
+ .attr("y", "-20%")
113
+ .attr("width", "140%")
114
+ .attr("height", "140%");
115
+
116
+ // 添加高斯模糊
117
+ filter.append("feGaussianBlur")
118
+ .attr("in", "SourceAlpha")
119
+ .attr("stdDeviation", 1)
120
+ .attr("result", "blur");
121
+
122
+ // 添加偏移
123
+ filter.append("feOffset")
124
+ .attr("in", "blur")
125
+ .attr("dx", 0)
126
+ .attr("dy", 1)
127
+ .attr("result", "offsetBlur");
128
+
129
+ // 添加颜色 - 改为黑色
130
+ filter.append("feFlood")
131
+ .attr("flood-color", "black") // 使用黑色而不是线条颜色
132
+ .attr("flood-opacity", 0.3)
133
+ .attr("result", "coloredBlur");
134
+
135
+ // 将颜色应用到偏移的模糊上
136
+ filter.append("feComposite")
137
+ .attr("in", "coloredBlur")
138
+ .attr("in2", "offsetBlur")
139
+ .attr("operator", "in")
140
+ .attr("result", "coloredBlurOffset");
141
+
142
+ // 合并原始图形和阴影
143
+ const feMerge = filter.append("feMerge");
144
+ feMerge.append("feMergeNode")
145
+ .attr("in", "coloredBlurOffset");
146
+ feMerge.append("feMergeNode")
147
+ .attr("in", "SourceGraphic");
148
+
149
+ // 绘制线条 - 添加阴影
150
+ g.append("path")
151
+ .datum(gd.values)
152
+ .attr("fill", "none")
153
+ .attr("stroke", gd.color)
154
+ .attr("stroke-width", 3)
155
+ .attr("d", line)
156
+ .attr("filter", `url(#${filterId})`);
157
+
158
+ // 获取最后一个数据点
159
+ const lastPoint = gd.values[gd.values.length - 1];
160
+ const x = xScale(chartUtils.format.parseDate(lastPoint[xField]));
161
+ const y = yScale(+lastPoint[yField]);
162
+
163
+ const valueText = chartUtils.format.fixed(+lastPoint[yField]).text;
164
+ const valueTextWidth = chartUtils.text.measure(null, valueText, { fontSize: "12px" }).width;
165
+
166
+ // 添加圆角矩形作为结束标记
167
+ const rectWidth = valueTextWidth + 10;
168
+ const rectHeight = 12;
169
+ const rectX = x - 6;
170
+ const rectY = y - rectHeight / 2;
171
+
172
+ // 添加圆角矩形背景
173
+ g.append("rect")
174
+ .attr("x", rectX)
175
+ .attr("y", rectY)
176
+ .attr("width", rectWidth)
177
+ .attr("height", rectHeight)
178
+ .attr("rx", 6)
179
+ .attr("ry", 6)
180
+ .attr("fill", gd.color); // 使用获取的颜色
181
+
182
+ // 添加数值文本(在矩形内)
183
+ g.append("text")
184
+ .attr("x", rectX + rectWidth / 2)
185
+ .attr("y", rectY + rectHeight / 2 + 1)
186
+ .attr("text-anchor", "middle")
187
+ .attr("dominant-baseline", "middle")
188
+ .style("font-family", "Arial")
189
+ .style("font-size", "12px")
190
+ .style("font-weight", "bold")
191
+ .style("fill", "white")
192
+ .text(chartUtils.format.fixed(+lastPoint[yField]).text);
193
+
194
+ // 添加国家名称
195
+ g.append("text")
196
+ .attr("x", rectX + rectWidth + 10)
197
+ .attr("y", rectY + rectHeight / 2)
198
+ .attr("text-anchor", "start")
199
+ .attr("dominant-baseline", "middle")
200
+ .style("font-family", "Arial")
201
+ .style("font-size", "14px")
202
+ .style("font-weight", "bold")
203
+ .style("fill", gd.color)
204
+ .text(gd.group);
205
+ });
206
+
207
+ // 找到结束时最高的数据点
208
+ let highestEndGroup = null;
209
+ let highestEndValue = -Infinity;
210
+ let highestEndX = 0;
211
+ let highestEndY = 0;
212
+ let highestEndRect = null;
213
+
214
+ groupedData.forEach(gd => {
215
+ const lastPoint = gd.values[gd.values.length - 1];
216
+ const value = lastPoint[yField];
217
+
218
+ if (value > highestEndValue) {
219
+ highestEndValue = value;
220
+ highestEndGroup = gd.group;
221
+ highestEndX = xScale(chartUtils.format.parseDate(lastPoint[xField]));
222
+ highestEndY = yScale(value);
223
+
224
+ // 保存矩形位置信息,用于后续添加标签
225
+ highestEndRect = {
226
+ x: highestEndX - 6,
227
+ y: highestEndY - 6,
228
+ width: 40,
229
+ height: 12
230
+ };
231
+ }
232
+ });
233
+
234
+ // 如果找到了最高点,添加Y轴encoding名称和指向三角形
235
+ if (highestEndRect) {
236
+ // 获取Y轴字段的显示名称
237
+ const yFieldName = chartUtils.schema.channelByKey(jsonData, yField, { fallbackKey: yField }).displayName || yField;
238
+
239
+ // 判断最高点的趋势
240
+ const highestGroup = groupedData.find(gd => gd.group === highestEndGroup);
241
+ const lastIndex = highestGroup.values.length - 1;
242
+ const lastValue = highestGroup.values[lastIndex][yField];
243
+ const prevValue = highestGroup.values[lastIndex - 1][yField];
244
+ const isIncreasing = lastValue > prevValue;
245
+
246
+ // 根据趋势决定标签位置
247
+ const labelBaseY = highestEndRect.y - 20
248
+
249
+ // Y轴encoding名称的位置
250
+ const encodingLabelY = labelBaseY;
251
+
252
+ // 添加Y轴encoding名称
253
+ g.append("text")
254
+ .attr("x", highestEndRect.x + highestEndRect.width / 2)
255
+ .attr("y", encodingLabelY)
256
+ .attr("text-anchor", "middle")
257
+ .attr("dominant-baseline", "middle")
258
+ .style("font-family", "Arial")
259
+ .style("font-size", "12px")
260
+ .style("font-weight", "bold")
261
+ .style("fill", "white")
262
+ .text(yLabel);
263
+
264
+ // 添加指向三角形
265
+ const triangleSize = 6;
266
+ const triangleX = highestEndRect.x + highestEndRect.width / 2;
267
+ const triangleY = encodingLabelY + 15;
268
+
269
+ g.append("path")
270
+ .attr("d", `M${triangleX},${triangleY} L${triangleX + triangleSize},${triangleY - triangleSize} L${triangleX - triangleSize},${triangleY - triangleSize} Z`)
271
+ .attr("fill", "white");
272
+ }
273
+
274
+ // 添加X轴 - 放在中间位置
275
+ const xAxisY = innerHeight / 2; // 将X轴放在中间
276
+
277
+ // 添加X轴线 - 使用点点虚线,延伸到整个SVG两侧
278
+ g.append("line")
279
+ .attr("x1", -margin.left) // 延伸到左侧边缘
280
+ .attr("y1", xAxisY)
281
+ .attr("x2", innerWidth + margin.right) // 延伸到右侧边缘
282
+ .attr("y2", xAxisY)
283
+ .attr("stroke", "#666")
284
+ .attr("stroke-width", 1)
285
+ .attr("opacity", 0.5)
286
+ .attr("stroke-dasharray", "1,1");
287
+
288
+ // 添加刻度和标签
289
+ xTicks.forEach(tick => {
290
+ const x = xScale(tick);
291
+
292
+ // 添加圆角矩形背景
293
+ g.append("rect")
294
+ .attr("x", x - 15)
295
+ .attr("y", xAxisY - 6)
296
+ .attr("width", 30)
297
+ .attr("height", 12)
298
+ .attr("rx", 6)
299
+ .attr("ry", 6)
300
+ .attr("fill", "#fde7e5");
301
+
302
+ // 添加年份文本
303
+ g.append("text")
304
+ .attr("x", x)
305
+ .attr("y", xAxisY + 4)
306
+ .attr("text-anchor", "middle")
307
+ .style("font-family", "Arial")
308
+ .style("font-size", "11px")
309
+ .style("font-weight", "bold")
310
+ .style("fill", "#333")
311
+ .text(xFormat(tick));
312
+ });
313
+
314
+ // 添加水平网格线 - 实线,延伸到整个SVG两侧
315
+ g.selectAll("grid-line")
316
+ .data(yScale.ticks(5))
317
+ .enter()
318
+ .append("line")
319
+ .attr("x1", -margin.left) // 延伸到左侧边缘
320
+ .attr("y1", d => yScale(d))
321
+ .attr("x2", innerWidth + margin.right) // 延伸到右侧边缘
322
+ .attr("y2", d => yScale(d))
323
+ .attr("stroke", "rgba(255, 255, 255, 0.2)")
324
+ .attr("stroke-width", 1)
325
+ .attr("stroke-dasharray", "none")
326
+ .attr("class", "background"); // 使用实线
327
+
328
+ // 添加Y轴刻度值 - 调整位置
329
+ g.selectAll(".y-tick")
330
+ .data(yScale.ticks(5))
331
+ .enter()
332
+ .append("text")
333
+ .attr("x", 10) // 正值,使文本位于图表内部
334
+ .attr("y", d => yScale(d) + 15)
335
+ .attr("text-anchor", "start") // 改为左对齐
336
+ .attr("dominant-baseline", "middle")
337
+ .style("font-family", "Arial")
338
+ .style("font-size", "12px")
339
+ .style("fill", "white")
340
+ .text(d => d);
341
+
342
+ return svg.node();
343
+ }
modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_09.js ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Multiple Line Graph",
5
+ "chart_name": "multiple_line_graph_09",
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/line_graph/multiple_line_graph_10.js ADDED
@@ -0,0 +1,563 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Multiple Line Graph",
5
+ "chart_name": "multiple_line_graph_10",
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
+ const legendSize = chartUtils.legend.draw(g, groups, {
269
+ fontSize: 14,
270
+ fontWeight: "bold",
271
+ maxWidth: chartWidth,
272
+ markerShape: "line",
273
+ markerSize: 10,
274
+ strokeWidth: 4,
275
+ colorScale,
276
+ });
277
+
278
+ const maxYTickPosition = yScale(yTicks[yTicks.length - 1]);
279
+
280
+ legendSize.group.attr("transform", `translate(${(chartWidth - legendSize.width) / 2}, ${maxYTickPosition - 50 - legendSize.height/2})`);
281
+
282
+ return svg.node();
283
+ }
284
+
285
+ // 动态规划标签放置算法 - 添加g参数
286
+ function placeLabelsDP(points, avoidYPositions = [], debugName = "", chartHeight, chartWidth, g) {
287
+ // 每个格点的高度(像素)
288
+ const GRID_SIZE = 3;
289
+ // 圆点周围的保护区域(格点数)
290
+ const PROTECTION_RADIUS = 3;
291
+ // 标签高度(格点数)
292
+ const LABEL_HEIGHT = 10;
293
+
294
+ // 离散化Y坐标,创建格点系统
295
+ const minY = 0;
296
+ const maxY = chartHeight; // 使用传入的chartHeight
297
+ const gridCount = Math.ceil((maxY - minY) / GRID_SIZE);
298
+
299
+ // 按照Y坐标排序点
300
+ points.sort((a, b) => a.y - b.y);
301
+
302
+ // 创建格点占用标记
303
+ const occupied = new Array(gridCount).fill(false);
304
+ // 存储占用原因,用于可视化
305
+ const occupiedReason = new Array(gridCount).fill("");
306
+
307
+ // 标记圆点周围的保护区域为已占用
308
+ points.forEach((point, idx) => {
309
+ const gridY = Math.floor(point.y / GRID_SIZE) - 3;
310
+ for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
311
+ occupied[i] = true;
312
+ occupiedReason[i] = `数据点${idx + 1}保护区`;
313
+ }
314
+ });
315
+
316
+
317
+ // 定义状态: dp[i][j] 表示前i个点,第i个点的标签放在第j个格点时的最小代价
318
+ const n = points.length;
319
+ const dp = Array(n).fill().map(() => Array(gridCount).fill(Infinity));
320
+ const prev = Array(n).fill().map(() => Array(gridCount).fill(-1));
321
+
322
+ // 初始条件:第一个点的标签放置
323
+ const firstPointGridY = Math.floor(points[0].y / GRID_SIZE);
324
+
325
+ // 尝试将第一个点的标签放在可行的位置
326
+ for (let j = 0; j < gridCount; j++) {
327
+ // 检查是否可行(不在保护区域内且有足够空间放置标签)
328
+ if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
329
+ // 检查标签是否会与其他标签重叠
330
+ let canPlace = true;
331
+ for (let k = 0; k < LABEL_HEIGHT; k++) {
332
+ if (j + k < gridCount && occupied[j + k]) {
333
+ canPlace = false;
334
+ break;
335
+ }
336
+ }
337
+
338
+ if (canPlace) {
339
+ // 计算代价:与圆点位置的距离
340
+ const cost = Math.abs(j - firstPointGridY);
341
+ dp[0][j] = cost;
342
+ }
343
+ }
344
+ }
345
+
346
+ // 填充dp表
347
+ for (let i = 1; i < n; i++) {
348
+ const pointGridY = Math.floor(points[i].y / GRID_SIZE);
349
+
350
+ for (let j = 0; j < gridCount; j++) {
351
+ // 检查当前位置是否可行
352
+ if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
353
+ // 检查标签是否会与其他标签重叠
354
+ let canPlace = true;
355
+ for (let k = 0; k < LABEL_HEIGHT; k++) {
356
+ if (j + k < gridCount && occupied[j + k]) {
357
+ canPlace = false;
358
+ break;
359
+ }
360
+ }
361
+
362
+ if (canPlace) {
363
+ // 根据上一个点的标签位置计算当前最小代价
364
+ for (let k = 0; k + LABEL_HEIGHT <= j; k++) {
365
+ if (dp[i-1][k] !== Infinity) {
366
+
367
+ // 当前标签的代价
368
+ const curCost = Math.abs(j - pointGridY);
369
+ const totalCost = dp[i-1][k] + curCost;
370
+
371
+ if (totalCost < dp[i][j]) {
372
+ dp[i][j] = totalCost;
373
+ prev[i][j] = k; // 记录前驱
374
+ }
375
+ }
376
+ }
377
+ }
378
+ }
379
+ }
380
+ }
381
+
382
+ // 找出最后一个点的最优放置位置
383
+ let minCost = Infinity;
384
+ let bestPos = -1;
385
+
386
+ for (let j = 0; j < gridCount; j++) {
387
+ if (dp[n-1][j] < minCost) {
388
+ minCost = dp[n-1][j];
389
+ bestPos = j;
390
+ }
391
+ }
392
+
393
+ // 存储最终选择的标签位置(格点索引)
394
+ const selectedGridPositions = [];
395
+
396
+ // 回溯构建结果
397
+ const labelPositions = [];
398
+ if (bestPos !== -1) {
399
+ // 从后向前回溯
400
+ let pos = bestPos;
401
+
402
+ for (let i = n - 1; i >= 0; i--) {
403
+ labelPositions.unshift({
404
+ point: points[i],
405
+ labelY: pos * GRID_SIZE
406
+ });
407
+
408
+ // 记录选择的位置
409
+ selectedGridPositions.unshift(pos);
410
+
411
+ pos = prev[i][pos];
412
+ }
413
+ } else {
414
+ // 无可行解,退化为简单方案处理
415
+ let lastY = 0;
416
+ for (let i = 0; i < n; i++) {
417
+ const point = points[i];
418
+ // 确保标签不超过下一个圆点的位置
419
+ let maxY = chartHeight;
420
+ if (i < n - 1) {
421
+ maxY = points[i+1].y;
422
+ }
423
+
424
+ const labelY = Math.min(Math.max(point.y + 20, lastY + 25), maxY - 5);
425
+
426
+ labelPositions.push({
427
+ point: point,
428
+ labelY: labelY
429
+ });
430
+
431
+ lastY = labelY;
432
+ }
433
+ }
434
+
435
+ const debug = false;
436
+
437
+ // 添加格子系统可视化
438
+ if (debug && points.length > 0) {
439
+ // 确定可视化的位置 - 使用数据点的X位置
440
+ const mainPointX = points.length > 0 ? points[0].x : 0; // 使用该组第一个点的X位置
441
+ const visX = mainPointX;
442
+ const visY = 10; // 从图表顶部开始
443
+ const visWidth = 50; // 减小宽度以免干扰图表
444
+
445
+ // 创建一个组用于添加可视化元素,并在需要时应用剪切
446
+ const visGroup = g.append("g");
447
+
448
+ // 限制可视化高度,避免太长
449
+ const maxVisHeight = chartHeight - 20;
450
+ const visibleGridCount = Math.min(gridCount, Math.floor(maxVisHeight / GRID_SIZE));
451
+
452
+ // 添加半透明背景
453
+ visGroup.append("rect")
454
+ .attr("x", visX - 5)
455
+ .attr("y", visY - 15)
456
+ .attr("width", visWidth + 10)
457
+ .attr("height", visibleGridCount * GRID_SIZE + 50)
458
+ .attr("fill", "#fff")
459
+ .attr("opacity", 0.8)
460
+ .attr("rx", 5);
461
+
462
+ // 添加标题
463
+ visGroup.append("text")
464
+ .attr("x", visX + visWidth/2)
465
+ .attr("y", visY - 5)
466
+ .attr("text-anchor", "middle")
467
+ .attr("fill", "#333")
468
+ .style("font-size", "10px")
469
+ .text(debugName + "标签位置");
470
+
471
+ // 绘制每个格子
472
+ for (let i = 0; i < visibleGridCount; i++) {
473
+ // 计算格子的颜色
474
+ let fillColor = "#ddd"; // 默认颜色 - 可用格子
475
+
476
+ if (occupied[i]) {
477
+ fillColor = "#ffcccc"; // 不可用格子 - 红色
478
+ }
479
+
480
+ // 标记最终选择的位置
481
+ const isSelected = selectedGridPositions.some(pos => {
482
+ // 检查格子是否在标签范围内
483
+ for (let j = 0; j < LABEL_HEIGHT; j++) {
484
+ if (pos + j === i) return true;
485
+ }
486
+ return false;
487
+ });
488
+
489
+ if (isSelected) {
490
+ fillColor = "#99ff99"; // 选中的格子 - 绿色
491
+ }
492
+
493
+ // 绘制格子
494
+ visGroup.append("rect")
495
+ .attr("x", visX)
496
+ .attr("y", visY + i * GRID_SIZE)
497
+ .attr("width", visWidth)
498
+ .attr("height", GRID_SIZE)
499
+ .attr("fill", fillColor)
500
+ .attr("stroke", "#ccc")
501
+ .attr("stroke-width", 0.2);
502
+ }
503
+
504
+ // 添加数据点位置标记 - 只显示该组的点
505
+ points.forEach((point, idx) => {
506
+ const gridY = Math.floor(point.y / GRID_SIZE);
507
+
508
+ // 仅当点位置在可视范围内时添加
509
+ if (gridY < visibleGridCount) {
510
+ // 绘制数据点位置线
511
+ visGroup.append("line")
512
+ .attr("x1", visX - 3)
513
+ .attr("y1", visY + gridY * GRID_SIZE + GRID_SIZE/2)
514
+ .attr("x2", visX + visWidth)
515
+ .attr("y2", visY + gridY * GRID_SIZE + GRID_SIZE/2)
516
+ .attr("stroke", point.color)
517
+ .attr("stroke-width", 1)
518
+ .attr("stroke-dasharray", "2,1");
519
+
520
+ // 添加简短的数据点标识
521
+ visGroup.append("text")
522
+ .attr("x", visX - 5)
523
+ .attr("y", visY + gridY * GRID_SIZE + GRID_SIZE/2)
524
+ .attr("text-anchor", "end")
525
+ .attr("dominant-baseline", "middle")
526
+ .attr("fill", point.color)
527
+ .style("font-size", "8px")
528
+ .text("点" + (idx + 1));
529
+ }
530
+ });
531
+
532
+ // 在底部添加简化的图例
533
+ const legendY = visY + visibleGridCount * GRID_SIZE + 5;
534
+ const legendItems = [
535
+ { label: "可用", color: "#ddd" },
536
+ { label: "占用", color: "#ffcccc" },
537
+ { label: "选中", color: "#99ff99" }
538
+ ];
539
+
540
+ legendItems.forEach((item, idx) => {
541
+ // 水平放置图例项
542
+ const itemX = visX + idx * (visWidth / 3);
543
+
544
+ visGroup.append("rect")
545
+ .attr("x", itemX)
546
+ .attr("y", legendY)
547
+ .attr("width", 6)
548
+ .attr("height", 6)
549
+ .attr("fill", item.color);
550
+
551
+ visGroup.append("text")
552
+ .attr("x", itemX + 8)
553
+ .attr("y", legendY + 3)
554
+ .attr("text-anchor", "start")
555
+ .attr("dominant-baseline", "middle")
556
+ .attr("fill", "#333")
557
+ .style("font-size", "6px")
558
+ .text(item.label);
559
+ });
560
+ }
561
+
562
+ return labelPositions;
563
+ }
modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_11.js ADDED
@@ -0,0 +1,563 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Multiple Line Graph",
5
+ "chart_name": "multiple_line_graph_11",
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.max(0, yMin - yPadding); // 确保下限不小于0
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
+ const legendSize = chartUtils.legend.draw(g, groups, {
269
+ fontSize: 14,
270
+ fontWeight: "bold",
271
+ maxWidth: chartWidth,
272
+ markerShape: "line",
273
+ markerSize: 10,
274
+ strokeWidth: 4,
275
+ colorScale,
276
+ });
277
+
278
+ const maxYTickPosition = yScale(yTicks[yTicks.length - 1]);
279
+
280
+ legendSize.group.attr("transform", `translate(${(chartWidth - legendSize.width) / 2}, ${maxYTickPosition - 50 - legendSize.height/2})`);
281
+
282
+ return svg.node();
283
+ }
284
+
285
+ // 动态规划标签放置算法 - 添加g参数
286
+ function placeLabelsDP(points, avoidYPositions = [], debugName = "", chartHeight, chartWidth, g) {
287
+ // 每个格点的高度(像素)
288
+ const GRID_SIZE = 3;
289
+ // 圆点周围的保护区域(格点数)
290
+ const PROTECTION_RADIUS = 3;
291
+ // 标签高度(格点数)
292
+ const LABEL_HEIGHT = 10;
293
+
294
+ // 离散化Y坐标,创建格点系统
295
+ const minY = 0;
296
+ const maxY = chartHeight; // 使用传入的chartHeight
297
+ const gridCount = Math.ceil((maxY - minY) / GRID_SIZE);
298
+
299
+ // 按照Y坐标排序点
300
+ points.sort((a, b) => a.y - b.y);
301
+
302
+ // 创建格点占用标记
303
+ const occupied = new Array(gridCount).fill(false);
304
+ // 存储占用原因,用于可视化
305
+ const occupiedReason = new Array(gridCount).fill("");
306
+
307
+ // 标记圆点周围的保护区域为已占用
308
+ points.forEach((point, idx) => {
309
+ const gridY = Math.floor(point.y / GRID_SIZE) - 3;
310
+ for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
311
+ occupied[i] = true;
312
+ occupiedReason[i] = `数据点${idx + 1}保护区`;
313
+ }
314
+ });
315
+
316
+
317
+ // 定义状态: dp[i][j] 表示前i个点,第i个点的标签放在第j个格点时的最小代价
318
+ const n = points.length;
319
+ const dp = Array(n).fill().map(() => Array(gridCount).fill(Infinity));
320
+ const prev = Array(n).fill().map(() => Array(gridCount).fill(-1));
321
+
322
+ // 初始条件:第一个点的标签放置
323
+ const firstPointGridY = Math.floor(points[0].y / GRID_SIZE);
324
+
325
+ // 尝试将第一个点的标签放在可行的位置
326
+ for (let j = 0; j < gridCount; j++) {
327
+ // 检查是否可行(不在保护区域内且有足够空间放置标签)
328
+ if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
329
+ // 检查标签是否会与其他标签重叠
330
+ let canPlace = true;
331
+ for (let k = 0; k < LABEL_HEIGHT; k++) {
332
+ if (j + k < gridCount && occupied[j + k]) {
333
+ canPlace = false;
334
+ break;
335
+ }
336
+ }
337
+
338
+ if (canPlace) {
339
+ // 计算代价:与圆点位置的距离
340
+ const cost = Math.abs(j - firstPointGridY);
341
+ dp[0][j] = cost;
342
+ }
343
+ }
344
+ }
345
+
346
+ // 填充dp表
347
+ for (let i = 1; i < n; i++) {
348
+ const pointGridY = Math.floor(points[i].y / GRID_SIZE);
349
+
350
+ for (let j = 0; j < gridCount; j++) {
351
+ // 检查当前位置是否可行
352
+ if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
353
+ // 检查标签是否会与其他标签重叠
354
+ let canPlace = true;
355
+ for (let k = 0; k < LABEL_HEIGHT; k++) {
356
+ if (j + k < gridCount && occupied[j + k]) {
357
+ canPlace = false;
358
+ break;
359
+ }
360
+ }
361
+
362
+ if (canPlace) {
363
+ // 根据上一个点的标签位置计算当前最小代价
364
+ for (let k = 0; k + LABEL_HEIGHT <= j; k++) {
365
+ if (dp[i-1][k] !== Infinity) {
366
+
367
+ // 当前标签的代价
368
+ const curCost = Math.abs(j - pointGridY);
369
+ const totalCost = dp[i-1][k] + curCost;
370
+
371
+ if (totalCost < dp[i][j]) {
372
+ dp[i][j] = totalCost;
373
+ prev[i][j] = k; // 记录前驱
374
+ }
375
+ }
376
+ }
377
+ }
378
+ }
379
+ }
380
+ }
381
+
382
+ // 找出最后一个点的最优放置位置
383
+ let minCost = Infinity;
384
+ let bestPos = -1;
385
+
386
+ for (let j = 0; j < gridCount; j++) {
387
+ if (dp[n-1][j] < minCost) {
388
+ minCost = dp[n-1][j];
389
+ bestPos = j;
390
+ }
391
+ }
392
+
393
+ // 存储最终选择的标签位置(格点索引)
394
+ const selectedGridPositions = [];
395
+
396
+ // 回溯构建结果
397
+ const labelPositions = [];
398
+ if (bestPos !== -1) {
399
+ // 从后向前回溯
400
+ let pos = bestPos;
401
+
402
+ for (let i = n - 1; i >= 0; i--) {
403
+ labelPositions.unshift({
404
+ point: points[i],
405
+ labelY: pos * GRID_SIZE
406
+ });
407
+
408
+ // 记录选择的位置
409
+ selectedGridPositions.unshift(pos);
410
+
411
+ pos = prev[i][pos];
412
+ }
413
+ } else {
414
+ // 无可行解,退化为简单方案处理
415
+ let lastY = 0;
416
+ for (let i = 0; i < n; i++) {
417
+ const point = points[i];
418
+ // 确保标签���超过下一个圆点的位置
419
+ let maxY = chartHeight;
420
+ if (i < n - 1) {
421
+ maxY = points[i+1].y;
422
+ }
423
+
424
+ const labelY = Math.min(Math.max(point.y + 20, lastY + 25), maxY - 5);
425
+
426
+ labelPositions.push({
427
+ point: point,
428
+ labelY: labelY
429
+ });
430
+
431
+ lastY = labelY;
432
+ }
433
+ }
434
+
435
+ const debug = false;
436
+
437
+ // 添加格子系统可视化
438
+ if (debug && points.length > 0) {
439
+ // 确定可视化的位置 - 使用数据点的X位置
440
+ const mainPointX = points.length > 0 ? points[0].x : 0; // 使用该组第一个点的X位置
441
+ const visX = mainPointX;
442
+ const visY = 10; // 从图表顶部开始
443
+ const visWidth = 50; // 减小宽度以免干扰图表
444
+
445
+ // 创建一个组用于添加可视化元素,并在需要时应用剪切
446
+ const visGroup = g.append("g");
447
+
448
+ // 限制可视化高度,避免太长
449
+ const maxVisHeight = chartHeight - 20;
450
+ const visibleGridCount = Math.min(gridCount, Math.floor(maxVisHeight / GRID_SIZE));
451
+
452
+ // 添加半透明背景
453
+ visGroup.append("rect")
454
+ .attr("x", visX - 5)
455
+ .attr("y", visY - 15)
456
+ .attr("width", visWidth + 10)
457
+ .attr("height", visibleGridCount * GRID_SIZE + 50)
458
+ .attr("fill", "#fff")
459
+ .attr("opacity", 0.8)
460
+ .attr("rx", 5);
461
+
462
+ // 添加标题
463
+ visGroup.append("text")
464
+ .attr("x", visX + visWidth/2)
465
+ .attr("y", visY - 5)
466
+ .attr("text-anchor", "middle")
467
+ .attr("fill", "#333")
468
+ .style("font-size", "10px")
469
+ .text(debugName + "标签位置");
470
+
471
+ // 绘制每个格子
472
+ for (let i = 0; i < visibleGridCount; i++) {
473
+ // 计算格子的颜色
474
+ let fillColor = "#ddd"; // 默认颜色 - 可用格子
475
+
476
+ if (occupied[i]) {
477
+ fillColor = "#ffcccc"; // 不可用格子 - 红色
478
+ }
479
+
480
+ // 标记最终选择的位置
481
+ const isSelected = selectedGridPositions.some(pos => {
482
+ // 检查格子是否在标签范围内
483
+ for (let j = 0; j < LABEL_HEIGHT; j++) {
484
+ if (pos + j === i) return true;
485
+ }
486
+ return false;
487
+ });
488
+
489
+ if (isSelected) {
490
+ fillColor = "#99ff99"; // 选中的格子 - 绿色
491
+ }
492
+
493
+ // 绘制格子
494
+ visGroup.append("rect")
495
+ .attr("x", visX)
496
+ .attr("y", visY + i * GRID_SIZE)
497
+ .attr("width", visWidth)
498
+ .attr("height", GRID_SIZE)
499
+ .attr("fill", fillColor)
500
+ .attr("stroke", "#ccc")
501
+ .attr("stroke-width", 0.2);
502
+ }
503
+
504
+ // 添加数据点位置标记 - 只显示该组的点
505
+ points.forEach((point, idx) => {
506
+ const gridY = Math.floor(point.y / GRID_SIZE);
507
+
508
+ // 仅当点位置在可视范围内时添加
509
+ if (gridY < visibleGridCount) {
510
+ // 绘制数据点位置线
511
+ visGroup.append("line")
512
+ .attr("x1", visX - 3)
513
+ .attr("y1", visY + gridY * GRID_SIZE + GRID_SIZE/2)
514
+ .attr("x2", visX + visWidth)
515
+ .attr("y2", visY + gridY * GRID_SIZE + GRID_SIZE/2)
516
+ .attr("stroke", point.color)
517
+ .attr("stroke-width", 1)
518
+ .attr("stroke-dasharray", "2,1");
519
+
520
+ // 添加简短的数据点标识
521
+ visGroup.append("text")
522
+ .attr("x", visX - 5)
523
+ .attr("y", visY + gridY * GRID_SIZE + GRID_SIZE/2)
524
+ .attr("text-anchor", "end")
525
+ .attr("dominant-baseline", "middle")
526
+ .attr("fill", point.color)
527
+ .style("font-size", "8px")
528
+ .text("点" + (idx + 1));
529
+ }
530
+ });
531
+
532
+ // 在底部添加简化的图例
533
+ const legendY = visY + visibleGridCount * GRID_SIZE + 5;
534
+ const legendItems = [
535
+ { label: "可用", color: "#ddd" },
536
+ { label: "占用", color: "#ffcccc" },
537
+ { label: "选中", color: "#99ff99" }
538
+ ];
539
+
540
+ legendItems.forEach((item, idx) => {
541
+ // 水平放置图例项
542
+ const itemX = visX + idx * (visWidth / 3);
543
+
544
+ visGroup.append("rect")
545
+ .attr("x", itemX)
546
+ .attr("y", legendY)
547
+ .attr("width", 6)
548
+ .attr("height", 6)
549
+ .attr("fill", item.color);
550
+
551
+ visGroup.append("text")
552
+ .attr("x", itemX + 8)
553
+ .attr("y", legendY + 3)
554
+ .attr("text-anchor", "start")
555
+ .attr("dominant-baseline", "middle")
556
+ .attr("fill", "#333")
557
+ .style("font-size", "6px")
558
+ .text(item.label);
559
+ });
560
+ }
561
+
562
+ return labelPositions;
563
+ }
modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_12.js ADDED
@@ -0,0 +1,466 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Multiple Line Graph",
5
+ "chart_name": "multiple_line_graph_12",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[5, 30], [0, "inf"], [2, 2]],
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
+ "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: 100, right: 30, bottom: 100, 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
+ // 按组分组数据
75
+ const groupedData = d3.group(chartData, d => d[groupField]);
76
+
77
+ // 计算每个组的平均值,找出最高和最低的组
78
+ const groupAverages = new Map();
79
+
80
+ groupedData.forEach((values, group) => {
81
+ const sum = values.reduce((acc, d) => acc + +d[yField], 0);
82
+ const avg = sum / values.length;
83
+ groupAverages.set(group, avg);
84
+ });
85
+
86
+ // 找出平均值最高和最低的组
87
+ let highestGroup = null;
88
+ let lowestGroup = null;
89
+ let highestAvg = -Infinity;
90
+ let lowestAvg = Infinity;
91
+
92
+ groupAverages.forEach((avg, group) => {
93
+ if (avg > highestAvg) {
94
+ highestAvg = avg;
95
+ highestGroup = group;
96
+ }
97
+ if (avg < lowestAvg) {
98
+ lowestAvg = avg;
99
+ lowestGroup = group;
100
+ }
101
+ });
102
+
103
+ // 只保留最高和最低的两个组
104
+ const selectedGroups = [highestGroup, lowestGroup];
105
+
106
+ const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, chartWidth);
107
+
108
+ // 创建y轴比例尺 - 使用数据的实际范围
109
+ const yMin = d3.min(chartData, d => +d[yField]);
110
+ const yMax = d3.max(chartData, d => +d[yField]);
111
+
112
+ // 为了美观,稍微扩展Y轴范围
113
+ const yPadding = (yMax - yMin) * 0.3;
114
+ const yDomainMax = yMax + yPadding;
115
+ const yDomainMin = Math.max(0, yMin - yPadding); // 确保下限不小于0
116
+
117
+ const yScale = d3.scaleLinear()
118
+ .domain([yDomainMin, yDomainMax])
119
+ .range([chartHeight, 0]);
120
+
121
+ // 创建颜色比例尺
122
+ const colorScale = colorResolver.scale(groups, { palette: "category10" });
123
+
124
+ // 获取实际的Y轴刻度 - 减少刻度数量
125
+ const yTicks = yScale.ticks(5); // 保持5个刻度
126
+ const maxYTick = yTicks[yTicks.length - 1]; // 最大的Y轴刻度值
127
+
128
+ // 计算最大Y刻度的位置
129
+ const maxYTickPosition = yScale(maxYTick);
130
+
131
+ // 添加图例 - 整体居中,放在最大Y轴刻度上方
132
+ const legendY = maxYTickPosition - 60; // 最大Y轴刻度上方20像素
133
+
134
+ // 计算比值圆形的Y位置
135
+ const ratioCircleY = maxYTickPosition - 30;
136
+
137
+ // 添加条纹背景 - 使用更合适的时间间隔
138
+
139
+ // 为每个X轴刻度创建条纹背景,使条纹以刻度为中心
140
+ // 条纹背景要覆盖到圆形区域
141
+ for (let i = 0; i < xTicks.length - 1; i++) {
142
+ // 获取相邻两个刻度
143
+ const currentTick = xTicks[i];
144
+ const nextTick = xTicks[i + 1];
145
+
146
+ // 计算当前���度和下一个刻度的位置
147
+ const x1 = xScale(currentTick);
148
+ const x2 = xScale(nextTick);
149
+
150
+ // 每隔一个刻度添加浅色背景
151
+ if (i % 2 === 0) {
152
+ g.append("rect")
153
+ .attr("x", x1)
154
+ .attr("y", legendY + 10) // 从legend下方20像素开始
155
+ .attr("width", x2 - x1)
156
+ .attr("height", chartHeight + xAxisTextHeight - (legendY + 10)) // 高度需要减去legend的位置
157
+ .attr("fill", "#ececec")
158
+ .attr("class", "background")
159
+ .attr("opacity", 0.8);
160
+ }
161
+ }
162
+
163
+ // 将条纹背景移到最底层
164
+ g.selectAll("rect").lower();
165
+
166
+ // 添加水平网格线
167
+ yTicks.forEach(tick => {
168
+ g.append("line")
169
+ .attr("x1", 0)
170
+ .attr("y1", yScale(tick))
171
+ .attr("x2", chartWidth)
172
+ .attr("y2", yScale(tick))
173
+ .attr("stroke", "#e0e0e0")
174
+ .attr("stroke-width", 1)
175
+ .attr("class", "background")
176
+ .attr("stroke-dasharray", "2,2");
177
+ });
178
+
179
+ // 定义线条粗细
180
+ const lineWidth = 4;
181
+
182
+ // 创建线条生成器
183
+ const line = d3.line()
184
+ .x(d => xScale(chartUtils.format.parseDate(d[xField])))
185
+ .y(d => yScale(+d[yField]))
186
+ .curve(d3.curveLinear);
187
+
188
+ // 为每个X刻度创建插值函数,计算比值
189
+ const highValues = groupedData.get(highestGroup);
190
+ const lowValues = groupedData.get(lowestGroup);
191
+
192
+ // 确保数据按日期排序
193
+ highValues.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
194
+ lowValues.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
195
+
196
+ // 创建插值函数
197
+ const highInterpolator = d3.scaleTime()
198
+ .domain(highValues.map(d => chartUtils.format.parseDate(d[xField])))
199
+ .range(highValues.map(d => +d[yField]))
200
+ .clamp(true);
201
+
202
+ const lowInterpolator = d3.scaleTime()
203
+ .domain(lowValues.map(d => chartUtils.format.parseDate(d[xField])))
204
+ .range(lowValues.map(d => +d[yField]))
205
+ .clamp(true);
206
+
207
+ // 计算每个X刻度的比值(转为百分比)
208
+ const ratios = xTicks.map(tick => {
209
+ const highVal = highInterpolator(tick);
210
+ const lowVal = lowInterpolator(tick);
211
+ return {
212
+ date: tick,
213
+ ratio: (lowVal / highVal) * 100 // 转为百分比
214
+ };
215
+ });
216
+
217
+ // 找出最大和最小的百分比值
218
+ const minRatio = d3.min(ratios, d => d.ratio);
219
+ const maxRatio = d3.max(ratios, d => d.ratio);
220
+
221
+ // 创建圆形大小的比例尺
222
+ const radiusScale = d3.scaleLinear()
223
+ .domain([minRatio, maxRatio])
224
+ .range([12, 20]); // 最小半径10,最大半径20
225
+
226
+ // 获取两个组的颜色
227
+ const highColor = colorScale(highestGroup);
228
+ const lowColor = colorScale(lowestGroup);
229
+
230
+ // 判断哪个颜色更浅
231
+ const highColorRGB = d3.rgb(highColor);
232
+ const lowColorRGB = d3.rgb(lowColor);
233
+
234
+ // 计算颜色的亮度(简单方法:R+G+B的总和)
235
+ const highBrightness = highColorRGB.r + highColorRGB.g + highColorRGB.b;
236
+ const lowBrightness = lowColorRGB.r + lowColorRGB.g + lowColorRGB.b;
237
+
238
+ // 确定浅色和深色
239
+ let lightColor, darkColor;
240
+ if (highBrightness >= lowBrightness) {
241
+ lightColor = highColorRGB;
242
+ darkColor = lowColorRGB;
243
+ } else {
244
+ lightColor = lowColorRGB;
245
+ darkColor = highColorRGB;
246
+ }
247
+
248
+ // 计算圆的颜色:浅色变得更浅
249
+ const circleR = Math.min(255, lightColor.r + (255 - lightColor.r) * 0.7);
250
+ const circleG = Math.min(255, lightColor.g + (255 - lightColor.g) * 0.7);
251
+ const circleB = Math.min(255, lightColor.b + (255 - lightColor.b) * 0.7);
252
+
253
+ const circleColor = d3.rgb(circleR, circleG, circleB);
254
+
255
+ // 绘制只选中的两个组的线条
256
+ selectedGroups.forEach(group => {
257
+ const values = groupedData.get(group);
258
+ // 确保数据按日期排序
259
+ values.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
260
+
261
+ const color = colorScale(group);
262
+
263
+ // 绘制线条
264
+ g.append("path")
265
+ .datum(values)
266
+ .attr("fill", "none")
267
+ .attr("stroke", color)
268
+ .attr("stroke-width", lineWidth)
269
+ .attr("d", line);
270
+
271
+ // 添加数据点 - 根据是否为起止点使用不同样式
272
+ values.forEach((d, i) => {
273
+ const isEndpoint = i === 0 || i === values.length - 1;
274
+
275
+ if (isEndpoint) {
276
+ // 起止点:白色填充,带有颜色描边
277
+ g.append("circle")
278
+ .attr("cx", xScale(chartUtils.format.parseDate(d[xField])))
279
+ .attr("cy", yScale(+d[yField]))
280
+ .attr("r", lineWidth * 1.2)
281
+ .attr("fill", "#fff")
282
+ .attr("stroke", color)
283
+ .attr("stroke-width", lineWidth);
284
+ } else {
285
+ // 中间点:实心颜色填充,无描边
286
+ g.append("circle")
287
+ .attr("cx", xScale(chartUtils.format.parseDate(d[xField])))
288
+ .attr("cy", yScale(+d[yField]))
289
+ .attr("r", lineWidth)
290
+ .attr("fill", color)
291
+ .attr("stroke", "none");
292
+ }
293
+ });
294
+
295
+ // 添加起点和终点标注
296
+ const firstPoint = values[0];
297
+ const lastPoint = values[values.length - 1];
298
+
299
+ // 添加起点标注
300
+ addDataLabel(firstPoint, true);
301
+
302
+ // 添加终点标注
303
+ addDataLabel(lastPoint, false);
304
+ });
305
+
306
+ // 添加X轴文本 - 放置在条纹背景的中间
307
+ for (let i = 0; i < xTicks.length - 1; i++) {
308
+ // 获取相邻两个刻度
309
+ const currentTick = xTicks[i];
310
+ const nextTick = xTicks[i + 1];
311
+
312
+ // 计算当前刻度和下一个刻度的位置
313
+ const x1 = xScale(currentTick);
314
+ const x2 = xScale(nextTick);
315
+
316
+ // 计算中点位置
317
+ const midX = (x1 + x2) / 2;
318
+
319
+ g.append("text")
320
+ .attr("x", midX)
321
+ .attr("y", chartHeight + 20)
322
+ .attr("text-anchor", "middle")
323
+ .attr("fill", "#666")
324
+ .style("font-size", "12px")
325
+ .text(xFormat(currentTick));
326
+ }
327
+
328
+
329
+ // 添加Y轴文本
330
+ yTicks.forEach(tick => {
331
+ g.append("text")
332
+ .attr("x", -10)
333
+ .attr("y", yScale(tick))
334
+ .attr("text-anchor", "end")
335
+ .attr("dominant-baseline", "middle")
336
+ .attr("fill", "#666")
337
+ .style("font-size", "12px")
338
+ .text(chartUtils.format.fixed(tick).text);
339
+ });
340
+
341
+ // 添加图例 - 整体居中,放在最大Y轴刻度上方
342
+ const legendGroup = g.append("g")
343
+ .attr("transform", `translate(0, -50)`);
344
+
345
+ const group_names = [lowestGroup, highestGroup, `${lowestGroup}/${highestGroup}`];
346
+ const colors_legend = {
347
+ field: {
348
+ [lowestGroup]: lowColor,
349
+ [highestGroup]: highColor,
350
+ [`${lowestGroup}/${highestGroup}`]: circleColor,
351
+ }
352
+ }
353
+
354
+ const legendSize = chartUtils.legend.draw(legendGroup, group_names, colors_legend, {
355
+ x: 0,
356
+ y: 0,
357
+ fontSize: 14,
358
+ fontWeight: "bold",
359
+ align: "left",
360
+ maxWidth: chartWidth,
361
+ shape: "circle",
362
+ });
363
+
364
+ // 居中legend
365
+ legendGroup.attr("transform", `translate(${(chartWidth - legendSize.width) / 2}, ${-50 - legendSize.height/2})`);
366
+
367
+ // 添加每个X刻度的比值,使用圆形背景
368
+ ratios.forEach((ratio, i) => {
369
+ if (i === 0) {
370
+ return;
371
+ }
372
+
373
+ // 跳过无效比值
374
+ if (ratio.invalid) {
375
+ return;
376
+ }
377
+
378
+ const x = xScale(ratio.date) - (xScale(ratio.date) - xScale(ratios[i-1].date)) / 2;
379
+ const y = ratioCircleY;
380
+ const radius = radiusScale(ratio.ratio);
381
+
382
+ // 添加圆形背景
383
+ g.append("circle")
384
+ .attr("cx", x)
385
+ .attr("cy", y)
386
+ .attr("r", radius)
387
+ .attr("fill", circleColor.toString());
388
+
389
+ // 添加百分比文本
390
+ g.append("text")
391
+ .attr("x", x)
392
+ .attr("y", y)
393
+ .attr("text-anchor", "middle")
394
+ .attr("dominant-baseline", "middle")
395
+ .attr("fill", "#333")
396
+ .style("font-size", "12px")
397
+ .text(chartUtils.format.percent(ratio.ratio, { digits: 0 }).text);
398
+ });
399
+
400
+ // 添加数据标注函数 - 根据数据点的实际值决定标签位置
401
+ function addDataLabel(point, isStart) {
402
+ const x = xScale(chartUtils.format.parseDate(point[xField]));
403
+ const y = yScale(+point[yField]);
404
+
405
+ // 获取点所属的组
406
+ const group = point[groupField];
407
+ const color = colorScale(group);
408
+
409
+ // 计算标签文本
410
+ const labelText = chartUtils.format.fixed(+point[yField], 0).text;
411
+
412
+ // 计算标签宽度和高度
413
+ const labelWidth = chartUtils.text.measure(null, labelText, { fontSize: 12 }).width + 16;
414
+ const labelHeight = 24;
415
+
416
+ // 获取另一组在同一时间点的值
417
+ const otherGroup = group === highestGroup ? lowestGroup : highestGroup;
418
+ const otherGroupData = groupedData.get(otherGroup);
419
+
420
+ // 找到同一时间点的另一组数据
421
+ const otherPoint = otherGroupData.find(d => d[xField] === point[xField]);
422
+
423
+ // 判断当前点的值是否大于另一组同时间点的值
424
+ const isHigherValue = otherPoint ? +point[yField] > +otherPoint[yField] : true;
425
+
426
+ // 根据值的大小决定标签位置
427
+ const labelY = isHigherValue ? y - 30 : y + 30;
428
+
429
+ // 添加圆角矩形背景
430
+ g.append("rect")
431
+ .attr("x", x - labelWidth / 2)
432
+ .attr("y", labelY - labelHeight / 2)
433
+ .attr("width", labelWidth)
434
+ .attr("height", labelHeight)
435
+ .attr("rx", 4)
436
+ .attr("ry", 4)
437
+ .attr("fill", color);
438
+
439
+ // 添加三角形 - 方向根据标签位置决定
440
+ const triangleSize = 8;
441
+ if (isHigherValue) {
442
+ // 值较高:向下的三角形
443
+ g.append("path")
444
+ .attr("d", `M${x-triangleSize/2},${labelY+labelHeight/2} L${x+triangleSize/2},${labelY+labelHeight/2} L${x},${labelY+labelHeight/2+triangleSize} Z`)
445
+ .attr("fill", color);
446
+ } else {
447
+ // 值较低:向上的三角形
448
+ g.append("path")
449
+ .attr("d", `M${x-triangleSize/2},${labelY-labelHeight/2} L${x+triangleSize/2},${labelY-labelHeight/2} L${x},${labelY-labelHeight/2-triangleSize} Z`)
450
+ .attr("fill", color);
451
+ }
452
+
453
+ // 添加文本 - 白色粗体
454
+ g.append("text")
455
+ .attr("x", x)
456
+ .attr("y", labelY)
457
+ .attr("text-anchor", "middle")
458
+ .attr("dominant-baseline", "middle")
459
+ .attr("fill", "#fff") // 白色文本
460
+ .attr("font-weight", "bold") // 粗体
461
+ .style("font-size", "12px")
462
+ .text(labelText);
463
+ }
464
+
465
+ return svg.node();
466
+ }
modules/chart_engine/template/d3-js/line_graph/multiple_spline_graph_01.js ADDED
@@ -0,0 +1,377 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Multiple Spline Graph",
5
+ "chart_name": "multiple_spline_graph_01",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[5, 30], ["-inf", "inf"], [2, 8]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["group"],
12
+ "required_other_colors": [],
13
+ "supported_effects": [],
14
+ "min_height": 400,
15
+ "min_width": 600,
16
+ "background": "dark",
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_dark || {};
32
+ const colorResolver = chartUtils.color.resolver(jsonData);
33
+ const dataColumns = chartUtils.schema.columns(jsonData);
34
+ const images = jsonData.images || {};
35
+ // 清空容器
36
+ d3.select(containerSelector).html("");
37
+
38
+ // 设置尺寸和边距 - 右侧留出更多空间用于标签
39
+ const width = variables.width;
40
+ const height = variables.height;
41
+ const margin = { top: 40, right: 180, bottom: 60, left: 80 };
42
+ const innerWidth = width - margin.left - margin.right;
43
+ const innerHeight = height - margin.top - margin.bottom;
44
+
45
+ // 获取字段名
46
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
47
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
48
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
49
+
50
+ // 获取唯一的组值和X值
51
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
52
+ const xValues = [...new Set(chartData.map(d => d[xField]))]
53
+ .sort((a, b) => chartUtils.format.parseDate(a) - chartUtils.format.parseDate(b));
54
+
55
+ // 创建SVG
56
+ const svg = d3.select(containerSelector)
57
+ .append("svg")
58
+ .attr("width", "100%")
59
+ .attr("height", height)
60
+ .attr("viewBox", `0 0 ${width} ${height}`)
61
+ .attr("style", "max-width: 100%; height: auto;")
62
+ .attr("xmlns", "http://www.w3.org/2000/svg")
63
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
64
+
65
+ // 创建图表组
66
+ const g = svg.append("g")
67
+ .attr("transform", `translate(${margin.left},${margin.top})`);
68
+
69
+ // 添加渐变效果
70
+ const defs = svg.append("defs");
71
+
72
+ // 添加网格背景渐变
73
+ const gridGradientId = "grid-background-gradient";
74
+ const gridGradient = defs.append("linearGradient")
75
+ .attr("class", "background")
76
+ .attr("id", gridGradientId)
77
+ .attr("x1", "0%")
78
+ .attr("y1", "0%")
79
+ .attr("x2", "0%")
80
+ .attr("y2", "100%");
81
+
82
+ gridGradient.append("stop")
83
+ .attr("offset", "0%")
84
+ .attr("stop-color", "#f0dcc1")
85
+ .attr("stop-opacity", 0); // 顶部完全透明
86
+
87
+ gridGradient.append("stop")
88
+ .attr("offset", "100%")
89
+ .attr("stop-color", "#f0dcc1")
90
+ .attr("stop-opacity", 0.3); // 底部半透明
91
+
92
+ // 添加网格背景矩形
93
+ g.append("rect")
94
+ .attr("width", innerWidth)
95
+ .attr("height", innerHeight)
96
+ .attr("fill", `url(#${gridGradientId})`) // 使用渐变填充
97
+ .attr("class", "background")
98
+ .attr("rx", 0)
99
+ .attr("ry", 0);
100
+
101
+ // 创建比例尺 - 修改为时间比例尺
102
+ const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, innerWidth);
103
+
104
+ // 修改Y轴比例尺,支持负值
105
+ const yScale = d3.scaleLinear()
106
+ .domain([
107
+ Math.min(0, d3.min(chartData, d => +d[yField]) * 1.1), // 取最小值和0中的较小者
108
+ d3.max(chartData, d => +d[yField]) * 1.1
109
+ ])
110
+ .range([innerHeight, 0]);
111
+
112
+ // 获取颜色
113
+ const getColor = (group) => {
114
+ return colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
115
+ };
116
+
117
+ // 添加网格线 - 使用半透明白色
118
+ // 首先获取Y轴的最大刻度值
119
+ const yTicks = yScale.ticks(5);
120
+ const maxYTick = yTicks[yTicks.length - 1];
121
+ const maxYPos = yScale(maxYTick);
122
+
123
+ // 绘制水平网格线 - 向左延伸
124
+ const gridExtension = 5; // 网格线向左延伸的距离
125
+ g.selectAll("line.grid-line-y")
126
+ .data(yTicks)
127
+ .enter()
128
+ .append("line")
129
+ .attr("class", "grid-line-y")
130
+ .attr("class", "background")
131
+ .attr("x1", -gridExtension) // 向左延伸
132
+ .attr("y1", d => yScale(d))
133
+ .attr("x2", innerWidth)
134
+ .attr("y2", d => yScale(d))
135
+ .attr("stroke", "#f0dcc1")
136
+ .attr("stroke-width", 1)
137
+ .attr("opacity", 0.2); // 半透明
138
+
139
+ // 添加垂直网格线渐变
140
+ const verticalGridGradientId = "vertical-grid-gradient";
141
+ const verticalGridGradient = defs.append("linearGradient")
142
+ .attr("id", verticalGridGradientId)
143
+ .attr("x1", "0%")
144
+ .attr("y1", "0%")
145
+ .attr("x2", "0%")
146
+ .attr("y2", "100%");
147
+
148
+ verticalGridGradient.append("stop")
149
+ .attr("offset", "0%")
150
+ .attr("stop-color", "#f0dcc1")
151
+ .attr("stop-opacity", 0.1); // 顶部较透明
152
+
153
+ verticalGridGradient.append("stop")
154
+ .attr("offset", "100%")
155
+ .attr("stop-color", "#f0dcc1")
156
+ .attr("stop-opacity", 0.4); // 底部较不透明
157
+
158
+ // 绘制垂直网格线 - 延伸超过Y轴最大刻度和X轴,但不包括最后一个刻度
159
+ g.selectAll("line.grid-line-x")
160
+ .data(xTicks.filter((d, i) => i > 0 && i < xTicks.length - 1)) // 移除第一个和最后一个刻度
161
+ .enter()
162
+ .append("line")
163
+ .attr("class", "grid-line-x")
164
+ .attr("class", "background")
165
+ .attr("x1", d => xScale(d))
166
+ .attr("y1", 0) // 从顶部开始,超过最大Y刻度
167
+ .attr("x2", d => xScale(d))
168
+ .attr("y2", innerHeight + 10) // 延伸到X轴下方10像素
169
+ .attr("stroke", "#f0dcc1") // 使用固定颜色
170
+ .attr("stroke-width", 1)
171
+ .attr("opacity", 0.3); // 半透明
172
+
173
+ // 为每个组添加线条渐变
174
+ groups.forEach(group => {
175
+ const gradientId = `line-gradient-${group.replace(/\s+/g, '-').toLowerCase()}`;
176
+ const baseColor = getColor(group);
177
+
178
+ const gradient = defs.append("linearGradient")
179
+ .attr("id", gradientId)
180
+ .attr("x1", "0%")
181
+ .attr("y1", "0%")
182
+ .attr("x2", "0%")
183
+ .attr("y2", "100%");
184
+
185
+ gradient.append("stop")
186
+ .attr("offset", "0%")
187
+ .attr("stop-color", baseColor)
188
+ .attr("stop-opacity", 0.3);
189
+
190
+ gradient.append("stop")
191
+ .attr("offset", "100%")
192
+ .attr("stop-color", baseColor)
193
+ .attr("stop-opacity", 0);
194
+ });
195
+
196
+ // 创建曲线生成器(而不是折线)
197
+ const line = d3.line()
198
+ .x(d => xScale(chartUtils.format.parseDate(d[xField])))
199
+ .y(d => yScale(+d[yField]))
200
+ .curve(d3.curveMonotoneX); // 使用单调曲线插值
201
+
202
+ // 计算所有标签的固定宽度
203
+ const fixedLabelWidth = 30; // 固定标签宽度
204
+
205
+ // 在最后的竖线上为每个组添加彩色小圆点,并创建指向它们的标签
206
+ let labelPositions = [];
207
+ // 按照最终值,对group排序,从高到低
208
+ groups.sort((a, b) => {
209
+ const aData = chartData.filter(d => d[groupField] === a);
210
+ const bData = chartData.filter(d => d[groupField] === b);
211
+ return d3.descending(aData[aData.length - 1][yField], bData[bData.length - 1][yField]);
212
+ });
213
+
214
+ groups.forEach((group, i) => {
215
+ const groupData = chartData.filter(d => d[groupField] === group);
216
+ const lastPoint = groupData[groupData.length - 1];
217
+ const circleY = yScale(+lastPoint[yField]); // 圆点的实际Y坐标
218
+ let labelY = circleY; // 标签的Y坐标,初始与圆点相同
219
+
220
+ // 检查是否与现有标签重叠
221
+ labelPositions.forEach(pos => {
222
+ if (Math.abs(labelY - pos) < 30) {
223
+ // 如果重叠,向下移动
224
+ labelY = pos + 30;
225
+ }
226
+ });
227
+
228
+ labelPositions.push(labelY);
229
+
230
+ // 创建标签组
231
+ const labelGroup = g.append("g")
232
+ .attr("transform", `translate(${innerWidth + 20}, ${labelY})`);
233
+
234
+ // 计算数值文本
235
+ const valueText = chartUtils.format.integer(+lastPoint[yField]).text;
236
+
237
+ const textWidth = chartUtils.text.measure(null, valueText, { fontSize: typography.label.font_size }).width + 10;
238
+
239
+ // 标签背景 - 固定宽度,无圆角
240
+ labelGroup.append("rect")
241
+ .attr("x", 0)
242
+ .attr("y", -10)
243
+ .attr("width", textWidth * 1.1) // 固定宽度
244
+ .attr("height", 20)
245
+ .attr("fill", getColor(group))
246
+ .attr("rx", 0) // 移除圆角
247
+ .attr("ry", 0); // 移除圆角
248
+
249
+ // 计算三角形的位置
250
+ // 计算圆点相对于标签的位置
251
+ const relativeCircleY = circleY - labelY; // 圆点相对于标签中心的Y偏移
252
+
253
+ // 创建一个自定义的三角形路径
254
+ // 三角形右边是垂直的,贴在标签上,左边的尖尖指向圆点
255
+ const trianglePath = `
256
+ M -12,${relativeCircleY}
257
+ L 0,-10
258
+ L 0,10
259
+ Z
260
+ `;
261
+
262
+ // 添加指向小圆点的三角形
263
+ labelGroup.append("path")
264
+ .attr("d", trianglePath)
265
+ .attr("fill", getColor(group));
266
+
267
+ // 标签文本 - 只显示数值
268
+ labelGroup.append("text")
269
+ .attr("x", textWidth/2) // 左对齐,留出一点间距
270
+ .attr("y", 2) // 垂直居中
271
+ .attr("text-anchor", "middle") //
272
+ .attr("dominant-baseline", "middle")
273
+ .style("font-family", typography.label.font_family)
274
+ .style("font-size", typography.label.font_size)
275
+ .style("fill", "#1c1c1c")
276
+ .text(valueText);
277
+
278
+ // 添加组名文本
279
+ labelGroup.append("text")
280
+ .attr("x", textWidth + 10) // 位于标签右侧,留出间距
281
+ .attr("y", 2) // 垂直居中
282
+ .attr("text-anchor", "start") // 左对齐
283
+ .attr("dominant-baseline", "middle")
284
+ .style("font-family", typography.label.font_family)
285
+ .style("font-size", typography.label.font_size)
286
+ .style("font-weight", "bold")
287
+ .style("fill", getColor(group)) // 使用与组相同的颜色
288
+ .text(group);
289
+ });
290
+
291
+ // 为每个组绘制线条和面积
292
+ groups.forEach(group => {
293
+ const groupData = chartData.filter(d => d[groupField] === group);
294
+
295
+ // 绘制线条
296
+ g.append("path")
297
+ .datum(groupData)
298
+ .attr("class", "line")
299
+ .attr("fill", "none")
300
+ .attr("stroke", getColor(group))
301
+ .attr("stroke-width", 2)
302
+ .attr("d", line);
303
+ });
304
+
305
+ // 直接使用xTicks绘制X轴文本
306
+ xTicks.forEach(tick => {
307
+ g.append("text")
308
+ .attr("x", xScale(tick))
309
+ .attr("y", innerHeight + 25) // 下移文本
310
+ .attr("text-anchor", "middle")
311
+ .style("font-family", typography.label.font_family)
312
+ .style("font-size", typography.label.font_size)
313
+ .style("fill", "#f0dcc1")
314
+ .text(xFormat(tick));
315
+ });
316
+
317
+
318
+ // 为每个刻度添加文本
319
+ yTicks.forEach(tick => {
320
+ g.append("text")
321
+ .attr("x", -gridExtension - 5)
322
+ .attr("y", yScale(tick))
323
+ .attr("text-anchor", "end")
324
+ .attr("dominant-baseline", "middle")
325
+ .style("font-family", typography.label.font_family)
326
+ .style("font-size", typography.label.font_size)
327
+ .style("fill", "#f0dcc1")
328
+ .text(tick);
329
+ });
330
+
331
+
332
+ // 添加Y轴编码标签
333
+ const labelGroup = g.append("g")
334
+ .attr("transform", `translate(${-margin.left + 35}, ${maxYPos - 40})`);
335
+
336
+ // 计算标签宽度(根据文字长度调整)
337
+ const labelText = yField;
338
+ const labelPadding = 20;
339
+ const textWidth = chartUtils.text.measure(null, labelText, { fontSize: typography.label.font_size }).width;
340
+
341
+ const labelWidth = textWidth + 2 * labelPadding;
342
+ const labelHeight = 20;
343
+ const triangleHeight = 6;
344
+
345
+ // 创建标签形状(矩形+三角形)
346
+ const labelPath = `
347
+ M 0,0
348
+ H ${labelWidth}
349
+ V ${labelHeight}
350
+ H ${labelWidth/2 + triangleHeight}
351
+ L ${labelWidth/2},${labelHeight + triangleHeight}
352
+ L ${labelWidth/2 - triangleHeight},${labelHeight}
353
+ H 0
354
+ Z
355
+ `;
356
+
357
+ // 绘制标签背景
358
+ labelGroup.append("path")
359
+ .attr("d", labelPath)
360
+ .attr("fill", "transparent")
361
+ .attr("stroke", "#f0dcc1")
362
+ .attr("stroke-width", 1)
363
+ .attr("stroke-opacity", 0.5);
364
+
365
+ // 添加文本
366
+ labelGroup.append("text")
367
+ .attr("x", labelWidth/2)
368
+ .attr("y", labelHeight/2 + 2)
369
+ .attr("text-anchor", "middle")
370
+ .attr("dominant-baseline", "middle")
371
+ .style("font-family", typography.label.font_family)
372
+ .style("font-size", typography.label.font_size)
373
+ .style("fill", "#f0dcc1")
374
+ .text(labelText);
375
+
376
+ return svg.node();
377
+ }
modules/chart_engine/template/d3-js/line_graph/multiple_spline_graph_02.js ADDED
@@ -0,0 +1,377 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Multiple Spline Graph",
5
+ "chart_name": "multiple_spline_graph_02",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[5, 30], ["-inf", "inf"], [2, 8]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["group"],
12
+ "required_other_colors": [],
13
+ "supported_effects": [],
14
+ "min_height": 400,
15
+ "min_width": 600,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "yes"
21
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+ function makeChart(containerSelector, data) {
26
+ // 提取数据
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
+ d3.select(containerSelector).html("");
37
+
38
+ // 设置尺寸和边距 - 右侧留出更多空间用于标签
39
+ const width = variables.width;
40
+ const height = variables.height;
41
+ const margin = { top: 40, right: 180, bottom: 60, left: 80 };
42
+ const innerWidth = width - margin.left - margin.right;
43
+ const innerHeight = height - margin.top - margin.bottom;
44
+
45
+ // 获取字段名
46
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
47
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
48
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
49
+
50
+ // 获取唯一的组值和X值
51
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
52
+ const xValues = [...new Set(chartData.map(d => d[xField]))]
53
+ .sort((a, b) => chartUtils.format.parseDate(a) - chartUtils.format.parseDate(b));
54
+
55
+ // 创建SVG
56
+ const svg = d3.select(containerSelector)
57
+ .append("svg")
58
+ .attr("width", "100%")
59
+ .attr("height", height)
60
+ .attr("viewBox", `0 0 ${width} ${height}`)
61
+ .attr("style", "max-width: 100%; height: auto;")
62
+ .attr("xmlns", "http://www.w3.org/2000/svg")
63
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
64
+
65
+ // 创建图表组
66
+ const g = svg.append("g")
67
+ .attr("transform", `translate(${margin.left},${margin.top})`);
68
+
69
+ // 添加渐变效果
70
+ const defs = svg.append("defs");
71
+
72
+ // 添加网格背景渐变
73
+ const gridGradientId = "grid-background-gradient";
74
+ const gridGradient = defs.append("linearGradient")
75
+ .attr("class", "background")
76
+ .attr("id", gridGradientId)
77
+ .attr("x1", "0%")
78
+ .attr("y1", "0%")
79
+ .attr("x2", "0%")
80
+ .attr("y2", "100%");
81
+
82
+ gridGradient.append("stop")
83
+ .attr("offset", "0%")
84
+ .attr("stop-color", "#1e90ff") // 深科技蓝
85
+ .attr("stop-opacity", 0); // 顶部完全透明
86
+
87
+ gridGradient.append("stop")
88
+ .attr("offset", "100%")
89
+ .attr("stop-color", "#1e90ff") // 深科技蓝
90
+ .attr("stop-opacity", 0.15); // 底部半透明
91
+
92
+ // 添加网格背景矩形
93
+ g.append("rect")
94
+ .attr("width", innerWidth)
95
+ .attr("height", innerHeight)
96
+ .attr("fill", `url(#${gridGradientId})`) // 使用渐变填充
97
+ .attr("class", "background")
98
+ .attr("rx", 0)
99
+ .attr("ry", 0);
100
+
101
+ // 创建比例尺 - 修改为时间比例尺
102
+ const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, innerWidth);
103
+
104
+ // 修改Y轴比例尺,支持负值
105
+ const yScale = d3.scaleLinear()
106
+ .domain([
107
+ Math.min(0, d3.min(chartData, d => +d[yField]) * 1.1), // 取最小值和0中的较小者
108
+ d3.max(chartData, d => +d[yField]) * 1.1
109
+ ])
110
+ .range([innerHeight, 0]);
111
+
112
+ // 获取颜色
113
+ const getColor = (group) => {
114
+ return colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
115
+ };
116
+
117
+ // 添加网格线 - 使用半透明白色
118
+ // 首先获取Y轴的最大刻度值
119
+ const yTicks = yScale.ticks(5);
120
+ const maxYTick = yTicks[yTicks.length - 1];
121
+ const maxYPos = yScale(maxYTick);
122
+
123
+ // 绘制水平网格线 - 向左延伸
124
+ const gridExtension = 5; // 网格线向左延伸的距离
125
+ g.selectAll("line.grid-line-y")
126
+ .data(yTicks)
127
+ .enter()
128
+ .append("line")
129
+ .attr("class", "grid-line-y")
130
+ .attr("class", "background")
131
+ .attr("x1", -gridExtension) // 向左延伸
132
+ .attr("y1", d => yScale(d))
133
+ .attr("x2", innerWidth)
134
+ .attr("y2", d => yScale(d))
135
+ .attr("stroke", "#1e90ff") // 深科技蓝
136
+ .attr("stroke-width", 1)
137
+ .attr("opacity", 0.15); // 半透明
138
+
139
+ // 添加垂直网格线渐变
140
+ const verticalGridGradientId = "vertical-grid-gradient";
141
+ const verticalGridGradient = defs.append("linearGradient")
142
+ .attr("id", verticalGridGradientId)
143
+ .attr("x1", "0%")
144
+ .attr("y1", "0%")
145
+ .attr("x2", "0%")
146
+ .attr("y2", "100%");
147
+
148
+ verticalGridGradient.append("stop")
149
+ .attr("offset", "0%")
150
+ .attr("stop-color", "#1e90ff") // 深科技蓝
151
+ .attr("stop-opacity", 0.1); // 顶部较透明
152
+
153
+ verticalGridGradient.append("stop")
154
+ .attr("offset", "100%")
155
+ .attr("stop-color", "#1e90ff") // 深科技蓝
156
+ .attr("stop-opacity", 0.2); // 底部较不透明
157
+
158
+ // 绘制垂直网格线 - 延伸超过Y轴最大刻度和X轴,但不包括最后一个刻度
159
+ g.selectAll("line.grid-line-x")
160
+ .data(xTicks.filter((d, i) => i > 0 && i < xTicks.length - 1)) // 移除第一个和最后一个刻度
161
+ .enter()
162
+ .append("line")
163
+ .attr("class", "grid-line-x")
164
+ .attr("class", "background")
165
+ .attr("x1", d => xScale(d))
166
+ .attr("y1", 0) // 从顶部开始,超过最大Y刻度
167
+ .attr("x2", d => xScale(d))
168
+ .attr("y2", innerHeight + 10) // 延伸到X轴下方10像素
169
+ .attr("stroke", "#1e90ff") // 深科技蓝
170
+ .attr("stroke-width", 1)
171
+ .attr("opacity", 0.15); // 半透明
172
+
173
+ // 为每个组添加线条渐变
174
+ groups.forEach(group => {
175
+ const gradientId = `line-gradient-${group.replace(/\s+/g, '-').toLowerCase()}`;
176
+ const baseColor = getColor(group);
177
+
178
+ const gradient = defs.append("linearGradient")
179
+ .attr("id", gradientId)
180
+ .attr("x1", "0%")
181
+ .attr("y1", "0%")
182
+ .attr("x2", "0%")
183
+ .attr("y2", "100%");
184
+
185
+ gradient.append("stop")
186
+ .attr("offset", "0%")
187
+ .attr("stop-color", baseColor)
188
+ .attr("stop-opacity", 0.3);
189
+
190
+ gradient.append("stop")
191
+ .attr("offset", "100%")
192
+ .attr("stop-color", baseColor)
193
+ .attr("stop-opacity", 0);
194
+ });
195
+
196
+ // 创建曲线生成器(而不是折线)
197
+ const line = d3.line()
198
+ .x(d => xScale(chartUtils.format.parseDate(d[xField])))
199
+ .y(d => yScale(+d[yField]))
200
+ .curve(d3.curveMonotoneX); // 使用单调曲线插值
201
+
202
+ // 计算所有标签的固定宽度
203
+ const fixedLabelWidth = 30; // 固定标签宽度
204
+
205
+ // 在最后的竖线上为每个组添加彩色小圆点,并创建指向它们的标签
206
+ let labelPositions = [];
207
+ // 按照最终值,对group排序,从高到低
208
+ groups.sort((a, b) => {
209
+ const aData = chartData.filter(d => d[groupField] === a);
210
+ const bData = chartData.filter(d => d[groupField] === b);
211
+ return d3.descending(aData[aData.length - 1][yField], bData[bData.length - 1][yField]);
212
+ });
213
+
214
+ groups.forEach((group, i) => {
215
+ const groupData = chartData.filter(d => d[groupField] === group);
216
+ const lastPoint = groupData[groupData.length - 1];
217
+ const circleY = yScale(+lastPoint[yField]); // 圆点的实际Y坐标
218
+ let labelY = circleY; // 标签的Y坐标,初始与圆点相同
219
+
220
+ // 检查是否与现有标签重叠
221
+ labelPositions.forEach(pos => {
222
+ if (Math.abs(labelY - pos) < 30) {
223
+ // 如果重叠,向下移动
224
+ labelY = pos + 30;
225
+ }
226
+ });
227
+
228
+ labelPositions.push(labelY);
229
+
230
+ // 创建标签组
231
+ const labelGroup = g.append("g")
232
+ .attr("transform", `translate(${innerWidth + 20}, ${labelY})`);
233
+
234
+ // 计算数值文本
235
+ const valueText = chartUtils.format.integer(+lastPoint[yField]).text;
236
+
237
+ const textWidth = chartUtils.text.measure(null, valueText, { fontSize: typography.label.font_size }).width + 10;
238
+
239
+ // 标签背景 - 固定宽度,无圆角
240
+ labelGroup.append("rect")
241
+ .attr("x", 0)
242
+ .attr("y", -10)
243
+ .attr("width", textWidth * 1.1) // 固定宽度
244
+ .attr("height", 20)
245
+ .attr("fill", getColor(group))
246
+ .attr("rx", 0) // 移除圆角
247
+ .attr("ry", 0); // 移除圆角
248
+
249
+ // 计算三角形的位置
250
+ // 计算圆点相对于标签的位置
251
+ const relativeCircleY = circleY - labelY; // 圆点相对于标签中心的Y偏移
252
+
253
+ // 创建一个自定义的三角形路径
254
+ // 三角形右边是垂直的,贴在标签上,左边的尖尖指向圆点
255
+ const trianglePath = `
256
+ M -12,${relativeCircleY}
257
+ L 0,-10
258
+ L 0,10
259
+ Z
260
+ `;
261
+
262
+ // 添加指向小圆点的三角形
263
+ labelGroup.append("path")
264
+ .attr("d", trianglePath)
265
+ .attr("fill", getColor(group));
266
+
267
+ // 标签文本 - 只显示数值
268
+ labelGroup.append("text")
269
+ .attr("x", textWidth/2) // 左对齐,留出一点间距
270
+ .attr("y", 2) // 垂直居中
271
+ .attr("text-anchor", "middle") //
272
+ .attr("dominant-baseline", "middle")
273
+ .style("font-family", typography.label.font_family)
274
+ .style("font-size", typography.label.font_size)
275
+ .style("fill", "#ffffff") // 改为白色
276
+ .text(valueText);
277
+
278
+ // 添加组名文本
279
+ labelGroup.append("text")
280
+ .attr("x", textWidth + 10) // 位于标签右侧,留出间距
281
+ .attr("y", 2) // 垂直居中
282
+ .attr("text-anchor", "start") // 左对齐
283
+ .attr("dominant-baseline", "middle")
284
+ .style("font-family", typography.label.font_family)
285
+ .style("font-size", typography.label.font_size)
286
+ .style("font-weight", "bold")
287
+ .style("fill", getColor(group)) // 使用与组相同的颜色
288
+ .text(group);
289
+ });
290
+
291
+ // 为每个组绘制线条和面积
292
+ groups.forEach(group => {
293
+ const groupData = chartData.filter(d => d[groupField] === group);
294
+
295
+ // 绘制线条
296
+ g.append("path")
297
+ .datum(groupData)
298
+ .attr("class", "line")
299
+ .attr("fill", "none")
300
+ .attr("stroke", getColor(group))
301
+ .attr("stroke-width", 2)
302
+ .attr("d", line);
303
+ });
304
+
305
+ // 直接使用xTicks绘制X轴文本
306
+ xTicks.forEach(tick => {
307
+ g.append("text")
308
+ .attr("x", xScale(tick))
309
+ .attr("y", innerHeight + 25) // 下移文本
310
+ .attr("text-anchor", "middle")
311
+ .style("font-family", typography.label.font_family)
312
+ .style("font-size", typography.label.font_size)
313
+ .style("fill", "#333333")
314
+ .text(xFormat(tick));
315
+ });
316
+
317
+
318
+ // 为每个刻度添加文本
319
+ yTicks.forEach(tick => {
320
+ g.append("text")
321
+ .attr("x", -gridExtension - 5)
322
+ .attr("y", yScale(tick))
323
+ .attr("text-anchor", "end")
324
+ .attr("dominant-baseline", "middle")
325
+ .style("font-family", typography.label.font_family)
326
+ .style("font-size", typography.label.font_size)
327
+ .style("fill", "#333333")
328
+ .text(tick);
329
+ });
330
+
331
+
332
+ // 添加Y轴编码标签
333
+ const labelGroup = g.append("g")
334
+ .attr("transform", `translate(${-margin.left + 35}, ${maxYPos - 40})`);
335
+
336
+ // 计算标签宽度(根据文字长度调整)
337
+ const labelText = yField;
338
+ const labelPadding = 20;
339
+ const textWidth = chartUtils.text.measure(null, labelText, { fontSize: typography.label.font_size }).width;
340
+
341
+ const labelWidth = textWidth + 2 * labelPadding;
342
+ const labelHeight = 20;
343
+ const triangleHeight = 6;
344
+
345
+ // 创建标签形状(矩形+三角形)
346
+ const labelPath = `
347
+ M 0,0
348
+ H ${labelWidth}
349
+ V ${labelHeight}
350
+ H ${labelWidth/2 + triangleHeight}
351
+ L ${labelWidth/2},${labelHeight + triangleHeight}
352
+ L ${labelWidth/2 - triangleHeight},${labelHeight}
353
+ H 0
354
+ Z
355
+ `;
356
+
357
+ // 绘制标签背景
358
+ labelGroup.append("path")
359
+ .attr("d", labelPath)
360
+ .attr("fill", "transparent")
361
+ .attr("stroke", "#333333")
362
+ .attr("stroke-width", 1)
363
+ .attr("stroke-opacity", 0.5);
364
+
365
+ // 添加文本
366
+ labelGroup.append("text")
367
+ .attr("x", labelWidth/2)
368
+ .attr("y", labelHeight/2 + 2)
369
+ .attr("text-anchor", "middle")
370
+ .attr("dominant-baseline", "middle")
371
+ .style("font-family", typography.label.font_family)
372
+ .style("font-size", typography.label.font_size)
373
+ .style("fill", "#333333")
374
+ .text(labelText);
375
+
376
+ return svg.node();
377
+ }
modules/chart_engine/template/d3-js/line_graph/multiple_step_line_graph_01.js ADDED
@@ -0,0 +1,563 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Multiple Step Line Graph",
5
+ "chart_name": "multiple_step_line_graph_01",
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.autoText(tick)); // 四舍五入到整数,不带百分号
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.curveStepAfter);
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
+ const legendSize = chartUtils.legend.draw(g, groups, {
269
+ fontSize: 14,
270
+ fontWeight: "bold",
271
+ maxWidth: chartWidth,
272
+ markerShape: "line",
273
+ markerSize: 10,
274
+ strokeWidth: 4,
275
+ colorScale,
276
+ });
277
+
278
+ const maxYTickPosition = yScale(yTicks[yTicks.length - 1]);
279
+
280
+ legendSize.group.attr("transform", `translate(${(chartWidth - legendSize.width) / 2}, ${maxYTickPosition - 50 - legendSize.height/2})`);
281
+
282
+ return svg.node();
283
+ }
284
+
285
+ // 动态规划标签放置算法 - 添加g参数
286
+ function placeLabelsDP(points, avoidYPositions = [], debugName = "", chartHeight, chartWidth, g) {
287
+ // 每个格点的高度(像素)
288
+ const GRID_SIZE = 3;
289
+ // 圆点周围的保护区域(格点数)
290
+ const PROTECTION_RADIUS = 3;
291
+ // 标签高度(格点数)
292
+ const LABEL_HEIGHT = 10;
293
+
294
+ // 离散化Y坐标,创建格点系统
295
+ const minY = 0;
296
+ const maxY = chartHeight; // 使用传入的chartHeight
297
+ const gridCount = Math.ceil((maxY - minY) / GRID_SIZE);
298
+
299
+ // 按照Y坐标排序点
300
+ points.sort((a, b) => a.y - b.y);
301
+
302
+ // 创建格点占用标记
303
+ const occupied = new Array(gridCount).fill(false);
304
+ // 存储占用原因,用于可视化
305
+ const occupiedReason = new Array(gridCount).fill("");
306
+
307
+ // 标记圆点周围的保护区域为已占用
308
+ points.forEach((point, idx) => {
309
+ const gridY = Math.floor(point.y / GRID_SIZE) - 3;
310
+ for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
311
+ occupied[i] = true;
312
+ occupiedReason[i] = `数据点${idx + 1}保护区`;
313
+ }
314
+ });
315
+
316
+
317
+ // 定义状态: dp[i][j] 表示前i个点,第i个点的标签放在第j个格点时的最小代价
318
+ const n = points.length;
319
+ const dp = Array(n).fill().map(() => Array(gridCount).fill(Infinity));
320
+ const prev = Array(n).fill().map(() => Array(gridCount).fill(-1));
321
+
322
+ // 初始条件:第一个点的标签放置
323
+ const firstPointGridY = Math.floor(points[0].y / GRID_SIZE);
324
+
325
+ // 尝试将第一个点的标签放在可行的位置
326
+ for (let j = 0; j < gridCount; j++) {
327
+ // 检查是否可行(不在保护区域内且有足够空间放置标签)
328
+ if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
329
+ // 检查标签是否会与其他标签重叠
330
+ let canPlace = true;
331
+ for (let k = 0; k < LABEL_HEIGHT; k++) {
332
+ if (j + k < gridCount && occupied[j + k]) {
333
+ canPlace = false;
334
+ break;
335
+ }
336
+ }
337
+
338
+ if (canPlace) {
339
+ // 计算代价:与圆点位置的距离
340
+ const cost = Math.abs(j - firstPointGridY);
341
+ dp[0][j] = cost;
342
+ }
343
+ }
344
+ }
345
+
346
+ // 填充dp表
347
+ for (let i = 1; i < n; i++) {
348
+ const pointGridY = Math.floor(points[i].y / GRID_SIZE);
349
+
350
+ for (let j = 0; j < gridCount; j++) {
351
+ // 检查当前位置是否可行
352
+ if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
353
+ // 检查标签是否会与其他标签重叠
354
+ let canPlace = true;
355
+ for (let k = 0; k < LABEL_HEIGHT; k++) {
356
+ if (j + k < gridCount && occupied[j + k]) {
357
+ canPlace = false;
358
+ break;
359
+ }
360
+ }
361
+
362
+ if (canPlace) {
363
+ // 根据上一个点的标签位置计算当前最小代价
364
+ for (let k = 0; k + LABEL_HEIGHT <= j; k++) {
365
+ if (dp[i-1][k] !== Infinity) {
366
+
367
+ // 当前标签的代价
368
+ const curCost = Math.abs(j - pointGridY);
369
+ const totalCost = dp[i-1][k] + curCost;
370
+
371
+ if (totalCost < dp[i][j]) {
372
+ dp[i][j] = totalCost;
373
+ prev[i][j] = k; // 记录前驱
374
+ }
375
+ }
376
+ }
377
+ }
378
+ }
379
+ }
380
+ }
381
+
382
+ // 找出最后一个点的最优放置位置
383
+ let minCost = Infinity;
384
+ let bestPos = -1;
385
+
386
+ for (let j = 0; j < gridCount; j++) {
387
+ if (dp[n-1][j] < minCost) {
388
+ minCost = dp[n-1][j];
389
+ bestPos = j;
390
+ }
391
+ }
392
+
393
+ // 存储最终选择的标签位置(格点索引)
394
+ const selectedGridPositions = [];
395
+
396
+ // 回溯构建结果
397
+ const labelPositions = [];
398
+ if (bestPos !== -1) {
399
+ // 从后向前回溯
400
+ let pos = bestPos;
401
+
402
+ for (let i = n - 1; i >= 0; i--) {
403
+ labelPositions.unshift({
404
+ point: points[i],
405
+ labelY: pos * GRID_SIZE
406
+ });
407
+
408
+ // 记录选择的位置
409
+ selectedGridPositions.unshift(pos);
410
+
411
+ pos = prev[i][pos];
412
+ }
413
+ } else {
414
+ // 无可行解,退化为简单方案处理
415
+ let lastY = 0;
416
+ for (let i = 0; i < n; i++) {
417
+ const point = points[i];
418
+ // 确保标签不超过下一个圆点的位置
419
+ let maxY = chartHeight;
420
+ if (i < n - 1) {
421
+ maxY = points[i+1].y;
422
+ }
423
+
424
+ const labelY = Math.min(Math.max(point.y + 20, lastY + 25), maxY - 5);
425
+
426
+ labelPositions.push({
427
+ point: point,
428
+ labelY: labelY
429
+ });
430
+
431
+ lastY = labelY;
432
+ }
433
+ }
434
+
435
+ const debug = false;
436
+
437
+ // 添加格子系统可视化
438
+ if (debug && points.length > 0) {
439
+ // 确定可视化的位置 - 使用数据点的X位置
440
+ const mainPointX = points.length > 0 ? points[0].x : 0; // 使用该组第一个点的X位置
441
+ const visX = mainPointX;
442
+ const visY = 10; // 从图表顶部开始
443
+ const visWidth = 50; // 减小宽度以免干扰图表
444
+
445
+ // 创建一个组用于添加可视化元素,并在需要时应用剪切
446
+ const visGroup = g.append("g");
447
+
448
+ // 限制可视化高度,避免太长
449
+ const maxVisHeight = chartHeight - 20;
450
+ const visibleGridCount = Math.min(gridCount, Math.floor(maxVisHeight / GRID_SIZE));
451
+
452
+ // 添加半透明背景
453
+ visGroup.append("rect")
454
+ .attr("x", visX - 5)
455
+ .attr("y", visY - 15)
456
+ .attr("width", visWidth + 10)
457
+ .attr("height", visibleGridCount * GRID_SIZE + 50)
458
+ .attr("fill", "#fff")
459
+ .attr("opacity", 0.8)
460
+ .attr("rx", 5);
461
+
462
+ // 添加标题
463
+ visGroup.append("text")
464
+ .attr("x", visX + visWidth/2)
465
+ .attr("y", visY - 5)
466
+ .attr("text-anchor", "middle")
467
+ .attr("fill", "#333")
468
+ .style("font-size", "10px")
469
+ .text(debugName + "标签位置");
470
+
471
+ // 绘制每个格子
472
+ for (let i = 0; i < visibleGridCount; i++) {
473
+ // 计算格子的颜色
474
+ let fillColor = "#ddd"; // 默认颜色 - 可用格子
475
+
476
+ if (occupied[i]) {
477
+ fillColor = "#ffcccc"; // 不可用格子 - 红色
478
+ }
479
+
480
+ // 标记最终选择的位置
481
+ const isSelected = selectedGridPositions.some(pos => {
482
+ // 检查格子是否在标签范围内
483
+ for (let j = 0; j < LABEL_HEIGHT; j++) {
484
+ if (pos + j === i) return true;
485
+ }
486
+ return false;
487
+ });
488
+
489
+ if (isSelected) {
490
+ fillColor = "#99ff99"; // 选中的格子 - 绿色
491
+ }
492
+
493
+ // 绘制格子
494
+ visGroup.append("rect")
495
+ .attr("x", visX)
496
+ .attr("y", visY + i * GRID_SIZE)
497
+ .attr("width", visWidth)
498
+ .attr("height", GRID_SIZE)
499
+ .attr("fill", fillColor)
500
+ .attr("stroke", "#ccc")
501
+ .attr("stroke-width", 0.2);
502
+ }
503
+
504
+ // 添加数据点位置标记 - 只显示该组的点
505
+ points.forEach((point, idx) => {
506
+ const gridY = Math.floor(point.y / GRID_SIZE);
507
+
508
+ // 仅当点位置在可视范围内时添加
509
+ if (gridY < visibleGridCount) {
510
+ // 绘制数据点位置线
511
+ visGroup.append("line")
512
+ .attr("x1", visX - 3)
513
+ .attr("y1", visY + gridY * GRID_SIZE + GRID_SIZE/2)
514
+ .attr("x2", visX + visWidth)
515
+ .attr("y2", visY + gridY * GRID_SIZE + GRID_SIZE/2)
516
+ .attr("stroke", point.color)
517
+ .attr("stroke-width", 1)
518
+ .attr("stroke-dasharray", "2,1");
519
+
520
+ // 添加简短的数据点标识
521
+ visGroup.append("text")
522
+ .attr("x", visX - 5)
523
+ .attr("y", visY + gridY * GRID_SIZE + GRID_SIZE/2)
524
+ .attr("text-anchor", "end")
525
+ .attr("dominant-baseline", "middle")
526
+ .attr("fill", point.color)
527
+ .style("font-size", "8px")
528
+ .text("点" + (idx + 1));
529
+ }
530
+ });
531
+
532
+ // 在底部添加简化的图例
533
+ const legendY = visY + visibleGridCount * GRID_SIZE + 5;
534
+ const legendItems = [
535
+ { label: "可用", color: "#ddd" },
536
+ { label: "占用", color: "#ffcccc" },
537
+ { label: "选中", color: "#99ff99" }
538
+ ];
539
+
540
+ legendItems.forEach((item, idx) => {
541
+ // 水平放置图例项
542
+ const itemX = visX + idx * (visWidth / 3);
543
+
544
+ visGroup.append("rect")
545
+ .attr("x", itemX)
546
+ .attr("y", legendY)
547
+ .attr("width", 6)
548
+ .attr("height", 6)
549
+ .attr("fill", item.color);
550
+
551
+ visGroup.append("text")
552
+ .attr("x", itemX + 8)
553
+ .attr("y", legendY + 3)
554
+ .attr("text-anchor", "start")
555
+ .attr("dominant-baseline", "middle")
556
+ .attr("fill", "#333")
557
+ .style("font-size", "6px")
558
+ .text(item.label);
559
+ });
560
+ }
561
+
562
+ return labelPositions;
563
+ }
modules/chart_engine/template/d3-js/line_graph/new_line_chart_01.js ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Multiple Line Graph",
5
+ "chart_name": "vertical_bar_chart_new_01",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["categorical"], ["numerical"]],
8
+ "required_fields_range": [[4, 12], [0, "inf"]],
9
+ "required_fields_icons": ["x"],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": ["primary"],
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
+ "chart_for": "trend"
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
+
44
+ // 获取y轴单位
45
+ const yUnit = chartUtils.schema.column(dataColumns, 1).unit;
46
+
47
+ // 设置尺寸和边距
48
+ const width = variables.width;
49
+ const height = variables.height;
50
+ const margin = { top: 20, right: 30, bottom: 80, left: 50 };
51
+
52
+ // 创建SVG
53
+ const svg = d3.select(containerSelector)
54
+ .append("svg")
55
+ .attr("width", "100%")
56
+ .attr("height", height)
57
+ .attr("viewBox", `0 0 ${width} ${height}`)
58
+ .attr("style", "max-width: 100%; height: auto;")
59
+ .attr("xmlns", "http://www.w3.org/2000/svg")
60
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
61
+
62
+ // 创建图表区域
63
+ const chartWidth = width - margin.left - margin.right;
64
+ const chartHeight = height - margin.top - margin.bottom;
65
+
66
+ const g = svg.append("g")
67
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
68
+
69
+ // 创建x轴比例尺 - 使用band scale来显示柱状图
70
+ const categories = chartData.map(d => d[xField]);
71
+ const xScale = d3.scaleBand()
72
+ .domain(categories)
73
+ .range([0, chartWidth])
74
+ .padding(0.3);
75
+
76
+ // 获取y值范围
77
+ const yMax = d3.max(chartData, d => +d[yField]);
78
+
79
+ // 最小柱子高度
80
+ const minBarHeight = 50;
81
+
82
+ // 创建y轴比例尺 - 调整range使小值也有足够高度
83
+ const effectiveHeight = chartHeight - minBarHeight; // 减去最小柱高
84
+ const yScale = d3.scaleLinear()
85
+ .domain([0, yMax * 1.2]) // 为标签留出空间
86
+ .range([effectiveHeight, 0]); // 调整可用高度范围
87
+
88
+ // 获取柱状图颜色 - 使用colorResolver.other("primary").value
89
+ const barColor = colorResolver.other("primary").value;
90
+
91
+ // 添加y轴网格线
92
+ yScale.ticks(5).forEach(tick => {
93
+ g.append("line")
94
+ .attr("x1", 0)
95
+ .attr("y1", yScale(tick))
96
+ .attr("x2", chartWidth)
97
+ .attr("y2", yScale(tick))
98
+ .attr("stroke", "#e0e0e0")
99
+ .attr("stroke-width", 1)
100
+ .attr("stroke-dasharray", "2,2");
101
+ });
102
+
103
+ // 创建渐变定义
104
+ const defs = svg.append("defs");
105
+
106
+ // 为每个柱子创建唯一的渐变ID
107
+ chartData.forEach((d, i) => {
108
+ const gradientId = `barGradient-${i}`;
109
+
110
+ // 创建线性渐变
111
+ const gradient = defs.append("linearGradient")
112
+ .attr("id", gradientId)
113
+ .attr("x1", "0%")
114
+ .attr("y1", "0%") // 顶部
115
+ .attr("x2", "0%")
116
+ .attr("y2", "100%"); // 底部
117
+
118
+ // 添加渐变停止点
119
+ gradient.append("stop")
120
+ .attr("offset", "0%")
121
+ .attr("stop-color", barColor)
122
+ .attr("stop-opacity", 0.25); // 顶部透明度
123
+
124
+ gradient.append("stop")
125
+ .attr("offset", "100%")
126
+ .attr("stop-color", barColor)
127
+ .attr("stop-opacity", 1.0); // 底部透明度
128
+ });
129
+
130
+ // 添加柱状图
131
+ g.selectAll(".bar")
132
+ .data(chartData)
133
+ .enter()
134
+ .append("rect")
135
+ .attr("class", "bar")
136
+ .attr("x", d => xScale(d[xField]))
137
+ .attr("y", d => yScale(+d[yField]))
138
+ .attr("width", xScale.bandwidth())
139
+ .attr("height", d => {
140
+ // 确保柱子高度至少为minBarHeight
141
+ return chartHeight - yScale(+d[yField]);
142
+ })
143
+ .attr("fill", (d, i) => `url(#barGradient-${i})`) // 使用渐变填充
144
+ .attr("rx", 4) // 圆角
145
+ .attr("ry", 4);
146
+
147
+ // 创建线条生成器,连接各个数据点
148
+ const line = d3.line()
149
+ .x(d => xScale(d[xField]) + xScale.bandwidth()/2)
150
+ .y(d => yScale(+d[yField]))
151
+ .curve(d3.curveLinear);
152
+
153
+ // 添加连接线
154
+ g.append("path")
155
+ .datum(chartData)
156
+ .attr("fill", "none")
157
+ .attr("stroke", barColor)
158
+ .attr("stroke-width", 2)
159
+ .attr("d", line);
160
+
161
+ // 添加数据点圆圈和标签
162
+ const circleRadius = 8; // 更小的圆圈
163
+
164
+ // 添加数据点圆圈
165
+ g.selectAll(".data-point")
166
+ .data(chartData)
167
+ .enter()
168
+ .append("circle")
169
+ .attr("class", "data-point")
170
+ .attr("cx", d => xScale(d[xField]) + xScale.bandwidth()/2)
171
+ .attr("cy", d => yScale(+d[yField]))
172
+ .attr("r", circleRadius)
173
+ .attr("fill", "white")
174
+ .attr("stroke", barColor)
175
+ .attr("stroke-width", 2);
176
+
177
+ // 添加数据标签,使用正确的单位
178
+ g.selectAll(".value-text")
179
+ .data(chartData)
180
+ .enter()
181
+ .append("text")
182
+ .attr("x", d => xScale(d[xField]) + xScale.bandwidth()/2)
183
+ .attr("y", d => yScale(+d[yField]) - circleRadius - 5) // 放在圆圈上方
184
+ .attr("text-anchor", "middle")
185
+ .attr("dominant-baseline", "middle")
186
+ .attr("fill", "#333")
187
+ .style("font-size", "12px")
188
+ .style("font-weight", "bold")
189
+ .text(d => `${chartUtils.format.autoText(+d[yField])}${yUnit}`);
190
+
191
+ // 添加图标,使用images对象中的base64数据
192
+ // 放在柱状图底部,宽度与柱状图相同,减少空隙
193
+ g.selectAll(".icon")
194
+ .data(chartData)
195
+ .enter()
196
+ .append("image")
197
+ .attr("x", d => xScale(d[xField])) // 与柱状图左边缘对齐
198
+ .attr("y", chartHeight - xScale.bandwidth()) // 紧贴柱状图底部
199
+ .attr("width", xScale.bandwidth()) // 宽度与柱状图相同
200
+ .attr("height", xScale.bandwidth())
201
+ .attr("xlink:href", d => {
202
+ // 从images.field获取对应的图标base64数据
203
+ if (images && images.field && images.field[d[xField]]) {
204
+ return images.field[d[xField]];
205
+ }
206
+ // 如果没有找到对应的图标,返回null
207
+ return null;
208
+ });
209
+
210
+ // 计算x轴标签是否需要换行和合适的字体大小
211
+ const calculateLabelFormat = () => {
212
+ // 创建临时canvas用于测量文本宽度
213
+ const canvas = document.createElement('canvas');
214
+ const context = canvas.getContext('2d');
215
+
216
+ // 初始字体大小
217
+ let fontSize = 12;
218
+ const minFontSize = 8; // 最小字体大小
219
+ const padding = 5; // 标签之间的最小间距
220
+ let needsWordWrap = false;
221
+
222
+ // 测试字体大小是否会导致标签重叠
223
+ while (fontSize >= minFontSize) {
224
+ context.font = `${fontSize}px sans-serif`;
225
+
226
+ let isOverlapping = false;
227
+
228
+ // 计算每个标签的宽度
229
+ const labelWidths = categories.map(category => {
230
+ return chartUtils.text.contextWidth(context, category);
231
+ });
232
+
233
+ // 检查相邻标签是否重叠
234
+ for (let i = 0; i < categories.length - 1; i++) {
235
+ const leftLabelWidth = labelWidths[i] / 2;
236
+ const rightLabelWidth = labelWidths[i + 1] / 2;
237
+
238
+ const leftCenter = xScale(categories[i]) + xScale.bandwidth() / 2;
239
+ const rightCenter = xScale(categories[i + 1]) + xScale.bandwidth() / 2;
240
+
241
+ const distance = rightCenter - leftCenter;
242
+
243
+ if (leftLabelWidth + rightLabelWidth + padding > distance) {
244
+ isOverlapping = true;
245
+ break;
246
+ }
247
+ }
248
+
249
+ // 如果不重叠,无需继续减小字体
250
+ if (!isOverlapping) {
251
+ break;
252
+ }
253
+
254
+ // 减小字体大小
255
+ fontSize -= 1;
256
+ }
257
+
258
+ // 检查是否需要换行(达到最小字体仍然重叠)
259
+ if (fontSize < minFontSize) {
260
+ fontSize = minFontSize;
261
+ needsWordWrap = true;
262
+ }
263
+
264
+ return { fontSize, needsWordWrap };
265
+ };
266
+
267
+ // 将文本分成多行
268
+ const wrapText = (text, bandWidth) => {
269
+ const canvas = document.createElement('canvas');
270
+ const context = canvas.getContext('2d');
271
+ context.font = `${minFontSize}px sans-serif`;
272
+
273
+ const words = text.split(' ');
274
+ const lines = [];
275
+ let currentLine = words[0];
276
+
277
+ for (let i = 1; i < words.length; i++) {
278
+ const word = words[i];
279
+ const width = chartUtils.text.contextWidth(context, currentLine + ' ' + word);
280
+
281
+ if (width < bandWidth * 0.8) { // 保留一些边距
282
+ currentLine += ' ' + word;
283
+ } else {
284
+ lines.push(currentLine);
285
+ currentLine = word;
286
+ }
287
+ }
288
+ lines.push(currentLine);
289
+
290
+ return lines;
291
+ };
292
+
293
+ // 获取合适的标签格式
294
+ const { fontSize, needsWordWrap } = calculateLabelFormat();
295
+ const minFontSize = 8; // 定义为常量,供wrapText使用
296
+
297
+ // 添加x轴标签,调整位置使其靠近柱状图底部
298
+ const labelYPosition = chartHeight + 25; // 减少与柱状图底部的间距
299
+
300
+ if (!needsWordWrap) {
301
+ // 单行标签
302
+ g.selectAll(".x-label")
303
+ .data(chartData)
304
+ .enter()
305
+ .append("text")
306
+ .attr("x", d => xScale(d[xField]) + xScale.bandwidth()/2)
307
+ .attr("y", labelYPosition)
308
+ .attr("text-anchor", "middle")
309
+ .attr("fill", "#333")
310
+ .style("font-size", `${fontSize}px`)
311
+ .text(d => d[xField]);
312
+ } else {
313
+ // 多行标签
314
+ chartData.forEach((d, i) => {
315
+ const lines = wrapText(d[xField], xScale.bandwidth());
316
+ const x = xScale(d[xField]) + xScale.bandwidth()/2;
317
+
318
+ // 添加每一行文本
319
+ lines.forEach((line, lineIndex) => {
320
+ g.append("text")
321
+ .attr("x", x)
322
+ .attr("y", labelYPosition + lineIndex * (minFontSize + 2)) // 每行之间添加2px的间距
323
+ .attr("text-anchor", "middle")
324
+ .attr("fill", "#333")
325
+ .style("font-size", `${minFontSize}px`)
326
+ .text(line);
327
+ });
328
+ });
329
+ }
330
+
331
+ // y轴标签 - 仅当确实存在单位时才绘制 (避免显示 sentinel "none")
332
+ if (yUnit) {
333
+ g.append("text")
334
+ .attr("transform", "rotate(-90)")
335
+ .attr("x", -chartHeight / 2)
336
+ .attr("y", -30)
337
+ .attr("text-anchor", "middle")
338
+ .attr("fill", "#333")
339
+ .style("font-size", "14px")
340
+ .text(yUnit);
341
+ }
342
+
343
+ return svg.node();
344
+ }
modules/chart_engine/template/d3-js/line_graph/new_line_chart_02.js ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Multiple Line Graph",
5
+ "chart_name": "vertical_bar_chart_new_02",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["categorical"], ["numerical"]],
8
+ "required_fields_range": [[4, 12], [0, "inf"]],
9
+ "required_fields_icons": ["x"],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": ["primary"],
13
+ "supported_effects": [],
14
+ "min_height": 400,
15
+ "min_width": 800,
16
+ "background": "dark",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "yes",
21
+ "chart_for": "trend"
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_dark || {};
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
+
44
+ // 获取y轴单位
45
+ const yUnit = chartUtils.schema.column(dataColumns, 1).unit;
46
+
47
+ // 设置尺寸和边距
48
+ const width = variables.width;
49
+ const height = variables.height;
50
+ const margin = { top: 20, right: 30, bottom: 80, left: 50 };
51
+
52
+ // 创建SVG
53
+ const svg = d3.select(containerSelector)
54
+ .append("svg")
55
+ .attr("width", "100%")
56
+ .attr("height", height)
57
+ .attr("viewBox", `0 0 ${width} ${height}`)
58
+ .attr("style", "max-width: 100%; height: auto;")
59
+ .attr("xmlns", "http://www.w3.org/2000/svg")
60
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
61
+
62
+ // 创建图表区域
63
+ const chartWidth = width - margin.left - margin.right;
64
+ const chartHeight = height - margin.top - margin.bottom;
65
+
66
+ const g = svg.append("g")
67
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
68
+
69
+ // 创建x轴比例尺 - 使用band scale来显示柱状图
70
+ const categories = chartData.map(d => d[xField]);
71
+ const xScale = d3.scaleBand()
72
+ .domain(categories)
73
+ .range([0, chartWidth])
74
+ .padding(0.3);
75
+
76
+ // 获取y值范围
77
+ const yMax = d3.max(chartData, d => +d[yField]);
78
+
79
+ // 最小柱子高度
80
+ const minBarHeight = 50;
81
+
82
+ // 创建y轴比例尺 - 调整range使小值也有足够高度
83
+ const effectiveHeight = chartHeight - minBarHeight; // 减去最小柱高
84
+ const yScale = d3.scaleLinear()
85
+ .domain([0, yMax * 1.2]) // 为标签留出空间
86
+ .range([effectiveHeight, 0]); // 调整可用高度范围
87
+
88
+ // 获取柱状图颜色 - 使用colorResolver.other("primary").value
89
+ const barColor = colorResolver.other("primary").value;
90
+
91
+ // 创建渐变定义
92
+ const defs = svg.append("defs");
93
+
94
+ // 为每个柱子创建唯一的渐变ID
95
+ chartData.forEach((d, i) => {
96
+ const gradientId = `barGradient-${i}`;
97
+
98
+ // 创建线性渐变
99
+ const gradient = defs.append("linearGradient")
100
+ .attr("id", gradientId)
101
+ .attr("x1", "0%")
102
+ .attr("y1", "0%") // 顶部
103
+ .attr("x2", "0%")
104
+ .attr("y2", "100%"); // 底部
105
+
106
+ // 添加渐变停止点
107
+ gradient.append("stop")
108
+ .attr("offset", "0%")
109
+ .attr("stop-color", barColor)
110
+ .attr("stop-opacity", 0.25); // 顶部透明度
111
+
112
+ gradient.append("stop")
113
+ .attr("offset", "100%")
114
+ .attr("stop-color", "white")
115
+ .attr("stop-opacity", 1.0); // 底部透明度
116
+ });
117
+
118
+ // 添加柱状图
119
+ g.selectAll(".bar")
120
+ .data(chartData)
121
+ .enter()
122
+ .append("rect")
123
+ .attr("class", "bar")
124
+ .attr("x", d => xScale(d[xField]))
125
+ .attr("y", d => yScale(+d[yField]))
126
+ .attr("width", xScale.bandwidth())
127
+ .attr("height", d => {
128
+ // 确保柱子高度至少为minBarHeight
129
+ return chartHeight - yScale(+d[yField]);
130
+ })
131
+ .attr("fill", (d, i) => `url(#barGradient-${i})`) // 使用渐变填充
132
+ .attr("rx", 4) // 圆角
133
+ .attr("ry", 4);
134
+
135
+ // 创建线条生成器,连接各个数据点
136
+ const line = d3.line()
137
+ .x(d => xScale(d[xField]) + xScale.bandwidth()/2)
138
+ .y(d => yScale(+d[yField]))
139
+ .curve(d3.curveLinear);
140
+
141
+ // 添加连接线
142
+ g.append("path")
143
+ .datum(chartData)
144
+ .attr("fill", "none")
145
+ .attr("stroke", barColor)
146
+ .attr("stroke-width", 2)
147
+ .attr("d", line);
148
+
149
+ // 添加数据点圆圈和标签
150
+ const circleRadius = 8; // 更小的圆圈
151
+
152
+ // 添加数据点圆圈
153
+ g.selectAll(".data-point")
154
+ .data(chartData)
155
+ .enter()
156
+ .append("circle")
157
+ .attr("class", "data-point")
158
+ .attr("cx", d => xScale(d[xField]) + xScale.bandwidth()/2)
159
+ .attr("cy", d => yScale(+d[yField]))
160
+ .attr("r", circleRadius)
161
+ .attr("fill", "white")
162
+ .attr("stroke", barColor)
163
+ .attr("stroke-width", 2);
164
+
165
+ // 添加数据标签,使用正确的单位
166
+ g.selectAll(".value-text")
167
+ .data(chartData)
168
+ .enter()
169
+ .append("text")
170
+ .attr("x", d => xScale(d[xField]) + xScale.bandwidth()/2)
171
+ .attr("y", d => yScale(+d[yField]) - circleRadius - 5) // 放在圆圈上方
172
+ .attr("text-anchor", "middle")
173
+ .attr("dominant-baseline", "middle")
174
+ .attr("fill", "white")
175
+ .style("font-size", "12px")
176
+ .style("font-weight", "bold")
177
+ .text(d => `${chartUtils.format.autoText(+d[yField])}`);
178
+
179
+ // 添加图标,使用images对象中的base64数据
180
+ // 放在柱状图底部,宽度与柱状图相同,减少空隙
181
+ g.selectAll(".icon")
182
+ .data(chartData)
183
+ .enter()
184
+ .append("image")
185
+ .attr("x", d => xScale(d[xField])) // 与柱状图左边缘对齐
186
+ .attr("y", chartHeight - xScale.bandwidth()) // 紧贴柱状图底部
187
+ .attr("width", xScale.bandwidth()) // 宽度与柱状图相同
188
+ .attr("height", xScale.bandwidth())
189
+ .attr("xlink:href", d => {
190
+ // 从images.field获取对应的图标base64数据
191
+ if (images && images.field && images.field[d[xField]]) {
192
+ return images.field[d[xField]];
193
+ }
194
+ // 如果没有找到对应的图标,返回null
195
+ return null;
196
+ });
197
+
198
+ // 计算x轴标签是否需要换行和合适的字体大小
199
+ const calculateLabelFormat = () => {
200
+ // 创建临时canvas用于测量文本宽度
201
+ const canvas = document.createElement('canvas');
202
+ const context = canvas.getContext('2d');
203
+
204
+ // 初始字体大小
205
+ let fontSize = 12;
206
+ const minFontSize = 8; // 最小字体大小
207
+ const padding = 5; // 标签之间的最小间距
208
+ let needsWordWrap = false;
209
+
210
+ // 测试字体大小是否会导致标签重叠
211
+ while (fontSize >= minFontSize) {
212
+ context.font = `${fontSize}px sans-serif`;
213
+
214
+ let isOverlapping = false;
215
+
216
+ // 计算每个标签的宽度
217
+ const labelWidths = categories.map(category => {
218
+ return chartUtils.text.contextWidth(context, category);
219
+ });
220
+
221
+ // 检查相邻标签是否重叠
222
+ for (let i = 0; i < categories.length - 1; i++) {
223
+ const leftLabelWidth = labelWidths[i] / 2;
224
+ const rightLabelWidth = labelWidths[i + 1] / 2;
225
+
226
+ const leftCenter = xScale(categories[i]) + xScale.bandwidth() / 2;
227
+ const rightCenter = xScale(categories[i + 1]) + xScale.bandwidth() / 2;
228
+
229
+ const distance = rightCenter - leftCenter;
230
+
231
+ if (leftLabelWidth + rightLabelWidth + padding > distance) {
232
+ isOverlapping = true;
233
+ break;
234
+ }
235
+ }
236
+
237
+ // 如果不重叠,无需继续减小字体
238
+ if (!isOverlapping) {
239
+ break;
240
+ }
241
+
242
+ // 减小字体大小
243
+ fontSize -= 1;
244
+ }
245
+
246
+ // 检查是否需要换行(达到最小字体仍然重叠)
247
+ if (fontSize < minFontSize) {
248
+ fontSize = minFontSize;
249
+ needsWordWrap = true;
250
+ }
251
+
252
+ return { fontSize, needsWordWrap };
253
+ };
254
+
255
+ // 将文本分成多行
256
+ const wrapText = (text, bandWidth) => {
257
+ const canvas = document.createElement('canvas');
258
+ const context = canvas.getContext('2d');
259
+ context.font = `${minFontSize}px sans-serif`;
260
+
261
+ const words = text.split(' ');
262
+ const lines = [];
263
+ let currentLine = words[0];
264
+
265
+ for (let i = 1; i < words.length; i++) {
266
+ const word = words[i];
267
+ const width = chartUtils.text.contextWidth(context, currentLine + ' ' + word);
268
+
269
+ if (width < bandWidth * 0.8) { // 保留一些边距
270
+ currentLine += ' ' + word;
271
+ } else {
272
+ lines.push(currentLine);
273
+ currentLine = word;
274
+ }
275
+ }
276
+ lines.push(currentLine);
277
+
278
+ return lines;
279
+ };
280
+
281
+ // 获取合适的标签格式
282
+ const { fontSize, needsWordWrap } = calculateLabelFormat();
283
+ const minFontSize = 8; // 定义为常量,供wrapText使用
284
+
285
+ // 添加x轴标签,调整位置使其靠近柱��图底部
286
+ const labelYPosition = chartHeight + 25; // 减少与柱状图底部的间距
287
+
288
+ if (!needsWordWrap) {
289
+ // 单行标签
290
+ g.selectAll(".x-label")
291
+ .data(chartData)
292
+ .enter()
293
+ .append("text")
294
+ .attr("x", d => xScale(d[xField]) + xScale.bandwidth()/2)
295
+ .attr("y", labelYPosition)
296
+ .attr("text-anchor", "middle")
297
+ .attr("fill", "white")
298
+ .style("font-size", `${fontSize}px`)
299
+ .text(d => d[xField]);
300
+ } else {
301
+ // 多行标签
302
+ chartData.forEach((d, i) => {
303
+ const lines = wrapText(d[xField], xScale.bandwidth());
304
+ const x = xScale(d[xField]) + xScale.bandwidth()/2;
305
+
306
+ // 添加每一行文本
307
+ lines.forEach((line, lineIndex) => {
308
+ g.append("text")
309
+ .attr("x", x)
310
+ .attr("y", labelYPosition + lineIndex * (minFontSize + 2)) // 每行之间添加2px的间距
311
+ .attr("text-anchor", "middle")
312
+ .attr("fill", "white")
313
+ .style("font-size", `${minFontSize}px`)
314
+ .text(line);
315
+ });
316
+ });
317
+ }
318
+
319
+ // y轴标签 - 仅当确实存在单位时才绘制 (避免显示 sentinel "none")
320
+ if (yUnit) {
321
+ g.append("text")
322
+ .attr("transform", "rotate(-90)")
323
+ .attr("x", -chartHeight / 2)
324
+ .attr("y", -30)
325
+ .attr("text-anchor", "middle")
326
+ .attr("fill", "white")
327
+ .style("font-size", "14px")
328
+ .text(yUnit);
329
+ }
330
+
331
+ return svg.node();
332
+ }
modules/chart_engine/template/d3-js/line_graph/slope_chart_01.js ADDED
@@ -0,0 +1,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Slope Chart",
5
+ "chart_name": "slope_chart_01",
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.max(0, yMin - yPadding); // 确保下限不小于0
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 endPoints = [];
169
+
170
+ groupedData.forEach((values, group) => {
171
+ // 确保数据按日期排序
172
+ values.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
173
+
174
+ const color = colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
175
+
176
+ // 只获取第一个和最后一个点
177
+ const firstPoint = values[0];
178
+ const lastPoint = values[values.length - 1];
179
+
180
+ // 创建只包含首尾两点的数据
181
+ const slopeData = [firstPoint, lastPoint];
182
+
183
+ // 绘制线条
184
+ g.append("path")
185
+ .datum(slopeData)
186
+ .attr("fill", "none")
187
+ .attr("stroke", color)
188
+ .attr("stroke-width", lineWidth)
189
+ .attr("d", line);
190
+
191
+ // 收集需要标注的点
192
+ // 起点
193
+ startPoints.push({
194
+ x: xScale(chartUtils.format.parseDate(firstPoint[xField])),
195
+ y: yScale(+firstPoint[yField]),
196
+ value: chartUtils.format.integer(firstPoint[yField]).text,
197
+ color: color,
198
+ group: group,
199
+ point: firstPoint
200
+ });
201
+
202
+ // 终点
203
+ endPoints.push({
204
+ x: xScale(chartUtils.format.parseDate(lastPoint[xField])),
205
+ y: yScale(+lastPoint[yField]),
206
+ value: chartUtils.format.integer(lastPoint[yField]).text,
207
+ color: color,
208
+ group: group,
209
+ point: lastPoint
210
+ });
211
+ });
212
+
213
+ // 分别对起点和终点应用动态规划 - 添加g参数
214
+ const startLabelPositions = placeLabelsDP(startPoints, yTicks.map(tick => yScale(tick)), "起点", chartHeight, chartWidth, g);
215
+ const endLabelPositions = placeLabelsDP(endPoints, yTicks.map(tick => yScale(tick)), "终点", chartHeight, chartWidth, g);
216
+
217
+ // 绘制标签函数 - 保持multiple_line_graph_10的样式
218
+ function drawLabels(labelPositions) {
219
+ labelPositions.forEach(placement => {
220
+ const point = placement.point;
221
+ const labelY = placement.labelY;
222
+
223
+ // 计算标签文本和宽度
224
+ const labelText = point.value;
225
+ const labelWidth = chartUtils.text.measure(null, labelText, { fontSize: 14, fontWeight: "bold" }).width + 10;
226
+ const labelHeight = 24;
227
+
228
+ // 添加圆角矩形背景
229
+ g.append("rect")
230
+ .attr("x", point.x - labelWidth / 2)
231
+ .attr("y", labelY + labelHeight / 2)
232
+ .attr("width", labelWidth)
233
+ .attr("height", labelHeight)
234
+ .attr("rx", 5)
235
+ .attr("ry", 5)
236
+ .attr("fill", point.color);
237
+
238
+ // 添加文本 - 白色粗体
239
+ g.append("text")
240
+ .attr("x", point.x)
241
+ .attr("y", labelY + labelHeight)
242
+ .attr("text-anchor", "middle")
243
+ .attr("dominant-baseline", "middle")
244
+ .attr("fill", "#fff")
245
+ .attr("font-weight", "bold")
246
+ .style("font-size", "14px")
247
+ .text(labelText);
248
+ });
249
+ }
250
+
251
+ // 绘制所有标签 - 只绘制起点和终点
252
+ drawLabels(startLabelPositions);
253
+ drawLabels(endLabelPositions);
254
+
255
+ const legendSize = chartUtils.legend.draw(g, groups, {
256
+ fontSize: 14,
257
+ fontWeight: "bold",
258
+ maxWidth: chartWidth,
259
+ markerShape: "line",
260
+ markerSize: 10,
261
+ strokeWidth: 4,
262
+ colorScale,
263
+ });
264
+
265
+ const maxYTickPosition = yScale(yTicks[yTicks.length - 1]);
266
+
267
+ legendSize.group.attr("transform", `translate(${(chartWidth - legendSize.width) / 2}, ${maxYTickPosition - 50 - legendSize.height/2})`);
268
+
269
+ return svg.node();
270
+ }
271
+
272
+ // 动态规划标签放置算法 - 添加g参数
273
+ function placeLabelsDP(points, avoidYPositions = [], debugName = "", chartHeight, chartWidth, g) {
274
+ // 每个格点的高度(像素)
275
+ const GRID_SIZE = 3;
276
+ // 圆点周围的保护区域(格点数)
277
+ const PROTECTION_RADIUS = 3;
278
+ // 标签高度(格点数)
279
+ const LABEL_HEIGHT = 10;
280
+
281
+ // 离散化Y坐标,创建格点系统
282
+ const minY = 0;
283
+ const maxY = chartHeight; // 使用传入的chartHeight
284
+ const gridCount = Math.ceil((maxY - minY) / GRID_SIZE);
285
+
286
+ // 按照Y坐标排序点
287
+ points.sort((a, b) => a.y - b.y);
288
+
289
+ // 创建格点占用标记
290
+ const occupied = new Array(gridCount).fill(false);
291
+ // 存储占用原因,用于可视化
292
+ const occupiedReason = new Array(gridCount).fill("");
293
+
294
+ // 标记圆点周围的保护区域为已占用
295
+ points.forEach((point, idx) => {
296
+ const gridY = Math.floor(point.y / GRID_SIZE) - 3;
297
+ for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
298
+ occupied[i] = true;
299
+ occupiedReason[i] = `数据点${idx + 1}保护区`;
300
+ }
301
+ });
302
+
303
+
304
+ // 定义状态: dp[i][j] 表示前i个点,第i个点的标签放在第j个格点时的最小代价
305
+ const n = points.length;
306
+ const dp = Array(n).fill().map(() => Array(gridCount).fill(Infinity));
307
+ const prev = Array(n).fill().map(() => Array(gridCount).fill(-1));
308
+
309
+ // 初始条件:第一个点的标签放置
310
+ const firstPointGridY = Math.floor(points[0].y / GRID_SIZE);
311
+
312
+ // 尝试将第一个点的标签放在可行的位置
313
+ for (let j = 0; j < gridCount; j++) {
314
+ // 检查是否可行(不在保护区域内且有足够空间放置标签)
315
+ if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
316
+ // 检查标签是否会与其他标签重叠
317
+ let canPlace = true;
318
+ for (let k = 0; k < LABEL_HEIGHT; k++) {
319
+ if (j + k < gridCount && occupied[j + k]) {
320
+ canPlace = false;
321
+ break;
322
+ }
323
+ }
324
+
325
+ if (canPlace) {
326
+ // 计算代价:与圆点位置的距离
327
+ const cost = Math.abs(j - firstPointGridY);
328
+ dp[0][j] = cost;
329
+ }
330
+ }
331
+ }
332
+
333
+ // 填充dp表
334
+ for (let i = 1; i < n; i++) {
335
+ const pointGridY = Math.floor(points[i].y / GRID_SIZE);
336
+
337
+ for (let j = 0; j < gridCount; j++) {
338
+ // 检查当前位置是否可行
339
+ if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
340
+ // 检查标签是否会与其他标签重叠
341
+ let canPlace = true;
342
+ for (let k = 0; k < LABEL_HEIGHT; k++) {
343
+ if (j + k < gridCount && occupied[j + k]) {
344
+ canPlace = false;
345
+ break;
346
+ }
347
+ }
348
+
349
+ if (canPlace) {
350
+ // 根据上一个点的标签位置计算当前最小代价
351
+ for (let k = 0; k + LABEL_HEIGHT <= j; k++) {
352
+ if (dp[i-1][k] !== Infinity) {
353
+
354
+ // 当前标签的代价
355
+ const curCost = Math.abs(j - pointGridY);
356
+ const totalCost = dp[i-1][k] + curCost;
357
+
358
+ if (totalCost < dp[i][j]) {
359
+ dp[i][j] = totalCost;
360
+ prev[i][j] = k; // 记录前驱
361
+ }
362
+ }
363
+ }
364
+ }
365
+ }
366
+ }
367
+ }
368
+
369
+ // 找出最后一个点的最优放置位置
370
+ let minCost = Infinity;
371
+ let bestPos = -1;
372
+
373
+ for (let j = 0; j < gridCount; j++) {
374
+ if (dp[n-1][j] < minCost) {
375
+ minCost = dp[n-1][j];
376
+ bestPos = j;
377
+ }
378
+ }
379
+
380
+ // 存储最终选择的标签位置(格点索引)
381
+ const selectedGridPositions = [];
382
+
383
+ // 回溯构建结果
384
+ const labelPositions = [];
385
+ if (bestPos !== -1) {
386
+ // 从后向前回溯
387
+ let pos = bestPos;
388
+
389
+ for (let i = n - 1; i >= 0; i--) {
390
+ labelPositions.unshift({
391
+ point: points[i],
392
+ labelY: pos * GRID_SIZE
393
+ });
394
+
395
+ // 记录选择的位置
396
+ selectedGridPositions.unshift(pos);
397
+
398
+ pos = prev[i][pos];
399
+ }
400
+ } else {
401
+ // 无可行解,退化为简单方案处理
402
+ let lastY = 0;
403
+ for (let i = 0; i < n; i++) {
404
+ const point = points[i];
405
+ // 确保标签不超过下一个圆点的位置
406
+ let maxY = chartHeight;
407
+ if (i < n - 1) {
408
+ maxY = points[i+1].y;
409
+ }
410
+
411
+ const labelY = Math.min(Math.max(point.y + 20, lastY + 25), maxY - 5);
412
+
413
+ labelPositions.push({
414
+ point: point,
415
+ labelY: labelY
416
+ });
417
+
418
+ lastY = labelY;
419
+ }
420
+ }
421
+
422
+ const debug = false;
423
+
424
+ // 添加格子系统可视化
425
+ if (debug && points.length > 0) {
426
+ // 确定可视化的位置 - 使用数据点的X位���
427
+ const mainPointX = points.length > 0 ? points[0].x : 0; // 使用该组第一个点的X位置
428
+ const visX = mainPointX;
429
+ const visY = 10; // 从图表顶部开始
430
+ const visWidth = 50; // 减小宽度以免干扰图表
431
+
432
+ // 创建一个组用于添加可视化元素,并在需要时应用剪切
433
+ const visGroup = g.append("g");
434
+
435
+ // 限制可视化高度,避免太长
436
+ const maxVisHeight = chartHeight - 20;
437
+ const visibleGridCount = Math.min(gridCount, Math.floor(maxVisHeight / GRID_SIZE));
438
+
439
+ // 添加半透明背景
440
+ visGroup.append("rect")
441
+ .attr("x", visX - 5)
442
+ .attr("y", visY - 15)
443
+ .attr("width", visWidth + 10)
444
+ .attr("height", visibleGridCount * GRID_SIZE + 50)
445
+ .attr("fill", "#fff")
446
+ .attr("opacity", 0.8)
447
+ .attr("rx", 5);
448
+
449
+ // 添加标题
450
+ visGroup.append("text")
451
+ .attr("x", visX + visWidth/2)
452
+ .attr("y", visY - 5)
453
+ .attr("text-anchor", "middle")
454
+ .attr("fill", "#333")
455
+ .style("font-size", "10px")
456
+ .text(debugName + "标签位置");
457
+
458
+ // 绘制每个格子
459
+ for (let i = 0; i < visibleGridCount; i++) {
460
+ // 计算格子的颜色
461
+ let fillColor = "#ddd"; // 默认颜色 - 可用格子
462
+
463
+ if (occupied[i]) {
464
+ fillColor = "#ffcccc"; // 不可用格子 - 红色
465
+ }
466
+
467
+ // 标记最终选择的位置
468
+ const isSelected = selectedGridPositions.some(pos => {
469
+ // 检查格子是否在标签范围内
470
+ for (let j = 0; j < LABEL_HEIGHT; j++) {
471
+ if (pos + j === i) return true;
472
+ }
473
+ return false;
474
+ });
475
+
476
+ if (isSelected) {
477
+ fillColor = "#99ff99"; // 选中的格子 - 绿色
478
+ }
479
+
480
+ // 绘制格子
481
+ visGroup.append("rect")
482
+ .attr("x", visX)
483
+ .attr("y", visY + i * GRID_SIZE)
484
+ .attr("width", visWidth)
485
+ .attr("height", GRID_SIZE)
486
+ .attr("fill", fillColor)
487
+ .attr("stroke", "#ccc")
488
+ .attr("stroke-width", 0.2);
489
+ }
490
+
491
+ // 添加数据点位置标记 - 只显示该组的点
492
+ points.forEach((point, idx) => {
493
+ const gridY = Math.floor(point.y / GRID_SIZE);
494
+
495
+ // 仅当点位置在可视范围内时添加
496
+ if (gridY < visibleGridCount) {
497
+ // 绘制数据点位置线
498
+ visGroup.append("line")
499
+ .attr("x1", visX - 3)
500
+ .attr("y1", visY + gridY * GRID_SIZE + GRID_SIZE/2)
501
+ .attr("x2", visX + visWidth)
502
+ .attr("y2", visY + gridY * GRID_SIZE + GRID_SIZE/2)
503
+ .attr("stroke", point.color)
504
+ .attr("stroke-width", 1)
505
+ .attr("stroke-dasharray", "2,1");
506
+
507
+ // 添加简短的数据点标识
508
+ visGroup.append("text")
509
+ .attr("x", visX - 5)
510
+ .attr("y", visY + gridY * GRID_SIZE + GRID_SIZE/2)
511
+ .attr("text-anchor", "end")
512
+ .attr("dominant-baseline", "middle")
513
+ .attr("fill", point.color)
514
+ .style("font-size", "8px")
515
+ .text("点" + (idx + 1));
516
+ }
517
+ });
518
+
519
+ // 在底部添加简化的图例
520
+ const legendY = visY + visibleGridCount * GRID_SIZE + 5;
521
+ const legendItems = [
522
+ { label: "可用", color: "#ddd" },
523
+ { label: "占用", color: "#ffcccc" },
524
+ { label: "选中", color: "#99ff99" }
525
+ ];
526
+
527
+ legendItems.forEach((item, idx) => {
528
+ // 水平放置图例项
529
+ const itemX = visX + idx * (visWidth / 3);
530
+
531
+ visGroup.append("rect")
532
+ .attr("x", itemX)
533
+ .attr("y", legendY)
534
+ .attr("width", 6)
535
+ .attr("height", 6)
536
+ .attr("fill", item.color);
537
+
538
+ visGroup.append("text")
539
+ .attr("x", itemX + 8)
540
+ .attr("y", legendY + 3)
541
+ .attr("text-anchor", "start")
542
+ .attr("dominant-baseline", "middle")
543
+ .attr("fill", "#333")
544
+ .style("font-size", "6px")
545
+ .text(item.label);
546
+ });
547
+ }
548
+
549
+ return labelPositions;
550
+ }
modules/chart_engine/template/d3-js/line_graph/slope_chart_02.js ADDED
@@ -0,0 +1,551 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Slope Chart",
5
+ "chart_name": "slope_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": "dark",
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_dark || {};
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.max(0, yMin - yPadding); // 确保下限不小于0
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", colorResolver.text({ fallback: "#333333" }).value)
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", colorResolver.text({ fallback: "#333333" }).value)
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 endPoints = [];
169
+
170
+ groupedData.forEach((values, group) => {
171
+ // 确保数据按日期排序
172
+ values.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
173
+
174
+ const color = colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
175
+
176
+ // 只获取第一个和最后一个点
177
+ const firstPoint = values[0];
178
+ const lastPoint = values[values.length - 1];
179
+
180
+ // 创建只包含首尾两点的数据
181
+ const slopeData = [firstPoint, lastPoint];
182
+
183
+ // 绘制线条
184
+ g.append("path")
185
+ .datum(slopeData)
186
+ .attr("fill", "none")
187
+ .attr("stroke", color)
188
+ .attr("stroke-width", lineWidth)
189
+ .attr("d", line);
190
+
191
+ // 收集需要标注的点
192
+ // 起点
193
+ startPoints.push({
194
+ x: xScale(chartUtils.format.parseDate(firstPoint[xField])),
195
+ y: yScale(+firstPoint[yField]),
196
+ value: chartUtils.format.integer(firstPoint[yField]).text,
197
+ color: color,
198
+ group: group,
199
+ point: firstPoint
200
+ });
201
+
202
+ // 终点
203
+ endPoints.push({
204
+ x: xScale(chartUtils.format.parseDate(lastPoint[xField])),
205
+ y: yScale(+lastPoint[yField]),
206
+ value: chartUtils.format.integer(lastPoint[yField]).text,
207
+ color: color,
208
+ group: group,
209
+ point: lastPoint
210
+ });
211
+ });
212
+
213
+ // 分别对起点和终点应用动态规划 - 添加g参数
214
+ const startLabelPositions = placeLabelsDP(startPoints, yTicks.map(tick => yScale(tick)), "起点", chartHeight, chartWidth, g);
215
+ const endLabelPositions = placeLabelsDP(endPoints, yTicks.map(tick => yScale(tick)), "终点", chartHeight, chartWidth, g);
216
+
217
+ // 绘制标签函数 - 保持multiple_line_graph_10的样式
218
+ function drawLabels(labelPositions) {
219
+ labelPositions.forEach(placement => {
220
+ const point = placement.point;
221
+ const labelY = placement.labelY;
222
+
223
+ // 计算标签文本和宽度
224
+ const labelText = point.value;
225
+ const labelWidth = chartUtils.text.measure(null, labelText, { fontSize: 14, fontWeight: "bold" }).width + 10;
226
+ const labelHeight = 24;
227
+
228
+ // 添加圆角矩形背景
229
+ g.append("rect")
230
+ .attr("x", point.x - labelWidth / 2)
231
+ .attr("y", labelY + labelHeight / 2)
232
+ .attr("width", labelWidth)
233
+ .attr("height", labelHeight)
234
+ .attr("rx", 5)
235
+ .attr("ry", 5)
236
+ .attr("fill", point.color);
237
+
238
+ // 添加文本 - 白色粗体
239
+ g.append("text")
240
+ .attr("x", point.x)
241
+ .attr("y", labelY + labelHeight)
242
+ .attr("text-anchor", "middle")
243
+ .attr("dominant-baseline", "middle")
244
+ .attr("fill", colorResolver.text({ fallback: "#333333" }).value)
245
+ .attr("font-weight", "bold")
246
+ .style("font-size", "14px")
247
+ .text(labelText);
248
+ });
249
+ }
250
+
251
+ // 绘制所有标签 - 只绘制起点和终点
252
+ drawLabels(startLabelPositions);
253
+ drawLabels(endLabelPositions);
254
+
255
+ const legendSize = chartUtils.legend.draw(g, groups, {
256
+ fontSize: 14,
257
+ fontWeight: "bold",
258
+ maxWidth: chartWidth,
259
+ markerShape: "line",
260
+ markerSize: 10,
261
+ strokeWidth: 4,
262
+ colorScale,
263
+ textColor: colorResolver.text({ fallback: "#333333" }).value,
264
+ });
265
+
266
+ const maxYTickPosition = yScale(yTicks[yTicks.length - 1]);
267
+
268
+ legendSize.group.attr("transform", `translate(${(chartWidth - legendSize.width) / 2}, ${maxYTickPosition - 50 - legendSize.height/2})`);
269
+
270
+ return svg.node();
271
+ }
272
+
273
+ // 动态规划标签放置算法 - 添加g参数
274
+ function placeLabelsDP(points, avoidYPositions = [], debugName = "", chartHeight, chartWidth, g) {
275
+ // 每个格点的高度(像素)
276
+ const GRID_SIZE = 3;
277
+ // 圆点周围的保护区��(格点数)
278
+ const PROTECTION_RADIUS = 3;
279
+ // 标签高度(格点数)
280
+ const LABEL_HEIGHT = 10;
281
+
282
+ // 离散化Y坐标,创建格点系统
283
+ const minY = 0;
284
+ const maxY = chartHeight; // 使用传入的chartHeight
285
+ const gridCount = Math.ceil((maxY - minY) / GRID_SIZE);
286
+
287
+ // 按照Y坐标排序点
288
+ points.sort((a, b) => a.y - b.y);
289
+
290
+ // 创建格点占用标记
291
+ const occupied = new Array(gridCount).fill(false);
292
+ // 存储占用原因,用于可视化
293
+ const occupiedReason = new Array(gridCount).fill("");
294
+
295
+ // 标记圆点周围的保护区域为已占用
296
+ points.forEach((point, idx) => {
297
+ const gridY = Math.floor(point.y / GRID_SIZE) - 3;
298
+ for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
299
+ occupied[i] = true;
300
+ occupiedReason[i] = `数据点${idx + 1}保护区`;
301
+ }
302
+ });
303
+
304
+
305
+ // 定义状态: dp[i][j] 表示前i个点,第i个点的标签放在第j个格点时的最小代价
306
+ const n = points.length;
307
+ const dp = Array(n).fill().map(() => Array(gridCount).fill(Infinity));
308
+ const prev = Array(n).fill().map(() => Array(gridCount).fill(-1));
309
+
310
+ // 初始条件:第一个点的标签放置
311
+ const firstPointGridY = Math.floor(points[0].y / GRID_SIZE);
312
+
313
+ // 尝试将第一个点的标签放在可行的位置
314
+ for (let j = 0; j < gridCount; j++) {
315
+ // 检查是否可行(不在保护区域内且有足够空间放置标签)
316
+ if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
317
+ // 检查标签是否会与其他标签重叠
318
+ let canPlace = true;
319
+ for (let k = 0; k < LABEL_HEIGHT; k++) {
320
+ if (j + k < gridCount && occupied[j + k]) {
321
+ canPlace = false;
322
+ break;
323
+ }
324
+ }
325
+
326
+ if (canPlace) {
327
+ // 计算代价:与圆点位置的距离
328
+ const cost = Math.abs(j - firstPointGridY);
329
+ dp[0][j] = cost;
330
+ }
331
+ }
332
+ }
333
+
334
+ // 填充dp表
335
+ for (let i = 1; i < n; i++) {
336
+ const pointGridY = Math.floor(points[i].y / GRID_SIZE);
337
+
338
+ for (let j = 0; j < gridCount; j++) {
339
+ // 检查当前位置是否可行
340
+ if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
341
+ // 检查标签是否会与其他标签重叠
342
+ let canPlace = true;
343
+ for (let k = 0; k < LABEL_HEIGHT; k++) {
344
+ if (j + k < gridCount && occupied[j + k]) {
345
+ canPlace = false;
346
+ break;
347
+ }
348
+ }
349
+
350
+ if (canPlace) {
351
+ // 根据上一个点的标签位置计算当前最小代价
352
+ for (let k = 0; k + LABEL_HEIGHT <= j; k++) {
353
+ if (dp[i-1][k] !== Infinity) {
354
+
355
+ // 当前标签的代价
356
+ const curCost = Math.abs(j - pointGridY);
357
+ const totalCost = dp[i-1][k] + curCost;
358
+
359
+ if (totalCost < dp[i][j]) {
360
+ dp[i][j] = totalCost;
361
+ prev[i][j] = k; // 记录前驱
362
+ }
363
+ }
364
+ }
365
+ }
366
+ }
367
+ }
368
+ }
369
+
370
+ // 找出最后一个点的最优放置位置
371
+ let minCost = Infinity;
372
+ let bestPos = -1;
373
+
374
+ for (let j = 0; j < gridCount; j++) {
375
+ if (dp[n-1][j] < minCost) {
376
+ minCost = dp[n-1][j];
377
+ bestPos = j;
378
+ }
379
+ }
380
+
381
+ // 存储最终选择的标签位置(格点索引)
382
+ const selectedGridPositions = [];
383
+
384
+ // 回溯构建结果
385
+ const labelPositions = [];
386
+ if (bestPos !== -1) {
387
+ // 从后向前回溯
388
+ let pos = bestPos;
389
+
390
+ for (let i = n - 1; i >= 0; i--) {
391
+ labelPositions.unshift({
392
+ point: points[i],
393
+ labelY: pos * GRID_SIZE
394
+ });
395
+
396
+ // 记录选择的位置
397
+ selectedGridPositions.unshift(pos);
398
+
399
+ pos = prev[i][pos];
400
+ }
401
+ } else {
402
+ // 无可行解,退化为简单方案处理
403
+ let lastY = 0;
404
+ for (let i = 0; i < n; i++) {
405
+ const point = points[i];
406
+ // 确保标签不超过下一个圆点的位置
407
+ let maxY = chartHeight;
408
+ if (i < n - 1) {
409
+ maxY = points[i+1].y;
410
+ }
411
+
412
+ const labelY = Math.min(Math.max(point.y + 20, lastY + 25), maxY - 5);
413
+
414
+ labelPositions.push({
415
+ point: point,
416
+ labelY: labelY
417
+ });
418
+
419
+ lastY = labelY;
420
+ }
421
+ }
422
+
423
+ const debug = false;
424
+
425
+ // 添加格子系统可视化
426
+ if (debug && points.length > 0) {
427
+ // 确定可视化的位置 - 使用数据点的X位置
428
+ const mainPointX = points.length > 0 ? points[0].x : 0; // 使用该组第一个点的X位置
429
+ const visX = mainPointX;
430
+ const visY = 10; // 从图表顶部开始
431
+ const visWidth = 50; // 减小宽度以免干扰图表
432
+
433
+ // 创建一个组用于添加可视化元素,并在需要时应用剪切
434
+ const visGroup = g.append("g");
435
+
436
+ // 限制可视化高度,避免太长
437
+ const maxVisHeight = chartHeight - 20;
438
+ const visibleGridCount = Math.min(gridCount, Math.floor(maxVisHeight / GRID_SIZE));
439
+
440
+ // 添加半透明背景
441
+ visGroup.append("rect")
442
+ .attr("x", visX - 5)
443
+ .attr("y", visY - 15)
444
+ .attr("width", visWidth + 10)
445
+ .attr("height", visibleGridCount * GRID_SIZE + 50)
446
+ .attr("fill", "#fff")
447
+ .attr("opacity", 0.8)
448
+ .attr("rx", 5);
449
+
450
+ // 添加标题
451
+ visGroup.append("text")
452
+ .attr("x", visX + visWidth/2)
453
+ .attr("y", visY - 5)
454
+ .attr("text-anchor", "middle")
455
+ .attr("fill", colorResolver.text({ fallback: "#333333" }).value)
456
+ .style("font-size", "10px")
457
+ .text(debugName + "标签位置");
458
+
459
+ // 绘制每个格子
460
+ for (let i = 0; i < visibleGridCount; i++) {
461
+ // 计算格子的颜色
462
+ let fillColor = "#ddd"; // 默认颜色 - 可用格子
463
+
464
+ if (occupied[i]) {
465
+ fillColor = "#ffcccc"; // 不可用格子 - 红色
466
+ }
467
+
468
+ // 标记最终选择的位置
469
+ const isSelected = selectedGridPositions.some(pos => {
470
+ // 检查格子是否在标签范围内
471
+ for (let j = 0; j < LABEL_HEIGHT; j++) {
472
+ if (pos + j === i) return true;
473
+ }
474
+ return false;
475
+ });
476
+
477
+ if (isSelected) {
478
+ fillColor = "#99ff99"; // 选中的格子 - 绿色
479
+ }
480
+
481
+ // 绘制格子
482
+ visGroup.append("rect")
483
+ .attr("x", visX)
484
+ .attr("y", visY + i * GRID_SIZE)
485
+ .attr("width", visWidth)
486
+ .attr("height", GRID_SIZE)
487
+ .attr("fill", fillColor)
488
+ .attr("stroke", "#ccc")
489
+ .attr("stroke-width", 0.2);
490
+ }
491
+
492
+ // 添加数据点位置标记 - 只显示该组的点
493
+ points.forEach((point, idx) => {
494
+ const gridY = Math.floor(point.y / GRID_SIZE);
495
+
496
+ // 仅当点位置在可视范围内时添加
497
+ if (gridY < visibleGridCount) {
498
+ // 绘制数据点位置线
499
+ visGroup.append("line")
500
+ .attr("x1", visX - 3)
501
+ .attr("y1", visY + gridY * GRID_SIZE + GRID_SIZE/2)
502
+ .attr("x2", visX + visWidth)
503
+ .attr("y2", visY + gridY * GRID_SIZE + GRID_SIZE/2)
504
+ .attr("stroke", point.color)
505
+ .attr("stroke-width", 1)
506
+ .attr("stroke-dasharray", "2,1");
507
+
508
+ // 添加简短的数据点标识
509
+ visGroup.append("text")
510
+ .attr("x", visX - 5)
511
+ .attr("y", visY + gridY * GRID_SIZE + GRID_SIZE/2)
512
+ .attr("text-anchor", "end")
513
+ .attr("dominant-baseline", "middle")
514
+ .attr("fill", colorResolver.text({ fallback: "#333333" }).value)
515
+ .style("font-size", "8px")
516
+ .text("点" + (idx + 1));
517
+ }
518
+ });
519
+
520
+ // 在底部添加简化的图例
521
+ const legendY = visY + visibleGridCount * GRID_SIZE + 5;
522
+ const legendItems = [
523
+ { label: "可用", color: "#ddd" },
524
+ { label: "占用", color: "#ffcccc" },
525
+ { label: "选中", color: "#99ff99" }
526
+ ];
527
+
528
+ legendItems.forEach((item, idx) => {
529
+ // 水平放置图例项
530
+ const itemX = visX + idx * (visWidth / 3);
531
+
532
+ visGroup.append("rect")
533
+ .attr("x", itemX)
534
+ .attr("y", legendY)
535
+ .attr("width", 6)
536
+ .attr("height", 6)
537
+ .attr("fill", item.color);
538
+
539
+ visGroup.append("text")
540
+ .attr("x", itemX + 8)
541
+ .attr("y", legendY + 3)
542
+ .attr("text-anchor", "start")
543
+ .attr("dominant-baseline", "middle")
544
+ .attr("fill", colorResolver.text({ fallback: "#333333" }).value)
545
+ .style("font-size", "6px")
546
+ .text(item.label);
547
+ });
548
+ }
549
+
550
+ return labelPositions;
551
+ }
modules/chart_engine/template/d3-js/line_graph/small_multiple_line_graph_01.js ADDED
@@ -0,0 +1,564 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "small multiple line graph",
5
+ "chart_name": "small_multiple_line_graph_01",
6
+ "is_composite": false,
7
+ "required_fields": ["x", "y", "group"],
8
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
9
+ "required_fields_range": [[5, 30], ["-inf", "inf"], [2, 8]],
10
+ "required_fields_icons": [],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": ["group"],
13
+ "required_other_colors": ["primary"],
14
+ "supported_effects": ["stroke", "spacing"],
15
+ "min_height": 600,
16
+ "min_width": 800,
17
+ "background": "light",
18
+ "icon_mark": "none",
19
+ "icon_label": "none",
20
+ "has_x_axis": "yes",
21
+ "has_y_axis": "yes"
22
+ }
23
+ REQUIREMENTS_END
24
+ */
25
+
26
+ function makeChart(containerSelector, data) {
27
+ const jsonData = data || {};
28
+ const chartData = (jsonData.data && jsonData.data.data) || [];
29
+ const dataColumns = chartUtils.schema.columns(jsonData);
30
+ const colorResolver = chartUtils.color.resolver(jsonData);
31
+ const chartUtilsFormatSample = chartUtils.format.autoText;
32
+ const chartUtilsTextSample = chartUtils.text.estimate;
33
+ const chartUtilsStandard = {
34
+ schema: chartUtils.schema,
35
+ format: chartUtils.format,
36
+ text: chartUtils.text,
37
+ color: chartUtils.color,
38
+ legendLayout: chartUtils.legend.layout,
39
+ legendDraw: chartUtils.legend.draw,
40
+ random: chartUtils.random.generator(jsonData, "dev-wz-style")
41
+ };
42
+ const standardChannels = chartUtils.schema.channels(jsonData, {
43
+ x: { fallbackIndex: 0 },
44
+ y: { fallbackIndex: 1 },
45
+ y2: { fallbackIndex: 2 },
46
+ y3: { fallbackIndex: 3 },
47
+ size: { fallbackIndex: 2 },
48
+ group: { fallbackIndex: 2 },
49
+ group2: { fallbackIndex: 3 },
50
+ group3: { fallbackIndex: 4 }
51
+ });
52
+ const variables = jsonData.variables || {};
53
+ const typography = jsonData.typography || {
54
+ label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
55
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
56
+ title: { font_family: "Arial", font_size: "14px", font_weight: "bold" }
57
+ };
58
+ const sourceColors = jsonData.colors || {};
59
+ const colorField = sourceColors.field || {};
60
+ const colorOther = sourceColors.other || {};
61
+
62
+ d3.select(containerSelector).html("");
63
+
64
+ const xColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
65
+ const yColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
66
+ const groupColumn = chartUtils.schema.channel(jsonData, "group", { fallbackIndex: 2 }).raw || {};
67
+ const xField = xColumn.name || "date";
68
+ const yField = yColumn.name || "value";
69
+ const groupField = groupColumn.name || "group";
70
+ const yUnit = yColumn.unit && yColumn.unit !== "none" ? yColumn.unit : "";
71
+
72
+ const width = Number(variables.width) || 800;
73
+ const height = Number(variables.height) || 620;
74
+ const textColor = sourceColors.text_color || "#243041";
75
+ const primaryColor = colorOther.primary || "#2f7db7";
76
+ const secondaryColor = colorOther.secondary || "#5b8f69";
77
+ const canvasColor = "#eef7f4";
78
+ const panelColor = "#fffdf7";
79
+ const cardColor = "#ffffff";
80
+ const linePalette = ["#2f7db7", "#f07a2b", "#67a84f", "#d6a22b", "#7b68b2", "#bf5f8d", "#2d9c9c", "#5a6f8f"];
81
+ const labelTypography = typography.label || {};
82
+ const titleTypography = typography.title || labelTypography;
83
+ const valueTypography = typography.annotation || labelTypography;
84
+ const labelFamily = labelTypography.font_family || "Arial";
85
+ const titleFamily = titleTypography.font_family || labelFamily;
86
+ const valueFamily = valueTypography.font_family || labelFamily;
87
+ const labelWeight = labelTypography.font_weight || "normal";
88
+ const titleWeight = titleTypography.font_weight || "700";
89
+ const valueWeight = valueTypography.font_weight || "600";
90
+ const baseLabelSize = parseFloat(labelTypography.font_size) || 11;
91
+ const baseTitleSize = parseFloat(titleTypography.font_size) || 13;
92
+ const baseValueSize = parseFloat(valueTypography.font_size) || 11;
93
+
94
+ function rgba(color, opacity) {
95
+ const c = d3.rgb(color);
96
+ return `rgba(${c.r}, ${c.g}, ${c.b}, ${opacity})`;
97
+ }
98
+
99
+ function parseTemporalDate(value) {
100
+ if (value instanceof Date && Number.isFinite(value.getTime())) return value;
101
+ const text = String(value ?? "").trim();
102
+ if (!text) return null;
103
+
104
+ let match = text.match(/^(-?\d{4})$/);
105
+ if (match) return new Date(Date.UTC(Number(match[1]), 0, 1));
106
+
107
+ match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
108
+ if (match) {
109
+ return new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3] || 1)));
110
+ }
111
+
112
+ match = text.match(/^Q([1-4])\s+(-?\d{4})$/i) || text.match(/^(-?\d{4})\s+Q([1-4])$/i);
113
+ if (match) {
114
+ const year = match[1].length === 4 ? Number(match[1]) : Number(match[2]);
115
+ const quarter = match[1].length === 4 ? Number(match[2]) : Number(match[1]);
116
+ return new Date(Date.UTC(year, (quarter - 1) * 3, 1));
117
+ }
118
+
119
+ const season = text.match(/^(spring|summer|fall|autumn|winter)\s+(-?\d{4})$/i);
120
+ if (season) {
121
+ const monthBySeason = { spring: 2, summer: 5, fall: 8, autumn: 8, winter: 11 };
122
+ return new Date(Date.UTC(Number(season[2]), monthBySeason[season[1].toLowerCase()], 1));
123
+ }
124
+
125
+ const parsed = new Date(text);
126
+ if (Number.isFinite(parsed.getTime())) {
127
+ return new Date(Date.UTC(parsed.getUTCFullYear(), parsed.getUTCMonth(), parsed.getUTCDate()));
128
+ }
129
+ return null;
130
+ }
131
+
132
+ function formatNumber(value) {
133
+ const numericValue = Number(value);
134
+ if (!Number.isFinite(numericValue)) return String(value ?? "");
135
+ const absValue = Math.abs(numericValue);
136
+ if (absValue >= 1000000000) return `${d3.format(".3~g")(numericValue / 1000000000)}B`;
137
+ if (absValue >= 1000000) return `${d3.format(".3~g")(numericValue / 1000000)}M`;
138
+ if (absValue >= 1000) return `${d3.format(".3~g")(numericValue / 1000)}K`;
139
+ return d3.format(",.4~g")(numericValue);
140
+ }
141
+
142
+ function formatWithUnit(value, unit) {
143
+ const formatted = formatNumber(value);
144
+ if (!unit) return formatted;
145
+ if (unit === "$") return `$${formatted}`;
146
+ if (unit === "%") return `${formatted}%`;
147
+ return `${formatted} ${unit}`;
148
+ }
149
+
150
+ function wrapText(text, maxChars) {
151
+ const words = String(text || "").split(/\s+/).filter(Boolean);
152
+ if (!words.length) return [String(text || "")];
153
+ const lines = [];
154
+ let current = "";
155
+ words.forEach(word => {
156
+ const next = current ? `${current} ${word}` : word;
157
+ if (next.length <= maxChars) {
158
+ current = next;
159
+ return;
160
+ }
161
+ if (current) lines.push(current);
162
+ if (word.length <= maxChars) {
163
+ current = word;
164
+ return;
165
+ }
166
+ let chunk = "";
167
+ for (const char of word) {
168
+ if ((chunk + char).length > maxChars) {
169
+ lines.push(`${chunk}-`);
170
+ chunk = char;
171
+ } else {
172
+ chunk += char;
173
+ }
174
+ }
175
+ current = chunk;
176
+ });
177
+ if (current) lines.push(current);
178
+ return lines.length ? lines : [String(text || "")];
179
+ }
180
+
181
+ function selectEvenTicks(values, maxTicks) {
182
+ if (values.length <= maxTicks) return values;
183
+ const selected = [];
184
+ for (let i = 0; i < maxTicks; i += 1) {
185
+ const index = Math.round(i * (values.length - 1) / (maxTicks - 1));
186
+ const value = values[index];
187
+ if (!selected.includes(value)) selected.push(value);
188
+ }
189
+ return selected;
190
+ }
191
+
192
+ const parsedRows = chartData.map((d, index) => {
193
+ const date = parseTemporalDate(d[xField]);
194
+ const value = Number(d[yField]);
195
+ const group = String(d[groupField] ?? "");
196
+ return {
197
+ ...d,
198
+ __index: index,
199
+ __date: date,
200
+ __time: date ? date.getTime() : NaN,
201
+ __xLabel: String(d[xField] ?? ""),
202
+ __value: value,
203
+ __group: group
204
+ };
205
+ }).filter(d => d.__date && Number.isFinite(d.__time) && Number.isFinite(d.__value) && d.__group !== "");
206
+
207
+ if (!parsedRows.length) return;
208
+
209
+ const groups = [];
210
+ parsedRows.forEach(d => {
211
+ if (!groups.includes(d.__group)) groups.push(d.__group);
212
+ });
213
+ if (!groups.length) return;
214
+
215
+ const xLabelByTime = new Map();
216
+ parsedRows.forEach(d => {
217
+ if (!xLabelByTime.has(d.__time)) xLabelByTime.set(d.__time, d.__xLabel);
218
+ });
219
+
220
+ const groupSeries = groups.map(group => {
221
+ const byTime = new Map();
222
+ parsedRows.filter(d => d.__group === group).forEach(d => {
223
+ if (!byTime.has(d.__time)) {
224
+ byTime.set(d.__time, {
225
+ date: d.__date,
226
+ time: d.__time,
227
+ label: d.__xLabel,
228
+ valueSum: 0,
229
+ count: 0
230
+ });
231
+ }
232
+ const entry = byTime.get(d.__time);
233
+ entry.valueSum += d.__value;
234
+ entry.count += 1;
235
+ });
236
+ const values = Array.from(byTime.values())
237
+ .map(d => ({
238
+ date: d.date,
239
+ time: d.time,
240
+ label: d.label,
241
+ value: d.valueSum / d.count
242
+ }))
243
+ .sort((a, b) => a.time - b.time);
244
+ return { group, values };
245
+ }).filter(d => d.values.length);
246
+
247
+ if (!groupSeries.length) return;
248
+
249
+ const xValues = Array.from(new Set(parsedRows.map(d => d.__time)))
250
+ .sort((a, b) => a - b)
251
+ .map(time => new Date(time));
252
+ const allValues = groupSeries.flatMap(series => series.values.map(d => d.value));
253
+ let yMin = d3.min(allValues);
254
+ let yMax = d3.max(allValues);
255
+ if (!Number.isFinite(yMin) || !Number.isFinite(yMax)) return;
256
+ const rawYMin = yMin;
257
+ const rawYMax = yMax;
258
+ if (yMin === yMax) {
259
+ const pad = Math.max(1, Math.abs(yMin) * 0.1);
260
+ yMin -= pad;
261
+ yMax += pad;
262
+ } else {
263
+ const pad = (yMax - yMin) * 0.1;
264
+ yMin -= pad;
265
+ yMax += pad;
266
+ }
267
+ if (rawYMin >= 0) yMin = Math.max(0, yMin);
268
+ if (rawYMax <= 0) yMax = Math.min(0, yMax);
269
+ if (yMin > 0 && (yMax - yMin) / Math.max(1, Math.abs(yMax)) > 0.55) yMin = 0;
270
+
271
+ const cols = groupSeries.length <= 2 ? groupSeries.length : (groupSeries.length <= 4 ? 2 : 3);
272
+ const rows = Math.ceil(groupSeries.length / cols);
273
+ const outer = { top: 20, right: 22, bottom: 26, left: 22 };
274
+ const gapX = variables.has_spacing === false ? 14 : 20;
275
+ const gapY = variables.has_spacing === false ? 18 : 24;
276
+ const panelWidth = (width - outer.left - outer.right - gapX * (cols - 1)) / cols;
277
+ const panelHeight = (height - outer.top - outer.bottom - gapY * (rows - 1)) / rows;
278
+ const plot = {
279
+ left: Math.min(50, Math.max(40, panelWidth * 0.18)),
280
+ right: 14,
281
+ top: 50,
282
+ bottom: 34
283
+ };
284
+ const innerWidth = Math.max(80, panelWidth - plot.left - plot.right);
285
+ const innerHeight = Math.max(70, panelHeight - plot.top - plot.bottom);
286
+ const tickCount = innerWidth < 145 ? 2 : (innerWidth < 230 ? 3 : 4);
287
+ const xTicks = selectEvenTicks(xValues, tickCount);
288
+
289
+ const xScale = d3.scaleUtc()
290
+ .domain(d3.extent(xValues))
291
+ .range([0, innerWidth]);
292
+ const yScale = d3.scaleLinear()
293
+ .domain([yMin, yMax])
294
+ .nice(4)
295
+ .range([innerHeight, 0]);
296
+ const yTicks = yScale.ticks(innerHeight < 95 ? 3 : 4);
297
+
298
+ const palette = d3.schemeTableau10 || d3.schemeCategory10;
299
+ const colorScale = d3.scaleOrdinal()
300
+ .domain(groups)
301
+ .range(groups.map((group, index) => {
302
+ if (colorField[group]) return colorField[group];
303
+ return linePalette[index % linePalette.length] || palette[index % palette.length] || primaryColor;
304
+ }));
305
+
306
+ function formatXTick(date) {
307
+ const label = xLabelByTime.get(date.getTime());
308
+ if (label) return label;
309
+ return d3.utcFormat("%Y")(date);
310
+ }
311
+
312
+ const yScaleNote = rawYMin >= 0
313
+ ? "Shared y-scale starts at zero for nonnegative data."
314
+ : "All panels use a shared y-scale from the original values.";
315
+
316
+ const line = d3.line()
317
+ .defined(d => Number.isFinite(d.value))
318
+ .x(d => xScale(d.date))
319
+ .y(d => yScale(d.value))
320
+ .curve(d3.curveMonotoneX);
321
+
322
+ const svg = d3.select(containerSelector)
323
+ .append("svg")
324
+ .attr("width", "100%")
325
+ .attr("height", height)
326
+ .attr("viewBox", `0 0 ${width} ${height}`)
327
+ .attr("style", "max-width: 100%; height: auto;")
328
+ .attr("xmlns", "http://www.w3.org/2000/svg");
329
+
330
+ const defs = svg.append("defs");
331
+ const panelGradient = defs.append("linearGradient")
332
+ .attr("id", "small_multiple_line_01_panel_gradient")
333
+ .attr("x1", "0%")
334
+ .attr("x2", "0%")
335
+ .attr("y1", "0%")
336
+ .attr("y2", "100%");
337
+ panelGradient.append("stop").attr("offset", "0%").attr("stop-color", "#fffef9");
338
+ panelGradient.append("stop").attr("offset", "100%").attr("stop-color", panelColor);
339
+ const cardShadow = defs.append("filter")
340
+ .attr("id", "small_multiple_line_01_card_shadow")
341
+ .attr("x", "-12%")
342
+ .attr("y", "-18%")
343
+ .attr("width", "124%")
344
+ .attr("height", "140%");
345
+ cardShadow.append("feDropShadow")
346
+ .attr("dx", 0)
347
+ .attr("dy", 1.4)
348
+ .attr("stdDeviation", 1.5)
349
+ .attr("flood-color", "#315d5a")
350
+ .attr("flood-opacity", 0.12);
351
+
352
+ svg.append("rect")
353
+ .attr("class", "chart-canvas")
354
+ .attr("x", 0)
355
+ .attr("y", 0)
356
+ .attr("width", width)
357
+ .attr("height", height)
358
+ .attr("fill", canvasColor);
359
+
360
+ svg.append("rect")
361
+ .attr("class", "chart-style-panel")
362
+ .attr("x", 9)
363
+ .attr("y", 9)
364
+ .attr("width", width - 18)
365
+ .attr("height", height - 18)
366
+ .attr("rx", 12)
367
+ .attr("ry", 12)
368
+ .attr("fill", "url(#small_multiple_line_01_panel_gradient)")
369
+ .attr("stroke", "rgba(95, 143, 120, 0.28)")
370
+ .attr("stroke-width", 1.1);
371
+
372
+ groupSeries.forEach((series, index) => {
373
+ const row = Math.floor(index / cols);
374
+ const col = index % cols;
375
+ const panelX = outer.left + col * (panelWidth + gapX);
376
+ const panelY = outer.top + row * (panelHeight + gapY);
377
+ const panel = svg.append("g")
378
+ .attr("class", "subplot")
379
+ .attr("data-group", series.group)
380
+ .attr("transform", `translate(${panelX}, ${panelY})`);
381
+
382
+ panel.append("rect")
383
+ .attr("class", "subplot-background subplot-card")
384
+ .attr("width", panelWidth)
385
+ .attr("height", panelHeight)
386
+ .attr("rx", 10)
387
+ .attr("ry", 10)
388
+ .attr("fill", cardColor)
389
+ .attr("stroke", "rgba(91, 134, 119, 0.16)")
390
+ .attr("stroke-width", 0.9)
391
+ .style("filter", "url(#small_multiple_line_01_card_shadow)");
392
+
393
+ panel.append("rect")
394
+ .attr("class", "subplot-accent")
395
+ .attr("x", 0)
396
+ .attr("y", 0)
397
+ .attr("width", panelWidth)
398
+ .attr("height", 7)
399
+ .attr("rx", 10)
400
+ .attr("ry", 10)
401
+ .attr("fill", colorScale(series.group))
402
+ .attr("opacity", 0.9);
403
+
404
+ const titleLines = wrapText(series.group, Math.max(9, Math.floor((panelWidth - 20) / 7.2)));
405
+ panel.append("line")
406
+ .attr("class", "group-swatch")
407
+ .attr("x1", 14)
408
+ .attr("x2", 34)
409
+ .attr("y1", 23)
410
+ .attr("y2", 23)
411
+ .attr("stroke", colorScale(series.group))
412
+ .attr("stroke-width", 3.2)
413
+ .attr("stroke-linecap", "round");
414
+
415
+ const title = panel.append("text")
416
+ .attr("class", "label group-label")
417
+ .attr("data-group", series.group)
418
+ .attr("x", 42)
419
+ .attr("y", 20)
420
+ .attr("text-anchor", "start")
421
+ .style("font-family", titleFamily)
422
+ .style("font-size", `${Math.max(10, Math.min(baseTitleSize, 13))}px`)
423
+ .style("font-weight", titleWeight)
424
+ .style("fill", textColor);
425
+ titleLines.slice(0, 3).forEach((lineText, lineIndex) => {
426
+ title.append("tspan")
427
+ .attr("x", 42)
428
+ .attr("dy", lineIndex === 0 ? 0 : 12)
429
+ .text(lineText);
430
+ });
431
+
432
+ const chart = panel.append("g")
433
+ .attr("class", "panel-plot")
434
+ .attr("transform", `translate(${plot.left}, ${plot.top})`);
435
+
436
+ yTicks.forEach(tick => {
437
+ chart.append("line")
438
+ .attr("class", "gridline")
439
+ .attr("x1", 0)
440
+ .attr("x2", innerWidth)
441
+ .attr("y1", yScale(tick))
442
+ .attr("y2", yScale(tick))
443
+ .attr("stroke", rgba(secondaryColor, 0.14))
444
+ .attr("stroke-width", 1);
445
+ chart.append("text")
446
+ .attr("class", "axis-tick y-axis-tick")
447
+ .attr("x", -7)
448
+ .attr("y", yScale(tick))
449
+ .attr("text-anchor", "end")
450
+ .attr("dominant-baseline", "middle")
451
+ .style("font-family", labelFamily)
452
+ .style("font-size", `${Math.max(8.5, Math.min(baseLabelSize, 10))}px`)
453
+ .style("font-weight", labelWeight)
454
+ .style("fill", rgba(textColor, 0.62))
455
+ .text(formatWithUnit(tick, yUnit));
456
+ });
457
+
458
+ chart.append("line")
459
+ .attr("class", "axis-line x-axis-line")
460
+ .attr("x1", 0)
461
+ .attr("x2", innerWidth)
462
+ .attr("y1", innerHeight)
463
+ .attr("y2", innerHeight)
464
+ .attr("stroke", rgba(textColor, 0.28))
465
+ .attr("stroke-width", 1);
466
+
467
+ xTicks.forEach(tick => {
468
+ chart.append("line")
469
+ .attr("class", "axis-tick-line")
470
+ .attr("x1", xScale(tick))
471
+ .attr("x2", xScale(tick))
472
+ .attr("y1", innerHeight)
473
+ .attr("y2", innerHeight + 4)
474
+ .attr("stroke", rgba(textColor, 0.36))
475
+ .attr("stroke-width", 1);
476
+ chart.append("text")
477
+ .attr("class", "axis-tick x-axis-tick")
478
+ .attr("x", xScale(tick))
479
+ .attr("y", innerHeight + 17)
480
+ .attr("text-anchor", "middle")
481
+ .style("font-family", labelFamily)
482
+ .style("font-size", `${Math.max(8.2, Math.min(baseLabelSize, 9.5))}px`)
483
+ .style("font-weight", labelWeight)
484
+ .style("fill", rgba(textColor, 0.72))
485
+ .text(formatXTick(tick));
486
+ });
487
+
488
+ chart.append("path")
489
+ .datum(series.values)
490
+ .attr("class", "line mark")
491
+ .attr("data-group", series.group)
492
+ .attr("data-count", series.values.length)
493
+ .attr("data-latest-value", series.values[series.values.length - 1]?.value ?? "")
494
+ .attr("fill", "none")
495
+ .attr("stroke", colorScale(series.group))
496
+ .attr("stroke-width", variables.has_stroke === false ? 2.6 : 3.2)
497
+ .attr("stroke-linecap", "round")
498
+ .attr("stroke-linejoin", "round")
499
+ .attr("d", line);
500
+
501
+ chart.selectAll(".point")
502
+ .data(series.values)
503
+ .enter()
504
+ .append("circle")
505
+ .attr("class", "point mark")
506
+ .attr("data-group", series.group)
507
+ .attr("data-time", d => d.time)
508
+ .attr("data-label", d => d.label)
509
+ .attr("data-value", d => d.value)
510
+ .attr("cx", d => xScale(d.date))
511
+ .attr("cy", d => yScale(d.value))
512
+ .attr("r", 3)
513
+ .attr("fill", "#ffffff")
514
+ .attr("stroke", colorScale(series.group))
515
+ .attr("stroke-width", 1.7);
516
+
517
+ const lastPoint = series.values[series.values.length - 1];
518
+ if (lastPoint) {
519
+ const latestText = formatWithUnit(lastPoint.value, yUnit);
520
+ const latestFontSize = Math.max(8.5, Math.min(baseValueSize, 10.5));
521
+ const latestY = Math.max(9, Math.min(innerHeight - 5, yScale(lastPoint.value) - 8));
522
+ const pillWidth = Math.max(34, latestText.length * latestFontSize * 0.58 + 12);
523
+ chart.append("rect")
524
+ .attr("class", "latest-value-pill")
525
+ .attr("data-group", series.group)
526
+ .attr("x", innerWidth - pillWidth)
527
+ .attr("y", latestY - latestFontSize + 1)
528
+ .attr("width", pillWidth)
529
+ .attr("height", latestFontSize + 8)
530
+ .attr("rx", 7)
531
+ .attr("ry", 7)
532
+ .attr("fill", "#fffdf8")
533
+ .attr("stroke", rgba(colorScale(series.group), 0.32))
534
+ .attr("stroke-width", 0.9);
535
+ chart.append("text")
536
+ .attr("class", "value latest-value")
537
+ .attr("data-group", series.group)
538
+ .attr("data-time", lastPoint.time)
539
+ .attr("data-label", lastPoint.label)
540
+ .attr("data-value", lastPoint.value)
541
+ .attr("x", innerWidth)
542
+ .attr("y", latestY)
543
+ .attr("text-anchor", "end")
544
+ .style("font-family", valueFamily)
545
+ .style("font-size", `${latestFontSize}px`)
546
+ .style("font-weight", valueWeight)
547
+ .style("fill", colorScale(series.group))
548
+ .text(latestText);
549
+ }
550
+ });
551
+
552
+ svg.append("text")
553
+ .attr("class", "scale-note")
554
+ .attr("x", width / 2)
555
+ .attr("y", height - 13)
556
+ .attr("text-anchor", "middle")
557
+ .style("font-family", valueFamily)
558
+ .style("font-size", `${Math.max(8, Math.min(baseValueSize, 10))}px`)
559
+ .style("font-style", "italic")
560
+ .style("fill", rgba(textColor, 0.58))
561
+ .text(yScaleNote);
562
+
563
+ return svg.node();
564
+ }
modules/chart_engine/template/d3-js/line_graph/small_multiple_step_line_graph_01.js ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Small Multiple Step Line Graph",
5
+ "chart_name": "small_multiple_step_line_graph_01",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[5, 30], ["-inf", "inf"], [4, 4]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["group"],
12
+ "required_other_colors": [],
13
+ "supported_effects": [],
14
+ "min_height": 600,
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
+ // 提取数据
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_dark || {};
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 groups = [...new Set(chartData.map(d => d[groupField]))];
46
+
47
+ // 设置尺寸和边距
48
+ const width = variables.width;
49
+ const height = variables.height;
50
+ const margin = { top: 20, right: 20, bottom: 20, left: 20 };
51
+
52
+ // 计算子图布局 - 2x2布局或根据组数量调整
53
+ const rows = Math.ceil(groups.length / 2);
54
+ const cols = Math.min(groups.length, 2);
55
+
56
+ // 子图尺寸
57
+ const subplotWidth = (width - margin.left - margin.right) / cols;
58
+ const subplotHeight = (height - margin.top - margin.bottom) / rows;
59
+ const subplotMargin = { top: 60, right: 20, bottom: 40, left: 50 };
60
+ const innerWidth = subplotWidth - subplotMargin.left - subplotMargin.right;
61
+ const innerHeight = subplotHeight - subplotMargin.top - subplotMargin.bottom;
62
+
63
+ // 创建SVG
64
+ const svg = d3.select(containerSelector)
65
+ .append("svg")
66
+ .attr("width", "100%")
67
+ .attr("height", height)
68
+ .attr("viewBox", `0 0 ${width} ${height}`)
69
+ .attr("style", "max-width: 100%; height: auto;")
70
+ .attr("xmlns", "http://www.w3.org/2000/svg")
71
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
72
+
73
+ // 创建defs元素用于存放遮罩
74
+ const defs = svg.append("defs");
75
+
76
+ const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, innerWidth);
77
+
78
+ // 为每个组创建子图
79
+ groups.forEach((group, i) => {
80
+ const row = Math.floor(i / cols);
81
+ const col = i % cols;
82
+
83
+ // 子图位置
84
+ const subplotX = margin.left + col * subplotWidth;
85
+ const subplotY = margin.top + row * subplotHeight;
86
+
87
+ // 创建子图组
88
+ const subplot = svg.append("g")
89
+ .attr("class", "subplot")
90
+ .attr("transform", `translate(${subplotX}, ${subplotY})`);
91
+
92
+ // 创建图表组
93
+ const g = subplot.append("g")
94
+ .attr("transform", `translate(${subplotMargin.left}, ${subplotMargin.top})`);
95
+
96
+ // 获取当前组的数据
97
+ const groupData = chartData.filter(d => d[groupField] === group);
98
+
99
+ // 创建y轴比例尺 - 根据数据调整
100
+ const groupYMin = Math.min(0, d3.min(groupData, d => +d[yField]) * 1.4);
101
+ const groupYMax = d3.max(groupData, d => +d[yField]) * 1.1;
102
+
103
+ const yScale = d3.scaleLinear()
104
+ .domain([groupYMin, groupYMax])
105
+ .range([innerHeight, 0]);
106
+
107
+ // 添加网格线 - 减少刻度密度
108
+ const yTicks = yScale.ticks(4); // 从6减少到4
109
+ yTicks.forEach(tick => {
110
+ g.append("line")
111
+ .attr("x1", 0)
112
+ .attr("y1", yScale(tick))
113
+ .attr("x2", innerWidth)
114
+ .attr("y2", yScale(tick))
115
+ .attr("stroke", "#333")
116
+ .attr("stroke-width", 0.5);
117
+ });
118
+
119
+ // 减少X轴刻度密度
120
+ xTicks.forEach(tick => {
121
+ g.append("line")
122
+ .attr("x1", xScale(tick))
123
+ .attr("y1", 0)
124
+ .attr("x2", xScale(tick))
125
+ .attr("y2", innerHeight)
126
+ .attr("stroke", "#333")
127
+ .attr("stroke-width", 0.5);
128
+ });
129
+
130
+ // 添加X轴线条(绘制在0刻度处)
131
+ g.append("line")
132
+ .attr("x1", 0)
133
+ .attr("y1", yScale(0)) // 使用0刻度的Y坐标
134
+ .attr("x2", innerWidth)
135
+ .attr("y2", yScale(0)) // 使用0���度的Y坐标
136
+ .attr("stroke", "#999999")
137
+ .attr("stroke-width", 1);
138
+
139
+ // 如果是最右侧的图表,在右侧也添加Y轴线
140
+ if (col === cols - 1) {
141
+ g.append("line")
142
+ .attr("x1", innerWidth)
143
+ .attr("y1", 0)
144
+ .attr("x2", innerWidth)
145
+ .attr("y2", innerHeight)
146
+ .attr("stroke", "#999999")
147
+ .attr("stroke-width", 1);
148
+ } else {
149
+ g.append("line")
150
+ .attr("x1", 0)
151
+ .attr("y1", 0)
152
+ .attr("x2", 0)
153
+ .attr("y2", innerHeight)
154
+ .attr("stroke", "#999999")
155
+ .attr("stroke-width", 1);
156
+ }
157
+
158
+ // 添加标题和图片
159
+ subplot.append("text")
160
+ .attr("x", subplotMargin.left + 40)
161
+ .attr("y", 32)
162
+ .attr("text-anchor", "start")
163
+ .style("font-family", 'Impact')
164
+ .style("font-size", "24px")
165
+ // .style("font-weight", "bold")
166
+ .style("fill", "#FFFFFF")
167
+ .text(group);
168
+
169
+ // 添加组图片(如果有)
170
+ if (images.field && images.field[group]) {
171
+ // 创建圆形遮罩
172
+ const maskId = `circle-mask-${i}`;
173
+
174
+ // 添加圆形遮罩定义
175
+ defs.append("mask")
176
+ .attr("id", maskId)
177
+ .append("circle")
178
+ .attr("cx", 18)
179
+ .attr("cy", 18)
180
+ .attr("r", 19.5)
181
+ .attr("fill", "white");
182
+
183
+ // 获取图片的base64数据
184
+ const imgData = images.field[group];
185
+
186
+ // 创建图片容器组
187
+ const imgGroup = subplot.append("g")
188
+ .attr("transform", `translate(${subplotMargin.left}, 7)`);
189
+
190
+ // 添加白色圆形边框
191
+ imgGroup.append("circle")
192
+ .attr("cx", 18)
193
+ .attr("cy", 18)
194
+ .attr("r", 19.5)
195
+ .attr("fill", "none")
196
+ .attr("stroke", "white")
197
+ .attr("stroke-width", 1);
198
+
199
+ // 添加带遮罩的图片
200
+ imgGroup.append("image")
201
+ .attr("x", 0)
202
+ .attr("y", 0)
203
+ .attr("width", 36)
204
+ .attr("height", 36)
205
+ .attr("mask", `url(#${maskId})`)
206
+ .attr("xlink:href", imgData);
207
+ }
208
+
209
+ // 添加y轴刻度 - 根据位置决定显示左侧还是右侧
210
+ yTicks.forEach(tick => {
211
+
212
+ // 右侧刻度 - 只有最右侧的图表显示
213
+ if (col === cols - 1) {
214
+ g.append("text")
215
+ .attr("x", innerWidth + 10)
216
+ .attr("y", yScale(tick))
217
+ .attr("text-anchor", "start")
218
+ .attr("dominant-baseline", "middle")
219
+ .style("font-family", 'Arial Narrow')
220
+ .style("font-size", "16px")
221
+ .style("fill", "#d5d5d5")
222
+ .text(tick);
223
+ } else {
224
+ // 左侧刻度
225
+ g.append("text")
226
+ .attr("x", -10)
227
+ .attr("y", yScale(tick))
228
+ .attr("text-anchor", "end")
229
+ .attr("dominant-baseline", "middle")
230
+ .style("font-family", 'Arial Narrow')
231
+ .style("font-size", "16px")
232
+ .style("fill", "#d5d5d5")
233
+ .text(tick);
234
+ }
235
+ });
236
+
237
+ // 添加x轴刻度
238
+ xTicks.forEach(tick => {
239
+ g.append("text")
240
+ .attr("x", xScale(tick))
241
+ .attr("y", innerHeight + 25)
242
+ .attr("text-anchor", "middle")
243
+ .style("font-family", 'Arial Narrow')
244
+ .style("font-size", "16px")
245
+ .style("fill", "#d5d5d5")
246
+ .text(xFormat(tick));
247
+ });
248
+
249
+ // 创建线条生成器
250
+ const line = d3.line()
251
+ .x(d => xScale(chartUtils.format.parseDate(d[xField])))
252
+ .y(d => yScale(+d[yField]))
253
+ .curve(d3.curveStepAfter); // 使用阶梯曲线
254
+
255
+ // 绘制线条
256
+ g.append("path")
257
+ .datum(groupData)
258
+ .attr("fill", "none")
259
+ .attr("stroke", colorResolver.field(group, 0, { fallbackKey: "primary" }).value) // 蓝色
260
+ .attr("stroke-width", 2.5)
261
+ .attr("d", line);
262
+
263
+ // 添加最终利率值标签
264
+ if (groupData.length > 0) {
265
+ const lastPoint = groupData[groupData.length - 1];
266
+ const secondLastPoint = groupData[groupData.length - 2];
267
+
268
+ // 判断最后一段是向上还是向下
269
+ const isGoingDown = +lastPoint[yField] < +secondLastPoint[yField];
270
+
271
+ // 根据���向决定标签位置
272
+ const labelOffset = isGoingDown ? 20 : -10;
273
+
274
+ const labelXOffset = col === cols - 1 ? -17 : 0;
275
+
276
+ g.append("text")
277
+ .attr("x", xScale(chartUtils.format.parseDate(lastPoint[xField])) + labelXOffset)
278
+ .attr("y", yScale(+lastPoint[yField]) + labelOffset)
279
+ .attr("text-anchor", "middle")
280
+ .style("font-family", 'Arial Narrow')
281
+ .style("font-size", "18px")
282
+ .style("fill", "#d5d5d5")
283
+ .text(chartUtils.format.fixed(+lastPoint[yField]).text);
284
+ }
285
+ });
286
+
287
+ return svg.node();
288
+ }
modules/chart_engine/template/d3-js/line_graph/spline_graph_01.js ADDED
@@ -0,0 +1,491 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Spline Graph",
5
+ "chart_name": "spline_graph_01",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[3, 50], [-1000, 1000], [2, 2]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": [],
13
+ "supported_effects": ["gradient", "opacity"],
14
+ "min_height": 600,
15
+ "min_width": 800,
16
+ "background": "styled",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "yes",
21
+ "chart_for": "comparison"
22
+ }
23
+ REQUIREMENTS_END
24
+ */
25
+
26
+ function makeChart(containerSelector, data) {
27
+ // 提取数据
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
+ // 清空容器
39
+ d3.select(containerSelector).html("");
40
+
41
+ // 获取字段名
42
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
43
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
44
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
45
+ const lineColor = colorResolver.other("primary", { fallback: "#8364ac" }).value;
46
+ const positiveColor = colorResolver.other("positive", { fallback: "#ef9522" }).value;
47
+ const negativeColor = colorResolver.other("negative", { fallback: "#9a8abe" }).value;
48
+
49
+ // 设置尺寸和边距
50
+ const width = variables.width;
51
+ const height = variables.height;
52
+ const margin = { top: 60, right: 40, bottom: 60, left: 60 };
53
+
54
+ // 创建SVG
55
+ const svg = d3.select(containerSelector)
56
+ .append("svg")
57
+ .attr("width", "100%")
58
+ .attr("height", height)
59
+ .attr("viewBox", `0 0 ${width} ${height}`)
60
+ .attr("style", "max-width: 100%; height: auto;")
61
+ .attr("xmlns", "http://www.w3.org/2000/svg")
62
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
63
+
64
+ // 创建图表区域
65
+ const chartWidth = width - margin.left - margin.right;
66
+ const chartHeight = height - margin.top - margin.bottom;
67
+
68
+ // 添加背景图片
69
+ svg.append("image")
70
+ .attr("xlink:href", 'https://www.yczddgj.com/infographic_assets/line_graph_01_BG.png')
71
+ .attr("width", "100%")
72
+ .attr("height", "100%")
73
+ .attr("preserveAspectRatio", "xMidYMid slice")
74
+ .attr("class", "background")
75
+ .attr("x", 0)
76
+ .attr("y", 0);
77
+
78
+ const g = svg.append("g")
79
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
80
+
81
+ // 分离两个组的数据
82
+ const groupNames = [...new Set(chartData.map(d => d[groupField]))];
83
+ const group1Data = chartData.filter(d => d[groupField] === groupNames[0]);
84
+ const group2Data = chartData.filter(d => d[groupField] === groupNames[1]);
85
+
86
+ // 创建一个新的数据集,计算两组之间的差异
87
+ const diffData = [];
88
+
89
+ // 确保两组数据按年份排序
90
+ group1Data.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
91
+ group2Data.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
92
+
93
+ // 计算差异
94
+ for (let i = 0; i < group1Data.length; i++) {
95
+ const year = group1Data[i][xField];
96
+ const group1Value = group1Data[i][yField];
97
+ const group2Item = group2Data.find(d => d[xField] === year);
98
+
99
+ if (group2Item) {
100
+ const group2Value = group2Item[yField];
101
+ const diff = group1Value - group2Value;
102
+ diffData.push({
103
+ [xField]: year,
104
+ [yField]: diff
105
+ });
106
+ }
107
+ }
108
+
109
+ const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(diffData, xField, 0, chartWidth);
110
+
111
+ // 创建y轴比例尺
112
+ const yMin = d3.min(diffData, d => +d[yField]);
113
+ const yMax = d3.max(diffData, d => +d[yField]);
114
+ const yPadding = Math.max(Math.abs(yMin), Math.abs(yMax)) * 0.1;
115
+
116
+ const yScale = d3.scaleLinear()
117
+ .domain([Math.min(yMin - yPadding, -5), Math.max(yMax + yPadding, 5)])
118
+ .range([chartHeight, 0]);
119
+
120
+ // 添加水平网格线
121
+ const yTicks = d3.ticks(yScale.domain()[0], yScale.domain()[1], 10);
122
+
123
+ // 添加y轴线
124
+ g.append("line")
125
+ .attr("x1", 0)
126
+ .attr("y1", yScale(yTicks[yTicks.length-1])) // 从最大刻度开始
127
+ .attr("x2", 0)
128
+ .attr("y2", yScale(yTicks[0])) // 到最小刻度结束
129
+ .attr("stroke", "#000000")
130
+ .attr("opacity", 0.1)
131
+ .attr("stroke-width", 1);
132
+
133
+ // 添加y轴刻度和标签
134
+ yTicks.forEach(tick => {
135
+ // 添加���度小横线
136
+ g.append("line")
137
+ .attr("x1", -5)
138
+ .attr("y1", yScale(tick))
139
+ .attr("x2", 0)
140
+ .attr("y2", yScale(tick))
141
+ .attr("stroke", "#000000")
142
+ .attr("opacity", 0.1)
143
+ .attr("stroke-width", 1);
144
+
145
+
146
+ g.append("text")
147
+ .attr("x", -10)
148
+ .attr("y", yScale(tick))
149
+ .attr("text-anchor", "end")
150
+ .attr("dominant-baseline", "middle")
151
+ .style("font-family", "Century Gothic")
152
+ .style("font-size", tick === 0 ? "16px" : "12px")
153
+ .style("font-weight", tick === 0 ? "bold" : "normal")
154
+ .style("fill", "#666666")
155
+ .text(tick + "%");
156
+ });
157
+
158
+ // 添加x轴刻度和标签
159
+ xTicks.forEach(tick => {
160
+ g.append("text")
161
+ .attr("x", xScale(tick))
162
+ .attr("y", yScale(0))
163
+ .attr("text-anchor", "middle")
164
+ .style("font-family", "Century Gothic")
165
+ .style("font-size", "12px")
166
+ .style("fill", "#666666")
167
+ .text(xFormat(tick));
168
+ });
169
+
170
+ // 创建线条生成器
171
+ const line = d3.line()
172
+ .x(d => xScale(chartUtils.format.parseDate(d[xField])))
173
+ .y(d => yScale(+d[yField]))
174
+ .curve(d3.curveMonotoneX);
175
+
176
+ // 创建渐变定义
177
+ const defs = svg.append("defs");
178
+
179
+ // 创建线条渐变
180
+ const lineGradient = defs.append("linearGradient")
181
+ .attr("id", "lineGradient")
182
+ .attr("gradientUnits", "userSpaceOnUse")
183
+ .attr("x1", 0)
184
+ .attr("y1", yScale(yScale.domain()[1])) // 顶部位置
185
+ .attr("x2", 0)
186
+ .attr("y2", yScale(yScale.domain()[0])); // 底部位置
187
+
188
+ // 添加渐变色停止点
189
+ lineGradient.append("stop")
190
+ .attr("offset", "0%")
191
+ .attr("stop-color", "#e2922b"); // 橙色
192
+
193
+ lineGradient.append("stop")
194
+ .attr("offset", `${((yScale(0) - 60) / chartHeight) * 100}%`)
195
+ .attr("stop-color", "#e2922b")
196
+ .attr("stop-opacity", 1);
197
+
198
+ lineGradient.append("stop")
199
+ .attr("offset", `${(yScale(0) / chartHeight) * 100}%`)
200
+ .attr("stop-color", "#e2922b")
201
+ .attr("stop-opacity", 0);
202
+
203
+ lineGradient.append("stop")
204
+ .attr("offset", `${(yScale(0) / chartHeight) * 100}%`)
205
+ .attr("stop-color", lineColor)
206
+ .attr("stop-opacity", 0);
207
+
208
+ lineGradient.append("stop")
209
+ .attr("offset", `${((yScale(0) + 60) / chartHeight) * 100}%`)
210
+ .attr("stop-color", lineColor)
211
+ .attr("stop-opacity", 1);
212
+
213
+ lineGradient.append("stop")
214
+ .attr("offset", "100%")
215
+ .attr("stop-color", lineColor); // 紫色
216
+
217
+ // 绘制线条
218
+ g.append("path")
219
+ .datum(diffData)
220
+ .attr("fill", "none")
221
+ .attr("stroke", "url(#lineGradient)")
222
+ .attr("stroke-width", 3)
223
+ .attr("d", line);
224
+
225
+ // 添加最大值和最小值标注
226
+ const maxPoint = diffData.reduce((max, p) => p[yField] > max[yField] ? p : max, diffData[0]);
227
+ g.append("circle")
228
+ .attr("cx", xScale(chartUtils.format.parseDate(maxPoint[xField])))
229
+ .attr("cy", yScale(maxPoint[yField]))
230
+ .attr("r", 6)
231
+ .attr("fill", "#ffffff")
232
+ .attr("stroke", positiveColor)
233
+ .attr("stroke-width", 2.5);
234
+
235
+ g.append("text")
236
+ .attr("x", xScale(chartUtils.format.parseDate(maxPoint[xField])))
237
+ .attr("y", yScale(maxPoint[yField]) - 15)
238
+ .attr("text-anchor", "middle")
239
+ .style("font-family", "Century Gothic")
240
+ .style("font-size", "12px")
241
+ .style("font-weight", "bold")
242
+ .style("fill", positiveColor)
243
+ .text(chartUtils.format.percent(+maxPoint[yField], { digits: 0 }).text);
244
+
245
+ const minPoint = diffData.reduce((min, p) => p[yField] < min[yField] ? p : min, diffData[0]);
246
+ g.append("circle")
247
+ .attr("cx", xScale(chartUtils.format.parseDate(minPoint[xField])))
248
+ .attr("cy", yScale(minPoint[yField]))
249
+ .attr("r", 6)
250
+ .attr("fill", "#ffffff")
251
+ .attr("stroke", negativeColor)
252
+ .attr("stroke-width", 2.5);
253
+
254
+ g.append("text")
255
+ .attr("x", xScale(chartUtils.format.parseDate(minPoint[xField])))
256
+ .attr("y", yScale(minPoint[yField]) + 20)
257
+ .attr("text-anchor", "middle")
258
+ .style("font-family", "Century Gothic")
259
+ .style("font-size", "12px")
260
+ .style("font-weight", "bold")
261
+ .style("fill", negativeColor)
262
+ .text(chartUtils.format.percent(+minPoint[yField], { digits: 0 }).text);
263
+
264
+ // 添加最新值标注
265
+ const lastPoint = diffData[diffData.length - 1];
266
+ if (lastPoint !== maxPoint && lastPoint !== minPoint) {
267
+ g.append("circle")
268
+ .attr("cx", xScale(chartUtils.format.parseDate(lastPoint[xField])))
269
+ .attr("cy", yScale(+lastPoint[yField]))
270
+ .attr("r", 6)
271
+ .attr("fill", "#ffffff")
272
+ .attr("stroke", +lastPoint[yField] >= 0 ? positiveColor : negativeColor)
273
+ .attr("stroke-width", 2.5);
274
+
275
+ g.append("text")
276
+ .attr("x", xScale(chartUtils.format.parseDate(lastPoint[xField])))
277
+ .attr("y", yScale(+lastPoint[yField]) + 20)
278
+ .attr("text-anchor", "middle")
279
+ .attr("dominant-baseline", "middle")
280
+ .style("font-family", "Century Gothic")
281
+ .style("font-size", "14px")
282
+ .style("font-weight", "bold")
283
+ .style("fill", +lastPoint[yField] >= 0 ? positiveColor : negativeColor)
284
+ .text(chartUtils.format.percent(+lastPoint[yField], { digits: 0 }).text);
285
+ }
286
+
287
+ // 添加说明文本
288
+ const wrapText = (text, width) => {
289
+ const words = text.split(/\s+/);
290
+ let line = [];
291
+ let lines = [];
292
+ let currentWidth = 0;
293
+
294
+ words.forEach(word => {
295
+ const wordWidth = chartUtils.text.measure(null, word, { fontSize: 14 }).width;
296
+ if (currentWidth + wordWidth > width) {
297
+ lines.push(line.join(' '));
298
+ line = [word];
299
+ currentWidth = wordWidth;
300
+ } else {
301
+ line.push(word);
302
+ currentWidth += wordWidth;
303
+ }
304
+ });
305
+ lines.push(line.join(' '));
306
+ return lines;
307
+ };
308
+
309
+ const nameLines = wrapText(chartUtils.schema.columnField(dataColumns, 1).toUpperCase() + " DIFFERENCE", 140);
310
+ nameLines.forEach((line, i) => {
311
+ g.append("text")
312
+ .attr("x", 20)
313
+ .attr("y", yScale(yMax) - 5 + i * 15) // 将第一行与最高刻度对齐
314
+ .attr("text-anchor", "start")
315
+ .style("font-family", "Century Gothic")
316
+ .style("font-size", "12px")
317
+ .style("font-weight", "bold")
318
+ .style("fill", "#666666")
319
+ .text(line);
320
+ });
321
+
322
+ const descLines = wrapText(groupNames[0] + " and " + groupNames[1], 160);
323
+ descLines.forEach((line, i) => {
324
+ g.append("text")
325
+ .attr("x", 20)
326
+ .attr("y", yScale(yMax) - 5 + nameLines.length * 20 + i * 14) // 相应调整描述文本位置
327
+ .attr("text-anchor", "start")
328
+ .style("font-family", "Century Gothic")
329
+ .style("font-size", "11px")
330
+ .style("fill", "#666666")
331
+ .text(line);
332
+ });
333
+
334
+ // 自动寻找最佳箭头位置
335
+ function findBestArrowPosition(isUpArrow) {
336
+ const zeroY = yScale(0);
337
+ const arrowSize = 100; // 箭头大小为100x100像素
338
+ const stepSize = 10; // 每10像素尝试一次
339
+
340
+ let bestX = 0;
341
+ let bestY = 0;
342
+ let bestCenterDistance = Infinity;
343
+
344
+ // 图表中心点
345
+ const centerX = chartWidth / 2;
346
+ const centerY = chartHeight / 2;
347
+
348
+ // 遍历可能的位置
349
+ for (let x = 40; x <= chartWidth - arrowSize; x += stepSize) {
350
+ for (let y = 0; y <= chartHeight - arrowSize; y += stepSize) {
351
+ // 检查是否在正确的区域(上箭头在零线上方,下箭头在零线下方)
352
+ if ((isUpArrow && y + arrowSize + 20 > zeroY) || (!isUpArrow && y - 20 < zeroY)) {
353
+ continue;
354
+ }
355
+
356
+ // 检查是否与数据线重叠
357
+ let overlapsLine = false;
358
+ for (let testX = x; testX < x + arrowSize; testX += 10) {
359
+ for (let testY = y; testY < y + arrowSize; testY += 10) {
360
+ // 检查是否在数据线附近
361
+ for (let i = 0; i < diffData.length - 1; i++) {
362
+ const x1 = xScale(chartUtils.format.parseDate(diffData[i][xField]));
363
+ const y1 = yScale(diffData[i][yField]);
364
+ const x2 = xScale(chartUtils.format.parseDate(diffData[i + 1][xField]));
365
+ const y2 = yScale(diffData[i + 1][yField]);
366
+
367
+ // 检查测试点是否在线段的范围内
368
+ if (testX >= Math.min(x1, x2) && testX <= Math.max(x1, x2)) {
369
+ // 计算点到线段的距离
370
+ const A = y2 - y1;
371
+ const B = x1 - x2;
372
+ const C = x2 * y1 - x1 * y2;
373
+
374
+ const distance = Math.abs(A * testX + B * testY + C) /
375
+ Math.sqrt(A * A + B * B);
376
+
377
+ if (distance < 5) { // 如果距离小于20像素,认为重叠
378
+ overlapsLine = true;
379
+ break;
380
+ }
381
+ }
382
+ }
383
+ if (overlapsLine) break;
384
+ }
385
+ if (overlapsLine) break;
386
+ }
387
+
388
+ if (isUpArrow) {
389
+ }
390
+
391
+ if (!overlapsLine) {
392
+ // 计算到中心点的距离
393
+ const centerDistance = Math.sqrt(Math.pow(x + arrowSize/2 - centerX, 2) +
394
+ Math.pow(y + arrowSize/2 - centerY, 2));
395
+
396
+ // 如果这个位置比之前找到的更好,更新最佳位置
397
+ if (centerDistance < bestCenterDistance) {
398
+ bestX = x;
399
+ bestY = y;
400
+ bestCenterDistance = centerDistance;
401
+ }
402
+ }
403
+ }
404
+ }
405
+
406
+ return { x: bestX, y: bestY };
407
+ }
408
+
409
+ // 获取最佳箭头位置
410
+ const upArrowPos = findBestArrowPosition(true);
411
+ const downArrowPos = findBestArrowPosition(false);
412
+
413
+ const leftOffset = 20;
414
+
415
+ // 添加向上的箭头和向下的箭头
416
+ g.append("image")
417
+ .attr("xlink:href", 'https://www.yczddgj.com/infographic_assets/line_graph_01_UpArrow.png')
418
+ .attr("width", "60")
419
+ .attr("height", "60")
420
+ .attr("x", upArrowPos.x + leftOffset)
421
+ .attr("y", upArrowPos.y);
422
+
423
+ // 根据文本长度计算背景宽度
424
+ const upLabelWidth = Math.max(groupNames[0].toUpperCase().length * 8 + 5, 100); // 每个字符10px宽度,两边各加20px边距
425
+
426
+ g.append("image")
427
+ .attr("xlink:href", 'https://www.yczddgj.com/infographic_assets/line_graph_01_UpLabelBG.png')
428
+ .attr("width", upLabelWidth)
429
+ .attr("height", "30")
430
+ .attr("x", upArrowPos.x + leftOffset - upLabelWidth/2 + 30)
431
+ .attr("y", upArrowPos.y + 65)
432
+ .attr("preserveAspectRatio", "none");
433
+ g.append("text")
434
+ .attr("x", upArrowPos.x + leftOffset + 30)
435
+ .attr("y", upArrowPos.y + 85)
436
+ .attr("text-anchor", "middle")
437
+ .style("font-family", "Century Gothic")
438
+ .style("font-size", "14px")
439
+ .style("font-weight", "bold")
440
+ .style("fill", "#111111")
441
+ .style("opacity", 0.8)
442
+ .text(groupNames[0].toUpperCase());
443
+ g.append("text")
444
+ .attr("x", upArrowPos.x + leftOffset + 30)
445
+ .attr("y", upArrowPos.y + 102)
446
+ .attr("text-anchor", "middle")
447
+ .style("font-family", "Century Gothic")
448
+ .style("font-size", "12px")
449
+ .style("fill", "#111111")
450
+ .style("opacity", 0.8)
451
+ .text("outperform");
452
+
453
+
454
+ g.append("image")
455
+ .attr("xlink:href", 'https://www.yczddgj.com/infographic_assets/line_graph_01_DownArrow.png')
456
+ .attr("width", "60")
457
+ .attr("height", "60")
458
+ .attr("x", downArrowPos.x + leftOffset)
459
+ .attr("y", downArrowPos.y);
460
+
461
+ // 根据文本长度计算背景宽度
462
+ const downLabelWidth = Math.max(groupNames[1].toUpperCase().length * 8 + 5, 100); // 每个字符10px宽度,两边各加20px边距
463
+ g.append("image")
464
+ .attr("xlink:href", 'https://www.yczddgj.com/infographic_assets/line_graph_01_DownLabelBG.png')
465
+ .attr("width", downLabelWidth)
466
+ .attr("height", "30")
467
+ .attr("x", downArrowPos.x + leftOffset - downLabelWidth/2 + 30)
468
+ .attr("y", downArrowPos.y + 65)
469
+ .attr("preserveAspectRatio", "none");
470
+ g.append("text")
471
+ .attr("x", downArrowPos.x + leftOffset + 30)
472
+ .attr("y", downArrowPos.y + 85)
473
+ .attr("text-anchor", "middle")
474
+ .style("font-family", "Century Gothic")
475
+ .style("font-size", "14px")
476
+ .style("font-weight", "bold")
477
+ .style("fill", "#111111")
478
+ .style("opacity", 0.8)
479
+ .text(groupNames[1].toUpperCase());
480
+ g.append("text")
481
+ .attr("x", downArrowPos.x + leftOffset + 30)
482
+ .attr("y", downArrowPos.y + 102)
483
+ .attr("text-anchor", "middle")
484
+ .style("font-family", "Century Gothic")
485
+ .style("font-size", "12px")
486
+ .style("fill", "#111111")
487
+ .style("opacity", 0.8)
488
+ .text("outperform");
489
+
490
+ return svg.node();
491
+ }
modules/chart_engine/template/d3-js/line_graph/step_line_graph_01.js ADDED
@@ -0,0 +1,590 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Step Line Graph",
5
+ "chart_name": "step_line_graph_01",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["temporal"], ["numerical"]],
8
+ "required_fields_range": [[4, 20], [0, "inf"]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": ["primary"],
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
+ "chart_for": "trend"
22
+ }
23
+ REQUIREMENTS_END
24
+ */
25
+
26
+ function makeChart(containerSelector, data) {
27
+ const jsonData = data || {};
28
+ const variables = jsonData.variables || {};
29
+ const typography = jsonData.typography || {};
30
+ const sourceColors = jsonData.colors || {};
31
+ const dataColumns = chartUtils.schema.columns(jsonData);
32
+ const colorResolver = chartUtils.color.resolver(jsonData);
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 sourceData = jsonData.data?.data || [];
54
+
55
+ d3.select(containerSelector).html("");
56
+
57
+ const xColumn = dataColumns[0] || {};
58
+ const yColumn = dataColumns[1] || {};
59
+ const xField = xColumn.name;
60
+ const yField = yColumn.name;
61
+ const readableName = value => String(value || "")
62
+ .replace(/_/g, " ")
63
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
64
+ .replace(/\s+/g, " ")
65
+ .trim();
66
+ const xLabel = readableName(xColumn.label || xColumn.name || "Time");
67
+ const yLabel = readableName(yColumn.label || yColumn.name || "Value");
68
+ const yUnit = (yColumn.unit || "").trim();
69
+
70
+ const width = Math.max(420, Number(variables.width) || 560);
71
+ const height = Math.max(460, Number(variables.height) || 560);
72
+ const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
73
+ const titleFontFamily = typography.title?.font_family || fontFamily;
74
+ const textColor = sourceColors.text_color || "#0B2B62";
75
+ const mutedText = "#60728A";
76
+ const axisColor = "#7694B6";
77
+ const gridColor = "#C9DAEA";
78
+ const primary = sourceColors.other?.primary || sourceColors.primary || "#1E71D5";
79
+ const secondary = sourceColors.other?.secondary || "#D76E6E";
80
+ const cardStroke = "#D8E7F3";
81
+
82
+ const svg = d3.select(containerSelector)
83
+ .append("svg")
84
+ .attr("width", "100%")
85
+ .attr("height", height)
86
+ .attr("viewBox", `0 0 ${width} ${height}`)
87
+ .attr("style", "max-width: 100%; height: auto;")
88
+ .attr("xmlns", "http://www.w3.org/2000/svg")
89
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
90
+ .attr("class", "step-line-chart-root reference-layout");
91
+
92
+ if (!xField || !yField) {
93
+ svg.append("text")
94
+ .attr("x", width / 2)
95
+ .attr("y", height / 2)
96
+ .attr("text-anchor", "middle")
97
+ .attr("fill", textColor)
98
+ .style("font-family", fontFamily)
99
+ .style("font-size", "16px")
100
+ .text("Missing step-line fields");
101
+ return svg.node();
102
+ }
103
+
104
+ const plainNumber = value => {
105
+ const abs = Math.abs(value);
106
+ const spec = abs >= 100 ? ",.0f" : abs >= 10 ? ",.1f" : abs >= 1 ? ",.2f" : ",.3f";
107
+ return d3.format(spec)(value).replace(/\.0$/, "").replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "");
108
+ };
109
+
110
+ const compactNumber = value => {
111
+ const abs = Math.abs(value);
112
+ const spec = abs >= 100 ? ".0f" : abs >= 10 ? ".1f" : abs >= 1 ? ".2f" : ".3f";
113
+ if (abs >= 1000000000) return `${d3.format(spec)(value / 1000000000)}B`;
114
+ if (abs >= 1000000) return `${d3.format(spec)(value / 1000000)}M`;
115
+ if (abs >= 1000) return `${d3.format(spec)(value / 1000)}K`;
116
+ return d3.format(spec)(value).replace(/\.0$/, "").replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "");
117
+ };
118
+
119
+ const formatLocalValue = value => {
120
+ const currencyWithSuffix = yUnit.match(/^([£$€¥])\s*([A-Za-z]+)$/);
121
+ if (yUnit === "%") return `${plainNumber(value)}%`;
122
+ if (currencyWithSuffix) return `${currencyWithSuffix[1]}${plainNumber(value)}${currencyWithSuffix[2]}`;
123
+ if (/^[£$€¥]$/.test(yUnit)) return `${yUnit}${compactNumber(value)}`;
124
+ if (yUnit) return `${compactNumber(value)} ${yUnit}`;
125
+ return compactNumber(value);
126
+ };
127
+
128
+ const shortLabel = (value, maxChars = 22) => {
129
+ const clean = readableName(value);
130
+ if (clean.length <= maxChars) return clean;
131
+ const words = clean.split(/[\s/,-]+/).filter(Boolean);
132
+ if (words.length > 1) {
133
+ const firstLast = `${words[0]} ${words[words.length - 1]}`;
134
+ if (firstLast.length <= maxChars) return firstLast;
135
+ }
136
+ return `${clean.slice(0, Math.max(1, maxChars - 1)).trim()}.`;
137
+ };
138
+
139
+ const chartData = sourceData
140
+ .map((d, index) => ({
141
+ ...d,
142
+ __sourceIndex: index,
143
+ __xDate: chartUtils.format.parseDate(d[xField]),
144
+ __xRaw: d[xField],
145
+ __value: Number(d[yField])
146
+ }))
147
+ .filter(d => !Number.isNaN(d.__xDate.getTime()) && Number.isFinite(d.__value))
148
+ .sort((a, b) => a.__xDate - b.__xDate)
149
+ .map((d, i) => ({ ...d, __rank: i + 1 }));
150
+
151
+ if (chartData.length < 2) {
152
+ svg.append("text")
153
+ .attr("x", width / 2)
154
+ .attr("y", height / 2)
155
+ .attr("text-anchor", "middle")
156
+ .attr("fill", textColor)
157
+ .style("font-family", fontFamily)
158
+ .style("font-size", "16px")
159
+ .text("Not enough data");
160
+ return svg.node();
161
+ }
162
+
163
+ const defs = svg.append("defs");
164
+ const bgGradient = defs.append("linearGradient")
165
+ .attr("id", "step-line-ref-bg")
166
+ .attr("x1", "0%")
167
+ .attr("x2", "0%")
168
+ .attr("y1", "0%")
169
+ .attr("y2", "100%");
170
+ bgGradient.append("stop").attr("offset", "0%").attr("stop-color", "#F7FBFF");
171
+ bgGradient.append("stop").attr("offset", "100%").attr("stop-color", "#EAF5FD");
172
+ const areaGradient = defs.append("linearGradient")
173
+ .attr("id", "step-line-area-fill")
174
+ .attr("x1", "0%")
175
+ .attr("x2", "0%")
176
+ .attr("y1", "0%")
177
+ .attr("y2", "100%");
178
+ areaGradient.append("stop").attr("offset", "0%").attr("stop-color", primary).attr("stop-opacity", 0.22);
179
+ areaGradient.append("stop").attr("offset", "100%").attr("stop-color", primary).attr("stop-opacity", 0.03);
180
+
181
+ const root = svg.append("g")
182
+ .attr("class", "step-line-reference-panel")
183
+ .attr("data-tag", "chart");
184
+ root.append("rect")
185
+ .attr("class", "chart-style-panel")
186
+ .attr("x", 2)
187
+ .attr("y", 2)
188
+ .attr("width", width - 4)
189
+ .attr("height", height - 4)
190
+ .attr("rx", 18)
191
+ .attr("fill", "url(#step-line-ref-bg)");
192
+
193
+ const addIconSlot = (group, cx, cy, r, className, fill, glyph) => {
194
+ const icon = group.append("g").attr("class", className);
195
+ icon.append("circle")
196
+ .attr("cx", cx)
197
+ .attr("cy", cy)
198
+ .attr("r", r)
199
+ .attr("fill", fill)
200
+ .attr("opacity", 0.95);
201
+ icon.append("text")
202
+ .attr("x", cx)
203
+ .attr("y", cy + 1)
204
+ .attr("text-anchor", "middle")
205
+ .attr("dominant-baseline", "middle")
206
+ .attr("fill", "#FFFFFF")
207
+ .style("font-family", fontFamily)
208
+ .style("font-size", `${Math.max(9, r * 0.72)}px`)
209
+ .style("font-weight", "900")
210
+ .text(glyph);
211
+ return icon;
212
+ };
213
+
214
+ const panelPad = Math.max(14, Math.min(24, width * 0.036));
215
+ const chipGap = 12;
216
+ const chipY = panelPad + 8;
217
+ const chipH = Math.max(54, Math.min(70, height * 0.12));
218
+ const chipW = (width - panelPad * 2 - chipGap) / 2;
219
+ const startDatum = chartData[0];
220
+ const endDatum = chartData[chartData.length - 1];
221
+ const delta = endDatum.__value - startDatum.__value;
222
+ const deltaText = `${delta >= 0 ? "+" : ""}${formatLocalValue(delta)}`;
223
+ const directionText = delta >= 0 ? "Increase" : "Decrease";
224
+ const formatDate = d => {
225
+ const xExtent = d3.extent(chartData, item => item.__xDate);
226
+ const dateSpanYears = (xExtent[1] - xExtent[0]) / (1000 * 60 * 60 * 24 * 365);
227
+ if (dateSpanYears > 2) return d3.timeFormat("%Y")(d);
228
+ if (dateSpanYears > 1) return `${d.getFullYear().toString().slice(-2)}Q${Math.floor(d.getMonth() / 3) + 1}`;
229
+ return d3.timeFormat("%b %Y")(d);
230
+ };
231
+
232
+ const addMetricChip = (x, color, glyph, title, value, subtitle, className) => {
233
+ const chip = root.append("g")
234
+ .attr("class", `metric-chip ${className}`)
235
+ .attr("transform", `translate(${x}, ${chipY})`);
236
+ chip.append("rect")
237
+ .attr("width", chipW)
238
+ .attr("height", chipH)
239
+ .attr("rx", 14)
240
+ .attr("fill", "#FFFFFF")
241
+ .attr("stroke", cardStroke)
242
+ .attr("stroke-width", 1);
243
+ addIconSlot(chip, 26, chipH / 2, 16, "metric-icon-slot decorative-icon-slot", color, glyph);
244
+ chip.append("text")
245
+ .attr("class", "metric-title")
246
+ .attr("x", 50)
247
+ .attr("y", 19)
248
+ .attr("fill", color)
249
+ .style("font-family", titleFontFamily)
250
+ .style("font-size", "9.5px")
251
+ .style("font-weight", "900")
252
+ .text(title);
253
+ chip.append("text")
254
+ .attr("class", "metric-value")
255
+ .attr("x", 50)
256
+ .attr("y", 39)
257
+ .attr("fill", textColor)
258
+ .style("font-family", titleFontFamily)
259
+ .style("font-size", "15px")
260
+ .style("font-weight", "900")
261
+ .text(shortLabel(value, Math.max(8, Math.floor((chipW - 58) / 7.5))));
262
+ chip.append("text")
263
+ .attr("class", "metric-subtitle")
264
+ .attr("x", 50)
265
+ .attr("y", chipH - 9)
266
+ .attr("fill", mutedText)
267
+ .style("font-family", fontFamily)
268
+ .style("font-size", "8.5px")
269
+ .style("font-weight", "650")
270
+ .text(shortLabel(subtitle, Math.max(10, Math.floor((chipW - 58) / 5.2))));
271
+ };
272
+ addMetricChip(panelPad, primary, "1", "START", formatLocalValue(startDatum.__value), formatDate(startDatum.__xDate), "start-metric-chip");
273
+ addMetricChip(panelPad + chipW + chipGap, secondary, "2", "LATEST", formatLocalValue(endDatum.__value), formatDate(endDatum.__xDate), "end-metric-chip");
274
+
275
+ const footerH = Math.max(74, Math.min(96, height * 0.17));
276
+ const footerY = height - footerH - panelPad;
277
+ const plotX = Math.max(52, width * 0.12);
278
+ const plotY = chipY + chipH + Math.max(26, height * 0.045);
279
+ const plotW = width - plotX - Math.max(24, width * 0.055);
280
+ const plotH = Math.max(190, footerY - plotY - 56);
281
+
282
+ const xExtent = d3.extent(chartData, d => d.__xDate);
283
+ if (+xExtent[0] === +xExtent[1]) {
284
+ xExtent[0] = d3.timeYear.offset(xExtent[0], -1);
285
+ xExtent[1] = d3.timeYear.offset(xExtent[1], 1);
286
+ }
287
+ const xScale = d3.scaleTime()
288
+ .domain(xExtent)
289
+ .range([0, plotW]);
290
+
291
+ const yMin = d3.min(chartData, d => d.__value);
292
+ const yMax = d3.max(chartData, d => d.__value);
293
+ const ySpan = yMax - yMin;
294
+ const yPadding = ySpan > 0 ? ySpan * 0.16 : Math.max(Math.abs(yMax) * 0.1, 1);
295
+ let yDomainMin = yMin - yPadding;
296
+ if (yMin >= 0 && yDomainMin < 0) yDomainMin = 0;
297
+ const yScale = d3.scaleLinear()
298
+ .domain([yDomainMin, yMax + yPadding])
299
+ .range([plotH, 0])
300
+ .nice(5);
301
+
302
+ const maxXTicks = Math.max(3, Math.min(6, Math.floor(plotW / 85)));
303
+ const selectXTicks = values => {
304
+ const step = Math.max(1, Math.ceil(values.length / maxXTicks));
305
+ const selected = values.filter((_, i) => i % step === 0);
306
+ if (+selected[0] !== +values[0]) selected.unshift(values[0]);
307
+ if (+selected[selected.length - 1] !== +values[values.length - 1]) selected.push(values[values.length - 1]);
308
+ const seen = new Set();
309
+ return selected.filter(d => {
310
+ const key = formatDate(d);
311
+ if (seen.has(key)) return false;
312
+ seen.add(key);
313
+ return true;
314
+ });
315
+ };
316
+ const uniqueYTicks = ticks => {
317
+ const seen = new Set();
318
+ return ticks.filter(tick => {
319
+ const key = formatLocalValue(tick);
320
+ if (seen.has(key)) return false;
321
+ seen.add(key);
322
+ return true;
323
+ });
324
+ };
325
+ const xTicks = selectXTicks(chartData.map(d => d.__xDate));
326
+ const yTicks = uniqueYTicks(yScale.ticks(5));
327
+
328
+ const plot = root.append("g")
329
+ .attr("class", "plot-area step-line-plot-area")
330
+ .attr("transform", `translate(${plotX}, ${plotY})`);
331
+ plot.append("rect")
332
+ .attr("class", "plot-card chart-panel-background")
333
+ .attr("x", -12)
334
+ .attr("y", -12)
335
+ .attr("width", plotW + 24)
336
+ .attr("height", plotH + 24)
337
+ .attr("rx", 15)
338
+ .attr("fill", "#FFFFFF")
339
+ .attr("opacity", 0.78)
340
+ .attr("stroke", cardStroke);
341
+
342
+ yTicks.forEach(tick => {
343
+ const y = yScale(tick);
344
+ plot.append("line")
345
+ .attr("class", "gridline y-gridline")
346
+ .attr("x1", 0)
347
+ .attr("y1", y)
348
+ .attr("x2", plotW)
349
+ .attr("y2", y)
350
+ .attr("stroke", gridColor)
351
+ .attr("stroke-width", tick === 0 ? 1.3 : 0.9)
352
+ .attr("stroke-dasharray", "3 3")
353
+ .attr("opacity", 0.85);
354
+ plot.append("text")
355
+ .attr("class", "axis-tick y-axis-tick")
356
+ .attr("x", -10)
357
+ .attr("y", y)
358
+ .attr("text-anchor", "end")
359
+ .attr("dominant-baseline", "middle")
360
+ .attr("fill", mutedText)
361
+ .style("font-family", fontFamily)
362
+ .style("font-size", "9px")
363
+ .style("font-weight", "650")
364
+ .text(formatLocalValue(tick));
365
+ });
366
+
367
+ xTicks.forEach(tick => {
368
+ const x = xScale(tick);
369
+ plot.append("line")
370
+ .attr("class", "gridline x-gridline")
371
+ .attr("x1", x)
372
+ .attr("y1", 0)
373
+ .attr("x2", x)
374
+ .attr("y2", plotH)
375
+ .attr("stroke", gridColor)
376
+ .attr("stroke-width", 0.9)
377
+ .attr("stroke-dasharray", "3 3")
378
+ .attr("opacity", 0.55);
379
+ plot.append("text")
380
+ .attr("class", "axis-tick x-axis-tick")
381
+ .attr("x", x)
382
+ .attr("y", plotH + 23)
383
+ .attr("text-anchor", "middle")
384
+ .attr("fill", mutedText)
385
+ .style("font-family", fontFamily)
386
+ .style("font-size", "9px")
387
+ .style("font-weight", "650")
388
+ .text(formatDate(tick));
389
+ });
390
+
391
+ plot.append("line")
392
+ .attr("class", "axis x-axis-line")
393
+ .attr("x1", 0)
394
+ .attr("y1", plotH)
395
+ .attr("x2", plotW)
396
+ .attr("y2", plotH)
397
+ .attr("stroke", axisColor)
398
+ .attr("stroke-width", 1.1);
399
+ plot.append("line")
400
+ .attr("class", "axis y-axis-line")
401
+ .attr("x1", 0)
402
+ .attr("y1", 0)
403
+ .attr("x2", 0)
404
+ .attr("y2", plotH)
405
+ .attr("stroke", axisColor)
406
+ .attr("stroke-width", 1.1);
407
+
408
+ const line = d3.line()
409
+ .x(d => xScale(d.__xDate))
410
+ .y(d => yScale(d.__value))
411
+ .curve(d3.curveStepAfter);
412
+ const area = d3.area()
413
+ .x(d => xScale(d.__xDate))
414
+ .y0(plotH)
415
+ .y1(d => yScale(d.__value))
416
+ .curve(d3.curveStepAfter);
417
+
418
+ plot.append("path")
419
+ .datum(chartData)
420
+ .attr("class", "step-line-area")
421
+ .attr("fill", "url(#step-line-area-fill)")
422
+ .attr("d", area);
423
+ plot.append("path")
424
+ .datum(chartData)
425
+ .attr("class", "mark step-line-path")
426
+ .attr("data-point-count", chartData.length)
427
+ .attr("fill", "none")
428
+ .attr("stroke", primary)
429
+ .attr("stroke-width", 3.4)
430
+ .attr("stroke-linejoin", "round")
431
+ .attr("stroke-linecap", "round")
432
+ .attr("d", line);
433
+
434
+ const pointGroups = plot.append("g")
435
+ .attr("class", "step-line-point-layer")
436
+ .selectAll(".data-point")
437
+ .data(chartData)
438
+ .enter()
439
+ .append("g")
440
+ .attr("class", "data-point")
441
+ .attr("data-date", d => d.__xDate.toISOString())
442
+ .attr("data-raw-x", d => d.__xRaw)
443
+ .attr("data-value", d => d.__value)
444
+ .attr("data-source-index", d => d.__sourceIndex)
445
+ .attr("data-rank", d => d.__rank)
446
+ .attr("transform", d => `translate(${xScale(d.__xDate)}, ${yScale(d.__value)})`);
447
+
448
+ pointGroups.append("circle")
449
+ .attr("class", d => `mark step-line-point${d.__rank === 1 || d.__rank === chartData.length ? " endpoint-point" : ""}`)
450
+ .attr("data-date", d => d.__xDate.toISOString())
451
+ .attr("data-raw-x", d => d.__xRaw)
452
+ .attr("data-value", d => d.__value)
453
+ .attr("data-source-index", d => d.__sourceIndex)
454
+ .attr("data-rank", d => d.__rank)
455
+ .attr("r", d => d.__rank === 1 || d.__rank === chartData.length ? 5.2 : 3.5)
456
+ .attr("fill", d => d.__rank === 1 || d.__rank === chartData.length ? "#FFFFFF" : primary)
457
+ .attr("stroke", primary)
458
+ .attr("stroke-width", d => d.__rank === 1 || d.__rank === chartData.length ? 2.5 : 1.8);
459
+
460
+ const drawEndpointLabel = (d, side) => {
461
+ const x = xScale(d.__xDate);
462
+ const y = yScale(d.__value);
463
+ const label = formatLocalValue(d.__value);
464
+ const labelWidth = Math.max(50, Math.min(94, label.length * 6.7 + 19));
465
+ const labelHeight = 24;
466
+ const isLeft = side === "left";
467
+ const preferredX = isLeft ? x + 11 : x - labelWidth - 11;
468
+ const labelX = Math.max(4, Math.min(plotW - labelWidth - 4, preferredX));
469
+ const labelY = Math.max(4, Math.min(plotH - labelHeight - 4, y - labelHeight / 2));
470
+ const labelGroup = plot.append("g")
471
+ .attr("class", `endpoint-label ${isLeft ? "start" : "end"}-endpoint-label`)
472
+ .attr("data-date", d.__xDate.toISOString())
473
+ .attr("data-raw-x", d.__xRaw)
474
+ .attr("data-value", d.__value)
475
+ .attr("data-source-index", d.__sourceIndex)
476
+ .attr("data-rank", d.__rank);
477
+ labelGroup.append("line")
478
+ .attr("class", "endpoint-label-leader")
479
+ .attr("x1", x)
480
+ .attr("y1", y)
481
+ .attr("x2", isLeft ? labelX : labelX + labelWidth)
482
+ .attr("y2", labelY + labelHeight / 2)
483
+ .attr("stroke", primary)
484
+ .attr("stroke-width", 1)
485
+ .attr("opacity", 0.52);
486
+ labelGroup.append("rect")
487
+ .attr("class", "endpoint-label-card")
488
+ .attr("x", labelX)
489
+ .attr("y", labelY)
490
+ .attr("width", labelWidth)
491
+ .attr("height", labelHeight)
492
+ .attr("rx", 8)
493
+ .attr("fill", "#FFFFFF")
494
+ .attr("stroke", primary)
495
+ .attr("stroke-width", 1);
496
+ labelGroup.append("text")
497
+ .attr("class", "endpoint-value-label value-label")
498
+ .attr("x", labelX + labelWidth / 2)
499
+ .attr("y", labelY + labelHeight / 2)
500
+ .attr("text-anchor", "middle")
501
+ .attr("dominant-baseline", "middle")
502
+ .attr("fill", textColor)
503
+ .style("font-family", fontFamily)
504
+ .style("font-size", "10px")
505
+ .style("font-weight", "900")
506
+ .text(label);
507
+ };
508
+ drawEndpointLabel(startDatum, "left");
509
+ drawEndpointLabel(endDatum, "right");
510
+
511
+ plot.append("text")
512
+ .attr("class", "axis-title y-axis-title")
513
+ .attr("x", -4)
514
+ .attr("y", -20)
515
+ .attr("text-anchor", "start")
516
+ .attr("fill", textColor)
517
+ .style("font-family", titleFontFamily)
518
+ .style("font-size", "10.5px")
519
+ .style("font-weight", "900")
520
+ .text(shortLabel(yUnit ? `${yLabel} (${yUnit})` : yLabel, Math.max(16, Math.floor(plotW / 15))));
521
+ plot.append("text")
522
+ .attr("class", "axis-title x-axis-title")
523
+ .attr("x", plotW)
524
+ .attr("y", plotH + 43)
525
+ .attr("text-anchor", "end")
526
+ .attr("fill", mutedText)
527
+ .style("font-family", fontFamily)
528
+ .style("font-size", "10px")
529
+ .style("font-weight", "700")
530
+ .text(shortLabel(xLabel, Math.max(12, Math.floor(plotW / 17))));
531
+
532
+ const legend = root.append("g")
533
+ .attr("class", "series-legend")
534
+ .attr("transform", `translate(${plotX}, ${plotY - 24})`);
535
+ legend.append("line")
536
+ .attr("x1", 0)
537
+ .attr("y1", 0)
538
+ .attr("x2", 30)
539
+ .attr("y2", 0)
540
+ .attr("stroke", primary)
541
+ .attr("stroke-width", 3.4)
542
+ .attr("stroke-linecap", "round");
543
+ legend.append("text")
544
+ .attr("x", 39)
545
+ .attr("y", 1)
546
+ .attr("dominant-baseline", "middle")
547
+ .attr("fill", textColor)
548
+ .style("font-family", fontFamily)
549
+ .style("font-size", "10px")
550
+ .style("font-weight", "800")
551
+ .text(shortLabel(yLabel, Math.max(14, Math.floor((plotW - 42) / 6))));
552
+
553
+ const footerGap = 12;
554
+ const footerCardW = (width - panelPad * 2 - footerGap) / 2;
555
+ const addFooterCard = (x, color, glyph, title, value, className) => {
556
+ const card = root.append("g")
557
+ .attr("class", `footer-note-card ${className}`)
558
+ .attr("transform", `translate(${x}, ${footerY})`);
559
+ card.append("rect")
560
+ .attr("width", footerCardW)
561
+ .attr("height", footerH)
562
+ .attr("rx", 15)
563
+ .attr("fill", "#FFFFFF")
564
+ .attr("opacity", 0.82)
565
+ .attr("stroke", cardStroke);
566
+ addIconSlot(card, 25, footerH / 2, 15, "footer-icon-slot decorative-icon-slot", color, glyph);
567
+ card.append("text")
568
+ .attr("class", "footer-title")
569
+ .attr("x", 50)
570
+ .attr("y", footerH / 2 - 8)
571
+ .attr("fill", color)
572
+ .style("font-family", titleFontFamily)
573
+ .style("font-size", "9px")
574
+ .style("font-weight", "900")
575
+ .text(title);
576
+ card.append("text")
577
+ .attr("class", "footer-value")
578
+ .attr("x", 50)
579
+ .attr("y", footerH / 2 + 12)
580
+ .attr("fill", textColor)
581
+ .style("font-family", fontFamily)
582
+ .style("font-size", "10px")
583
+ .style("font-weight", "800")
584
+ .text(shortLabel(value, Math.max(12, Math.floor((footerCardW - 58) / 5.3))));
585
+ };
586
+ addFooterCard(panelPad, secondary, "D", "CHANGE", `${directionText} ${deltaText}`, "change-card");
587
+ addFooterCard(panelPad + footerCardW + footerGap, primary, "T", "PERIOD", `${formatDate(startDatum.__xDate)} to ${formatDate(endDatum.__xDate)}`, "period-card");
588
+
589
+ return svg.node();
590
+ }