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

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/type24_bump_chart/bump_plain_chart_01.js ADDED
@@ -0,0 +1,522 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Bump Chart",
5
+ "chart_name": "bump_chart_05",
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
+ const referencePalette = ["#1F66B3", "#EA5B55", "#E0A315", "#7E5BB6", "#24A37B", "#4965C8", "#D34A8B", "#5E7D2B", "#2F8EA3", "#B65D2E"];
46
+ const paletteIndex = typeof groups !== "undefined" ? groups.indexOf(group) : -1;
47
+ if (paletteIndex >= 0) {
48
+ return referencePalette[paletteIndex % referencePalette.length];
49
+ }
50
+ return colorResolver.field(group, 0, { fallbackKey: "primary", fallback: "#888" }).value;
51
+ };
52
+
53
+ // 清空容器
54
+ d3.select(containerSelector).html("");
55
+
56
+ // 设置尺寸和边距 - 左侧预留参考图中的排行、图标槽和标签列
57
+ const width = variables.width;
58
+ const height = variables.height;
59
+ const margin = { top: 70, right: 32, bottom: 45, left: 285 };
60
+ const innerWidth = width - margin.left - margin.right;
61
+ const innerHeight = height - margin.top - margin.bottom;
62
+
63
+ // 获取字段名
64
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
65
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
66
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
67
+
68
+ // 获取唯一的组值和X值
69
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
70
+ const xValues = [...new Set(chartData.map(d => d[xField]))]
71
+ .sort((a, b) => chartUtils.format.parseDate(a) - chartUtils.format.parseDate(b));
72
+
73
+ // 创建SVG
74
+ const svg = d3.select(containerSelector)
75
+ .append("svg")
76
+ .attr("width", "100%")
77
+ .attr("height", height)
78
+ .attr("viewBox", `0 0 ${width} ${height}`)
79
+ .attr("style", "max-width: 100%; height: auto;")
80
+ .attr("xmlns", "http://www.w3.org/2000/svg")
81
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
82
+
83
+ // 创建图表组
84
+ const g = svg.append("g")
85
+ .attr("transform", `translate(${margin.left},${margin.top})`);
86
+
87
+ const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, innerWidth);
88
+
89
+ // 为每个组分配固定位置 (1到groups.length)
90
+ const fixedGroupPositions = {};
91
+ groups.forEach((group, i) => {
92
+ fixedGroupPositions[group] = i + 1;
93
+ });
94
+
95
+ // 1. 计算每个x下各group的排名
96
+ // 构建排名数据结构:{ group1: [{x, rank}], group2: ... }
97
+ const rankData = {};
98
+
99
+ // 初始化每个group的数据结构
100
+ groups.forEach(group => {
101
+ rankData[group] = [];
102
+ });
103
+
104
+ // 为每个时间点和每个组创建排名数据
105
+ xValues.forEach(x => {
106
+ // 取出该x下所有group的数据
107
+ const items = chartData.filter(d => d[xField] === x);
108
+
109
+ // 记录该时间点存在的组
110
+ const existingGroups = items.map(d => d[groupField]);
111
+
112
+ // 按yField降序排序(y越大排名越高)
113
+ items.sort((a, b) => b[yField] - a[yField]);
114
+
115
+ // 为有数据的组计算排名
116
+ const rankByGroup = {};
117
+ items.forEach((d, i) => {
118
+ rankByGroup[d[groupField]] = i + 1; // 排名从1开始
119
+ });
120
+
121
+ // 为每个组添加排名数据,无论是否存在
122
+ groups.forEach(group => {
123
+ if (existingGroups.includes(group)) {
124
+ // 该组在该时间点有数据
125
+ const dataItem = items.find(d => d[groupField] === group);
126
+ rankData[group].push({
127
+ x: x,
128
+ rank: rankByGroup[group],
129
+ value: dataItem[yField],
130
+ missing: false
131
+ });
132
+ } else {
133
+ // 该组在该时间点没有数据
134
+
135
+ // 查找该组最近的有效排名
136
+ let nearestRank = null;
137
+ // 先尝试找前一个时间点的排名
138
+ const groupData = rankData[group];
139
+ if (groupData && groupData.length > 0) {
140
+ const lastValidPoint = [...groupData].reverse().find(d => !d.missing);
141
+ if (lastValidPoint) {
142
+ nearestRank = lastValidPoint.rank;
143
+ }
144
+ }
145
+
146
+ // 如果找不到有效的历史排名,使用固定位置
147
+ if (nearestRank === null) {
148
+ nearestRank = fixedGroupPositions[group];
149
+ }
150
+
151
+ rankData[group].push({
152
+ x: x,
153
+ rank: nearestRank, // 使用最近的有效排名或固定位置
154
+ value: null,
155
+ missing: true
156
+ });
157
+ }
158
+ });
159
+ });
160
+
161
+ // 对每个组的数据按x值排序
162
+ groups.forEach(group => {
163
+ rankData[group].sort((a, b) => {
164
+ const dateA = chartUtils.format.parseDate(a.x);
165
+ const dateB = chartUtils.format.parseDate(b.x);
166
+ return dateA - dateB;
167
+ });
168
+ });
169
+
170
+ // 2. y轴比例尺:排名(1在顶部)
171
+ const yScale = d3.scaleLinear()
172
+ .domain([1, groups.length])
173
+ .range([0, innerHeight]);
174
+
175
+ const rowStep = groups.length > 1 ? innerHeight / (groups.length - 1) : innerHeight;
176
+ const rowBandHeight = Math.max(34, Math.min(88, rowStep * 0.72));
177
+ const rowBandX = -margin.left + 12;
178
+ const rowBandW = width - 24;
179
+ const tabW = Math.max(26, Math.min(40, rowBandHeight * 0.46));
180
+ const iconR = Math.max(12, Math.min(25, rowBandHeight * 0.30));
181
+ const iconX = rowBandX + tabW + iconR + 18;
182
+ const labelX = iconX + iconR + 18;
183
+ const triangleX = Math.min(-26, labelX + 108);
184
+ const labelMaxWidth = Math.max(118, Math.min(155, innerWidth * 0.36));
185
+
186
+ const truncateToWidth = (text, maxWidth, fontSize, fontWeight = "700") => {
187
+ const value = String(text);
188
+ if (chartUtils.text.measure(null, value, { fontSize, fontWeight }).width <= maxWidth) {
189
+ return value;
190
+ }
191
+ let trimmed = value;
192
+ while (trimmed.length > 3 && chartUtils.text.measure(null, `${trimmed}...`, { fontSize, fontWeight }).width > 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 (chartUtils.text.measure(null, candidate, { fontSize, fontWeight }).width <= 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) {
213
+ return lines;
214
+ }
215
+ const kept = lines.slice(0, maxLines);
216
+ kept[maxLines - 1] = truncateToWidth(kept[maxLines - 1], maxWidth, fontSize, fontWeight);
217
+ return kept;
218
+ };
219
+
220
+ const defs = svg.append("defs");
221
+ const shadow = defs.append("filter")
222
+ .attr("id", "bump05-card-shadow")
223
+ .attr("x", "-10%")
224
+ .attr("y", "-20%")
225
+ .attr("width", "120%")
226
+ .attr("height", "150%");
227
+ shadow.append("feDropShadow")
228
+ .attr("dx", 0)
229
+ .attr("dy", 4)
230
+ .attr("stdDeviation", 7)
231
+ .attr("flood-color", "#12315A")
232
+ .attr("flood-opacity", 0.08);
233
+
234
+ groups.forEach((group, i) => {
235
+ const rowY = yScale(fixedGroupPositions[group]);
236
+ const color = getColor(group);
237
+ g.append("rect")
238
+ .attr("class", "bump-row-band")
239
+ .attr("x", rowBandX)
240
+ .attr("y", rowY - rowBandHeight / 2)
241
+ .attr("width", rowBandW)
242
+ .attr("height", rowBandHeight)
243
+ .attr("rx", Math.min(22, rowBandHeight / 2))
244
+ .attr("fill", i % 2 === 0 ? "#F7FAFE" : "#FFFFFF")
245
+ .attr("filter", "url(#bump05-card-shadow)");
246
+
247
+ g.append("rect")
248
+ .attr("class", "bump-rank-tab")
249
+ .attr("x", rowBandX)
250
+ .attr("y", rowY - rowBandHeight / 2)
251
+ .attr("width", tabW)
252
+ .attr("height", rowBandHeight)
253
+ .attr("rx", Math.min(18, rowBandHeight / 2))
254
+ .attr("fill", color)
255
+ .attr("opacity", 0.92);
256
+ g.append("rect")
257
+ .attr("x", rowBandX + tabW - Math.min(18, rowBandHeight / 2))
258
+ .attr("y", rowY - rowBandHeight / 2)
259
+ .attr("width", Math.min(18, rowBandHeight / 2))
260
+ .attr("height", rowBandHeight)
261
+ .attr("fill", color)
262
+ .attr("opacity", 0.92);
263
+ g.append("text")
264
+ .attr("class", "bump-rank-label")
265
+ .attr("x", rowBandX + tabW / 2)
266
+ .attr("y", rowY + 6)
267
+ .attr("text-anchor", "middle")
268
+ .attr("font-family", "Arial, Helvetica, sans-serif")
269
+ .attr("font-weight", "800")
270
+ .attr("font-size", Math.max(14, Math.min(28, rowBandHeight * 0.34)))
271
+ .attr("fill", "#FFFFFF")
272
+ .text(i + 1);
273
+
274
+ const slot = g.append("g")
275
+ .attr("class", "reserved-asset-slot bump-group-icon-slot")
276
+ .attr("data-asset-slot", "group-icon")
277
+ .attr("data-asset-source-policy", "neutral_placeholder")
278
+ .attr("data-anchor", `row-${i + 1}-label-center`)
279
+ .attr("data-collision-rule", "stay inside row band and left of plotted rank lines")
280
+ .attr("transform", `translate(${iconX},${rowY})`);
281
+ slot.append("circle")
282
+ .attr("r", iconR)
283
+ .attr("fill", "#EEF5FF")
284
+ .attr("stroke", "#FFFFFF")
285
+ .attr("stroke-width", 3);
286
+ slot.append("circle")
287
+ .attr("r", iconR * 0.62)
288
+ .attr("fill", "none")
289
+ .attr("stroke", color)
290
+ .attr("stroke-width", 2)
291
+ .attr("opacity", 0.32);
292
+ slot.append("text")
293
+ .attr("text-anchor", "middle")
294
+ .attr("dy", "0.35em")
295
+ .attr("font-family", "Arial, Helvetica, sans-serif")
296
+ .attr("font-size", Math.max(8, iconR * 0.50))
297
+ .attr("font-weight", "800")
298
+ .attr("fill", color)
299
+ .text(String(group).trim().slice(0, 1).toUpperCase());
300
+ });
301
+
302
+ // 3. 绘制线条
303
+ groups.forEach(group => {
304
+ const groupRanks = rankData[group];
305
+
306
+ // 使用定义的线条生成器来创建中断的线条
307
+ const lineGenerator = d3.line()
308
+ .x(d => xScale(chartUtils.format.parseDate(d.x)))
309
+ .y(d => yScale(d.rank))
310
+ .defined(d => !d.missing); // 只在有效数据点之间绘制线条
311
+
312
+ g.append("path")
313
+ .datum(groupRanks)
314
+ .attr("fill", "none")
315
+ .attr("stroke", getColor(group))
316
+ .attr("stroke-width", Math.max(3, Math.min(5, rowBandHeight * 0.06)))
317
+ .attr("stroke-linecap", "round")
318
+ .attr("stroke-linejoin", "round")
319
+ .attr("opacity", 0.96)
320
+ .attr("d", lineGenerator);
321
+
322
+ // 计算y值的范围用于缩放圆点大小
323
+ const yValues = chartData.map(d => +d[yField]).filter(d => d !== null && d !== undefined);
324
+ const yMin = Math.min(...yValues);
325
+ const yMax = Math.max(...yValues);
326
+ const radiusScale = d3.scaleLinear()
327
+ .domain([yMin, yMax])
328
+ .range([
329
+ Math.max(7, Math.min(14, rowBandHeight * 0.17)),
330
+ Math.max(11, Math.min(24, rowBandHeight * 0.30))
331
+ ]);
332
+
333
+ // 为每个组生成一个安全的CSS类名
334
+ const safeGroupId = `group-${groups.indexOf(group)}`;
335
+
336
+ // 绘制每个点的圆点 - 只绘制非缺失的点
337
+ g.selectAll(`.dot-${safeGroupId}`)
338
+ .data(groupRanks.filter(d => !d.missing))
339
+ .enter()
340
+ .append("circle")
341
+ .attr("class", `dot-${safeGroupId}`)
342
+ .attr("cx", d => xScale(chartUtils.format.parseDate(d.x)))
343
+ .attr("cy", d => yScale(d.rank))
344
+ .attr("r", d => radiusScale(d.value))
345
+ .attr("fill", getColor(group))
346
+ .attr("stroke", "#fff")
347
+ .attr("stroke-width", Math.max(2, Math.min(4, rowBandHeight * 0.05)));
348
+
349
+ // 添加数值标签 - 只为非缺失的点添加
350
+ g.selectAll(`.label-${safeGroupId}`)
351
+ .data(groupRanks.filter(d => !d.missing))
352
+ .enter()
353
+ .append("text")
354
+ .attr("class", `label-${safeGroupId}`)
355
+ .attr("x", d => xScale(chartUtils.format.parseDate(d.x)))
356
+ .attr("y", d => {
357
+ const radius = radiusScale(d.value);
358
+ return radius < 10 ? yScale(d.rank) - radius - 5 : yScale(d.rank);
359
+ })
360
+ .attr("text-anchor", "middle")
361
+ .attr("dy", d => {
362
+ const radius = radiusScale(d.value);
363
+ return radius < 10 ? "0" : "0.35em";
364
+ })
365
+ .attr("font-size", "12px")
366
+ .attr("font-family", "Arial, Helvetica, sans-serif")
367
+ .attr("fill", d => {
368
+ const radius = radiusScale(d.value);
369
+ if (radius < 10) {
370
+ return "#0B172A"; // 小圆点外的标签使用深色
371
+ } else {
372
+ // 大圆点内的标签根据背景亮度选择颜色
373
+ const luminance = chartUtils.color.brightness(getColor(group));
374
+ return luminance > 0.62 ? "#0B172A" : "#fff";
375
+ }
376
+ })
377
+ .attr("font-weight", "800")
378
+ .text(d => chartUtils.format.integer(d.value).text);
379
+ });
380
+
381
+ // 优化时间标签���免重叠
382
+ const optimizeTimeLabels = (ticks, format, availableWidth) => {
383
+ if (ticks.length <= 1) return ticks;
384
+
385
+ // 计算所有标签的宽度
386
+ const calculateLabelWidth = (tick) => {
387
+ return chartUtils.text.measure(g, format(tick), { fontSize: 13, fontWeight: "700" }).width + 8; // 添加一些边距
388
+ };
389
+
390
+ const tickWidths = ticks.map(tick => calculateLabelWidth(tick));
391
+
392
+ // 检查是否会重叠
393
+ let willOverlap = false;
394
+ let positions = ticks.map(t => xScale(t));
395
+
396
+ for (let i = 0; i < positions.length - 1; i++) {
397
+ const gap = positions[i+1] - positions[i];
398
+ if (gap < (tickWidths[i] + tickWidths[i+1]) / 2) {
399
+ willOverlap = true;
400
+ break;
401
+ }
402
+ }
403
+
404
+ // 如果不会重叠,显示所有标签
405
+ if (!willOverlap) {
406
+ return ticks;
407
+ }
408
+
409
+ // 计算可以显示的标签数量
410
+ const averageWidth = tickWidths.reduce((a, b) => a + b, 0) / ticks.length;
411
+ let maxLabels = Math.floor(availableWidth / averageWidth);
412
+
413
+ // 确保至少显示首尾标签
414
+ if (maxLabels <= 2) {
415
+ return [ticks[0], ticks[ticks.length - 1]];
416
+ }
417
+
418
+ // 均匀选择标签
419
+ const step = Math.ceil(ticks.length / maxLabels);
420
+ const optimizedTicks = [];
421
+
422
+ for (let i = 0; i < ticks.length; i += step) {
423
+ optimizedTicks.push(ticks[i]);
424
+ }
425
+
426
+ // 确保包含最后一个标签
427
+ if (optimizedTicks[optimizedTicks.length - 1] !== ticks[ticks.length - 1]) {
428
+ optimizedTicks.push(ticks[ticks.length - 1]);
429
+ }
430
+
431
+ return optimizedTicks;
432
+ };
433
+
434
+ const enforceTickGap = (ticks, minGap) => {
435
+ const kept = [];
436
+ ticks.forEach((tick, index) => {
437
+ const currentX = xScale(tick);
438
+ if (!kept.length) {
439
+ kept.push(tick);
440
+ return;
441
+ }
442
+ const previousX = xScale(kept[kept.length - 1]);
443
+ if (currentX - previousX >= minGap) {
444
+ kept.push(tick);
445
+ return;
446
+ }
447
+ if (index === ticks.length - 1) {
448
+ kept[kept.length - 1] = tick;
449
+ }
450
+ });
451
+ return kept;
452
+ };
453
+
454
+ // 优化时间标签
455
+ const optimizedXTicks = enforceTickGap(optimizeTimeLabels(xTicks, xFormat, innerWidth), 38);
456
+
457
+ // 添加顶部时间轴导轨和时间标签
458
+ const axisY = -38;
459
+ g.append("line")
460
+ .attr("class", "bump-time-rail")
461
+ .attr("x1", xScale(optimizedXTicks[0]))
462
+ .attr("x2", Math.min(innerWidth + 14, xScale(optimizedXTicks[optimizedXTicks.length - 1]) + 14))
463
+ .attr("y1", axisY)
464
+ .attr("y2", axisY)
465
+ .attr("stroke", "#C7CDD6")
466
+ .attr("stroke-width", 1.5)
467
+ .attr("stroke-linecap", "round");
468
+ g.append("path")
469
+ .attr("d", `M${Math.min(innerWidth + 14, xScale(optimizedXTicks[optimizedXTicks.length - 1]) + 14)},${axisY} l-8,-5 v10 z`)
470
+ .attr("fill", "#A8B0BA");
471
+ optimizedXTicks.forEach(tick => {
472
+ g.append("circle")
473
+ .attr("class", "bump-time-dot")
474
+ .attr("cx", xScale(tick))
475
+ .attr("cy", axisY)
476
+ .attr("r", 4)
477
+ .attr("fill", "#A8B0BA")
478
+ .attr("stroke", "#F8FAFC")
479
+ .attr("stroke-width", 1.5);
480
+ g.append("text")
481
+ .attr("x", xScale(tick))
482
+ .attr("y", axisY - 16)
483
+ .attr("text-anchor", "middle")
484
+ .attr("font-family", "Arial, Helvetica, sans-serif")
485
+ .attr("font-size", 13)
486
+ .attr("font-weight", "700")
487
+ .attr("fill", "#59616D")
488
+ .text(xFormat(tick));
489
+ });
490
+ // 4. 绘制group标签和三角形标记,固定在左侧卡片栏内以避免与线/点重叠
491
+ groups.forEach((group, i) => {
492
+ const fixedRank = fixedGroupPositions[group];
493
+ const rowY = yScale(fixedRank);
494
+
495
+ // 添加三角形标记(指向右侧)
496
+ g.append("path")
497
+ .attr("d", "M0,-5 L10,0 L0,5 Z")
498
+ .attr("transform", `translate(${triangleX},${rowY})`)
499
+ .attr("fill", getColor(group));
500
+
501
+ // 添加group标签
502
+ const labelFontSize = Math.max(10, Math.min(17, rowBandHeight * 0.22));
503
+ const labelLines = wrapToLines(group, labelMaxWidth, labelFontSize, "800", rowBandHeight > 50 ? 2 : 1);
504
+ const labelText = g.append("text")
505
+ .attr("x", labelX)
506
+ .attr("y", rowY - ((labelLines.length - 1) * labelFontSize * 0.56))
507
+ .attr("text-anchor", "start")
508
+ .attr("dominant-baseline", "middle")
509
+ .attr("font-family", "Arial, Helvetica, sans-serif")
510
+ .attr("font-weight", "800")
511
+ .attr("font-size", labelFontSize)
512
+ .attr("fill", getColor(group));
513
+ labelLines.forEach((line, lineIndex) => {
514
+ labelText.append("tspan")
515
+ .attr("x", labelX)
516
+ .attr("dy", lineIndex === 0 ? 0 : labelFontSize * 1.08)
517
+ .text(line);
518
+ });
519
+ });
520
+
521
+ return svg.node();
522
+ }
modules/chart_engine/template/d3-js/type24_bump_chart/bump_plain_chart_02.js ADDED
@@ -0,0 +1,423 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Bump Chart",
5
+ "chart_name": "bump_plain_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
+ [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
+ 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 dataColumns = chartUtils.schema.columns(jsonData);
37
+
38
+ const width = Number(variables.width) || 600;
39
+ const height = Number(variables.height) || 600;
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
+ const referencePalette = [
46
+ "#14AAA9",
47
+ "#F2A400",
48
+ "#2E86CF",
49
+ "#E94E67",
50
+ "#7657D1",
51
+ "#36A96F",
52
+ "#D9589D",
53
+ "#5669B8",
54
+ "#E27A3F",
55
+ "#238C9D"
56
+ ];
57
+
58
+ const groups = [];
59
+ chartData.forEach(d => {
60
+ const group = String(d[groupField]);
61
+ if (!groups.includes(group)) groups.push(group);
62
+ });
63
+
64
+ const makeClassSafe = value => String(value).replace(/[^a-z0-9]/gi, "_");
65
+ const chronologicalKey = value => {
66
+ const parsed = chartUtils.format.parseDate(value);
67
+ const time = parsed instanceof Date ? parsed.getTime() : Number(parsed);
68
+ if (Number.isFinite(time)) return time;
69
+ const numeric = Number(value);
70
+ if (Number.isFinite(numeric)) return numeric;
71
+ return String(value);
72
+ };
73
+ const xValues = Array.from(new Set(chartData.map(d => d[xField])))
74
+ .sort((a, b) => {
75
+ const ka = chronologicalKey(a);
76
+ const kb = chronologicalKey(b);
77
+ if (typeof ka === "number" && typeof kb === "number") return ka - kb;
78
+ return String(a).localeCompare(String(b));
79
+ });
80
+ const xKey = value => String(value);
81
+
82
+ const getColor = group => {
83
+ const idx = groups.indexOf(String(group));
84
+ if (idx >= 0) return referencePalette[idx % referencePalette.length];
85
+ return colorResolver.field(group, 0, { fallbackKey: "primary", fallback: "#14AAA9" }).value;
86
+ };
87
+ const luminance = hex => {
88
+ const clean = String(hex).replace("#", "");
89
+ if (clean.length < 6) return 0.45;
90
+ const rgb = [0, 2, 4].map(i => parseInt(clean.slice(i, i + 2), 16) / 255);
91
+ return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
92
+ };
93
+ const measure = (text, fontSize, fontWeight = "700") => {
94
+ return chartUtils.text.measure(null, String(text), { fontSize, fontWeight }).width;
95
+ };
96
+ const fitText = (text, maxWidth, fontSize, fontWeight = "700") => {
97
+ const value = String(text);
98
+ if (measure(value, fontSize, fontWeight) <= maxWidth) return value;
99
+ let trimmed = value;
100
+ while (trimmed.length > 2 && measure(`${trimmed}...`, fontSize, fontWeight) > maxWidth) {
101
+ trimmed = trimmed.slice(0, -1);
102
+ }
103
+ return `${trimmed}...`;
104
+ };
105
+ const yearLabel = value => chartUtils.format.value(value).text;
106
+ const valueLabel = value => chartUtils.format.integer(value).text;
107
+
108
+ d3.select(containerSelector).html("");
109
+ const svg = d3.select(containerSelector)
110
+ .append("svg")
111
+ .attr("width", "100%")
112
+ .attr("height", height)
113
+ .attr("viewBox", `0 0 ${width} ${height}`)
114
+ .attr("style", "max-width: 100%; height: auto;")
115
+ .attr("xmlns", "http://www.w3.org/2000/svg")
116
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
117
+
118
+ const defs = svg.append("defs");
119
+ const cardShadow = defs.append("filter")
120
+ .attr("id", "bump02-card-shadow")
121
+ .attr("x", "-20%")
122
+ .attr("y", "-25%")
123
+ .attr("width", "140%")
124
+ .attr("height", "160%");
125
+ cardShadow.append("feDropShadow")
126
+ .attr("dx", 0)
127
+ .attr("dy", 6)
128
+ .attr("stdDeviation", 8)
129
+ .attr("flood-color", "#244A64")
130
+ .attr("flood-opacity", 0.14);
131
+
132
+ const nodeShadow = defs.append("filter")
133
+ .attr("id", "bump02-node-shadow")
134
+ .attr("x", "-25%")
135
+ .attr("y", "-25%")
136
+ .attr("width", "150%")
137
+ .attr("height", "150%");
138
+ nodeShadow.append("feDropShadow")
139
+ .attr("dx", 0)
140
+ .attr("dy", 3)
141
+ .attr("stdDeviation", 3.5)
142
+ .attr("flood-color", "#14384F")
143
+ .attr("flood-opacity", 0.20);
144
+
145
+ svg.append("rect")
146
+ .attr("width", width)
147
+ .attr("height", height)
148
+ .attr("fill", "none");
149
+
150
+ const decor = svg.append("g")
151
+ .attr("class", "bump02-background-decoration")
152
+ .attr("aria-hidden", "true");
153
+ decor.append("path")
154
+ .attr("d", `M0,${height * 0.83} C${width * 0.16},${height * 0.77} ${width * 0.22},${height * 0.90} ${width * 0.42},${height * 0.84} C${width * 0.64},${height * 0.78} ${width * 0.78},${height * 0.84} ${width},${height * 0.75} L${width},${height} L0,${height} Z`)
155
+ .attr("fill", "#DDF2F8")
156
+ .attr("opacity", 0.62);
157
+ decor.append("path")
158
+ .attr("d", `M0,${height * 0.90} C${width * 0.20},${height * 0.86} ${width * 0.31},${height * 0.96} ${width * 0.55},${height * 0.90} C${width * 0.78},${height * 0.85} ${width * 0.88},${height * 0.93} ${width},${height * 0.88} L${width},${height} L0,${height} Z`)
159
+ .attr("fill", "#CFEAF4")
160
+ .attr("opacity", 0.56);
161
+ const dotGroups = [
162
+ { x: width - 74, y: 30 },
163
+ { x: 22, y: 150 }
164
+ ];
165
+ dotGroups.forEach(group => {
166
+ const g = decor.append("g").attr("transform", `translate(${group.x},${group.y})`);
167
+ d3.range(5).forEach(row => {
168
+ d3.range(5).forEach(col => {
169
+ g.append("circle")
170
+ .attr("cx", col * 11)
171
+ .attr("cy", row * 11)
172
+ .attr("r", 2.2)
173
+ .attr("fill", "#8FC7E9")
174
+ .attr("opacity", 0.46);
175
+ });
176
+ });
177
+ });
178
+
179
+ const rowCount = Math.max(groups.length, 1);
180
+ const chartTop = 20;
181
+ const chartBottom = height - 76;
182
+ const plotX0 = Math.max(220, width * 0.37);
183
+ const plotX1 = width - 44;
184
+ const xScale = d3.scalePoint()
185
+ .domain(xValues.map(xKey))
186
+ .range([plotX0, plotX1])
187
+ .padding(0.14);
188
+ const yScale = d3.scalePoint()
189
+ .domain(groups)
190
+ .range([chartTop + 72, chartBottom])
191
+ .padding(0.08);
192
+ const rowStep = rowCount > 1 ? Math.abs(yScale(groups[1]) - yScale(groups[0])) : 78;
193
+ const rowCardH = Math.max(48, Math.min(66, rowStep * 0.64));
194
+ const rowCardX = 28;
195
+ const rowCardW = Math.max(148, Math.min(174, plotX0 - rowCardX - 20));
196
+ const iconR = Math.max(16, Math.min(23, rowCardH * 0.32));
197
+
198
+ const yearLayer = svg.append("g")
199
+ .attr("class", "bump02-year-axis")
200
+ .attr("data-coordinate-relationship", "grid-aligned-to-chronological-year-columns");
201
+ const guideTop = chartTop + 48;
202
+ const guideBottom = chartBottom + 24;
203
+ xValues.forEach(value => {
204
+ const x = xScale(xKey(value));
205
+ yearLayer.append("line")
206
+ .attr("class", "bump02-year-guide")
207
+ .attr("x1", x)
208
+ .attr("x2", x)
209
+ .attr("y1", guideTop)
210
+ .attr("y2", guideBottom)
211
+ .attr("stroke", "#B8DDF1")
212
+ .attr("stroke-width", 1.5)
213
+ .attr("stroke-dasharray", "3 6")
214
+ .attr("opacity", 0.82);
215
+ const chipW = Math.max(56, Math.min(74, (plotX1 - plotX0) / Math.max(xValues.length, 1) * 0.86));
216
+ yearLayer.append("rect")
217
+ .attr("class", "bump02-year-chip")
218
+ .attr("x", x - chipW / 2)
219
+ .attr("y", chartTop + 18)
220
+ .attr("width", chipW)
221
+ .attr("height", 27)
222
+ .attr("rx", 9)
223
+ .attr("fill", "#E3F2FA")
224
+ .attr("opacity", 0.86);
225
+ yearLayer.append("text")
226
+ .attr("class", "bump02-year-label")
227
+ .attr("x", x)
228
+ .attr("y", chartTop + 37)
229
+ .attr("text-anchor", "middle")
230
+ .attr("font-family", "Arial, Helvetica, sans-serif")
231
+ .attr("font-size", 14)
232
+ .attr("font-weight", 800)
233
+ .attr("fill", "#071B43")
234
+ .text(yearLabel(value));
235
+ });
236
+
237
+ const byGroupYear = new Map();
238
+ chartData.forEach(d => {
239
+ byGroupYear.set(`${String(d[groupField])}__${xKey(d[xField])}`, d);
240
+ });
241
+ const yValues = chartData.map(d => Number(d[yField])).filter(Number.isFinite);
242
+ const yMin = d3.min(yValues) || 0;
243
+ const yMax = d3.max(yValues) || 1;
244
+ const sizeScale = d3.scaleSqrt()
245
+ .domain([yMin, yMax])
246
+ .range([24, 44]);
247
+
248
+ const rowLayer = svg.append("g")
249
+ .attr("class", "bump02-row-cards")
250
+ .attr("data-coordinate-relationship", "centerline-aligned-row-cards");
251
+ groups.forEach((group, i) => {
252
+ const y = yScale(group);
253
+ const color = getColor(group);
254
+ const slotX = rowCardX + 22;
255
+ const slotY = y - iconR;
256
+ rowLayer.append("rect")
257
+ .attr("class", "bump02-left-card")
258
+ .attr("x", rowCardX)
259
+ .attr("y", y - rowCardH / 2)
260
+ .attr("width", rowCardW)
261
+ .attr("height", rowCardH)
262
+ .attr("rx", rowCardH / 2)
263
+ .attr("fill", "#FFFFFF")
264
+ .attr("filter", "url(#bump02-card-shadow)")
265
+ .attr("data-group", group)
266
+ .attr("data-row-index", i + 1);
267
+ const wedgeX = rowCardX + rowCardW - 20;
268
+ rowLayer.append("path")
269
+ .attr("class", "bump02-left-card-wedge")
270
+ .attr("d", `M${wedgeX},${y - rowCardH / 2} L${rowCardX + rowCardW},${y} L${wedgeX},${y + rowCardH / 2} Q${rowCardX + rowCardW - 5},${y} ${wedgeX},${y - rowCardH / 2} Z`)
271
+ .attr("fill", color);
272
+ rowLayer.append("g")
273
+ .attr("class", "reserved-asset-slot bump02-row-icon-slot")
274
+ .attr("data-asset-slot", `row-icon-${i + 1}`)
275
+ .attr("data-reserved-slot", `row_icon_${i + 1}`)
276
+ .attr("data-asset-source-policy", "image2_asset")
277
+ .attr("data-anchor", `row-card-${i + 1}-centerline`)
278
+ .attr("data-bbox", `${slotX - iconR},${slotY},${iconR * 2},${iconR * 2}`)
279
+ .attr("data-collision-rule", "stay inside left row card; avoid rank text, group label, colored wedge, and first-year node")
280
+ .attr("data-no-embedded-raster", "true")
281
+ .attr("data-no-copied-reference-art", "true")
282
+ .call(slot => {
283
+ slot.append("circle")
284
+ .attr("cx", slotX)
285
+ .attr("cy", y)
286
+ .attr("r", iconR)
287
+ .attr("fill", "#FFFFFF")
288
+ .attr("stroke", color)
289
+ .attr("stroke-width", 2);
290
+ slot.append("circle")
291
+ .attr("cx", slotX)
292
+ .attr("cy", y)
293
+ .attr("r", Math.max(4, iconR * 0.18))
294
+ .attr("fill", color)
295
+ .attr("opacity", 0.18);
296
+ slot.append("circle")
297
+ .attr("class", "reserved-slot-anchor")
298
+ .attr("cx", slotX)
299
+ .attr("cy", y)
300
+ .attr("r", 1.8)
301
+ .attr("fill", color)
302
+ .attr("opacity", 0.45);
303
+ });
304
+ const textX = rowCardX + 72;
305
+ const labelMaxW = Math.max(62, rowCardW - 96);
306
+ const baseLabelFont = Math.max(14, Math.min(19, rowCardH * 0.30));
307
+ let labelFont = baseLabelFont;
308
+ while (labelFont > 11 && measure(String(group), labelFont, "800") > labelMaxW) {
309
+ labelFont -= 1;
310
+ }
311
+ rowLayer.append("text")
312
+ .attr("class", "bump02-row-rank")
313
+ .attr("x", textX)
314
+ .attr("y", y - 4)
315
+ .attr("font-family", "Arial, Helvetica, sans-serif")
316
+ .attr("font-size", Math.max(16, Math.min(22, rowCardH * 0.34)))
317
+ .attr("font-weight", 900)
318
+ .attr("fill", color)
319
+ .text(i + 1);
320
+ rowLayer.append("text")
321
+ .attr("class", "bump02-row-label")
322
+ .attr("x", textX)
323
+ .attr("y", y + 18)
324
+ .attr("font-family", "Arial, Helvetica, sans-serif")
325
+ .attr("font-size", labelFont)
326
+ .attr("font-weight", 800)
327
+ .attr("fill", color)
328
+ .text(fitText(group, labelMaxW, labelFont, "800"));
329
+ });
330
+
331
+ const markLayer = svg.append("g")
332
+ .attr("class", "bump02-marks")
333
+ .attr("data-coordinate-relationship", "grid-aligned-value-nodes-to-year-columns-and-group-rows");
334
+ groups.forEach((group, groupIndex) => {
335
+ const color = getColor(group);
336
+ const points = xValues
337
+ .map(x => {
338
+ const datum = byGroupYear.get(`${group}__${xKey(x)}`);
339
+ if (!datum) return null;
340
+ const value = Number(datum[yField]);
341
+ return {
342
+ xRaw: x,
343
+ x: xScale(xKey(x)),
344
+ y: yScale(group),
345
+ value,
346
+ group,
347
+ datum
348
+ };
349
+ })
350
+ .filter(Boolean);
351
+ if (!points.length) return;
352
+ markLayer.append("path")
353
+ .datum(points)
354
+ .attr("class", `bump02-line bump02-line-${makeClassSafe(group)}`)
355
+ .attr("data-group", group)
356
+ .attr("fill", "none")
357
+ .attr("stroke", color)
358
+ .attr("stroke-width", 3.4)
359
+ .attr("stroke-linecap", "round")
360
+ .attr("stroke-linejoin", "round")
361
+ .attr("opacity", 0.88)
362
+ .attr("d", d3.line()
363
+ .x(d => d.x)
364
+ .y(d => d.y)
365
+ .curve(d3.curveMonotoneX));
366
+ points.forEach((point, pointIndex) => {
367
+ const base = sizeScale(point.value);
368
+ const isFinal = pointIndex === points.length - 1;
369
+ const size = Math.min(58, base + (isFinal ? 10 : 0));
370
+ const fill = d3.color(color);
371
+ const nodeFill = isFinal && fill ? fill.darker(0.45).formatHex() : color;
372
+ markLayer.append("rect")
373
+ .attr("class", `bump02-node bump02-node-${makeClassSafe(group)}`)
374
+ .attr("data-group", group)
375
+ .attr("data-year", yearLabel(point.xRaw))
376
+ .attr("data-value", point.value)
377
+ .attr("data-role", "value-node")
378
+ .attr("x", point.x - size / 2)
379
+ .attr("y", point.y - size / 2)
380
+ .attr("width", size)
381
+ .attr("height", size)
382
+ .attr("rx", Math.max(7, size * 0.18))
383
+ .attr("fill", nodeFill)
384
+ .attr("stroke", "#FFFFFF")
385
+ .attr("stroke-width", 3.2)
386
+ .attr("filter", "url(#bump02-node-shadow)");
387
+ markLayer.append("text")
388
+ .attr("class", `bump02-node-label bump02-node-label-${makeClassSafe(group)}`)
389
+ .attr("x", point.x)
390
+ .attr("y", point.y + 1)
391
+ .attr("text-anchor", "middle")
392
+ .attr("dominant-baseline", "middle")
393
+ .attr("font-family", "Arial, Helvetica, sans-serif")
394
+ .attr("font-size", Math.max(11, Math.min(20, size * 0.37)))
395
+ .attr("font-weight", 900)
396
+ .attr("fill", luminance(nodeFill) > 0.62 ? "#0A1736" : "#FFFFFF")
397
+ .text(valueLabel(point.value));
398
+ });
399
+ });
400
+
401
+ svg.append("g")
402
+ .attr("class", "reserved-asset-slot bump02-bottom-icon-slot")
403
+ .attr("data-asset-slot", "bottom-summary-icon")
404
+ .attr("data-reserved-slot", "bottom_summary_icon")
405
+ .attr("data-asset-source-policy", "image2_asset_optional")
406
+ .attr("data-anchor", "bottom-safe-area-below-chart-rows")
407
+ .attr("data-bbox", `${width * 0.45},${height - 38},${width * 0.10},22`)
408
+ .attr("data-collision-rule", "omit if it would overlap chart rows, labels, or canvas edge; no prose wrapper")
409
+ .attr("data-no-embedded-raster", "true")
410
+ .attr("data-no-copied-reference-art", "true")
411
+ .append("rect")
412
+ .attr("x", width * 0.45)
413
+ .attr("y", height - 38)
414
+ .attr("width", width * 0.10)
415
+ .attr("height", 22)
416
+ .attr("rx", 11)
417
+ .attr("fill", "none")
418
+ .attr("stroke", "#9DD6E7")
419
+ .attr("stroke-dasharray", "4 5")
420
+ .attr("opacity", 0.01);
421
+
422
+ return svg.node();
423
+ }
modules/chart_engine/template/d3-js/type24_bump_chart/bump_plain_chart_03.js ADDED
@@ -0,0 +1,434 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Sorted Lines Chart",
5
+ "chart_name": "bump_plain_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
+ [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
+ // 排序线图(Bump Chart)实现 - 使用D3.js
32
+ function makeChart(containerSelector, data) {
33
+ // ---------- 1. 数据准备阶段 ----------
34
+
35
+ // 提取数据和配置
36
+ const jsonData = data; // 完整的JSON数据对象
37
+ const chartData = jsonData.data.data; // 实际数据点数组
38
+ const variables = jsonData.variables || {}; // 图表配置
39
+ const typography = jsonData.typography || { // 字体设置,如果不存在则使用默认值
40
+ title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
41
+ label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
42
+ description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
43
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "bold" }
44
+ };
45
+ const colors = jsonData.colors || { text_color: "#333333" }; // 颜色设置
46
+ const colorResolver = chartUtils.color.resolver(jsonData);
47
+ const dataColumns = chartUtils.schema.columns(jsonData); // 数据列定义
48
+
49
+ // 清空容器
50
+ d3.select(containerSelector).html("");
51
+
52
+ // ---------- 辅助函数:动态调整字体大小 ----------
53
+ function getAdaptedFontSize(text, maxWidth, initialFontSize, fontFamily, fontWeight) {
54
+ let fontSize = initialFontSize;
55
+ const tempSvgForFont = d3.select(containerSelector)
56
+ .append("svg")
57
+ .attr("width", 0)
58
+ .attr("height", 0)
59
+ .style("position", "absolute") // 防止影响布局
60
+ .style("visibility", "hidden");
61
+
62
+ while (fontSize > 1) { // 最小字体大小为1px
63
+ const textWidth = chartUtils.text.measure(tempSvgForFont, text, {
64
+ fontFamily,
65
+ fontSize,
66
+ fontWeight
67
+ }).width;
68
+ if (textWidth <= maxWidth) {
69
+ break;
70
+ }
71
+ fontSize -= 1;
72
+ }
73
+ tempSvgForFont.remove();
74
+ return fontSize;
75
+ }
76
+
77
+ // ---------- 辅助函数:创建正六边形路径(横边朝上) ----------
78
+ function hexagonPath(cx, cy, r) {
79
+ const angles = d3.range(6).map(i => (i * Math.PI / 3) + (Math.PI / 6)); // 六边形的6个角,旋转30度(π/6)使横边朝上
80
+ const points = angles.map(angle => [
81
+ cx + r * Math.sin(angle),
82
+ cy - r * Math.cos(angle)
83
+ ]);
84
+ return d3.line()(points) + "Z"; // Z表示闭合路径
85
+ }
86
+
87
+ // ---------- 辅助函数:获取文本度量 ----------
88
+ function getTextMetrics(text, fontSize, fontFamily, fontWeight) {
89
+ return chartUtils.text.measure(null, text, { fontFamily, fontSize, fontWeight });
90
+ }
91
+
92
+ // ---------- 辅助函数:判断颜色是否为浅色 ----------
93
+ function isColorLight(hexColor) {
94
+ return chartUtils.color.brightness(hexColor, { fallback: "#000000" }) > (160 / 255);
95
+ }
96
+
97
+ // ---------- 2. 尺寸和布局设置 ----------
98
+
99
+ // The selected reference uses a portrait infographic wrapper. This inner
100
+ // chart owns the year ribbon, row bands, reserved row asset slots, and marks.
101
+ const width = variables.width || 800;
102
+ const height = Math.max(620, variables.height || 620);
103
+
104
+ const palette = [
105
+ "#238A2A",
106
+ "#F58215",
107
+ "#EF3E44",
108
+ "#119BA5",
109
+ "#2F7DD1",
110
+ "#8E44AD",
111
+ "#9A6A10",
112
+ "#64748B",
113
+ "#B45309",
114
+ "#0F766E"
115
+ ];
116
+
117
+ // ---------- 3. 提取字段名和单位 ----------
118
+
119
+ // 根据数据列顺序提取字段名
120
+ const timeField = chartUtils.schema.channel(jsonData, "x").key;
121
+ const valueField = chartUtils.schema.channel(jsonData, "y").key;
122
+ const groupField = chartUtils.schema.channel(jsonData, "group").key;
123
+
124
+ // 注意:此图表模板不使用字段单位
125
+
126
+ // ---------- 4. 数据处理 ----------
127
+
128
+ // 获取唯一时间点和分组值。Year/month/date-like fields must stay chronological.
129
+ const timePoints = [...new Set(chartData.map(d => d[timeField]))]
130
+ .sort((a, b) => chartUtils.format.parseDate(a) - chartUtils.format.parseDate(b));
131
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
132
+
133
+ // Sparse panels are valid: render each group's available points.
134
+
135
+ function safeId(value) {
136
+ return String(value).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "group";
137
+ }
138
+
139
+ function displayTime(value) {
140
+ if (value instanceof Date) return String(value.getFullYear());
141
+ const raw = String(value);
142
+ const yearMatch = raw.match(/\b(19|20)\d{2}\b/);
143
+ return yearMatch ? yearMatch[0] : raw;
144
+ }
145
+
146
+ function formatNodeValue(value) {
147
+ const numeric = +value;
148
+ if (!Number.isFinite(numeric)) return "";
149
+ if (Math.abs(numeric) >= 10) return String(Math.round(numeric));
150
+ return chartUtils.format.compactFixedText(numeric);
151
+ }
152
+
153
+ function seriesColor(group, index) {
154
+ return palette[index % palette.length];
155
+ }
156
+
157
+ // ---------- 5. 创建SVG容器 ----------
158
+
159
+ const svg = d3.select(containerSelector)
160
+ .append("svg")
161
+ .attr("width", "100%")
162
+ .attr("height", height)
163
+ .attr("viewBox", `0 0 ${width} ${height}`)
164
+ .attr("style", "max-width: 100%; height: auto;")
165
+ .attr("xmlns", "http://www.w3.org/2000/svg")
166
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
167
+
168
+ const chartBg = svg.append("defs");
169
+ const bgGradient = chartBg.append("linearGradient")
170
+ .attr("id", "bump03-chart-bg")
171
+ .attr("x1", "0%")
172
+ .attr("x2", "0%")
173
+ .attr("y1", "0%")
174
+ .attr("y2", "100%");
175
+ bgGradient.append("stop").attr("offset", "0%").attr("stop-color", "#F4FBFF");
176
+ bgGradient.append("stop").attr("offset", "100%").attr("stop-color", "#F4FBFF");
177
+
178
+ svg.append("rect")
179
+ .attr("class", "bump03-body-background")
180
+ .attr("x", 0)
181
+ .attr("y", 0)
182
+ .attr("width", width)
183
+ .attr("height", height)
184
+ .attr("fill", "url(#bump03-chart-bg)");
185
+
186
+ const ribbonX = 28;
187
+ const ribbonY = 16;
188
+ const ribbonW = width - 56;
189
+ const ribbonH = 42;
190
+ const labelColumnW = Math.max(132, Math.min(180, width * 0.19));
191
+ const plotX = labelColumnW + 36;
192
+ const plotRight = width - 42;
193
+ const plotW = Math.max(220, plotRight - plotX);
194
+ const plotTop = ribbonY + ribbonH + 34;
195
+ const plotBottom = height - 34;
196
+ const plotH = Math.max(220, plotBottom - plotTop);
197
+ const rowH = plotH / Math.max(1, groups.length);
198
+ const badgeR = Math.max(14, Math.min(38, rowH * 0.25));
199
+ const badgeX = Math.max(44, plotX - 106);
200
+ const labelFont = Math.max(11, Math.min(22, rowH * 0.17));
201
+ const valueFont = Math.max(10, Math.min(17, rowH * 0.14));
202
+ const nodeR = Math.max(4.5, Math.min(7, rowH * 0.055));
203
+ const endNodeR = nodeR + 3.2;
204
+ const xScale = d3.scalePoint()
205
+ .domain(timePoints)
206
+ .range([plotX, plotRight])
207
+ .padding(0.03);
208
+ const xStep = timePoints.length > 1 ? Math.abs(xScale(timePoints[1]) - xScale(timePoints[0])) : plotW;
209
+ const latestTime = timePoints[timePoints.length - 1];
210
+
211
+ svg.append("rect")
212
+ .attr("class", "bump03-final-year-highlight")
213
+ .attr("data-anchor", "last-chronological-time-point")
214
+ .attr("x", xScale(latestTime) - xStep * 0.42)
215
+ .attr("y", plotTop)
216
+ .attr("width", Math.max(22, xStep * 0.72))
217
+ .attr("height", plotH)
218
+ .attr("fill", "#DDF4F0")
219
+ .attr("opacity", 0.58);
220
+
221
+ const ribbon = svg.append("g")
222
+ .attr("class", "bump03-year-ribbon")
223
+ .attr("data-coordinate-relationship", "grid-aligned-to-temporal-columns");
224
+
225
+ ribbon.append("rect")
226
+ .attr("x", ribbonX)
227
+ .attr("y", ribbonY)
228
+ .attr("width", ribbonW)
229
+ .attr("height", ribbonH)
230
+ .attr("rx", 13)
231
+ .attr("fill", "#08345D");
232
+
233
+ ribbon.append("text")
234
+ .attr("x", ribbonX + 54)
235
+ .attr("y", ribbonY + ribbonH / 2)
236
+ .attr("dy", "0.35em")
237
+ .attr("text-anchor", "middle")
238
+ .style("font-family", "Arial, Helvetica, sans-serif")
239
+ .style("font-size", "16px")
240
+ .style("font-weight", "800")
241
+ .style("letter-spacing", "0")
242
+ .style("fill", "#FFFFFF")
243
+ .text("YEAR");
244
+
245
+ timePoints.forEach(timePoint => {
246
+ const x = xScale(timePoint);
247
+ ribbon.append("line")
248
+ .attr("x1", x - xStep / 2)
249
+ .attr("x2", x - xStep / 2)
250
+ .attr("y1", ribbonY + 9)
251
+ .attr("y2", ribbonY + ribbonH - 9)
252
+ .attr("stroke", "#2C5578")
253
+ .attr("stroke-width", 1)
254
+ .attr("opacity", 0.55);
255
+ ribbon.append("text")
256
+ .attr("x", x)
257
+ .attr("y", ribbonY + ribbonH / 2)
258
+ .attr("dy", "0.35em")
259
+ .attr("text-anchor", "middle")
260
+ .style("font-family", "Arial, Helvetica, sans-serif")
261
+ .style("font-size", "15px")
262
+ .style("font-weight", "800")
263
+ .style("letter-spacing", "0")
264
+ .style("fill", "#FFFFFF")
265
+ .text(displayTime(timePoint));
266
+ });
267
+
268
+ const plotLayer = svg.append("g")
269
+ .attr("class", "bump03-plot-layer")
270
+ .attr("data-chart-role", "chronological-bump-lines");
271
+
272
+ timePoints.forEach(timePoint => {
273
+ const x = xScale(timePoint);
274
+ plotLayer.append("line")
275
+ .attr("class", "bump03-year-guide")
276
+ .attr("data-time", displayTime(timePoint))
277
+ .attr("x1", x)
278
+ .attr("x2", x)
279
+ .attr("y1", plotTop)
280
+ .attr("y2", plotBottom)
281
+ .attr("stroke", "#BFD3DF")
282
+ .attr("stroke-width", 1)
283
+ .attr("stroke-dasharray", "3 5")
284
+ .attr("opacity", 0.82);
285
+ });
286
+
287
+ groups.forEach((group, groupIndex) => {
288
+ const rowTop = plotTop + groupIndex * rowH;
289
+ const rowCenter = rowTop + rowH / 2;
290
+ const rowBottom = rowTop + rowH;
291
+ const color = seriesColor(group, groupIndex);
292
+ const slotId = safeId(group);
293
+ const groupData = chartData.filter(d => d[groupField] === group)
294
+ .sort((a, b) => timePoints.indexOf(a[timeField]) - timePoints.indexOf(b[timeField]));
295
+ const values = groupData.map(d => +d[valueField]).filter(Number.isFinite);
296
+ const minValue = d3.min(values);
297
+ const maxValue = d3.max(values);
298
+ const amplitude = Math.max(12, Math.min(rowH * 0.34, 44));
299
+ const valueScale = d3.scaleLinear()
300
+ .domain(minValue === maxValue ? [minValue - 1, maxValue + 1] : [minValue, maxValue])
301
+ .range([rowCenter + amplitude / 2, rowCenter - amplitude / 2]);
302
+
303
+ if (groupIndex > 0) {
304
+ plotLayer.append("line")
305
+ .attr("class", "bump03-row-divider")
306
+ .attr("x1", ribbonX)
307
+ .attr("x2", plotRight)
308
+ .attr("y1", rowTop)
309
+ .attr("y2", rowTop)
310
+ .attr("stroke", "#B8D0DD")
311
+ .attr("stroke-width", 1);
312
+ }
313
+
314
+ const badge = plotLayer.append("g")
315
+ .attr("class", "reserved-asset-slot bump03-row-badge-slot")
316
+ .attr("data-asset-slot", "row-badge")
317
+ .attr("data-reserved-slot", `row-badge-${slotId}`)
318
+ .attr("data-asset-source-policy", "image2_asset")
319
+ .attr("data-anchor", "group-row-centerline")
320
+ .attr("data-group", group)
321
+ .attr("data-bbox", `${(badgeX - badgeR).toFixed(1)},${(rowCenter - badgeR).toFixed(1)},${(badgeR * 2).toFixed(1)},${(badgeR * 2).toFixed(1)}`)
322
+ .attr("data-collision-rule", "avoid group label, year ribbon, line marks, values, and row dividers")
323
+ .attr("data-no-embedded-raster", "true")
324
+ .attr("data-no-copied-reference-art", "true");
325
+
326
+ badge.append("circle")
327
+ .attr("cx", badgeX)
328
+ .attr("cy", rowCenter)
329
+ .attr("r", badgeR)
330
+ .attr("fill", chartUtils.color.variant(color, { mode: "brighter", amount: 1.6 }))
331
+ .attr("fill-opacity", 0.28)
332
+ .attr("stroke", color)
333
+ .attr("stroke-width", 1.6);
334
+ badge.append("circle")
335
+ .attr("cx", badgeX)
336
+ .attr("cy", rowCenter)
337
+ .attr("r", Math.max(4, badgeR * 0.16))
338
+ .attr("fill", color)
339
+ .attr("opacity", 0.52);
340
+ badge.append("text")
341
+ .attr("x", badgeX)
342
+ .attr("y", rowCenter + Math.max(3, badgeR * 0.12))
343
+ .attr("text-anchor", "middle")
344
+ .style("font-family", "Arial, Helvetica, sans-serif")
345
+ .style("font-size", Math.max(9, badgeR * 0.42) + "px")
346
+ .style("font-weight", "800")
347
+ .style("fill", color)
348
+ .text(String(group).slice(0, 2).toUpperCase());
349
+
350
+ const labelY = rowH >= 82 ? rowCenter + badgeR + labelFont * 0.95 : rowCenter + labelFont * 0.36;
351
+ plotLayer.append("text")
352
+ .attr("class", "bump03-group-label")
353
+ .attr("data-group", group)
354
+ .attr("x", rowH >= 82 ? badgeX : badgeX + badgeR + 10)
355
+ .attr("y", labelY)
356
+ .attr("text-anchor", rowH >= 82 ? "middle" : "start")
357
+ .style("font-family", "Arial, Helvetica, sans-serif")
358
+ .style("font-size", labelFont + "px")
359
+ .style("font-weight", "800")
360
+ .style("letter-spacing", "0")
361
+ .style("fill", "#082344")
362
+ .text(group);
363
+
364
+ const lineGenerator = d3.line()
365
+ .defined(d => Number.isFinite(+d[valueField]) && xScale(d[timeField]) != null)
366
+ .x(d => xScale(d[timeField]))
367
+ .y(d => valueScale(+d[valueField]));
368
+
369
+ if (groupData.length >= 2) {
370
+ plotLayer.append("path")
371
+ .datum(groupData)
372
+ .attr("class", "bump03-series-line")
373
+ .attr("data-series", group)
374
+ .attr("fill", "none")
375
+ .attr("stroke", color)
376
+ .attr("stroke-width", 4.2)
377
+ .attr("stroke-linecap", "round")
378
+ .attr("stroke-linejoin", "round")
379
+ .attr("opacity", 0.98)
380
+ .attr("d", lineGenerator);
381
+ }
382
+
383
+ groupData.forEach(d => {
384
+ const value = +d[valueField];
385
+ if (!Number.isFinite(value) || xScale(d[timeField]) == null) return;
386
+ const cx = xScale(d[timeField]);
387
+ const cy = valueScale(value);
388
+ const isLatest = d[timeField] === latestTime;
389
+ const r = isLatest ? endNodeR : nodeR;
390
+ const labelYClamped = Math.max(rowTop + valueFont + 4, cy - r - 8);
391
+
392
+ plotLayer.append("circle")
393
+ .attr("class", "bump03-node")
394
+ .attr("data-series", group)
395
+ .attr("data-time", displayTime(d[timeField]))
396
+ .attr("data-value", value)
397
+ .attr("cx", cx)
398
+ .attr("cy", cy)
399
+ .attr("r", r)
400
+ .attr("fill", "#F8FDFF")
401
+ .attr("stroke", color)
402
+ .attr("stroke-width", isLatest ? 4.2 : 3)
403
+ .attr("opacity", 1);
404
+
405
+ plotLayer.append("text")
406
+ .attr("class", "bump03-node-value")
407
+ .attr("data-series", group)
408
+ .attr("data-time", displayTime(d[timeField]))
409
+ .attr("x", cx)
410
+ .attr("y", labelYClamped)
411
+ .attr("text-anchor", "middle")
412
+ .style("font-family", "Arial, Helvetica, sans-serif")
413
+ .style("font-size", (isLatest ? valueFont + 2 : valueFont) + "px")
414
+ .style("font-weight", "800")
415
+ .style("letter-spacing", "0")
416
+ .style("fill", color)
417
+ .text(formatNodeValue(value));
418
+ });
419
+
420
+ if (groupIndex === groups.length - 1) {
421
+ plotLayer.append("line")
422
+ .attr("class", "bump03-row-divider")
423
+ .attr("x1", ribbonX)
424
+ .attr("x2", plotRight)
425
+ .attr("y1", rowBottom)
426
+ .attr("y2", rowBottom)
427
+ .attr("stroke", "#B8D0DD")
428
+ .attr("stroke-width", 1);
429
+ }
430
+ });
431
+
432
+ // 返回SVG节点
433
+ return svg.node();
434
+ }