Ray1ee01 commited on
Commit
02cf3fc
·
verified ·
1 Parent(s): 3aeaea2

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/type49_donut_chart/donut_chart_icons_01.js ADDED
@@ -0,0 +1,607 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Donut Chart",
5
+ "chart_name": "donut_chart_icons_01",
6
+ "is_composite": false,
7
+ "required_fields": ["x", "y"],
8
+ "required_fields_type": [["categorical"], ["numerical"]],
9
+ "required_fields_range": [[2, 20], [0, "inf"]],
10
+ "required_fields_icons": ["x"],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": ["x"],
13
+ "required_other_colors": [],
14
+ "supported_effects": ["shadow", "radius_corner", "stroke"],
15
+ "min_height": 400,
16
+ "min_width": 400,
17
+ "background": "no",
18
+ "icon_mark": "none",
19
+ "icon_label": "none",
20
+ "has_x_axis": "no",
21
+ "has_y_axis": "no"
22
+ }
23
+ REQUIREMENTS_END
24
+ */
25
+
26
+ // 半圆饼图实现 - 使用半圆饼图表示数据值
27
+ function makeChart(containerSelector, data) {
28
+ const colorResolver = chartUtils.color.resolver(data);
29
+ // ---------- 1. 数据准备 ----------
30
+ // 提取数据和配置
31
+ const jsonData = data;
32
+ const chartData = jsonData.data.data || [];
33
+ const variables = jsonData.variables || {};
34
+ const dataColumns = chartUtils.schema.columns(jsonData);
35
+
36
+ const typography = jsonData.typography || {
37
+ title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
38
+ label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
39
+ description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
40
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
41
+ };
42
+ let colors = jsonData.colors || {
43
+ text_color: "#333333",
44
+ field: {},
45
+ other: {
46
+ primary: "#4682B4" // 默认主色调
47
+ }
48
+ };
49
+
50
+ // 提取字段名称
51
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
52
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
53
+ // Set dimensions and margins
54
+ const width = variables.width;
55
+ const height = variables.height;
56
+ const margin = { top: 40, right: 40, bottom: 40, left: 40 };
57
+ const chartWidth = width - margin.left - margin.right;
58
+ const chartHeight = height - margin.top - margin.bottom;
59
+
60
+ // Create 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
+
69
+ // Calculate center point and max radius
70
+ const centerX = width / 2;
71
+ const centerY = height / 2;
72
+ const maxRadius = Math.min(chartWidth, chartHeight) / 2;
73
+
74
+ // Create a root group
75
+ const g = svg.append("g")
76
+ .attr("transform", `translate(${centerX}, ${centerY})`)
77
+ .attr("xmlns", "http://www.w3.org/2000/svg")
78
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
79
+
80
+ // 创建饼图生成器
81
+ const pie = d3.pie()
82
+ .value(d => +d[yField])
83
+ .sort(null);
84
+
85
+ // 创建弧形生成器
86
+ const arc = d3.arc()
87
+ .innerRadius(maxRadius*0.5)
88
+ .outerRadius(maxRadius)
89
+ .padAngle(0)
90
+ .cornerRadius(5);
91
+
92
+ // 计算每个组的百分比
93
+ const total = d3.sum(chartData, d => +d[yField]);
94
+ const dataWithPercentages = chartData.map(d => ({
95
+ ...d,
96
+ percentage: (+d[yField] / total) * 100
97
+ }));
98
+ const sectors = pie(dataWithPercentages);
99
+
100
+ // 存储图像位置信息,用于后续避免标签重叠
101
+ const imagePositions = [];
102
+
103
+ // 计算避免标签与图像重叠的位置
104
+ function calculateLabelPosition(d, iconCentroid, iconWidth, innerRadius, outerRadius, textWidth, textHeight) {
105
+ // 计算扇区中心角度
106
+ const angle = (d.startAngle + d.endAngle) / 2;
107
+
108
+ // 计算基础文本位置(在内圆和外圆之间)
109
+ const labelRadius = (innerRadius + outerRadius) / 2;
110
+ const x = Math.sin(angle) * labelRadius;
111
+ const y = -Math.cos(angle) * labelRadius;
112
+
113
+ // 计算文本边界框
114
+ const textBBox = {
115
+ x: x - textWidth / 2,
116
+ y: y - textHeight / 2,
117
+ width: textWidth,
118
+ height: textHeight
119
+ };
120
+
121
+ // 计算图像边界框
122
+ const iconBBox = {
123
+ x: iconCentroid[0] - iconWidth / 2,
124
+ y: iconCentroid[1] - iconWidth / 2,
125
+ width: iconWidth,
126
+ height: iconWidth
127
+ };
128
+
129
+ // 检查边界框是否重叠
130
+ // 计算重叠程度
131
+ let overlapX = 0;
132
+ let overlapY = 0;
133
+
134
+ // 计算X轴重叠
135
+ if (textBBox.x < iconBBox.x + iconBBox.width && iconBBox.x < textBBox.x + textBBox.width) {
136
+ // 找出重叠部分的左边界和右边界
137
+ const leftOverlap = Math.max(textBBox.x, iconBBox.x);
138
+ const rightOverlap = Math.min(textBBox.x + textBBox.width, iconBBox.x + iconBBox.width);
139
+ overlapX = rightOverlap - leftOverlap;
140
+ }
141
+
142
+ // 计算Y轴重叠
143
+ if (textBBox.y < iconBBox.y + iconBBox.height && iconBBox.y < textBBox.y + textBBox.height) {
144
+ // 找出重叠部分的上边界和下边界
145
+ const topOverlap = Math.max(textBBox.y, iconBBox.y);
146
+ const bottomOverlap = Math.min(textBBox.y + textBBox.height, iconBBox.y + iconBBox.height);
147
+ overlapY = bottomOverlap - topOverlap;
148
+ }
149
+
150
+ // 只有当X轴和Y轴都有重叠时,才算是真正的重叠
151
+ const overlap = overlapX > 0 && overlapY > 0;
152
+
153
+ if (overlap) {
154
+ // 计算重叠面积占文本面积的比例
155
+ const overlapArea = overlapX * overlapY;
156
+ const textArea = textWidth * textHeight;
157
+ const overlapRatio = overlapArea / textArea;
158
+
159
+ // 确定最小安全距离,基于重叠比例和图像尺寸
160
+ const minSafeDistance = iconWidth/2 + 5; // 最小基础安全距离
161
+ // 根据重叠比例增加安全距离,重叠越大,距离越远
162
+ const additionalDistance = Math.max(30 * overlapRatio, 10);
163
+ const safetyDistance = minSafeDistance + additionalDistance;
164
+
165
+ // 计算当前标签中心到图像中心的距离
166
+ const currentDistance = Math.sqrt(
167
+ Math.pow(x - iconCentroid[0], 2) +
168
+ Math.pow(y - iconCentroid[1], 2)
169
+ );
170
+
171
+ // 如果当前位置与图标重叠,尝试沿圆周寻找合适的位置
172
+ if (currentDistance < safetyDistance) {
173
+ // 在当前角度的基础上,尝试在圆周方向上偏移
174
+ // 尝试不同的偏移角度,寻找最佳位置
175
+ const maxOffset = Math.PI / 4; // 最大偏移±45度
176
+ const steps = 16; // 增加尝试步数
177
+
178
+ let bestPosition = null;
179
+ let bestDistance = -1;
180
+ let bestOverlapRatio = Infinity; // 记录最小重叠比例
181
+
182
+ // 在扇区角度范围内,以步进方式尝试不同位置
183
+ const sectorWidth = d.endAngle - d.startAngle;
184
+ // 使用扇区宽度或最大偏移值中较小的一个作为搜索范围
185
+ const searchRange = Math.max(Math.min(maxOffset, sectorWidth * 0.8), Math.PI / 12);
186
+ const angularStep = searchRange / steps;
187
+
188
+ // 同时尝试不同的半径
189
+ const radiusVariations = [
190
+ labelRadius * 0.8, // 向内收缩
191
+ labelRadius, // 原始半径
192
+ labelRadius * 1.1, // 稍微向外扩展
193
+ labelRadius * 1.2 // 更多向外扩展
194
+ ];
195
+
196
+ // 扩大搜索范围
197
+ for (let i = -steps; i <= steps; i++) {
198
+ // 计算偏移角度
199
+ const offsetAngle = angle + i * angularStep;
200
+
201
+ // 对每种半径变化尝试
202
+ for (const testRadius of radiusVariations) {
203
+ // 计算新位置
204
+ const newX = Math.sin(offsetAngle) * testRadius;
205
+ const newY = -Math.cos(offsetAngle) * testRadius;
206
+
207
+ // 计算新文本边界框
208
+ const newTextBBox = {
209
+ x: newX - textWidth / 2,
210
+ y: newY - textHeight / 2,
211
+ width: textWidth,
212
+ height: textHeight
213
+ };
214
+
215
+ // 计算与图标的距离
216
+ const dist = Math.sqrt(
217
+ Math.pow(newX - iconCentroid[0], 2) +
218
+ Math.pow(newY - iconCentroid[1], 2)
219
+ );
220
+
221
+ // 检查新位置是否与图标重叠
222
+ let newOverlapX = 0;
223
+ let newOverlapY = 0;
224
+
225
+ if (newTextBBox.x < iconBBox.x + iconBBox.width && iconBBox.x < newTextBBox.x + newTextBBox.width) {
226
+ const leftOverlap = Math.max(newTextBBox.x, iconBBox.x);
227
+ const rightOverlap = Math.min(newTextBBox.x + newTextBBox.width, iconBBox.x + iconBBox.width);
228
+ newOverlapX = rightOverlap - leftOverlap;
229
+ }
230
+
231
+ if (newTextBBox.y < iconBBox.y + iconBBox.height && iconBBox.y < newTextBBox.y + newTextBBox.height) {
232
+ const topOverlap = Math.max(newTextBBox.y, iconBBox.y);
233
+ const bottomOverlap = Math.min(newTextBBox.y + newTextBBox.height, iconBBox.y + iconBBox.height);
234
+ newOverlapY = bottomOverlap - topOverlap;
235
+ }
236
+
237
+ const newOverlap = newOverlapX > 0 && newOverlapY > 0;
238
+
239
+ if (!newOverlap) {
240
+ // 如果没有重叠,计算这个位置相对于理想位置的距离偏移
241
+ // 理想位置是沿着原角度的中间半径
242
+ const idealX = Math.sin(angle) * labelRadius;
243
+ const idealY = -Math.cos(angle) * labelRadius;
244
+ const deviationFromIdeal = Math.sqrt(
245
+ Math.pow(newX - idealX, 2) +
246
+ Math.pow(newY - idealY, 2)
247
+ );
248
+
249
+ // 如果是首次找到无重叠位置,或者这个位置比之前找到的更接近理想位置
250
+ if (bestPosition === null || deviationFromIdeal < bestDistance) {
251
+ bestPosition = [newX, newY];
252
+ bestDistance = deviationFromIdeal;
253
+ bestOverlapRatio = 0;
254
+ }
255
+ } else if (bestOverlapRatio > 0) {
256
+ // 如果有重叠但比之前找到的重叠度更小
257
+ const overlapArea = newOverlapX * newOverlapY;
258
+ const textArea = textWidth * textHeight;
259
+ const overlapRatio = overlapArea / textArea;
260
+
261
+ if (overlapRatio < bestOverlapRatio) {
262
+ // 如果重叠程度更小,也许是个好选择
263
+ bestPosition = [newX, newY];
264
+ bestDistance = dist;
265
+ bestOverlapRatio = overlapRatio;
266
+ }
267
+ }
268
+ }
269
+ }
270
+
271
+ // 如果找到了更好的位置,返回它
272
+ if (bestPosition !== null) {
273
+ return bestPosition;
274
+ }
275
+
276
+ // 如果没有找到合适的圆周位置,则尝试调整半径
277
+ // 根据角度所在象限决定向内还是向外移动
278
+ const extraDistance = safetyDistance - currentDistance;
279
+ let adjustedRadius;
280
+
281
+ if (angle >= 0 && angle < Math.PI) {
282
+ // 右半边,向外移动
283
+ adjustedRadius = labelRadius + extraDistance + 10; // 额外10像素作为缓冲
284
+ } else {
285
+ // 左半边,向内移动(如果内径足够大)或向外移动(如果内径太小)
286
+ if (innerRadius > textWidth) {
287
+ adjustedRadius = Math.max(innerRadius * 0.6, labelRadius - extraDistance - 10);
288
+ } else {
289
+ adjustedRadius = labelRadius + extraDistance + 10;
290
+ }
291
+ }
292
+
293
+ return [
294
+ Math.sin(angle) * adjustedRadius,
295
+ -Math.cos(angle) * adjustedRadius
296
+ ];
297
+ }
298
+ }
299
+
300
+ return [x, y];
301
+ }
302
+
303
+ // 估算文本宽度和高度的函数
304
+ function estimateTextDimensions(text, fontSize) {
305
+ return chartUtils.text.estimate(text, { fontSize });
306
+ }
307
+
308
+ // 计算扇区最大可用宽度
309
+ function calculateSectorMaxWidth(d, radius) {
310
+ // 计算扇区角度
311
+ const sectorAngle = d.endAngle - d.startAngle;
312
+ // 在弧的中间位置计算最大宽度
313
+ // 弧长 = 角度 * 半径,取80%作为安全尺寸
314
+ return sectorAngle * radius * 0.8;
315
+ }
316
+
317
+ // 截断或调整文本以适应最大宽度
318
+ function fitTextToWidth(text, fontSize, maxWidth) {
319
+ const dimensions = estimateTextDimensions(text, fontSize);
320
+ if (dimensions.width <= maxWidth) {
321
+ return { text, fontSize };
322
+ }
323
+
324
+ // 如果文本太长,先尝试缩小字体
325
+ const minFontSize = 10; // 最小可接受的字体大小
326
+ if (fontSize > minFontSize) {
327
+ const newFontSize = Math.max(minFontSize, fontSize * (maxWidth / dimensions.width));
328
+ const newDimensions = estimateTextDimensions(text, newFontSize);
329
+ if (newDimensions.width <= maxWidth) {
330
+ return { text, fontSize: newFontSize };
331
+ }
332
+ }
333
+
334
+ // 如果缩小字体后仍然超出,则截断文本
335
+ const charWidth = fontSize * 0.6;
336
+ const maxChars = Math.floor(maxWidth / charWidth) - 2; // 减2留出省略号空间
337
+ if (maxChars < 3) { // 如果连3个字符都放不下
338
+ return { text: "...", fontSize };
339
+ }
340
+
341
+ return { text: text.substring(0, maxChars) + "...", fontSize };
342
+ }
343
+
344
+ for (let i = 0; i < sectors.length; i++) {
345
+ const d = sectors[i];
346
+ // 绘制甜甜圈图的各个部分
347
+ const path = g.append("path")
348
+ .attr("fill", colorResolver.field(d.data[xField], 0, { fallbackKey: "primary" }).value)
349
+ .attr("stroke", "none")
350
+ .attr("stroke-width", 2)
351
+ .attr("d", arc(d));
352
+
353
+
354
+ const outerLength = (d.endAngle - d.startAngle) * maxRadius;
355
+
356
+
357
+ let iconWidth = Math.min(outerLength / 3, 150);
358
+ let iconHeight = iconWidth;
359
+ if (iconWidth > 20) {
360
+ const iconArc = d3.arc()
361
+ .innerRadius(maxRadius)
362
+ .outerRadius(maxRadius);
363
+
364
+ // 创建剪切路径
365
+ const clipId = `clip-${i}`;
366
+ const defs = g.append("defs");
367
+ const clipPath = defs.append("clipPath")
368
+ .attr("id", clipId);
369
+
370
+ // 确保剪切路径有正确的位置和尺寸
371
+ const [cx, cy] = iconArc.centroid(d);
372
+ clipPath.append("circle")
373
+ .attr("cx", cx)
374
+ .attr("cy", cy)
375
+ .attr("r", iconWidth / 2);
376
+
377
+ // 添加白色背景圆
378
+ const circle = g.append("circle")
379
+ .attr("cx", cx)
380
+ .attr("cy", cy)
381
+ .attr("r", iconWidth / 2 + 3)
382
+ .attr("fill", "white")
383
+ .attr("stroke", colorResolver.field(d.data[xField], 0, { fallbackKey: "primary" }).value)
384
+ .attr("stroke-width", 2);
385
+
386
+ // 使用剪切路径裁剪图片
387
+ const icon = g.append("image")
388
+ .attr("xlink:href", jsonData.images.field[d.data[xField]])
389
+ .attr("clip-path", `url(#${clipId})`)
390
+ .attr("x", cx - iconWidth / 2)
391
+ .attr("y", cy - iconHeight / 2)
392
+ .attr("width", iconWidth)
393
+ .attr("height", iconHeight);
394
+
395
+ // 存储图像位置信息
396
+ imagePositions.push({
397
+ centroid: [cx, cy],
398
+ width: iconWidth,
399
+ height: iconHeight
400
+ });
401
+
402
+ let displayTextCategory = d.data[xField];
403
+ let displayTextNumerical = d.data.percentage >= 2 ? `${chartUtils.format.percent(d.data.percentage).text} (${chartUtils.format.autoText(+d.data[yField])})` : '';
404
+ let categoryFontSize = 20;
405
+ let numericalFontSize = 20;
406
+
407
+ // 计算扇区的最大可用宽度
408
+ const maxAvailableWidth = calculateSectorMaxWidth(d, (maxRadius + maxRadius*0.5) / 2);
409
+
410
+ // 调整类别文本和字体大小以适应扇区
411
+ const fittedCategory = fitTextToWidth(displayTextCategory, categoryFontSize, maxAvailableWidth);
412
+ displayTextCategory = fittedCategory.text;
413
+ categoryFontSize = fittedCategory.fontSize;
414
+
415
+ // 调整数值文本和字体大小
416
+ const fittedNumerical = fitTextToWidth(displayTextNumerical, numericalFontSize, maxAvailableWidth);
417
+ displayTextNumerical = fittedNumerical.text;
418
+ numericalFontSize = fittedNumerical.fontSize;
419
+
420
+ // 估算文本尺寸
421
+ const categoryDimensions = estimateTextDimensions(displayTextCategory, categoryFontSize);
422
+ const numericalDimensions = estimateTextDimensions(displayTextNumerical, numericalFontSize);
423
+ // 计算标签总高度(包括两行文本和间距)
424
+ const labelHeight = categoryDimensions.height + numericalDimensions.height + 5;
425
+ // 取两行文本中较宽的一个作为标签宽度
426
+ const labelWidth = Math.max(categoryDimensions.width, numericalDimensions.width);
427
+
428
+ // 对于大图标,调整标签区域以避免重叠
429
+ const labelInnerRadius = maxRadius * 0.4; // 稍微向内收缩内半径
430
+ const labelOuterRadius = maxRadius * 1.1; // 稍微向外扩展外半径
431
+
432
+ // 计算避免与图像重叠的标签位置
433
+ const labelPosition = calculateLabelPosition(
434
+ d,
435
+ [cx, cy],
436
+ iconWidth,
437
+ labelInnerRadius,
438
+ labelOuterRadius,
439
+ labelWidth,
440
+ labelHeight
441
+ );
442
+
443
+ const textCategory = g.append("text")
444
+ .attr("transform", `translate(${labelPosition})`)
445
+ .attr("text-anchor", "middle")
446
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
447
+ .style("font-family", typography.label.font_family)
448
+ .style("font-size", categoryFontSize)
449
+ .text(displayTextCategory);
450
+
451
+ const textNumerical = g.append("text")
452
+ .attr("transform", `translate(0,20) translate(${labelPosition})`)
453
+ .attr("text-anchor", "middle")
454
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
455
+ .style("font-family", typography.label.font_family)
456
+ .style("font-size", numericalFontSize)
457
+ .text(displayTextNumerical);
458
+ } else {
459
+ iconWidth = 20;
460
+ iconHeight = 20;
461
+ const iconArc = d3.arc()
462
+ .innerRadius(maxRadius+30)
463
+ .outerRadius(maxRadius+30);
464
+
465
+ // 创建剪切路径
466
+ const clipId = `clip-${i}`;
467
+ const defs = g.append("defs");
468
+ const clipPath = defs.append("clipPath")
469
+ .attr("id", clipId);
470
+
471
+ // 确保剪切路径有正确的位置和尺寸
472
+ const [cx, cy] = iconArc.centroid(d);
473
+ clipPath.append("circle")
474
+ .attr("cx", cx)
475
+ .attr("cy", cy)
476
+ .attr("r", iconWidth / 2);
477
+
478
+ // 添加白色背景圆
479
+ const circle = g.append("circle")
480
+ .attr("cx", cx)
481
+ .attr("cy", cy)
482
+ .attr("r", iconWidth / 2 + 3)
483
+ .attr("fill", "white")
484
+ .attr("stroke", colorResolver.field(d.data[xField], 0, { fallbackKey: "primary" }).value)
485
+ .attr("stroke-width", 2);
486
+
487
+ // 使用剪切路径裁剪图片
488
+ const icon = g.append("image")
489
+ .attr("xlink:href", jsonData.images.field[d.data[xField]])
490
+ .attr("clip-path", `url(#${clipId})`)
491
+ .attr("x", cx - iconWidth / 2)
492
+ .attr("y", cy - iconHeight / 2)
493
+ .attr("width", iconWidth)
494
+ .attr("height", iconHeight);
495
+
496
+ // 存储图像位置信息
497
+ imagePositions.push({
498
+ centroid: [cx, cy],
499
+ width: iconWidth,
500
+ height: iconHeight
501
+ });
502
+
503
+ let displayTextCategory = d.data[xField];
504
+ let displayTextNumerical = `${chartUtils.format.percent(d.data.percentage).text} (${chartUtils.format.autoText(+d.data[yField])})`;
505
+ let categoryFontSize = 20;
506
+ let numericalFontSize = 20;
507
+
508
+ // 计算扇区的最大可用宽度
509
+ const maxAvailableWidth = calculateSectorMaxWidth(d, maxRadius + 30);
510
+
511
+ // 调整类别文本和字体大小以适应扇区
512
+ const fittedCategory = fitTextToWidth(displayTextCategory, categoryFontSize, maxAvailableWidth);
513
+ displayTextCategory = fittedCategory.text;
514
+ categoryFontSize = fittedCategory.fontSize;
515
+
516
+ // 调整数值文本和字体大小
517
+ const fittedNumerical = fitTextToWidth(displayTextNumerical, numericalFontSize, maxAvailableWidth);
518
+ displayTextNumerical = fittedNumerical.text;
519
+ numericalFontSize = fittedNumerical.fontSize;
520
+
521
+ // 估算文本尺寸
522
+ const categoryDimensions = estimateTextDimensions(displayTextCategory, categoryFontSize);
523
+ const numericalDimensions = estimateTextDimensions(displayTextNumerical, numericalFontSize);
524
+ // 计算标签总高度(包括两行文本和间距)
525
+ const labelHeight = categoryDimensions.height + numericalDimensions.height + 5;
526
+ // 取两行文本中较宽的一个作为标签宽度
527
+ const labelWidth = Math.max(categoryDimensions.width, numericalDimensions.width);
528
+
529
+ // 对于小图标情况,使用更大的内外半径差,给标签更多空间
530
+ const labelInnerRadius = maxRadius + 10; // 向外偏移一点
531
+ const labelOuterRadius = maxRadius + 70; // 更大的外半径给更多空间
532
+
533
+ // 计算不重叠的标签位置
534
+ const labelPosition = calculateLabelPosition(
535
+ d,
536
+ [cx, cy],
537
+ iconWidth,
538
+ labelInnerRadius,
539
+ labelOuterRadius,
540
+ labelWidth,
541
+ labelHeight
542
+ );
543
+
544
+ const fillColor = colorResolver.field(d.data[xField], 0, { fallbackKey: "primary" }).value;
545
+
546
+ const textCategory = g.append("text")
547
+ .attr("transform", `translate(${labelPosition})`)
548
+ .attr("text-anchor", "middle")
549
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
550
+ .style("font-family", typography.label.font_family)
551
+ .style("font-size", categoryFontSize)
552
+ .text(displayTextCategory);
553
+
554
+ const textNumerical = g.append("text")
555
+ .attr("transform", `translate(0,20) translate(${labelPosition})`)
556
+ .attr("text-anchor", "middle")
557
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
558
+ .style("font-family", typography.label.font_family)
559
+ .style("font-size", numericalFontSize)
560
+ .text(displayTextNumerical);
561
+ }
562
+ }
563
+
564
+ // 加入label
565
+ const label = g.append("text")
566
+ .attr("transform", `translate(${centerX}, ${centerY})`)
567
+ .attr("text-anchor", "middle")
568
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
569
+ .style("font-family", typography.label.font_family)
570
+ .style("font-size", typography.label.font_size)
571
+ .text(jsonData.title);
572
+
573
+ // 添加图例 - 放在图表上方
574
+ const legendGroup = svg.append("g")
575
+ .attr("transform", `translate(0, -85)`);
576
+
577
+ // 计算字段名宽度并添加间距
578
+ const titleWidth = xField.length * 10;
579
+ const titleMargin = 15;
580
+
581
+ let xs = [...new Set(chartData.map(d => d[xField]))];
582
+
583
+ const legendSize = chartUtils.legend.draw(legendGroup, xs, colors, {
584
+ x: titleWidth + titleMargin,
585
+ y: 0,
586
+ fontSize: 14,
587
+ fontWeight: "bold",
588
+ align: "left",
589
+ maxWidth: chartWidth - titleWidth - titleMargin,
590
+ shape: "rect",
591
+ });
592
+
593
+ // 添加字段名称
594
+ legendGroup.append("text")
595
+ .attr("x", 0)
596
+ .attr("y", legendSize.height / 2)
597
+ .attr("dominant-baseline", "middle")
598
+ .attr("fill", colorResolver.text({ fallback: "#333333" }).value)
599
+ .style("font-size", "16px")
600
+ .style("font-weight", "bold")
601
+ .text(xField);
602
+
603
+ // 将图例组向上移动 height/2, 并居中
604
+ legendGroup.attr("transform", `translate(${(chartWidth - legendSize.width - titleWidth - titleMargin) / 2}, ${-legendSize.height / 2 - 20})`);
605
+
606
+ return svg.node();
607
+ }
modules/chart_engine/template/d3-js/type49_donut_chart/donut_chart_icons_02.js ADDED
@@ -0,0 +1,411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Donut Chart",
5
+ "chart_name": "donut_chart_icons_02",
6
+ "is_composite": false,
7
+ "required_fields": ["x", "y"],
8
+ "required_fields_type": [["categorical"], ["numerical"]],
9
+ "required_fields_range": [[2, 20], [0, "inf"]],
10
+ "required_fields_icons": ["x"],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": ["x"],
13
+ "required_other_colors": [],
14
+ "supported_effects": ["shadow", "radius_corner", "stroke"],
15
+ "min_height": 400,
16
+ "min_width": 400,
17
+ "background": "no",
18
+ "icon_mark": "none",
19
+ "icon_label": "none",
20
+ "has_x_axis": "no",
21
+ "has_y_axis": "no"
22
+ }
23
+ REQUIREMENTS_END
24
+ */
25
+
26
+ // 半圆饼图实现 - 使用半圆饼图表示数据值
27
+ function makeChart(containerSelector, data) {
28
+ const colorResolver = chartUtils.color.resolver(data);
29
+ // ---------- 1. 数据准备 ----------
30
+ // 提取数据和配置
31
+ const jsonData = data;
32
+ const chartData = jsonData.data.data || [];
33
+ const variables = jsonData.variables || {};
34
+ const dataColumns = chartUtils.schema.columns(jsonData);
35
+
36
+ const typography = jsonData.typography || {
37
+ title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
38
+ label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
39
+ description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
40
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
41
+ };
42
+ let colors = jsonData.colors || {
43
+ text_color: "#333333",
44
+ field: {},
45
+ other: {
46
+ primary: "#4682B4" // 默认主色调
47
+ }
48
+ };
49
+
50
+ // 提取字段名称
51
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
52
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
53
+ // Set dimensions and margins
54
+ const width = variables.width;
55
+ const height = variables.height;
56
+ const margin = { top: 40, right: 40, bottom: 40, left: 40 };
57
+ const chartWidth = width - margin.left - margin.right;
58
+ const chartHeight = height - margin.top - margin.bottom;
59
+
60
+ // Create 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
+
69
+ // Calculate center point and max radius
70
+ const centerX = width / 2;
71
+ const centerY = height / 2 + 100;
72
+ const maxRadius = Math.min(chartWidth, chartHeight) / 2;
73
+
74
+ // Create a root group
75
+ const g = svg.append("g")
76
+ .attr("transform", `translate(${centerX}, ${centerY})`)
77
+ .attr("xmlns", "http://www.w3.org/2000/svg")
78
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
79
+
80
+ // 创建饼图生成器
81
+ const pie = d3.pie()
82
+ .value(d => +d[yField])
83
+ .sort(null)
84
+ .startAngle(0) // 设置起始角度为-90度
85
+ .endAngle(2*Math.PI) // 设置结束角度为90度
86
+
87
+ // 创建弧形生成器
88
+ const arc = d3.arc()
89
+ .innerRadius(maxRadius*0.5)
90
+ .outerRadius(maxRadius)
91
+ .padAngle(0)
92
+ .cornerRadius(5);
93
+
94
+ // 计算每个组的百分比
95
+ const total = d3.sum(chartData, d => +d[yField]);
96
+ const dataWithPercentages = chartData.map(d => ({
97
+ ...d,
98
+ percentage: (+d[yField] / total) * 100
99
+ }));
100
+ const sectors = pie(dataWithPercentages);
101
+
102
+ // 存储图像位置信息,用于后续避免标签重叠
103
+ const imagePositions = [];
104
+
105
+ // 计算避免标签与图像重叠的位置
106
+ function calculateLabelPosition(d, iconCentroid, iconWidth, innerRadius, outerRadius, textWidth, textHeight, isLargeIcon) {
107
+ // 计算扇区中心角度
108
+ const angle = (d.startAngle + d.endAngle) / 2;
109
+
110
+ // 计算图标半径,添加额外边距
111
+ const iconRadius = iconWidth / 2 + 5;
112
+
113
+ // 增加安全距离,确保标签和图标不重叠
114
+ const safetyDistance = isLargeIcon ?
115
+ iconRadius + Math.max(textWidth, textHeight) / 1.5 :
116
+ iconRadius * 2;
117
+
118
+ // 根据扇区角度调整标签位置
119
+ let labelRadius;
120
+
121
+ // 根据扇区的角度位置采用不同的放置策略
122
+ if (angle >= 0 && angle <= Math.PI / 4) {
123
+ // 右上角,标签放在外圈右侧
124
+ labelRadius = outerRadius + safetyDistance / 2.5;
125
+ } else if (angle > Math.PI / 4 && angle <= 3 * Math.PI / 4) {
126
+ // 上半部,标签放在较远的位置
127
+ labelRadius = outerRadius + safetyDistance / 2;
128
+ } else if (angle > 3 * Math.PI / 4 && angle <= 5 * Math.PI / 4) {
129
+ // 左半部,标签靠近内圈
130
+ labelRadius = Math.max(innerRadius - safetyDistance / 1.8, innerRadius * 0.4);
131
+ } else if (angle > 5 * Math.PI / 4 && angle <= 7 * Math.PI / 4) {
132
+ // 下半部,标签放在较远的位置
133
+ labelRadius = outerRadius + safetyDistance / 2;
134
+ } else {
135
+ // 右下角,标签放在外圈右侧
136
+ labelRadius = outerRadius + safetyDistance / 2.5;
137
+ }
138
+
139
+ // 基于角度和半径计算最终位置
140
+ const x = Math.sin(angle) * labelRadius;
141
+ const y = -Math.cos(angle) * labelRadius;
142
+
143
+ // 额外检查:检测是否与图标有重叠
144
+ const distance = Math.sqrt(
145
+ Math.pow(x - iconCentroid[0], 2) +
146
+ Math.pow(y - iconCentroid[1], 2)
147
+ );
148
+
149
+ // 如果距离太近,再增加一些距离
150
+ if (distance < safetyDistance) {
151
+ const extraFactor = 2.0; // 增加额外的安全系数
152
+ const extraDistance = (safetyDistance - distance) * extraFactor;
153
+
154
+ // 根据角度决定向哪个方向移动更多
155
+ if ((angle >= 0 && angle <= Math.PI / 4) || (angle > 7 * Math.PI / 4 && angle <= 2 * Math.PI)) {
156
+ // 右侧区域,向外移动
157
+ labelRadius += extraDistance;
158
+ } else if (angle > Math.PI / 4 && angle <= 3 * Math.PI / 4) {
159
+ // 上方区域,向上移动
160
+ labelRadius += extraDistance;
161
+ } else if (angle > 3 * Math.PI / 4 && angle <= 5 * Math.PI / 4) {
162
+ // 左侧区域,优先向内移动
163
+ if (innerRadius > textWidth * 1.2) {
164
+ labelRadius = Math.max(innerRadius * 0.35, labelRadius - extraDistance);
165
+ } else {
166
+ labelRadius += extraDistance;
167
+ }
168
+ } else {
169
+ // 下方区域,向下移动
170
+ labelRadius += extraDistance;
171
+ }
172
+
173
+ // 重新计算位置
174
+ return [
175
+ Math.sin(angle) * labelRadius,
176
+ -Math.cos(angle) * labelRadius
177
+ ];
178
+ }
179
+
180
+ return [x, y];
181
+ }
182
+
183
+ // 估算文本宽度和高度的函数
184
+ function estimateTextDimensions(text, fontSize) {
185
+ return chartUtils.text.estimate(text, { fontSize });
186
+ }
187
+
188
+ // 计算扇区最大可用宽度
189
+ function calculateSectorMaxWidth(d, radius) {
190
+ // 计算扇区角度
191
+ const sectorAngle = d.endAngle - d.startAngle;
192
+ // 在弧的中间位置计算最大宽度
193
+ // 弧长 = 角度 * 半径,取80%作为安全尺寸
194
+ return sectorAngle * radius * 0.8;
195
+ }
196
+
197
+ // 调整文本以适应最大宽度 - 只缩小字体,不截断文本
198
+ function fitTextToWidth(text, fontSize, maxWidth) {
199
+ const dimensions = estimateTextDimensions(text, fontSize);
200
+ if (dimensions.width <= maxWidth) {
201
+ return { text, fontSize };
202
+ }
203
+
204
+ // 如果文本太长,尝试缩小字体
205
+ const minFontSize = 8; // 最小可接受的字体大小
206
+ if (fontSize > minFontSize) {
207
+ const newFontSize = Math.max(minFontSize, fontSize * (maxWidth / dimensions.width));
208
+ return { text, fontSize: newFontSize };
209
+ }
210
+
211
+ return { text, fontSize: minFontSize };
212
+ }
213
+
214
+ // 计算标签位置 - 确保标签始终在外部,不会出现在饼图内部
215
+ function calculateOuterLabelPosition(d, outerRadius) {
216
+ // 计算扇区中心角度
217
+ const angle = (d.startAngle + d.endAngle) / 2;
218
+
219
+ // 确保标签始终在外圈,添加更大的边距确保不会出现在内部
220
+ // 增加倍数从1.2到1.5,确保标签彻底在外部
221
+ const labelRadius = outerRadius * 1.5;
222
+
223
+ // 基于角度和半径计算最终位置
224
+ return [
225
+ Math.sin(angle) * labelRadius,
226
+ -Math.cos(angle) * labelRadius
227
+ ];
228
+ }
229
+
230
+ // 检查扇区是否有足够空间放置图标
231
+ function hasEnoughSpaceForIcon(d, innerRadius) {
232
+ // 计算扇区的内圆弧长度
233
+ const sectorAngle = d.endAngle - d.startAngle;
234
+ const arcLength = sectorAngle * innerRadius;
235
+
236
+ // 严格判断:扇区必须足够大才显示图标
237
+ // 最小要求提高到30像素,确保图标有足够空间
238
+ return arcLength >= 30;
239
+ }
240
+
241
+ for (let i = 0; i < sectors.length; i++) {
242
+ const d = sectors[i];
243
+ // 绘制甜甜圈图的各个部分
244
+ const path = g.append("path")
245
+ .attr("fill", colorResolver.field(d.data[xField], 0, { fallbackKey: "primary" }).value)
246
+ .attr("stroke", "#FFFFFF")
247
+ .attr("stroke-width", 2)
248
+ .attr("d", arc(d));
249
+
250
+ // 检查扇区是否有足够空间放置图标
251
+ const innerRadius = maxRadius * 0.75;
252
+ const hasSpace = hasEnoughSpaceForIcon(d, innerRadius);
253
+
254
+ // 只有当扇区空间足够大时才显示图标
255
+ if (hasSpace) {
256
+ // 计算扇区的内圆弧长度
257
+ const sectorAngle = d.endAngle - d.startAngle;
258
+ const arcLength = sectorAngle * innerRadius;
259
+
260
+ // 根据弧长计算图标大小,保持最小和最大限制
261
+ const minIconSize = 16; // 最小图标尺寸
262
+ const maxIconSize = 50; // 最大图标尺寸
263
+ const baseIconSize = Math.min(maxIconSize, Math.max(minIconSize, arcLength * 0.5));
264
+
265
+ // 设置图标尺寸
266
+ const iconWidth = baseIconSize;
267
+ const iconHeight = baseIconSize;
268
+
269
+ // 计算图标位置 - 确保图标在内圆弧上居中
270
+ const angle = (d.startAngle + d.endAngle) / 2;
271
+ const iconRadius = innerRadius; // 确保图标位于内圆弧之上
272
+ const cx = Math.sin(angle) * iconRadius;
273
+ const cy = -Math.cos(angle) * iconRadius;
274
+
275
+ // 创建剪切路径
276
+ const clipId = `clip-${i}`;
277
+ const defs = g.append("defs");
278
+ const clipPath = defs.append("clipPath")
279
+ .attr("id", clipId);
280
+
281
+ clipPath.append("circle")
282
+ .attr("cx", cx)
283
+ .attr("cy", cy)
284
+ .attr("r", iconWidth / 2);
285
+
286
+ // 添加白色背景圆
287
+ g.append("circle")
288
+ .attr("cx", cx)
289
+ .attr("cy", cy)
290
+ .attr("r", iconWidth / 2 + 3)
291
+ .attr("fill", "white")
292
+ .attr("stroke", colorResolver.field(d.data[xField], 0, { fallbackKey: "primary" }).value)
293
+ .attr("stroke-width", 2);
294
+
295
+ // 使用剪切路径裁剪图片
296
+ g.append("image")
297
+ .attr("xlink:href", jsonData.images.field[d.data[xField]])
298
+ .attr("clip-path", `url(#${clipId})`)
299
+ .attr("x", cx - iconWidth / 2)
300
+ .attr("y", cy - iconHeight / 2)
301
+ .attr("width", iconWidth)
302
+ .attr("height", iconHeight);
303
+ }
304
+
305
+ // 标签处理 - 所有标签都放在外部
306
+ // 准备显示的文本
307
+ let displayTextCategory = d.data[xField];
308
+ let displayTextValue = `${chartUtils.format.percent(d.data.percentage).text} (${chartUtils.format.autoText(+d.data[yField])})`;
309
+
310
+ // 设置初始字体大小
311
+ let categoryFontSize = 14;
312
+ let valueFontSize = 12;
313
+
314
+ // 计算标签位置 - 确保标签在外部
315
+ const labelPosition = calculateOuterLabelPosition(d, maxRadius);
316
+
317
+ // 计算最大可用宽度 - 外部标签可以有更多空间
318
+ const outerSpaceWidth = width * 0.4; // 增加到宽度的40%
319
+
320
+ // 调整文本字体大小以确保文本完整显示
321
+ const fittedCategory = fitTextToWidth(displayTextCategory, categoryFontSize, outerSpaceWidth);
322
+ const fittedValue = fitTextToWidth(displayTextValue, valueFontSize, outerSpaceWidth);
323
+
324
+ // 设置文本颜色
325
+ const fillColor = colorResolver.field(d.data[xField], 0, { fallbackKey: "primary" }).value;
326
+ // 计算文本颜色 - 使用黑色作为默认颜色,确保可读性
327
+ const textColor = "#000000";
328
+
329
+ // 添加标签 - 类别名称
330
+ g.append("text")
331
+ .attr("transform", `translate(${labelPosition})`)
332
+ .attr("text-anchor", "middle")
333
+ .style("fill", textColor)
334
+ .style("font-family", typography.label.font_family)
335
+ .style("font-size", `${fittedCategory.fontSize}px`)
336
+ .style("font-weight", "bold")
337
+ .text(fittedCategory.text);
338
+
339
+ // 添加标签 - 百分比和原始值
340
+ g.append("text")
341
+ .attr("transform", `translate(${labelPosition[0]}, ${labelPosition[1] + fittedCategory.fontSize + 4})`)
342
+ .attr("text-anchor", "middle")
343
+ .style("fill", textColor)
344
+ .style("font-family", typography.label.font_family)
345
+ .style("font-size", `${fittedValue.fontSize}px`)
346
+ .text(fittedValue.text);
347
+
348
+ // 添加辅助线连接扇区和标签
349
+ const arcCentroid = arc.centroid(d);
350
+
351
+ // 连接线起点位于扇区中心位置
352
+ const lineStartX = arcCentroid[0];
353
+ const lineStartY = arcCentroid[1];
354
+
355
+ // 连接线终点位于标签起始位置的90%处,避免直接连到文字
356
+ const lineEndX = labelPosition[0] * 0.9;
357
+ const lineEndY = labelPosition[1] * 0.9;
358
+
359
+ g.append("line")
360
+ .attr("x1", lineStartX)
361
+ .attr("y1", lineStartY)
362
+ .attr("x2", lineEndX)
363
+ .attr("y2", lineEndY)
364
+ .attr("stroke", "#888888")
365
+ .attr("stroke-width", 1)
366
+ .attr("stroke-dasharray", "3,3");
367
+ }
368
+
369
+ // 加入label
370
+ const label = g.append("text")
371
+ .attr("transform", `translate(${centerX}, ${centerY})`)
372
+ .attr("text-anchor", "middle")
373
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
374
+ .style("font-family", typography.label.font_family)
375
+ .style("font-size", typography.label.font_size)
376
+ .text(jsonData.title);
377
+
378
+ // 添加图例 - 放在图表上方
379
+ const legendGroup = svg.append("g");
380
+
381
+ // 计算字段名宽度并添加间距
382
+ const titleWidth = xField.length * 10;
383
+ const titleMargin = 15;
384
+
385
+ let xs = [...new Set(chartData.map(d => d[xField]))];
386
+
387
+ const legendSize = chartUtils.legend.draw(legendGroup, xs, colors, {
388
+ x: titleWidth + titleMargin,
389
+ y: 0,
390
+ fontSize: 14,
391
+ fontWeight: "bold",
392
+ align: "left",
393
+ maxWidth: chartWidth - titleWidth - titleMargin,
394
+ shape: "rect",
395
+ });
396
+
397
+ // 添加字段名称
398
+ legendGroup.append("text")
399
+ .attr("x", 0)
400
+ .attr("y", legendSize.height / 2)
401
+ .attr("dominant-baseline", "middle")
402
+ .attr("fill", colorResolver.text({ fallback: "#333333" }).value)
403
+ .style("font-size", "16px")
404
+ .style("font-weight", "bold")
405
+ .text(xField);
406
+
407
+ // 将图例组向上移动 height/2, 并居中
408
+ legendGroup.attr("transform", `translate(${(chartWidth - legendSize.width - titleWidth - titleMargin) / 2}, ${-legendSize.height / 2 - 50})`);
409
+
410
+ return svg.node();
411
+ }
modules/chart_engine/template/d3-js/type49_donut_chart/donut_chart_icons_03.js ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Donut Chart",
5
+ "chart_name": "donut_chart_icons_03",
6
+ "is_composite": false,
7
+ "required_fields": ["x", "y"],
8
+ "required_fields_type": [["categorical"], ["numerical"]],
9
+ "required_fields_range": [[2, 20], [0, "inf"]],
10
+ "required_fields_icons": ["x"],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": ["x"],
13
+ "required_other_colors": [],
14
+ "supported_effects": ["shadow", "radius_corner", "stroke"],
15
+ "min_height": 400,
16
+ "min_width": 400,
17
+ "background": "no",
18
+ "icon_mark": "none",
19
+ "icon_label": "none",
20
+ "has_x_axis": "no",
21
+ "has_y_axis": "no"
22
+ }
23
+ REQUIREMENTS_END
24
+ */
25
+
26
+ // 半圆饼图实现 - 使用半圆饼图表示数据值
27
+ function makeChart(containerSelector, data) {
28
+ const colorResolver = chartUtils.color.resolver(data);
29
+ // ---------- 1. 数据准备 ----------
30
+ // 提取数据和配置
31
+ const jsonData = data;
32
+ const chartData = jsonData.data.data || [];
33
+ const variables = jsonData.variables || {};
34
+ const dataColumns = chartUtils.schema.columns(jsonData);
35
+
36
+ const typography = jsonData.typography || {
37
+ title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
38
+ label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
39
+ description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
40
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
41
+ };
42
+ let colors = jsonData.colors || {
43
+ text_color: "#333333",
44
+ field: {},
45
+ other: {
46
+ primary: "#4682B4" // 默认主色调
47
+ }
48
+ };
49
+
50
+ // 提取字段名称
51
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
52
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
53
+ // Set dimensions and margins
54
+ const width = variables.width;
55
+ const height = variables.height;
56
+ const margin = { top: 40, right: 40, bottom: 40, left: 40 };
57
+ const chartWidth = width - margin.left - margin.right;
58
+ const chartHeight = height - margin.top - margin.bottom;
59
+
60
+ // Create 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
+
69
+ // Calculate center point and max radius
70
+ const centerX = width / 2;
71
+ const centerY = height / 2;
72
+ const maxRadius = Math.min(chartWidth, chartHeight) / 2;
73
+
74
+ // Create a root group
75
+ const g = svg.append("g")
76
+ .attr("transform", `translate(${centerX}, ${centerY})`)
77
+ .attr("xmlns", "http://www.w3.org/2000/svg")
78
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
79
+
80
+ // 创建饼图生成器
81
+ const pie = d3.pie()
82
+ .value(d => +d[yField])
83
+ .sort(null);
84
+
85
+ // 创建弧形生成器
86
+ const arc = d3.arc()
87
+ .innerRadius(maxRadius*0.5)
88
+ .outerRadius(maxRadius)
89
+ .padAngle(0)
90
+ .cornerRadius(5);
91
+
92
+ // 计算每个组的百分比
93
+ const total = d3.sum(chartData, d => +d[yField]);
94
+ const dataWithPercentages = chartData.map(d => ({
95
+ ...d,
96
+ percentage: (+d[yField] / total) * 100
97
+ }));
98
+ const sectors = pie(dataWithPercentages);
99
+
100
+ // 计算标签位置的函数
101
+ function calculateLabelPosition(d, iconCentroid, iconWidth, innerRadius, outerRadius, textWidth, textHeight, isLargeIcon) {
102
+ // 计算扇区中心角度
103
+ const angle = (d.startAngle + d.endAngle) / 2;
104
+
105
+ // 计算图标半径,添加额外边距
106
+ const iconRadius = iconWidth / 2 + 5;
107
+
108
+ // 增加安全距离,确保标签和图标不重叠
109
+ const safetyDistance = 20;
110
+
111
+ // 所有标签都放在外圈,统一使用较大的半径
112
+ const labelRadius = outerRadius + safetyDistance;
113
+
114
+ // 基于角度和半径计算最终位置
115
+ const x = Math.sin(angle) * labelRadius;
116
+ const y = -Math.cos(angle) * labelRadius;
117
+
118
+ // 额外检查:检测是否与图标有重叠
119
+ const distance = Math.sqrt(
120
+ Math.pow(x - iconCentroid[0], 2) +
121
+ Math.pow(y - iconCentroid[1], 2)
122
+ );
123
+
124
+ // 如果距离太近,再增加一些距离
125
+ if (distance < safetyDistance) {
126
+ const extraFactor = 1.5; // 增加额外的安全系数
127
+ const extraDistance = (safetyDistance - distance) * extraFactor;
128
+ labelRadius += extraDistance;
129
+
130
+ // 重新计算位置
131
+ return [
132
+ Math.sin(angle) * labelRadius,
133
+ -Math.cos(angle) * labelRadius
134
+ ];
135
+ }
136
+
137
+ return [x, y];
138
+ }
139
+
140
+ // 估算文本宽度和高度的函数
141
+ function estimateTextDimensions(text, fontSize) {
142
+ return chartUtils.text.estimate(text, { fontSize });
143
+ }
144
+
145
+ // 计算扇区最大可用宽度
146
+ function calculateSectorMaxWidth(d, radius) {
147
+ // 计算扇区角度
148
+ const sectorAngle = d.endAngle - d.startAngle;
149
+ // 在弧的中间位置计算最大宽度
150
+ // 弧长 = 角度 * 半径,取80%作为安全尺寸
151
+ return Math.min(sectorAngle * radius * 0.8, 60); // 添加硬限制60
152
+ }
153
+
154
+ // 截断或调整文本以适应最大宽度
155
+ function fitTextToWidth(text, fontSize, maxWidth) {
156
+ const dimensions = estimateTextDimensions(text, fontSize);
157
+ if (dimensions.width <= maxWidth) {
158
+ return { text, fontSize };
159
+ }
160
+
161
+ // 如果文本太长,先尝试缩小字体
162
+ const minFontSize = 10; // 最小可接受的字体大小
163
+ if (fontSize > minFontSize) {
164
+ const newFontSize = Math.max(minFontSize, fontSize * (maxWidth / dimensions.width));
165
+ const newDimensions = estimateTextDimensions(text, newFontSize);
166
+ if (newDimensions.width <= maxWidth) {
167
+ return { text, fontSize: newFontSize };
168
+ }
169
+ }
170
+
171
+ // 如果缩小字体后仍然超出,则截断文本
172
+ const charWidth = fontSize * 0.6;
173
+ const maxChars = Math.floor(maxWidth / charWidth) - 2; // 减2留出省略号空间
174
+ if (maxChars < 3) { // 如果连3个字符都放不下
175
+ return { text: "...", fontSize };
176
+ }
177
+
178
+ return { text: text.substring(0, maxChars) + "...", fontSize };
179
+ }
180
+
181
+ for (let i = 0; i < sectors.length; i++) {
182
+ const d = sectors[i];
183
+ // 绘制甜甜圈图的各个部分
184
+ const path = g.append("path")
185
+ .attr("fill", colorResolver.field(d.data[xField], 0, { fallbackKey: "primary" }).value)
186
+ .attr("stroke", "#FFFFFF")
187
+ .attr("stroke-width", 2)
188
+ .attr("d", arc(d));
189
+
190
+ // 计算扇区中心点
191
+ // 创建一个专门用于计算扇区中心的弧生成器
192
+ const arcCentroid = d3.arc()
193
+ .innerRadius((maxRadius*0.5 + maxRadius) / 2) // 使用内外半径的中点
194
+ .outerRadius((maxRadius*0.5 + maxRadius) / 2);
195
+
196
+ // 获取扇区中心点坐标
197
+ const [cx, cy] = arcCentroid.centroid(d);
198
+
199
+ // 计算图标尺寸,根据扇区大小调整
200
+ const angleSize = d.endAngle - d.startAngle;
201
+ const iconSizeBase = Math.min(angleSize * maxRadius * 0.3, 60);
202
+ let iconWidth = Math.max(iconSizeBase, 20); // 至少20像素
203
+ let iconHeight = iconWidth;
204
+
205
+ // 创建剪切路径
206
+ const clipId = `clip-${i}`;
207
+ const defs = g.append("defs");
208
+ const clipPath = defs.append("clipPath")
209
+ .attr("id", clipId);
210
+
211
+ // 设置剪切路径
212
+ clipPath.append("circle")
213
+ .attr("cx", cx)
214
+ .attr("cy", cy)
215
+ .attr("r", iconWidth / 2);
216
+
217
+ // 添加白色背景圆
218
+ const circle = g.append("circle")
219
+ .attr("cx", cx)
220
+ .attr("cy", cy)
221
+ .attr("r", iconWidth / 2 + 3)
222
+ .attr("fill", "white")
223
+ .attr("stroke", colorResolver.field(d.data[xField], 0, { fallbackKey: "primary" }).value)
224
+ .attr("stroke-width", 2);
225
+
226
+ // 使用剪切路径裁剪图片
227
+ const icon = g.append("image")
228
+ .attr("xlink:href", jsonData.images.field[d.data[xField]])
229
+ .attr("clip-path", `url(#${clipId})`)
230
+ .attr("x", cx - iconWidth / 2)
231
+ .attr("y", cy - iconHeight / 2)
232
+ .attr("width", iconWidth)
233
+ .attr("height", iconHeight);
234
+
235
+ // 计算标签位置(在外部)
236
+ const labelPosition = calculateLabelPosition(
237
+ d,
238
+ [cx, cy],
239
+ iconWidth,
240
+ maxRadius*0.5,
241
+ maxRadius,
242
+ iconWidth,
243
+ iconHeight,
244
+ true
245
+ );
246
+
247
+ // 准备显示文本
248
+ let displayTextCategory = d.data[xField];
249
+ let displayTextNumerical = chartUtils.format.percent(d.data.percentage).text;
250
+
251
+ // 设置字体大小
252
+ let categoryFontSize = 20;
253
+ let numericalFontSize = 20;
254
+
255
+ // 估算文本尺寸
256
+ const categoryDimensions = estimateTextDimensions(displayTextCategory, categoryFontSize);
257
+ const numericalDimensions = estimateTextDimensions(displayTextNumerical, numericalFontSize);
258
+ // 计算标签总高度(包括两行文本和间距)
259
+ const labelHeight = categoryDimensions.height + numericalDimensions.height + 5;
260
+ // 取两行文本中较宽的一个作为标签宽度
261
+ const labelWidth = Math.max(categoryDimensions.width, numericalDimensions.width);
262
+
263
+ // 根据角度决定文本对齐方式
264
+ const angle = (d.startAngle + d.endAngle) / 2;
265
+ let textAnchor;
266
+ if (angle < Math.PI/8 || angle > 15*Math.PI/8) {
267
+ textAnchor = "middle"; // 正上方和正下方,居中对齐
268
+ } else if (angle >= Math.PI/8 && angle < 7*Math.PI/8) {
269
+ textAnchor = "start"; // 右侧区域,左对齐
270
+ } else if (angle >= 7*Math.PI/8 && angle < 9*Math.PI/8) {
271
+ textAnchor = "middle"; // 正左方,居中对齐
272
+ } else if (angle >= 9*Math.PI/8 && angle < 15*Math.PI/8) {
273
+ textAnchor = "end"; // 左侧区域,右对齐
274
+ } else {
275
+ textAnchor = "middle"; // 默认值
276
+ }
277
+
278
+ // 确定文本颜色
279
+ const textColor = colorResolver.text({ fallback: "#333333" }).value;
280
+
281
+ // 添加文本标签
282
+ // 添加类别文本
283
+ const textCategory = g.append("text")
284
+ .attr("transform", `translate(${labelPosition})`)
285
+ .attr("text-anchor", textAnchor)
286
+ .style("fill", textColor)
287
+ .style("font-family", typography.label.font_family)
288
+ .style("font-size", `${categoryFontSize}px`)
289
+ .text(displayTextCategory);
290
+
291
+ // 添加数值文本(在类别下方)
292
+ const textNumerical = g.append("text")
293
+ .attr("transform", `translate(0,20) translate(${labelPosition})`)
294
+ .attr("text-anchor", textAnchor)
295
+ .style("fill", textColor)
296
+ .style("font-family", typography.label.font_family)
297
+ .style("font-size", `${numericalFontSize}px`)
298
+ .style("font-weight", "bold")
299
+ .text(displayTextNumerical);
300
+
301
+ }
302
+
303
+ // 加入label
304
+ const label = g.append("text")
305
+ .attr("transform", `translate(${centerX}, ${centerY})`)
306
+ .attr("text-anchor", "middle")
307
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
308
+ .style("font-family", typography.label.font_family)
309
+ .style("font-size", typography.label.font_size)
310
+ .text(jsonData.title);
311
+
312
+ // 添加图例 - 放在图表上方
313
+ const legendGroup = svg.append("g")
314
+ .attr("transform", `translate(0, -50)`);
315
+
316
+ // 计算字段名宽度并添加间距
317
+ const titleWidth = xField.length * 10;
318
+ const titleMargin = 15;
319
+
320
+ let xs = [...new Set(chartData.map(d => d[xField]))];
321
+
322
+ const legendSize = chartUtils.legend.draw(legendGroup, xs, colors, {
323
+ x: titleWidth + titleMargin,
324
+ y: 0,
325
+ fontSize: 14,
326
+ fontWeight: "bold",
327
+ align: "left",
328
+ maxWidth: chartWidth - titleWidth - titleMargin,
329
+ shape: "rect",
330
+ });
331
+
332
+ // 添加字段名称
333
+ legendGroup.append("text")
334
+ .attr("x", 0)
335
+ .attr("y", legendSize.height / 2)
336
+ .attr("dominant-baseline", "middle")
337
+ .attr("fill", colorResolver.text({ fallback: "#333333" }).value)
338
+ .style("font-size", "16px")
339
+ .style("font-weight", "bold")
340
+ .text(xField);
341
+
342
+ // 将图例组向上移动 height/2, 并居中
343
+ legendGroup.attr("transform", `translate(${(chartWidth - legendSize.width - titleWidth - titleMargin) / 2}, ${-legendSize.height / 2 - 20})`);
344
+
345
+ return svg.node();
346
+ }