Ray1ee01 commited on
Commit
06b1b95
·
verified ·
1 Parent(s): 7a8995d

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/type18_vertical_bar_chart_with_ circle/vertical_bar_chart_with_circle_plain_01.js ADDED
@@ -0,0 +1,455 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Vertical Bar Chart With Circle",
5
+ "chart_name": "vertical_bar_chart_with_circle_plain_01",
6
+ "is_composite": true,
7
+ "required_fields": ["x", "y", "y2"],
8
+ "required_fields_type": [
9
+ ["categorical"],
10
+ ["numerical"],
11
+ ["numerical"]
12
+ ],
13
+ "required_fields_range": [[2, 12], ["-inf", "inf"], [0, "inf"]],
14
+ "required_fields_icons": ["x"],
15
+ "required_other_icons": [],
16
+ "required_fields_colors": ["x"],
17
+ "required_other_colors": ["primary","secondary"],
18
+ "supported_effects": ["radius_corner", "spacing"],
19
+ "min_height": 400,
20
+ "min_width": 400,
21
+ "background": "no",
22
+ "icon_mark": "none",
23
+ "icon_label": "side",
24
+ "has_x_axis": "no",
25
+ "has_y_axis": "no"
26
+ }
27
+ REQUIREMENTS_END
28
+ */
29
+
30
+ // 垂直条形图与比例圆复合图表实现 - 使用D3.js vertical_bar_proportional_circle_area_chart_01
31
+ function makeChart(containerSelector, data) {
32
+ const colorResolver = chartUtils.color.resolver(data);
33
+ // ---------- 1. 数据准备阶段 ----------
34
+
35
+ // 提取数据和配置
36
+ const jsonData = data;
37
+ const chartData = jsonData.data.data;
38
+ const variables = jsonData.variables || {};
39
+ const typography = jsonData.typography || {
40
+ title: { font_family: "Arial", font_size: "28px", font_weight: 700 },
41
+ label: { font_family: "Arial", font_size: "16px", font_weight: 500 },
42
+ description: { font_family: "Arial", font_size: "16px", font_weight: 500 },
43
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: 400 }
44
+ };
45
+ const colors = jsonData.colors || {
46
+ text_color: "#000000",
47
+ background_color: "#FFFFFF",
48
+ other: { primary: "#008080", secondary: "#FF0000" }, // 默认颜色: 蓝绿色和红色
49
+ available_colors: ["#FFBF00"] // 默认: 黄色
50
+ };
51
+ const images = jsonData.images || { field: {}, other: {} };
52
+ const dataColumns = chartUtils.schema.columns(jsonData);
53
+
54
+ // 数值单位规范
55
+ // 添加数值格式化函数
56
+
57
+ // *** 添加: 创建用于文本测量的Canvas Context ***
58
+ const canvas = document.createElement('canvas');
59
+ const context = canvas.getContext('2d');
60
+
61
+ // *** 修改: 提前提取字段名和单位 ***
62
+ const dimensionField = chartUtils.schema.channel(jsonData, "x").key ;
63
+ const valueField = chartUtils.schema.channel(jsonData, "y").key ;
64
+ const valueField2 = chartUtils.schema.channel(jsonData, "y2").key ;
65
+
66
+ let valueUnit = chartUtils.schema.channel(jsonData, "y").unit === "none" ? "" :
67
+ chartUtils.schema.channel(jsonData, "y").unit || ""; // 默认为百分号
68
+ let valueUnit2 = chartUtils.schema.channel(jsonData, "y2").unit === "none" ? "" :
69
+ chartUtils.schema.channel(jsonData, "y2").unit || "";
70
+
71
+ // 确保单位是字符串
72
+ valueUnit = valueUnit || "";
73
+ valueUnit2 = valueUnit2 || "";
74
+
75
+ // 清空容器
76
+ d3.select(containerSelector).html("");
77
+
78
+ // ---------- 2. 尺寸和布局设置 ----------
79
+
80
+ const width = variables.width || 800;
81
+ const height = variables.height || 600;
82
+
83
+ // 边距 - 减少底部边距,因为标签上移
84
+ const margin = { top: 90, right: 50, bottom: 30, left: 50 }; // *** 修改: 减少 bottom ***
85
+ const innerWidth = width - margin.left - margin.right;
86
+ const innerHeight = height - margin.top - margin.bottom; // *** 修改: 重新计算 innerHeight ***
87
+
88
+ // 定义中间区域的高度 (保持不变或按需调整)
89
+ const centralBandHeight = innerHeight * 0.20; // 中间区域占20%高度
90
+
91
+ // 上下条形图的总可用高度
92
+ const barAreaHeight = innerHeight - centralBandHeight;
93
+
94
+ // 按正负最大值比例分配高度
95
+ const maxPositiveY = d3.max(chartData, d => Math.max(0, +d[valueField])) || 0;
96
+ const maxNegativeY = Math.abs(d3.min(chartData, d => Math.min(0, +d[valueField])) || 0);
97
+ const totalMagnitudeRange = maxPositiveY + maxNegativeY;
98
+
99
+ let topBarAreaHeight;
100
+ let bottomBarAreaHeight;
101
+
102
+ if (totalMagnitudeRange > 0) {
103
+ topBarAreaHeight = barAreaHeight * (maxPositiveY / totalMagnitudeRange);
104
+ bottomBarAreaHeight = barAreaHeight * (maxNegativeY / totalMagnitudeRange);
105
+ } else {
106
+ topBarAreaHeight = barAreaHeight / 2;
107
+ bottomBarAreaHeight = barAreaHeight / 2;
108
+ }
109
+
110
+ // *** 修改: 重新计算中间区域的 Y 坐标 ***
111
+ const centralBandTopY = margin.top + topBarAreaHeight;
112
+ const centralBandBottomY = centralBandTopY + centralBandHeight;
113
+
114
+ // 定义图标和标签尺寸 (保持不变)
115
+ const iconSize = 20;
116
+ const iconMargin = 3;
117
+ const labelMargin = 3;
118
+ const circlePadding = 5;
119
+
120
+ const baseFontSizeLabel = parseFloat(typography.label.font_size) || 14;
121
+ const baseFontSizeAnnotation = parseFloat(typography.annotation.font_size) || 12;
122
+ const minFontSize = 8;
123
+
124
+ // *** 修改: 仅在此处预估中间区域元素 Y 坐标 ***
125
+ const estDimensionLabelY = centralBandTopY + centralBandHeight * 0.30;
126
+ const estCircleY = centralBandTopY + centralBandHeight * 0.70;
127
+
128
+ // ---------- 4. 数据处理 ----------
129
+
130
+ // 转换数值类型
131
+ chartData.forEach(d => {
132
+ d[valueField] = +d[valueField];
133
+ d[valueField2] = +d[valueField2];
134
+ });
135
+
136
+ // *** 修改: 按 y 值 (valueField) 降序排序数据 ***
137
+ chartData.sort((a, b) => +b[valueField] - +a[valueField]);
138
+
139
+ // 获取排序后的维度列表
140
+ const dimensions = chartData.map(d => d[dimensionField]);
141
+
142
+ // *** 添加: 获取第一个维度的颜色用于图例 ***
143
+ let firstDimensionColor = "#888888"; // 默认灰色
144
+ if (chartData.length > 0) {
145
+ const firstDimension = chartData[0][dimensionField];
146
+ firstDimensionColor = colorResolver.field(firstDimension, 0, { fallback: firstDimensionColor }).value;
147
+ }
148
+
149
+ // ---------- 5. 创建比例尺 ----------
150
+
151
+ // X轴比例尺 (类别)
152
+ const xScale = d3.scaleBand()
153
+ .domain(dimensions)
154
+ .range([0, innerWidth])
155
+ .padding(0.2); // 柱子之间的间距
156
+
157
+ // *** 修改: 更新 Y 轴比例尺范围以适应中间区域 ***
158
+ const yScalePositive = d3.scaleLinear()
159
+ .domain([0, maxPositiveY === 0 ? 1 : maxPositiveY])
160
+ .range([centralBandTopY, margin.top]); // 从中间区域顶部到图表顶部边距
161
+
162
+ const yScaleNegative = d3.scaleLinear()
163
+ .domain([0, maxNegativeY === 0 ? 1 : maxNegativeY])
164
+ .range([centralBandBottomY, height - margin.bottom]); // 从中间区域底部到图表底部边距
165
+
166
+ // 圆形面积比例尺 (y2值)
167
+ const maxValue2 = d3.max(chartData, d => +d[valueField2]) || 0;
168
+ const minRadius = 2;
169
+
170
+ // *** 修改: 计算精确的中间区域 Y 坐标和最大半径 ***
171
+ const dimensionLabelY = centralBandTopY + centralBandHeight * 0.30; // 最终维度标签Y
172
+ const circleY = centralBandTopY + centralBandHeight * 0.70; // 最终圆圈Y
173
+
174
+ const maxRadiusFromBarWidth = xScale.bandwidth() / 2 * 0.9;
175
+ // *** 修改: 使用基础字体大小估算圆圈区域顶部 Y 坐标 ***
176
+ const actualCircleAreaTopY = dimensionLabelY + baseFontSizeLabel + labelMargin; // 使用 baseFontSizeLabel 进行布局估算
177
+ const circleAreaHeight = centralBandBottomY - actualCircleAreaTopY;
178
+ const maxCircleRadiusAvailableBasedOnHeight = Math.max(0, (circleAreaHeight * 0.9) / 2);
179
+ const maxCircleRadiusAvailable = Math.min(maxCircleRadiusAvailableBasedOnHeight, maxRadiusFromBarWidth);
180
+ const maxRadius = Math.max(minRadius, maxCircleRadiusAvailable);
181
+
182
+ const radiusScale = d3.scaleSqrt()
183
+ .domain([0, maxValue2])
184
+ .range([minRadius, maxRadius]);
185
+
186
+ // *** 添加: 文本换行辅助函数 ***
187
+ function wrapText(textElement, text, width, x, y, fontSize, fontWeight, fontFamily) {
188
+ textElement.each(function() {
189
+ let words = text.split(/\s+/).reverse(),
190
+ word,
191
+ line = [],
192
+ lineNumber = 0,
193
+ lineHeight = 1.1, // ems
194
+ tspan = textElement.text(null).append("tspan").attr("x", x).attr("y", y),
195
+ dy = 0; // Initial dy for first line
196
+
197
+ // Estimate width function (simple version)
198
+ const estimate = (txt) => txt.length * fontSize * 0.6;
199
+
200
+ // Check total estimated width first
201
+ if (measureTextWidth(text, fontSize, fontWeight, fontFamily) <= width) {
202
+ tspan.text(text);
203
+ return; // No wrapping needed
204
+ }
205
+
206
+ // Try to wrap (limit to max 2 lines for simplicity here)
207
+ let lines = [];
208
+ let currentLine = "";
209
+ while (word = words.pop()) {
210
+ line.push(word);
211
+ currentLine = line.join(" ");
212
+ if (measureTextWidth(currentLine, fontSize, fontWeight, fontFamily) > width && line.length > 1) {
213
+ // Line exceeds width, back up one word
214
+ line.pop(); // remove the word that broke the limit
215
+ lines.push(line.join(" ")); // Add the previous line
216
+ line = [word]; // Start new line with the current word
217
+ if (lines.length >= 1) { // Stop after creating the first line break potential
218
+ line = [word].concat(words.reverse()); // Put remaining words on the second line
219
+ break;
220
+ }
221
+ }
222
+ }
223
+ lines.push(line.join(" ")); // Add the last line
224
+
225
+ // Render the lines (max 4)
226
+ lines.slice(0, 4).forEach((lineText, i) => {
227
+ if (i > 0) dy = lineHeight;
228
+ tspan = textElement.append("tspan")
229
+ .attr("x", x)
230
+ .attr("y", y)
231
+ .attr("dy", `${dy}em`)
232
+ .text(lineText);
233
+ });
234
+ // If more than 2 lines would be needed, the second line might truncate
235
+
236
+ });
237
+ }
238
+
239
+ // *** 修改: 字体大小计算函数 -> 精确测量 ***
240
+ function measureTextWidth(text, fontSize, fontWeight, fontFamily) {
241
+ return chartUtils.text.measure(null, text, { fontFamily: fontFamily, fontSize: fontSize, fontWeight: fontWeight }).width;
242
+ }
243
+
244
+ let minDimensionLabelRatio = 1.0;
245
+ let minCircleLabelRatio = 1.0;
246
+ let minBarLabelRatio = 1.0;
247
+
248
+ const maxDimensionLabelWidth = xScale.bandwidth() * 0.98;
249
+ const maxCircleLabelWidth = xScale.bandwidth() * 1.03;
250
+ const maxBarLabelWidth = xScale.bandwidth();
251
+
252
+ // *** 注意: 这里的 finalDimensionFontSize, finalCircleFontSize, finalBarFontSize
253
+ // 是在循环之后计算的,但在循环内部以及半径计算中可能需要提前估算或使用基础值。
254
+ // 当前代码在半径计算中使用了 finalDimensionFontSize || baseFontSizeLabel
255
+
256
+ chartData.forEach(d => {
257
+ // 维度标签
258
+ const dimensionText = String(d[dimensionField]); // Ensure string
259
+ // *** 修改: 使用精确宽度计算 ***
260
+ let currentWidth = measureTextWidth(dimensionText, baseFontSizeLabel, typography.label.font_weight, typography.label.font_family);
261
+ if (currentWidth > maxDimensionLabelWidth) {
262
+ minDimensionLabelRatio = Math.min(minDimensionLabelRatio, maxDimensionLabelWidth / currentWidth);
263
+ }
264
+
265
+ // 圆圈标签
266
+ const circleText = chartUtils.format.autoText(+d[valueField2]) + valueUnit2; // *** 修改: 单位放在后面 ***
267
+ currentWidth = measureTextWidth(circleText, baseFontSizeLabel, typography.label.font_weight, typography.label.font_family);
268
+ // *** 修改: 圆圈标签宽度只受bar宽度限制 ***
269
+ const effectiveMaxCircleWidth = maxCircleLabelWidth; // Remove constraint from maxAllowedCircleWidth
270
+ if (currentWidth > effectiveMaxCircleWidth) {
271
+ minCircleLabelRatio = Math.min(minCircleLabelRatio, effectiveMaxCircleWidth / currentWidth);
272
+ }
273
+
274
+ // 条形图标签
275
+ const barValue = d[valueField];
276
+ const barText = (barValue > 0 ? "+" : "") + chartUtils.format.autoText(barValue) + valueUnit;
277
+ // *** 修改: 使用精确宽度计算 ***
278
+ currentWidth = measureTextWidth(barText, baseFontSizeAnnotation, typography.annotation.font_weight, typography.annotation.font_family);
279
+ if (currentWidth > maxBarLabelWidth) {
280
+ minBarLabelRatio = Math.min(minBarLabelRatio, maxBarLabelWidth / currentWidth);
281
+ }
282
+ });
283
+
284
+ // 计算最终字体大小 (应用最小比例并限制最小值)
285
+ const finalDimensionFontSize = Math.max(minFontSize, baseFontSizeLabel * minDimensionLabelRatio);
286
+ const finalCircleFontSize = Math.max(minFontSize, baseFontSizeLabel * minCircleLabelRatio);
287
+ const finalBarFontSize = Math.max(minFontSize, baseFontSizeAnnotation * minBarLabelRatio);
288
+
289
+ // ---------- 6. 创建SVG容器 ----------
290
+
291
+ const svg = d3.select(containerSelector)
292
+ .append("svg")
293
+ .attr("width", "100%")
294
+ .attr("height", height)
295
+ .attr("viewBox", `0 0 ${width} ${height}`)
296
+ .attr("preserveAspectRatio", "xMidYMid meet") // 保持宽高比
297
+ .attr("style", "max-width: 100%; height: auto;")
298
+ .attr("xmlns", "http://www.w3.org/2000/svg")
299
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
300
+
301
+
302
+
303
+ // ---------- 7. 创建主图表组 ----------
304
+
305
+ const g = svg.append("g")
306
+ .attr("transform", `translate(${margin.left}, 0)`); // Y方向不移动,因为Y坐标已包含margin.top
307
+ const legendY = margin.top / 2;
308
+ const legendSquareSize = 12;
309
+ const legendCircleRadius = 6;
310
+ const legendPadding = 15;
311
+ const legendItemPadding = 5;
312
+ const yName = valueField;
313
+ const y2Name = valueField2;
314
+ const legendSymbolColor = firstDimensionColor;
315
+ chartUtils.legend.mixedCentered(svg, [
316
+ { markerShape: "rect", markerSize: legendSquareSize, color: legendSymbolColor, label: yName },
317
+ { markerShape: "circle", markerSize: legendCircleRadius, color: legendSymbolColor, label: y2Name },
318
+ ], {
319
+ centerX: margin.left + innerWidth / 2,
320
+ y: legendY,
321
+ itemGap: legendPadding,
322
+ markerTextGap: legendItemPadding,
323
+ fontSize: parseFloat(typography.annotation.font_size),
324
+ fontFamily: typography.annotation.font_family,
325
+ fontWeight: typography.annotation.font_weight,
326
+ textColor: colorResolver.text({ fallback: "#000000" }).value,
327
+ });
328
+
329
+ // ---------- 9. 绘制图表元素 (循环处理每个数据点) ----------
330
+
331
+ chartData.forEach((d, i) => {
332
+ const x = xScale(d[dimensionField]); // 获取当前类别的X坐标
333
+ const barWidth = xScale.bandwidth(); // 获取条形的宽度
334
+ const centerX = x + barWidth / 2; // 当前类别的中心X坐标
335
+
336
+ // *** 添加: 确定当前维度的颜色 ***
337
+ const dimension = d[dimensionField];
338
+ let dimensionColor = "#888888"; // 默认灰色
339
+ dimensionColor = colorResolver.field(dimension, 0, { fallback: dimensionColor }).value;
340
+
341
+ // 9.1 绘制垂直条形图 (y值)
342
+ const yValue = d[valueField];
343
+ let barHeight, barY;
344
+
345
+ // *** 修改: 使用更新后的 yScale 和边界计算高度/位置 ***
346
+ if (yValue > 0) {
347
+ barY = yScalePositive(yValue);
348
+ barHeight = centralBandTopY - barY; // 高度是中间区域顶部到 scale 输出的差值
349
+ } else if (yValue < 0) {
350
+ barY = centralBandBottomY; // 负值条从中间区域底部开始
351
+ const scaledY = yScaleNegative(Math.abs(yValue));
352
+ barHeight = scaledY - barY; // 高度是 scale 输出和中间区域底部的差值
353
+ } else {
354
+ barHeight = 0;
355
+ barY = centralBandTopY;
356
+ }
357
+
358
+ if (barHeight < 0) barHeight = 0;
359
+
360
+ if (barHeight > 0) { // 只有高度大于0才绘制
361
+ g.append("rect")
362
+ .attr("x", x)
363
+ .attr("width", barWidth)
364
+ .attr("fill", dimensionColor)
365
+ .attr("y", barY)
366
+ .attr("height", barHeight)
367
+ .attr("rx", 5)
368
+ .attr("ry", 5);
369
+
370
+ // 9.2 添加条形图数值标签 (y值)
371
+ const labelText = (yValue > 0 ? "+" : "") + chartUtils.format.autoText(yValue) + valueUnit;
372
+ // *** 修改: 标签 Y 坐标逻辑不变,但依赖的 barY/barHeight 已更新 ***
373
+ const labelY = (yValue >= 0) ? barY - 5 : barY + barHeight + (finalBarFontSize * 0.8);
374
+ const textAnchor = "middle";
375
+
376
+ g.append("text")
377
+ .attr("x", centerX)
378
+ .attr("y", labelY)
379
+ .attr("text-anchor", textAnchor)
380
+ .style("font-family", typography.annotation.font_family)
381
+ .style("font-size", `${finalBarFontSize}px`)
382
+ .style("font-weight", typography.annotation.font_weight)
383
+ .style("fill", colorResolver.text({ fallback: "#000000" }).value)
384
+ .text(labelText);
385
+
386
+ // 图标位置逻辑不变,依赖 labelY
387
+ if (images.field && images.field[d[dimensionField]]) {
388
+ const iconTargetY = labelY - (finalBarFontSize * (yValue >= 0 ? 0.7 : 0.1));
389
+ const iconY = iconTargetY - iconMargin - iconSize;
390
+
391
+ g.append("image")
392
+ .attr("x", centerX - iconSize / 2)
393
+ .attr("y", iconY)
394
+ .attr("width", iconSize)
395
+ .attr("height", iconSize)
396
+ .attr("xlink:href", images.field[d[dimensionField]])
397
+ .attr("preserveAspectRatio","xMidYMid meet");
398
+ }
399
+ }
400
+
401
+ // 9.3 绘制圆圈 (y2值)
402
+ const circleRadius = radiusScale(+d[valueField2]);
403
+
404
+ g.append("circle")
405
+ .attr("cx", centerX)
406
+ .attr("cy", circleY) // *** 修改: 使用新的 circleY ***
407
+ .attr("r", circleRadius)
408
+ .attr("fill", dimensionColor)
409
+ .attr("opacity", 0.8);
410
+
411
+ // 9.4 添加圆圈数值标签 (y2值) - 位置和颜色根据大小调整
412
+ const circleLabelText = chartUtils.format.autoText(+d[valueField2]) + valueUnit2;
413
+ const textWidth = measureTextWidth(circleLabelText, finalCircleFontSize, typography.label.font_weight, typography.label.font_family);
414
+ const isCircleBigEnough = textWidth < (circleRadius * 1.6);
415
+ let labelColor, labelActualY, labelDy;
416
+
417
+ if (isCircleBigEnough && circleRadius > minFontSize / 2) {
418
+ labelColor = "#ffffff";
419
+ labelActualY = circleY; // *** 修改: 内部标签使用新的 circleY ***
420
+ labelDy = "0.35em";
421
+ } else {
422
+ labelColor = colorResolver.text({ fallback: "#000000" }).value;
423
+ // *** 修改: 外部标签基于新的 circleY 定位 ***
424
+ labelActualY = circleY + circleRadius + (finalCircleFontSize * 0.6);
425
+ labelDy = "0em";
426
+ }
427
+
428
+ g.append("text")
429
+ .attr("x", centerX)
430
+ .attr("y", labelActualY) // *** 修改: 使用计算出的 labelActualY ***
431
+ .attr("dy", labelDy)
432
+ .attr("text-anchor", "middle")
433
+ .style("font-family", typography.label.font_family)
434
+ .style("font-size", `${finalCircleFontSize}px`)
435
+ .style("font-weight", typography.label.font_weight)
436
+ .style("fill", labelColor)
437
+ .text(circleLabelText);
438
+
439
+ // 9.5 添加维度标签 (x值)
440
+ // 图标已移走
441
+ g.append("text")
442
+ .attr("x", centerX)
443
+ .attr("y", dimensionLabelY) // *** 修改: 使用新的 dimensionLabelY ***
444
+ .attr("text-anchor", "middle")
445
+ .style("font-family", typography.label.font_family)
446
+ .style("font-size", `${finalDimensionFontSize}px`)
447
+ .style("font-weight", typography.label.font_weight)
448
+ .style("fill", colorResolver.text({ fallback: "#000000" }).value)
449
+ .call(wrapText, d[dimensionField], maxDimensionLabelWidth, centerX, dimensionLabelY, finalDimensionFontSize, typography.label.font_weight, typography.label.font_family);
450
+
451
+ });
452
+
453
+ // ---------- 10. 返回SVG节点 ----------
454
+ return svg.node();
455
+ }
modules/chart_engine/template/d3-js/type18_vertical_bar_chart_with_ circle/vertical_bar_chart_with_circle_plain_02.js ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Vertical Bar Chart With Circle",
5
+ "chart_name": "vertical_bar_chart_with_circle_plain_02",
6
+ "is_composite": true,
7
+ "required_fields": ["x", "y", "y2"],
8
+ "required_fields_type": [["categorical"], ["numerical"], ["numerical"]],
9
+ "required_fields_range": [[2, 12], ["-inf", "inf"], [0, "inf"]],
10
+ "required_fields_icons": ["x"],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": [],
13
+ "required_other_colors": [],
14
+ "supported_effects": [],
15
+ "min_height": 400,
16
+ "min_width": 400,
17
+ "background": "no",
18
+ "icon_mark": "none",
19
+ "icon_label": "side",
20
+ "has_x_axis": "no",
21
+ "has_y_axis": "no"
22
+ }
23
+ REQUIREMENTS_END
24
+ */
25
+
26
+ // 垂直条形图与比例圆复合图表实现 - 使用D3.js vertical_bar_chart_with_circle_plain_02 半圆
27
+ function makeChart(containerSelector, data) {
28
+ const colorResolver = chartUtils.color.resolver(data);
29
+ // ---------- 1. 数据准备阶段 ----------
30
+ const jsonData = data;
31
+ const chartData = jsonData.data.data;
32
+ const variables = jsonData.variables || {};
33
+ const typography = jsonData.typography || {
34
+ title: { font_family: "Arial", font_size: "28px", font_weight: 700 },
35
+ label: { font_family: "Arial", font_size: "16px", font_weight: 500 },
36
+ description: { font_family: "Arial", font_size: "16px", font_weight: 500 },
37
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: 400 }
38
+ };
39
+ const colors = jsonData.colors || {
40
+ text_color: "#000000",
41
+ other: { primary: "#008080", secondary: "#FF0000" },
42
+ available_colors: ["#FFBF00"]
43
+ };
44
+ const images = jsonData.images || { field: {}, other: {} };
45
+ const dataColumns = chartUtils.schema.columns(jsonData);
46
+
47
+ // 创建文本测量Context
48
+ const context = document.createElement('canvas').getContext('2d');
49
+
50
+ // 提取字段名和单位
51
+ const dimensionField = chartUtils.schema.channel(jsonData, "x").key;
52
+ const valueField = chartUtils.schema.channel(jsonData, "y").key;
53
+ const valueField2 = chartUtils.schema.channel(jsonData, "y2").key;
54
+ const valueUnit = chartUtils.schema.channel(jsonData, "y").unit === "none" ? "" : (chartUtils.schema.channel(jsonData, "y").unit || "");
55
+ const valueUnit2 = chartUtils.schema.channel(jsonData, "y2").unit === "none" ? "" : (chartUtils.schema.channel(jsonData, "y2").unit || "");
56
+
57
+ // 数值格式化函数
58
+
59
+ d3.select(containerSelector).html("");
60
+
61
+ // ---------- 2. 尺寸和布局设置 ----------
62
+ const width = variables.width || 800;
63
+ const height = variables.height || 600;
64
+ const margin = { top: 90, right: 50, bottom: 60, left: 50 };
65
+ const innerWidth = width - margin.left - margin.right;
66
+ const innerHeight = height - margin.top - margin.bottom;
67
+ const centralBandHeight = innerHeight * 0.20;
68
+ const barAreaHeight = innerHeight - centralBandHeight;
69
+
70
+ // 按正负最大值比例分配高度
71
+ const maxPositiveY = d3.max(chartData, d => Math.max(0, +d[valueField])) || 0;
72
+ const maxNegativeY = Math.abs(d3.min(chartData, d => Math.min(0, +d[valueField])) || 0);
73
+ const totalMagnitudeRange = maxPositiveY + maxNegativeY;
74
+ const topBarAreaHeight = totalMagnitudeRange > 0 ? barAreaHeight * (maxPositiveY / totalMagnitudeRange) : barAreaHeight / 2;
75
+ const bottomBarAreaHeight = barAreaHeight - topBarAreaHeight;
76
+
77
+ // 计算关键Y坐标
78
+ const centralBandTopY = margin.top + topBarAreaHeight;
79
+ const centralBandBottomY = centralBandTopY + centralBandHeight;
80
+
81
+ // 布局参数
82
+ const iconSize = 20;
83
+ const baseFontSizeLabel = parseFloat(typography.label.font_size) || 14;
84
+ const baseFontSizeAnnotation = parseFloat(typography.annotation.font_size) || 12;
85
+ const minFontSize = 8;
86
+
87
+ // 计算元素Y坐标
88
+ const iconY = centralBandTopY + 5 + iconSize / 2;
89
+ const dimensionLabelY = iconY + iconSize / 2 + 3 + baseFontSizeLabel / 2;
90
+ const circleAreaHeight = centralBandBottomY - (dimensionLabelY + baseFontSizeLabel / 2 + 3) - 5;
91
+ const maxCircleRadiusAvailable = Math.min((circleAreaHeight * 0.9) / 2, innerWidth / 2);
92
+
93
+ // ---------- 3. 数据处理和比例尺 ----------
94
+ chartData.forEach(d => { d[valueField] = +d[valueField]; d[valueField2] = +d[valueField2]; });
95
+ chartData.sort((a, b) => +b[valueField] - +a[valueField]);
96
+ const dimensions = chartData.map(d => d[dimensionField]);
97
+
98
+ const xScale = d3.scaleBand().domain(dimensions).range([0, innerWidth]).padding(0.2);
99
+ const yScalePositive = d3.scaleLinear().domain([0, maxPositiveY || 1]).range([centralBandTopY, centralBandTopY - topBarAreaHeight]);
100
+ const yScaleNegative = d3.scaleLinear().domain([0, maxNegativeY || 1]).range([centralBandBottomY, centralBandBottomY + bottomBarAreaHeight]);
101
+ const radiusScale = d3.scaleSqrt().domain([0, d3.max(chartData, d => +d[valueField2]) || 0]).range([2, Math.max(2, maxCircleRadiusAvailable)]);
102
+
103
+ // ---------- 4. 辅助函数 ----------
104
+ // 文本宽度测量
105
+ const measureTextWidth = (text, fontSize, fontWeight, fontFamily) => {
106
+ return chartUtils.text.measure(null, text, { fontFamily: fontFamily, fontSize: fontSize, fontWeight: fontWeight }).width;
107
+ };
108
+
109
+ // 文本换行函数
110
+ function wrapText(textElement, text, width, x, y, fontSize, fontWeight, fontFamily, alignment = 'middle') {
111
+ textElement.each(function() {
112
+ const element = d3.select(this);
113
+ const words = text.split(/\s+/).reverse();
114
+ const lineHeight = 1.3;
115
+
116
+ element.text(null);
117
+ let tspans = [];
118
+
119
+ if (words.length > 1) {
120
+ let currentLine = [];
121
+ let word;
122
+ while (word = words.pop()) {
123
+ currentLine.push(word);
124
+ if (measureTextWidth(currentLine.join(" "), fontSize, fontWeight, fontFamily) > width && currentLine.length > 1) {
125
+ currentLine.pop();
126
+ tspans.push(currentLine.join(" "));
127
+ currentLine = [word];
128
+ }
129
+ }
130
+ if (currentLine.length > 0) tspans.push(currentLine.join(" "));
131
+ } else {
132
+ let currentLine = '';
133
+ for (let char of text.split('')) {
134
+ const nextLine = currentLine + char;
135
+ if (measureTextWidth(nextLine, fontSize, fontWeight, fontFamily) > width && currentLine.length > 0) {
136
+ tspans.push(currentLine);
137
+ currentLine = char;
138
+ } else {
139
+ currentLine = nextLine;
140
+ }
141
+ }
142
+ if (currentLine.length > 0) tspans.push(currentLine);
143
+ }
144
+
145
+ // 计算对齐偏移
146
+ const totalLines = tspans.length;
147
+ let startDy = 0;
148
+ if (alignment === 'middle') startDy = -((totalLines - 1) * lineHeight / 2);
149
+ else if (alignment === 'bottom') startDy = -(totalLines * lineHeight - lineHeight);
150
+
151
+ tspans.forEach((lineText, i) => {
152
+ element.append("tspan")
153
+ .attr("x", x)
154
+ .attr("dy", (i === 0 ? startDy : lineHeight) + "em")
155
+ .text(lineText);
156
+ });
157
+
158
+ element.attr("data-lines", totalLines);
159
+ });
160
+ }
161
+
162
+ // 计算字体大小
163
+ let minDimensionLabelRatio = 1.0, minCircleLabelRatio = 1.0, minBarLabelRatio = 1.0;
164
+ const maxDimensionLabelWidth = xScale.bandwidth() * 0.95;
165
+ const maxCircleLabelWidth = xScale.bandwidth() * 1.03;
166
+ const maxBarLabelWidth = xScale.bandwidth();
167
+
168
+ chartData.forEach(d => {
169
+ // 维度标签
170
+ let currentWidth = measureTextWidth(String(d[dimensionField]), baseFontSizeLabel, typography.label.font_weight, typography.label.font_family);
171
+ if (currentWidth > maxDimensionLabelWidth) minDimensionLabelRatio = Math.min(minDimensionLabelRatio, maxDimensionLabelWidth / currentWidth);
172
+
173
+ // 圆圈标签
174
+ currentWidth = measureTextWidth(chartUtils.format.scaledText(+d[valueField2]) + (valueUnit2 ? ` ${valueUnit2}` : ''), baseFontSizeLabel, typography.label.font_weight, typography.label.font_family);
175
+ if (currentWidth > maxCircleLabelWidth) minCircleLabelRatio = Math.min(minCircleLabelRatio, maxCircleLabelWidth / currentWidth);
176
+
177
+ // 条形图标签
178
+ currentWidth = measureTextWidth((+d[valueField] > 0 ? "+" : "") + chartUtils.format.scaledText(+d[valueField]) + (valueUnit ? ` ${valueUnit}` : ''), baseFontSizeAnnotation, typography.annotation.font_weight, typography.annotation.font_family);
179
+ if (currentWidth > maxBarLabelWidth) minBarLabelRatio = Math.min(minBarLabelRatio, maxBarLabelWidth / currentWidth);
180
+ });
181
+
182
+ const finalDimensionFontSize = Math.max(minFontSize, baseFontSizeLabel * minDimensionLabelRatio);
183
+ const finalCircleFontSize = Math.max(minFontSize, baseFontSizeLabel * minCircleLabelRatio);
184
+ const finalBarFontSize = Math.max(minFontSize, baseFontSizeAnnotation * minBarLabelRatio);
185
+
186
+ // ---------- 5. 创建SVG容器 ----------
187
+ const svg = d3.select(containerSelector)
188
+ .append("svg")
189
+ .attr("width", "100%")
190
+ .attr("height", height)
191
+ .attr("viewBox", `0 0 ${width} ${height}`)
192
+ .attr("preserveAspectRatio", "xMidYMid meet")
193
+ .attr("style", "max-width: 100%; height: auto;")
194
+ .attr("xmlns", "http://www.w3.org/2000/svg")
195
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
196
+
197
+ const g = svg.append("g").attr("transform", `translate(${margin.left}, 0)`);
198
+
199
+ // ---------- 6. 添加图例 ----------
200
+ const positiveColor = colorResolver.other("primary", { fallback: "#008080" }).value;
201
+ const negativeColor = colorResolver.other("secondary", { fallback: "#FF0000" }).value;
202
+ const circleLegendColor = colorResolver.available(0, { fallback: "#FFBF00" }).value;
203
+ const legendY = margin.top - 30;
204
+ chartUtils.legend.segmentsCentered(svg, [
205
+ { type: "rect", size: 12, color: positiveColor },
206
+ { type: "rect", size: 12, color: negativeColor },
207
+ { type: "text", label: valueField },
208
+ { type: "circle", size: 12, radius: 6, color: circleLegendColor },
209
+ { type: "text", label: valueField2 },
210
+ ], {
211
+ centerX: margin.left + innerWidth / 2,
212
+ y: legendY,
213
+ itemGap: 5,
214
+ sectionGap: 5,
215
+ fontSize: 12,
216
+ fontFamily: typography.annotation.font_family,
217
+ textColor: colorResolver.text({ fallback: "#333333" }).value,
218
+ });
219
+ // ---------- 7. 绘制图表元素 ----------
220
+ // 计算维度标签最大高度
221
+ let maxLabelHeight = 0;
222
+ const dimensionLabels = chartData.map((d, i) => {
223
+ const tempText = g.append("text").style("visibility", "hidden")
224
+ .call(wrapText, d[dimensionField], maxDimensionLabelWidth, 0, 0, finalDimensionFontSize, typography.label.font_weight, typography.label.font_family, 'top');
225
+ const lineCount = parseInt(tempText.attr("data-lines") || "1");
226
+ const height = lineCount * finalDimensionFontSize * 1.3;
227
+ maxLabelHeight = Math.max(maxLabelHeight, height);
228
+ tempText.remove();
229
+ return { lineCount, height };
230
+ });
231
+
232
+ const uniformCircleY = dimensionLabelY + maxLabelHeight + 10;
233
+
234
+ // 绘制所有元素
235
+ chartData.forEach((d, i) => {
236
+ const x = xScale(d[dimensionField]);
237
+ const barWidth = xScale.bandwidth();
238
+ const centerX = x + barWidth / 2;
239
+ const yValue = d[valueField];
240
+
241
+ // 绘制条形图
242
+ if (yValue !== 0) {
243
+ let barY, barHeight, barColor;
244
+ if (yValue > 0) {
245
+ barY = yScalePositive(yValue);
246
+ barHeight = centralBandTopY - barY;
247
+ barColor = colorResolver.other("primary", { fallback: "#008080" }).value;
248
+ } else {
249
+ barY = centralBandBottomY;
250
+ barHeight = yScaleNegative(Math.abs(yValue)) - barY;
251
+ barColor = colorResolver.other("secondary", { fallback: "#FF0000" }).value;
252
+ }
253
+
254
+ if (barHeight > 0) {
255
+ g.append("rect").attr("class", "mark").attr("x", x).attr("width", barWidth).attr("fill", barColor)
256
+ .attr("y", barY).attr("height", barHeight).attr("rx", barWidth / 2).attr("ry", barWidth / 2);
257
+
258
+ // 条形数值标签
259
+ const labelText = (yValue > 0 ? "+" : "") + chartUtils.format.scaledText(yValue) + (valueUnit ? ` ${valueUnit}` : '');
260
+ const labelY = yValue >= 0 ? barY - 5 : barY + barHeight + finalBarFontSize * 0.8;
261
+ g.append("text").attr("class", "value").attr("x", centerX).attr("y", labelY).attr("text-anchor", "middle")
262
+ .style("font-family", typography.annotation.font_family).style("font-size", `${finalBarFontSize}px`)
263
+ .style("font-weight", typography.annotation.font_weight).style("fill", colorResolver.text({ fallback: "#000000" }).value).text(labelText);
264
+ }
265
+ }
266
+
267
+ // 维度标签
268
+ g.append("text").attr("class", "label").attr("x", centerX).attr("y", dimensionLabelY).attr("text-anchor", "middle")
269
+ .style("font-family", typography.label.font_family).style("font-size", `${finalDimensionFontSize}px`)
270
+ .style("font-weight", typography.label.font_weight).style("fill", colorResolver.text({ fallback: "#000000" }).value)
271
+ .call(wrapText, d[dimensionField], maxDimensionLabelWidth, centerX, dimensionLabelY, finalDimensionFontSize, typography.label.font_weight, typography.label.font_family, 'top');
272
+
273
+ // 圆圈
274
+ const circleRadius = radiusScale(+d[valueField2]);
275
+ g.append("circle").attr("class", "mark").attr("cx", centerX).attr("cy", uniformCircleY).attr("r", circleRadius)
276
+ .attr("fill", colorResolver.available(0, { fallback: "#FFBF00" }).value).attr("opacity", 0.8);
277
+
278
+ // 圆圈数值标签
279
+ const circleLabelText = chartUtils.format.scaledText(+d[valueField2]) + (valueUnit2 ? ` ${valueUnit2}` : '');
280
+ g.append("text").attr("class", "value").attr("x", centerX).attr("y", uniformCircleY).attr("dy", "0.35em").attr("text-anchor", "middle")
281
+ .style("font-family", typography.label.font_family).style("font-size", `${finalCircleFontSize}px`)
282
+ .style("font-weight", typography.label.font_weight).style("fill", colorResolver.text({ fallback: "#000000" }).value).text(circleLabelText);
283
+
284
+ // 图标
285
+ if (images.field && images.field[d[dimensionField]]) {
286
+ g.append("image").attr("class", "image").attr("x", centerX - iconSize / 2).attr("y", iconY - iconSize / 2)
287
+ .attr("width", iconSize).attr("height", iconSize).attr("xlink:href", images.field[d[dimensionField]]).attr("preserveAspectRatio", "xMidYMid meet");
288
+ }
289
+ });
290
+
291
+ return svg.node();
292
+ }