Ray1ee01 commited on
Commit
73c8a35
·
verified ·
1 Parent(s): 4615c86

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/type3_vertical_group_bar_chart/vertical_group_bar_plain_chart_01.js ADDED
@@ -0,0 +1,370 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Vertical Group Bar Chart",
5
+ "chart_name": "vertical_group_bar_plain_chart_01",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[3, 6], [0, 100], [3, 4]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["group"],
12
+ "required_other_colors": ["primary", "secondary", "background"],
13
+ "supported_effects": ["gradient", "opacity"],
14
+ "min_height": 600,
15
+ "min_width": 800,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "yes"
21
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+
26
+ function makeChart(containerSelector, data) {
27
+ const colorResolver = chartUtils.color.resolver(data);
28
+ // ---------- 1. 数据准备阶段 ----------
29
+
30
+ // 提取数据和配置
31
+ const jsonData = data; // 完整的JSON数据对象
32
+ const chartData = jsonData.data.data; // 实际数据点数组
33
+ const variables = jsonData.variables || {}; // 图表配置
34
+ const typography = jsonData.typography || { // 字体设置,如果不存在则使用默认值
35
+ title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
36
+ label: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
37
+ description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
38
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
39
+ };
40
+ const colors = jsonData.colors || {
41
+ text_color: "#333333",
42
+ other: {
43
+ primary: "#D32F2F", // Red for "Still active"
44
+ secondary: "#AAAAAA", // Gray for "Ended"
45
+ background: "#F0F0F0"
46
+ }
47
+ }; // 颜色设置
48
+ const dataColumns = chartUtils.schema.columns(jsonData); // 数据列定义
49
+
50
+ // 设置视觉效果变量的默认值
51
+ variables.has_shadow = variables.has_shadow || false;
52
+ variables.has_stroke = variables.has_stroke || false;
53
+
54
+ // 清空容器
55
+ d3.select(containerSelector).html("");
56
+
57
+ // 数值单位规范
58
+ // 添加数值格式化函数
59
+
60
+ // ---------- 2. 尺寸和布局设置 ----------
61
+
62
+ // 设置图表总尺寸
63
+ const width = variables.width || 600;
64
+ const height = variables.height || 400;
65
+
66
+ // 设置边距
67
+ const margin = {
68
+ top: 50,
69
+ right: 30,
70
+ bottom: 80,
71
+ left: 40
72
+ };
73
+
74
+ // 计算实际绘图区域大小
75
+ const chartWidth = width - margin.left - margin.right;
76
+ const chartHeight = height - margin.top - margin.bottom;
77
+
78
+ // ---------- 3. 提取字段名和单位 ----------
79
+
80
+ // 根据数据列获取字段名
81
+ const xField = chartUtils.schema.channel(jsonData, "x").key || "period";
82
+ const yField = chartUtils.schema.channel(jsonData, "y").key || "value";
83
+ const groupField = chartUtils.schema.channel(jsonData, "group").key || "group";
84
+
85
+ // 获取字段单位(如果存在)
86
+ let xUnit = "";
87
+ let yUnit = "";
88
+ let groupUnit = "";
89
+
90
+ if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
91
+ xUnit = chartUtils.schema.channel(jsonData, "x").unit;
92
+ }
93
+
94
+ if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
95
+ yUnit = chartUtils.schema.channel(jsonData, "y").unit;
96
+ }
97
+
98
+ if (chartUtils.schema.channel(jsonData, "group").unit !== "none") {
99
+ groupUnit = chartUtils.schema.channel(jsonData, "group").unit;
100
+ }
101
+
102
+ // ---------- 4. 数据处理 ----------
103
+
104
+ // 获取所有唯一的分组值
105
+ const groups = Array.from(new Set(chartData.map(d => d[groupField])));
106
+
107
+ // 处理数据,按照分组组织
108
+ const processedData = chartData.reduce((acc, d) => {
109
+ const category = d[xField];
110
+ const group = d[groupField];
111
+ const value = +d[yField];
112
+
113
+ const existingCategory = acc.find(item => item.category === category);
114
+ if (existingCategory) {
115
+ existingCategory.groups[group] = value;
116
+ } else {
117
+ const newCategory = {
118
+ category: category,
119
+ groups: {}
120
+ };
121
+ newCategory.groups[group] = value;
122
+ acc.push(newCategory);
123
+ }
124
+ return acc;
125
+ }, []);
126
+
127
+ // ---------- 5. 创建比例尺 ----------
128
+
129
+ // X轴比例尺 - 使用分类数据
130
+ const xScale = d3.scaleBand()
131
+ .domain(processedData.map(d => d.category))
132
+ .range([0, chartWidth])
133
+ .padding(0.2);
134
+
135
+ // 分组比例尺
136
+ const groupScale = d3.scaleBand()
137
+ .domain(groups)
138
+ .range([0, xScale.bandwidth()])
139
+ .padding(0.05);
140
+
141
+ // Y轴比例尺 - 使用数值
142
+ const yScale = d3.scaleLinear()
143
+ .domain([0, d3.max(chartData, d => +d[yField]) || 1]) // Ensure domain is not [0,0]
144
+ .range([chartHeight, 0])
145
+ .nice();
146
+ // +++ 辅助函数区 +++
147
+ const canvas = document.createElement('canvas');
148
+ const ctx = canvas.getContext('2d');
149
+ function measureTextWidth(text, fontFamily, fontSize, fontWeight) {
150
+ ctx.font = `${fontWeight || 'normal'} ${fontSize}px ${fontFamily || 'Arial'}`;
151
+ return chartUtils.text.contextWidth(ctx, text);
152
+ }
153
+
154
+ const calculateFontSize = (text, maxWidth, baseSize = 14) => {
155
+ if (!text || typeof text !== 'string' || !maxWidth || maxWidth <= 0 || !baseSize || baseSize <= 0) {
156
+ return Math.max(10, baseSize || 14);
157
+ }
158
+ const avgCharWidth = baseSize * 0.6;
159
+ const textWidth = text.length * avgCharWidth;
160
+ if (textWidth < maxWidth) {
161
+ return baseSize;
162
+ }
163
+ return Math.max(10, Math.floor(baseSize * (maxWidth / textWidth)));
164
+ };
165
+
166
+ function wrapText(textElement, str, width, lineHeight = 1.1, alignment = 'middle') {
167
+ const words = str.split(/\s+/).reverse();
168
+ let word;
169
+ let line = [];
170
+ let lineNumber = 0;
171
+ const initialY = parseFloat(textElement.attr("data-initial-y")); // 从data属性获取初始Y
172
+ // const initialX = parseFloat(textElement.attr("x")); // 这一行应该被注释掉或移除
173
+
174
+ textElement.text(null);
175
+ let tspans = [];
176
+
177
+ if (words.length > 1) {
178
+ let currentLine = [];
179
+ while (word = words.pop()) {
180
+ currentLine.push(word);
181
+ const isOverflow = chartUtils.text.measure(null, currentLine.join(" "), {
182
+ fontFamily: textElement.style("font-family"),
183
+ fontSize: parseFloat(textElement.style("font-size")),
184
+ fontWeight: textElement.style("font-weight")
185
+ }).width > width;
186
+ if (isOverflow && currentLine.length > 1) {
187
+ currentLine.pop();
188
+ tspans.push(currentLine.join(" "));
189
+ currentLine = [word];
190
+ lineNumber++;
191
+ }
192
+ }
193
+ if (currentLine.length > 0) {
194
+ tspans.push(currentLine.join(" "));
195
+ }
196
+ } else {
197
+ const chars = str.split('');
198
+ let currentLine = '';
199
+ for (let i = 0; i < chars.length; i++) {
200
+ const nextLine = currentLine + chars[i];
201
+ const isOverflow = chartUtils.text.measure(null, nextLine, {
202
+ fontFamily: textElement.style("font-family"),
203
+ fontSize: parseFloat(textElement.style("font-size")),
204
+ fontWeight: textElement.style("font-weight")
205
+ }).width > width;
206
+ if (isOverflow && currentLine.length > 0) {
207
+ tspans.push(currentLine);
208
+ currentLine = chars[i];
209
+ lineNumber++;
210
+ } else {
211
+ currentLine = nextLine;
212
+ }
213
+ }
214
+ if (currentLine.length > 0) {
215
+ tspans.push(currentLine);
216
+ }
217
+ }
218
+
219
+ const totalLines = tspans.length;
220
+ let startDy = 0;
221
+ textElement.attr("y", initialY); // 重置Y到初始值
222
+
223
+ if (alignment === 'middle') {
224
+ startDy = -( (totalLines - 1) * lineHeight / 2);
225
+ } else if (alignment === 'bottom') {
226
+ const totalHeightEm = totalLines * lineHeight;
227
+ startDy = -(totalHeightEm - lineHeight);
228
+ }
229
+
230
+ tspans.forEach((lineText, i) => {
231
+ textElement.append("tspan")
232
+ // .attr("x", initialX) // 关键:确保这一行被注释掉或移除
233
+ .attr("dy", (i === 0 ? startDy : lineHeight) + "em")
234
+ .text(lineText);
235
+ });
236
+ textElement.attr("data-lines", totalLines);
237
+ }
238
+ // +++ 辅助函数区结束 +++
239
+
240
+ // ---------- 6. 创建SVG容器 ----------
241
+
242
+ const svg = d3.select(containerSelector)
243
+ .append("svg")
244
+ .attr("width", "100%")
245
+ .attr("height", height)
246
+ .attr("viewBox", `0 0 ${width} ${height}`)
247
+ .attr("style", "max-width: 100%; height: auto;")
248
+ .attr("xmlns", "http://www.w3.org/2000/svg")
249
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
250
+
251
+ // 添加图表主体容器
252
+ const chartGroup = svg.append("g")
253
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
254
+
255
+ // ---------- 7. 绘制图表元素 ----------
256
+
257
+ // 添加X轴
258
+ const xCategories = processedData.map(d => d.category);
259
+ const baseLabelFontSize = parseFloat(typography.label.font_size) || 14;
260
+ const xLabelMaxWidth = xScale.bandwidth() * 0.95;
261
+ const longestXLabel = xCategories.reduce((a, b) => String(a).length > String(b).length ? a : b, "").toString();
262
+ const uniformXLabelFontSize = calculateFontSize(longestXLabel, xLabelMaxWidth, baseLabelFontSize);
263
+
264
+ // +++ Pre-calculate for Value Labels +++
265
+ let longestValueLabelStr = "";
266
+ processedData.forEach(pd => {
267
+ groups.forEach(group => {
268
+ const value = pd.groups[group] || 0;
269
+ const labelStr = chartUtils.format.autoText(value) + (yUnit ? ` ${yUnit}` : '');
270
+ if (labelStr.length > longestValueLabelStr.length) {
271
+ longestValueLabelStr = labelStr;
272
+ }
273
+ });
274
+ });
275
+ const baseValueLabelFontSize = parseFloat(typography.annotation.font_size) || 12; // Use annotation font size as base
276
+ const valueLabelMaxWidth = groupScale.bandwidth();
277
+ const uniformValueLabelFontSize = calculateFontSize(longestValueLabelStr, valueLabelMaxWidth, baseValueLabelFontSize);
278
+ // +++ End Pre-calculate for Value Labels +++
279
+
280
+ const xAxis = d3.axisBottom(xScale)
281
+ .tickSize(0) // 移除刻度线
282
+ .tickPadding(10); // 增加标签和轴线的间距
283
+
284
+ chartGroup.append("g")
285
+ .attr("class", "x-axis")
286
+ .attr("transform", `translate(0, ${chartHeight})`)
287
+ .call(xAxis)
288
+ .selectAll(".tick text") // 选择刻度文本
289
+ .attr("data-initial-y", typography.label.font_size)
290
+ .style("font-family", typography.label.font_family)
291
+ .style("font-size", `${uniformXLabelFontSize}px`)
292
+ .style("text-anchor", "middle")
293
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
294
+ .each(function(d) {
295
+ const textElement = d3.select(this);
296
+ wrapText(textElement, String(d), xLabelMaxWidth, 1.1, 'top');
297
+ });
298
+
299
+ // 添加Y轴
300
+ const yAxis = d3.axisLeft(yScale)
301
+ .ticks(5)
302
+ .tickFormat(d => chartUtils.format.autoText(d) + (yUnit ? ` ${yUnit}` : ''))
303
+ .tickSize(0) // 移除刻度线
304
+ .tickPadding(10); // 增加文字和轴的间距
305
+
306
+ chartGroup.append("g")
307
+ .attr("class", "y-axis")
308
+ .call(yAxis)
309
+ .call(g => g.select(".domain").remove()) // 移除轴线
310
+ .selectAll("text")
311
+ .remove()
312
+ // .style("font-family", typography.label.font_family)
313
+ // .style("font-size", typography.label.font_size)
314
+ // .style("fill", colorResolver.text({ fallback: "#333333" }).value)
315
+
316
+ // 修改条形图绘制部分
317
+ const barGroups = chartGroup.selectAll(".bar-group")
318
+ .data(processedData)
319
+ .enter()
320
+ .append("g")
321
+ .attr("class", "bar-group")
322
+ .attr("transform", d => `translate(${xScale(d.category)},0)`);
323
+
324
+ groups.forEach(group => {
325
+ barGroups.append("rect")
326
+ .attr("class", "bar")
327
+ .attr("x", d => groupScale(group))
328
+ .attr("y", d => yScale(d.groups[group] || 0))
329
+ .attr("width", groupScale.bandwidth())
330
+ .attr("height", d => chartHeight - yScale(d.groups[group] || 0))
331
+ .attr("fill", colorResolver.field(group, 0, { fallbackKey: "primary" }).value);
332
+
333
+ // 添加数值标签
334
+ barGroups.append("text")
335
+ .attr("class", "label")
336
+ .attr("text-anchor", "middle")
337
+ .style("font-family", typography.annotation.font_family)
338
+ .style("font-size", `${uniformValueLabelFontSize}px`)
339
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
340
+ .attr("data-initial-y", d => yScale(d.groups[group] || 0)-5)
341
+ .each(function(d_barGroup) {
342
+ const textElement = d3.select(this);
343
+ const value = d_barGroup.groups[group] || 0;
344
+ const labelText = chartUtils.format.autoText(value) + (yUnit ? ` ${yUnit}` : '');
345
+ textElement.attr("x", groupScale(group) + groupScale.bandwidth() / 2);
346
+ wrapText(textElement, labelText, valueLabelMaxWidth, 1.1, 'bottom');
347
+ });
348
+ });
349
+ // 添加图例 - 放在图表上方
350
+ const legendGroup = svg.append("g")
351
+ .attr("transform", `translate(0, -50)`);
352
+
353
+ // 计算字段名宽度并添加间距
354
+ const titleWidth = groupField.length * 10;
355
+ const titleMargin = 15;
356
+
357
+
358
+ const legendSize = chartUtils.legend.draw(legendGroup, groups, colors, {
359
+ x: titleWidth + titleMargin,
360
+ y: 0,
361
+ fontSize: 14,
362
+ fontWeight: "bold",
363
+ align: "left",
364
+ maxWidth: chartWidth - titleWidth - titleMargin,
365
+ shape: "rect",
366
+ });
367
+
368
+
369
+ return svg.node();
370
+ }
modules/chart_engine/template/d3-js/type3_vertical_group_bar_chart/vertical_group_bar_plain_chart_02.js ADDED
@@ -0,0 +1,623 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Vertical Group Bar Chart",
5
+ "chart_name": "vertical_group_bar_plain_chart_02",
6
+ "is_composite": false,
7
+ "required_fields": ["x", "y", "group"],
8
+ "required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
9
+ "required_fields_range": [[2, 20], [0, "inf"], [2, 2]],
10
+ "required_fields_icons": ["x"],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": ["group"],
13
+ "required_other_colors": [],
14
+ "supported_effects": ["shadow", "radius_corner", "gradient", "stroke", "spacing"],
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 typography = jsonData.typography || {
35
+ title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
36
+ label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
37
+ description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
38
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
39
+ };
40
+ const colors = jsonData.colors || {
41
+ text_color: "#333333",
42
+ field: {},
43
+ other: {
44
+ primary: "#4682B4" // 默认主色调
45
+ }
46
+ };
47
+ const images = jsonData.images || { field: {}, other: {} };
48
+ const dataColumns = chartUtils.schema.columns(jsonData);
49
+
50
+ // 如果不存在,添加副标题字段
51
+ typography.subtitle = typography.subtitle || typography.description;
52
+
53
+ // 设置视觉效果变量
54
+ variables.has_rounded_corners = variables.has_rounded_corners || false;
55
+ variables.has_shadow = variables.has_shadow || false;
56
+ variables.has_gradient = variables.has_gradient || false;
57
+ variables.has_stroke = variables.has_stroke || false;
58
+ variables.has_spacing = variables.has_spacing || false;
59
+
60
+ // 清除容器
61
+ d3.select(containerSelector).html("");
62
+
63
+ // ---------- 2. 尺寸和布局设置 ----------
64
+ // 设置图表尺寸和边距
65
+ const width = variables.width || 800;
66
+ const height = variables.height || 500;
67
+
68
+ // 边距:上,右,下,左
69
+ const margin = {
70
+ top: 100, // 标题和标签的空间
71
+ right: 30, // 右侧标签的空间
72
+ bottom: 80, // x轴和标签的空间
73
+ left: 30 // y轴和标签的空间
74
+ };
75
+
76
+ // 计算实际绘图区域大小
77
+ const innerWidth = width - margin.left - margin.right;
78
+ const innerHeight = height - margin.top - margin.bottom;
79
+
80
+ // ---------- 3. 提取字段名称和单位 ----------
81
+ let xField, yField, groupField;
82
+ let xUnit = "", yUnit = "";
83
+
84
+ // 安全提取字段名称
85
+ const xChannel = chartUtils.schema.channel(jsonData, "x");
86
+ const yChannel = chartUtils.schema.channel(jsonData, "y");
87
+ const groupChannel = chartUtils.schema.channel(jsonData, "group");
88
+
89
+ xField = xChannel.key;
90
+ yField = yChannel.key;
91
+ groupField = groupChannel.key;
92
+
93
+ // 获取字段单位
94
+ xUnit = xChannel.unit;
95
+ yUnit = yChannel.unit;
96
+
97
+ // ---------- 4. 数据处理 ----------
98
+ // 使用提供的数据
99
+ let useData = chartData;
100
+
101
+ // 获取x轴和分组的唯一值
102
+ const xValues = [...new Set(useData.map(d => d[xField]))];
103
+ let groupValues = [...new Set(useData.map(d => d[groupField]))];
104
+
105
+ // 如果组的数量不符合要求,给出警告
106
+ if (groupValues.length !== 2) {
107
+ console.warn("此图表需要恰好2个组字段");
108
+ }
109
+
110
+ // 第一个组是左侧柱子,第二个组是右侧柱子
111
+ const leftBarGroup = groupValues[0];
112
+ const rightBarGroup = groupValues[1];
113
+
114
+ // ---------- 5. 创建SVG容器 ----------
115
+ const svg = d3.select(containerSelector)
116
+ .append("svg")
117
+ .attr("width", "100%")
118
+ .attr("height", height)
119
+ .attr("viewBox", `0 0 ${width} ${height}`)
120
+ .attr("style", "max-width: 100%; height: auto;")
121
+ .attr("xmlns", "http://www.w3.org/2000/svg")
122
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
123
+
124
+ // ---------- 6. 创建视觉效果 ----------
125
+ const defs = svg.append("defs");
126
+
127
+ // 如果需要,创建阴影滤镜
128
+ if (variables.has_shadow) {
129
+ const filter = defs.append("filter")
130
+ .attr("id", "shadow")
131
+ .attr("filterUnits", "userSpaceOnUse")
132
+ .attr("width", "200%")
133
+ .attr("height", "200%");
134
+
135
+ filter.append("feGaussianBlur")
136
+ .attr("in", "SourceAlpha")
137
+ .attr("stdDeviation", 3);
138
+
139
+ filter.append("feOffset")
140
+ .attr("dx", 2)
141
+ .attr("dy", 2)
142
+ .attr("result", "offsetblur");
143
+
144
+ const feMerge = filter.append("feMerge");
145
+ feMerge.append("feMergeNode");
146
+ feMerge.append("feMergeNode").attr("in", "SourceGraphic");
147
+ }
148
+
149
+ // *** 添加: 定义斜线纹理模式 ***
150
+ const patternDensity = 6; // 固定斜线密度
151
+ const patternStrokeWidth = 1.5; // 固定斜线宽度
152
+ const groups = [leftBarGroup, rightBarGroup]; // 使用之前确定的分组名称
153
+ const defaultColors = ["#4269d0", "#ff725c"]; // 默认颜色
154
+
155
+ groups.forEach((group, i) => {
156
+ // 为每个组获取颜色
157
+ const groupColor = colorResolver.field(group, 0, { fallback: defaultColors }).value[i % defaultColors.length]; // 使用预设的默认颜色
158
+
159
+ // 创建斜线纹理模式
160
+ const patternId = `pattern-${i === 0 ? 'left' : 'right'}`; // 使用 'left'/'right' 作为 ID 一部分
161
+ const pattern = defs.append("pattern")
162
+ .attr("id", patternId)
163
+ .attr("patternUnits", "userSpaceOnUse")
164
+ .attr("width", patternDensity)
165
+ .attr("height", patternDensity)
166
+ .attr("patternTransform", "rotate(45)");
167
+
168
+ // 添加背景矩形
169
+ pattern.append("rect")
170
+ .attr("width", patternDensity)
171
+ .attr("height", patternDensity)
172
+ .attr("fill", groupColor)
173
+ .attr("opacity", 0.8); // 设置背景透明度
174
+
175
+ // 添加斜线
176
+ pattern.append("line")
177
+ .attr("x1", 0)
178
+ .attr("y1", 0)
179
+ .attr("x2", 0)
180
+ .attr("y2", patternDensity)
181
+ .attr("stroke", "white") // 斜线颜色
182
+ .attr("stroke-width", patternStrokeWidth)
183
+ .attr("opacity", 0.6); // 斜线透明度
184
+ });
185
+ // *** 结束添加纹理 ***
186
+
187
+ // ---------- 7. 创建图表区域 ----------
188
+ const chart = svg.append("g")
189
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
190
+
191
+ // ---------- 8. 创建比例尺 ----------
192
+ // X比例尺(分类)用于主分类
193
+ const xScale = d3.scaleBand()
194
+ .domain(xValues)
195
+ .range([0, innerWidth])
196
+ .padding(0.2);
197
+
198
+ // 分组比例尺,用于每个类别内的细分
199
+ const groupScale = d3.scaleBand()
200
+ .domain([0, 1]) // 只有两个柱子,左侧和右侧
201
+ .range([0, xScale.bandwidth()])
202
+ .padding(0.2); // 同一维度柱子之间的间隙,增加间隔
203
+
204
+ // Y比例尺(数值)- 直接使用最大值映射到可用高度
205
+ const dataMax = d3.max(useData, d => +d[yField]) || 100;
206
+ const yScale = d3.scaleLinear()
207
+ .domain([0, dataMax])
208
+ .range([innerHeight, 0]);
209
+
210
+ // ---------- 9. 文本宽度计算和字体大小调整 (移动到这里,在绘制坐标轴之前) ----------
211
+ // 创建格式化函数
212
+
213
+ // 数值标签的可用宽度 = bar宽度 + 间距
214
+ const barWidth = groupScale.bandwidth();
215
+ const valueSpacing = xScale.bandwidth() * 0.1;
216
+ const valueLabelAvailableWidth = barWidth; // 每个值标签只能占据其柱子的宽度
217
+
218
+ // 维度标签的可用宽度 = 两个bar宽度 + bar间距
219
+ const dimensionLabelAvailableWidth = xScale.bandwidth();
220
+
221
+ // 计算最长的数值标签和维度标签
222
+ let maxValueLabelWidth = 0;
223
+ let maxDimensionLabelWidth = 0;
224
+
225
+ // 计算所有数值标签的最大宽度
226
+ useData.forEach(d => {
227
+ const textWidth = chartUtils.text.measure(svg, chartUtils.format.appendUnit(+d[yField], yUnit) + (yUnit ? ` ${yUnit}` : ''), {
228
+ fontFamily: typography.label.font_family,
229
+ fontSize: typography.label.font_size,
230
+ fontWeight: "bold"
231
+ }).width;
232
+ maxValueLabelWidth = Math.max(maxValueLabelWidth, textWidth);
233
+ });
234
+
235
+ // 计算所有维度标签的最大宽度
236
+ xValues.forEach(xValue => {
237
+ const textWidth = chartUtils.text.measure(svg, xValue, {
238
+ fontFamily: typography.label.font_family,
239
+ fontSize: typography.label.font_size,
240
+ fontWeight: "bold"
241
+ }).width;
242
+ maxDimensionLabelWidth = Math.max(maxDimensionLabelWidth, textWidth);
243
+ });
244
+
245
+ // 计算需要的字体缩放比例
246
+ const valueFontScale = Math.min(1, valueLabelAvailableWidth / maxValueLabelWidth);
247
+ const dimensionFontScale = Math.min(1, dimensionLabelAvailableWidth / maxDimensionLabelWidth);
248
+
249
+ // 计算实际使用的字体大小
250
+ const valueFontSize = Math.max(8, parseInt(typography.label.font_size) * valueFontScale); // 最小字体8px
251
+ const dimensionFontSize = Math.max(8, parseInt(typography.label.font_size) * dimensionFontScale);
252
+
253
+ // 计算动态文本大小的函数
254
+ const calculateFontSize = (text, maxWidth, baseSize = 12) => {
255
+ // 估算每个字符的平均宽度 (假设为baseSize的60%)
256
+ const avgCharWidth = baseSize * 0.6;
257
+ // 计算文本的估计宽度
258
+ const textWidth = text.length * avgCharWidth;
259
+ // 如果文本宽度小于最大宽度,返回基础大小
260
+ if (textWidth < maxWidth) {
261
+ return baseSize;
262
+ }
263
+ // 否则,按比例缩小字体大小
264
+ return Math.max(10, Math.floor(baseSize * (maxWidth / textWidth)));
265
+ };
266
+
267
+ // ---------- 10. 创建坐标轴 (原来的第9步) ----------
268
+ // 创建x轴组,用于添加刻度标签
269
+ const xAxisGroup = chart.append("g")
270
+ .attr("class", "x-axis")
271
+ .attr("transform", `translate(0, ${innerHeight})`);
272
+
273
+ // 第一步:找出最长的标签并计算统一的字体大小
274
+ let maxLabelLength = 0;
275
+ const allLabels = xValues.map(d => d.toString());
276
+
277
+ // 找出最长的标签
278
+ const longestLabel = allLabels.reduce((a, b) => a.length > b.length ? a : b, "");
279
+
280
+ // 使用最长标签计算合适的统一字体大小
281
+ const labelMaxWidth = xScale.bandwidth()*1.3;
282
+ const uniformFontSize = calculateFontSize(longestLabel, labelMaxWidth, parseInt(typography.label.font_size));
283
+
284
+ // 绘制x轴标签
285
+ xAxisGroup.selectAll(".x-label")
286
+ .data(xValues)
287
+ .enter()
288
+ .append("text")
289
+ .attr("class", "x-label")
290
+ .attr("x", d => xScale(d) + xScale.bandwidth() / 2)
291
+ .attr("y", 20)
292
+ .attr("text-anchor", "middle")
293
+ .style("font-family", typography.label.font_family)
294
+ .style("font-size", `${uniformFontSize}px`) // 应用统一的字体大小
295
+ .style("font-weight", typography.label.font_weight)
296
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
297
+ .text(d => d)
298
+ .each(function(d) {
299
+ const text = d3.select(this);
300
+ const labelText = d.toString();
301
+ const labelWidth = chartUtils.text.measure(null, labelText, {
302
+ fontFamily: typography.label.font_family,
303
+ fontSize: uniformFontSize,
304
+ fontWeight: typography.label.font_weight
305
+ }).width;
306
+
307
+ // 检查使用统一字体大小后,文本是否仍然超过可用宽度
308
+ if (labelWidth > labelMaxWidth) {
309
+ // 如果仍然太长,应用文本换行
310
+ wrapText(text, labelText, labelMaxWidth, 1.1);
311
+ }
312
+ });
313
+
314
+ // 文本换行助手函数
315
+ function wrapText(text, str, width, lineHeight) {
316
+ const words = str.split(/\s+/).reverse();
317
+ let word;
318
+ let line = [];
319
+ let lineNumber = 0;
320
+ const y = text.attr("y");
321
+ const dy = parseFloat(text.attr("dy") || 0);
322
+
323
+ // 先清空文本
324
+ text.text(null);
325
+
326
+ // 处理文本
327
+ let tspans = [];
328
+
329
+ // 如果没有空格可分割,按字符分割
330
+ if (words.length <= 1) {
331
+ const chars = str.split('');
332
+ let currentLine = '';
333
+
334
+ for (let i = 0; i < chars.length; i++) {
335
+ currentLine += chars[i];
336
+
337
+ // 测量候选文本宽度
338
+ const isOverflow = chartUtils.text.measure(null, currentLine, {
339
+ fontFamily: text.style("font-family"),
340
+ fontSize: parseFloat(text.style("font-size")),
341
+ fontWeight: text.style("font-weight")
342
+ }).width > width;
343
+
344
+ if (isOverflow && currentLine.length > 1) {
345
+ // 当前行过长,回退一个字符并换行
346
+ currentLine = currentLine.slice(0, -1);
347
+
348
+ // 添加到tspans数组
349
+ tspans.push(currentLine);
350
+
351
+ // 重新开始下一行
352
+ currentLine = chars[i];
353
+ lineNumber++;
354
+ }
355
+ }
356
+
357
+ // 添加最后一行
358
+ if (currentLine.length > 0) {
359
+ tspans.push(currentLine);
360
+ }
361
+ } else {
362
+ // 处理有空格的文本
363
+ let currentLine = [];
364
+
365
+ while (word = words.pop()) {
366
+ currentLine.push(word);
367
+
368
+ // 测量候选文本宽度
369
+ const isOverflow = chartUtils.text.measure(null, currentLine.join(" "), {
370
+ fontFamily: text.style("font-family"),
371
+ fontSize: parseFloat(text.style("font-size")),
372
+ fontWeight: text.style("font-weight")
373
+ }).width > width;
374
+
375
+ if (isOverflow && currentLine.length > 1) {
376
+ // 回退一个词
377
+ currentLine.pop();
378
+
379
+ // 添加到tspans数组
380
+ tspans.push(currentLine.join(" "));
381
+
382
+ // 重新开始下一行
383
+ currentLine = [word];
384
+ lineNumber++;
385
+ }
386
+ }
387
+
388
+ // 添加最后一行
389
+ if (currentLine.length > 0) {
390
+ tspans.push(currentLine.join(" "));
391
+ }
392
+ }
393
+
394
+ // 计算总行数
395
+ const totalLines = tspans.length;
396
+
397
+ // 计算垂���居中的起始位置
398
+ // 对于单行文本,y位置保持不变
399
+ // 对于多行文本,需要向上偏移以保持垂直居中
400
+ let startY = y;
401
+ if (totalLines > 1) {
402
+ // 向上偏移半行距离 * (总行数-1)
403
+ startY = parseFloat(y) - (lineHeight * (totalLines - 1) / 2);
404
+ }
405
+
406
+ // 创建所有行的tspan元素
407
+ tspans.forEach((lineText, i) => {
408
+ text.append("tspan")
409
+ .attr("x", text.attr("x"))
410
+ .attr("y", startY)
411
+ .attr("dy", `${i * lineHeight}em`)
412
+ .text(lineText);
413
+ });
414
+ }
415
+
416
+ // ---------- 11. 绘制图例 (原来的第10步) ----------
417
+ // 创建临时文本元素计算文本宽度
418
+ const legendItems = legendData.map(item => ({
419
+ label: item.key,
420
+ color: item.color,
421
+ }));
422
+ chartUtils.legend.centered(svg, legendItems, {}, width / 2, 30, {
423
+ markerShape: "rect",
424
+ markerSize: 15,
425
+ labelGap: 5,
426
+ itemGap: legendSpacing,
427
+ fontSize: 12,
428
+ fontFamily: typography.label.font_family,
429
+ fontWeight: typography.label.font_weight,
430
+ textColor: colorResolver.text({ fallback: "#333333" }).value,
431
+ markerRadius: variables.has_rounded_corners ? 2 : 0,
432
+ });
433
+
434
+ // 绘制条形图和标签
435
+ xValues.forEach(xValue => {
436
+ // 获取当前x类别的数据
437
+ const xData = useData.filter(d => d[xField] === xValue);
438
+
439
+ // 获取左侧柱子的数据
440
+ const leftBarData = xData.find(d => d[groupField] === leftBarGroup);
441
+
442
+ // 获取右侧柱子的数据
443
+ const rightBarData = xData.find(d => d[groupField] === rightBarGroup);
444
+
445
+ // 计算左侧柱子的位置和高度
446
+ const leftBarX = xScale(xValue);
447
+ let leftBarY, leftBarHeight, leftValue;
448
+
449
+ // --- Variables to track label/bar positions for icon placement ---
450
+ let minBarTopY = innerHeight;
451
+ let minExternalLabelTopY = innerHeight;
452
+ let leftLabelIsOutside = false; // Track label position
453
+ let rightLabelIsOutside = false;
454
+ // -------------------------------------------------------------
455
+
456
+ if (leftBarData) {
457
+ leftValue = leftBarData[yField];
458
+ leftBarHeight = innerHeight - yScale(leftValue);
459
+ leftBarY = yScale(leftValue);
460
+ minBarTopY = Math.min(minBarTopY, leftBarY);
461
+
462
+ // 绘制左侧柱子
463
+ chart.append("rect")
464
+ .attr("class", "bar left-bar")
465
+ .attr("x", leftBarX)
466
+ .attr("y", leftBarY)
467
+ .attr("width", barWidth)
468
+ .attr("height", leftBarHeight)
469
+ .attr("fill", "url(#pattern-left)")
470
+ .attr("rx", barWidth/2)
471
+ .attr("ry", barWidth/2)
472
+ .attr("stroke", variables.has_stroke ? "#555" : "none")
473
+ .attr("stroke-width", variables.has_stroke ? 1 : 0)
474
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "none");
475
+
476
+ // 绘制左侧柱子顶部的标签
477
+ const leftValueText = chartUtils.format.appendUnit(leftValue, yUnit) + (yUnit ? ` ${yUnit}` : '');
478
+ let leftLabelFontSize = valueFontSize; // Start with calculated size
479
+ let leftTextWidth = chartUtils.text.measure(chart, leftValueText, {
480
+ fontFamily: typography.label.font_family,
481
+ fontSize: `${leftLabelFontSize}px`,
482
+ fontWeight: "bold"
483
+ }).width;
484
+ const maxLabelWidth = barWidth * 1.1;
485
+ if (leftTextWidth > maxLabelWidth) {
486
+ leftLabelFontSize = Math.max(4, leftLabelFontSize * (maxLabelWidth / leftTextWidth));
487
+ }
488
+ // *** 修改: 判断标签是否能放入 Bar 内 ***
489
+ const labelHeightRequired = leftLabelFontSize + 10; // 字体高度 + 上下 padding
490
+ let leftLabelY, leftLabelColor, leftDominantBaseline = "auto"; // Default baseline
491
+
492
+ if (leftBarHeight > labelHeightRequired) {
493
+ // --- 放内部 ---
494
+ leftLabelY = leftBarY + 5; // 顶部向下一点
495
+ leftLabelColor = "#ffffff";
496
+ leftDominantBaseline = "hanging"; // 锚定到顶部
497
+ leftLabelIsOutside = false;
498
+ } else {
499
+ // --- 放外部 ---
500
+ leftLabelY = leftBarY - 5; // 柱子顶部向上一点
501
+ leftLabelColor = colorResolver.text({ fallback: "#333333" }).value;
502
+ // leftDominantBaseline remains 'auto' (middle-ish)
503
+ minExternalLabelTopY = Math.min(minExternalLabelTopY, leftLabelY - leftLabelFontSize); // Update highest point if label is outside
504
+ leftLabelIsOutside = true;
505
+ }
506
+
507
+ chart.append("text")
508
+ .attr("class", "bar-label left-label")
509
+ .attr("x", leftBarX + barWidth / 2)
510
+ .attr("y", leftLabelY) // 使用���算的 Y
511
+ .attr("dominant-baseline", leftDominantBaseline) // 设置 baseline
512
+ .attr("text-anchor", "middle")
513
+ .style("font-family", typography.label.font_family)
514
+ .style("font-size", `${leftLabelFontSize}px`)
515
+ .style("font-weight", "bold")
516
+ .style("fill", leftLabelColor) // 使用计算的颜色
517
+ .text(leftValueText);
518
+ } else {
519
+ leftValue = 0;
520
+ leftBarHeight = 0;
521
+ leftBarY = innerHeight;
522
+ }
523
+
524
+ const rightBarX = leftBarX + barWidth + xScale.bandwidth() * 0.1;
525
+ let rightBarY, rightBarHeight, rightValue;
526
+
527
+ if (rightBarData) {
528
+ rightValue = rightBarData[yField];
529
+ rightBarHeight = innerHeight - yScale(rightValue);
530
+ rightBarY = yScale(rightValue);
531
+ minBarTopY = Math.min(minBarTopY, rightBarY);
532
+
533
+ // 绘制右侧柱子
534
+ chart.append("rect")
535
+ .attr("class", "bar right-bar")
536
+ .attr("x", rightBarX)
537
+ .attr("y", rightBarY)
538
+ .attr("width", barWidth)
539
+ .attr("height", rightBarHeight)
540
+ .attr("fill", "url(#pattern-right)")
541
+ .attr("rx", barWidth/2)
542
+ .attr("ry", barWidth/2)
543
+ .attr("stroke", variables.has_stroke ? "#555" : "none")
544
+ .attr("stroke-width", variables.has_stroke ? 1 : 0)
545
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "none");
546
+
547
+ // 绘制右侧柱子顶部的标签
548
+ const rightValueText = chartUtils.format.appendUnit(rightValue, yUnit) + (yUnit ? ` ${yUnit}` : '');
549
+ let rightLabelFontSize = valueFontSize; // Start with calculated size
550
+ let rightTextWidth = chartUtils.text.measure(chart, rightValueText, {
551
+ fontFamily: typography.label.font_family,
552
+ fontSize: `${rightLabelFontSize}px`,
553
+ fontWeight: "bold"
554
+ }).width;
555
+ const rightMaxLabelWidth = barWidth * 1.1;
556
+ if (rightTextWidth > rightMaxLabelWidth) {
557
+ rightLabelFontSize = Math.max(4, rightLabelFontSize * (rightMaxLabelWidth / rightTextWidth));
558
+ }
559
+ // *** 修改: 判断标签是否能放入 Bar 内 ***
560
+ const rightLabelHeightRequired = rightLabelFontSize + 10; // 字体高度 + 上下 padding
561
+ let rightLabelY, rightLabelColor, rightDominantBaseline = "auto";
562
+
563
+ if (rightBarHeight > rightLabelHeightRequired) {
564
+ // --- 放内部 ---
565
+ rightLabelY = rightBarY + 5;
566
+ rightLabelColor = "#ffffff";
567
+ rightDominantBaseline = "hanging";
568
+ rightLabelIsOutside = false;
569
+ } else {
570
+ // --- 放外部 ---
571
+ rightLabelY = rightBarY - 5;
572
+ rightLabelColor = colorResolver.text({ fallback: "#333333" }).value;
573
+ minExternalLabelTopY = Math.min(minExternalLabelTopY, rightLabelY - rightLabelFontSize);
574
+ rightLabelIsOutside = true;
575
+ }
576
+
577
+ chart.append("text")
578
+ .attr("class", "bar-label right-label")
579
+ .attr("x", rightBarX + barWidth / 2)
580
+ .attr("y", rightLabelY) // 使用计算的 Y
581
+ .attr("dominant-baseline", rightDominantBaseline) // 设置 baseline
582
+ .attr("text-anchor", "middle")
583
+ .style("font-family", typography.label.font_family)
584
+ .style("font-size", `${rightLabelFontSize}px`)
585
+ .style("font-weight", "bold")
586
+ .style("fill", rightLabelColor) // 使用计算的颜色
587
+ .text(rightValueText);
588
+ } else {
589
+ rightValue = 0;
590
+ rightBarHeight = 0;
591
+ rightBarY = innerHeight;
592
+ }
593
+
594
+ // --- 图标绘制逻辑 ---
595
+ const iconMargin = 5; // 图标与上方元素的间距
596
+ const iconSize = 30; // 保持图标大小
597
+
598
+ // 确定图标需要放置的最高点 (取 bar 顶部和外部标签顶部的最小值)
599
+ const placementRefY = Math.min(minBarTopY, minExternalLabelTopY);
600
+
601
+ // 计算图标 Y 坐标 (使其位于最高点上方)
602
+ const iconY = placementRefY - iconMargin - iconSize / 2 - 10;
603
+
604
+ // 获取图标 URL (假设 xValue 对应图标)
605
+ if (images.field && images.field[xValue]) {
606
+ const iconX = xScale(xValue) + xScale.bandwidth() / 2; // 中心 X
607
+
608
+ chart.append("image")
609
+ .attr("class", "category-icon-above") // 新类名?
610
+ .attr("x", iconX - iconSize / 2)
611
+ .attr("y", iconY) // 使用计算出的 Y 坐标
612
+ .attr("width", iconSize)
613
+ .attr("height", iconSize)
614
+ .attr("preserveAspectRatio", "xMidYMid meet")
615
+ .attr("xlink:href", images.field[xValue]);
616
+ }
617
+ // --- 结束图标绘制逻辑 ---
618
+
619
+ }); // --- END xValues.forEach ---
620
+
621
+ // 返回SVG节点
622
+ return svg.node();
623
+ }
modules/chart_engine/template/d3-js/type3_vertical_group_bar_chart/vertical_group_bar_plain_chart_03.js ADDED
@@ -0,0 +1,641 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Vertical Group Bar Chart",
5
+ "chart_name": "vertical_group_bar_plain_chart_03",
6
+ "is_composite": false,
7
+ "required_fields": ["x", "y", "group"],
8
+ "required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
9
+ "required_fields_range": [[2, 20], [0, "inf"], [2, 2]],
10
+ "required_fields_icons": ["x"],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": [],
13
+ "required_other_colors": ["primary"],
14
+ "supported_effects": ["shadow", "radius_corner", "gradient", "stroke", "spacing"],
15
+ "min_height": 400,
16
+ "min_width": 400,
17
+ "background": "no",
18
+ "icon_mark": "none",
19
+ "icon_label": "none",
20
+ "has_x_axis": "yes",
21
+ "has_y_axis": "no"
22
+ }
23
+ REQUIREMENTS_END
24
+ */
25
+
26
+ // 垂直分组条形图实现 Vertical Grouped Bar Chart plain chart#3 bar顶端三角
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 typography = jsonData.typography || {
35
+ title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
36
+ label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
37
+ description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
38
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
39
+ };
40
+ const colors = jsonData.colors || {
41
+ text_color: "#333333",
42
+ field: {},
43
+ other: {
44
+ primary: "#4682B4" // 默认主色调
45
+ }
46
+ };
47
+ const images = jsonData.images || { field: {}, other: {} };
48
+ const dataColumns = chartUtils.schema.columns(jsonData);
49
+
50
+ // 如果不存在,添加副标题字段
51
+ typography.subtitle = typography.subtitle || typography.description;
52
+
53
+ // 设置视觉效果变量
54
+ variables.has_rounded_corners = variables.has_rounded_corners || false;
55
+ variables.has_shadow = variables.has_shadow || false;
56
+ variables.has_gradient = variables.has_gradient || false;
57
+ variables.has_stroke = variables.has_stroke || false;
58
+ variables.has_spacing = variables.has_spacing || false;
59
+
60
+ // 清除容器
61
+ d3.select(containerSelector).html("");
62
+
63
+ // 添加数值格式化函数
64
+
65
+ // ---------- 2. 尺寸和布局设置 ----------
66
+ // 设置图表尺寸和边距
67
+ const width = variables.width || 800;
68
+ const height = variables.height || 500;
69
+
70
+ // 边距:上,右,下,左
71
+ const margin = {
72
+ top: 100, // 标题和标签的空间
73
+ right: 30, // 右侧标签的空间
74
+ bottom: 80, // x轴和标签的空间
75
+ left: 30 // y轴和标签的空间
76
+ };
77
+
78
+ // 计算实际绘图区域大小
79
+ const innerWidth = width - margin.left - margin.right;
80
+ const innerHeight = height - margin.top - margin.bottom;
81
+
82
+ // ---------- 3. 提取字段名称和单位 ----------
83
+ let xField, yField, groupField;
84
+ let xUnit = "", yUnit = "";
85
+
86
+ // 安全提取字段名称
87
+ const xChannel = chartUtils.schema.channel(jsonData, "x");
88
+ const yChannel = chartUtils.schema.channel(jsonData, "y");
89
+ const groupChannel = chartUtils.schema.channel(jsonData, "group");
90
+
91
+ xField = xChannel.key;
92
+ yField = yChannel.key;
93
+ groupField = groupChannel.key;
94
+
95
+ // 获取字段单位
96
+ xUnit = xChannel.unit;
97
+ yUnit = yChannel.unit;
98
+
99
+ // ---------- 4. 数据处理 ----------
100
+ // 使用提供的数据
101
+ let useData = chartData;
102
+
103
+ // 获取x轴和分组的唯一值
104
+ const xValues = [...new Set(useData.map(d => d[xField]))];
105
+ let groupValues = [...new Set(useData.map(d => d[groupField]))];
106
+
107
+ // 如果组的数量不符合要求,给出警告
108
+ if (groupValues.length !== 2) {
109
+ console.warn("此图表需要恰好2个组字段");
110
+ }
111
+
112
+ // 第一个组是左侧柱子,第二个组是右侧柱子
113
+ const leftBarGroup = groupValues[0];
114
+ const rightBarGroup = groupValues[1];
115
+
116
+ // ---------- 5. 创建SVG容器 ----------
117
+ const svg = d3.select(containerSelector)
118
+ .append("svg")
119
+ .attr("width", "100%")
120
+ .attr("height", height)
121
+ .attr("viewBox", `0 0 ${width} ${height}`)
122
+ .attr("style", "max-width: 100%; height: auto;")
123
+ .attr("xmlns", "http://www.w3.org/2000/svg")
124
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
125
+
126
+ // ---------- 6. 创建视觉效果 ----------
127
+ const defs = svg.append("defs");
128
+
129
+ // 如果需要,创建阴影滤镜
130
+ if (variables.has_shadow) {
131
+ const filter = defs.append("filter")
132
+ .attr("id", "shadow")
133
+ .attr("filterUnits", "userSpaceOnUse")
134
+ .attr("width", "200%")
135
+ .attr("height", "200%");
136
+
137
+ filter.append("feGaussianBlur")
138
+ .attr("in", "SourceAlpha")
139
+ .attr("stdDeviation", 3);
140
+
141
+ filter.append("feOffset")
142
+ .attr("dx", 2)
143
+ .attr("dy", 2)
144
+ .attr("result", "offsetblur");
145
+
146
+ const feMerge = filter.append("feMerge");
147
+ feMerge.append("feMergeNode");
148
+ feMerge.append("feMergeNode").attr("in", "SourceGraphic");
149
+ }
150
+
151
+ // ---------- 7. 创建图表区域 ----------
152
+ const chart = svg.append("g")
153
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
154
+
155
+ // ---------- 8. 创建比例尺 ----------
156
+ // X比例尺(分类)用于主分类
157
+ const xScale = d3.scaleBand()
158
+ .domain(xValues)
159
+ .range([0, innerWidth])
160
+ .padding(0.2);
161
+
162
+ // 分组比例尺,用于每个类别内的细分
163
+ const groupScale = d3.scaleBand()
164
+ .domain([0, 1]) // 只有两个柱子,左侧和右侧
165
+ .range([0, xScale.bandwidth()])
166
+ .padding(0.2); // 同一维度柱子之间的间隙,增加间隔
167
+
168
+ // Y比例尺(数值)- 直接使用最大值映射到可用高度
169
+ const dataMax = d3.max(useData, d => +d[yField]) || 100;
170
+ const yScale = d3.scaleLinear()
171
+ .domain([0, dataMax])
172
+ .range([innerHeight, 0]);
173
+
174
+ // ---------- 9. 文本宽度计算和字体大小调整 (移动到这里,在绘制坐标轴之前) ----------
175
+ // 数值标签的可用宽度 = bar宽度 + 间距
176
+ const barWidth = groupScale.bandwidth();
177
+ const valueSpacing = xScale.bandwidth() * 0.1;
178
+ const valueLabelAvailableWidth = barWidth; // 每个值标签只能占据其柱子的宽度
179
+
180
+ // 维度标签的可用宽度 = 两个bar宽度 + bar间距
181
+ const dimensionLabelAvailableWidth = xScale.bandwidth();
182
+
183
+ // 计算最长的数值标签和维度标签
184
+ let maxValueLabelWidth = 0;
185
+ let maxDimensionLabelWidth = 0;
186
+
187
+ // 计算所有数值标签的最大宽度
188
+ useData.forEach(d => {
189
+ const textWidth = chartUtils.text.measure(svg, chartUtils.format.autoText(+d[yField]) + (yUnit ? ` ${yUnit}` : ''), {
190
+ fontFamily: typography.label.font_family,
191
+ fontSize: typography.label.font_size,
192
+ fontWeight: "bold"
193
+ }).width;
194
+ maxValueLabelWidth = Math.max(maxValueLabelWidth, textWidth);
195
+ });
196
+
197
+ // 计算所有维度标签的最大宽度
198
+ xValues.forEach(xValue => {
199
+ const textWidth = chartUtils.text.measure(svg, xValue, {
200
+ fontFamily: typography.label.font_family,
201
+ fontSize: typography.label.font_size,
202
+ fontWeight: "bold"
203
+ }).width;
204
+ maxDimensionLabelWidth = Math.max(maxDimensionLabelWidth, textWidth);
205
+ });
206
+
207
+ // 计算需要的字体缩放比例
208
+ const valueFontScale = Math.min(1, valueLabelAvailableWidth / maxValueLabelWidth);
209
+ const dimensionFontScale = Math.min(1, dimensionLabelAvailableWidth / maxDimensionLabelWidth);
210
+
211
+ // 计算实际使用的字体大小
212
+ const valueFontSize = Math.max(8, parseInt(typography.label.font_size) * valueFontScale); // 最小字体8px
213
+ const dimensionFontSize = Math.max(8, parseInt(typography.label.font_size) * dimensionFontScale);
214
+
215
+ // 计算动态文本大小的函数
216
+ const calculateFontSize = (text, maxWidth, baseSize = 12) => {
217
+ // 估算每个字符的平均宽度 (假设为baseSize的60%)
218
+ const avgCharWidth = baseSize * 0.6;
219
+ // 计算文本的估计宽度
220
+ const textWidth = text.length * avgCharWidth;
221
+ // 如果文本宽度小于最大宽度,返回基础大小
222
+ if (textWidth < maxWidth) {
223
+ return baseSize;
224
+ }
225
+ // 否则,按比例缩小字体大小
226
+ return Math.max(10, Math.floor(baseSize * (maxWidth / textWidth)));
227
+ };
228
+
229
+ // ---------- 10. 创建坐标轴 (原来的第9步) ----------
230
+ // 底部的X轴(仅一条长线)
231
+ chart.append("line")
232
+ .attr("x1", 0)
233
+ .attr("y1", innerHeight)
234
+ .attr("x2", innerWidth)
235
+ .attr("y2", innerHeight)
236
+ .attr("stroke", "black")
237
+ .attr("stroke-width", 2);
238
+
239
+ // 创建x轴组,用于添加刻度标签
240
+ const xAxisGroup = chart.append("g")
241
+ .attr("class", "x-axis")
242
+ .attr("transform", `translate(0, ${innerHeight})`);
243
+
244
+ // 第一步:找出最长的标签并计算统一的字体大小
245
+ let maxLabelLength = 0;
246
+ const allLabels = xValues.map(d => d.toString());
247
+
248
+ // 找出最长的标签
249
+ const longestLabel = allLabels.reduce((a, b) => a.length > b.length ? a : b, "");
250
+
251
+ // 使用最长标签计算合适的统一字体大小
252
+ const labelMaxWidth = xScale.bandwidth()*1.3;
253
+ const uniformFontSize = calculateFontSize(longestLabel, labelMaxWidth, parseInt(typography.label.font_size));
254
+
255
+ // 绘制x轴标签
256
+ xAxisGroup.selectAll(".x-label")
257
+ .data(xValues)
258
+ .enter()
259
+ .append("text")
260
+ .attr("class", "x-label")
261
+ .attr("x", d => xScale(d) + xScale.bandwidth() / 2)
262
+ .attr("y", 20)
263
+ .attr("text-anchor", "middle")
264
+ .style("font-family", typography.label.font_family)
265
+ .style("font-size", `${uniformFontSize}px`) // 应用统一的字体大小
266
+ .style("font-weight", typography.label.font_weight)
267
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
268
+ .text(d => d)
269
+ .each(function(d) {
270
+ const text = d3.select(this);
271
+ const labelText = d.toString();
272
+ const labelWidth = chartUtils.text.measure(null, labelText, {
273
+ fontFamily: typography.label.font_family,
274
+ fontSize: uniformFontSize,
275
+ fontWeight: typography.label.font_weight
276
+ }).width;
277
+
278
+ // 检查使用统一字体大小后,文本是否仍然超过可用宽度
279
+ if (labelWidth > labelMaxWidth) {
280
+ // 如果仍然太长,应用文本换行
281
+ wrapText(text, labelText, labelMaxWidth, 1.1);
282
+ }
283
+ });
284
+
285
+ // 文本换行助手函数
286
+ function wrapText(text, str, width, lineHeight) {
287
+ const words = str.split(/\s+/).reverse();
288
+ let word;
289
+ let line = [];
290
+ let lineNumber = 0;
291
+ const y = text.attr("y");
292
+ const dy = parseFloat(text.attr("dy") || 0);
293
+
294
+ // 先清空文本
295
+ text.text(null);
296
+
297
+ // 处理文本
298
+ let tspans = [];
299
+
300
+ // 如果没有空格可分割,按字符分割
301
+ if (words.length <= 1) {
302
+ const chars = str.split('');
303
+ let currentLine = '';
304
+
305
+ for (let i = 0; i < chars.length; i++) {
306
+ currentLine += chars[i];
307
+
308
+ // 测量候选文本宽度
309
+ const isOverflow = chartUtils.text.measure(null, currentLine, {
310
+ fontFamily: text.style("font-family"),
311
+ fontSize: parseFloat(text.style("font-size")),
312
+ fontWeight: text.style("font-weight")
313
+ }).width > width;
314
+
315
+ if (isOverflow && currentLine.length > 1) {
316
+ // 当前行过长,回退一个字符并换行
317
+ currentLine = currentLine.slice(0, -1);
318
+
319
+ // 添加到tspans数组
320
+ tspans.push(currentLine);
321
+
322
+ // 重新开始下一行
323
+ currentLine = chars[i];
324
+ lineNumber++;
325
+ }
326
+ }
327
+
328
+ // 添加最后一行
329
+ if (currentLine.length > 0) {
330
+ tspans.push(currentLine);
331
+ }
332
+ } else {
333
+ // 处理有空格的文本
334
+ let currentLine = [];
335
+
336
+ while (word = words.pop()) {
337
+ currentLine.push(word);
338
+
339
+ // 测量候选文本宽度
340
+ const isOverflow = chartUtils.text.measure(null, currentLine.join(" "), {
341
+ fontFamily: text.style("font-family"),
342
+ fontSize: parseFloat(text.style("font-size")),
343
+ fontWeight: text.style("font-weight")
344
+ }).width > width;
345
+
346
+ if (isOverflow && currentLine.length > 1) {
347
+ // 回退一个词
348
+ currentLine.pop();
349
+
350
+ // 添加到tspans数组
351
+ tspans.push(currentLine.join(" "));
352
+
353
+ // 重新开始下一行
354
+ currentLine = [word];
355
+ lineNumber++;
356
+ }
357
+ }
358
+
359
+ // 添加最后一行
360
+ if (currentLine.length > 0) {
361
+ tspans.push(currentLine.join(" "));
362
+ }
363
+ }
364
+
365
+ // 计算总行数
366
+ const totalLines = tspans.length;
367
+
368
+ // 计算垂直居中的起始位置
369
+ // 对于单行文本,y位置保持不变
370
+ // 对于多行文本,需要向上偏移以保持垂直居中
371
+ let startY = y;
372
+ if (totalLines > 1) {
373
+ // 向上偏移半行距离 * (总行数-1)
374
+ startY = parseFloat(y) - (lineHeight * (totalLines - 1) / 2);
375
+ }
376
+
377
+ // 创建所有行的tspan元素
378
+ tspans.forEach((lineText, i) => {
379
+ text.append("tspan")
380
+ .attr("x", text.attr("x"))
381
+ .attr("y", startY)
382
+ .attr("dy", `${i * lineHeight}em`)
383
+ .text(lineText);
384
+ });
385
+ }
386
+
387
+ // ---------- 11. 绘制图例 (原来的第10步) ----------
388
+ // 创建临时文本元素计算文本宽度
389
+ const legendItems = legendData.map(item => ({
390
+ label: item.key,
391
+ color: item.color,
392
+ }));
393
+ chartUtils.legend.centered(svg, legendItems, {}, width / 2, 30, {
394
+ markerShape: "rect",
395
+ markerSize: 15,
396
+ labelGap: 5,
397
+ itemGap: legendSpacing,
398
+ fontSize: 12,
399
+ fontFamily: typography.label.font_family,
400
+ fontWeight: typography.label.font_weight,
401
+ textColor: colorResolver.text({ fallback: "#333333" }).value,
402
+ markerRadius: variables.has_rounded_corners ? 2 : 0,
403
+ });
404
+
405
+ // 计算图标大小 - 基于柱子宽度,但不受圆形限制
406
+ const iconSize = Math.min(barWidth * 0.8, 30); // 图标大小为柱子宽度的80%,最大30px
407
+
408
+ // 使用setTimeout确保DOM更新完成,从而获取精确的标签边界框
409
+ setTimeout(() => {
410
+ // 精确计算维度标签的最下方位置并绘制图标
411
+ let actualMaxLabelBottomY = Number.NEGATIVE_INFINITY;
412
+ const labelBottomOffset = labelNode => {
413
+ const label = d3.select(labelNode);
414
+ const fontSize = parseFloat(label.style("font-size")) || uniformFontSize;
415
+ const tspans = label.selectAll("tspan").nodes();
416
+ if (tspans.length > 0) {
417
+ const lastTspan = d3.select(tspans[tspans.length - 1]);
418
+ const y = parseFloat(lastTspan.attr("y") || label.attr("y") || 0);
419
+ const dy = parseFloat(lastTspan.attr("dy") || 0);
420
+ return y + dy * fontSize + fontSize * 0.3;
421
+ }
422
+ return (parseFloat(label.attr("y") || 0) || 0) + fontSize * 0.3;
423
+ };
424
+
425
+ const xLabels = chart.selectAll(".x-label").nodes(); // 获取DOM节点数组
426
+ if (xLabels.length > 0) {
427
+ xLabels.forEach(labelNode => {
428
+ try {
429
+ const absoluteLabelBottom = innerHeight + labelBottomOffset(labelNode);
430
+ actualMaxLabelBottomY = Math.max(actualMaxLabelBottomY, absoluteLabelBottom);
431
+ } catch (e) {
432
+ console.warn("无法获取标签边界框:", e);
433
+ }
434
+ });
435
+ } else {
436
+ // 如果没有标签,提供一个默认的回退位置
437
+ actualMaxLabelBottomY = innerHeight + 20; // 默认在x轴下方20px
438
+ }
439
+
440
+ // 如果由于某种原因actualMaxLabelBottomY仍然是NEGATIVE_INFINITY,则使用默认值
441
+ if (actualMaxLabelBottomY === Number.NEGATIVE_INFINITY) {
442
+ actualMaxLabelBottomY = innerHeight + 20;
443
+ }
444
+
445
+ // 图标顶部的Y位置 = 计算出的标签最下方 + 5px间距
446
+ const iconTopY = actualMaxLabelBottomY + 5;
447
+
448
+ // 绘制所有图标
449
+ xValues.forEach(xValue => {
450
+ if (images.field && images.field[xValue]) {
451
+ const iconX = xScale(xValue) + xScale.bandwidth() / 2;
452
+ chart.append("image")
453
+ .attr("class", "category-icon")
454
+ .attr("x", iconX - iconSize / 2)
455
+ .attr("y", iconTopY) // 图标的顶部Y坐标
456
+ .attr("width", iconSize)
457
+ .attr("height", iconSize)
458
+ .attr("preserveAspectRatio", "xMidYMid meet")
459
+ .attr("xlink:href", images.field[xValue]);
460
+ }
461
+ });
462
+ }, 0);
463
+
464
+ // 绘制条形图和标签
465
+ xValues.forEach(xValue => {
466
+ // 获取当前x类别的数据
467
+ const xData = useData.filter(d => d[xField] === xValue);
468
+
469
+ // 获取左侧柱子的数据
470
+ const leftBarData = xData.find(d => d[groupField] === leftBarGroup);
471
+
472
+ // 获取右侧柱子的数据
473
+ const rightBarData = xData.find(d => d[groupField] === rightBarGroup);
474
+
475
+ // 计算左侧柱子的位置和高度
476
+ const leftBarX = xScale(xValue);
477
+ let leftBarY, leftBarHeight, leftValue;
478
+
479
+ if (leftBarData) {
480
+ leftValue = leftBarData[yField];
481
+ leftBarHeight = innerHeight - yScale(leftValue);
482
+ leftBarY = yScale(leftValue);
483
+
484
+ // 绘制左侧柱子 - 改为path以添加三角形顶部
485
+ // 如果高度为0,跳过绘制
486
+ if (leftBarHeight > 0) {
487
+ // 三角形的高度,设置为条形图宽度,但最小10px,最大30px
488
+ const leftTriangleHeight = Math.min(30, Math.max(10, barWidth));
489
+ // 确保三角形高度不超过总高度
490
+ const leftActualTriangleHeight = Math.min(leftTriangleHeight, leftBarHeight);
491
+
492
+ chart.append("path")
493
+ .attr("class", "bar left-bar")
494
+ .attr("d", () => {
495
+ // 构建路径 - 从左下角开始
496
+ let path = `M ${leftBarX} ${innerHeight}`;
497
+ // 到左上角(矩形顶部)
498
+ path += ` L ${leftBarX} ${leftBarY + leftActualTriangleHeight}`;
499
+ // 到三角形左侧点
500
+ path += ` L ${leftBarX} ${leftBarY + leftActualTriangleHeight}`;
501
+ // 到三角形顶点
502
+ path += ` L ${leftBarX + barWidth / 2} ${leftBarY}`;
503
+ // 到三角形右侧点
504
+ path += ` L ${leftBarX + barWidth} ${leftBarY + leftActualTriangleHeight}`;
505
+ // 到右上角(矩形顶部)
506
+ path += ` L ${leftBarX + barWidth} ${leftBarY + leftActualTriangleHeight}`;
507
+ // 到右下角
508
+ path += ` L ${leftBarX + barWidth} ${innerHeight}`;
509
+ // 闭合路径
510
+ path += ` Z`;
511
+ return path;
512
+ })
513
+ .attr("fill", colorResolver.field(leftBarGroup, 0, { fallback: "#4269d0" }).value)
514
+ .attr("stroke", variables.has_stroke ? "#555" : "none")
515
+ .attr("stroke-width", variables.has_stroke ? 1 : 0)
516
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "none");
517
+ }
518
+
519
+ // 绘制左侧柱子顶部的标签
520
+ // 首先计算标签文本宽度并调整字体大小
521
+ const leftValueText = chartUtils.format.autoText(leftValue) + (yUnit ? ` ${yUnit}` : '');
522
+ let leftLabelFontSize = valueFontSize; // 默认使用之前计算的字体大小
523
+
524
+ let leftTextWidth = chartUtils.text.measure(chart, leftValueText, {
525
+ fontFamily: typography.label.font_family,
526
+ fontSize: `${leftLabelFontSize}px`,
527
+ fontWeight: "bold"
528
+ }).width;
529
+ // 最大允许宽度为柱子宽度的1.1倍
530
+ const maxLabelWidth = barWidth * 1.1;
531
+
532
+ // 如果文本宽度超过允许值,动态缩小字体
533
+ if (leftTextWidth > maxLabelWidth) {
534
+ // 按比例计算新字体大小
535
+ leftLabelFontSize = Math.max(4, leftLabelFontSize * (maxLabelWidth / leftTextWidth));
536
+ leftTextWidth = chartUtils.text.measure(chart, leftValueText, {
537
+ fontFamily: typography.label.font_family,
538
+ fontSize: `${leftLabelFontSize}px`,
539
+ fontWeight: "bold"
540
+ }).width;
541
+ }
542
+
543
+ // 使用调整后的字体大小绘制标签
544
+ chart.append("text")
545
+ .attr("class", "bar-label")
546
+ .attr("x", leftBarX + barWidth / 2)
547
+ .attr("y", leftBarY - 5)
548
+ .attr("text-anchor", "middle")
549
+ .style("font-family", typography.label.font_family)
550
+ .style("font-size", `${leftLabelFontSize}px`) // 使用动态调整的字体大小
551
+ .style("font-weight", "bold")
552
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
553
+ .text(leftValueText);
554
+ }
555
+
556
+ // 计算右侧柱子的位置和高度
557
+ // 使用groupScale正确计算右侧柱子的位置,确保两个柱子之间有间距
558
+ const rightBarX = leftBarX + barWidth + xScale.bandwidth() * 0.1; // 添加额外间距为柱子宽度的10%
559
+ let rightBarY, rightBarHeight, rightValue;
560
+
561
+ if (rightBarData) {
562
+ rightValue = rightBarData[yField];
563
+ rightBarHeight = innerHeight - yScale(rightValue);
564
+ rightBarY = yScale(rightValue);
565
+
566
+ // 绘制右侧柱子 - 改为path以添加三角形顶部
567
+ // 如果高度为0,跳过绘制
568
+ if (rightBarHeight > 0) {
569
+ // 三角形的高度,设置为条形图宽度,但最小10px,最大30px
570
+ const rightTriangleHeight = Math.min(30, Math.max(10, barWidth));
571
+ // 确保三角形高度不超过总高度
572
+ const rightActualTriangleHeight = Math.min(rightTriangleHeight, rightBarHeight);
573
+
574
+ chart.append("path")
575
+ .attr("class", "bar right-bar")
576
+ .attr("d", () => {
577
+ // 构建路径 - 从左下角开始
578
+ let path = `M ${rightBarX} ${innerHeight}`;
579
+ // 到左上角(矩形顶部)
580
+ path += ` L ${rightBarX} ${rightBarY + rightActualTriangleHeight}`;
581
+ // 到三角形左侧点
582
+ path += ` L ${rightBarX} ${rightBarY + rightActualTriangleHeight}`;
583
+ // 到三角形顶点
584
+ path += ` L ${rightBarX + barWidth / 2} ${rightBarY}`;
585
+ // 到三角形右侧点
586
+ path += ` L ${rightBarX + barWidth} ${rightBarY + rightActualTriangleHeight}`;
587
+ // 到右上角(矩形顶部)
588
+ path += ` L ${rightBarX + barWidth} ${rightBarY + rightActualTriangleHeight}`;
589
+ // 到右下角
590
+ path += ` L ${rightBarX + barWidth} ${innerHeight}`;
591
+ // 闭合路径
592
+ path += ` Z`;
593
+ return path;
594
+ })
595
+ .attr("fill", colorResolver.field(rightBarGroup, 0, { fallback: "#ff725c" }).value)
596
+ .attr("stroke", variables.has_stroke ? "#555" : "none")
597
+ .attr("stroke-width", variables.has_stroke ? 1 : 0)
598
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "none");
599
+ }
600
+
601
+ // 绘制右侧柱子顶部的��签
602
+ // 首先计算标签文本宽度并调整字体大小
603
+ const rightValueText = chartUtils.format.autoText(rightValue) + (yUnit ? ` ${yUnit}` : '');
604
+ let rightLabelFontSize = valueFontSize; // 默认使用之前计算的字体大小
605
+
606
+ let rightTextWidth = chartUtils.text.measure(chart, rightValueText, {
607
+ fontFamily: typography.label.font_family,
608
+ fontSize: `${rightLabelFontSize}px`,
609
+ fontWeight: "bold"
610
+ }).width;
611
+ // 最大允许宽度为柱子宽度的1.1倍
612
+ const rightMaxLabelWidth = barWidth * 1.1;
613
+
614
+ // 如果文本宽度超过允许值,动态缩小字体
615
+ if (rightTextWidth > rightMaxLabelWidth) {
616
+ // 按比例计算新字体大小
617
+ rightLabelFontSize = Math.max(4, rightLabelFontSize * (rightMaxLabelWidth / rightTextWidth));
618
+ rightTextWidth = chartUtils.text.measure(chart, rightValueText, {
619
+ fontFamily: typography.label.font_family,
620
+ fontSize: `${rightLabelFontSize}px`,
621
+ fontWeight: "bold"
622
+ }).width;
623
+ }
624
+
625
+ // 使用调整后的字体大小绘制标签
626
+ chart.append("text")
627
+ .attr("class", "bar-label")
628
+ .attr("x", rightBarX + barWidth / 2)
629
+ .attr("y", rightBarY - 5)
630
+ .attr("text-anchor", "middle")
631
+ .style("font-family", typography.label.font_family)
632
+ .style("font-size", `${rightLabelFontSize}px`) // 使用动态调整的字体大小
633
+ .style("font-weight", "bold")
634
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
635
+ .text(rightValueText);
636
+ }
637
+ });
638
+
639
+ // 返回SVG节点
640
+ return svg.node();
641
+ }
modules/chart_engine/template/d3-js/type3_vertical_group_bar_chart/vertical_group_bar_plain_chart_04.js ADDED
@@ -0,0 +1,468 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Vertical Grouped Bar Chart",
5
+ "chart_name": "vertical_group_bar_plain_chart_04",
6
+ "is_composite": false,
7
+ "required_fields": ["x", "y", "group"],
8
+ "required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
9
+ "required_fields_range": [[2, 5], [0, "inf"], [2, 3]],
10
+ "required_fields_icons": [],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": ["group"],
13
+ "required_other_colors": ["primary"],
14
+ "supported_effects": [],
15
+ "min_height": 400,
16
+ "min_width": 600,
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, dataJSON) {
28
+
29
+ /* ============ 1. 字段检查 ============ */
30
+ const xChannel = chartUtils.schema.channel(dataJSON, "x", { fallbackKey: "" });
31
+ const yChannel = chartUtils.schema.channel(dataJSON, "y", { fallbackKey: "" });
32
+ const groupChannel = chartUtils.schema.channel(dataJSON, "group", { fallbackKey: "" });
33
+ const xField = xChannel.key;
34
+ const yField = yChannel.key;
35
+ const groupField = groupChannel.key;
36
+ const yUnit = yChannel.unit;
37
+ if(!xField || !yField || !groupField){
38
+ d3.select(containerSelector).html('<div style="color:red">缺少必要字段</div>');
39
+ return;
40
+ }
41
+
42
+ const raw = dataJSON.data.data.filter(d=>+d[yField]>0);
43
+ if(!raw.length){
44
+ d3.select(containerSelector).html('<div>无有效数据</div>');
45
+ return;
46
+ }
47
+
48
+ /* ============ 2. 尺寸与比例尺 ============ */
49
+ const fullW = dataJSON.variables?.width || 600;
50
+ const fullH = dataJSON.variables?.height || 400;
51
+ const margin = { top: 80, right: 40, bottom: 80, left: 40 }; // 边距调整,增加底部和顶部空间
52
+ const W = fullW - margin.left - margin.right; // 绘图区域宽度
53
+ const H = fullH - margin.top - margin.bottom; // 绘图区域高度
54
+ const colorResolver = chartUtils.color.resolver(dataJSON);
55
+
56
+ // 获取主颜色
57
+ const primaryColor = colorResolver.other("primary", { fallback: "#C13C37" }).value; // 默认为红色
58
+
59
+ // 数据处理
60
+ // 获取所有唯一的x值和group值
61
+ const xValues = Array.from(new Set(raw.map(d => d[xField])));
62
+ const groupValues = Array.from(new Set(raw.map(d => d[groupField])));
63
+
64
+ // 计算每个分组的最大值,用于比例尺
65
+ const maxValue = d3.max(raw, d => +d[yField]);
66
+
67
+ // 计算分组间距和条形宽度
68
+ const xGroupScale = d3.scaleBand()
69
+ .domain(xValues)
70
+ .range([0, W])
71
+ .padding(0.2);
72
+
73
+ const xBarScale = d3.scaleBand()
74
+ .domain(groupValues)
75
+ .range([0, xGroupScale.bandwidth()])
76
+ .padding(0.1);
77
+
78
+ // 高度比例尺,留出上方空间给数值标签
79
+ const yScale = d3.scaleLinear()
80
+ .domain([0, maxValue])
81
+ .range([H, 50]);
82
+
83
+ /* ============ 3. 绘图 ============ */
84
+ d3.select(containerSelector).html(""); // 清空容器
85
+
86
+ // 创建 SVG 画布
87
+ const svg = d3.select(containerSelector)
88
+ .append("svg")
89
+ .attr("width", "100%") // 宽度占满容器
90
+ .attr("height", fullH) // 高度固定
91
+ .attr("viewBox", `0 0 ${fullW} ${fullH}`) // 设置视窗
92
+ .attr("preserveAspectRatio", "xMidYMid meet") // 保持宽高比
93
+ .style("max-width", "100%") // 最大宽度
94
+ .style("height", "auto") // 高度自适应
95
+ .attr("xmlns", "http://www.w3.org/2000/svg")
96
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
97
+
98
+ // 创建阴影效果滤镜
99
+ const defs = svg.append("defs");
100
+ const shadowFilter = defs.append("filter")
101
+ .attr("id", "bar-shadow")
102
+ .attr("width", "150%")
103
+ .attr("height", "150%");
104
+
105
+ // 添加阴影效果
106
+ shadowFilter.append("feDropShadow")
107
+ .attr("dx", "2") // 水平偏移
108
+ .attr("dy", "2") // 垂直偏移
109
+ .attr("stdDeviation", "2") // 模糊度
110
+ .attr("flood-color", "rgba(0,0,0,0.3)") // 阴影颜色
111
+ .attr("flood-opacity", "0.4"); // 阴影不透明度
112
+
113
+ // 创建主绘图区域 <g> 元素,应用边距
114
+ const g = svg.append("g")
115
+ .attr("transform", `translate(${margin.left},${margin.top})`);
116
+
117
+ /* ---- 文本和样式设置 ---- */
118
+ // 提取字体排印设置,提供默认值
119
+ const valueFontFamily = dataJSON.typography?.annotation?.font_family || 'Arial';
120
+ const valueFontSize = parseFloat(dataJSON.typography?.annotation?.font_size || '12'); // 数值标签字号
121
+ const valueFontWeight = dataJSON.typography?.annotation?.font_weight || 'bold'; // 数值标签字重
122
+ const categoryFontFamily = dataJSON.typography?.label?.font_family || 'Arial';
123
+ const categoryFontSize = 11;
124
+ const categoryFontWeight = dataJSON.typography?.label?.font_weight || 'normal'; // 维度��签字重
125
+
126
+ // 辅助函数 - 使用canvas测量文本宽度
127
+ const canvas = document.createElement('canvas');
128
+ const ctx = canvas.getContext('2d');
129
+ function measureTextWidth(text, fontFamily, fontSize, fontWeight) {
130
+ return chartUtils.text.measure(null, text, { fontFamily: fontFamily, fontSize: fontSize, fontWeight: fontWeight }).width;
131
+ }
132
+
133
+ // 添加数值格式化函数
134
+
135
+ // 文本分行辅助函数
136
+ function splitTextIntoLines(text, fontFamily, fontSize, maxWidth, fontWeight) {
137
+ if (!text) return [""];
138
+
139
+ const words = text.split(/\s+/);
140
+ const lines = [];
141
+ let currentLine = "";
142
+
143
+ // 如果单词很少,可能是中文或者其他不使用空格分隔的语言
144
+ if (words.length <= 2 && text.length > 5) {
145
+ // 按字符分割
146
+ const chars = text.split('');
147
+ currentLine = chars[0] || "";
148
+
149
+ for (let i = 1; i < chars.length; i++) {
150
+ const testLine = currentLine + chars[i];
151
+ if (measureTextWidth(testLine, fontFamily, fontSize, fontWeight) <= maxWidth) {
152
+ currentLine = testLine;
153
+ } else {
154
+ lines.push(currentLine);
155
+ currentLine = chars[i];
156
+ }
157
+ }
158
+
159
+ if (currentLine) {
160
+ lines.push(currentLine);
161
+ }
162
+ } else {
163
+ // 按单词分割
164
+ currentLine = words[0] || "";
165
+
166
+ for (let i = 1; i < words.length; i++) {
167
+ const testLine = currentLine + " " + words[i];
168
+ if (measureTextWidth(testLine, fontFamily, fontSize, fontWeight) <= maxWidth) {
169
+ currentLine = testLine;
170
+ } else {
171
+ lines.push(currentLine);
172
+ currentLine = words[i];
173
+ }
174
+ }
175
+
176
+ if (currentLine) {
177
+ lines.push(currentLine);
178
+ }
179
+ }
180
+
181
+ return lines;
182
+ }
183
+
184
+ // 专门用于数值标签的文本分行函数
185
+ function splitValueTextIntoLines(text, fontFamily, fontSize, fontWeight, maxWidthForSplit) {
186
+ if (!text) return [""];
187
+ const lines = [];
188
+ if (maxWidthForSplit <= 0) return [text]; // 防止条形宽度过小导致问题
189
+
190
+ let currentLine = "";
191
+ for (let i = 0; i < text.length; i++) {
192
+ const char = text[i];
193
+ const testLine = currentLine + char;
194
+ if (measureTextWidth(testLine, fontFamily, fontSize, fontWeight) <= maxWidthForSplit) {
195
+ currentLine = testLine;
196
+ } else {
197
+ if (currentLine === "") { // 当前字符本身就超出宽度
198
+ lines.push(char);
199
+ // currentLine 保持为空,因为这个字符自成一行
200
+ } else {
201
+ lines.push(currentLine);
202
+ currentLine = char; // 新行以当前字符开始
203
+ }
204
+ }
205
+ }
206
+ if (currentLine) {
207
+ lines.push(currentLine);
208
+ }
209
+ // 如果没有产生任何行,确保返回包含原始文本的一行
210
+ return lines.length > 0 ? lines : (text ? [text] : [""]);
211
+ }
212
+
213
+ // 创建轴线(基准线)
214
+ g.append("line")
215
+ .attr("x1", 0)
216
+ .attr("y1", H)
217
+ .attr("x2", W)
218
+ .attr("y2", H)
219
+ .attr("stroke", "#aaa")
220
+ .attr("stroke-width", 1)
221
+ .attr("stroke-dasharray", "3,3");
222
+
223
+ // 创建圆角三角形路径的辅助函数
224
+ function createRoundedTrianglePath(topPoint, leftPoint, rightPoint, radius) {
225
+ // 计算每个顶点的单位向量方向
226
+ function calculateUnitVector(p1, p2) {
227
+ const dx = p2[0] - p1[0];
228
+ const dy = p2[1] - p1[1];
229
+ const length = Math.sqrt(dx * dx + dy * dy);
230
+ return [dx / length, dy / length];
231
+ }
232
+
233
+ // 顶点间的向量
234
+ const top_left = calculateUnitVector(topPoint, leftPoint);
235
+ const left_right = calculateUnitVector(leftPoint, rightPoint);
236
+ const right_top = calculateUnitVector(rightPoint, topPoint);
237
+
238
+ // 计算圆角起始点
239
+ const topLeftStart = [
240
+ topPoint[0] + top_left[0] * radius,
241
+ topPoint[1] + top_left[1] * radius
242
+ ];
243
+ const leftRightStart = [
244
+ leftPoint[0] + left_right[0] * radius,
245
+ leftPoint[1] + left_right[1] * radius
246
+ ];
247
+ const rightTopStart = [
248
+ rightPoint[0] + right_top[0] * radius,
249
+ rightPoint[1] + right_top[1] * radius
250
+ ];
251
+
252
+ // 计算圆角结束点
253
+ const topRightEnd = [
254
+ topPoint[0] + right_top[0] * radius * -1,
255
+ topPoint[1] + right_top[1] * radius * -1
256
+ ];
257
+ const leftTopEnd = [
258
+ leftPoint[0] + top_left[0] * radius * -1,
259
+ leftPoint[1] + top_left[1] * radius * -1
260
+ ];
261
+ const rightLeftEnd = [
262
+ rightPoint[0] + left_right[0] * radius * -1,
263
+ rightPoint[1] + left_right[1] * radius * -1
264
+ ];
265
+
266
+ // 构建圆角三角形的路径
267
+ return `
268
+ M ${topLeftStart[0]},${topLeftStart[1]}
269
+ L ${leftTopEnd[0]},${leftTopEnd[1]}
270
+ A ${radius},${radius} 0 0 0 ${leftRightStart[0]},${leftRightStart[1]}
271
+ L ${rightLeftEnd[0]},${rightLeftEnd[1]}
272
+ A ${radius},${radius} 0 0 0 ${rightTopStart[0]},${rightTopStart[1]}
273
+ L ${topRightEnd[0]},${topRightEnd[1]}
274
+ A ${radius},${radius} 0 0 0 ${topLeftStart[0]},${topLeftStart[1]}
275
+ Z
276
+ `;
277
+ }
278
+
279
+ // 检查x轴标签的宽度,决定是否旋转
280
+ const shouldRotateLabels = xValues.some(x => {
281
+ const width = measureTextWidth(x, categoryFontFamily, categoryFontSize, categoryFontWeight);
282
+ return width > xGroupScale.bandwidth() * 0.8;
283
+ });
284
+
285
+ // 绘制x轴标签
286
+ xValues.forEach(x => {
287
+ const xPos = xGroupScale(x) + xGroupScale.bandwidth() / 2;
288
+
289
+ if (shouldRotateLabels) {
290
+ g.append("text")
291
+ .attr("class", "x-axis-label")
292
+ .attr("text-anchor", "end")
293
+ .attr("x", xPos)
294
+ .attr("y", H + 10)
295
+ .attr("transform", `rotate(-45, ${xPos}, ${H + 10})`)
296
+ .style("font-family", categoryFontFamily)
297
+ .style("font-size", `${categoryFontSize}px`)
298
+ .style("font-weight", categoryFontWeight)
299
+ .style("fill", "#333")
300
+ .text(x);
301
+ } else {
302
+ // 如果标签太长,分行显示
303
+ const maxWidth = xGroupScale.bandwidth() * 0.9;
304
+ const lines = splitTextIntoLines(x, categoryFontFamily, categoryFontSize, maxWidth, categoryFontWeight);
305
+ const lineHeight = categoryFontSize * 1.2;
306
+
307
+ lines.forEach((line, i) => {
308
+ g.append("text")
309
+ .attr("class", "x-axis-label")
310
+ .attr("text-anchor", "middle")
311
+ .attr("x", xPos)
312
+ .attr("y", H + 15 + i * lineHeight)
313
+ .style("font-family", categoryFontFamily)
314
+ .style("font-size", `${categoryFontSize}px`)
315
+ .style("font-weight", categoryFontWeight)
316
+ .style("fill", "#333")
317
+ .text(line);
318
+ });
319
+ }
320
+ });
321
+
322
+ // 绘制每个分组下的条形
323
+ xValues.forEach(x => {
324
+ groupValues.forEach(group => {
325
+ // 查找对应的数据点
326
+ const dataPoint = raw.find(d => d[xField] === x && d[groupField] === group);
327
+ if (!dataPoint) return; // 跳过没有数据的组合
328
+
329
+ const value = +dataPoint[yField];
330
+ if (value <= 0) return; // 跳过0或负值
331
+
332
+ // 获取对应group的颜色,如果没有则使用主颜色
333
+ const color = colorResolver.field(group, groupValues.indexOf(group), { fallback: primaryColor, useAvailable: false }).value;
334
+
335
+ // 计算条形位置和尺寸
336
+ const barX = xGroupScale(x) + xBarScale(group);
337
+ const barY = yScale(value);
338
+ const barHeight = H - barY;
339
+ const barWidth = xBarScale.bandwidth();
340
+ const barMidX = barX + barWidth / 2;
341
+
342
+ // 创建三角形路径坐标
343
+ const cornerRadius = 4; // 圆角半径
344
+ const trianglePath = createRoundedTrianglePath(
345
+ [barMidX, barY], // 顶点(上)
346
+ [barX, H], // 左下角
347
+ [barX + barWidth, H], // 右下角
348
+ cornerRadius
349
+ );
350
+
351
+ // 绘制三角形
352
+ g.append("path")
353
+ .attr("class", "bar")
354
+ .attr("d", trianglePath)
355
+ .attr("fill", color)
356
+ .attr("fill-opacity", 0.7) // 设置透明度
357
+ .attr("filter", "url(#bar-shadow)");
358
+
359
+ // 添加数值标签 - 修改为始终在条形上方
360
+ const formattedValue = chartUtils.format.autoText(value);
361
+ const valText = `${formattedValue}${yUnit}`;
362
+
363
+ // 使用条形宽度作为文本换行的最大宽度
364
+ const maxTextWidth = barWidth - 4;
365
+ const lines = splitValueTextIntoLines(valText, valueFontFamily, valueFontSize, valueFontWeight, maxTextWidth);
366
+
367
+ const actualLineHeight = valueFontSize * 1.2; // 每行文本的估计高度 (包括行间距)
368
+ const wrappedLabelHeight = lines.length * actualLineHeight;
369
+
370
+ // 计算标签位置 - 始终在条形上方
371
+ const labelAboveBarBottomMargin = 4; // 标签底部与条形顶部的间距
372
+
373
+ // 计算第一行文本中心点的Y坐标
374
+ // 确保最后一行文本的底部与条形顶部有一定间距
375
+ const startYForFirstLineCenter = barY - labelAboveBarBottomMargin - wrappedLabelHeight + actualLineHeight / 2;
376
+
377
+ // 为上方的标签添加背景矩形以提高可读性
378
+ const maxWrappedTextWidth = d3.max(lines, l => measureTextWidth(l, valueFontFamily, valueFontSize, valueFontWeight)) || 0;
379
+ if (maxWrappedTextWidth > 0) {
380
+ const rectPadding = 3; // 内边距
381
+ const bgRectWidth = Math.max(barWidth, maxWrappedTextWidth + rectPadding * 2); // 背景矩形宽度
382
+ const bgRectHeight = wrappedLabelHeight + rectPadding; // 背景矩形高度
383
+
384
+ // 背景矩形的Y坐标 (矩形顶部)
385
+ const bgRectY = startYForFirstLineCenter - (actualLineHeight / 2) - rectPadding / 2;
386
+
387
+ g.append("rect")
388
+ .attr("x", barMidX - bgRectWidth / 2) // 水平居中于条形
389
+ .attr("y", bgRectY)
390
+ .attr("width", bgRectWidth)
391
+ .attr("height", bgRectHeight)
392
+ .attr("rx", 2)
393
+ .attr("ry", 2)
394
+ .attr("fill", "#fff")
395
+ .attr("fill-opacity", 0.9); // 背景稍微透明
396
+ }
397
+
398
+ // 绘制每一行文本
399
+ lines.forEach((line, i) => {
400
+ const textY = startYForFirstLineCenter + (i * actualLineHeight);
401
+ g.append("text")
402
+ .attr("class", "value-label")
403
+ .attr("text-anchor", "middle")
404
+ .attr("x", barMidX)
405
+ .attr("y", textY)
406
+ .attr("dominant-baseline", "middle")
407
+ .style("font-family", valueFontFamily)
408
+ .style("font-size", `${valueFontSize}px`)
409
+ .style("font-weight", valueFontWeight)
410
+ .style("fill", color)
411
+ .text(line);
412
+ });
413
+ });
414
+ });
415
+
416
+ /* ============ 4. 添加动态图例 ============ */ // 支持图例换行并且居中,并且能够让图例刚好在chart上方
417
+ if (groupValues && groupValues.length > 0) {
418
+ const legendMarkerWidth = 12; // 图例标记宽度
419
+ const legendMarkerHeight = 12; // 图例标记高度
420
+ const legendMarkerRx = 3; // 图例标记圆角X
421
+ const legendPadding = 6; // 图例标记和文本之间的间距
422
+ const legendInterItemSpacing = 12; // 图例项之间的水平间距
423
+
424
+ const legendFontFamily = dataJSON.typography?.label?.font_family || 'Arial'; // 图例字体
425
+ const legendFontSize = parseFloat(dataJSON.typography?.label?.font_size || '11'); // 图例字号
426
+ const legendFontWeight = dataJSON.typography?.label?.font_weight || 'normal'; // 图例字重
427
+ const availableWidthForLegendWrapping = W; // W = fullW - margin.left - margin.right
428
+ const itemMaxHeight = Math.max(legendMarkerHeight, legendFontSize); // 单行图例内容的最大高度
429
+ const legendOptions = {
430
+ color: (group, index) => colorResolver.field(group, index, { fallback: primaryColor, useAvailable: false }).value,
431
+ markerShape: "rect",
432
+ markerWidth: legendMarkerWidth,
433
+ markerHeight: legendMarkerHeight,
434
+ markerSize: legendMarkerWidth,
435
+ markerRadius: legendMarkerRx,
436
+ markerOpacity: 0.85,
437
+ labelGap: legendPadding,
438
+ itemGap: legendInterItemSpacing,
439
+ rowGap: 6,
440
+ itemHeight: itemMaxHeight,
441
+ itemPaddingEnd: 0,
442
+ maxWidth: availableWidthForLegendWrapping,
443
+ align: "center",
444
+ fontFamily: legendFontFamily,
445
+ fontSize: legendFontSize,
446
+ fontWeight: legendFontWeight,
447
+ textColor: "#333",
448
+ className: "custom-legend-container",
449
+ };
450
+ const legendLayout = chartUtils.legend.layout(groupValues, { ...legendOptions, context: svg });
451
+
452
+ if (legendLayout.items.length > 0) {
453
+ const paddingBelowLegendToChart = 15; // 图例块底部与图表顶部的间距
454
+ const minSvgGlobalTopPadding = 15; // SVG顶部到图例块的最小间距
455
+
456
+ let legendBlockStartY = margin.top - paddingBelowLegendToChart - legendLayout.height;
457
+ legendBlockStartY = Math.max(minSvgGlobalTopPadding, legendBlockStartY); // 确保不超出SVG顶部
458
+
459
+ chartUtils.legend.draw(svg, groupValues, {
460
+ ...legendOptions,
461
+ x: (fullW - availableWidthForLegendWrapping) / 2,
462
+ y: legendBlockStartY,
463
+ });
464
+ }
465
+ }
466
+
467
+ return svg.node(); // 返回 SVG DOM 节点
468
+ }