Ray1ee01 commited on
Commit
c799c19
·
verified ·
1 Parent(s): aafd71e

Upload folder using huggingface_hub

Browse files
Files changed (20) hide show
  1. modules/chart_engine/template/d3-js/bar_chart/bottle_bar_chart_02.js.bug +0 -0
  2. modules/chart_engine/template/d3-js/bar_chart/horizontal_bar_chart_0.js.bug +212 -0
  3. modules/chart_engine/template/d3-js/bar_chart/horizontal_diverging_bar_chart_11.js +444 -0
  4. modules/chart_engine/template/d3-js/bar_chart/horizontal_diverging_bar_chart_12.js +596 -0
  5. modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_06.js +345 -0
  6. modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_07.js +433 -0
  7. modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_10.js +379 -0
  8. modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_11.js +393 -0
  9. modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_12.js +556 -0
  10. modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_13.js +252 -0
  11. modules/chart_engine/template/d3-js/bar_chart/horizontal_pictorial_bar_chart_01.js +515 -0
  12. modules/chart_engine/template/d3-js/bar_chart/vertical_bar_chart_07.js +489 -0
  13. modules/chart_engine/template/d3-js/bar_chart/vertical_bar_chart_08.js +529 -0
  14. modules/chart_engine/template/d3-js/bar_chart/vertical_bar_chart_12.js +856 -0
  15. modules/chart_engine/template/d3-js/bar_chart/vertical_bar_chart_13.js +652 -0
  16. modules/chart_engine/template/d3-js/bar_chart/vertical_group_bar_chart_11.js +597 -0
  17. modules/chart_engine/template/d3-js/bar_chart/vertical_group_bar_chart_12.js +857 -0
  18. modules/chart_engine/template/d3-js/bar_chart/vertical_group_bar_chart_13.js +1198 -0
  19. modules/chart_engine/template/d3-js/bar_chart/vertical_pictorial_percentage_bar_chart_01.js +444 -0
  20. modules/chart_engine/template/d3-js/bar_chart/vertical_pictorial_percentage_bar_chart_02.js +448 -0
modules/chart_engine/template/d3-js/bar_chart/bottle_bar_chart_02.js.bug ADDED
The diff for this file is too large to render. See raw diff
 
modules/chart_engine/template/d3-js/bar_chart/horizontal_bar_chart_0.js.bug ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Horizontal Bar Chart",
5
+ "chart_name": "horizontal_bar_chart_0",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["categorical"], ["numerical"]],
8
+ "required_fields_range": [[3, 20], [0, 100000]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": [],
13
+ "supported_effects": ["gradient", "opacity"],
14
+ "min_height": 300,
15
+ "min_width": 300,
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
+ // ---------- 1. 数据准备阶段 ----------
28
+
29
+ // 提取数据和配置
30
+ const jsonData = data; // 完整的JSON数据对象
31
+ const chartData = jsonData.data.data; // 实际数据点数组
32
+ const variables = jsonData.variables || {}; // 图表配置
33
+ const typography = jsonData.typography || { // 字体设置,如果不存在则使用默认值
34
+ title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
35
+ label: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
36
+ description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
37
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
38
+ };
39
+ const colors = jsonData.colors || {
40
+ text_color: "#333333",
41
+ other: {
42
+ primary: "#D32F2F", // Red for "Still active"
43
+ secondary: "#AAAAAA", // Gray for "Ended"
44
+ background: "#F0F0F0"
45
+ }
46
+ }; // 颜色设置
47
+ const dataColumns = jsonData.data.columns || []; // 数据列定义
48
+
49
+ // 设置视觉效果变量的默认值
50
+ variables.has_shadow = variables.has_shadow || false;
51
+ variables.has_stroke = variables.has_stroke || false;
52
+
53
+ // 清空容器
54
+ d3.select(containerSelector).html("");
55
+
56
+ // ---------- 2. 尺寸和布局设置 ----------
57
+
58
+ // 设置图表总尺寸
59
+ const width = variables.width || 300;
60
+ const height = variables.height || 400;
61
+
62
+ // 设置边距
63
+ const margin = {
64
+ top: 50,
65
+ right: 30,
66
+ bottom: 80,
67
+ left: 40
68
+ };
69
+
70
+ // 计算实际绘图区域大小
71
+ const chartWidth = width - margin.left - margin.right;
72
+ const chartHeight = height - margin.top - margin.bottom;
73
+
74
+ // ---------- 3. 提取字段名和单位 ----------
75
+
76
+ // 根据数据列获取字段名
77
+ const xField = dataColumns.find(col => col.role === "x")?.name || "period";
78
+ const yField = dataColumns.find(col => col.role === "y")?.name || "value";
79
+
80
+ // 获取字段单位(如果存在)
81
+ let xUnit = "";
82
+ let yUnit = "";
83
+ let groupUnit = "";
84
+
85
+ if (dataColumns.find(col => col.role === "x")?.unit !== "none") {
86
+ xUnit = dataColumns.find(col => col.role === "x").unit;
87
+ }
88
+
89
+ if (dataColumns.find(col => col.role === "y")?.unit !== "none") {
90
+ yUnit = dataColumns.find(col => col.role === "y").unit;
91
+ }
92
+
93
+
94
+ // ---------- 4. 数据处理 ----------
95
+
96
+ // 处理数据,确保数据格式正确
97
+ const processedData = chartData.map(d => ({
98
+ category: d[xField],
99
+ value: +d[yField] // 确保转换为数字
100
+ })).sort((a, b) => b.value - a.value); // 按值降序排序
101
+
102
+ // ---------- 5. 创建比例尺 ----------
103
+
104
+ // Y轴比例尺 - 使用分类数据
105
+ const yScale = d3.scaleBand()
106
+ .domain(processedData.map(d => d.category))
107
+ .range([0, chartHeight])
108
+ .padding(0.3);
109
+
110
+ // X轴比例尺 - 使用数值
111
+ const xScale = d3.scaleLinear()
112
+ .domain([0, d3.max(processedData, d => d.value)])
113
+ .range([0, chartWidth])
114
+ .nice();
115
+
116
+ // 颜色比例尺
117
+ const colorScale = d3.scaleOrdinal()
118
+ .domain(processedData.map(d => d.category))
119
+ .range([colors.other.primary, colors.other.primary]);
120
+
121
+ // ---------- 6. 创建SVG容器 ----------
122
+
123
+ const svg = d3.select(containerSelector)
124
+ .append("svg")
125
+ .attr("width", "100%")
126
+ .attr("height", height)
127
+ .attr("viewBox", `0 0 ${width} ${height}`)
128
+ .attr("style", "max-width: 100%; height: auto;")
129
+ .attr("xmlns", "http://www.w3.org/2000/svg")
130
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
131
+
132
+ // 添加图表主体容器
133
+ const chartGroup = svg.append("g")
134
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
135
+
136
+ // ---------- 7. 绘制图表元素 ----------
137
+
138
+ // 添加X轴(隐藏)
139
+ const xAxis = d3.axisBottom(xScale)
140
+ .ticks(5)
141
+ .tickFormat(d => d + (yUnit ? ` ${yUnit}` : ''))
142
+ .tickSize(0) // 移除刻度线
143
+ .tickPadding(10); // 增加文字和轴的间距
144
+
145
+
146
+ chartGroup.append("g")
147
+ .attr("class", "x-axis")
148
+ .attr("transform", `translate(0, ${chartHeight})`)
149
+ .call(xAxis)
150
+ .call(g => g.select(".domain").remove()) // 移除轴线
151
+ .selectAll("text")
152
+ .style("font-family", typography.label.font_family)
153
+ .style("font-size", typography.label.font_size)
154
+ .style("fill", colors.text_color)
155
+ .style("opacity", 0); // 隐藏X轴文本
156
+
157
+ // 添加Y轴(不显示刻度线)
158
+ const yAxis = d3.axisLeft(yScale)
159
+ .tickSize(0); // 移除刻度线
160
+
161
+ chartGroup.append("g")
162
+ .attr("class", "y-axis")
163
+ .call(yAxis)
164
+ .call(g => g.select(".domain").remove()) // 移除轴线
165
+ .selectAll("text")
166
+ .style("font-family", typography.label.font_family)
167
+ .style("font-size", typography.label.font_size)
168
+ .style("fill", colors.text_color);
169
+
170
+ // 添加条形
171
+ const bars = chartGroup.selectAll(".bar")
172
+ .data(processedData)
173
+ .enter()
174
+ .append("rect")
175
+ .attr("class", "bar")
176
+ .attr("y", d => yScale(d.category))
177
+ .attr("x", 0)
178
+ .attr("height", yScale.bandwidth())
179
+ .attr("width", d => xScale(d.value)) // 直接设置最终宽度
180
+ .attr("fill", d => colorScale(d.category));
181
+
182
+ // 添加数值格式化函数
183
+ const formatValue = (value) => {
184
+ if (value >= 1000000000) {
185
+ return d3.format("~g")(value / 1000000000) + "B";
186
+ } else if (value >= 1000000) {
187
+ return d3.format("~g")(value / 1000000) + "M";
188
+ } else if (value >= 1000) {
189
+ return d3.format("~g")(value / 1000) + "K";
190
+ } else {
191
+ return d3.format("~g")(value);
192
+ }
193
+ }
194
+
195
+ // 添加数值标签
196
+ const labels = chartGroup.selectAll(".label")
197
+ .data(processedData)
198
+ .enter()
199
+ .append("text")
200
+ .attr("class", "label")
201
+ .attr("y", d => yScale(d.category) + yScale.bandwidth() / 2)
202
+ .attr("x", d => xScale(d.value) + 5)
203
+ .attr("dy", ".35em")
204
+ .style("font-family", typography.label.font_family)
205
+ .style("font-size", typography.label.font_size)
206
+ .style("fill", colors.text_color)
207
+ .text(d => `${formatValue(d.value)}${yUnit ? ` ${yUnit}` : ''}`)
208
+ .style("opacity", 1); // 直接设置为可见
209
+
210
+
211
+ return svg.node();
212
+ }
modules/chart_engine/template/d3-js/bar_chart/horizontal_diverging_bar_chart_11.js ADDED
@@ -0,0 +1,444 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Horizontal Diverging Bar Chart",
5
+ "chart_name": "horizontal_diverging_bar_chart_11",
6
+ "chart_for": "comparison",
7
+ "is_composite": false,
8
+ "required_fields": ["x", "y", "group"],
9
+ "required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
10
+ "required_fields_range": [[2, 20], [0, "inf"], [2, 2]],
11
+ "required_fields_icons": [],
12
+ "required_other_icons": [],
13
+ "required_fields_colors": ["group"],
14
+ "required_other_colors": [],
15
+ "supported_effects": ["shadow", "radius_corner", "gradient", "stroke", "spacing"],
16
+ "min_height": 400,
17
+ "min_width": 400,
18
+ "background": "none",
19
+ "icon_mark": "none",
20
+ "icon_label": "side",
21
+ "has_x_axis": "no",
22
+ "has_y_axis": "no"
23
+ }
24
+ REQUIREMENTS_END
25
+ */
26
+
27
+ // Reference-local card layout for selected GPT image-2 sample_00 variant_03.
28
+ function makeChart(containerSelector, data) {
29
+ const jsonData = data;
30
+ const rawData = Array.isArray(jsonData.data) ? jsonData.data : (jsonData.data && jsonData.data.data) || [];
31
+ const variables = jsonData.variables || {};
32
+ const typography = jsonData.typography || {
33
+ label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
34
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
35
+ };
36
+ const width = Math.max(+variables.width || 714, 1180);
37
+ const height = Math.min(Math.max(+variables.height || 520, 500), 540);
38
+ const colorResolver = chartUtils.color.resolver(jsonData);
39
+
40
+ d3.select(containerSelector).html("");
41
+
42
+ const dimensionChannel = chartUtils.schema.channel(jsonData, "x");
43
+ const valueChannel = chartUtils.schema.channel(jsonData, "y");
44
+ const groupChannel = chartUtils.schema.channel(jsonData, "group");
45
+ const dimensionField = dimensionChannel.key;
46
+ const valueField = valueChannel.key;
47
+ const groupField = groupChannel.key;
48
+ const dimensionUnit = dimensionChannel.unit && dimensionChannel.unit !== "none" ? dimensionChannel.unit : "";
49
+ const valueUnit = valueChannel.unit && valueChannel.unit !== "none" ? valueChannel.unit : "";
50
+ const groupUnit = groupChannel.unit && groupChannel.unit !== "none" ? groupChannel.unit : "";
51
+
52
+ const groups = [...new Set(rawData.map(d => d[groupField]))].slice(0, 2);
53
+ const dimensions = [...new Set(rawData.map(d => d[dimensionField]))].sort((a, b) => {
54
+ const na = +a;
55
+ const nb = +b;
56
+ if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb;
57
+ return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" });
58
+ });
59
+ const leftGroup = groups[0];
60
+ const rightGroup = groups[1];
61
+ const complete = groups.length === 2 && dimensions.every(dimension =>
62
+ rawData.some(d => d[dimensionField] === dimension && d[groupField] === leftGroup) &&
63
+ rawData.some(d => d[dimensionField] === dimension && d[groupField] === rightGroup)
64
+ );
65
+
66
+ if (!complete) {
67
+ d3.select(containerSelector)
68
+ .append("div")
69
+ .style("color", "#b87312")
70
+ .style("text-align", "center")
71
+ .style("padding", "20px")
72
+ .style("font-family", typography.label.font_family || "Arial")
73
+ .style("font-size", typography.label.font_size || "12px")
74
+ .text("Missing paired group data for horizontal diverging bars.");
75
+ return;
76
+ }
77
+
78
+ const values = rawData.map(d => Math.max(0, +d[valueField] || 0));
79
+ const maxValue = d3.max(values) || 1;
80
+ const titleText = [
81
+ jsonData.title,
82
+ jsonData.titles && jsonData.titles.main_title,
83
+ jsonData.title_text,
84
+ jsonData.meta && jsonData.meta.title
85
+ ].filter(Boolean).join(" ");
86
+
87
+ const leftColor = "#1762d4";
88
+ const leftLight = "#eaf2ff";
89
+ const rightColor = "#d79519";
90
+ const rightLight = "#fff4dc";
91
+ const textColor = colorResolver.text({ fallback: "#12275c" }).value;
92
+
93
+ const svg = d3.select(containerSelector)
94
+ .append("svg")
95
+ .attr("width", "100%")
96
+ .attr("height", height)
97
+ .attr("viewBox", `0 0 ${width} ${height}`)
98
+ .attr("style", "max-width: 100%; height: auto;")
99
+ .attr("xmlns", "http://www.w3.org/2000/svg")
100
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
101
+ .attr("data-chart-variation", "horizontal_diverging_bar_chart_11")
102
+ .attr("data-reference-layout", "sample_00_gpt_image_2_03");
103
+
104
+ const defs = svg.append("defs");
105
+ const filter = defs.append("filter")
106
+ .attr("id", "hdbar11-card-shadow")
107
+ .attr("x", "-8%")
108
+ .attr("y", "-10%")
109
+ .attr("width", "116%")
110
+ .attr("height", "124%");
111
+ filter.append("feDropShadow")
112
+ .attr("dx", 0)
113
+ .attr("dy", 6)
114
+ .attr("stdDeviation", 8)
115
+ .attr("flood-color", "#6780ad")
116
+ .attr("flood-opacity", 0.18);
117
+
118
+ const makeGradient = (id, stops) => {
119
+ const grad = defs.append("linearGradient").attr("id", id).attr("x1", "0%").attr("x2", "100%").attr("y1", "0%").attr("y2", "0%");
120
+ stops.forEach(stop => grad.append("stop").attr("offset", stop.offset).attr("stop-color", stop.color).attr("stop-opacity", stop.opacity || 1));
121
+ };
122
+ makeGradient("hdbar11-blue-gradient", [
123
+ { offset: "0%", color: "#a8c8ff" },
124
+ { offset: "100%", color: leftColor }
125
+ ]);
126
+ makeGradient("hdbar11-gold-gradient", [
127
+ { offset: "0%", color: "#f4c257" },
128
+ { offset: "100%", color: "#d68d12" }
129
+ ]);
130
+
131
+ const makeHatch = (id, baseFill) => {
132
+ const pattern = defs.append("pattern")
133
+ .attr("id", id)
134
+ .attr("patternUnits", "userSpaceOnUse")
135
+ .attr("width", 7)
136
+ .attr("height", 7)
137
+ .attr("patternTransform", "rotate(45)");
138
+ pattern.append("rect").attr("width", 7).attr("height", 7).attr("fill", baseFill);
139
+ pattern.append("line")
140
+ .attr("x1", 0)
141
+ .attr("y1", 0)
142
+ .attr("x2", 0)
143
+ .attr("y2", 7)
144
+ .attr("stroke", "#ffffff")
145
+ .attr("stroke-width", 1.25)
146
+ .attr("opacity", 0.35);
147
+ };
148
+ makeHatch("hdbar11-blue-hatch", "url(#hdbar11-blue-gradient)");
149
+ makeHatch("hdbar11-gold-hatch", "url(#hdbar11-gold-gradient)");
150
+
151
+ const cardX = Math.max(6, width * 0.018);
152
+ const cardY = Math.max(6, height * 0.02);
153
+ const cardW = width - cardX * 2;
154
+ const cardH = height - cardY * 2;
155
+ const padX = Math.max(28, Math.min(48, width * 0.04));
156
+ const headerH = Math.max(58, Math.min(76, cardH * 0.16));
157
+ const rowTop = cardY + headerH + 18;
158
+ const rowBottom = cardY + cardH - 18;
159
+ const rowAreaH = Math.max(100, rowBottom - rowTop);
160
+ const rowStep = rowAreaH / dimensions.length;
161
+ const barH = Math.max(10, Math.min(22, rowStep * 0.42));
162
+ const chipR = Math.max(11, Math.min(18, rowStep * 0.34));
163
+ const centerX = width / 2;
164
+ const maxDimensionChars = d3.max(dimensions, d => String(dimensionUnit ? `${d}${dimensionUnit}` : d).length) || 1;
165
+ const compactCenterLabels = maxDimensionChars <= 4;
166
+ const centerGap = compactCenterLabels
167
+ ? chipR * 2 + Math.max(28, width * 0.034)
168
+ : Math.min(width * 0.22, Math.max(chipR * 2 + 44, maxDimensionChars * 6.6 + 30));
169
+ const centerChipW = compactCenterLabels ? chipR * 2 : Math.max(chipR * 2 + 20, centerGap - 22);
170
+ const labelW = Math.max(88, Math.min(118, width * 0.1));
171
+ const leftTrackX = cardX + padX + labelW + 10;
172
+ const leftTrackEnd = centerX - centerGap / 2;
173
+ const rightTrackX = centerX + centerGap / 2;
174
+ const rightLabelW = labelW;
175
+ const rightTrackEnd = cardX + cardW - padX - rightLabelW - 10;
176
+ const trackW = Math.max(36, Math.min(leftTrackEnd - leftTrackX, rightTrackEnd - rightTrackX));
177
+ const leftValueX = Math.max(cardX + padX + labelW - 8, leftTrackX - 20);
178
+ const rightValueX = Math.min(cardX + cardW - padX - rightLabelW + 8, rightTrackX + trackW + 20);
179
+ const labelFont = Math.max(11, Math.min(16, rowStep * 0.29));
180
+ const valueFont = Math.max(11, Math.min(17, rowStep * 0.34));
181
+
182
+ svg.append("rect")
183
+ .attr("class", "hdbar11-chart-card")
184
+ .attr("x", cardX)
185
+ .attr("y", cardY)
186
+ .attr("width", cardW)
187
+ .attr("height", cardH)
188
+ .attr("rx", 14)
189
+ .attr("fill", "#ffffff")
190
+ .attr("stroke", "#e3e9f4")
191
+ .attr("stroke-width", 1)
192
+ .attr("filter", "url(#hdbar11-card-shadow)");
193
+
194
+ const headerY = cardY + Math.max(29, headerH * 0.58);
195
+ const leftHeaderX = cardX + padX + Math.max(118, Math.min(150, cardW * 0.2));
196
+ const rightHeaderX = cardX + cardW - padX - Math.max(118, Math.min(150, cardW * 0.2));
197
+
198
+ const groupLabel = group => {
199
+ let label = `${group}${groupUnit || ""}`.trim();
200
+ if (/developer/i.test(titleText) && !/developer/i.test(label)) label = `${label} Developers`;
201
+ return label.toUpperCase();
202
+ };
203
+
204
+ const addHeaderSlot = (x, y, side, color) => {
205
+ const slot = svg.append("g")
206
+ .attr("class", `hdbar11-header-icon-slot hdbar11-${side}-icon-slot`)
207
+ .attr("data-asset-slot", `${side}-group-icon`)
208
+ .attr("data-slot-policy", "neutral_placeholder")
209
+ .attr("data-slot-anchor", `${side}-group-header`)
210
+ .attr("data-slot-bbox", `${Math.round(x - 14)},${Math.round(y - 14)},28,28`)
211
+ .attr("data-slot-mask", "circle")
212
+ .attr("data-slot-collision", "inside-chart-card-header-row")
213
+ .attr("data-no-embedded-raster", "true")
214
+ .attr("data-no-copied-reference-art", "true");
215
+ slot.append("circle")
216
+ .attr("cx", x)
217
+ .attr("cy", y)
218
+ .attr("r", 13)
219
+ .attr("fill", "#ffffff")
220
+ .attr("stroke", color)
221
+ .attr("stroke-width", 2);
222
+ slot.append("circle")
223
+ .attr("cx", x)
224
+ .attr("cy", y)
225
+ .attr("r", 3.2)
226
+ .attr("fill", color)
227
+ .attr("opacity", 0.52);
228
+ };
229
+
230
+ addHeaderSlot(leftHeaderX - Math.max(84, Math.min(104, cardW * 0.14)), headerY - 1, "left", leftColor);
231
+ addHeaderSlot(rightHeaderX - Math.max(84, Math.min(104, cardW * 0.14)), headerY - 1, "right", rightColor);
232
+
233
+ svg.append("text")
234
+ .attr("x", leftHeaderX + 12)
235
+ .attr("y", headerY + 5)
236
+ .attr("text-anchor", "middle")
237
+ .style("font-family", typography.label.font_family || "Arial")
238
+ .style("font-size", `${Math.max(11, Math.min(14, width * 0.018))}px`)
239
+ .style("font-weight", 800)
240
+ .style("fill", leftColor)
241
+ .text(groupLabel(leftGroup));
242
+
243
+ svg.append("text")
244
+ .attr("x", rightHeaderX + 16)
245
+ .attr("y", headerY + 5)
246
+ .attr("text-anchor", "middle")
247
+ .style("font-family", typography.label.font_family || "Arial")
248
+ .style("font-size", `${Math.max(11, Math.min(14, width * 0.018))}px`)
249
+ .style("font-weight", 800)
250
+ .style("fill", rightColor)
251
+ .text(groupLabel(rightGroup));
252
+
253
+ svg.append("text")
254
+ .attr("x", centerX)
255
+ .attr("y", headerY + 5)
256
+ .attr("text-anchor", "middle")
257
+ .style("font-family", typography.label.font_family || "Arial")
258
+ .style("font-size", `${Math.max(10, Math.min(13, width * 0.017))}px`)
259
+ .style("font-weight", 700)
260
+ .style("letter-spacing", "0")
261
+ .style("fill", "#202848")
262
+ .attr("textLength", String(dimensionField || "Category").length > 10 ? Math.min(centerGap - 12, 100) : null)
263
+ .attr("lengthAdjust", String(dimensionField || "Category").length > 10 ? "spacingAndGlyphs" : null)
264
+ .text(String(dimensionField || "Category").toUpperCase());
265
+
266
+ svg.append("line")
267
+ .attr("x1", cardX + padX)
268
+ .attr("x2", cardX + cardW - padX)
269
+ .attr("y1", cardY + headerH)
270
+ .attr("y2", cardY + headerH)
271
+ .attr("stroke", "#e2e7f0")
272
+ .attr("stroke-width", 1);
273
+
274
+ const leftByDimension = new Map(rawData.filter(d => d[groupField] === leftGroup).map(d => [d[dimensionField], d]));
275
+ const rightByDimension = new Map(rawData.filter(d => d[groupField] === rightGroup).map(d => [d[dimensionField], d]));
276
+ const leftMaxValue = d3.max([...leftByDimension.values()], d => +d[valueField] || 0) || maxValue;
277
+ const rightMaxValue = d3.max([...rightByDimension.values()], d => +d[valueField] || 0) || maxValue;
278
+
279
+ const formatDimension = value => dimensionUnit ? `${value}${dimensionUnit}` : `${value}`;
280
+ const formatValueParts = value => {
281
+ const numeric = +value || 0;
282
+ if (/usd|\$|dollar/i.test(valueUnit) && Math.abs(numeric) >= 1000) {
283
+ return { number: `${chartUtils.format.autoText(numeric / 1000)}K`, unit: "USD" };
284
+ }
285
+ const formatted = chartUtils.format.autoText(numeric);
286
+ return { number: formatted, unit: valueUnit };
287
+ };
288
+
289
+ const addValueLabel = (x, y, value, color, anchor) => {
290
+ const parts = formatValueParts(value);
291
+ const text = svg.append("text")
292
+ .attr("class", "hdbar11-value-label")
293
+ .attr("x", x)
294
+ .attr("y", y + 1)
295
+ .attr("dy", "0.35em")
296
+ .attr("text-anchor", anchor)
297
+ .style("font-family", typography.annotation.font_family || "Arial")
298
+ .style("font-weight", 800)
299
+ .style("font-size", `${valueFont}px`)
300
+ .style("fill", color)
301
+ .attr("data-value", value)
302
+ .attr("data-label-anchor", "bar-outer-endpoint")
303
+ .attr("data-coordinate-rule", "attached-row-centerline");
304
+ text.append("tspan").text(parts.number);
305
+ if (parts.unit) {
306
+ text.append("tspan")
307
+ .style("font-size", `${Math.max(8, valueFont * 0.58)}px`)
308
+ .style("font-weight", 700)
309
+ .attr("dx", 3)
310
+ .text(parts.unit);
311
+ }
312
+ };
313
+
314
+ dimensions.forEach((dimension, index) => {
315
+ const rowCenterY = rowTop + index * rowStep + rowStep / 2;
316
+ const guideY = rowTop + index * rowStep;
317
+ const leftDatum = leftByDimension.get(dimension);
318
+ const rightDatum = rightByDimension.get(dimension);
319
+ const leftValue = +leftDatum[valueField] || 0;
320
+ const rightValue = +rightDatum[valueField] || 0;
321
+ const leftBarW = Math.max(3, trackW * Math.min(1, 0.38 + 0.62 * Math.sqrt(Math.max(0, leftValue) / leftMaxValue)));
322
+ const rightBarW = Math.max(3, trackW * Math.min(1, 0.30 + 0.70 * Math.sqrt(Math.max(0, rightValue) / rightMaxValue)));
323
+ const leftBarX = leftTrackX;
324
+ const rightBarX = rightTrackX;
325
+ const barY = rowCenterY - barH / 2;
326
+ const radius = barH / 2;
327
+
328
+ svg.append("line")
329
+ .attr("class", "hdbar11-row-guide")
330
+ .attr("x1", cardX + padX)
331
+ .attr("x2", cardX + cardW - padX)
332
+ .attr("y1", guideY)
333
+ .attr("y2", guideY)
334
+ .attr("stroke", "#e4eaf4")
335
+ .attr("stroke-width", 1)
336
+ .attr("stroke-dasharray", index % 2 === 0 ? "3 3" : null)
337
+ .attr("opacity", 0.86);
338
+
339
+ svg.append("rect")
340
+ .attr("class", "hdbar11-left-track")
341
+ .attr("x", leftTrackX)
342
+ .attr("y", barY)
343
+ .attr("width", trackW)
344
+ .attr("height", barH)
345
+ .attr("rx", radius)
346
+ .attr("fill", leftLight);
347
+ svg.append("rect")
348
+ .attr("class", "hdbar11-right-track")
349
+ .attr("x", rightTrackX)
350
+ .attr("y", barY)
351
+ .attr("width", trackW)
352
+ .attr("height", barH)
353
+ .attr("rx", radius)
354
+ .attr("fill", rightLight);
355
+
356
+ svg.append("rect")
357
+ .attr("class", "hdbar11-bar hdbar11-left-bar")
358
+ .attr("x", leftBarX)
359
+ .attr("y", barY)
360
+ .attr("width", leftBarW)
361
+ .attr("height", barH)
362
+ .attr("rx", radius)
363
+ .attr("fill", "url(#hdbar11-blue-hatch)")
364
+ .attr("data-category", dimension)
365
+ .attr("data-group", leftGroup)
366
+ .attr("data-value", leftValue)
367
+ .attr("data-endpoint-shape", "rounded-pill-like")
368
+ .attr("data-coordinate-rule", "row-centerline-left-of-center-chip");
369
+ svg.append("rect")
370
+ .attr("class", "hdbar11-bar hdbar11-right-bar")
371
+ .attr("x", rightBarX)
372
+ .attr("y", barY)
373
+ .attr("width", rightBarW)
374
+ .attr("height", barH)
375
+ .attr("rx", radius)
376
+ .attr("fill", "url(#hdbar11-gold-hatch)")
377
+ .attr("data-category", dimension)
378
+ .attr("data-group", rightGroup)
379
+ .attr("data-value", rightValue)
380
+ .attr("data-endpoint-shape", "rounded-pill-like")
381
+ .attr("data-coordinate-rule", "row-centerline-right-of-center-chip");
382
+
383
+ if (compactCenterLabels) {
384
+ svg.append("circle")
385
+ .attr("class", "hdbar11-center-chip")
386
+ .attr("cx", centerX)
387
+ .attr("cy", rowCenterY)
388
+ .attr("r", chipR)
389
+ .attr("fill", "#ffffff")
390
+ .attr("stroke", "#dbe3ef")
391
+ .attr("stroke-width", 1)
392
+ .attr("filter", "url(#hdbar11-card-shadow)")
393
+ .attr("data-coordinate-rule", "centerline-aligned")
394
+ .attr("data-category", dimension);
395
+ } else {
396
+ svg.append("rect")
397
+ .attr("class", "hdbar11-center-chip hdbar11-center-chip-pill")
398
+ .attr("x", centerX - centerChipW / 2)
399
+ .attr("y", rowCenterY - chipR)
400
+ .attr("width", centerChipW)
401
+ .attr("height", chipR * 2)
402
+ .attr("rx", chipR)
403
+ .attr("fill", "#ffffff")
404
+ .attr("stroke", "#dbe3ef")
405
+ .attr("stroke-width", 1)
406
+ .attr("filter", "url(#hdbar11-card-shadow)")
407
+ .attr("data-coordinate-rule", "centerline-aligned-pill")
408
+ .attr("data-category", dimension);
409
+ }
410
+ const formattedDimension = formatDimension(dimension);
411
+ const dimensionSafeWidth = centerChipW - 9;
412
+ const needsFit = formattedDimension.length * labelFont * 0.56 > dimensionSafeWidth;
413
+ svg.append("text")
414
+ .attr("class", "hdbar11-center-chip-label")
415
+ .attr("x", centerX)
416
+ .attr("y", rowCenterY + 1)
417
+ .attr("dy", "0.35em")
418
+ .attr("text-anchor", "middle")
419
+ .style("font-family", typography.label.font_family || "Arial")
420
+ .style("font-size", `${labelFont}px`)
421
+ .style("font-weight", 800)
422
+ .style("fill", textColor)
423
+ .attr("textLength", needsFit ? dimensionSafeWidth : null)
424
+ .attr("lengthAdjust", needsFit ? "spacingAndGlyphs" : null)
425
+ .text(formattedDimension);
426
+
427
+ addValueLabel(leftValueX, rowCenterY, leftValue, leftColor, "end");
428
+ addValueLabel(rightValueX, rowCenterY, rightValue, rightColor, "start");
429
+ });
430
+
431
+ svg.append("line")
432
+ .attr("class", "hdbar11-center-divider")
433
+ .attr("x1", centerX)
434
+ .attr("x2", centerX)
435
+ .attr("y1", rowTop - 4)
436
+ .attr("y2", rowBottom + 2)
437
+ .attr("stroke", "#d7deea")
438
+ .attr("stroke-width", 1.1)
439
+ .attr("stroke-dasharray", "2 6")
440
+ .attr("opacity", 0.8)
441
+ .lower();
442
+
443
+ return svg.node();
444
+ }
modules/chart_engine/template/d3-js/bar_chart/horizontal_diverging_bar_chart_12.js ADDED
@@ -0,0 +1,596 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Horizontal Diverging Bar Chart",
5
+ "chart_name": "horizontal_diverging_bar_chart_12",
6
+ "chart_for": "comparison",
7
+ "is_composite": false,
8
+ "required_fields": ["x", "y", "group"],
9
+ "required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
10
+ "required_fields_range": [[2, 20], [0, "inf"], [2, 2]],
11
+ "required_fields_icons": [],
12
+ "required_other_icons": [],
13
+ "required_fields_colors": ["group"],
14
+ "required_other_colors": ["primary"],
15
+ "supported_effects": ["shadow", "radius_corner", "gradient", "stroke", "spacing"],
16
+ "min_height": 400,
17
+ "min_width": 400,
18
+ "background": "no",
19
+ "icon_mark": "none",
20
+ "icon_label": "none",
21
+ "has_x_axis": "no",
22
+ "has_y_axis": "no"
23
+ }
24
+ REQUIREMENTS_END
25
+ */
26
+
27
+ function makeChart(containerSelector, data) {
28
+ const jsonData = data;
29
+ const chartData = (jsonData.data && jsonData.data.data) || [];
30
+ const variables = jsonData.variables || {};
31
+ const columns = (jsonData.data && jsonData.data.columns) || [];
32
+ const colorResolver = chartUtils.color.resolver(jsonData);
33
+
34
+ d3.select(containerSelector).html("");
35
+
36
+ const width = variables.width || 1024;
37
+ const height = variables.height || 1536;
38
+ const svg = d3.select(containerSelector)
39
+ .append("svg")
40
+ .attr("width", "100%")
41
+ .attr("height", height)
42
+ .attr("viewBox", `0 0 ${width} ${height}`)
43
+ .attr("style", "max-width: 100%; height: auto;")
44
+ .attr("xmlns", "http://www.w3.org/2000/svg")
45
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
46
+
47
+ const defs = svg.append("defs");
48
+ defs.append("linearGradient")
49
+ .attr("id", "hd12-bg")
50
+ .attr("x1", "0%")
51
+ .attr("y1", "0%")
52
+ .attr("x2", "0%")
53
+ .attr("y2", "100%")
54
+ .call(g => {
55
+ g.append("stop").attr("offset", "0%").attr("stop-color", "#fbfff8");
56
+ g.append("stop").attr("offset", "62%").attr("stop-color", "#ffffff");
57
+ g.append("stop").attr("offset", "100%").attr("stop-color", "#f4f7ef");
58
+ });
59
+ defs.append("linearGradient")
60
+ .attr("id", "hd12-left-grad")
61
+ .attr("x1", "0%")
62
+ .attr("y1", "0%")
63
+ .attr("x2", "100%")
64
+ .attr("y2", "0%")
65
+ .call(g => {
66
+ g.append("stop").attr("offset", "0%").attr("stop-color", "#7fbd2f");
67
+ g.append("stop").attr("offset", "100%").attr("stop-color", "#2d7f20");
68
+ });
69
+ defs.append("linearGradient")
70
+ .attr("id", "hd12-right-grad")
71
+ .attr("x1", "0%")
72
+ .attr("y1", "0%")
73
+ .attr("x2", "100%")
74
+ .attr("y2", "0%")
75
+ .call(g => {
76
+ g.append("stop").attr("offset", "0%").attr("stop-color", "#0f74d7");
77
+ g.append("stop").attr("offset", "100%").attr("stop-color", "#074aa9");
78
+ });
79
+ const shadow = defs.append("filter")
80
+ .attr("id", "hd12-soft-shadow")
81
+ .attr("x", "-20%")
82
+ .attr("y", "-20%")
83
+ .attr("width", "140%")
84
+ .attr("height", "140%");
85
+ shadow.append("feDropShadow")
86
+ .attr("dx", 0)
87
+ .attr("dy", 8)
88
+ .attr("stdDeviation", 10)
89
+ .attr("flood-color", "#24420e")
90
+ .attr("flood-opacity", 0.11);
91
+
92
+ svg.append("rect")
93
+ .attr("width", width)
94
+ .attr("height", height)
95
+ .attr("fill", "url(#hd12-bg)");
96
+
97
+ const channel = (name, fallbackIndex) => {
98
+ try {
99
+ const resolved = chartUtils.schema.channel(jsonData, name);
100
+ if (resolved && resolved.key) return resolved;
101
+ } catch (e) {}
102
+ const col = columns[fallbackIndex] || {};
103
+ return { key: col.name, unit: col.unit || "" };
104
+ };
105
+ const dimensionChannel = channel("x", 0);
106
+ const valueChannel = channel("y", 1);
107
+ const groupChannel = channel("group", 2);
108
+ const dimensionField = dimensionChannel.key;
109
+ const valueField = valueChannel.key;
110
+ const groupField = groupChannel.key;
111
+ const valueUnit = valueChannel.unit && valueChannel.unit !== "none" ? valueChannel.unit : "";
112
+
113
+ const groups = Array.from(new Set(chartData.map(d => d[groupField]))).slice(0, 2);
114
+ const leftGroup = groups[0] || "Group A";
115
+ const rightGroup = groups[1] || "Group B";
116
+ const dimensionsInInput = Array.from(new Set(chartData.map(d => d[dimensionField])));
117
+ const temporalNames = ["year", "date", "month", "season", "semester", "quarter", "time"];
118
+ const dimensionName = String(dimensionField || "").toLowerCase();
119
+ const dimensionColumn = columns.find(c => c.name === dimensionField) || {};
120
+ const isTemporal = temporalNames.some(token => dimensionName.includes(token)) ||
121
+ String(dimensionColumn.data_type || "").toLowerCase().includes("temporal");
122
+ const monthIndex = {
123
+ jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3, apr: 4, april: 4,
124
+ may: 5, jun: 6, june: 6, jul: 7, july: 7, aug: 8, august: 8, sep: 9,
125
+ sept: 9, september: 9, oct: 10, october: 10, nov: 11, november: 11,
126
+ dec: 12, december: 12
127
+ };
128
+ const temporalKey = (value) => {
129
+ if (typeof value === "number") return value;
130
+ const raw = String(value || "").trim();
131
+ const numeric = Number(raw.replace(/[^0-9.-]/g, ""));
132
+ if (!Number.isNaN(numeric) && raw.match(/[0-9]/)) return numeric;
133
+ return monthIndex[raw.toLowerCase()] || raw.toLowerCase();
134
+ };
135
+ const dimensions = isTemporal
136
+ ? dimensionsInInput.slice().sort((a, b) => d3.ascending(temporalKey(a), temporalKey(b)))
137
+ : dimensionsInInput.slice();
138
+
139
+ const rows = dimensions.map(dimension => {
140
+ const left = chartData.find(d => d[dimensionField] === dimension && d[groupField] === leftGroup);
141
+ const right = chartData.find(d => d[dimensionField] === dimension && d[groupField] === rightGroup);
142
+ return {
143
+ dimension,
144
+ left,
145
+ right,
146
+ leftValue: left ? Math.abs(+left[valueField]) || 0 : 0,
147
+ rightValue: right ? Math.abs(+right[valueField]) || 0 : 0
148
+ };
149
+ });
150
+ const leftMax = d3.max(rows, d => d.leftValue) || 1;
151
+ const rightMax = d3.max(rows, d => d.rightValue) || 1;
152
+
153
+ const getGroupColor = (group, index, fallback) => {
154
+ try {
155
+ return colorResolver.field(group, index, { fallbackKey: "primary" }).value || fallback;
156
+ } catch (e) {
157
+ return fallback;
158
+ }
159
+ };
160
+ const leftColor = getGroupColor(leftGroup, 0, "#4e9a1d");
161
+ const rightColor = getGroupColor(rightGroup, 1, "#0b63c7");
162
+ const titleDark = "#082746";
163
+ const greenDark = "#185d20";
164
+ const mutedText = "#3f4a48";
165
+
166
+ const formatValue = (value) => {
167
+ if (!Number.isFinite(value)) return "";
168
+ let text;
169
+ if (valueUnit === "$") {
170
+ const absValue = Math.abs(value);
171
+ if (absValue >= 1000000) {
172
+ text = `${(value / 1000000).toFixed(value % 1000000 === 0 ? 0 : 1)}M`;
173
+ } else if (absValue >= 1000) {
174
+ text = `${(value / 1000).toFixed(value % 1000 === 0 ? 0 : 1)}k`;
175
+ } else {
176
+ text = Number.isInteger(value) ? String(value) : value.toFixed(1).replace(/\.0$/, "");
177
+ }
178
+ return `$${text}`;
179
+ }
180
+ if (valueUnit === "%") {
181
+ text = Math.abs(value % 1) > 0.001 ? value.toFixed(1) : value.toFixed(0);
182
+ return `${text}%`;
183
+ }
184
+ if (valueUnit.toLowerCase() === "ppm") {
185
+ text = value < 0.1 ? value.toFixed(3).replace(/0+$/, "").replace(/\.$/, "") : value.toFixed(2);
186
+ return `${text} ppm`;
187
+ }
188
+ if (Number.isInteger(value)) {
189
+ text = String(value);
190
+ } else if (Math.abs(value) < 1) {
191
+ text = value.toFixed(3).replace(/0+$/, "").replace(/\.$/, "");
192
+ } else {
193
+ text = value.toFixed(1).replace(/\.0$/, "");
194
+ }
195
+ return valueUnit ? `${text}${valueUnit}` : text;
196
+ };
197
+
198
+ const wrapWords = (text, maxChars, maxLines) => {
199
+ const words = String(text || "").split(/\s+/).filter(Boolean);
200
+ const lines = [];
201
+ let line = "";
202
+ words.forEach(word => {
203
+ const next = line ? `${line} ${word}` : word;
204
+ if (next.length > maxChars && line) {
205
+ lines.push(line);
206
+ line = word;
207
+ } else {
208
+ line = next;
209
+ }
210
+ });
211
+ if (line) lines.push(line);
212
+ if (maxLines && lines.length > maxLines) {
213
+ const clipped = lines.slice(0, maxLines);
214
+ clipped[maxLines - 1] = clipped[maxLines - 1].replace(/\s+\S+$/, "");
215
+ return clipped;
216
+ }
217
+ return lines;
218
+ };
219
+
220
+ const titleSource = (jsonData.titles && jsonData.titles.main_title) ||
221
+ (jsonData.metadata && jsonData.metadata.title) || "Diverging Comparison";
222
+ const subtitleSource = (jsonData.titles && jsonData.titles.sub_title) ||
223
+ (jsonData.metadata && jsonData.metadata.description) || "";
224
+ const hasSplit = titleSource.toLowerCase().indexOf(" has ");
225
+ const kicker = hasSplit > 0 ? titleSource.slice(0, hasSplit) : wrapWords(titleSource, 28, 1)[0];
226
+ const displayTitle = hasSplit > 0 ? titleSource.slice(hasSplit + 1) : titleSource.replace(kicker, "").trim() || titleSource;
227
+ const titleLines = wrapWords(displayTitle, 22, 2);
228
+ const subtitleLines = wrapWords(subtitleSource, 52, 4);
229
+
230
+ const centerX = width / 2;
231
+ const page = svg.append("g").attr("class", "hd12-page");
232
+
233
+ page.append("rect")
234
+ .attr("x", 194)
235
+ .attr("y", 42)
236
+ .attr("width", 636)
237
+ .attr("height", 50)
238
+ .attr("rx", 25)
239
+ .attr("fill", "#0c6a35");
240
+ page.append("text")
241
+ .attr("x", centerX)
242
+ .attr("y", 76)
243
+ .attr("text-anchor", "middle")
244
+ .attr("font-family", "Arial, sans-serif")
245
+ .attr("font-size", 24)
246
+ .attr("font-weight", 800)
247
+ .attr("letter-spacing", 3)
248
+ .attr("fill", "#ffffff")
249
+ .text(String(kicker || "").toUpperCase());
250
+
251
+ const titleY = 142;
252
+ titleLines.forEach((line, i) => {
253
+ page.append("text")
254
+ .attr("x", 370)
255
+ .attr("y", titleY + i * 70)
256
+ .attr("text-anchor", "middle")
257
+ .attr("font-family", "Georgia, 'Times New Roman', serif")
258
+ .attr("font-size", titleLines.length > 1 ? 64 : 72)
259
+ .attr("font-weight", 800)
260
+ .attr("fill", i === 0 ? titleDark : greenDark)
261
+ .text(line);
262
+ });
263
+
264
+ const dividerY = titleY + titleLines.length * 70 + 22;
265
+ page.append("line").attr("x1", 282).attr("x2", 470).attr("y1", dividerY).attr("y2", dividerY)
266
+ .attr("stroke", "#6ea448").attr("stroke-width", 2);
267
+ page.append("line").attr("x1", 554).attr("x2", 742).attr("y1", dividerY).attr("y2", dividerY)
268
+ .attr("stroke", "#6ea448").attr("stroke-width", 2);
269
+ page.append("circle").attr("cx", centerX).attr("cy", dividerY).attr("r", 10).attr("fill", "#5d9d35").attr("opacity", 0.9);
270
+
271
+ const subtitleY = dividerY + 52;
272
+ subtitleLines.forEach((line, i) => {
273
+ page.append("text")
274
+ .attr("x", centerX)
275
+ .attr("y", subtitleY + i * 36)
276
+ .attr("text-anchor", "middle")
277
+ .attr("font-family", "Arial, sans-serif")
278
+ .attr("font-size", 23)
279
+ .attr("font-weight", 500)
280
+ .attr("fill", mutedText)
281
+ .text(line);
282
+ });
283
+
284
+ const headerSlot = page.append("g")
285
+ .attr("class", "reserved-asset-slot hd12-header-topic-art-slot")
286
+ .attr("data-asset-slot", "header-topic-art")
287
+ .attr("data-reserved-slot", "header_topic_art")
288
+ .attr("data-slot-policy", "image2_asset")
289
+ .attr("data-asset-source-policy", "image2-generated")
290
+ .attr("data-anchor", "upper-right-header-safe-area")
291
+ .attr("data-bbox", "842 266 126 126")
292
+ .attr("data-collision-rule", "stay-right-of-title-and-above-legend-table;shrink-or-hide-for-long-title")
293
+ .attr("data-mask", "rounded-rect")
294
+ .attr("data-no-embedded-raster", "true")
295
+ .attr("data-no-copied-reference-art", "true")
296
+ .attr("transform", "translate(842,266)");
297
+ headerSlot.append("rect")
298
+ .attr("width", 126)
299
+ .attr("height", 126)
300
+ .attr("rx", 32)
301
+ .attr("fill", "#eaf5df")
302
+ .attr("stroke", "#9ac56d")
303
+ .attr("stroke-width", 2)
304
+ .attr("stroke-dasharray", "8 8")
305
+ .attr("opacity", 0.62);
306
+ headerSlot.append("circle").attr("cx", 45).attr("cy", 68).attr("r", 30).attr("fill", "#77aa43").attr("opacity", 0.18);
307
+ headerSlot.append("rect").attr("x", 78).attr("y", 42).attr("width", 30).attr("height", 56).attr("rx", 12).attr("fill", "#245f2b").attr("opacity", 0.12);
308
+
309
+ const legendX = 104;
310
+ const legendY = 470;
311
+ const legendW = 816;
312
+ const legendH = 86;
313
+ page.append("rect")
314
+ .attr("x", legendX)
315
+ .attr("y", legendY)
316
+ .attr("width", legendW)
317
+ .attr("height", legendH)
318
+ .attr("rx", 24)
319
+ .attr("fill", "#ffffff")
320
+ .attr("stroke", "#d9e4d5")
321
+ .attr("stroke-width", 1.4)
322
+ .attr("filter", "url(#hd12-soft-shadow)");
323
+ page.append("line")
324
+ .attr("x1", legendX + legendW / 2)
325
+ .attr("x2", legendX + legendW / 2)
326
+ .attr("y1", legendY + 18)
327
+ .attr("y2", legendY + legendH - 18)
328
+ .attr("stroke", "#c8d3d2")
329
+ .attr("stroke-width", 1.5);
330
+
331
+ const drawLegendItem = (x, group, color, slotName) => {
332
+ const item = page.append("g").attr("transform", `translate(${x},${legendY + legendH / 2})`);
333
+ const slot = item.append("g")
334
+ .attr("class", "reserved-asset-slot hd12-legend-group-icon-slot")
335
+ .attr("data-asset-slot", "legend-group-icon")
336
+ .attr("data-reserved-slot", slotName)
337
+ .attr("data-slot-policy", "neutral_placeholder")
338
+ .attr("data-asset-source-policy", "neutral-placeholder")
339
+ .attr("data-anchor", `legend-${slotName}`)
340
+ .attr("data-bbox", `${x - 28} ${legendY + 17} 56 52`)
341
+ .attr("data-collision-rule", "stay-inside-legend-card-and-avoid-label")
342
+ .attr("data-no-embedded-raster", "true")
343
+ .attr("data-no-copied-reference-art", "true");
344
+ slot.append("circle").attr("r", 25).attr("fill", color).attr("opacity", 0.18);
345
+ slot.append("circle").attr("r", 18).attr("fill", color).attr("opacity", 0.9);
346
+ item.append("text")
347
+ .attr("x", 46)
348
+ .attr("y", -4)
349
+ .attr("font-family", "Arial, sans-serif")
350
+ .attr("font-size", 22)
351
+ .attr("font-weight", 800)
352
+ .attr("fill", titleDark)
353
+ .text(group);
354
+ item.append("text")
355
+ .attr("x", 46)
356
+ .attr("y", 25)
357
+ .attr("font-family", "Arial, sans-serif")
358
+ .attr("font-size", 17)
359
+ .attr("fill", "#4a5552")
360
+ .text(valueUnit ? `(${valueUnit})` : "value");
361
+ };
362
+ drawLegendItem(148, leftGroup, leftColor, "legend_left_group_icon");
363
+ drawLegendItem(562, rightGroup, rightColor, "legend_right_group_icon");
364
+
365
+ const tableX = 64;
366
+ const tableY = 625;
367
+ const tableW = 896;
368
+ const tableH = Math.min(850, height - tableY - 92);
369
+ const headerH = 60;
370
+ const rowH = (tableH - headerH) / Math.max(rows.length, 1);
371
+ const dimW = 270;
372
+ const leftW = 345;
373
+ const rightW = tableW - dimW - leftW;
374
+ const leftX = tableX + dimW;
375
+ const rightX = leftX + leftW;
376
+
377
+ page.append("rect")
378
+ .attr("x", tableX)
379
+ .attr("y", tableY)
380
+ .attr("width", tableW)
381
+ .attr("height", tableH)
382
+ .attr("rx", 20)
383
+ .attr("fill", "#ffffff")
384
+ .attr("stroke", "#c8d8c8")
385
+ .attr("stroke-width", 1.4)
386
+ .attr("filter", "url(#hd12-soft-shadow)");
387
+ page.append("clipPath")
388
+ .attr("id", "hd12-table-clip")
389
+ .append("rect")
390
+ .attr("x", tableX)
391
+ .attr("y", tableY)
392
+ .attr("width", tableW)
393
+ .attr("height", tableH)
394
+ .attr("rx", 20);
395
+ const table = page.append("g").attr("clip-path", "url(#hd12-table-clip)");
396
+ table.append("rect").attr("x", tableX).attr("y", tableY).attr("width", dimW).attr("height", headerH).attr("fill", "#06482f");
397
+ table.append("rect").attr("x", leftX).attr("y", tableY).attr("width", leftW).attr("height", headerH).attr("fill", "#0b5c30");
398
+ table.append("rect").attr("x", rightX).attr("y", tableY).attr("width", rightW).attr("height", headerH).attr("fill", "#075f9f");
399
+
400
+ const headerText = (x, y, text, anchor) => {
401
+ table.append("text")
402
+ .attr("x", x)
403
+ .attr("y", y)
404
+ .attr("text-anchor", anchor || "middle")
405
+ .attr("font-family", "Arial, sans-serif")
406
+ .attr("font-size", 19)
407
+ .attr("font-weight", 800)
408
+ .attr("letter-spacing", 1.2)
409
+ .attr("fill", "#ffffff")
410
+ .text(text);
411
+ };
412
+ const groupHeaderText = (x, group) => {
413
+ const groupLines = wrapWords(String(group).toUpperCase(), 22, 2);
414
+ groupLines.forEach((line, index) => {
415
+ table.append("text")
416
+ .attr("x", x)
417
+ .attr("y", tableY + 25 + index * 19)
418
+ .attr("text-anchor", "middle")
419
+ .attr("font-family", "Arial, sans-serif")
420
+ .attr("font-size", 17)
421
+ .attr("font-weight", 800)
422
+ .attr("letter-spacing", 0.8)
423
+ .attr("fill", "#ffffff")
424
+ .text(line);
425
+ });
426
+ if (valueUnit) {
427
+ table.append("text")
428
+ .attr("x", x)
429
+ .attr("y", tableY + 52)
430
+ .attr("text-anchor", "middle")
431
+ .attr("font-family", "Arial, sans-serif")
432
+ .attr("font-size", 14)
433
+ .attr("font-weight", 700)
434
+ .attr("fill", "#dbeee0")
435
+ .text(`(${valueUnit})`);
436
+ }
437
+ };
438
+ headerText(tableX + dimW / 2, tableY + 38, String(dimensionField || "State").toUpperCase());
439
+ groupHeaderText(leftX + leftW / 2, leftGroup);
440
+ groupHeaderText(rightX + rightW / 2, rightGroup);
441
+
442
+ [leftX, rightX].forEach(x => {
443
+ table.append("line")
444
+ .attr("x1", x)
445
+ .attr("x2", x)
446
+ .attr("y1", tableY)
447
+ .attr("y2", tableY + tableH)
448
+ .attr("stroke", "#d9e2da")
449
+ .attr("stroke-width", 1.2);
450
+ });
451
+
452
+ const barH = Math.max(16, Math.min(28, rowH * 0.34));
453
+ const leftBarX = leftX + 26;
454
+ const leftBarMaxW = leftW - 165;
455
+ const rightBarX = rightX + 25;
456
+ const rightBarMaxW = rightW - 220;
457
+ const labelFont = rowH < 58 ? 18 : 22;
458
+ const valueFont = rowH < 58 ? 16 : 21;
459
+
460
+ rows.forEach((row, i) => {
461
+ const rowY = tableY + headerH + i * rowH;
462
+ const rowCenter = rowY + rowH / 2;
463
+ table.append("rect")
464
+ .attr("x", tableX)
465
+ .attr("y", rowY)
466
+ .attr("width", tableW)
467
+ .attr("height", rowH)
468
+ .attr("fill", i % 2 === 0 ? "#fbfdf8" : "#ffffff");
469
+ table.append("line")
470
+ .attr("x1", tableX)
471
+ .attr("x2", tableX + tableW)
472
+ .attr("y1", rowY)
473
+ .attr("y2", rowY)
474
+ .attr("stroke", "#dfe7db")
475
+ .attr("stroke-width", 1);
476
+
477
+ const slot = table.append("g")
478
+ .attr("class", "reserved-asset-slot hd12-row-topic-icon-slot")
479
+ .attr("data-asset-slot", "row-topic-icon")
480
+ .attr("data-reserved-slot", `row_topic_icon_${i + 1}`)
481
+ .attr("data-slot-policy", "image2_asset")
482
+ .attr("data-asset-source-policy", "image2-generated")
483
+ .attr("data-anchor", `row-${i + 1}-centerline`)
484
+ .attr("data-bbox", `${tableX + 30} ${rowCenter - 27} 54 54`)
485
+ .attr("data-collision-rule", "stay-inside-dimension-column-and-avoid-label-text")
486
+ .attr("data-mask", "circle")
487
+ .attr("data-no-embedded-raster", "true")
488
+ .attr("data-no-copied-reference-art", "true")
489
+ .attr("transform", `translate(${tableX + 57},${rowCenter})`);
490
+ slot.append("circle").attr("r", 27).attr("fill", i % 2 === 0 ? "#e3f2cd" : "#e5f4f8");
491
+ slot.append("circle").attr("r", 16).attr("fill", i % 2 === 0 ? "#7ab546" : "#5ba4bd").attr("opacity", 0.18);
492
+
493
+ const dimLabel = String(row.dimension);
494
+ const dimLines = wrapWords(dimLabel, rowH < 58 ? 14 : 16, 2);
495
+ dimLines.forEach((line, lineIndex) => {
496
+ table.append("text")
497
+ .attr("x", tableX + 104)
498
+ .attr("y", rowCenter + (lineIndex - (dimLines.length - 1) / 2) * (labelFont + 2) + 7)
499
+ .attr("font-family", "Arial, sans-serif")
500
+ .attr("font-size", labelFont)
501
+ .attr("font-weight", 800)
502
+ .attr("fill", titleDark)
503
+ .text(line);
504
+ });
505
+
506
+ const leftWValue = Math.max(10, (row.leftValue / leftMax) * leftBarMaxW);
507
+ const rightWValue = Math.max(10, (row.rightValue / rightMax) * rightBarMaxW);
508
+ const leftY = rowCenter - barH / 2;
509
+ const rx = Math.min(12, barH / 2);
510
+
511
+ table.append("rect")
512
+ .attr("x", leftBarX)
513
+ .attr("y", leftY)
514
+ .attr("width", leftWValue)
515
+ .attr("height", barH)
516
+ .attr("rx", rx)
517
+ .attr("ry", rx)
518
+ .attr("fill", "url(#hd12-left-grad)");
519
+ table.append("text")
520
+ .attr("x", Math.min(leftBarX + leftWValue + 16, leftX + leftW - 120))
521
+ .attr("y", rowCenter + 7)
522
+ .attr("font-family", "Arial, sans-serif")
523
+ .attr("font-size", valueFont)
524
+ .attr("font-weight", 800)
525
+ .attr("fill", "#2c7a13")
526
+ .text(formatValue(row.leftValue));
527
+
528
+ table.append("rect")
529
+ .attr("x", rightBarX)
530
+ .attr("y", leftY)
531
+ .attr("width", rightWValue)
532
+ .attr("height", barH)
533
+ .attr("rx", rx)
534
+ .attr("ry", rx)
535
+ .attr("fill", "url(#hd12-right-grad)");
536
+ table.append("text")
537
+ .attr("x", Math.min(rightBarX + rightWValue + 18, tableX + tableW - 155))
538
+ .attr("y", rowCenter + 7)
539
+ .attr("font-family", "Arial, sans-serif")
540
+ .attr("font-size", valueFont)
541
+ .attr("font-weight", 800)
542
+ .attr("fill", "#075fbd")
543
+ .text(formatValue(row.rightValue));
544
+
545
+ if (row.leftValue === leftMax || row.rightValue === rightMax) {
546
+ const isLeft = row.leftValue === leftMax;
547
+ const badgeX = isLeft ? leftX + leftW - 8 : tableX + tableW - 34;
548
+ const badgeColor = isLeft ? "#0d7140" : "#0a67c7";
549
+ const badge = table.append("g")
550
+ .attr("class", "hd12-max-value-badge")
551
+ .attr("data-annotation", "max-value")
552
+ .attr("data-group", isLeft ? leftGroup : rightGroup)
553
+ .attr("data-dimension", row.dimension)
554
+ .attr("data-anchor", "row-centerline-value-end")
555
+ .attr("data-collision-rule", "clamp-inside-table-edge-and-avoid-value-label")
556
+ .attr("transform", `translate(${badgeX},${rowCenter})`);
557
+ badge.append("rect")
558
+ .attr("x", -14)
559
+ .attr("y", -14)
560
+ .attr("width", 28)
561
+ .attr("height", 28)
562
+ .attr("rx", 6)
563
+ .attr("fill", badgeColor);
564
+ badge.append("text")
565
+ .attr("x", 0)
566
+ .attr("y", 7)
567
+ .attr("text-anchor", "middle")
568
+ .attr("font-family", "Arial, sans-serif")
569
+ .attr("font-size", 18)
570
+ .attr("font-weight", 900)
571
+ .attr("fill", "#ffffff")
572
+ .attr("data-chart-native-marker", "max")
573
+ .text("★");
574
+ }
575
+ });
576
+
577
+ table.append("line")
578
+ .attr("x1", tableX)
579
+ .attr("x2", tableX + tableW)
580
+ .attr("y1", tableY + tableH)
581
+ .attr("y2", tableY + tableH)
582
+ .attr("stroke", "#c8d8c8")
583
+ .attr("stroke-width", 1.4);
584
+
585
+ page.append("text")
586
+ .attr("x", centerX)
587
+ .attr("y", height - 42)
588
+ .attr("text-anchor", "middle")
589
+ .attr("font-family", "Arial, sans-serif")
590
+ .attr("font-size", 14)
591
+ .attr("fill", "#8b968d")
592
+ .attr("data-ignored-bottom-wrapper", "takeaway-source-footer-omitted")
593
+ .text("");
594
+
595
+ return svg.node();
596
+ }
modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_06.js ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Horizontal Group Bar Chart",
5
+ "chart_name": "horizontal_group_bar_chart_06",
6
+ "chart_for": "comparison",
7
+ "is_composite": false,
8
+ "required_fields": ["x", "y", "group"],
9
+ "required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
10
+ "required_fields_range": [[2, 20], [0, "inf"], [2, 5]],
11
+ "required_fields_icons": [],
12
+ "required_other_icons": [],
13
+ "required_fields_colors": ["group"],
14
+ "required_other_colors": ["primary"],
15
+ "supported_effects": ["shadow", "radius_corner", "gradient", "stroke", "spacing"],
16
+ "min_height": 400,
17
+ "min_width": 400,
18
+ "background": "no",
19
+ "icon_mark": "none",
20
+ "icon_label": "none",
21
+ "has_x_axis": "no",
22
+ "has_y_axis": "no"
23
+ }
24
+ REQUIREMENTS_END
25
+ */
26
+
27
+ // 水平分组条形图实现 - 使用D3.js horizontal_split_bar_chart_06
28
+ function makeChart(containerSelector, data) {
29
+ // ---------- 1. 数据准备阶段 ----------
30
+
31
+ // 提取数据和配置
32
+ const jsonData = data; // 完整的JSON数据对象
33
+ const chartData = jsonData.data.data; // 实际数据点数组
34
+ const variables = jsonData.variables || {}; // 图表配置
35
+ const typography = jsonData.typography || { // 字体设置,如果不存在则使用默认值
36
+ title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
37
+ label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
38
+ description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
39
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
40
+ };
41
+ const colors = jsonData.colors || { text_color: "#333333" }; // 颜色设置
42
+ const colorResolver = chartUtils.color.resolver(jsonData);
43
+ const images = jsonData.images || { field: {}, other: {} }; // 图像设置
44
+ const dataColumns = chartUtils.schema.columns(jsonData); // 数据列定义
45
+
46
+ // 设置视觉效果变量的默认值
47
+ variables.has_rounded_corners = variables.has_rounded_corners || false;
48
+
49
+ // 清空容器
50
+ d3.select(containerSelector).html("");
51
+
52
+ // ---------- 2. 尺寸和布局设置 ----------
53
+
54
+ // 设置图表总尺寸
55
+ const width = variables.width || 800;
56
+ const height = variables.height || 600;
57
+
58
+ // 设置边距
59
+ const margin = {
60
+ top: 100, // 顶部留出标题空间
61
+ right: 30, // 右侧足够显示数值
62
+ bottom: 60, // 底部边距
63
+ left: 60 // 左侧初始空间,用于维度标签和图标
64
+ };
65
+
66
+ // ---------- 3. 提取字段名和单位 ----------
67
+
68
+ // 根据数据列顺序提取字段名
69
+ const dimensionField = chartUtils.schema.channel(jsonData, "x").key;
70
+ const valueField = chartUtils.schema.channel(jsonData, "y").key;
71
+ const groupField = chartUtils.schema.channel(jsonData, "group").key;
72
+
73
+ // 获取字段单位(如果存在)
74
+ let dimensionUnit = "";
75
+ let valueUnit = "";
76
+ let groupUnit = "";
77
+
78
+ if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
79
+ dimensionUnit = chartUtils.schema.channel(jsonData, "x").unit;
80
+ }
81
+
82
+ if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
83
+ valueUnit = chartUtils.schema.channel(jsonData, "y").unit;
84
+ }
85
+ if (chartUtils.schema.channel(jsonData, "group").unit !== "none") {
86
+ groupUnit = chartUtils.schema.channel(jsonData, "group").unit;
87
+ }
88
+
89
+ // ---------- 4. 数据处理 ----------
90
+
91
+ // 添加数值格式化函数
92
+
93
+ // 获取唯一维度值和分组值
94
+ const dimensions = [...new Set(chartData.map(d => d[dimensionField]))];
95
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
96
+
97
+ // 不对数据进行排序,使用原始顺序
98
+
99
+ // ---------- 5. 计算标签宽度 ----------
100
+
101
+ // 创建临时SVG来测量文本宽度
102
+ const tempSvg = d3.select(containerSelector)
103
+ .append("svg")
104
+ .attr("width", 0)
105
+ .attr("height", 0)
106
+ .style("visibility", "hidden");
107
+
108
+ // 初始填充值(会根据barHeight动态调整)
109
+ // const flagPadding = 5; // 不再需要图标填充
110
+
111
+ // 计算最大维度标签宽度
112
+ let maxLabelWidth = 0;
113
+ dimensions.forEach(dimension => {
114
+ // 格式化维度名称(附加单位,如果有)
115
+ const formattedDimension = dimensionUnit ?
116
+ `${dimension}${dimensionUnit}` :
117
+ `${dimension}`;
118
+
119
+ const textWidth = chartUtils.text.measure(tempSvg, formattedDimension, {
120
+ fontFamily: typography.label.font_family,
121
+ fontSize: typography.label.font_size,
122
+ fontWeight: typography.label.font_weight
123
+ }).width;
124
+ const totalWidth = textWidth; // 只考虑文本宽度
125
+
126
+ maxLabelWidth = Math.max(maxLabelWidth, totalWidth);
127
+ });
128
+
129
+ // ��算最大数值标签宽度
130
+ let maxValueWidth = 0;
131
+ chartData.forEach(d => {
132
+ const formattedValue = valueUnit ?
133
+ `${chartUtils.format.autoText(+d[valueField])}${valueUnit}` :
134
+ `${chartUtils.format.autoText(+d[valueField])}`;
135
+
136
+ const textWidth = chartUtils.text.measure(tempSvg, formattedValue, {
137
+ fontFamily: typography.annotation.font_family,
138
+ fontSize: typography.annotation.font_size,
139
+ fontWeight: typography.annotation.font_weight
140
+ }).width;
141
+
142
+ maxValueWidth = Math.max(maxValueWidth, textWidth);
143
+ });
144
+ // 删除临时SVG
145
+ tempSvg.remove();
146
+
147
+ // 根据标签宽度调整左边距(添加一些边距)
148
+ margin.left = Math.max(margin.left, maxLabelWidth + 20);
149
+ margin.right = Math.max(margin.right, maxValueWidth + 20);
150
+
151
+ // 计算内部绘图区域尺寸
152
+ const innerWidth = width - margin.left - margin.right;
153
+ const innerHeight = height - margin.top - margin.bottom;
154
+
155
+ // ---------- 6. 创建SVG容器 ----------
156
+
157
+ const svg = d3.select(containerSelector)
158
+ .append("svg")
159
+ .attr("width", "100%")
160
+ .attr("height", height)
161
+ .attr("viewBox", `0 0 ${width} ${height}`)
162
+ .attr("style", "max-width: 100%; height: auto;")
163
+ .attr("xmlns", "http://www.w3.org/2000/svg")
164
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
165
+
166
+ // 添加defs用于视觉效果
167
+ const defs = svg.append("defs");
168
+
169
+ // 创建斜线纹理模式
170
+ const patternDensity = 6; // 固定斜线密度
171
+ const patternStrokeWidth = 1.5; // 固定斜线宽度
172
+ groups.forEach((group, i) => {
173
+ // 为每个组获取颜色
174
+ const groupColor = colorResolver.field(group, i, { palette: "category10" }).value;
175
+
176
+ // 创建斜线纹理模式
177
+ const patternId = `pattern-${group.replace(/\\s+/g, '-')}-${i}`; // 确保 ID 唯一
178
+ const pattern = defs.append("pattern")
179
+ .attr("id", patternId)
180
+ .attr("patternUnits", "userSpaceOnUse")
181
+ .attr("width", patternDensity) // 使用固定值
182
+ .attr("height", patternDensity) // 使用固定值
183
+ .attr("patternTransform", "rotate(45)");
184
+
185
+ // 添加背景矩形
186
+ pattern.append("rect")
187
+ .attr("width", patternDensity) // 使用固定值
188
+ .attr("height", patternDensity) // 使用固定值
189
+ .attr("fill", groupColor)
190
+ .attr("opacity", 0.8);
191
+
192
+ // 添加斜线
193
+ pattern.append("line")
194
+ .attr("x1", 0)
195
+ .attr("y1", 0)
196
+ .attr("x2", 0)
197
+ .attr("y2", patternDensity) // 使用固定值
198
+ .attr("stroke", "white")
199
+ .attr("stroke-width", patternStrokeWidth) // 使用固定值
200
+ .attr("opacity", 0.6);
201
+ });
202
+
203
+ // ---------- 7. 创建比例尺 ----------
204
+
205
+ // 计算条形的额外间距 - 使用固定值
206
+ const barPadding = 0.1; // 设置固定边距
207
+
208
+ // Y轴比例尺(用于维度)- 使用原始顺序
209
+ const yScale = d3.scaleBand()
210
+ .domain(dimensions)
211
+ .range([0, innerHeight])
212
+ .padding(barPadding);
213
+
214
+ // X轴比例尺(用于数值)
215
+ const xScale = d3.scaleLinear()
216
+ .domain([0, d3.max(chartData, d => +d[valueField])])
217
+ .range([0, innerWidth]);
218
+
219
+ // 颜色比例尺(用于组)
220
+ const colorScale = colorResolver.scale(groups, { palette: "category10" });
221
+ chartUtils.legend.centered(svg, groups, {
222
+ colorScale,
223
+ colorResolver,
224
+ colors,
225
+ }, width / 2, margin.top / 2, {
226
+ markerShape: "rect",
227
+ markerSize: 15,
228
+ labelGap: 5,
229
+ itemGap: legendPadding,
230
+ fontSize: typography.label.font_size,
231
+ fontFamily: typography.label.font_family,
232
+ fontWeight: typography.label.font_weight,
233
+ textColor: colorResolver.text({ fallback: "#333333" }).value,
234
+ markerRadius: variables.has_rounded_corners ? 2 : 0,
235
+ });
236
+ // ---------- 9. 创建主图表组 ----------
237
+
238
+ const g = svg.append("g")
239
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
240
+
241
+ // ---------- 12. 为每个维度绘制分组条形 ----------
242
+
243
+ dimensions.forEach(dimension => {
244
+ // 获取此维度下的所有数据点
245
+ const dimensionData = chartData.filter(d => d[dimensionField] === dimension);
246
+
247
+ if (dimensionData.length > 0) {
248
+ const barHeight = yScale.bandwidth();
249
+
250
+
251
+ const labelY = yScale(dimension) + barHeight / 2;
252
+
253
+ // 添加维度标签
254
+ g.append("text")
255
+ // .attr("x", flagX - 5) // 旧的基于图标位置的 X 坐标
256
+ .attr("x", -10) // 设置新的 X 坐标,例如在 Y 轴左侧 10px 处
257
+ .attr("y", labelY)
258
+ .attr("dy", "0.35em")
259
+ .attr("text-anchor", "end")
260
+ .style("font-family", typography.label.font_family)
261
+ .style("font-size", typography.label.font_size)
262
+ .style("font-weight", typography.label.font_weight)
263
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
264
+ .text(dimension);
265
+
266
+ // 计算每个组的条形高度
267
+ const groupBarHeight = barHeight / groups.length;
268
+
269
+ // 固定圆角半径
270
+ const cornerRadius = 3;
271
+
272
+ // 绘制每个组的条形
273
+ groups.forEach((group, groupIndex) => {
274
+ // 找到此维度和此组的数据点
275
+ const dataPoint = dimensionData.find(d => d[groupField] === group);
276
+
277
+ if (dataPoint) {
278
+ const value = +dataPoint[valueField];
279
+ const barWidth = xScale(value);
280
+
281
+ // 计算垂直位置 - 这里是关键修改,确保条形不重叠
282
+ const groupY = yScale(dimension) + (groupIndex * groupBarHeight);
283
+
284
+ // 绘制条形 - 使用 path 实现右侧圆角
285
+ let pathData;
286
+ // 仅在条形足够宽和高时应用圆角
287
+ if (barWidth >= cornerRadius && groupBarHeight >= 2 * cornerRadius) {
288
+ pathData = `
289
+ M 0,${groupY}
290
+ L ${barWidth - cornerRadius},${groupY}
291
+ A ${cornerRadius},${cornerRadius} 0 0 1 ${barWidth},${groupY + cornerRadius}
292
+ L ${barWidth},${groupY + groupBarHeight - cornerRadius}
293
+ A ${cornerRadius},${cornerRadius} 0 0 1 ${barWidth - cornerRadius},${groupY + groupBarHeight}
294
+ L 0,${groupY + groupBarHeight}
295
+ Z
296
+ `;
297
+ } else {
298
+ // 对于太窄或太矮的条形,绘制普通矩形
299
+ pathData = `
300
+ M 0,${groupY}
301
+ L ${barWidth},${groupY}
302
+ L ${barWidth},${groupY + groupBarHeight}
303
+ L 0,${groupY + groupBarHeight}
304
+ Z
305
+ `;
306
+ }
307
+
308
+ const bar = g.append("path") // 使用 path 替代 rect
309
+ .attr("d", pathData)
310
+ .on("mouseover", function() {
311
+ d3.select(this).attr("opacity", 0.8);
312
+ })
313
+ .on("mouseout", function() {
314
+ d3.select(this).attr("opacity", 1);
315
+ });
316
+
317
+
318
+
319
+ // 应用填充:始终使用纹理
320
+ const patternIdBar = `pattern-${group.replace(/\\s+/g, '-')}-${groupIndex}`;
321
+ bar.attr("fill", `url(#${patternIdBar})`);
322
+
323
+ // 添加数值标签
324
+ const formattedValue = valueUnit ?
325
+ `${chartUtils.format.autoText(value)}${valueUnit}` :
326
+ `${chartUtils.format.autoText(value)}`;
327
+
328
+ g.append("text")
329
+ .attr("x", barWidth + 5)
330
+ .attr("y", groupY + groupBarHeight / 2)
331
+ .attr("dy", "0.35em")
332
+ .attr("text-anchor", "start")
333
+ .style("font-family", typography.annotation.font_family)
334
+ .style("font-size", `${Math.max(groupBarHeight * 0.5, parseFloat(typography.annotation.font_size))}px`)
335
+ .style("font-weight", typography.annotation.font_weight)
336
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
337
+ .text(formattedValue);
338
+ }
339
+ });
340
+ }
341
+ });
342
+
343
+ // 返回SVG节点
344
+ return svg.node();
345
+ }
modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_07.js ADDED
@@ -0,0 +1,433 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Horizontal Group Bar Chart",
5
+ "chart_name": "horizontal_group_bar_chart_07",
6
+ "chart_for": "comparison",
7
+ "is_composite": false,
8
+ "required_fields": ["x", "y", "group"],
9
+ "required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
10
+ "required_fields_range": [[2, 20], [0, "inf"], [2,5]],
11
+ "required_fields_icons": ["x"],
12
+ "required_other_icons": [],
13
+ "required_fields_colors": ["group"],
14
+ "required_other_colors": ["primary"],
15
+ "supported_effects": ["shadow", "radius_corner", "gradient", "stroke", "spacing"],
16
+ "min_height": 400,
17
+ "min_width": 400,
18
+ "background": "no",
19
+ "icon_mark": "none",
20
+ "icon_label": "side",
21
+ "has_x_axis": "no",
22
+ "has_y_axis": "no"
23
+ }
24
+ REQUIREMENTS_END
25
+ */
26
+
27
+ // 水平分组条形图实现 - 使用D3.js - 胶囊数量表示数值
28
+ function makeChart(containerSelector, data) {
29
+ // ---------- 1. 数据准备阶段 ----------
30
+
31
+ // 提取数据和配置
32
+ const jsonData = data; // 完整的JSON数据对象
33
+ const chartData = jsonData.data.data; // 实际数据点数组
34
+ const variables = jsonData.variables || {}; // 图表配置
35
+ const typography = jsonData.typography || { // 字体设置,如果不存在则使用默认值
36
+ title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
37
+ label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
38
+ description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
39
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
40
+ };
41
+ const colors = jsonData.colors || { text_color: "#333333" }; // 颜色设置
42
+ const colorResolver = chartUtils.color.resolver(jsonData);
43
+ const images = jsonData.images || { field: {}, other: {} }; // 图像设置
44
+ const dataColumns = chartUtils.schema.columns(jsonData); // 数据列定义
45
+
46
+ // 设置视觉效果变量的默认值 - 圆角始终启用
47
+ // variables.has_rounded_corners = variables.has_rounded_corners || false;
48
+ variables.has_shadow = variables.has_shadow !== undefined ? variables.has_shadow : false;
49
+ variables.has_gradient = variables.has_gradient !== undefined ? variables.has_gradient : false; // 虽然不用渐变填充,但保留效果选项
50
+ variables.has_stroke = variables.has_stroke !== undefined ? variables.has_stroke : false;
51
+ variables.has_spacing = variables.has_spacing !== undefined ? variables.has_spacing : true; // 胶囊间需要间距
52
+
53
+ // 清空容器
54
+ d3.select(containerSelector).html("");
55
+
56
+ // ---------- 2. 尺寸和布局设置 ----------
57
+
58
+ // 设置图表总尺寸
59
+ const width = variables.width || 800;
60
+ const height = variables.height || 600;
61
+
62
+ // 设置边距 - 右侧需要重新计算
63
+ const margin = {
64
+ top: 100, // 顶部留出标题空间
65
+ right: 30, // 初始右侧边距,稍后调整
66
+ bottom: 60, // 底部边距
67
+ left: 60 // 左侧初始空间,用于维度标签和图标
68
+ };
69
+
70
+ // ---------- 3. 提取字段名和单位 ----------
71
+
72
+ // 根据数据列顺序提取字段名
73
+ const dimensionField = chartUtils.schema.channel(jsonData, "x").key;
74
+ const valueField = chartUtils.schema.channel(jsonData, "y").key;
75
+ const groupField = chartUtils.schema.channel(jsonData, "group").key;
76
+
77
+ // 获取字段单位(如果存在)
78
+ let dimensionUnit = "";
79
+ let valueUnit = "";
80
+ let groupUnit = "";
81
+
82
+ if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
83
+ dimensionUnit = chartUtils.schema.channel(jsonData, "x").unit;
84
+ }
85
+
86
+ if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
87
+ valueUnit = chartUtils.schema.channel(jsonData, "y").unit;
88
+ }
89
+ if (chartUtils.schema.channel(jsonData, "group").unit !== "none") {
90
+ groupUnit = chartUtils.schema.channel(jsonData, "group").unit;
91
+ }
92
+
93
+ // ---------- 4. 数据处理与值缩放 ----------
94
+
95
+ // 获取唯一维度值和分组值
96
+ const dimensions = [...new Set(chartData.map(d => d[dimensionField]))];
97
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
98
+
99
+ // 找出最大值,以确定胶囊数量
100
+ const overallMax = d3.max(chartData, d => Math.abs(+d[valueField])) || 0;
101
+
102
+ // 胶囊数量计算
103
+ const maxCapsules = 30; // 每组最多显示30个胶囊
104
+ // 计算每个胶囊代表的数值(处理 overallMax 为 0 的情况)
105
+ const valuePerCapsule = overallMax > 0 ? Math.ceil(overallMax / maxCapsules) : 1;
106
+
107
+ // 计算值到胶囊数量的映射函数
108
+ const valueToCapsules = (value) => {
109
+ if (valuePerCapsule === 0) return 0; // 避免除以零
110
+ return Math.min(maxCapsules, Math.ceil(Math.abs(value) / valuePerCapsule));
111
+ };
112
+
113
+ // 添加数值格式化函数
114
+
115
+ // ---------- 5. 计算标签与布局 ----------
116
+
117
+ // 图标和胶囊尺寸配置
118
+ const iconSizeRatio = 0.6; // 图标高度占维度带宽的比例
119
+ const maxIconSize = 25; // 图标最大尺寸
120
+ const iconPadding = 5; // 图标和标签/胶囊之间的间距
121
+ const capsuleWidth = 5; // 单个胶囊宽度
122
+ const capsuleSpacing = 2; // 胶囊之间的间距
123
+ const labelMeasureOptions = {
124
+ fontFamily: typography.label.font_family,
125
+ fontSize: typography.label.font_size,
126
+ fontWeight: typography.label.font_weight
127
+ };
128
+ const annotationMeasureOptions = {
129
+ fontFamily: typography.annotation.font_family,
130
+ fontSize: typography.annotation.font_size,
131
+ fontWeight: typography.annotation.font_weight
132
+ };
133
+
134
+ // 计算最大维度标签宽度
135
+ let maxDimLabelWidth = 0;
136
+ dimensions.forEach(dimension => {
137
+ const formattedDimension = dimensionUnit ? `${dimension}${dimensionUnit}` : `${dimension}`;
138
+ const textWidth = chartUtils.text.measure(null, formattedDimension, labelMeasureOptions).width;
139
+ maxDimLabelWidth = Math.max(maxDimLabelWidth, textWidth);
140
+ });
141
+
142
+ // 计算最大数值标签宽度 (基于最大可能值和单位)
143
+ let maxValueLabelWidth = 0;
144
+ const maxPossibleValue = overallMax;
145
+ // 使用 formatValue 和 valueUnit 来计算最大值标签宽度
146
+ const formattedMaxValueWithUnit = `${chartUtils.format.autoText(maxPossibleValue)}${valueUnit}`;
147
+ maxValueLabelWidth = chartUtils.text.measure(null, formattedMaxValueWithUnit, annotationMeasureOptions).width;
148
+
149
+ // 计算图例项宽度和总宽度
150
+ const legendItemWidths = [];
151
+ let totalLegendWidth = 0;
152
+ const legendPadding = 15; // 图例项之间的间距 (增加间距)
153
+ const legendCapsuleWidth = 12; // 图例胶囊宽度
154
+ const legendCapsuleHeight = 8; // 图例胶囊高度
155
+ const legendTextPadding = 5; // 图例胶囊和文本之间的间距
156
+
157
+ groups.forEach(group => {
158
+ const textWidth = chartUtils.text.measure(null, group, labelMeasureOptions).width;
159
+ // 图例项宽度 = 胶囊宽度 + 间距 + 文本宽度 + 右侧间距
160
+ const legendItemWidth = legendCapsuleWidth + legendTextPadding + textWidth + legendPadding;
161
+ legendItemWidths.push(legendItemWidth);
162
+ totalLegendWidth += legendItemWidth;
163
+ });
164
+
165
+ // 重新计算左边距以包含图标
166
+ // 左边距 = 最大标签宽度 + 标签与图标间距 + 图标最大宽度 + 图标与条形图间距
167
+ const labelIconPadding = 5; // 标签和图标之间的间距
168
+ const iconBarPadding = 10; // 图标和条形图开始位置的间距
169
+ margin.left = Math.max(margin.left, maxDimLabelWidth + labelIconPadding + maxIconSize + iconBarPadding);
170
+
171
+ // 计算所需的最大右侧宽度 (保持不变)
172
+ const maxCapsulesWidth = maxCapsules * (capsuleWidth + capsuleSpacing);
173
+ const requiredRightWidth = maxCapsulesWidth + maxValueLabelWidth + 10; // 10px 额外间距
174
+ margin.right = Math.max(margin.right, requiredRightWidth);
175
+
176
+ // 计算内部绘图区域尺寸
177
+ const innerWidth = width - margin.left - margin.right;
178
+ const innerHeight = height - margin.top - margin.bottom;
179
+
180
+ // ---------- 6. 创建SVG容器与效果 ----------
181
+
182
+ const svg = d3.select(containerSelector)
183
+ .append("svg")
184
+ .attr("width", "100%")
185
+ .attr("height", height)
186
+ .attr("viewBox", `0 0 ${width} ${height}`)
187
+ .attr("style", "max-width: 100%; height: auto;")
188
+ .attr("xmlns", "http://www.w3.org/2000/svg")
189
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
190
+
191
+ // 添加defs用于视觉效果 (保留阴影和纹理)
192
+ const defs = svg.append("defs");
193
+
194
+ const getColor = (group) => {
195
+ if (colorResolver.field(group, 0, { fallbackKey: "primary" }).value) {
196
+ return colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
197
+ }
198
+
199
+ }
200
+
201
+ if (variables.has_shadow) {
202
+ const filter = defs.append("filter")
203
+ .attr("id", "shadow")
204
+ .attr("filterUnits", "userSpaceOnUse")
205
+ .attr("width", "200%")
206
+ .attr("height", "200%");
207
+
208
+ filter.append("feGaussianBlur")
209
+ .attr("in", "SourceAlpha")
210
+ .attr("stdDeviation", 3);
211
+
212
+ filter.append("feOffset")
213
+ .attr("dx", 2)
214
+ .attr("dy", 2)
215
+ .attr("result", "offsetblur");
216
+
217
+ const feMerge = filter.append("feMerge");
218
+ feMerge.append("feMergeNode");
219
+ feMerge.append("feMergeNode").attr("in", "SourceGraphic");
220
+ }
221
+
222
+ // 创建斜线纹理模式 (包含渐变背景)
223
+ const patternDensity = 6; // 固定斜线密度
224
+ const patternStrokeWidth = 1.5; // 固定斜线宽度
225
+ groups.forEach((group, i) => {
226
+ const groupColor = getColor(group);
227
+ const patternId = `pattern-${group.replace(/[^a-zA-Z0-9]/g, '-')}-${i}`; // 基�� group 生成 ID
228
+ const gradientId = `pattern-gradient-${group.replace(/[^a-zA-Z0-9]/g, '-')}-${i}`; // 渐变 ID
229
+
230
+ // 1. 定义 pattern 内使用的渐变
231
+ const gradient = defs.append("linearGradient")
232
+ .attr("id", gradientId)
233
+ .attr("x1", "0%").attr("y1", "0%")
234
+ .attr("x2", "100%").attr("y2", "0%"); // 水平从左到右
235
+
236
+ // 渐变颜色:从深到亮
237
+ gradient.append("stop").attr("offset", "0%").attr("stop-color", colorResolver.variant(groupColor, { mode: "darker", amount: 0.7 }));
238
+ gradient.append("stop").attr("offset", "100%").attr("stop-color", colorResolver.variant(groupColor, { mode: "brighter", amount: 1.2 }));
239
+
240
+ // 2. 定义 pattern
241
+ const pattern = defs.append("pattern")
242
+ .attr("id", patternId)
243
+ .attr("patternUnits", "userSpaceOnUse")
244
+ .attr("width", patternDensity)
245
+ .attr("height", patternDensity)
246
+ .attr("patternTransform", "rotate(45)");
247
+
248
+ // 3. 在 pattern 内添加使用上面定义的渐变的背景矩形
249
+ pattern.append("rect")
250
+ .attr("width", patternDensity)
251
+ .attr("height", patternDensity)
252
+ .attr("fill", `url(#${gradientId})`); // 使用 pattern 内的渐变填充
253
+
254
+ // 4. 在 pattern 内添加白色斜线 (覆盖在渐变背景上)
255
+ pattern.append("line")
256
+ .attr("x1", 0).attr("y1", 0).attr("x2", 0).attr("y2", patternDensity)
257
+ .attr("stroke", "white").attr("stroke-width", patternStrokeWidth).attr("opacity", 0.6);
258
+ });
259
+
260
+ // ---------- 7. 创建比例尺 ----------
261
+
262
+ // Y轴比例尺(用于维度)- 使用原始顺序
263
+ const yScale = d3.scaleBand()
264
+ .domain(dimensions)
265
+ .range([0, innerHeight])
266
+ .padding(0.3); // 维度组之间的间距
267
+
268
+ // ---------- 8. 创建图例 ----------
269
+
270
+ // 添加图例 (修改为水平胶囊,与数据条一致,并实现自动换行)
271
+ const legendGroup = svg.append("g")
272
+ .attr("class", "legend")
273
+ .attr("transform", `translate(${margin.left}, ${margin.top *0.7})`); // 从左边距开始
274
+
275
+ // 图例项样式参数
276
+ const legendCapsuleHeightH = 10; // 水平胶囊的高度
277
+ const legendCapsuleWidthH = 15; // 水平胶囊的宽度
278
+ const legendVerticalSpacing = 10; // 行之间的垂直间距
279
+ const rowHeight = legendCapsuleHeightH + legendVerticalSpacing;
280
+
281
+ // 测量图例项宽度
282
+ groups.forEach(group => {
283
+ const textWidth = chartUtils.text.measure(svg, group, labelMeasureOptions).width;
284
+ // 图例项宽度 = 胶囊宽度 + 间距 + 文本宽度
285
+ const itemWidth = legendCapsuleWidthH + legendTextPadding + textWidth;
286
+ legendItemWidths.push(itemWidth);
287
+ });
288
+
289
+ chartUtils.legend.draw(svg, groups, {
290
+ color: (group, index) => `url(#pattern-${group.replace(/[^a-zA-Z0-9]/g, '-')}-${index})`,
291
+ colorResolver,
292
+ colors,
293
+ }, {
294
+ x: margin.left,
295
+ y: margin.top * 0.7,
296
+ maxWidth: width - 100,
297
+ markerShape: "capsule",
298
+ markerWidth: legendCapsuleWidthH,
299
+ markerHeight: legendCapsuleHeightH,
300
+ labelGap: legendTextPadding,
301
+ itemGap: legendPadding,
302
+ rowGap: legendVerticalSpacing,
303
+ fontSize: typography.label.font_size,
304
+ fontFamily: typography.label.font_family,
305
+ fontWeight: typography.label.font_weight,
306
+ textColor: colorResolver.text({ fallback: "#333333" }).value,
307
+ });
308
+
309
+ // ---------- 9. 创建主图表组 ----------
310
+
311
+ const g = svg.append("g")
312
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
313
+
314
+ // 获取描边颜色(如果启用描边)
315
+ const getStrokeColor = (barColor) => {
316
+ return variables.has_stroke ? colorResolver.variant(barColor, { mode: "darker", amount: 0.5 }) : "none";
317
+ };
318
+
319
+ // ---------- 10. 为每个维度绘制分组胶囊 ----------
320
+
321
+ dimensions.forEach(dimension => {
322
+ // 获取此维度下的所有数据点
323
+ const dimensionData = chartData.filter(d => d[dimensionField] === dimension);
324
+
325
+ if (dimensionData.length > 0) {
326
+ const dimensionY = yScale(dimension);
327
+ const dimensionBandwidth = yScale.bandwidth();
328
+
329
+ // 计算每个组的胶囊行高度 (考虑组间间距)
330
+ const groupSpacingRatio = 0.1; // 组间距占行高的比例
331
+ const numGroups = groups.length;
332
+ const totalSpacingHeight = dimensionBandwidth * groupSpacingRatio * (numGroups > 1 ? numGroups - 1 : 0);
333
+ const availableGroupHeight = dimensionBandwidth - totalSpacingHeight;
334
+ const groupCapsuleHeight = availableGroupHeight / numGroups;
335
+ const finalCapsuleHeight = Math.max(4, groupCapsuleHeight); // 最小高度 4px
336
+ const groupSpacing = dimensionBandwidth * groupSpacingRatio; // 实际间距像素值
337
+
338
+ // 计算图标尺寸和位置
339
+ const iconSize = Math.min(maxIconSize, dimensionBandwidth * iconSizeRatio);
340
+ const iconY = dimensionY + (dimensionBandwidth - iconSize) / 2; // 图标垂直居中
341
+ const iconAvailable = images.field && images.field[dimension];
342
+
343
+ // --- Corrected Icon and Label Positioning ---
344
+ let finalLabelX;
345
+ const labelCenterY = dimensionY + dimensionBandwidth / 2;
346
+
347
+ if (iconAvailable) {
348
+ // Position icon just left of the bar area (x=0)
349
+ const iconX = -iconBarPadding - iconSize;
350
+ // Position label left of the icon
351
+ finalLabelX = iconX - labelIconPadding;
352
+
353
+ // 添加维度图标
354
+ g.append("image")
355
+ .attr("x", iconX)
356
+ .attr("y", iconY)
357
+ .attr("width", iconSize)
358
+ .attr("height", iconSize)
359
+ .attr("preserveAspectRatio", "xMidYMid meet")
360
+ .attr("xlink:href", images.field[dimension]);
361
+ } else {
362
+ // Position label just left of the bar area (x=0)
363
+ finalLabelX = -iconBarPadding;
364
+ }
365
+
366
+ // 添加维度标签 (使用计算好的 finalLabelX)
367
+ g.append("text")
368
+ .attr("x", finalLabelX)
369
+ .attr("y", labelCenterY)
370
+ .attr("dy", "0.35em")
371
+ .attr("text-anchor", "end")
372
+ .style("font-family", typography.label.font_family)
373
+ .style("font-size", typography.label.font_size)
374
+ .style("font-weight", typography.label.font_weight)
375
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
376
+ .text(dimension);
377
+
378
+ // --- End of Corrected Positioning ---
379
+
380
+ // 绘制每个组的胶囊
381
+ groups.forEach((group, groupIndex) => {
382
+ const dataPoint = dimensionData.find(d => d[groupField] === group);
383
+
384
+ if (dataPoint) {
385
+ const value = +dataPoint[valueField];
386
+ const capsuleCount = valueToCapsules(value);
387
+
388
+ // 计算当前组胶囊的垂直起始位置 (包含组间距)
389
+ const groupY = dimensionY + (groupIndex * (finalCapsuleHeight + groupSpacing));
390
+
391
+ // 绘制胶囊
392
+ for (let j = 0; j < capsuleCount; j++) {
393
+ const capsuleX = j * (capsuleWidth + capsuleSpacing);
394
+
395
+ g.append("rect")
396
+ .attr("x", capsuleX)
397
+ .attr("y", groupY)
398
+ .attr("width", capsuleWidth)
399
+ .attr("height", finalCapsuleHeight)
400
+ // 应用包含渐变的纹理填充
401
+ .attr("fill", `url(#pattern-${group.replace(/[^a-zA-Z0-9]/g, '-')}-${groupIndex})`)
402
+ // 圆角
403
+ .attr("rx", capsuleWidth / 2)
404
+ .attr("ry", capsuleWidth / 2) // 使用宽度的一半确保胶囊状
405
+ .style("stroke", getStrokeColor(getColor(group)))
406
+ .style("stroke-width", variables.has_stroke ? 1 : 0)
407
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "none");
408
+ }
409
+
410
+ // 添加数值标签
411
+ const formattedValue = `${chartUtils.format.autoText(value)}${valueUnit}`;
412
+ // 定位在最后一个胶囊之后
413
+ const lastCapsuleEnd = capsuleCount * (capsuleWidth + capsuleSpacing) - capsuleSpacing; // 最后一个胶囊的结束X坐标 (如果 count > 0)
414
+ const labelX = (capsuleCount > 0 ? lastCapsuleEnd : 0) + 5; // 如果没有胶囊,标签从0开始,否则从最后一个胶囊后5px开始
415
+
416
+ g.append("text")
417
+ .attr("x", labelX)
418
+ .attr("y", groupY + finalCapsuleHeight / 2) // 垂直居中于胶囊行
419
+ .attr("dy", "0.35em")
420
+ .attr("text-anchor", "start")
421
+ .style("font-family", typography.annotation.font_family)
422
+ .style("font-size", `${Math.max(finalCapsuleHeight * 0.5, parseFloat(typography.annotation.font_size))}px`)
423
+ .style("font-weight", typography.annotation.font_weight)
424
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
425
+ .text(formattedValue);
426
+ }
427
+ });
428
+ }
429
+ });
430
+
431
+ // 返回SVG节点
432
+ return svg.node();
433
+ }
modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_10.js ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Horizontal Group Bar Chart",
5
+ "chart_name": "horizontal_group_bar_chart_10",
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, 5]],
10
+ "required_fields_icons": ["x"],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": ["group"],
13
+ "required_other_colors": ["primary"],
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 //horizontal_grouped_bar_chart_05
27
+ function makeChart(containerSelector, 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: "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 || { text_color: "#333333" }; // 颜色设置
41
+ const colorResolver = chartUtils.color.resolver(jsonData);
42
+ const images = jsonData.images || { field: {}, other: {} }; // 图像设置
43
+ const dataColumns = chartUtils.schema.columns(jsonData); // 数据列定义
44
+
45
+ // 清空容器
46
+ d3.select(containerSelector).html("");
47
+
48
+ // ---------- 2. 尺寸和布局设置 ----------
49
+
50
+ // 设置图表总尺寸
51
+ const width = variables.width || 800;
52
+ const height = variables.height || 600;
53
+
54
+ // 设置边距
55
+ const margin = {
56
+ top: 40, // 顶部留出标题空间
57
+ right: 60, // 右侧足够显示数值和分组标签
58
+ bottom: 30, // 底部边距
59
+ left: 40 // 左侧空间减少,因为我们不显示维度文本,只显示图标
60
+ };
61
+
62
+ // ---------- 3. 提取字段名和单位 ----------
63
+
64
+ // 根据数据列顺序提取字段名
65
+ const dimensionField = chartUtils.schema.channel(jsonData, "x").key;
66
+ const valueField = chartUtils.schema.channel(jsonData, "y").key;
67
+
68
+ const primaryGroupField = chartUtils.schema.channel(jsonData, "group").key;
69
+
70
+ // 获取字段单位(如果存在)
71
+ let dimensionUnit = "";
72
+ let valueUnit = "";
73
+
74
+ if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
75
+ dimensionUnit = chartUtils.schema.channel(jsonData, "x").unit;
76
+ }
77
+
78
+ if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
79
+ valueUnit = chartUtils.schema.channel(jsonData, "y").unit;
80
+ }
81
+
82
+ // ---------- 4. 数据处理 ----------
83
+
84
+ // 添加数值格式化函数
85
+
86
+ // 获取唯一维度值和分组值
87
+ const dimensions = [...new Set(chartData.map(d => d[dimensionField]))];
88
+ const primaryGroups = [...new Set(chartData.map(d => d[primaryGroupField]))];
89
+
90
+ // 按主要分组和数值进行分组和排序
91
+ const groupedData = {};
92
+ primaryGroups.forEach(group => {
93
+ // 过滤属于此组的数据
94
+ const groupItems = chartData.filter(d => d[primaryGroupField] === group);
95
+
96
+ // 按数值降序排序
97
+ const sortedItems = [...groupItems].sort((a, b) => +b[valueField] - +a[valueField]);
98
+
99
+ groupedData[group] = sortedItems;
100
+ });
101
+
102
+ // ---------- 5. 计算标签宽度 ----------
103
+
104
+ // 图标尺寸
105
+ const flagWidth = 20;
106
+ const flagHeight = 15;
107
+ const flagPadding = 5;
108
+ const labelMeasureOptions = {
109
+ fontFamily: typography.label.font_family,
110
+ fontSize: typography.label.font_size,
111
+ fontWeight: typography.label.font_weight
112
+ };
113
+ const annotationMeasureOptions = {
114
+ fontFamily: typography.annotation.font_family,
115
+ fontSize: typography.annotation.font_size,
116
+ fontWeight: typography.annotation.font_weight
117
+ };
118
+
119
+ // 计算最大数值标签宽度
120
+ let maxValueWidth = 0;
121
+ chartData.forEach(d => {
122
+ const formattedValue = valueUnit ?
123
+ `${chartUtils.format.autoText(+d[valueField])}${valueUnit}` :
124
+ `${chartUtils.format.autoText(+d[valueField])}`;
125
+
126
+ const textWidth = chartUtils.text.measure(null, formattedValue, annotationMeasureOptions).width;
127
+
128
+ maxValueWidth = Math.max(maxValueWidth, textWidth);
129
+ });
130
+
131
+ // *** BEGIN ADDITION: Calculate Max Dimension Label Width ***
132
+ let maxDimensionLabelWidth = 0;
133
+ dimensions.forEach(dimension => {
134
+ const formattedDimension = dimensionUnit ?
135
+ `${dimension}${dimensionUnit}` :
136
+ `${dimension}`;
137
+
138
+ const textWidth = chartUtils.text.measure(null, formattedDimension, labelMeasureOptions).width;
139
+ maxDimensionLabelWidth = Math.max(maxDimensionLabelWidth, textWidth);
140
+ });
141
+ // *** END ADDITION ***
142
+
143
+ // 计算分组注释的最大宽度
144
+ let maxGroupNoteWidth = 0;
145
+ primaryGroups.forEach(group => {
146
+ const textWidth = chartUtils.text.measure(null, group, labelMeasureOptions).width;
147
+
148
+ maxGroupNoteWidth = Math.max(maxGroupNoteWidth, textWidth);
149
+ });
150
+
151
+ // ** MODIFY ** 根据图标和最大标签尺寸调整左边距
152
+ const baseLeftPadding = 10; // Base padding to the left of the labels
153
+ margin.left = maxDimensionLabelWidth + flagPadding + flagWidth + flagPadding + baseLeftPadding;
154
+
155
+
156
+ // ---------- 6. 计算分组布局 ----------
157
+
158
+ // 计算内部绘图区域尺寸
159
+ const innerWidth = width - margin.left - margin.right;
160
+ const innerHeight = height - margin.top - margin.bottom;
161
+
162
+ // 计算合适的条形高度,确保所有条形在可用高度内可见
163
+ // 计算总条形数量和组数
164
+ let totalBars = 0;
165
+ primaryGroups.forEach(group => {
166
+ totalBars += groupedData[group].length;
167
+ });
168
+
169
+ const numGroups = primaryGroups.length;
170
+
171
+ // 计算组间间距的总高度
172
+ const groupPaddingRatio = 0.15; // 组间距占总高度的比例
173
+ const groupPadding = innerHeight * groupPaddingRatio / (numGroups + 1); // 分配给每个组间距
174
+ const totalGroupPadding = groupPadding * (numGroups - 1);
175
+
176
+ // 计算可用于条形的总高度
177
+ const availableForBars = innerHeight - totalGroupPadding;
178
+
179
+ // 计算条形高度和条形间距
180
+ const barPaddingRatio = 0.15; // Use fixed padding ratio, remove spacing effect logic
181
+
182
+ // 计算理想的条形高度(包括间距)
183
+ const idealBarUnitHeight = availableForBars / totalBars;
184
+
185
+ // 初始计算实际条形高度(不包括间距)
186
+ let calculatedBarHeight = idealBarUnitHeight * (1 - barPaddingRatio);
187
+
188
+ // 限制最大条形高度
189
+ const barHeight = Math.min(calculatedBarHeight, 40);
190
+
191
+ // 根据最终的条形高度计算条形间距,以保持总单元高度
192
+ const barPadding = idealBarUnitHeight - barHeight;
193
+
194
+ // 计算每组的高度和偏移量
195
+ const groupHeights = {};
196
+ const groupOffsets = {};
197
+
198
+ let currentOffset = margin.top;
199
+ primaryGroups.forEach(group => {
200
+ const itemCount = groupedData[group].length;
201
+ // 计算当前组的总高度(包括所有条形和条形间距)
202
+ const groupHeight = itemCount * barHeight + (itemCount - 1) * barPadding;
203
+
204
+ groupHeights[group] = groupHeight;
205
+ groupOffsets[group] = currentOffset;
206
+
207
+ // 更新下一组的起始位置
208
+ currentOffset += groupHeight + groupPadding;
209
+ });
210
+
211
+ // ---------- 7. 创建SVG容器 ----------
212
+
213
+ const svg = d3.select(containerSelector)
214
+ .append("svg")
215
+ .attr("width", "100%")
216
+ .attr("height", height) // 使用定义的高度,不进行自动调整
217
+ .attr("viewBox", `0 0 ${width} ${height}`)
218
+ .attr("style", "max-width: 100%; height: auto;")
219
+ .attr("xmlns", "http://www.w3.org/2000/svg")
220
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
221
+
222
+ // ---------- 8. 创建比例尺 ----------
223
+
224
+ // X轴比例尺(用于数值)
225
+ const xScale = d3.scaleLinear()
226
+ .domain([0, d3.max(chartData, d => +d[valueField]) * 1.05]) // 添加5%边距
227
+ .range([0, innerWidth]);
228
+
229
+ // ---------- 9. 创建主图表组 ----------
230
+
231
+ const g = svg.append("g")
232
+ .attr("transform", `translate(${margin.left}, 0)`);
233
+
234
+ // ---------- 10. 渲染每个分组 ----------
235
+
236
+ // 获取条形颜色的辅助函数
237
+ const getBarColor = (group) => {
238
+ return colorResolver.field(group, 0, { fallbackKey: "primary", fallback: "#3ca951" }).value;
239
+ };
240
+
241
+ // 在条形左侧添加灰色竖线
242
+ g.append("line")
243
+ .attr("x1", 0)
244
+ .attr("y1", margin.top)
245
+ .attr("x2", 0)
246
+ .attr("y2", height - margin.bottom)
247
+ .attr("stroke", "#cccccc")
248
+ .attr("stroke-width", 3);
249
+ // 遍历每个主要分组,添加背景矩形
250
+ primaryGroups.forEach(group => {
251
+ const groupData = groupedData[group];
252
+ const groupOffset = groupOffsets[group];
253
+ const groupHeight = groupHeights[group];
254
+ const barColor = getBarColor(group);
255
+
256
+ // 添加分组注释
257
+ if (groupData.length > 0) {
258
+ const lastItemIndex = groupData.length - 1;
259
+ const lastItemY = groupOffset + lastItemIndex * (barHeight + barPadding) + barHeight / 2;
260
+
261
+ g.append("text")
262
+ .attr("x", width - margin.left - 10)
263
+ .attr("y", lastItemY)
264
+ .attr("dy", "0.35em")
265
+ .attr("text-anchor", "end")
266
+ .style("font-family", typography.label.font_family) // 使用label字体
267
+ .style("font-size", typography.label.font_size)
268
+ .style("font-weight", typography.label.font_weight)
269
+ .style("fill", colorResolver.text({ fallback: "#000000" }).value)
270
+
271
+ .text(group);
272
+ }
273
+
274
+ // 为组中的每个项目绘制条形和标签
275
+ groupData.forEach((dataPoint, index) => {
276
+ // 计算条形的y坐标,考虑到条形间距
277
+ const yPos = groupOffset + index * (barHeight + barPadding);
278
+ const barWidth = xScale(+dataPoint[valueField]);
279
+
280
+ // Draw the bar using a simple rect element
281
+ g.append("rect") // Revert back to rect
282
+ .attr("x", 0)
283
+ .attr("y", yPos)
284
+ .attr("width", barWidth)
285
+ .attr("height", barHeight)
286
+ .attr("fill", barColor) // Use solid bar color
287
+ .attr("rx", barHeight/6)
288
+ .attr("ry", barHeight/6)
289
+ .on("mouseover", function() {
290
+ d3.select(this).attr("opacity", 0.8);
291
+ })
292
+ .on("mouseout", function() {
293
+ d3.select(this).attr("opacity", 1);
294
+ });
295
+
296
+ // 提取维度
297
+ const dimension = dataPoint[dimensionField];
298
+
299
+ // 添加国家/维度图标 (Positioned left of bar)
300
+ const flagX = -flagWidth - flagPadding; // Icon right edge at -flagPadding
301
+ const flagY = yPos + (barHeight - flagHeight) / 2;
302
+
303
+ if (images.field && images.field[dimension]) {
304
+ g.append("image")
305
+ .attr("x", flagX)
306
+ .attr("y", flagY)
307
+ .attr("width", flagWidth)
308
+ .attr("height", flagHeight)
309
+ .attr("preserveAspectRatio","xMidYMid meet")
310
+ .attr("xlink:href", images.field[dimension]);
311
+ }
312
+
313
+ // *** BEGIN ADDITION: Add Dimension Label (Left of Icon, Right Aligned) ***
314
+ const labelX = flagX - flagPadding; // Label right edge next to icon's left edge
315
+ const labelY = yPos + barHeight / 2; // Vertically centered with bar
316
+ const formattedDimension = dimensionUnit ?
317
+ `${dimension}${dimensionUnit}` :
318
+ `${dimension}`;
319
+
320
+ g.append("text")
321
+ .attr("class", "dimension-label")
322
+ .attr("x", labelX)
323
+ .attr("y", labelY)
324
+ .attr("dy", "0.35em")
325
+ .attr("text-anchor", "end") // Right align text
326
+ .style("font-family", typography.label.font_family)
327
+ .style("font-size", typography.label.font_size)
328
+ .style("font-weight", typography.label.font_weight)
329
+ .style("fill", colorResolver.text({ fallback: "#000000" }).value)
330
+ .text(formattedDimension);
331
+ // *** END ADDITION ***
332
+
333
+ // 添加数值标签
334
+ const formattedValue = valueUnit ?
335
+ `${chartUtils.format.autoText(+dataPoint[valueField])}${valueUnit}` :
336
+ `${chartUtils.format.autoText(+dataPoint[valueField])}`;
337
+
338
+ // --- BEGIN MODIFICATION: Conditional Label Placement ---
339
+ const valueLabelFontSize = `${barHeight * 0.7}px`;
340
+ const textWidth = chartUtils.text.measure(g, formattedValue, {
341
+ fontFamily: typography.annotation.font_family,
342
+ fontSize: valueLabelFontSize,
343
+ fontWeight: typography.annotation.font_weight
344
+ }).width;
345
+
346
+ const labelPadding = 10; // Padding for inside label (5px each side)
347
+
348
+ // Decide label position and color
349
+ let valueLabelX, valueLabelAnchor, valueLabelFill;
350
+ if (textWidth + labelPadding < barWidth) {
351
+ // Place inside, centered, white
352
+ valueLabelX = barWidth / 2;
353
+ valueLabelAnchor = "middle";
354
+ valueLabelFill = "white";
355
+ } else {
356
+ // Place outside, right, default color
357
+ valueLabelX = barWidth + 5;
358
+ valueLabelAnchor = "start";
359
+ valueLabelFill = colorResolver.text({ fallback: "#000000" }).value;
360
+ }
361
+
362
+ // Create the actual value label with calculated properties
363
+ g.append("text")
364
+ .attr("x", valueLabelX)
365
+ .attr("y", yPos + barHeight / 2)
366
+ .attr("dy", "0.35em")
367
+ .attr("text-anchor", valueLabelAnchor)
368
+ .style("font-family", typography.annotation.font_family)
369
+ .style("font-size", `${barHeight * 0.7}px`)
370
+ .style("font-weight", typography.annotation.font_weight)
371
+ .style("fill", valueLabelFill)
372
+ .text(formattedValue);
373
+ // --- END MODIFICATION ---
374
+ });
375
+ });
376
+
377
+ // 返回SVG节点
378
+ return svg.node();
379
+ }
modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_11.js ADDED
@@ -0,0 +1,393 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Horizontal Group Bar Chart",
5
+ "chart_name": "horizontal_group_bar_chart_11",
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, 5]],
10
+ "required_fields_icons": ["x"],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": ["group"],
13
+ "required_other_colors": ["primary"],
14
+ "supported_effects": ["gradient"],
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 //horizontal_grouped_bar_chart_05
27
+ function makeChart(containerSelector, 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: "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 || { text_color: "#333333" }; // 颜色设置
41
+ const colorResolver = chartUtils.color.resolver(jsonData);
42
+ const images = jsonData.images || { field: {}, other: {} }; // 图像设置
43
+ const dataColumns = chartUtils.schema.columns(jsonData); // 数据列定义
44
+
45
+ // 清空容器
46
+ d3.select(containerSelector).html("");
47
+
48
+ // ---------- 2. 尺寸和布局设置 ----------
49
+
50
+ // 设置图表总尺寸
51
+ const width = variables.width || 800;
52
+ const height = variables.height || 600;
53
+
54
+ // 设置边距
55
+ const margin = {
56
+ top: 40, // 顶部留出标题空间
57
+ right: 60, // 右侧足够显示数值和分组标签
58
+ bottom: 30, // 底部边距
59
+ left: 40 // 左侧空间减少,因为我们不显示维度文本,只显示图标
60
+ };
61
+
62
+ // ---------- 3. 提取字段名和单位 ----------
63
+
64
+ // 根据数据列顺序提取字段名
65
+ const dimensionField = chartUtils.schema.channel(jsonData, "x").key;
66
+ const valueField = chartUtils.schema.channel(jsonData, "y").key;
67
+
68
+ const primaryGroupField = chartUtils.schema.channel(jsonData, "group").key;
69
+
70
+ // 获取字段单位(如果存在)
71
+ let dimensionUnit = "";
72
+ let valueUnit = "";
73
+
74
+ if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
75
+ dimensionUnit = chartUtils.schema.channel(jsonData, "x").unit;
76
+ }
77
+
78
+ if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
79
+ valueUnit = chartUtils.schema.channel(jsonData, "y").unit;
80
+ }
81
+
82
+ // ---------- 4. 数据处理 ----------
83
+
84
+ // 添加数值格式化函数
85
+
86
+ // 获取唯一维度值和分组值
87
+ const dimensions = [...new Set(chartData.map(d => d[dimensionField]))];
88
+ const primaryGroups = [...new Set(chartData.map(d => d[primaryGroupField]))];
89
+
90
+ // 按主要分组和数值进行分组和排序
91
+ const groupedData = {};
92
+ primaryGroups.forEach(group => {
93
+ // 过滤属于此组的数据
94
+ const groupItems = chartData.filter(d => d[primaryGroupField] === group);
95
+
96
+ // 按数值降序排序
97
+ const sortedItems = [...groupItems].sort((a, b) => +b[valueField] - +a[valueField]);
98
+
99
+ groupedData[group] = sortedItems;
100
+ });
101
+
102
+ // ---------- 5. 计算标签宽度 ----------
103
+
104
+ // 图标尺寸
105
+ const flagWidth = 20;
106
+ const flagHeight = 15;
107
+ const flagPadding = 5;
108
+ const labelMeasureOptions = {
109
+ fontFamily: typography.label.font_family,
110
+ fontSize: typography.label.font_size,
111
+ fontWeight: typography.label.font_weight
112
+ };
113
+ const annotationMeasureOptions = {
114
+ fontFamily: typography.annotation.font_family,
115
+ fontSize: typography.annotation.font_size,
116
+ fontWeight: typography.annotation.font_weight
117
+ };
118
+
119
+ // 计算最大数值标签宽度
120
+ let maxValueWidth = 0;
121
+ chartData.forEach(d => {
122
+ const formattedValue = valueUnit ?
123
+ `${chartUtils.format.autoText(+d[valueField])}${valueUnit}` :
124
+ `${chartUtils.format.autoText(+d[valueField])}`;
125
+
126
+ const textWidth = chartUtils.text.measure(null, formattedValue, annotationMeasureOptions).width;
127
+
128
+ maxValueWidth = Math.max(maxValueWidth, textWidth);
129
+ });
130
+
131
+ // *** BEGIN ADDITION: Calculate Max Dimension Label Width ***
132
+ let maxDimensionLabelWidth = 0;
133
+ dimensions.forEach(dimension => {
134
+ const formattedDimension = dimensionUnit ?
135
+ `${dimension}${dimensionUnit}` :
136
+ `${dimension}`;
137
+
138
+ const textWidth = chartUtils.text.measure(null, formattedDimension, labelMeasureOptions).width;
139
+ maxDimensionLabelWidth = Math.max(maxDimensionLabelWidth, textWidth);
140
+ });
141
+ // *** END ADDITION ***
142
+
143
+ // 计算分组注释的最大宽度 - Now used for Legend Items
144
+ let totalLegendWidth = 0;
145
+ const legendItemWidths = [];
146
+ const legendSwatchSize = 15;
147
+ const legendSwatchPadding = 5;
148
+ const legendItemSpacing = 15; // Spacing between legend items
149
+
150
+ primaryGroups.forEach(group => {
151
+ const textWidth = chartUtils.text.measure(null, group, labelMeasureOptions).width;
152
+ const itemWidth = legendSwatchSize + legendSwatchPadding + textWidth + legendItemSpacing;
153
+ legendItemWidths.push(itemWidth);
154
+ totalLegendWidth += itemWidth;
155
+ });
156
+ // Adjust total width (remove spacing after last item)
157
+ if (totalLegendWidth > 0) {
158
+ totalLegendWidth -= legendItemSpacing;
159
+ }
160
+
161
+ // --- BEGIN LEGEND MARGIN ADJUSTMENT ---
162
+ const legendHeight = legendSwatchSize + 20; // Swatch height + top/bottom padding
163
+ margin.top += legendHeight;
164
+ // --- END LEGEND MARGIN ADJUSTMENT ---
165
+
166
+ // ** MODIFY ** 根据图标和最大标签尺寸调整左边距
167
+ const baseLeftPadding = 10; // Base padding to the left of the labels
168
+ margin.left = maxDimensionLabelWidth + flagPadding + flagWidth + flagPadding + baseLeftPadding;
169
+
170
+
171
+ // ---------- 6. 计算分组布局 ----------
172
+
173
+ // 计算内部绘图区域尺寸
174
+ const innerWidth = width - margin.left - margin.right;
175
+ let innerHeight = height - margin.top - margin.bottom; // Recalculate innerHeight
176
+
177
+ // 计算合适的条形高度,确保所有条形在可用高度内可见
178
+ // 计算总条形数量和组数
179
+ let totalBars = 0;
180
+ primaryGroups.forEach(group => {
181
+ totalBars += groupedData[group].length;
182
+ });
183
+
184
+ const numGroups = primaryGroups.length;
185
+
186
+ // 计算组间间距的总高度
187
+ const groupPaddingRatio = 0.15; // 组间距占总高度的比例
188
+ const groupPadding = innerHeight * groupPaddingRatio / (numGroups + 1); // 分配给每个组间距
189
+ const totalGroupPadding = groupPadding * (numGroups - 1);
190
+
191
+ // 计算可用于条形的总高度
192
+ const availableForBars = innerHeight - totalGroupPadding;
193
+
194
+ // 计算条形高度和条形间距
195
+ const barPaddingRatio = 0.15; // Use fixed padding ratio, remove spacing effect logic
196
+
197
+ // 计算理想的条形高度(包括间距)
198
+ const idealBarUnitHeight = availableForBars / totalBars;
199
+
200
+ // 实际条形高度(不包括间距)
201
+ const barHeight = idealBarUnitHeight * (1 - barPaddingRatio);
202
+ const barPadding = idealBarUnitHeight * barPaddingRatio;
203
+
204
+ // 计算每组的高度和偏移量
205
+ const groupHeights = {};
206
+ const groupOffsets = {};
207
+
208
+ let currentOffset = margin.top;
209
+ primaryGroups.forEach(group => {
210
+ const itemCount = groupedData[group].length;
211
+ // 计算当前组的总高度(包括所有条形和条形间距)
212
+ const groupHeight = itemCount * barHeight + (itemCount - 1) * barPadding;
213
+
214
+ groupHeights[group] = groupHeight;
215
+ groupOffsets[group] = currentOffset;
216
+
217
+ // 更新下一组的起始位置
218
+ currentOffset += groupHeight + groupPadding;
219
+ });
220
+
221
+ // ---------- 7. 创建SVG容器 ----------
222
+
223
+ const svg = d3.select(containerSelector)
224
+ .append("svg")
225
+ .attr("width", "100%")
226
+ .attr("height", height) // 使用定义的高度,不进行自动调整
227
+ .attr("viewBox", `0 0 ${width} ${height}`)
228
+ .attr("style", "max-width: 100%; height: auto;")
229
+ .attr("xmlns", "http://www.w3.org/2000/svg")
230
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
231
+
232
+ // --- BEGIN Gradient Definitions ---
233
+ const defs = svg.append("defs");
234
+
235
+ // Moved getBarColor definition here (Needed for gradient definition)
236
+ const getBarColor = (group) => {
237
+ return colorResolver.field(group, 0, { fallbackKey: "primary", fallback: "#3ca951" }).value;
238
+ };
239
+
240
+ primaryGroups.forEach(group => {
241
+ const groupColor = getBarColor(group);
242
+ const gradientId = `bar-gradient-${group.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '')}`;
243
+
244
+ const gradient = defs.append("linearGradient")
245
+ .attr("id", gradientId)
246
+ .attr("x1", "0%")
247
+ .attr("y1", "0%")
248
+ .attr("x2", "100%") // Horizontal gradient
249
+ .attr("y2", "0%");
250
+
251
+ // 3-stop gradient: dark -> bright -> dark
252
+ gradient.append("stop")
253
+ .attr("offset", "0%")
254
+ .attr("stop-color", colorResolver.variant(groupColor, { mode: "darker", amount: 0.3 }));
255
+
256
+ gradient.append("stop")
257
+ .attr("offset", "50%")
258
+ .attr("stop-color", colorResolver.variant(groupColor, { mode: "brighter", amount: 1.5 })); // Adjusted brightness a bit from 2.5
259
+
260
+ gradient.append("stop")
261
+ .attr("offset", "100%")
262
+ .attr("stop-color", colorResolver.variant(groupColor, { mode: "darker", amount: 0.3 }));
263
+ });
264
+ // --- END Gradient Definitions ---
265
+
266
+ // --- BEGIN LEGEND CREATION ---
267
+ // getBarColor is defined above now
268
+ chartUtils.legend.centered(svg, primaryGroups, {
269
+ color: group => getBarColor(group),
270
+ colorResolver,
271
+ colors,
272
+ }, width / 2, legendHeight / 2 + 80, {
273
+ markerShape: "rect",
274
+ markerSize: 15,
275
+ labelGap: 5,
276
+ itemGap: 15,
277
+ fontSize: typography.label.font_size,
278
+ fontFamily: typography.label.font_family,
279
+ fontWeight: typography.label.font_weight,
280
+ textColor: colorResolver.text({ fallback: "#333333" }).value,
281
+ markerRadius: variables.has_rounded_corners ? 2 : 0,
282
+ });
283
+
284
+ // ---------- 8. 创建比例尺 ----------
285
+
286
+ // X轴比例尺(用于数值)
287
+ const xScale = d3.scaleLinear()
288
+ .domain([0, d3.max(chartData, d => +d[valueField]) * 1.05]) // 添加5%边距
289
+ .range([0, innerWidth]);
290
+
291
+ // ---------- 9. 创建主图表组 ----------
292
+
293
+ const g = svg.append("g")
294
+ .attr("transform", `translate(${margin.left}, ${margin.top})`); // Adjust group transform for new margin
295
+
296
+ // ---------- 10. 渲染每个分组 ----------
297
+
298
+ // 遍历每个主要分组,添加背景矩形
299
+ primaryGroups.forEach(group => {
300
+ const groupData = groupedData[group];
301
+ const groupOffset = groupOffsets[group];
302
+ const groupHeight = groupHeights[group];
303
+ const barColor = getBarColor(group);
304
+
305
+ // 为组中的每个项目绘制条形和标签
306
+ groupData.forEach((dataPoint, index) => {
307
+ // 计算条形的y坐标,考虑到条形间距
308
+ const yPos = groupOffset + index * (barHeight + barPadding);
309
+ const barWidth = xScale(+dataPoint[valueField]);
310
+
311
+ // Draw the bar using a simple rect element
312
+ g.append("rect") // Revert back to rect
313
+ .attr("x", 0)
314
+ .attr("y", yPos)
315
+ .attr("width", barWidth)
316
+ .attr("height", barHeight)
317
+ // Apply gradient fill
318
+ .attr("fill", `url(#bar-gradient-${group.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '')})`)
319
+ .attr("rx", barHeight/6)
320
+ .attr("ry", barHeight/6)
321
+ .on("mouseover", function() {
322
+ d3.select(this).attr("opacity", 0.8);
323
+ })
324
+ .on("mouseout", function() {
325
+ d3.select(this).attr("opacity", 1);
326
+ });
327
+
328
+ // 提取维度
329
+ const dimension = dataPoint[dimensionField];
330
+
331
+ // 添加国家/维度图标 (Positioned left of bar)
332
+ const flagX = -flagWidth - flagPadding; // Icon right edge at -flagPadding
333
+ const flagY = yPos + (barHeight - flagHeight) / 2;
334
+
335
+ if (images.field && images.field[dimension]) {
336
+ g.append("image")
337
+ .attr("x", flagX)
338
+ .attr("y", flagY)
339
+ .attr("width", flagWidth)
340
+ .attr("height", flagHeight)
341
+ .attr("preserveAspectRatio","xMidYMid meet")
342
+ .attr("xlink:href", images.field[dimension]);
343
+ }
344
+
345
+ // *** BEGIN ADDITION: Add Dimension Label (Left of Icon, Right Aligned) ***
346
+ const labelX = flagX - flagPadding; // Label right edge next to icon's left edge
347
+ const labelY = yPos + barHeight / 2; // Vertically centered with bar
348
+ const formattedDimension = dimensionUnit ?
349
+ `${dimension}${dimensionUnit}` :
350
+ `${dimension}`;
351
+
352
+ g.append("text")
353
+ .attr("class", "dimension-label")
354
+ .attr("x", labelX)
355
+ .attr("y", labelY)
356
+ .attr("dy", "0.35em")
357
+ .attr("text-anchor", "end") // Right align text
358
+ .style("font-family", typography.label.font_family)
359
+ .style("font-size", typography.label.font_size)
360
+ .style("font-weight", typography.label.font_weight)
361
+ .style("fill", colorResolver.text({ fallback: "#000000" }).value)
362
+ .text(formattedDimension);
363
+ // *** END ADDITION ***
364
+
365
+ // 添加数值标签
366
+ const formattedValue = valueUnit ?
367
+ `${chartUtils.format.autoText(+dataPoint[valueField])}${valueUnit}` :
368
+ `${chartUtils.format.autoText(+dataPoint[valueField])}`;
369
+
370
+ // --- BEGIN MODIFICATION: Force Label Outside ---
371
+
372
+ const valueLabelX = barWidth + 5;
373
+ const valueLabelAnchor = "start";
374
+ const valueLabelFill = colorResolver.text({ fallback: "#000000" }).value;
375
+
376
+ // Create the actual value label with fixed properties
377
+ g.append("text")
378
+ .attr("x", valueLabelX)
379
+ .attr("y", yPos + barHeight / 2)
380
+ .attr("dy", "0.35em")
381
+ .attr("text-anchor", valueLabelAnchor)
382
+ .style("font-family", typography.annotation.font_family)
383
+ .style("font-size", `${barHeight * 0.7}px`)
384
+ .style("font-weight", typography.annotation.font_weight)
385
+ .style("fill", valueLabelFill)
386
+ .text(formattedValue);
387
+ // --- END MODIFICATION ---
388
+ });
389
+ });
390
+
391
+ // 返回SVG节点
392
+ return svg.node();
393
+ }
modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_12.js ADDED
@@ -0,0 +1,556 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Horizontal Group Bar Chart",
5
+ "chart_name": "horizontal_group_bar_chart_12",
6
+ "is_composite": false,
7
+ "required_fields": ["x", "y", "group"],
8
+ "required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
9
+ "required_fields_range": [[2, 30], [0, "inf"], [2, 10]],
10
+ "required_fields_icons": ["x"],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": ["group"],
13
+ "required_other_colors": [],
14
+ "supported_effects": [],
15
+ "min_height": 400,
16
+ "min_width": 400,
17
+ "background": "styled",
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 horizontal_grouped_bar_chart_01
27
+ function makeChart(containerSelector, 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: "'Comic Sans MS', cursive", font_size: "18px", font_weight: "bold" },
36
+ label: { font_family: "'Comic Sans MS', cursive", font_size: "12px", font_weight: "normal" },
37
+ description: { font_family: "'Comic Sans MS', cursive", font_size: "14px", font_weight: "normal" },
38
+ annotation: { font_family: "'Comic Sans MS', cursive", font_size: "12px", font_weight: "normal" }
39
+ };
40
+ const colors = jsonData.colors || { text_color: "#333333" }; // 颜色设置
41
+ const colorResolver = chartUtils.color.resolver(jsonData);
42
+ const images = jsonData.images || { field: {}, other: {} }; // 图像设置
43
+ const dataColumns = chartUtils.schema.columns(jsonData); // 数据列定义
44
+
45
+
46
+
47
+ // 清空容器
48
+ d3.select(containerSelector).html("");
49
+
50
+ // ---------- 2. 尺寸和布局设置 ----------
51
+
52
+ // 设置图表总尺寸和边距
53
+ const width = variables.width || 800;
54
+ const height = variables.height || 600;
55
+
56
+ // 为标题和副标题预留空间,即使不显示它们
57
+ const titleHeight = 70; // 为标题预留至少70的高度
58
+
59
+ // 分析图表区域底部边距
60
+ const bottomMargin = 80;
61
+
62
+ // 初始设置边距
63
+ const margin = {
64
+ top: titleHeight, // 顶部留出标题空间
65
+ right: 80, // 右侧足够显示数值
66
+ bottom: bottomMargin, // 底部边距
67
+ left: 150 // 左侧暂时设为最小值,稍后会根据标签长度调整
68
+ };
69
+
70
+ // ---------- 3. 提取字段名和单位 ----------
71
+ const dimensionField = chartUtils.schema.channel(jsonData, "x").key;
72
+ const valueField = chartUtils.schema.channel(jsonData, "y").key;
73
+ const groupField = chartUtils.schema.channel(jsonData, "group").key;
74
+
75
+ // 获取字段单位(如果存在)
76
+ let dimensionUnit = "";
77
+ let valueUnit = "";
78
+ let groupUnit = "";
79
+
80
+ if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
81
+ dimensionUnit = chartUtils.schema.channel(jsonData, "x").unit;
82
+ }
83
+
84
+ if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
85
+ valueUnit = chartUtils.schema.channel(jsonData, "y").unit;
86
+ }
87
+
88
+ if (chartUtils.schema.channel(jsonData, "group").unit !== "none") {
89
+ groupUnit = chartUtils.schema.channel(jsonData, "group").unit;
90
+ }
91
+
92
+
93
+ // ---------- 4. 数据处理 ----------
94
+
95
+ // 获取维度(如Gen Z, Millennials等)和分组(如$50 or more, $1-49, Nothing)
96
+ const dimensions = [...new Set(chartData.map(d => d[dimensionField]))];
97
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
98
+
99
+ // ---------- 5. 计算标签和图标空间 ----------
100
+
101
+ // 创建临时SVG用于测量文本宽度
102
+ const tempSvg = d3.select(containerSelector)
103
+ .append("svg")
104
+ .attr("width", 0)
105
+ .attr("height", 0)
106
+ .style("visibility", "hidden");
107
+
108
+ // 计算维度标签最大宽度
109
+ let maxDimensionWidth = 0;
110
+ dimensions.forEach(dimension => {
111
+ const textWidth = chartUtils.text.measure(tempSvg, dimension, {
112
+ fontFamily: "'Comic Sans MS', cursive",
113
+ fontSize: typography.label.font_size
114
+ }).width;
115
+ maxDimensionWidth = Math.max(maxDimensionWidth, textWidth);
116
+ });
117
+
118
+ // 计算分组标签最大宽度
119
+ let maxGroupWidth = 0;
120
+ groups.forEach(group => {
121
+ const textWidth = chartUtils.text.measure(tempSvg, group, {
122
+ fontFamily: "'Comic Sans MS', cursive",
123
+ fontSize: typography.label.font_size,
124
+ fontWeight: "bold"
125
+ }).width;
126
+ maxGroupWidth = Math.max(maxGroupWidth, textWidth);
127
+ });
128
+
129
+ // 删��临时SVG
130
+ tempSvg.remove();
131
+
132
+ // === 修改点:结合维度标签和组标签最大宽度,重新计算margin.left ===
133
+ const maxLabelWidth = Math.max(maxDimensionWidth, maxGroupWidth);
134
+ const sketchPadding = 5; // 背景内边距
135
+ const dimensionLabelWidth = maxLabelWidth + 2 * sketchPadding + 20; // 标签宽度 + 背景padding + 额外空间
136
+ margin.left = Math.max(margin.left, dimensionLabelWidth + 20);
137
+
138
+ // 计算实际绘图区域尺寸
139
+ const innerWidth = width - margin.left - margin.right;
140
+ const innerHeight = height - margin.top - margin.bottom;
141
+
142
+ // *** 修改: 移除间距相关的计算 ***
143
+ // const groupPadding = innerHeight * (variables.has_spacing ? 0.08 : 0.06); // 移除组间距
144
+ const groupHeight = innerHeight / groups.length; // 每组的原始高度
145
+
146
+ // *** 修改: 移除间距对barHeight的影响 ***
147
+ // const barHeight = (groupHeight - groupPadding) / dimensions.length;
148
+ const barHeight = groupHeight / dimensions.length; // 维度在组内均分高度
149
+ // const actualBarHeight = variables.has_spacing ? barHeight * 0.65 : barHeight * 0.75; // 移除间距影响
150
+ const actualBarHeight = barHeight * 0.75; // 固定条形实际高度比例
151
+
152
+ // 标志尺寸
153
+ const iconWidth = actualBarHeight * 0.9;
154
+ const iconHeight = iconWidth;
155
+ const iconRightPadding = iconWidth * 0.4; // 图标右侧到柱子的间距
156
+
157
+ // ---------- 6. 创建SVG容器 ----------
158
+
159
+ const svg = d3.select(containerSelector)
160
+ .append("svg")
161
+ .attr("width", "100%")
162
+ .attr("height", height)
163
+ .attr("viewBox", `0 0 ${width} ${height}`)
164
+ .attr("style", "max-width: 100%; height: auto;")
165
+ .attr("xmlns", "http://www.w3.org/2000/svg")
166
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
167
+
168
+ // 创建defs用于滤镜和渐变
169
+ const defs = svg.append("defs");
170
+
171
+ // ---------- 6.1 创建视觉效果 ----------
172
+
173
+ // --- Sketch Pattern for Left Bars (or even index groups) ---
174
+ const patternLeft = defs.append("pattern")
175
+ .attr("id", "pattern-sketch-left")
176
+ .attr("width", 8)
177
+ .attr("height", 8)
178
+ .attr("patternUnits", "userSpaceOnUse")
179
+ .attr("patternTransform", "rotate(45)");
180
+
181
+ patternLeft.append("rect")
182
+ .attr("width", 8)
183
+ .attr("height", 8)
184
+ .attr("fill", "#4269d0"); // 使用一个基色,具体颜色会被覆盖
185
+
186
+ patternLeft.append("path")
187
+ .attr("d", "M -1,2 l 10,0 M -1,5 l 10,0 M -1,8 l 10,0")
188
+ .attr("stroke", "#FFFFFF")
189
+ .attr("stroke-width", 0.8);
190
+
191
+ // --- Sketch Pattern for Right Bars (or odd index groups) ---
192
+ const patternRight = defs.append("pattern")
193
+ .attr("id", "pattern-sketch-right")
194
+ .attr("width", 8)
195
+ .attr("height", 8)
196
+ .attr("patternUnits", "userSpaceOnUse")
197
+ .attr("patternTransform", "rotate(-45)"); // 反向旋转
198
+
199
+ patternRight.append("rect")
200
+ .attr("width", 8)
201
+ .attr("height", 8)
202
+ .attr("fill", "#ff725c"); // 使用另一个基色
203
+
204
+ patternRight.append("path")
205
+ .attr("d", "M -1,2 l 10,0 M -1,5 l 10,0 M -1,8 l 10,0")
206
+ .attr("stroke", "#FFFFFF")
207
+ .attr("stroke-width", 0.8);
208
+
209
+ // --- Sketch Pattern for Label Backgrounds ---
210
+ const patternLabelSketch = defs.append("pattern")
211
+ .attr("id", "pattern-label-sketch")
212
+ .attr("width", 8)
213
+ .attr("height", 8)
214
+ .attr("patternUnits", "userSpaceOnUse")
215
+ .attr("patternTransform", "rotate(45)");
216
+
217
+ patternLabelSketch.append("rect")
218
+ .attr("width", 8)
219
+ .attr("height", 8)
220
+ .attr("fill", "#FFFFFF");
221
+
222
+ patternLabelSketch.append("path")
223
+ .attr("d", "M -1,2 l 10,0 M -1,5 l 10,0 M -1,8 l 10,0")
224
+ .attr("stroke", "#CCCCCC")
225
+ .attr("stroke-width", 0.6);
226
+
227
+
228
+ // ---------- 7. 创建比例尺 ----------
229
+
230
+ // 计算最大值用于X轴比例尺
231
+ const maxValue = d3.max(chartData, d => +d[valueField]);
232
+
233
+ // 添加数值格式化函数
234
+
235
+ // 值的X比例尺
236
+ const xScale = d3.scaleLinear()
237
+ .domain([0, maxValue * 1.1]) // 添加10%边距
238
+ .range([0, innerWidth]);
239
+
240
+ // ---------- 8. 创建主图表组 ----------
241
+
242
+ const g = svg.append("g")
243
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
244
+
245
+ // ---------- 9. 辅助函数 ----------
246
+
247
+ // 获取组颜色
248
+ function getGroupColor(group) {
249
+ // 优先使用指定颜色
250
+ if (colorResolver.field(group, 0, { fallbackKey: "primary" }).value) {
251
+ return colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
252
+ }
253
+
254
+ // 如果有可用颜色数组,按索引使用
255
+ const groupIndex = groups.indexOf(group);
256
+ const availableColor = colorResolver.available(groupIndex, { cycle: true, usePalette: false }).value;
257
+ if (availableColor) return availableColor;
258
+
259
+ // Provide default fallback colors if none are defined
260
+ const defaultColors = ["#4269d0", "#ff725c", "#4CAF50", "#FFC107", "#9C27B0", "#795548"];
261
+ return defaultColors[groupIndex % defaultColors.length];
262
+ }
263
+
264
+ // 获取描边颜色
265
+ const getStrokeColor = () => {
266
+ return "#555"; // 固定手绘描边颜色
267
+ };
268
+
269
+ // *** 添加: 计算字体大小的函数 (从 vertical_group_bar_chart_13 复制) ***
270
+ const calculateFontSize = (text, maxWidth, baseSize = 12) => {
271
+ const avgCharWidth = baseSize * 0.6;
272
+ const textWidth = text.length * avgCharWidth;
273
+ if (textWidth < maxWidth) {
274
+ return baseSize;
275
+ }
276
+ return Math.max(8, Math.floor(baseSize * (maxWidth / textWidth))); // 最小字体8px
277
+ };
278
+
279
+ // ========== 9. 添加交替行背景(新增) ==========
280
+ if (jsonData.variation?.background === "styled") {
281
+ // 每个 group 下有若干维度,故在每个 group 里为维度的"行"画背景
282
+ groups.forEach((group, groupIndex) => {
283
+ const groupStartY = groupIndex * groupHeight;
284
+ dimensions.forEach((dimension, dimIndex) => {
285
+ // 偶数行添加背景
286
+ if (dimIndex % 2 === 0) {
287
+ g.append("rect")
288
+ .attr("x", 0)
289
+ .attr("y", groupStartY + dimIndex * barHeight)
290
+ .attr("width", innerWidth)
291
+ .attr("height", barHeight)
292
+ .attr("class","background")
293
+ .attr("fill", "#f0f0f0") // 稍微调整背景色
294
+ .attr("opacity", 0.7); // 稍微调整透明度
295
+ }
296
+ });
297
+ });
298
+ }
299
+
300
+ // ---------- 10. 绘制图表 ----------
301
+
302
+ // 为每个组创建一个分组
303
+ groups.forEach((group, groupIndex) => {
304
+ // 计算该组的垂直位置
305
+ const groupStartY = groupIndex * (groupHeight);
306
+
307
+ // 筛选该组的数据
308
+ const groupData = chartData.filter(d => d[groupField] === group);
309
+
310
+ // *** 添加: 确定该组使用的手绘图案 ***
311
+ const barPatternId = groupIndex % 2 === 0 ? "url(#pattern-sketch-left)" : "url(#pattern-sketch-right)";
312
+ // *** 添加: 获取组的实际颜色,用于图案背景 (可选,如果需要动态颜色图案) ***
313
+ const groupBaseColor = getGroupColor(group);
314
+ // *** 可选: 更新对应 pattern 的 rect fill (如果需要动态颜色) ***
315
+ // d3.select(`#${barPatternId.slice(5, -1)} rect`).attr("fill", groupBaseColor);
316
+
317
+ // 为每个维度绘制条形
318
+ dimensions.forEach((dimension, dimIndex) => {
319
+ // 查找数据点
320
+ const dataPoint = groupData.find(d => d[dimensionField] === dimension);
321
+
322
+ if (dataPoint) {
323
+ // 条形的垂直位置
324
+ const barY = groupStartY + dimIndex * barHeight;
325
+
326
+ // 条形宽度
327
+ const barWidth = xScale(+dataPoint[valueField]);
328
+
329
+ // 创建标签和图标组
330
+ const labelGroup = g.append("g")
331
+ .attr("transform", `translate(0, ${barY + actualBarHeight / 2})`);
332
+
333
+ // 绘制图标
334
+ if (images.field && images.field[dimension]) {
335
+ // 图标位置:紧贴条形左侧
336
+ labelGroup.append("image")
337
+ .attr("x", -iconWidth - iconRightPadding)
338
+ .attr("y", -iconHeight/2)
339
+ .attr("width", iconWidth)
340
+ .attr("height", iconHeight)
341
+ .attr("preserveAspectRatio","xMidYMid meet")
342
+ .attr("xlink:href", images.field[dimension]);
343
+ }
344
+
345
+ // *** 修改: 绘制带手绘背景的维度标签 ***
346
+ const dimensionTextContent = dimension;
347
+ const dimensionLabelX = -iconWidth - iconRightPadding - 15; // 文本右边缘 X 坐标
348
+ const dimensionBaseFontSize = parseFloat(typography.label.font_size);
349
+ const dimensionMaxTextHeight = actualBarHeight * 0.8; // 限制最大高度
350
+ const dimensionFontSize = Math.min(dimensionBaseFontSize, dimensionMaxTextHeight);
351
+
352
+ const dimBBox = chartUtils.text.measure(labelGroup, dimensionTextContent, {
353
+ fontFamily: "'Comic Sans MS', cursive",
354
+ fontSize: `${dimensionFontSize}px`,
355
+ fontWeight: typography.label.font_weight
356
+ });
357
+
358
+ // 3. 计算背景尺寸和位置
359
+ const dimBgWidth = dimBBox.width + 2 * sketchPadding;
360
+ const dimBgHeight = dimBBox.height + 1.5 * sketchPadding;
361
+ const dimBgX = dimensionLabelX - dimBBox.width - sketchPadding; // 背景左上角X
362
+ const dimBgY = -dimBgHeight / 2; // 背景垂直居中
363
+
364
+ // 4. 绘制背景 (在文本之前插入)
365
+ labelGroup.insert("rect", ":first-child") // 插入为第一个子元素
366
+ .attr("x", dimBgX)
367
+ .attr("y", dimBgY)
368
+ .attr("width", dimBgWidth)
369
+ .attr("height", dimBgHeight)
370
+ .attr("fill", "url(#pattern-label-sketch)")
371
+ .attr("stroke", "#AAAAAA")
372
+ .attr("stroke-width", 0.5);
373
+
374
+ // 5. 绘制最终文本 (在背景之上)
375
+ labelGroup.append("text")
376
+ .attr("class", "dimension-label")
377
+ .attr("x", dimensionLabelX)
378
+ .attr("y", 0)
379
+ .attr("dy", "0.35em")
380
+ .attr("text-anchor", "end")
381
+ .style("font-family", "'Comic Sans MS', cursive") // *** 强制字体 ***
382
+ .style("font-size", `${dimensionFontSize}px`)
383
+ .style("font-weight", typography.label.font_weight)
384
+ .style("fill", colorResolver.text({ fallback: "#333" }).value)
385
+ .text(dimensionTextContent);
386
+
387
+ // *** 修改: 绘制条形,使用手绘图案和描边 ***
388
+ g.append("rect")
389
+ .attr("class", `bar group-${groupIndex}`)
390
+ .attr("x", 0)
391
+ .attr("y", barY)
392
+ .attr("width", barWidth)
393
+ .attr("height", actualBarHeight)
394
+ .attr("fill", barPatternId) // 应用手绘图案
395
+ .attr("rx", 5) // *** 强制圆角 ***
396
+ .attr("ry", 5) // *** 强制圆角 ***
397
+ .style("stroke", variables.has_stroke ? getStrokeColor() : "none") // 应用手绘描边
398
+ .style("stroke-width", variables.has_stroke ? 1 : 0) // 固定描边宽度
399
+ // .style("filter", variables.has_shadow ? "url(#shadow)" : "none") // *** 移除阴影应用 ***
400
+ .on("mouseover", function() {
401
+ d3.select(this).attr("opacity", 0.8);
402
+ })
403
+ .on("mouseout", function() {
404
+ d3.select(this).attr("opacity", 1);
405
+ });
406
+
407
+ // *** 修改: 绘制带手绘背景的数值标签 ***
408
+ const formattedValue = valueUnit ?
409
+ `${chartUtils.format.autoText(+dataPoint[valueField])}${valueUnit}` :
410
+ `${chartUtils.format.autoText(+dataPoint[valueField])}`;
411
+ const valueLabelBaseFontSize = Math.min(parseFloat(typography.annotation.font_size), actualBarHeight * 0.55); // 基础字体大小
412
+
413
+ // -- 创建一个组来容纳标签和背景 --
414
+ const valueLabelGroup = g.append("g")
415
+ .attr("class", "value-label-group");
416
+
417
+ const valueBBox = chartUtils.text.measure(valueLabelGroup, formattedValue, {
418
+ fontFamily: typography.annotation.font_family,
419
+ fontSize: `${valueLabelBaseFontSize}px`,
420
+ fontWeight: "bold"
421
+ });
422
+
423
+ // 3. 计算背景尺寸和位置 (相对于组的0,0) - 用于居中
424
+ const valueBgWidth = valueBBox.width + 2 * sketchPadding;
425
+ const valueBgHeight = valueBBox.height + 1.5 * sketchPadding;
426
+ // *** 修改: 背景定位改为相对中心 (0,0) ***
427
+ const valueBgX = -valueBgWidth / 2;
428
+ const valueBgY = -valueBgHeight / 2;
429
+
430
+ // 4. 绘制背景
431
+ valueLabelGroup.append("rect")
432
+ .attr("class", "value-label-background")
433
+ .attr("x", valueBgX)
434
+ .attr("y", valueBgY)
435
+ .attr("width", valueBgWidth)
436
+ .attr("height", valueBgHeight)
437
+ .attr("fill", "url(#pattern-label-sketch)")
438
+ .attr("stroke", "#AAAAAA")
439
+ .attr("stroke-width", 0.5);
440
+
441
+ // 5. 绘制最终文本 (在组内居中)
442
+ valueLabelGroup.append("text")
443
+ .attr("class", "value-label")
444
+ .attr("x", 0) // *** 文本X居中 ***
445
+ .attr("y", 0) // *** 文本Y基线调整 ***
446
+ .attr("dy", "0em") // 配合 dominant-baseline
447
+ .attr("text-anchor", "middle") // *** 文本水平居中 ***
448
+ .attr("dominant-baseline", "middle") // *** 文本垂直居中 ***
449
+ .style("fill", colorResolver.text({ fallback: "#333" }).value)
450
+ .style("font-family", "'Comic Sans MS', cursive") // *** 强制字体 ***
451
+ .style("font-size", `${valueLabelBaseFontSize}px`)
452
+ .style("font-weight", "bold")
453
+ .style("pointer-events", "none")
454
+ .text(formattedValue);
455
+
456
+ // 6. 定位整个组 (调整X坐标以考虑背景宽度)
457
+ // const valueLabelGroupX = barWidth + 8; // 旧的起始X
458
+ const valueLabelGroupX = barWidth + 8 + valueBgWidth / 2; // *** 调整组X位置,使其中心在 bar 右侧 8px 处 ***
459
+ const valueLabelGroupY = barY + actualBarHeight / 2; // 组的Y位置:条形垂直中心
460
+ valueLabelGroup.attr("transform", `translate(${valueLabelGroupX}, ${valueLabelGroupY})`);
461
+ }
462
+ });
463
+ });
464
+
465
+ // *** 添加: wrapText 辅助函数 (如果尚未存在,需要从 vertical_group_bar_chart_13 复制或确保可用) ***
466
+ // (假设 wrapText 函数已存在或已添加)
467
+ function wrapText(text, str, width, lineHeight) {
468
+ const words = str.split(/\s+/).reverse();
469
+ let word;
470
+ let line = [];
471
+ let lineNumber = 0;
472
+ const y = text.attr("y"); // 获取原始 y
473
+ const x = text.attr("x"); // 获取原始 x
474
+ const dy = parseFloat(text.attr("dy") || 0); // 获取原始 dy
475
+
476
+ text.text(null); // 清空现有文本
477
+
478
+ let tspans = [];
479
+
480
+ // 优先按单词换行
481
+ if (words.length > 1 && words.some(w => w.length > 0)) { // Check for actual words
482
+ let currentLine = [];
483
+ while (word = words.pop()) {
484
+ currentLine.push(word);
485
+ const isOverflow = chartUtils.text.measure(null, currentLine.join(" "), {
486
+ fontFamily: text.style("font-family"),
487
+ fontSize: parseFloat(text.style("font-size")),
488
+ fontWeight: text.style("font-weight")
489
+ }).width > width;
490
+
491
+ if (isOverflow && currentLine.length > 1) {
492
+ currentLine.pop();
493
+ tspans.push(currentLine.join(" "));
494
+ currentLine = [word];
495
+ lineNumber++;
496
+ }
497
+ }
498
+ if (currentLine.length > 0) {
499
+ tspans.push(currentLine.join(" "));
500
+ }
501
+ } else { // 按字符换行
502
+ const chars = str.split('');
503
+ let currentLine = '';
504
+ for (let i = 0; i < chars.length; i++) {
505
+ const nextLine = currentLine + chars[i];
506
+ const isOverflow = chartUtils.text.measure(null, nextLine, {
507
+ fontFamily: text.style("font-family"),
508
+ fontSize: parseFloat(text.style("font-size")),
509
+ fontWeight: text.style("font-weight")
510
+ }).width > width;
511
+
512
+ if (isOverflow && currentLine.length > 0) {
513
+ tspans.push(currentLine);
514
+ currentLine = chars[i];
515
+ lineNumber++;
516
+ } else {
517
+ currentLine = nextLine;
518
+ }
519
+ }
520
+ if (currentLine.length > 0) {
521
+ tspans.push(currentLine);
522
+ }
523
+ }
524
+
525
+ const totalLines = tspans.length;
526
+ // 调整垂直居中: 向上移动半行 * (总行数 - 1)
527
+ // dominant-baseline="hanging" 时,第一个 tspan 的 y 决定顶部位置
528
+ // dy 用于后续行的偏移
529
+ const firstLineYAdjust = - (totalLines - 1) / 2 * lineHeight;
530
+
531
+ tspans.forEach((lineText, i) => {
532
+ text.append("tspan")
533
+ .attr("x", x) // 保持原始 x
534
+ .attr("dy", (i === 0 ? firstLineYAdjust + dy : lineHeight) + "em") // 第一行调整基线,后续行应用行高
535
+ .text(lineText);
536
+ });
537
+ }
538
+
539
+ const equalGroupRectWidth = Math.min(innerWidth / groups.length, width * 0.8 / groups.length);
540
+ chartUtils.legend.labeledStrip(svg, groups.map((group, index) => ({
541
+ label: group,
542
+ fill: index % 2 === 0 ? "url(#pattern-sketch-left)" : "url(#pattern-sketch-right)",
543
+ })), {
544
+ centerWidth: width,
545
+ y: height - margin.bottom + 20,
546
+ itemWidth: equalGroupRectWidth,
547
+ showNumber: true,
548
+ labelFontFamily: "'Comic Sans MS', cursive",
549
+ numberFontFamily: "'Comic Sans MS', cursive",
550
+ labelFontSize: 12,
551
+ textColor: colorResolver.text({ fallback: "#333333" }).value,
552
+ wrapLabel: (node, label, w) => wrapText(node, label, w, 1.1),
553
+ });
554
+
555
+ return svg.node();
556
+ }
modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_13.js ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Horizontal Group Bar Chart",
5
+ "chart_name": "horizontal_group_bar_chart_13",
6
+ "chart_for": "comparison",
7
+ "is_composite": false,
8
+ "required_fields": ["x", "y", "group"],
9
+ "required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
10
+ "required_fields_range": [[2, 20], [0, "inf"], [2, 5]],
11
+ "required_fields_icons": [],
12
+ "required_other_icons": [],
13
+ "required_fields_colors": ["group"],
14
+ "required_other_colors": ["primary"],
15
+ "supported_effects": [],
16
+ "min_height": 400,
17
+ "min_width": 400,
18
+ "background": "no",
19
+ "icon_mark": "none",
20
+ "icon_label": "none",
21
+ "has_x_axis": "no",
22
+ "has_y_axis": "no"
23
+ }
24
+ REQUIREMENTS_END
25
+ */
26
+
27
+ // 水平分组条形图实现 - 使用D3.js horizontal_split_bar_chart_06 (Applying Sketch Style)
28
+ function makeChart(containerSelector, data) {
29
+ // ---------- 1. 数据准备阶段 ----------
30
+
31
+ // 提取数据和配置
32
+ const jsonData = data; // 完整的JSON数据对象
33
+ const chartData = jsonData.data.data; // 实际数据点数组
34
+ const variables = jsonData.variables || {}; // 图表配置
35
+ const typography = jsonData.typography || { // 字体设置,如果不存在则使用默认值
36
+ title: { font_family: "'Comic Sans MS', cursive", font_size: "18px", font_weight: "bold" },
37
+ label: { font_family: "'Comic Sans MS', cursive", font_size: "12px", font_weight: "normal" },
38
+ description: { font_family: "'Comic Sans MS', cursive", font_size: "14px", font_weight: "normal" },
39
+ annotation: { font_family: "'Comic Sans MS', cursive", font_size: "12px", font_weight: "normal" }
40
+ };
41
+ const colors = jsonData.colors || { text_color: "#333333" }; // 颜色设置
42
+ const colorResolver = chartUtils.color.resolver(jsonData);
43
+ const images = jsonData.images || { field: {}, other: {} }; // 图像设置
44
+ const dataColumns = chartUtils.schema.columns(jsonData); // 数据列定义
45
+
46
+ // 设置视觉效果变量的默认值
47
+ variables.has_stroke = variables.has_stroke !== undefined ? variables.has_stroke : true; // Default stroke to true for sketch
48
+
49
+ // 清空容器
50
+ d3.select(containerSelector).html("");
51
+
52
+ // ---------- 2. 尺寸和布局设置 ----------
53
+
54
+ // 设置图表总尺寸
55
+ const width = variables.width || 800;
56
+ const height = variables.height || 600;
57
+
58
+ // 设置边距
59
+ const margin = {
60
+ top: 100, // 顶部留出标题空间
61
+ right: 30, // 右侧足够显示数值
62
+ bottom: 60, // 底部边距
63
+ left: 60 // 左侧初始空间,会动态调整
64
+ };
65
+
66
+ // ---------- 3. 提取字段名和单位 ----------
67
+
68
+ // 根据数据列顺序提取字段名
69
+ const dimensionField = chartUtils.schema.channel(jsonData, "x").key;
70
+ const valueField = chartUtils.schema.channel(jsonData, "y").key;
71
+ const groupField = chartUtils.schema.channel(jsonData, "group").key;
72
+
73
+ // 获取字段单位(如果存在)
74
+ let dimensionUnit = "";
75
+ let valueUnit = "";
76
+ let groupUnit = "";
77
+
78
+ if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
79
+ dimensionUnit = chartUtils.schema.channel(jsonData, "x").unit;
80
+ }
81
+
82
+ if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
83
+ valueUnit = chartUtils.schema.channel(jsonData, "y").unit;
84
+ }
85
+ if (chartUtils.schema.channel(jsonData, "group").unit !== "none") {
86
+ groupUnit = chartUtils.schema.channel(jsonData, "group").unit;
87
+ }
88
+
89
+ // ---------- 4. 数据处理 ----------
90
+
91
+ // 获取唯一维度值和分组值
92
+ const dimensions = [...new Set(chartData.map(d => d[dimensionField]))];
93
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
94
+
95
+ // 不对数据进行排序,使用原始顺序
96
+
97
+ // ---------- 5. 计算标签宽度 (使用手绘字体) ----------
98
+ // *** 添加: 手绘背景内边距 ***
99
+ const sketchPadding = 5;
100
+
101
+ // 创建临时SVG来测量文本宽度
102
+ const tempSvg = d3.select(containerSelector)
103
+ .append("svg")
104
+ .attr("width", 0)
105
+ .attr("height", 0)
106
+ .style("visibility", "hidden");
107
+
108
+ // 计算最大维度标签宽度
109
+ let maxLabelWidth = 0;
110
+ dimensions.forEach(dimension => {
111
+ const formattedDimension = dimensionUnit ?
112
+ `${dimension}${dimensionUnit}` :
113
+ `${dimension}`;
114
+
115
+ const textWidth = chartUtils.text.measure(tempSvg, formattedDimension, {
116
+ fontFamily: "'Comic Sans MS', cursive",
117
+ fontSize: typography.label.font_size,
118
+ fontWeight: typography.label.font_weight
119
+ }).width;
120
+ // *** 修改: 考虑背景padding ***
121
+ const totalWidth = textWidth + 2 * sketchPadding; // 文本宽度 + 背景padding
122
+
123
+ maxLabelWidth = Math.max(maxLabelWidth, totalWidth);
124
+ });
125
+
126
+ // 计算最大数值标签宽度
127
+ let maxValueWidth = 0;
128
+ chartData.forEach(d => {
129
+ const formattedValue = valueUnit ?
130
+ `${d[valueField]}${valueUnit}` :
131
+ `${chartUtils.format.autoText(+d[valueField])}`;
132
+
133
+ const textWidth = chartUtils.text.measure(tempSvg, formattedValue, {
134
+ fontFamily: typography.annotation.font_family,
135
+ fontSize: typography.annotation.font_size,
136
+ fontWeight: typography.annotation.font_weight
137
+ }).width;
138
+ // *** 修改: 考虑背景padding ***
139
+ maxValueWidth = Math.max(maxValueWidth, textWidth + 2 * sketchPadding);
140
+ });
141
+ // 删除临时SVG
142
+ tempSvg.remove();
143
+
144
+ // 根据标签宽度调整左边距(添加一些边距)
145
+ margin.left = Math.max(margin.left, maxLabelWidth + 15); // 增加右侧空间
146
+ // 根据数值标签宽度调整右边距 (添加一些边距)
147
+ margin.right = Math.max(margin.right, maxValueWidth + 15);
148
+
149
+ // 计算实际绘图区域尺寸
150
+ const innerWidth = width - margin.left - margin.right;
151
+ const innerHeight = height - margin.top - margin.bottom;
152
+
153
+ // 添加数值格式化函数
154
+
155
+ // ---------- 6. 创建SVG容器 ----------
156
+
157
+ const svg = d3.select(containerSelector)
158
+ .append("svg")
159
+ .attr("width", "100%")
160
+ .attr("height", height)
161
+ .attr("viewBox", `0 0 ${width} ${height}`)
162
+ .attr("style", "max-width: 100%; height: auto;")
163
+ .attr("xmlns", "http://www.w3.org/2000/svg")
164
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
165
+
166
+ // 添加defs用于视觉效果
167
+ const defs = svg.append("defs");
168
+
169
+ // *** 修改: 使用来自 vertical_group_bar_chart_13 的手绘图案定义 ***
170
+ // --- Sketch Pattern Definitions ---
171
+ const patternLeft = defs.append("pattern")
172
+ .attr("id", "pattern-sketch-left")
173
+ .attr("width", 8).attr("height", 8)
174
+ .attr("patternUnits", "userSpaceOnUse")
175
+ .attr("patternTransform", "rotate(45)");
176
+ patternLeft.append("rect").attr("width", 8).attr("height", 8).attr("fill", "#4269d0"); // Default color
177
+ patternLeft.append("path").attr("d", "M -1,2 l 10,0 M -1,5 l 10,0 M -1,8 l 10,0").attr("stroke", "#FFFFFF").attr("stroke-width", 0.8);
178
+
179
+ const patternRight = defs.append("pattern")
180
+ .attr("id", "pattern-sketch-right")
181
+ .attr("width", 8).attr("height", 8)
182
+ .attr("patternUnits", "userSpaceOnUse")
183
+ .attr("patternTransform", "rotate(-45)");
184
+ patternRight.append("rect").attr("width", 8).attr("height", 8).attr("fill", "#ff725c"); // Default color
185
+ patternRight.append("path").attr("d", "M -1,2 l 10,0 M -1,5 l 10,0 M -1,8 l 10,0").attr("stroke", "#FFFFFF").attr("stroke-width", 0.8);
186
+
187
+ const patternLabelSketch = defs.append("pattern")
188
+ .attr("id", "pattern-label-sketch")
189
+ .attr("width", 8).attr("height", 8)
190
+ .attr("patternUnits", "userSpaceOnUse")
191
+ .attr("patternTransform", "rotate(45)");
192
+ patternLabelSketch.append("rect").attr("width", 8).attr("height", 8).attr("fill", "#FFFFFF");
193
+ patternLabelSketch.append("path").attr("d", "M -1,2 l 10,0 M -1,5 l 10,0 M -1,8 l 10,0").attr("stroke", "#CCCCCC").attr("stroke-width", 0.6);
194
+
195
+ // ---------- 7. 创建比例尺 ----------
196
+
197
+ // 计算条形的额外间距 - 使用固定值
198
+ const barPadding = 0.1;
199
+
200
+ // Y轴比例尺(用于维度)- 使用原始顺序
201
+ const yScale = d3.scaleBand()
202
+ .domain(dimensions)
203
+ .range([0, innerHeight])
204
+ .padding(barPadding);
205
+
206
+ // X轴比例尺(用于数值)
207
+ const xScale = d3.scaleLinear()
208
+ .domain([0, d3.max(chartData, d => +d[valueField]) || 1]) // Ensure domain starts at 0 and handles empty data
209
+ .range([0, innerWidth]);
210
+
211
+ // 获取组颜色 - 用于动态更新图案背景色 (可选)
212
+ function getGroupColor(group) {
213
+ // 优先使用指定颜色
214
+ if (colorResolver.field(group, 0, { fallbackKey: "primary" }).value) {
215
+ return colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
216
+ }
217
+ // 如果有可用颜色数组,按索引使用
218
+ const groupIndex = groups.indexOf(group);
219
+ const availableColor = colorResolver.available(groupIndex, { cycle: true, usePalette: false }).value;
220
+ if (availableColor) return availableColor;
221
+ const primaryColor = colorResolver.other("primary").value;
222
+ if (primaryColor) {
223
+ // 可以基于 group 索引生成不同深浅的 primary 颜色
224
+ // 简单的亮度调整示例
225
+ const factor = 1 - (groupIndex * 0.15); // 索引越大越暗
226
+ return chartUtils.color.variant(
227
+ chartUtils.color.variant(primaryColor, { mode: "brighter", amount: factor * 0.5 }),
228
+ { mode: "darker", amount: factor * 0.2 }
229
+ ); // 示例调整
230
+ }
231
+ // 提供默认 fallback 颜色
232
+ const defaultColors = ["#4269d0", "#ff725c", "#4CAF50", "#FFC107", "#9C27B0"];
233
+ return defaultColors[groupIndex % defaultColors.length];
234
+ }
235
+
236
+ const equalGroupRectWidth = Math.min(innerWidth / groups.length, width * 0.8 / groups.length);
237
+ chartUtils.legend.labeledStrip(svg, groups.map(group => ({
238
+ label: group,
239
+ fill: getGroupColor(group),
240
+ })), {
241
+ centerWidth: width,
242
+ y: height - margin.bottom + 20,
243
+ itemWidth: equalGroupRectWidth,
244
+ labelFontFamily: typography.annotation.font_family,
245
+ numberFontFamily: typography.label.font_family,
246
+ labelFontSize: 12,
247
+ textColor: colorResolver.text({ fallback: "#333333" }).value,
248
+ wrapLabel: (node, label, w) => wrapText(node, label, w, 1.1),
249
+ });
250
+
251
+ return svg.node();
252
+ }
modules/chart_engine/template/d3-js/bar_chart/horizontal_pictorial_bar_chart_01.js ADDED
@@ -0,0 +1,515 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Horizontal Bar Chart",
5
+ "chart_name": "horizontal_bar_chart_01",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["categorical"], ["numerical"]],
8
+ "required_fields_range": [[3, 20], [0, 100000]],
9
+ "required_fields_icons": ["x"],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": [],
13
+ "supported_effects": ["gradient", "opacity"],
14
+ "min_height": 300,
15
+ "min_width": 300,
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
+
29
+ const jsonData = data;
30
+ const chartData = jsonData.data.data;
31
+ const variables = jsonData.variables || {};
32
+ const typography = jsonData.typography || {
33
+ title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
34
+ label: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
35
+ description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
36
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
37
+ };
38
+ const colors = jsonData.colors || {
39
+ text_color: "#333333",
40
+ other: {
41
+ primary: "#0458A7",
42
+ secondary: "#6BAFEF",
43
+ background: "#F0F0F0"
44
+ }
45
+ };
46
+
47
+ variables.has_shadow = variables.has_shadow || false;
48
+ variables.has_stroke = variables.has_stroke || false;
49
+
50
+ d3.select(containerSelector).html("");
51
+
52
+ const width = Math.max(variables.width || 300, 860);
53
+ const height = Math.max(variables.height || 400, 520);
54
+
55
+ const xField = chartUtils.schema.channel(jsonData, "x").key || "period";
56
+ const yField = chartUtils.schema.channel(jsonData, "y").key || "value";
57
+
58
+ let xUnit = "";
59
+ let yUnit = "";
60
+
61
+ if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
62
+ xUnit = chartUtils.schema.channel(jsonData, "x").unit;
63
+ }
64
+ if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
65
+ yUnit = chartUtils.schema.channel(jsonData, "y").unit;
66
+ }
67
+
68
+ const processedData = chartData.map(d => ({
69
+ category: d[xField],
70
+ value: +d[yField]
71
+ })).filter(d => Number.isFinite(d.value))
72
+ .sort((a, b) => b.value - a.value);
73
+
74
+ const rawTextColor = colorResolver.text({ fallback: colors.text_color || "#10214A" }).value;
75
+ const primaryColor = colorResolver.other("primary", { fallback: colors.other?.primary || "#0458A7" }).value;
76
+ const secondaryColor = "#5DA9F6";
77
+ const fontFamily = typography.label?.font_family || "Arial";
78
+
79
+ const svg = d3.select(containerSelector)
80
+ .append("svg")
81
+ .attr("width", "100%")
82
+ .attr("height", height)
83
+ .attr("viewBox", `0 0 ${width} ${height}`)
84
+ .attr("style", "max-width: 100%; height: auto;")
85
+ .attr("xmlns", "http://www.w3.org/2000/svg")
86
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
87
+
88
+ const defs = svg.append("defs");
89
+ const uid = `hbar01_${Math.floor(Math.random() * 1e9)}`;
90
+ const blueGradient = defs.append("linearGradient")
91
+ .attr("id", `${uid}_blue`)
92
+ .attr("x1", "0%")
93
+ .attr("x2", "100%")
94
+ .attr("y1", "0%")
95
+ .attr("y2", "0%");
96
+ blueGradient.append("stop").attr("offset", "0%").attr("stop-color", colorResolver.variant(primaryColor, { mode: "darker", amount: 0.45 }) || "#0344A3");
97
+ blueGradient.append("stop").attr("offset", "100%").attr("stop-color", colorResolver.variant(primaryColor, { mode: "brighter", amount: 0.75 }) || "#1F8BFF");
98
+
99
+ const lightBlueGradient = defs.append("linearGradient")
100
+ .attr("id", `${uid}_light_blue`)
101
+ .attr("x1", "0%")
102
+ .attr("x2", "100%")
103
+ .attr("y1", "0%")
104
+ .attr("y2", "0%");
105
+ lightBlueGradient.append("stop").attr("offset", "0%").attr("stop-color", "#4EA5F5");
106
+ lightBlueGradient.append("stop").attr("offset", "100%").attr("stop-color", "#8EC8FF");
107
+
108
+ const grayGradient = defs.append("linearGradient")
109
+ .attr("id", `${uid}_gray`)
110
+ .attr("x1", "0%")
111
+ .attr("x2", "100%")
112
+ .attr("y1", "0%")
113
+ .attr("y2", "0%");
114
+ grayGradient.append("stop").attr("offset", "0%").attr("stop-color", "#AAB1BC");
115
+ grayGradient.append("stop").attr("offset", "100%").attr("stop-color", "#D3D7DE");
116
+
117
+ const hasThreePanelLayout = width >= 720;
118
+ const hasRoomForLegend = width >= 520;
119
+ const margin = {
120
+ top: Math.max(12, height * 0.035),
121
+ right: Math.max(16, width * 0.022),
122
+ bottom: Math.max(42, height * 0.08),
123
+ left: Math.max(16, width * 0.026)
124
+ };
125
+ const innerW = width - margin.left - margin.right;
126
+ const innerH = height - margin.top - margin.bottom;
127
+ const sideGap = hasThreePanelLayout ? Math.max(18, innerW * 0.024) : 0;
128
+ const leftSlotW = hasThreePanelLayout ? Math.min(230, Math.max(174, innerW * 0.265)) : 0;
129
+ const rightPanelW = hasThreePanelLayout ? Math.min(128, Math.max(108, innerW * 0.145)) : 0;
130
+ const chartX = hasThreePanelLayout ? leftSlotW + sideGap : 0;
131
+ const chartW = hasThreePanelLayout
132
+ ? Math.max(380, innerW - leftSlotW - rightPanelW - sideGap * 2)
133
+ : innerW;
134
+ const legendW = hasThreePanelLayout ? rightPanelW : (hasRoomForLegend ? Math.min(92, innerW * 0.14) : 0);
135
+ const valueW = Math.min(82, chartW * 0.14);
136
+ const iconW = Math.min(48, Math.max(36, chartW * 0.075));
137
+ const labelW = Math.min(156, Math.max(112, chartW * 0.24));
138
+ const colGap = Math.max(7, chartW * 0.014);
139
+ const barX = iconW + labelW + colGap * 2;
140
+ const valueX = chartW - valueW;
141
+ const barW = Math.max(70, valueX - barX - colGap);
142
+ const chartH = innerH;
143
+ const rowStep = chartH / Math.max(1, processedData.length);
144
+ const rowBand = Math.max(22, rowStep);
145
+ const barH = Math.max(8, Math.min(22, rowStep * 0.34));
146
+ const iconR = Math.max(11, Math.min(22, rowStep * 0.34));
147
+ const labelFont = Math.max(10, Math.min(16, rowStep * 0.28, parseFloat(typography.label?.font_size) || 14));
148
+ const valueFont = Math.max(11, Math.min(19, rowStep * 0.34));
149
+ const tickFont = Math.max(8, Math.min(10, labelFont * 0.72));
150
+
151
+ const valueMax = d3.max(processedData, d => d.value) || 1;
152
+ const percentLike = yUnit === "%";
153
+ const useFullPercentDomain = percentLike && valueMax > 20;
154
+ const xMax = useFullPercentDomain ? Math.max(100, valueMax) : valueMax;
155
+ const xScale = d3.scaleLinear()
156
+ .domain([0, xMax])
157
+ .range([0, barW]);
158
+
159
+ const root = svg.append("g")
160
+ .attr("class", "horizontal-bar-chart-01")
161
+ .attr("data-reference-geometry", "left-slot-central-ranked-pill-bars-independent-right-panel")
162
+ .attr("data-no-copied-reference-art", "true")
163
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
164
+
165
+ if (hasThreePanelLayout) {
166
+ const leftSlotH = Math.max(220, chartH * 0.72);
167
+ const leftSlotY = Math.max(6, chartH - leftSlotH - 8);
168
+ const leftSlot = root.append("g")
169
+ .attr("class", "reserved-asset-slot horizontal-bar-01-left-image-panel-slot")
170
+ .attr("data-type", "image-slot")
171
+ .attr("data-asset-slot", "left-primary-image-panel")
172
+ .attr("data-reserved-slot", "horizontal_bar_01_left_primary_image_panel")
173
+ .attr("data-slot-policy", "image2_or_input_asset_reserved_only")
174
+ .attr("data-asset-source-policy", "image2_asset_or_input_asset;not-template-hardcoded")
175
+ .attr("data-anchor", "left-body-panel-below-title")
176
+ .attr("data-bbox", `0,${leftSlotY.toFixed(1)},${leftSlotW.toFixed(1)},${leftSlotH.toFixed(1)}`)
177
+ .attr("data-bbox-policy", "child rounded rectangle defines visible reserved image panel bbox")
178
+ .attr("data-mask-policy", "downstream image2/retrieval may replace only inside this visible neutral slot; code emits no embedded raster")
179
+ .attr("data-collision-rule", "stay left of central ranked chart and below title/subtitle; never overlap bars labels values or right panel")
180
+ .attr("data-no-embedded-raster", "true")
181
+ .attr("data-no-copied-reference-art", "true")
182
+ .attr("transform", `translate(0, ${leftSlotY})`);
183
+
184
+ const leftRadius = Math.min(18, leftSlotW * 0.08);
185
+ leftSlot.append("rect")
186
+ .attr("class", "left-image-panel-frame")
187
+ .attr("width", leftSlotW)
188
+ .attr("height", leftSlotH)
189
+ .attr("rx", leftRadius)
190
+ .attr("ry", leftRadius)
191
+ .attr("fill", "#F2F8FF")
192
+ .attr("stroke", "#C8DFF5")
193
+ .attr("stroke-width", 1.4);
194
+
195
+ leftSlot.append("rect")
196
+ .attr("class", "left-image-panel-mask-placeholder")
197
+ .attr("x", leftSlotW * 0.08)
198
+ .attr("y", leftSlotH * 0.08)
199
+ .attr("width", leftSlotW * 0.84)
200
+ .attr("height", leftSlotH * 0.74)
201
+ .attr("rx", leftRadius * 0.72)
202
+ .attr("ry", leftRadius * 0.72)
203
+ .attr("fill", "#E6F3FF")
204
+ .attr("stroke", "#9FC9F1")
205
+ .attr("stroke-width", 1.1)
206
+ .attr("stroke-dasharray", "7 6")
207
+ .attr("opacity", 0.84);
208
+
209
+ leftSlot.append("path")
210
+ .attr("class", "left-image-panel-neutral-landform")
211
+ .attr("d", `M ${leftSlotW * 0.08} ${leftSlotH * 0.63} C ${leftSlotW * 0.26} ${leftSlotH * 0.50}, ${leftSlotW * 0.39} ${leftSlotH * 0.68}, ${leftSlotW * 0.54} ${leftSlotH * 0.56} C ${leftSlotW * 0.70} ${leftSlotH * 0.44}, ${leftSlotW * 0.78} ${leftSlotH * 0.58}, ${leftSlotW * 0.92} ${leftSlotH * 0.48} L ${leftSlotW * 0.92} ${leftSlotH * 0.82} L ${leftSlotW * 0.08} ${leftSlotH * 0.82} Z`)
212
+ .attr("fill", "#BFE2FF")
213
+ .attr("opacity", 0.58);
214
+
215
+ leftSlot.append("circle")
216
+ .attr("class", "left-image-panel-neutral-anchor")
217
+ .attr("cx", leftSlotW * 0.5)
218
+ .attr("cy", leftSlotH * 0.42)
219
+ .attr("r", Math.min(leftSlotW, leftSlotH) * 0.07)
220
+ .attr("fill", primaryColor)
221
+ .attr("opacity", 0.18);
222
+
223
+ leftSlot.append("rect")
224
+ .attr("class", "left-image-panel-bottom-band")
225
+ .attr("x", 0)
226
+ .attr("y", leftSlotH * 0.82)
227
+ .attr("width", leftSlotW)
228
+ .attr("height", leftSlotH * 0.18)
229
+ .attr("rx", leftRadius)
230
+ .attr("ry", leftRadius)
231
+ .attr("fill", primaryColor)
232
+ .attr("opacity", 0.95);
233
+ }
234
+
235
+ const chartGroup = root.append("g")
236
+ .attr("class", "central-ranked-chart")
237
+ .attr("data-reference-relationship", "central-ranked-table")
238
+ .attr("data-anchor", "between-left-image-slot-and-right-art-panel")
239
+ .attr("transform", `translate(${chartX}, 0)`);
240
+
241
+ const rowLayer = chartGroup.append("g").attr("class", "row-layer");
242
+ const axisY = chartH + Math.min(32, margin.bottom * 0.58);
243
+
244
+ const wrapLabel = (text, maxWidth, fontSize, maxLines = 2) => {
245
+ const words = String(text ?? "").split(/\s+/).filter(Boolean);
246
+ const maxChars = Math.max(8, Math.floor(maxWidth / (fontSize * 0.55)));
247
+ const lines = [];
248
+ let line = "";
249
+ words.forEach(word => {
250
+ const next = line ? `${line} ${word}` : word;
251
+ if (next.length <= maxChars || !line) {
252
+ line = next;
253
+ } else {
254
+ lines.push(line);
255
+ line = word;
256
+ }
257
+ });
258
+ if (line) lines.push(line);
259
+ const limited = lines.slice(0, maxLines);
260
+ if (lines.length > maxLines && limited.length) {
261
+ const last = limited[limited.length - 1];
262
+ limited[limited.length - 1] = `${last.slice(0, Math.max(3, maxChars - 1)).trim()}...`;
263
+ }
264
+ return limited.length ? limited : [String(text ?? "")];
265
+ };
266
+
267
+ const initials = value => {
268
+ const cleaned = String(value || "").replace(/\([^)]*\)/g, " ").split(/[^A-Za-z0-9]+/).filter(Boolean);
269
+ const selected = cleaned.filter(w => !["and", "the", "of", "for", "with"].includes(w.toLowerCase())).slice(0, 2);
270
+ return (selected.map(w => w[0]).join("") || cleaned.join("").slice(0, 2) || "?").toUpperCase();
271
+ };
272
+
273
+ const isTraditional = category => {
274
+ const c = String(category).toLowerCase();
275
+ return c.includes("foot") || c.includes("vehicle") || c.includes("community") || c.includes("aerial");
276
+ };
277
+
278
+ const formatChartNumber = value => {
279
+ const compactUnit = yUnit === "%" || String(yUnit).startsWith("$");
280
+ return chartUtils.format.number(value, { unit: yUnit, spaceBeforeUnit: !compactUnit }).text;
281
+ };
282
+ const formatValue = d => formatChartNumber(d.value);
283
+
284
+ const rows = rowLayer.selectAll(".hbar01-row")
285
+ .data(processedData)
286
+ .enter()
287
+ .append("g")
288
+ .attr("class", "hbar01-row")
289
+ .attr("data-category", d => d.category)
290
+ .attr("data-value", d => d.value)
291
+ .attr("transform", (d, i) => `translate(0, ${i * rowStep})`);
292
+
293
+ rows.append("line")
294
+ .attr("class", "row-divider")
295
+ .attr("x1", iconW + colGap * 0.4)
296
+ .attr("x2", Math.min(valueX + valueW, innerW - legendW - colGap))
297
+ .attr("y1", rowStep)
298
+ .attr("y2", rowStep)
299
+ .attr("stroke", "#DDE7F1")
300
+ .attr("stroke-width", 1);
301
+
302
+ rows.append("circle")
303
+ .attr("class", "category-icon-slot")
304
+ .attr("data-asset-slot", "category-icon")
305
+ .attr("data-no-embedded-raster", "true")
306
+ .attr("data-no-copied-reference-art", "true")
307
+ .attr("data-anchor", "row-centerline")
308
+ .attr("data-collision-policy", "stay-left-of-label-column")
309
+ .attr("cx", iconW * 0.5)
310
+ .attr("cy", rowStep * 0.5)
311
+ .attr("r", iconR)
312
+ .attr("fill", "#EEF6FF")
313
+ .attr("stroke", "#CDE2F7")
314
+ .attr("stroke-width", 1.2);
315
+
316
+ rows.append("text")
317
+ .attr("class", "category-icon-initials")
318
+ .attr("x", iconW * 0.5)
319
+ .attr("y", rowStep * 0.5)
320
+ .attr("dy", "0.34em")
321
+ .attr("text-anchor", "middle")
322
+ .style("font-family", fontFamily)
323
+ .style("font-size", `${Math.max(8, iconR * 0.52)}px`)
324
+ .style("font-weight", 800)
325
+ .style("fill", primaryColor)
326
+ .text(d => initials(d.category));
327
+
328
+ rows.append("line")
329
+ .attr("class", "icon-label-divider")
330
+ .attr("x1", iconW + colGap * 0.55)
331
+ .attr("x2", iconW + colGap * 0.55)
332
+ .attr("y1", rowStep * 0.22)
333
+ .attr("y2", rowStep * 0.78)
334
+ .attr("stroke", "#D5E1ED")
335
+ .attr("stroke-width", 1);
336
+
337
+ rows.append("text")
338
+ .attr("class", "category-label")
339
+ .attr("x", iconW + colGap)
340
+ .attr("y", rowStep * 0.5)
341
+ .attr("dominant-baseline", "middle")
342
+ .style("font-family", fontFamily)
343
+ .style("font-size", `${labelFont}px`)
344
+ .style("font-weight", 800)
345
+ .style("fill", "#0D2147")
346
+ .each(function(d) {
347
+ const lines = wrapLabel(d.category, labelW, labelFont, rowStep < 30 ? 1 : 3);
348
+ const text = d3.select(this);
349
+ const lineH = labelFont * 1.08;
350
+ const firstY = -(lines.length - 1) * lineH / 2;
351
+ lines.forEach((line, index) => {
352
+ text.append("tspan")
353
+ .attr("x", iconW + colGap)
354
+ .attr("dy", index === 0 ? firstY : lineH)
355
+ .text(line);
356
+ });
357
+ });
358
+
359
+ rows.append("rect")
360
+ .attr("class", "bar-track")
361
+ .attr("x", barX)
362
+ .attr("y", rowStep * 0.5 - barH / 2)
363
+ .attr("width", barW)
364
+ .attr("height", barH)
365
+ .attr("rx", barH / 2)
366
+ .attr("ry", barH / 2)
367
+ .attr("fill", "#EDF2F7");
368
+
369
+ rows.append("rect")
370
+ .attr("class", "bar")
371
+ .attr("data-mark-shape", "pill-capsule")
372
+ .attr("data-baseline-edge", "rounded")
373
+ .attr("data-value-edge", "rounded")
374
+ .attr("x", barX)
375
+ .attr("y", rowStep * 0.5 - barH / 2)
376
+ .attr("width", d => Math.max(barH, xScale(d.value)))
377
+ .attr("height", barH)
378
+ .attr("rx", barH / 2)
379
+ .attr("ry", barH / 2)
380
+ .attr("fill", (d, i) => {
381
+ if (i < 4 || String(d.category).toLowerCase().includes("drone")) return `url(#${uid}_blue)`;
382
+ if (!isTraditional(d.category)) return `url(#${uid}_light_blue)`;
383
+ return `url(#${uid}_gray)`;
384
+ });
385
+
386
+ rows.append("text")
387
+ .attr("class", "value-label")
388
+ .attr("x", valueX + valueW)
389
+ .attr("y", rowStep * 0.5)
390
+ .attr("dy", "0.34em")
391
+ .attr("text-anchor", "end")
392
+ .style("font-family", fontFamily)
393
+ .style("font-size", `${valueFont}px`)
394
+ .style("font-weight", 800)
395
+ .style("fill", (d, i) => i < 4 ? primaryColor : "#0D2147")
396
+ .text(formatValue);
397
+
398
+ const freeScaleTickCount = Math.max(3, Math.min(5, Math.floor(barW / 90)));
399
+ const ticks = useFullPercentDomain ? [0, 20, 40, 60, 80, 100] : xScale.ticks(freeScaleTickCount);
400
+ const axis = chartGroup.append("g")
401
+ .attr("class", "x-axis")
402
+ .attr("transform", `translate(${barX}, ${axisY})`);
403
+
404
+ chartGroup.append("text")
405
+ .attr("class", "bar-column-heading")
406
+ .attr("x", barX + barW + valueW)
407
+ .attr("y", Math.max(0, -margin.top * 0.2))
408
+ .attr("text-anchor", "end")
409
+ .style("font-family", fontFamily)
410
+ .style("font-size", `${Math.max(9, tickFont * 1.08)}px`)
411
+ .style("font-weight", 800)
412
+ .style("letter-spacing", "0")
413
+ .style("fill", primaryColor)
414
+ .text(String(yField || "Value").replace(/([a-z])([A-Z])/g, "$1 $2").toUpperCase());
415
+
416
+ axis.selectAll(".tick-label")
417
+ .data(ticks)
418
+ .enter()
419
+ .append("text")
420
+ .attr("class", "tick-label")
421
+ .attr("x", d => xScale(d))
422
+ .attr("y", 0)
423
+ .attr("text-anchor", (d, i) => i === 0 ? "start" : (i === ticks.length - 1 ? "end" : "middle"))
424
+ .style("font-family", fontFamily)
425
+ .style("font-size", `${tickFont}px`)
426
+ .style("font-weight", 600)
427
+ .style("fill", "#7B8AA4")
428
+ .text(d => formatChartNumber(d));
429
+
430
+ if (legendW >= 72) {
431
+ const panelX = hasThreePanelLayout ? chartX + chartW + sideGap : innerW - legendW;
432
+ const panelH = hasThreePanelLayout ? Math.max(260, chartH - 10) : Math.min(chartH, Math.max(120, chartH * 0.56));
433
+ const panelY = hasThreePanelLayout ? 5 : Math.max(4, (chartH - panelH) * 0.08);
434
+ const legend = root.append("g")
435
+ .attr("class", "legend-side-panel")
436
+ .attr("data-reference-relationship", "independent")
437
+ .attr("data-asset-slot", "side-panel-art")
438
+ .attr("data-reserved-slot", "horizontal_bar_01_right_art_legend_panel")
439
+ .attr("data-slot-policy", "image2_asset_reserved_only")
440
+ .attr("data-asset-source-policy", "image2_asset;not-template-hardcoded")
441
+ .attr("data-no-embedded-raster", "true")
442
+ .attr("data-no-copied-reference-art", "true")
443
+ .attr("data-anchor", "right-safe-column")
444
+ .attr("data-bbox", `${panelX.toFixed(1)},${panelY.toFixed(1)},${legendW.toFixed(1)},${panelH.toFixed(1)}`)
445
+ .attr("data-bbox-policy", "child rounded rectangle defines visible right art and legend panel bbox")
446
+ .attr("data-mask-policy", "downstream image2 may replace only inside the upper reserved art area; code emits no embedded raster")
447
+ .attr("data-collision-policy", "avoid-value-label-column-and-central-bars")
448
+ .attr("transform", `translate(${panelX}, ${panelY})`);
449
+
450
+ legend.append("rect")
451
+ .attr("class", "legend-panel-bg")
452
+ .attr("width", legendW)
453
+ .attr("height", panelH)
454
+ .attr("rx", 12)
455
+ .attr("ry", 12)
456
+ .attr("fill", "#F2F8FF")
457
+ .attr("stroke", "#CAE0F6")
458
+ .attr("stroke-width", 1.2);
459
+
460
+ const legendItems = [
461
+ { label: "High", color: primaryColor },
462
+ { label: "Mid", color: secondaryColor },
463
+ { label: "Base", color: "#AAB1BC" }
464
+ ];
465
+ const legendBaseY = hasThreePanelLayout ? panelH - 70 : panelH - 58;
466
+ legend.selectAll(".legend-item")
467
+ .data(legendItems)
468
+ .enter()
469
+ .append("g")
470
+ .attr("class", "legend-item")
471
+ .attr("transform", (d, i) => `translate(${Math.max(10, legendW * 0.12)}, ${legendBaseY + i * 18})`)
472
+ .each(function(d) {
473
+ const item = d3.select(this);
474
+ item.append("rect")
475
+ .attr("width", 10)
476
+ .attr("height", 10)
477
+ .attr("rx", 2)
478
+ .attr("fill", d.color);
479
+ item.append("text")
480
+ .attr("x", 16)
481
+ .attr("y", 9)
482
+ .style("font-family", fontFamily)
483
+ .style("font-size", `${Math.max(8, tickFont)}px`)
484
+ .style("font-weight", 600)
485
+ .style("fill", "#25446C")
486
+ .text(d.label);
487
+ });
488
+
489
+ legend.append("rect")
490
+ .attr("class", "side-panel-art-placeholder")
491
+ .attr("data-asset-slot", "side-panel-art")
492
+ .attr("data-no-embedded-raster", "true")
493
+ .attr("data-no-copied-reference-art", "true")
494
+ .attr("x", legendW * 0.18)
495
+ .attr("y", 22)
496
+ .attr("width", legendW * 0.64)
497
+ .attr("height", Math.min(panelH * 0.34, legendW * 1.18))
498
+ .attr("rx", Math.min(16, legendW * 0.16))
499
+ .attr("fill", "#DDEEFF")
500
+ .attr("stroke", "#C4DDF8")
501
+ .attr("stroke-width", 1)
502
+ .attr("stroke-dasharray", "6 5")
503
+ .attr("opacity", 0.9);
504
+
505
+ legend.append("circle")
506
+ .attr("class", "side-panel-art-neutral-anchor")
507
+ .attr("cx", legendW * 0.5)
508
+ .attr("cy", 22 + Math.min(panelH * 0.34, legendW * 1.18) * 0.48)
509
+ .attr("r", Math.max(8, legendW * 0.12))
510
+ .attr("fill", primaryColor)
511
+ .attr("opacity", 0.16);
512
+ }
513
+
514
+ return svg.node();
515
+ }
modules/chart_engine/template/d3-js/bar_chart/vertical_bar_chart_07.js ADDED
@@ -0,0 +1,489 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Vertical Bar Chart",
5
+ "chart_name": "vertical_bar_chart_07",
6
+ "is_composite": false,
7
+ "required_fields": ["x", "y"],
8
+ "required_fields_type": [["categorical"], ["numerical"]],
9
+ "required_fields_range": [[2, 20], [0, "inf"]],
10
+ "required_fields_icons": ["x"],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": [],
13
+ "required_other_colors": ["primary"],
14
+ "supported_effects": ["shadow", "radius_corner", "gradient", "stroke", "spacing"],
15
+ "min_height": 400,
16
+ "min_width": 400,
17
+ "background": "no",
18
+ "icon_mark": "overlay",
19
+ "icon_label": "side",
20
+ "has_x_axis": "no",
21
+ "has_y_axis": "no"
22
+ }
23
+ REQUIREMENTS_END
24
+ */
25
+
26
+ // 使用D3.js创建简单的垂直条形图
27
+ function makeChart(containerSelector, data) {
28
+ const colorResolver = chartUtils.color.resolver(data);
29
+ // ---------- 1. 数据准备 ----------
30
+ const jsonData = data; // 完整的JSON数据对象
31
+ const chartData = jsonData.data.data || []; // 图表数据
32
+ const variables = jsonData.variables || {}; // 图表配置
33
+ const typography = jsonData.typography || { // 字体设置
34
+ title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
35
+ label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
36
+ description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
37
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
38
+ };
39
+ const colors = jsonData.colors || {
40
+ text_color: "#333333",
41
+ other: { primary: "#73D2C7", secondary: "#4682B4" }
42
+ };
43
+ const images = jsonData.images || { field: {}, other: {} }; // 图片(标志等)
44
+ const dataColumns = chartUtils.schema.columns(jsonData); // 数据列
45
+
46
+ // 设置视觉效果变量
47
+ variables.has_rounded_corners = variables.has_rounded_corners || false;
48
+ variables.has_shadow = variables.has_shadow || false;
49
+ variables.has_gradient = variables.has_gradient || false;
50
+ variables.has_stroke = variables.has_stroke || false;
51
+ variables.has_spacing = variables.has_spacing || false;
52
+
53
+ // 清空容器
54
+ d3.select(containerSelector).html("");
55
+
56
+ // ---------- 2. 尺寸和布局设置 ----------
57
+
58
+ // 设置图表尺寸和边距
59
+ const width = variables.width || 800;
60
+ const height = variables.height || 600;
61
+
62
+ // 边距: 上, 右, 下, 左
63
+ const margin = {
64
+ top: 60, // 柱子上方数值标签的空间
65
+ right: 30,
66
+ bottom: 150, // x轴和图标的空间
67
+ left: 60
68
+ };
69
+
70
+ // 计算实际图表区域尺寸
71
+ const innerWidth = width - margin.left - margin.right;
72
+ const innerHeight = height - margin.top - margin.bottom;
73
+
74
+ // ---------- 3. 提取字段名称和单位 ----------
75
+
76
+ let xField, yField;
77
+ let xUnit = "", yUnit = "";
78
+
79
+ // 安全提取字段名称
80
+ const xChannel = chartUtils.schema.channel(jsonData, "x");
81
+ const yChannel = chartUtils.schema.channel(jsonData, "y");
82
+
83
+ xField = xChannel.key;
84
+ yField = yChannel.key;
85
+
86
+ // 获取字段单位(如果存在)
87
+ if (xChannel.unit) {
88
+ xUnit = xChannel.unit;
89
+ }
90
+
91
+ if (yChannel.unit) {
92
+ yUnit = yChannel.unit;
93
+ }
94
+
95
+ // ---------- 4. 数据处理 ----------
96
+
97
+ // 获取唯一的x轴值
98
+ const xValues = [...new Set(chartData.map(d => d[xField]))];
99
+
100
+ // 创建按y值降序排序的数据
101
+ const sortedData = chartData.map(d => ({
102
+ x: d[xField],
103
+ y: +d[yField] // 确保y是数值类型
104
+ })).sort((a, b) => b.y - a.y);
105
+
106
+ // ---------- 5. 创建SVG容器 ----------
107
+
108
+ const svg = d3.select(containerSelector)
109
+ .append("svg")
110
+ .attr("width", "100%")
111
+ .attr("height", height)
112
+ .attr("viewBox", `0 0 ${width} ${height}`)
113
+ .attr("style", "max-width: 100%; height: auto;")
114
+ .attr("xmlns", "http://www.w3.org/2000/svg")
115
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
116
+
117
+
118
+
119
+ // ---------- 6. 创建视觉效果 ----------
120
+
121
+ const defs = svg.append("defs");
122
+
123
+ // 获取颜色函数
124
+ const getColor = () => colorResolver.other("primary").value;
125
+
126
+ // 如果需要创建阴影滤镜
127
+ if (variables.has_shadow) {
128
+ const filter = defs.append("filter")
129
+ .attr("id", "shadow")
130
+ .attr("filterUnits", "userSpaceOnUse")
131
+ .attr("width", "200%")
132
+ .attr("height", "200%");
133
+
134
+ filter.append("feGaussianBlur")
135
+ .attr("in", "SourceAlpha")
136
+ .attr("stdDeviation", 3);
137
+
138
+ filter.append("feOffset")
139
+ .attr("dx", 2)
140
+ .attr("dy", 2)
141
+ .attr("result", "offsetblur");
142
+
143
+ const feMerge = filter.append("feMerge");
144
+ feMerge.append("feMergeNode");
145
+ feMerge.append("feMergeNode").attr("in", "SourceGraphic");
146
+ }
147
+
148
+ // 如果需要创建渐变
149
+ if (variables.has_gradient) {
150
+ sortedData.forEach((item, idx) => {
151
+ // 添加安全的ID处理
152
+ const safeCategory = typeof item.x === 'string' ?
153
+ item.x.toString().replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() :
154
+ `category-${idx}`;
155
+
156
+ const baseColor = getColor();
157
+ const gradientId = `gradient-${safeCategory}`;
158
+
159
+ const gradient = defs.append("linearGradient")
160
+ .attr("id", gradientId)
161
+ .attr("x1", "0%")
162
+ .attr("y1", "100%")
163
+ .attr("x2", "0%")
164
+ .attr("y2", "0%");
165
+
166
+ gradient.append("stop")
167
+ .attr("offset", "0%")
168
+ .attr("stop-color", colorResolver.variant(baseColor, { mode: "darker", amount: 0.1 }));
169
+
170
+ gradient.append("stop")
171
+ .attr("offset", "100%")
172
+ .attr("stop-color", colorResolver.variant(baseColor, { mode: "brighter", amount: 0.1 }));
173
+ });
174
+ }
175
+
176
+ // ---------- 7. 创建图表 ----------
177
+
178
+ // 创建图表组
179
+ const chart = svg.append("g")
180
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
181
+
182
+ // 创建比例尺
183
+ const xScale = d3.scaleBand()
184
+ .domain(sortedData.map(d => d.x))
185
+ .range([0, innerWidth])
186
+ .padding(variables.has_spacing ? 0.4 : 0.2);
187
+
188
+ const yMax = d3.max(sortedData, d => d.y);
189
+ const yScale = d3.scaleLinear()
190
+ .domain([0, yMax > 0 ? yMax * 1.1 : 1]) // 处理y都为0的情况,并添加10%的填充
191
+ .range([innerHeight, 0]);
192
+
193
+ // 添加数值格式化函数
194
+
195
+ // *******************************************************************
196
+ // ** 开始修改:预计算标签字体大小和最大行数 **
197
+ // *******************************************************************
198
+ const defaultLabelFontSize = parseFloat(typography.label.font_size || 12);
199
+ const minLabelFontSize = 10; // 最小字体大小
200
+ const currentBarWidth = xScale.bandwidth(); // 使用实际的bar宽度
201
+ let finalLabelFontSize = defaultLabelFontSize;
202
+ let maxLinesNeeded = 1; // 记录所有标签所需的最大行数
203
+ const labelFontFamily = typography.label.font_family || "Arial";
204
+ const labelFontWeight = typography.label.font_weight || "normal";
205
+ const lineHeightFactor = 1.2; // 行高倍数
206
+
207
+ // 文本宽度测量辅助函数 (使用 canvas 提高性能)
208
+ const canvas = document.createElement('canvas');
209
+ const ctx = canvas.getContext('2d');
210
+ function measureTextWidth(text, fontFamily, fontSize, fontWeight) {
211
+ ctx.font = `${fontWeight || 'normal'} ${fontSize}px ${fontFamily || 'Arial'}`;
212
+ return chartUtils.text.contextWidth(ctx, text);
213
+ }
214
+
215
+ // 第一遍计算:确定统一的字体大小
216
+ if (currentBarWidth > 0) { // 确保 barWidth 有效
217
+ let maxRatio = 1;
218
+ sortedData.forEach(d => {
219
+ const labelText = String(d.x); // 确保是字符串
220
+ const textWidth = measureTextWidth(labelText, labelFontFamily, defaultLabelFontSize, labelFontWeight);
221
+ const ratio = textWidth / currentBarWidth;
222
+ if (ratio > maxRatio) {
223
+ maxRatio = ratio;
224
+ }
225
+ });
226
+
227
+ if (maxRatio > 1) {
228
+ finalLabelFontSize = Math.max(minLabelFontSize, Math.floor(defaultLabelFontSize / maxRatio));
229
+ }
230
+
231
+ // 第二遍计算:确定是否需要换行以及最大行数 (使用最终字体大小)
232
+ sortedData.forEach(d => {
233
+ const labelText = String(d.x);
234
+ const textWidth = measureTextWidth(labelText, labelFontFamily, finalLabelFontSize, labelFontWeight);
235
+
236
+ if (textWidth > currentBarWidth) {
237
+ // 模拟换行计算行数
238
+ const words = labelText.split(/\s+/); // 按空格分词
239
+ let currentLine = '';
240
+ let lines = 1;
241
+ let simulationSuccess = false; // 标记模拟是否成功
242
+
243
+ if (words.length > 1) { // 优先按单词换行
244
+ for (let i = 0; i < words.length; i++) {
245
+ const testLine = currentLine ? currentLine + " " + words[i] : words[i];
246
+ const testWidth = measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight);
247
+ if (testWidth > currentBarWidth && currentLine !== '') {
248
+ lines++;
249
+ currentLine = words[i];
250
+ // 检查单个单词是否也超长
251
+ if (measureTextWidth(currentLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > currentBarWidth) {
252
+ // 单个单词超长,需要转字符换行
253
+ simulationSuccess = false;
254
+ break; // 退出单词循环
255
+ }
256
+ } else {
257
+ currentLine = testLine;
258
+ }
259
+ }
260
+ if(currentLine !== '') simulationSuccess = true; // 如果有剩余单词则标记成功
261
+
262
+ }
263
+
264
+ // 如果单词换行不成功或只有一个单词,尝试字符换行
265
+ if(words.length <= 1 || !simulationSuccess) {
266
+ lines = 1; // 重置行数
267
+ const chars = labelText.split('');
268
+ currentLine = '';
269
+ for (let i = 0; i < chars.length; i++) {
270
+ const testLine = currentLine + chars[i];
271
+ if (measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > currentBarWidth && currentLine !== '') {
272
+ lines++;
273
+ currentLine = chars[i];
274
+ } else {
275
+ currentLine += chars[i];
276
+ }
277
+ }
278
+ }
279
+
280
+
281
+ if (lines > maxLinesNeeded) {
282
+ maxLinesNeeded = lines;
283
+ }
284
+ }
285
+ });
286
+ } else {
287
+ // 如果barWidth无效,则使用默认字体大小且不换行
288
+ finalLabelFontSize = defaultLabelFontSize;
289
+ maxLinesNeeded = 1;
290
+ }
291
+ // *******************************************************************
292
+ // ** 结束修改:预计算标签字体大小和最大行数 **
293
+ // *******************************************************************
294
+
295
+ // *******************************************************************
296
+ // ** 开始修改:计算图标的统一Y位置 **
297
+ // *******************************************************************
298
+ const labelStartY = innerHeight + 10; // 标签开始的 Y 位置 (靠近柱子底部)
299
+ const lineHeight = finalLabelFontSize * lineHeightFactor; // 行高
300
+ const labelBottomApprox = labelStartY + (maxLinesNeeded - 1) * lineHeight + finalLabelFontSize * 0.71; // 估算最下方标签基线位置
301
+ const iconRadius = 15; // 图标半径
302
+ const iconYPosition = labelBottomApprox + iconRadius + 5; // 在标签下方留出 5px 间距放置图标中心
303
+ const iconBottomY = iconYPosition + iconRadius; // 图标底部的Y坐标
304
+ const barExtensionBuffer = 5; // 柱子比图标底部多延伸的距离
305
+ const barBottomY = iconBottomY + barExtensionBuffer; // 柱子需要延伸到的最终Y坐标
306
+ // *******************************************************************
307
+ // ** 结束修改:计算图标的统一Y位置 **
308
+ // *******************************************************************
309
+
310
+ // 添加柱子 (修改高度)
311
+ chart.selectAll(".bar")
312
+ .data(sortedData)
313
+ .enter()
314
+ .append("rect")
315
+ .attr("class", "bar")
316
+ .attr("x", d => xScale(d.x))
317
+ .attr("y", d => yScale(d.y))
318
+ .attr("width", xScale.bandwidth())
319
+ // 修改: 使柱子高度只取决于数据值和yScale
320
+ .attr("height", d => Math.max(0, innerHeight - yScale(d.y)))
321
+ .attr("fill", d => {
322
+ if (variables.has_gradient) {
323
+ const safeCategory = typeof d.x === 'string' ?
324
+ d.x.toString().replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() :
325
+ `category-${sortedData.findIndex(item => item.x === d.x)}`; // 修正: 使用 findIndex 确保唯一性
326
+ return `url(#gradient-${safeCategory})`;
327
+ }
328
+ return getColor();
329
+ })
330
+ .attr("rx", variables.has_rounded_corners ? 4 : 0)
331
+ .attr("ry", variables.has_rounded_corners ? 4 : 0)
332
+ .style("stroke", variables.has_stroke ? "#333" : "none")
333
+ .style("stroke-width", variables.has_stroke ? 1 : 0)
334
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "none")
335
+ .on("mouseover", function() {
336
+ d3.select(this).attr("opacity", 0.8);
337
+ })
338
+ .on("mouseout", function() {
339
+ d3.select(this).attr("opacity", 1);
340
+ });
341
+
342
+ // 在柱子上方添加数值标签
343
+ const defaultAnnotationFontSize = parseFloat(typography.annotation.font_size || 12);
344
+ const minAnnotationFontSize = 6; // 数值标签最小字体
345
+ const annotationFontFamily = typography.annotation.font_family || "Arial";
346
+ const annotationFontWeight = typography.annotation.font_weight || "bold";
347
+
348
+ chart.selectAll(".value-label")
349
+ .data(sortedData)
350
+ .enter()
351
+ .append("text")
352
+ .attr("class", "value-label")
353
+ .attr("x", d => xScale(d.x) + xScale.bandwidth() / 2)
354
+ .attr("y", d => yScale(d.y) - 5)
355
+ .attr("text-anchor", "middle")
356
+ .style("font-family", annotationFontFamily)
357
+ .style("font-weight", annotationFontWeight)
358
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
359
+ .each(function(d) {
360
+ const valueText = chartUtils.format.autoText(d.y) + (yUnit || "");
361
+ const maxWidth = xScale.bandwidth() * 1.1; // 最大允许宽度为柱宽的1.1倍
362
+ let finalValueFontSize = defaultAnnotationFontSize;
363
+
364
+ if (maxWidth > 0) { // 仅当宽度有效时计算
365
+ const textWidth = measureTextWidth(valueText, annotationFontFamily, defaultAnnotationFontSize, annotationFontWeight);
366
+ if (textWidth > maxWidth) {
367
+ finalValueFontSize = Math.max(minAnnotationFontSize, Math.floor(defaultAnnotationFontSize * (maxWidth / textWidth)));
368
+ }
369
+ }
370
+
371
+ d3.select(this)
372
+ .style("font-size", `${finalValueFontSize}px`)
373
+ .text(valueText);
374
+ });
375
+
376
+ // 在维度标签上方添加水平线
377
+ chart.append("line")
378
+ .attr("x1", 0)
379
+ .attr("y1", innerHeight + 20) // 直接位于维度标签上方的位置
380
+ .attr("x2", innerWidth)
381
+ .attr("y2", innerHeight + 20)
382
+ .attr("stroke", "#e0e0e0")
383
+ .attr("stroke-width", 1);
384
+
385
+ // *******************************************************************
386
+ // ** 开始修改:添加维度标签 (统一字体大小,自动换行) **
387
+ // *******************************************************************
388
+ chart.selectAll(".dimension-label")
389
+ .data(sortedData)
390
+ .enter()
391
+ .append("text")
392
+ .attr("class", "dimension-label")
393
+ .attr("text-anchor", "middle")
394
+ .style("font-family", labelFontFamily)
395
+ .style("font-size", `${finalLabelFontSize}px`) // 应用预计算的字体大小
396
+ .style("font-weight", labelFontWeight)
397
+ .style("fill", "#ffffff") // 使用默认文本颜色
398
+ .each(function(d) {
399
+ const labelText = String(d.x);
400
+ const textWidth = measureTextWidth(labelText, labelFontFamily, finalLabelFontSize, labelFontWeight);
401
+ const textElement = d3.select(this);
402
+ const xPos = xScale(d.x) + xScale.bandwidth() / 2;
403
+ const availableWidth = xScale.bandwidth();
404
+
405
+ if (textWidth > availableWidth && availableWidth > 0) {
406
+ // 需要换行 (重复之前的换行逻辑来渲染)
407
+ const words = labelText.split(/\s+/);
408
+ let lines = [];
409
+ let currentLine = '';
410
+ let simulationSuccess = false;
411
+
412
+ if(words.length > 1){
413
+ for (let i = 0; i < words.length; i++) {
414
+ const testLine = currentLine ? currentLine + " " + words[i] : words[i];
415
+ const testWidth = measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight);
416
+ if (testWidth > availableWidth && currentLine !== '') {
417
+ lines.push(currentLine);
418
+ currentLine = words[i];
419
+ if (measureTextWidth(currentLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > availableWidth) {
420
+ simulationSuccess = false; break;
421
+ }
422
+ } else { currentLine = testLine; }
423
+ }
424
+ if(currentLine !== '') { lines.push(currentLine); simulationSuccess = true; }
425
+ }
426
+ if(words.length <= 1 || !simulationSuccess) {
427
+ lines = [];
428
+ const chars = labelText.split('');
429
+ currentLine = '';
430
+ for (let i = 0; i < chars.length; i++) {
431
+ const testLine = currentLine + chars[i];
432
+ if (measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > availableWidth && currentLine !== '') {
433
+ lines.push(currentLine); currentLine = chars[i];
434
+ } else { currentLine += chars[i]; }
435
+ }
436
+ lines.push(currentLine);
437
+ }
438
+
439
+ lines.forEach((line, i) => {
440
+ textElement.append("tspan")
441
+ .attr("x", xPos)
442
+ .attr("y", labelStartY + i * lineHeight)
443
+ .attr("dy", "0.71em")
444
+ .text(line);
445
+ });
446
+ } else {
447
+ // 不需要换行
448
+ textElement.append("tspan")
449
+ .attr("x", xPos)
450
+ .attr("y", labelStartY)
451
+ .attr("dy", "0.71em")
452
+ .text(labelText);
453
+ }
454
+ });
455
+ // *******************************************************************
456
+ // ** 结束修改:添加维度标签 **
457
+ // *******************************************************************
458
+
459
+
460
+ // *******************************************************************
461
+ // ** 开始修改:添加图标 (移除圆形) - 动态调整 Y 位置 **
462
+ // *******************************************************************
463
+ chart.selectAll(".icon-group") // 修改类名以反映不再是圆形
464
+ .data(sortedData)
465
+ .enter()
466
+ .append("g")
467
+ .attr("class", "icon-group") // 修改类名
468
+ .attr("transform", d => `translate(${xScale(d.x) + xScale.bandwidth() / 2}, ${iconYPosition})`) // 应用动态Y位置
469
+ .each(function(d) {
470
+
471
+ if (images && images.field && images.field[d.x]) {
472
+ d3.select(this)
473
+ .append("image")
474
+ .attr("xlink:href", images.field[d.x])
475
+ .attr("x", -12)
476
+ .attr("y", -12)
477
+ .attr("width", 24)
478
+ .attr("preserveAspectRatio","xMidYMid meet")
479
+ .attr("height", 24);
480
+ }
481
+ });
482
+ // *******************************************************************
483
+ // ** 结束修改:添加图标 **
484
+ // *******************************************************************
485
+
486
+
487
+ // 返回SVG节点
488
+ return svg.node();
489
+ }
modules/chart_engine/template/d3-js/bar_chart/vertical_bar_chart_08.js ADDED
@@ -0,0 +1,529 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Vertical Bar Chart",
5
+ "chart_name": "vertical_bar_chart_08",
6
+ "is_composite": false,
7
+ "required_fields": ["x", "y"],
8
+ "required_fields_type": [["categorical"], ["numerical"]],
9
+ "required_fields_range": [[2, 20], [0, "inf"]],
10
+ "required_fields_icons": ["x"],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": ["x"],
13
+ "required_other_colors": ["primary"],
14
+ "supported_effects": ["shadow", "radius_corner", "gradient", "stroke", "spacing"],
15
+ "min_height": 400,
16
+ "min_width": 400,
17
+ "background": "no",
18
+ "icon_mark": "overlay",
19
+ "icon_label": "side",
20
+ "has_x_axis": "no",
21
+ "has_y_axis": "no"
22
+ }
23
+ REQUIREMENTS_END
24
+ */
25
+
26
+ // 使用D3.js创建简单的垂直条形图
27
+ function makeChart(containerSelector, data) {
28
+ const colorResolver = chartUtils.color.resolver(data);
29
+ // ---------- 1. 数据准备 ----------
30
+ const jsonData = data; // 完整的JSON数据对象
31
+ const chartData = jsonData.data.data || []; // 图表数据
32
+ const variables = jsonData.variables || {}; // 图表配置
33
+ const typography = jsonData.typography || { // 字体设置
34
+ title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
35
+ label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
36
+ description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
37
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
38
+ };
39
+ const colors = jsonData.colors || {
40
+ text_color: "#333333",
41
+ other: { primary: "#73D2C7", secondary: "#4682B4" }
42
+ };
43
+ const images = jsonData.images || { field: {}, other: {} }; // 图片(标志等)
44
+ const dataColumns = chartUtils.schema.columns(jsonData); // 数据列
45
+
46
+ // 设置视觉效果变量
47
+ variables.has_rounded_corners = variables.has_rounded_corners || false;
48
+ variables.has_shadow = variables.has_shadow || false;
49
+ variables.has_gradient = variables.has_gradient || false;
50
+ variables.has_stroke = variables.has_stroke || false;
51
+ variables.has_spacing = variables.has_spacing || false;
52
+
53
+ // 清空容器
54
+ d3.select(containerSelector).html("");
55
+
56
+ // ---------- 2. 尺寸和布局设置 ----------
57
+
58
+ // 设置图表尺寸和边距
59
+ const width = variables.width || 800;
60
+ const height = variables.height || 600;
61
+
62
+ // 边距: 上, 右, 下, 左
63
+ const margin = {
64
+ top: 60, // 柱子上方数值标签的空间
65
+ right: 30,
66
+ bottom: 150, // x轴和图标的空间
67
+ left: 60
68
+ };
69
+
70
+ // 计算实际图表区域尺寸
71
+ const innerWidth = width - margin.left - margin.right;
72
+ const innerHeight = height - margin.top - margin.bottom;
73
+
74
+ // ---------- 3. 提取字段名称和单位 ----------
75
+
76
+ let xField, yField;
77
+ let xUnit = "", yUnit = "";
78
+
79
+ // 安全提取字段名称
80
+ const xChannel = chartUtils.schema.channel(jsonData, "x");
81
+ const yChannel = chartUtils.schema.channel(jsonData, "y");
82
+
83
+ xField = xChannel.key;
84
+ yField = yChannel.key;
85
+
86
+ // 获取字段单位(如果存在)
87
+ if (xChannel.unit) {
88
+ xUnit = xChannel.unit;
89
+ }
90
+
91
+ if (yChannel.unit) {
92
+ yUnit = yChannel.unit;
93
+ }
94
+
95
+ // ---------- 4. 数据处理 ----------
96
+
97
+ // 获取唯一的x轴值
98
+ const xValues = [...new Set(chartData.map(d => d[xField]))];
99
+
100
+ // 创建按y值降序排序的数据
101
+ const sortedData = chartData.map(d => ({
102
+ x: d[xField],
103
+ y: +d[yField] // 确保y是数值类型
104
+ })).sort((a, b) => b.y - a.y);
105
+
106
+ // ---------- 5. 创建SVG容器 ----------
107
+
108
+ const svg = d3.select(containerSelector)
109
+ .append("svg")
110
+ .attr("width", "100%")
111
+ .attr("height", height)
112
+ .attr("viewBox", `0 0 ${width} ${height}`)
113
+ .attr("style", "max-width: 100%; height: auto;")
114
+ .attr("xmlns", "http://www.w3.org/2000/svg")
115
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
116
+
117
+
118
+
119
+ // ---------- 6. 创建视觉效果 ----------
120
+
121
+ const defs = svg.append("defs");
122
+
123
+ // *** BEGIN REMOVAL: Remove stripe pattern generation ***
124
+ // const patternDensity = 6;
125
+ // const patternStrokeWidth = 1.5;
126
+ // sortedData.forEach((item, idx) => {
127
+ // const dimension = item.x;
128
+ // let barColor = colorResolver.other("primary").value;
129
+ // if (colorResolver.field(dimension, 0, { fallbackKey: "primary" }).value) {
130
+ // barColor = colorResolver.field(dimension, 0, { fallbackKey: "primary" }).value;
131
+ // }
132
+ // const safeCategory = typeof dimension === 'string' ?
133
+ // dimension.toString().replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() :
134
+ // `category-${idx}`;
135
+ // const patternId = `pattern-stripe-${safeCategory}`;
136
+ // const pattern = defs.append("pattern")
137
+ // .attr("id", patternId)
138
+ // .attr("patternUnits", "userSpaceOnUse")
139
+ // .attr("width", patternDensity)
140
+ // .attr("height", patternDensity)
141
+ // .attr("patternTransform", "rotate(45)");
142
+ // pattern.append("rect")
143
+ // .attr("width", patternDensity)
144
+ // .attr("height", patternDensity)
145
+ // .attr("fill", barColor)
146
+ // .attr("opacity", 0.8);
147
+ // pattern.append("line")
148
+ // .attr("x1", 0)
149
+ // .attr("y1", 0)
150
+ // .attr("x2", 0)
151
+ // .attr("y2", patternDensity)
152
+ // .attr("stroke", "white")
153
+ // .attr("stroke-width", patternStrokeWidth)
154
+ // .attr("opacity", 0.6);
155
+ // });
156
+ // *** END REMOVAL ***
157
+
158
+ // *** BEGIN ADDITION: Define vertical gradient per category ***
159
+ sortedData.forEach((item, idx) => {
160
+ const dimension = item.x;
161
+ let barColor = colorResolver.other("primary").value; // Default color
162
+ if (colorResolver.field(dimension, 0, { fallbackKey: "primary" }).value) {
163
+ barColor = colorResolver.field(dimension, 0, { fallbackKey: "primary" }).value; // Use specific color if available
164
+ }
165
+
166
+ // Create safe ID for the gradient
167
+ const safeCategory = typeof dimension === 'string' ?
168
+ dimension.toString().replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() :
169
+ `category-${idx}`;
170
+ const gradientId = `gradient-${safeCategory}`;
171
+
172
+ const gradient = defs.append("linearGradient")
173
+ .attr("id", gradientId)
174
+ .attr("x1", "0%")
175
+ .attr("y1", "100%") // Bottom
176
+ .attr("x2", "0%")
177
+ .attr("y2", "0%"); // Top
178
+
179
+ // Darker color at the bottom
180
+ gradient.append("stop")
181
+ .attr("offset", "0%")
182
+ .attr("stop-color", colorResolver.variant(barColor, { mode: "darker", amount: 0.5 })); // Adjust factor as needed
183
+
184
+ // Lighter color at the top
185
+ gradient.append("stop")
186
+ .attr("offset", "100%")
187
+ .attr("stop-color", colorResolver.variant(barColor, { mode: "brighter", amount: 0.5 })); // Adjust factor as needed
188
+ });
189
+ // *** END ADDITION ***
190
+
191
+ // 如果需要创建阴影滤镜
192
+ // if (variables.has_shadow) {
193
+ // const filter = defs.append("filter")
194
+ // .attr("id", "shadow")
195
+ // .attr("filterUnits", "userSpaceOnUse")
196
+ // .attr("width", "200%")
197
+ // .attr("height", "200%");
198
+
199
+ // filter.append("feGaussianBlur")
200
+ // .attr("in", "SourceAlpha")
201
+ // .attr("stdDeviation", 3);
202
+
203
+ // filter.append("feOffset")
204
+ // .attr("dx", 2)
205
+ // .attr("dy", 2)
206
+ // .attr("result", "offsetblur");
207
+
208
+ // const feMerge = filter.append("feMerge");
209
+ // feMerge.append("feMergeNode");
210
+ // feMerge.append("feMergeNode").attr("in", "SourceGraphic");
211
+ // }
212
+
213
+ // ---------- 7. 创建图表 ----------
214
+
215
+ // 创建图表组
216
+ const chart = svg.append("g")
217
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
218
+
219
+ // 创建比例尺
220
+ const xScale = d3.scaleBand()
221
+ .domain(sortedData.map(d => d.x))
222
+ .range([0, innerWidth])
223
+ .padding(variables.has_spacing ? 0.4 : 0.2);
224
+
225
+ const yMax = d3.max(sortedData, d => d.y);
226
+ const yScale = d3.scaleLinear()
227
+ .domain([0, yMax > 0 ? yMax * 1.1 : 1]) // 处理y都为0的情况,并添加10%的填充
228
+ .range([innerHeight, 0]);
229
+
230
+ // *******************************************************************
231
+ // ** 开始修改:预计算标签字体大小和最大行数 **
232
+ // *******************************************************************
233
+ const defaultLabelFontSize = parseFloat(typography.label.font_size || 12);
234
+ const minLabelFontSize = 10; // 最小字体大小
235
+ const currentBarWidth = xScale.bandwidth(); // 使用实际的bar宽度
236
+ let finalLabelFontSize = defaultLabelFontSize;
237
+ let maxLinesNeeded = 1; // 记录所有标签所需的最大行数
238
+ const labelFontFamily = typography.label.font_family || "Arial";
239
+ const labelFontWeight = typography.label.font_weight || "normal";
240
+ const lineHeightFactor = 1.2; // 行高倍数
241
+
242
+ // 文本宽度测量辅助函数 (使用 canvas 提高性能)
243
+ const canvas = document.createElement('canvas');
244
+ const ctx = canvas.getContext('2d');
245
+ function measureTextWidth(text, fontFamily, fontSize, fontWeight) {
246
+ ctx.font = `${fontWeight || 'normal'} ${fontSize}px ${fontFamily || 'Arial'}`;
247
+ return chartUtils.text.contextWidth(ctx, text);
248
+ }
249
+
250
+ // 第一遍计算:确定统一的字体大小
251
+ if (currentBarWidth > 0) { // 确保 barWidth 有效
252
+ let maxRatio = 1;
253
+ sortedData.forEach(d => {
254
+ const labelText = String(d.x); // 确保是字符串
255
+ const textWidth = measureTextWidth(labelText, labelFontFamily, defaultLabelFontSize, labelFontWeight);
256
+ const ratio = textWidth / currentBarWidth;
257
+ if (ratio > maxRatio) {
258
+ maxRatio = ratio;
259
+ }
260
+ });
261
+
262
+ if (maxRatio > 1) {
263
+ finalLabelFontSize = Math.max(minLabelFontSize, Math.floor(defaultLabelFontSize / maxRatio));
264
+ }
265
+
266
+ // 第二遍计算:确定是否需要换行以及最大行数 (使用最终字体大小)
267
+ sortedData.forEach(d => {
268
+ const labelText = String(d.x);
269
+ const textWidth = measureTextWidth(labelText, labelFontFamily, finalLabelFontSize, labelFontWeight);
270
+
271
+ if (textWidth > currentBarWidth) {
272
+ // 模拟换行计算行数
273
+ const words = labelText.split(/\s+/); // 按空格分词
274
+ let currentLine = '';
275
+ let lines = 1;
276
+ let simulationSuccess = false; // 标记模拟是否成功
277
+
278
+ if (words.length > 1) { // 优先按单词换行
279
+ for (let i = 0; i < words.length; i++) {
280
+ const testLine = currentLine ? currentLine + " " + words[i] : words[i];
281
+ const testWidth = measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight);
282
+ if (testWidth > currentBarWidth && currentLine !== '') {
283
+ lines++;
284
+ currentLine = words[i];
285
+ // 检查单个单词是否也超长
286
+ if (measureTextWidth(currentLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > currentBarWidth) {
287
+ // 单个单词超长,需要转字符换行
288
+ simulationSuccess = false;
289
+ break; // 退出单词循环
290
+ }
291
+ } else {
292
+ currentLine = testLine;
293
+ }
294
+ }
295
+ if(currentLine !== '') simulationSuccess = true; // 如果有剩余单词则标记成功
296
+
297
+ }
298
+
299
+ // 如果单词换行不成功或只有一个单词,尝试字符换行
300
+ if(words.length <= 1 || !simulationSuccess) {
301
+ lines = 1; // 重置行数
302
+ const chars = labelText.split('');
303
+ currentLine = '';
304
+ for (let i = 0; i < chars.length; i++) {
305
+ const testLine = currentLine + chars[i];
306
+ if (measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > currentBarWidth && currentLine !== '') {
307
+ lines++;
308
+ currentLine = chars[i];
309
+ } else {
310
+ currentLine += chars[i];
311
+ }
312
+ }
313
+ }
314
+
315
+
316
+ if (lines > maxLinesNeeded) {
317
+ maxLinesNeeded = lines;
318
+ }
319
+ }
320
+ });
321
+ } else {
322
+ // 如果barWidth无效,则使用默认字体大小且不换行
323
+ finalLabelFontSize = defaultLabelFontSize;
324
+ maxLinesNeeded = 1;
325
+ }
326
+ // *******************************************************************
327
+ // ** 结束修改:预计算标签字体大小和最大行数 **
328
+ // *******************************************************************
329
+
330
+ // *******************************************************************
331
+ // ** 开始修改:计算图标的统一Y位置 **
332
+ // *******************************************************************
333
+ const labelStartY = innerHeight + 10; // 标签开始的 Y 位置 (靠近柱子底部)
334
+ const lineHeight = finalLabelFontSize * lineHeightFactor; // 行高
335
+ const labelBottomApprox = labelStartY + (maxLinesNeeded - 1) * lineHeight + finalLabelFontSize * 0.71; // 估算最下方标签基线位置
336
+ const iconRadius = 15; // 图标半径
337
+ const iconYPosition = labelBottomApprox + iconRadius + 5; // 在标签下方留出 5px 间距放置图标中心
338
+ const iconBottomY = iconYPosition + iconRadius; // 图标底部的Y坐标
339
+ const barExtensionBuffer = 5; // 柱子比图标底部多延伸的距离
340
+ const barBottomY = iconBottomY + barExtensionBuffer; // 柱子需要延伸到的最终Y坐标
341
+ // *******************************************************************
342
+ // ** 结束修改:计算图标的统一Y位置 **
343
+ // *******************************************************************
344
+
345
+ // 添加柱子 (修改高度)
346
+ chart.selectAll(".bar")
347
+ .data(sortedData)
348
+ .enter()
349
+ .append("rect")
350
+ .attr("class", "bar")
351
+ .attr("x", d => xScale(d.x))
352
+ .attr("y", d => yScale(d.y))
353
+ .attr("width", xScale.bandwidth())
354
+ // 修改: 使柱子高度只取决于数据值和yScale
355
+ .attr("height", d => Math.max(0, innerHeight - yScale(d.y)))
356
+ // *** BEGIN MODIFICATION: Apply specific gradient fill ***
357
+ .attr("fill", d => {
358
+ const dimension = d.x;
359
+ const safeCategory = typeof dimension === 'string' ?
360
+ dimension.toString().replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() :
361
+ `category-${sortedData.findIndex(item => item.x === dimension)}`;
362
+ const gradientId = `gradient-${safeCategory}`;
363
+ return `url(#${gradientId})`;
364
+ })
365
+ // *** END MODIFICATION ***
366
+ // *** BEGIN MODIFICATION: Force rounded corners ***
367
+ .attr("rx", 4) // Force rounded corners
368
+ .attr("ry", 4) // Force rounded corners
369
+ // *** END MODIFICATION ***
370
+ .style("stroke", variables.has_stroke ? "#333" : "none")
371
+ .style("stroke-width", variables.has_stroke ? 1 : 0)
372
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "none")
373
+ .on("mouseover", function() {
374
+ d3.select(this).attr("opacity", 0.8);
375
+ })
376
+ .on("mouseout", function() {
377
+ d3.select(this).attr("opacity", 1);
378
+ });
379
+
380
+ // 在柱子上方添加数值标签
381
+ const defaultAnnotationFontSize = parseFloat(typography.annotation.font_size || 12);
382
+ const minAnnotationFontSize = 6; // 数值标签最小字体
383
+ const annotationFontFamily = typography.annotation.font_family || "Arial";
384
+ const annotationFontWeight = typography.annotation.font_weight || "bold";
385
+
386
+ // 添加数值格式化函数
387
+
388
+ chart.selectAll(".value-label")
389
+ .data(sortedData)
390
+ .enter()
391
+ .append("text")
392
+ .attr("class", "value-label")
393
+ .attr("x", d => xScale(d.x) + xScale.bandwidth() / 2)
394
+ .attr("y", d => yScale(d.y) - 5)
395
+ .attr("text-anchor", "middle")
396
+ .style("font-family", annotationFontFamily)
397
+ .style("font-weight", annotationFontWeight)
398
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
399
+ .each(function(d) {
400
+ const valueText = `${chartUtils.format.autoText(d.y)}${yUnit}`;
401
+ const maxWidth = xScale.bandwidth() * 1.1; // 最大允许宽度为柱宽的1.1倍
402
+ let finalValueFontSize = defaultAnnotationFontSize;
403
+
404
+ if (maxWidth > 0) { // 仅当宽度有效时计算
405
+ const textWidth = measureTextWidth(valueText, annotationFontFamily, defaultAnnotationFontSize, annotationFontWeight);
406
+ if (textWidth > maxWidth) {
407
+ finalValueFontSize = Math.max(minAnnotationFontSize, Math.floor(defaultAnnotationFontSize * (maxWidth / textWidth)));
408
+ }
409
+ }
410
+
411
+ d3.select(this)
412
+ .style("font-size", `${finalValueFontSize}px`)
413
+ .text(valueText);
414
+ });
415
+
416
+ // 在维度标签上方添加水平线
417
+ chart.append("line")
418
+ .attr("x1", 0)
419
+ .attr("y1", innerHeight + 20) // 直接位于维度标签上方的位置
420
+ .attr("x2", innerWidth)
421
+ .attr("y2", innerHeight + 20)
422
+ .attr("stroke", "#e0e0e0")
423
+ .attr("stroke-width", 1);
424
+
425
+ // *******************************************************************
426
+ // ** 开始修改:添加维度标签 (统一字体大小,自动换行) **
427
+ // *******************************************************************
428
+ chart.selectAll(".dimension-label")
429
+ .data(sortedData)
430
+ .enter()
431
+ .append("text")
432
+ .attr("class", "dimension-label")
433
+ .attr("text-anchor", "middle")
434
+ .style("font-family", labelFontFamily)
435
+ .style("font-size", `${finalLabelFontSize}px`) // 应用预计算的字体大小
436
+ .style("font-weight", labelFontWeight)
437
+ .style("fill", "#ffffff") // 使用默认文本颜色
438
+ .each(function(d) {
439
+ const labelText = String(d.x);
440
+ const textWidth = measureTextWidth(labelText, labelFontFamily, finalLabelFontSize, labelFontWeight);
441
+ const textElement = d3.select(this);
442
+ const xPos = xScale(d.x) + xScale.bandwidth() / 2;
443
+ const availableWidth = xScale.bandwidth();
444
+
445
+ if (textWidth > availableWidth && availableWidth > 0) {
446
+ // 需要换行 (重复之前的换行逻辑来渲染)
447
+ const words = labelText.split(/\s+/);
448
+ let lines = [];
449
+ let currentLine = '';
450
+ let simulationSuccess = false;
451
+
452
+ if(words.length > 1){
453
+ for (let i = 0; i < words.length; i++) {
454
+ const testLine = currentLine ? currentLine + " " + words[i] : words[i];
455
+ const testWidth = measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight);
456
+ if (testWidth > availableWidth && currentLine !== '') {
457
+ lines.push(currentLine);
458
+ currentLine = words[i];
459
+ if (measureTextWidth(currentLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > availableWidth) {
460
+ simulationSuccess = false; break;
461
+ }
462
+ } else { currentLine = testLine; }
463
+ }
464
+ if(currentLine !== '') { lines.push(currentLine); simulationSuccess = true; }
465
+ }
466
+ if(words.length <= 1 || !simulationSuccess) {
467
+ lines = [];
468
+ const chars = labelText.split('');
469
+ currentLine = '';
470
+ for (let i = 0; i < chars.length; i++) {
471
+ const testLine = currentLine + chars[i];
472
+ if (measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > availableWidth && currentLine !== '') {
473
+ lines.push(currentLine); currentLine = chars[i];
474
+ } else { currentLine += chars[i]; }
475
+ }
476
+ lines.push(currentLine);
477
+ }
478
+
479
+ lines.forEach((line, i) => {
480
+ textElement.append("tspan")
481
+ .attr("x", xPos)
482
+ .attr("y", labelStartY + i * lineHeight)
483
+ .attr("dy", "0.71em")
484
+ .text(line);
485
+ });
486
+ } else {
487
+ // 不需要换行
488
+ textElement.append("tspan")
489
+ .attr("x", xPos)
490
+ .attr("y", labelStartY)
491
+ .attr("dy", "0.71em")
492
+ .text(labelText);
493
+ }
494
+ });
495
+ // *******************************************************************
496
+ // ** 结束修改:添加维度标签 **
497
+ // *******************************************************************
498
+
499
+
500
+ // *******************************************************************
501
+ // ** 开始修改:添加图标 (移除圆形) - 动态调整 Y 位置 **
502
+ // *******************************************************************
503
+ chart.selectAll(".icon-group") // 修改类名以反映不再是圆形
504
+ .data(sortedData)
505
+ .enter()
506
+ .append("g")
507
+ .attr("class", "icon-group") // 修改类名
508
+ .attr("transform", d => `translate(${xScale(d.x) + xScale.bandwidth() / 2}, ${iconYPosition})`) // 应用动态Y位置
509
+ .each(function(d) {
510
+
511
+ if (images && images.field && images.field[d.x]) {
512
+ d3.select(this)
513
+ .append("image")
514
+ .attr("xlink:href", images.field[d.x])
515
+ .attr("x", -12)
516
+ .attr("y", -12)
517
+ .attr("width", 24)
518
+ .attr("preserveAspectRatio","xMidYMid meet")
519
+ .attr("height", 24);
520
+ }
521
+ });
522
+ // *******************************************************************
523
+ // ** 结束修改:添加图标 **
524
+ // *******************************************************************
525
+
526
+
527
+ // 返回SVG节点
528
+ return svg.node();
529
+ }
modules/chart_engine/template/d3-js/bar_chart/vertical_bar_chart_12.js ADDED
@@ -0,0 +1,856 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Vertical Bar Chart",
5
+ "chart_name": "vertical_bar_chart_12",
6
+ "is_composite": true,
7
+ "required_fields": [["x", "y"], ["x", "y2"]],
8
+ "required_fields_type": [
9
+ [["categorical"], ["numerical"]],
10
+ [["categorical"], ["numerical"]]
11
+ ],
12
+ "required_fields_range": [
13
+ [[2, 30], ["-inf", "inf"]],
14
+ [[2, 30], [0, "inf"]]
15
+ ],
16
+ "required_fields_icons": ["x"],
17
+ "required_other_icons": [],
18
+ "required_fields_colors": ["x"],
19
+ "required_other_colors": ["primary","secondary"],
20
+ "supported_effects": ["radius_corner", "spacing"],
21
+ "min_height": 400,
22
+ "min_width": 400,
23
+ "background": "no",
24
+ "icon_mark": "none",
25
+ "icon_label": "side",
26
+ "has_x_axis": "no",
27
+ "has_y_axis": "no"
28
+ }
29
+ REQUIREMENTS_END
30
+ */
31
+
32
+ function makeChart(containerSelector, dataJSON) {
33
+ const jsonData = dataJSON || {};
34
+ const dataColumns = chartUtils.schema.columns(jsonData);
35
+ const colorResolver = chartUtils.color.resolver(jsonData);
36
+ const chartUtilsFormatSample = chartUtils.format.autoText;
37
+ const chartUtilsTextSample = chartUtils.text.estimate;
38
+ const chartUtilsStandard = {
39
+ schema: chartUtils.schema,
40
+ format: chartUtils.format,
41
+ text: chartUtils.text,
42
+ color: chartUtils.color,
43
+ legendLayout: chartUtils.legend.layout,
44
+ legendDraw: chartUtils.legend.draw,
45
+ random: chartUtils.random.generator(jsonData, "dev-wz-style")
46
+ };
47
+ const standardChannels = chartUtils.schema.channels(jsonData, {
48
+ x: { fallbackIndex: 0 },
49
+ y: { fallbackIndex: 1 },
50
+ y2: { fallbackIndex: 2 },
51
+ y3: { fallbackIndex: 3 },
52
+ size: { fallbackIndex: 2 },
53
+ group: { fallbackIndex: 2 },
54
+ group2: { fallbackIndex: 3 },
55
+ group3: { fallbackIndex: 4 }
56
+ });
57
+ const rawDataBlock = jsonData.data || {};
58
+ const dataBlock = Array.isArray(rawDataBlock)
59
+ ? { data: rawDataBlock, columns: jsonData.columns || [] }
60
+ : rawDataBlock;
61
+ const sourceRows = dataBlock.data || [];
62
+ const columns = dataBlock.columns || jsonData.columns || [];
63
+ const variables = jsonData.variables || {};
64
+ const typography = jsonData.typography || {};
65
+ const sourceColors = jsonData.colors || {};
66
+
67
+ d3.select(containerSelector).html("");
68
+
69
+ const xColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
70
+ const yColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
71
+ const y2Column = Array.from(columns).find(c => c["role"] === "y2")
72
+ || Array.from(columns).find(c => c !== xColumn && c !== yColumn && c.data_type === "numerical")
73
+ || columns[2] || {};
74
+ const xField = xColumn.name;
75
+ const yField = yColumn.name;
76
+ const y2Field = y2Column.name;
77
+
78
+ const width = Math.max(580, Number(variables.width) || 640);
79
+ const height = Math.max(860, Number(variables.height) || 900);
80
+ const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
81
+ const titleFamily = typography.title?.font_family || fontFamily;
82
+ const annotationFamily = typography.annotation?.font_family || fontFamily;
83
+ const darkText = sourceColors.text_color || "#08255a";
84
+ const mutedText = "#53657a";
85
+ const navy = "#08245c";
86
+ const blue = sourceColors.other?.secondary || "#0b82c8";
87
+ const red = sourceColors.other?.primary || "#b9152d";
88
+ const palette = sourceColors.available_colors?.length
89
+ ? sourceColors.available_colors
90
+ : [red, "#159f9b", "#12a84f", "#5cb33e", "#7350a6", "#f3b719", "#1f77b4", "#f97316"];
91
+ const labelSize = Math.max(8.5, Math.min(11.5, parseFloat(typography.label?.font_size) || 10));
92
+ const valueSize = Math.max(8.5, Math.min(12, parseFloat(typography.annotation?.font_size) || 10));
93
+ const yUnit = yColumn.unit === "none" ? "" : (yColumn.unit || "");
94
+ const y2Unit = y2Column.unit === "none" ? "" : (y2Column.unit || "");
95
+
96
+ const svg = d3.select(containerSelector)
97
+ .append("svg")
98
+ .attr("width", "100%")
99
+ .attr("height", height)
100
+ .attr("viewBox", `0 0 ${width} ${height}`)
101
+ .attr("style", "max-width: 100%; height: auto;")
102
+ .attr("xmlns", "http://www.w3.org/2000/svg")
103
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
104
+ .attr("class", "vertical-bar-chart-12 vertical-bar-chart-12-reference-layout");
105
+
106
+ const defs = svg.append("defs");
107
+ const pageGradient = defs.append("linearGradient")
108
+ .attr("id", "vb12-page-gradient")
109
+ .attr("x1", "0%")
110
+ .attr("x2", "100%")
111
+ .attr("y1", "0%")
112
+ .attr("y2", "100%");
113
+ pageGradient.append("stop").attr("offset", "0%").attr("stop-color", "#ffffff");
114
+ pageGradient.append("stop").attr("offset", "62%").attr("stop-color", "#fbfdff");
115
+ pageGradient.append("stop").attr("offset", "100%").attr("stop-color", "#e5f5ff");
116
+
117
+ const barGradient = defs.append("linearGradient")
118
+ .attr("id", "vb12-bar-highlight")
119
+ .attr("x1", "0%")
120
+ .attr("x2", "100%")
121
+ .attr("y1", "0%")
122
+ .attr("y2", "100%");
123
+ barGradient.append("stop").attr("offset", "0%").attr("stop-color", "rgba(255,255,255,0.26)");
124
+ barGradient.append("stop").attr("offset", "100%").attr("stop-color", "rgba(0,0,0,0.10)");
125
+
126
+ const cardShadow = defs.append("filter")
127
+ .attr("id", "vb12-card-shadow")
128
+ .attr("x", "-20%")
129
+ .attr("y", "-20%")
130
+ .attr("width", "140%")
131
+ .attr("height", "150%");
132
+ cardShadow.append("feDropShadow")
133
+ .attr("dx", 0)
134
+ .attr("dy", 8)
135
+ .attr("stdDeviation", 10)
136
+ .attr("flood-color", "#7bb9df")
137
+ .attr("flood-opacity", 0.18);
138
+
139
+ svg.append("rect")
140
+ .attr("class", "chart-canvas reference-page-background")
141
+ .attr("x", 0)
142
+ .attr("y", 0)
143
+ .attr("width", width)
144
+ .attr("height", height)
145
+ .attr("fill", "url(#vb12-page-gradient)");
146
+
147
+ svg.append("path")
148
+ .attr("class", "top-background-wave")
149
+ .attr("d", `M${width * 0.48},0 C${width * 0.62},${height * 0.04} ${width * 0.72},${height * 0.02} ${width},${height * 0.09} L${width},0 Z`)
150
+ .attr("fill", "#dff0fb");
151
+
152
+ svg.append("path")
153
+ .attr("class", "bottom-wave wave-back")
154
+ .attr("d", `M0 ${height - 62} C${width * 0.18} ${height - 90},${width * 0.36} ${height - 42},${width * 0.52} ${height - 68} S${width * 0.82} ${height - 96},${width} ${height - 56} L${width} ${height} L0 ${height} Z`)
155
+ .attr("fill", "#dceffd");
156
+ svg.append("path")
157
+ .attr("class", "bottom-wave wave-front")
158
+ .attr("d", `M0 ${height - 36} C${width * 0.18} ${height - 58},${width * 0.40} ${height - 12},${width * 0.60} ${height - 38} S${width * 0.83} ${height - 68},${width} ${height - 28} L${width} ${height} L0 ${height} Z`)
159
+ .attr("fill", "#55ace5");
160
+ svg.append("path")
161
+ .attr("class", "bottom-wave wave-deep")
162
+ .attr("d", `M0 ${height - 18} C${width * 0.24} ${height - 40},${width * 0.44} ${height + 8},${width * 0.70} ${height - 22} C${width * 0.84} ${height - 38},${width * 0.92} ${height - 22},${width} ${height - 30} L${width} ${height} L0 ${height} Z`)
163
+ .attr("fill", "#1594d2");
164
+
165
+ function message(text) {
166
+ svg.append("text")
167
+ .attr("x", width / 2)
168
+ .attr("y", height / 2)
169
+ .attr("text-anchor", "middle")
170
+ .attr("fill", darkText)
171
+ .style("font-family", fontFamily)
172
+ .style("font-size", "16px")
173
+ .text(text);
174
+ return svg.node();
175
+ }
176
+
177
+ if (!xField || !yField || !y2Field) return message("Missing vertical bar fields");
178
+
179
+ function readableLabel(value) {
180
+ return String(value ?? "")
181
+ .replace(/_/g, " ")
182
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
183
+ .replace(/\s+/g, " ")
184
+ .trim();
185
+ }
186
+
187
+ function parseTemporalRank(value) {
188
+ const text = String(value ?? "").trim();
189
+ const lower = text.toLowerCase();
190
+ const months = {
191
+ jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3, apr: 4, april: 4,
192
+ may: 5, jun: 6, june: 6, jul: 7, july: 7, aug: 8, august: 8,
193
+ sep: 9, sept: 9, september: 9, oct: 10, october: 10, nov: 11, november: 11, dec: 12, december: 12
194
+ };
195
+ const seasons = { winter: 1, spring: 2, summer: 3, fall: 4, autumn: 4 };
196
+ if (months[lower] != null) return months[lower];
197
+ let match = text.match(/^(-?\d{4})$/);
198
+ if (match) return Number(match[1]) * 10000;
199
+ match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
200
+ if (match) return Number(match[1]) * 10000 + Number(match[2]) * 100 + Number(match[3] || 1);
201
+ match = text.match(/^Q([1-4])\s+(-?\d{4})$/i) || text.match(/^(-?\d{4})\s+Q([1-4])$/i);
202
+ if (match && text.toUpperCase().startsWith("Q")) return Number(match[2]) * 10 + Number(match[1]);
203
+ if (match) return Number(match[1]) * 10 + Number(match[2]);
204
+ match = text.match(/^(winter|spring|summer|fall|autumn)\s+(-?\d{4})$/i);
205
+ if (match) return Number(match[2]) * 10 + seasons[match[1].toLowerCase()];
206
+ match = text.match(/^(-?\d{4})\s+(winter|spring|summer|fall|autumn)$/i);
207
+ if (match) return Number(match[1]) * 10 + seasons[match[2].toLowerCase()];
208
+ return NaN;
209
+ }
210
+
211
+ function parseOrdinalRank(value) {
212
+ const text = String(value ?? "").trim().toLowerCase();
213
+ const named = {
214
+ primary: 1, elementary: 1, secondary: 2, college: 3, university: 3,
215
+ low: 1, lower: 1, medium: 2, moderate: 2, high: 3, higher: 3,
216
+ children: 1, adolescents: 2, teens: 2, adults: 3, seniors: 4
217
+ };
218
+ if (named[text] != null) return named[text];
219
+ let match = text.match(/(?:less than|under)\s*[$]?\s*([\d,]+)/);
220
+ if (match) return Number(match[1].replace(/,/g, "")) - 0.5;
221
+ match = text.match(/(?:more than|over)\s*[$]?\s*([\d,]+)/);
222
+ if (match) return Number(match[1].replace(/,/g, "")) + 0.5;
223
+ match = text.match(/^[$]?\s*([\d,]+(?:\.\d+)?)\s*(?:-|to)/);
224
+ if (match) return Number(match[1].replace(/,/g, ""));
225
+ match = text.match(/^(-?\d+(?:\.\d+)?)/);
226
+ if (match) return Number(match[1]);
227
+ return NaN;
228
+ }
229
+
230
+ function compactNumber(value) {
231
+ const numeric = Number(value);
232
+ const abs = Math.abs(numeric);
233
+ if (abs >= 1000000000) return `${d3.format(".3~g")(numeric / 1000000000)}B`;
234
+ if (abs >= 1000000) return `${d3.format(".3~g")(numeric / 1000000)}M`;
235
+ if (abs >= 1000) return `${d3.format(".3~g")(numeric / 1000)}K`;
236
+ return d3.format(",.4~g")(numeric);
237
+ }
238
+
239
+ function formatLocalValue(value, unit = "") {
240
+ const numeric = Number(value);
241
+ const formatted = compactNumber(numeric);
242
+ if (!unit) return formatted;
243
+ if (unit === "%") return `${formatted}%`;
244
+ if (unit.startsWith("$")) {
245
+ const sign = numeric < 0 ? "-" : "";
246
+ return `${sign}$${compactNumber(Math.abs(numeric))}${unit.slice(1)}`;
247
+ }
248
+ if (unit.length <= 3 && /^[^A-Za-z0-9]+$/.test(unit)) return `${unit}${formatted}`;
249
+ return `${formatted} ${unit}`;
250
+ }
251
+
252
+ function fitText(value, maxChars) {
253
+ const text = readableLabel(value);
254
+ if (text.length <= maxChars) return text;
255
+ return text.slice(0, Math.max(1, maxChars)).trim();
256
+ }
257
+
258
+ function wrapLines(value, maxChars, maxLines = 2) {
259
+ const words = readableLabel(value).split(/\s+/).filter(Boolean);
260
+ if (!words.length) return [""];
261
+ const lines = [];
262
+ let current = "";
263
+ words.forEach(word => {
264
+ const shortWord = word.length > maxChars ? word.slice(0, maxChars) : word;
265
+ const next = current ? `${current} ${shortWord}` : shortWord;
266
+ if (next.length <= maxChars) {
267
+ current = next;
268
+ } else {
269
+ if (current) lines.push(current);
270
+ current = shortWord;
271
+ }
272
+ });
273
+ if (current) lines.push(current);
274
+ if (lines.length <= maxLines) return lines;
275
+ return lines.slice(0, maxLines).map(line => fitText(line, maxChars));
276
+ }
277
+
278
+ const grouped = new Map();
279
+ sourceRows.forEach((row, index) => {
280
+ const category = readableLabel(row[xField]);
281
+ const yValue = Number(row[yField]);
282
+ const y2Value = Number(row[y2Field]);
283
+ if (!category || !Number.isFinite(yValue) || !Number.isFinite(y2Value)) return;
284
+ if (!grouped.has(category)) {
285
+ grouped.set(category, {
286
+ category,
287
+ rawCategory: row[xField],
288
+ firstIndex: index,
289
+ y: 0,
290
+ y2: 0
291
+ });
292
+ }
293
+ const entry = grouped.get(category);
294
+ entry.y += yValue;
295
+ entry.y2 += Math.max(0, y2Value);
296
+ });
297
+
298
+ const rows = Array.from(grouped.values()).map((row, index) => ({
299
+ ...row,
300
+ originalIndex: index,
301
+ temporalRank: parseTemporalRank(row.rawCategory),
302
+ ordinalRank: parseOrdinalRank(row.rawCategory)
303
+ }));
304
+ if (!rows.length) return message("No valid data for vertical bars");
305
+
306
+ const allTemporal = xColumn.data_type === "temporal" || rows.every(d => Number.isFinite(d.temporalRank));
307
+ const allOrdinal = !allTemporal && rows.every(d => Number.isFinite(d.ordinalRank));
308
+ const sortMode = allTemporal ? "chronological" : (allOrdinal ? "ordered category" : "largest primary value");
309
+ rows.sort((a, b) => {
310
+ if (allTemporal) return d3.ascending(a.temporalRank, b.temporalRank) || d3.ascending(a.firstIndex, b.firstIndex);
311
+ if (allOrdinal) return d3.ascending(a.ordinalRank, b.ordinalRank) || d3.ascending(a.firstIndex, b.firstIndex);
312
+ return d3.descending(a.y, b.y) || d3.ascending(a.category, b.category);
313
+ });
314
+
315
+ const yLabel = readableLabel(yColumn.label || yField);
316
+ const y2Label = readableLabel(y2Column.label || y2Field);
317
+ const xLabel = readableLabel(xColumn.label || xField);
318
+ const titleLines = wrapLines(`${yLabel} and ${y2Label}`, 16, 5);
319
+ const byLine = fitText(`by ${xLabel}`, 24);
320
+ const subtitle = `${fitText(yLabel, 36)} compared with ${fitText(y2Label, 34)} across ${fitText(xLabel, 24).toLowerCase()} categories.`;
321
+
322
+ const headerX = 42;
323
+ const headerTop = 42;
324
+ const titleSize = Math.max(22, Math.min(25, width * 0.042));
325
+ const titleLeading = titleSize * 0.93;
326
+ const byLineY = titleLines.length * titleLeading + 8;
327
+ const accentY = byLineY + 31;
328
+ const subtitleY = accentY + 29;
329
+ const referenceHeader = svg.append("g")
330
+ .attr("class", "reference-header title-text-block")
331
+ .attr("transform", `translate(${headerX}, ${headerTop})`);
332
+
333
+ titleLines.forEach((line, index) => {
334
+ referenceHeader.append("text")
335
+ .attr("class", `reference-title-line reference-title-line-${index + 1}`)
336
+ .attr("x", 0)
337
+ .attr("y", index * titleLeading)
338
+ .attr("fill", navy)
339
+ .style("font-family", titleFamily)
340
+ .style("font-size", `${titleSize}px`)
341
+ .style("font-weight", "900")
342
+ .style("letter-spacing", "0")
343
+ .text(line);
344
+ });
345
+
346
+ referenceHeader.append("text")
347
+ .attr("class", "reference-byline")
348
+ .attr("x", 0)
349
+ .attr("y", byLineY)
350
+ .attr("fill", blue)
351
+ .style("font-family", titleFamily)
352
+ .style("font-size", `${Math.max(18, titleSize * 0.60)}px`)
353
+ .style("font-weight", "900")
354
+ .style("letter-spacing", "0")
355
+ .text(byLine);
356
+
357
+ referenceHeader.append("line")
358
+ .attr("class", "title-accent-rule")
359
+ .attr("x1", 0)
360
+ .attr("x2", 58)
361
+ .attr("y1", accentY)
362
+ .attr("y2", accentY)
363
+ .attr("stroke", "#0e9f9a")
364
+ .attr("stroke-width", 4)
365
+ .attr("stroke-linecap", "round");
366
+
367
+ wrapLines(subtitle, 42, 2).forEach((line, index) => {
368
+ referenceHeader.append("text")
369
+ .attr("class", "reference-subtitle-line")
370
+ .attr("x", 0)
371
+ .attr("y", subtitleY + index * 20)
372
+ .attr("fill", "#374151")
373
+ .style("font-family", annotationFamily)
374
+ .style("font-size", "15px")
375
+ .style("font-weight", "500")
376
+ .text(line);
377
+ });
378
+
379
+ const decor = svg.append("g")
380
+ .attr("class", "top-decorative-image-slot swimmer-wave-slot")
381
+ .attr("transform", `translate(${width * 0.48}, 34)`);
382
+ const decorW = width * 0.46;
383
+ decor.append("path")
384
+ .attr("class", "decorative-wave-ribbon")
385
+ .attr("d", `M0 110 C${decorW * 0.25} 88,${decorW * 0.36} 132,${decorW * 0.60} 92 C${decorW * 0.75} 68,${decorW * 0.90} 86,${decorW} 62 L${decorW} 118 C${decorW * 0.73} 139,${decorW * 0.42} 141,0 126 Z`)
386
+ .attr("fill", "#bfe6ff")
387
+ .attr("opacity", 0.86);
388
+ decor.append("path")
389
+ .attr("class", "decorative-wave-front")
390
+ .attr("d", `M${decorW * 0.06} 122 C${decorW * 0.25} 92,${decorW * 0.38} 124,${decorW * 0.55} 98 C${decorW * 0.76} 66,${decorW * 0.82} 94,${decorW * 0.98} 78`)
391
+ .attr("fill", "none")
392
+ .attr("stroke", "#178ed8")
393
+ .attr("stroke-width", 9)
394
+ .attr("stroke-linecap", "round")
395
+ .attr("opacity", 0.72);
396
+ decor.append("path")
397
+ .attr("class", "decorative-swimmer-arm")
398
+ .attr("d", `M${decorW * 0.28} 72 C${decorW * 0.41} 56,${decorW * 0.54} 42,${decorW * 0.64} 48 C${decorW * 0.70} 54,${decorW * 0.72} 68,${decorW * 0.76} 78`)
399
+ .attr("fill", "none")
400
+ .attr("stroke", "#f4a072")
401
+ .attr("stroke-width", 18)
402
+ .attr("stroke-linecap", "round");
403
+ decor.append("circle")
404
+ .attr("class", "decorative-swimmer-head")
405
+ .attr("cx", decorW * 0.84)
406
+ .attr("cy", 66)
407
+ .attr("r", 19)
408
+ .attr("fill", "#f5b18a");
409
+ decor.append("path")
410
+ .attr("class", "decorative-swimmer-cap")
411
+ .attr("d", `M${decorW * 0.80} 56 Q${decorW * 0.84} 37 ${decorW * 0.90} 55 L${decorW * 0.89} 73 Q${decorW * 0.84} 62 ${decorW * 0.80} 56 Z`)
412
+ .attr("fill", "#e91d36");
413
+ decor.append("circle")
414
+ .attr("class", "decorative-swimmer-goggle")
415
+ .attr("cx", decorW * 0.855)
416
+ .attr("cy", 64)
417
+ .attr("r", 5)
418
+ .attr("fill", navy);
419
+
420
+ const legendY = Math.max(214, headerTop + subtitleY + 64);
421
+ const legend = svg.append("g")
422
+ .attr("class", "legend-card reference-legend")
423
+ .attr("transform", `translate(${headerX}, ${legendY})`);
424
+ legend.append("rect")
425
+ .attr("x", 0)
426
+ .attr("y", 0)
427
+ .attr("width", Math.min(width - 84, 308))
428
+ .attr("height", 46)
429
+ .attr("rx", 10)
430
+ .attr("fill", "rgba(255,255,255,0.92)")
431
+ .attr("stroke", "rgba(148,163,184,0.36)");
432
+
433
+ const legendItems = [
434
+ { label: yLabel, color: red, shape: "rect", className: "legend-primary" },
435
+ { label: y2Label, color: navy, shape: "circle", className: "legend-secondary" }
436
+ ];
437
+ legendItems.forEach((item, index) => {
438
+ const itemX = index ? Math.min(176, width * 0.30) : 24;
439
+ const itemG = legend.append("g")
440
+ .attr("class", `legend-item ${item.className}`)
441
+ .attr("transform", `translate(${itemX}, 23)`);
442
+ if (item.shape === "rect") {
443
+ itemG.append("rect")
444
+ .attr("x", -8)
445
+ .attr("y", -8)
446
+ .attr("width", 16)
447
+ .attr("height", 16)
448
+ .attr("rx", 1.5)
449
+ .attr("fill", item.color);
450
+ } else {
451
+ itemG.append("circle")
452
+ .attr("cx", 0)
453
+ .attr("cy", 0)
454
+ .attr("r", 8)
455
+ .attr("fill", item.color);
456
+ }
457
+ itemG.append("text")
458
+ .attr("x", 18)
459
+ .attr("y", 0)
460
+ .attr("dominant-baseline", "middle")
461
+ .attr("fill", darkText)
462
+ .style("font-family", annotationFamily)
463
+ .style("font-size", "13px")
464
+ .style("font-weight", "700")
465
+ .text(fitText(item.label + (index === 0 && yUnit ? ` (${yUnit})` : index === 1 && y2Unit ? ` (${y2Unit})` : ""), 19));
466
+ });
467
+
468
+ const chartX = Math.max(58, width * 0.10);
469
+ const chartY = legendY + 84;
470
+ const chartW = width - chartX * 2;
471
+ const footerH = 72;
472
+ const footerY = height - footerH - 45;
473
+ const chartH = Math.max(300, footerY - chartY - 34);
474
+ const plotTop = chartY + 142;
475
+ const plotBottom = chartY + chartH - 74;
476
+ const circleY = chartY + chartH - 34;
477
+ const xScale = d3.scaleBand()
478
+ .domain(rows.map(d => d.category))
479
+ .range([0, chartW])
480
+ .padding(rows.length > 8 ? 0.13 : 0.17);
481
+
482
+ const minY = d3.min(rows, d => d.y) || 0;
483
+ const maxY = d3.max(rows, d => d.y) || 0;
484
+ const span = Math.max(1, maxY - minY);
485
+ const yDomain = [
486
+ Math.min(0, minY - span * 0.12),
487
+ Math.max(0, maxY + span * 0.12)
488
+ ];
489
+ const yScale = d3.scaleLinear()
490
+ .domain(yDomain)
491
+ .nice(5)
492
+ .range([plotBottom, plotTop]);
493
+ const zeroY = yScale(0);
494
+ const maxY2 = d3.max(rows, d => d.y2) || 1;
495
+ const circleScale = d3.scaleSqrt()
496
+ .domain([0, maxY2])
497
+ .range([Math.max(5, Math.min(8, xScale.bandwidth() * 0.18)), Math.max(13, Math.min(22, xScale.bandwidth() * 0.32))]);
498
+ const colorFor = (row, index) => sourceColors.field?.[row.rawCategory]
499
+ || sourceColors.field?.[row.category]
500
+ || palette[index % palette.length]
501
+ || red;
502
+ const softFillFor = (color, index) => {
503
+ const fallback = ["#f8e9ed", "#e7f6f6", "#eaf8ef", "#edf7e9", "#f0ecfa", "#fff4cf", "#e8f1ff", "#fff0df"];
504
+ return d3.color(color)?.copy({ opacity: 0.10 })?.formatRgb() || fallback[index % fallback.length];
505
+ };
506
+
507
+ const chart = svg.append("g")
508
+ .attr("class", "reference-chart-block vertical-bar-plot")
509
+ .attr("transform", `translate(${chartX}, 0)`);
510
+
511
+ chart.append("rect")
512
+ .attr("class", "plot-background reference-plot-panel")
513
+ .attr("x", -10)
514
+ .attr("y", chartY - 8)
515
+ .attr("width", chartW + 20)
516
+ .attr("height", chartH + 24)
517
+ .attr("rx", 10)
518
+ .attr("fill", "rgba(255,255,255,0.68)")
519
+ .attr("stroke", "rgba(210,225,238,0.62)");
520
+
521
+ let yTicks = yScale.ticks(5);
522
+ if (!yTicks.some(tick => Math.abs(tick) < 1e-9)) yTicks.push(0);
523
+ yTicks = Array.from(new Set(yTicks.map(tick => Number(tick.toPrecision(12))))).sort((a, b) => a - b);
524
+
525
+ yTicks.forEach(tick => {
526
+ const y = yScale(tick);
527
+ chart.append("line")
528
+ .attr("class", "gridline y-axis-gridline")
529
+ .attr("x1", 0)
530
+ .attr("x2", chartW)
531
+ .attr("y1", y)
532
+ .attr("y2", y)
533
+ .attr("stroke", Math.abs(tick) < 1e-9 ? "rgba(148,163,184,0.58)" : "rgba(148,163,184,0.26)")
534
+ .attr("stroke-width", Math.abs(tick) < 1e-9 ? 1.2 : 0.8)
535
+ .attr("stroke-dasharray", Math.abs(tick) < 1e-9 ? "4 3" : "none");
536
+ chart.append("text")
537
+ .attr("class", "y-axis-tick primary-axis-tick")
538
+ .attr("x", -12)
539
+ .attr("y", y)
540
+ .attr("text-anchor", "end")
541
+ .attr("dominant-baseline", "middle")
542
+ .attr("fill", "#3f4755")
543
+ .style("font-family", annotationFamily)
544
+ .style("font-size", `${Math.max(8, valueSize - 0.5)}px`)
545
+ .style("font-weight", "650")
546
+ .text(formatLocalValue(tick, yUnit));
547
+ });
548
+
549
+ const y2Scale = d3.scaleLinear().domain([0, maxY2]).nice(4).range([plotBottom, plotTop]);
550
+ y2Scale.ticks(4).forEach(tick => {
551
+ chart.append("text")
552
+ .attr("class", "secondary-axis-tick")
553
+ .attr("x", chartW + 12)
554
+ .attr("y", y2Scale(tick))
555
+ .attr("dominant-baseline", "middle")
556
+ .attr("fill", "#075ca5")
557
+ .style("font-family", annotationFamily)
558
+ .style("font-size", `${Math.max(8, valueSize - 0.5)}px`)
559
+ .style("font-weight", "650")
560
+ .text(formatLocalValue(tick, y2Unit));
561
+ });
562
+
563
+ chart.append("text")
564
+ .attr("class", "axis-title y-axis-title")
565
+ .attr("x", -(plotTop + plotBottom) / 2)
566
+ .attr("y", -76)
567
+ .attr("transform", "rotate(-90)")
568
+ .attr("text-anchor", "middle")
569
+ .attr("fill", red)
570
+ .style("font-family", annotationFamily)
571
+ .style("font-size", `${Math.max(10, valueSize)}px`)
572
+ .style("font-weight", "800")
573
+ .text(fitText(yLabel + (yUnit ? ` (${yUnit})` : ""), 34));
574
+
575
+ chart.append("text")
576
+ .attr("class", "axis-title secondary-axis-title")
577
+ .attr("x", (plotTop + plotBottom) / 2)
578
+ .attr("y", -(chartW + 64))
579
+ .attr("transform", "rotate(90)")
580
+ .attr("text-anchor", "middle")
581
+ .attr("fill", "#075ca5")
582
+ .style("font-family", annotationFamily)
583
+ .style("font-size", `${Math.max(10, valueSize)}px`)
584
+ .style("font-weight", "800")
585
+ .text(fitText(y2Label + (y2Unit ? ` (${y2Unit})` : ""), 34));
586
+
587
+ rows.forEach((row, index) => {
588
+ const x = xScale(row.category);
589
+ const bandWidth = xScale.bandwidth();
590
+ const centerX = x + bandWidth / 2;
591
+ const color = colorFor(row, index);
592
+ const panelWidth = Math.max(32, bandWidth * 0.94);
593
+ const panelX = centerX - panelWidth / 2;
594
+ const panelY = chartY + 12;
595
+ const panelH = chartH - 2;
596
+ const iconR = Math.max(14, Math.min(24, bandWidth * 0.27));
597
+ const barWidth = Math.max(13, Math.min(40, bandWidth * 0.46));
598
+ const yValue = row.y;
599
+ const yTop = yScale(Math.max(0, yValue));
600
+ const yBottom = yScale(Math.min(0, yValue));
601
+ const barHeight = Math.max(1, Math.abs(yBottom - yTop));
602
+ const barY = yValue >= 0 ? yTop : zeroY;
603
+ const labelY = yValue >= 0 ? Math.max(plotTop - 8, barY - 13) : Math.min(plotBottom + 17, barY + barHeight + 13);
604
+ const radius = circleScale(row.y2);
605
+
606
+ chart.append("rect")
607
+ .attr("class", "category-column-panel reference-column-card")
608
+ .attr("data-source-index", row.firstIndex)
609
+ .attr("data-category", row.category)
610
+ .attr("data-y", row.y)
611
+ .attr("data-y2", row.y2)
612
+ .attr("x", panelX)
613
+ .attr("y", panelY)
614
+ .attr("width", panelWidth)
615
+ .attr("height", panelH)
616
+ .attr("rx", 13)
617
+ .attr("fill", softFillFor(color, index))
618
+ .attr("stroke", "rgba(255,255,255,0.88)")
619
+ .attr("filter", rows.length <= 7 ? "url(#vb12-card-shadow)" : null);
620
+
621
+ const icon = chart.append("g")
622
+ .attr("class", "category-icon-slot")
623
+ .attr("data-source-index", row.firstIndex)
624
+ .attr("data-category", row.category)
625
+ .attr("transform", `translate(${centerX}, ${chartY + 50})`);
626
+ icon.append("circle")
627
+ .attr("r", iconR)
628
+ .attr("fill", "rgba(255,255,255,0.86)")
629
+ .attr("stroke", "rgba(226,232,240,0.72)");
630
+ icon.append("path")
631
+ .attr("d", index % 3 === 0
632
+ ? `M${-iconR * 0.55},${iconR * 0.16} C${-iconR * 0.18},${-iconR * 0.36} ${iconR * 0.18},${iconR * 0.48} ${iconR * 0.55},${-iconR * 0.14}`
633
+ : index % 3 === 1
634
+ ? `M${-iconR * 0.52},${iconR * 0.24} L${-iconR * 0.10},${-iconR * 0.38} L${iconR * 0.50},${iconR * 0.16}`
635
+ : `M${-iconR * 0.48},0 L${iconR * 0.48},0 M0,${-iconR * 0.48} L0,${iconR * 0.48}`)
636
+ .attr("fill", "none")
637
+ .attr("stroke", color)
638
+ .attr("stroke-width", Math.max(2.2, iconR * 0.18))
639
+ .attr("stroke-linecap", "round")
640
+ .attr("stroke-linejoin", "round");
641
+
642
+ const categoryText = chart.append("text")
643
+ .attr("class", "x-axis-tick category-label")
644
+ .attr("data-source-index", row.firstIndex)
645
+ .attr("data-category", row.category)
646
+ .attr("x", centerX)
647
+ .attr("y", chartY + 94)
648
+ .attr("text-anchor", "middle")
649
+ .attr("fill", navy)
650
+ .style("font-family", fontFamily)
651
+ .style("font-size", `${Math.max(8, Math.min(labelSize + 0.5, bandWidth * 0.17))}px`)
652
+ .style("font-weight", "900");
653
+ const categoryChars = Math.max(5, Math.floor(panelWidth / (labelSize * 0.48)));
654
+ wrapLines(row.category.toUpperCase(), categoryChars, 2).forEach((line, lineIndex) => {
655
+ categoryText.append("tspan")
656
+ .attr("x", centerX)
657
+ .attr("dy", lineIndex === 0 ? 0 : labelSize + 1)
658
+ .text(line);
659
+ });
660
+
661
+ chart.append("rect")
662
+ .attr("class", "bar-track")
663
+ .attr("data-source-index", row.firstIndex)
664
+ .attr("data-category", row.category)
665
+ .attr("x", centerX - barWidth / 2)
666
+ .attr("y", plotTop)
667
+ .attr("width", barWidth)
668
+ .attr("height", plotBottom - plotTop)
669
+ .attr("rx", Math.min(12, barWidth / 2))
670
+ .attr("fill", "rgba(255,255,255,0.62)");
671
+
672
+ chart.append("rect")
673
+ .attr("class", "vertical-bar bar-mark data-point mark")
674
+ .attr("data-source-index", row.firstIndex)
675
+ .attr("data-category", row.category)
676
+ .attr("data-y", row.y)
677
+ .attr("data-y2", row.y2)
678
+ .attr("x", centerX - barWidth / 2)
679
+ .attr("y", barY)
680
+ .attr("width", barWidth)
681
+ .attr("height", barHeight)
682
+ .attr("rx", variables.has_rounded_corners === false ? 4 : Math.min(12, barWidth / 2))
683
+ .attr("fill", color);
684
+
685
+ chart.append("rect")
686
+ .attr("class", "bar-highlight")
687
+ .attr("x", centerX - barWidth / 2)
688
+ .attr("y", barY)
689
+ .attr("width", barWidth)
690
+ .attr("height", barHeight)
691
+ .attr("rx", variables.has_rounded_corners === false ? 4 : Math.min(12, barWidth / 2))
692
+ .attr("fill", "url(#vb12-bar-highlight)")
693
+ .attr("opacity", 0.45);
694
+
695
+ chart.append("text")
696
+ .attr("class", "bar-value-label value-label")
697
+ .attr("data-source-index", row.firstIndex)
698
+ .attr("data-category", row.category)
699
+ .attr("data-y", row.y)
700
+ .attr("x", centerX)
701
+ .attr("y", labelY)
702
+ .attr("text-anchor", "middle")
703
+ .attr("fill", color)
704
+ .style("font-family", annotationFamily)
705
+ .style("font-size", `${Math.max(11, valueSize + 2)}px`)
706
+ .style("font-weight", "900")
707
+ .text(formatLocalValue(row.y, yUnit));
708
+
709
+ chart.append("line")
710
+ .attr("class", "secondary-marker-guide")
711
+ .attr("data-source-index", row.firstIndex)
712
+ .attr("data-category", row.category)
713
+ .attr("x1", centerX)
714
+ .attr("x2", centerX)
715
+ .attr("y1", zeroY)
716
+ .attr("y2", circleY)
717
+ .attr("stroke", color)
718
+ .attr("stroke-width", 1.2)
719
+ .attr("stroke-dasharray", "3 3")
720
+ .attr("opacity", 0.62);
721
+
722
+ chart.append("circle")
723
+ .attr("class", "secondary-circle circle-mark")
724
+ .attr("data-source-index", row.firstIndex)
725
+ .attr("data-category", row.category)
726
+ .attr("data-y2", row.y2)
727
+ .attr("cx", centerX)
728
+ .attr("cy", circleY)
729
+ .attr("r", radius)
730
+ .attr("fill", color)
731
+ .attr("stroke", "rgba(255,255,255,0.95)")
732
+ .attr("stroke-width", 2.2);
733
+
734
+ chart.append("text")
735
+ .attr("class", "secondary-value-label")
736
+ .attr("data-source-index", row.firstIndex)
737
+ .attr("data-category", row.category)
738
+ .attr("data-y2", row.y2)
739
+ .attr("x", centerX)
740
+ .attr("y", circleY + Math.max(3, valueSize * 0.36))
741
+ .attr("text-anchor", "middle")
742
+ .attr("fill", "#ffffff")
743
+ .style("font-family", annotationFamily)
744
+ .style("font-size", `${Math.max(7.5, Math.min(valueSize, radius * 0.72))}px`)
745
+ .style("font-weight", "900")
746
+ .text(formatLocalValue(row.y2, ""));
747
+
748
+ if (y2Unit) {
749
+ chart.append("text")
750
+ .attr("class", "secondary-unit-label")
751
+ .attr("data-source-index", row.firstIndex)
752
+ .attr("data-category", row.category)
753
+ .attr("x", centerX)
754
+ .attr("y", circleY + radius + 17)
755
+ .attr("text-anchor", "middle")
756
+ .attr("fill", "#334155")
757
+ .style("font-family", annotationFamily)
758
+ .style("font-size", `${Math.max(7.5, valueSize - 1)}px`)
759
+ .text(fitText(y2Unit, 8));
760
+ }
761
+ });
762
+
763
+ const footer = svg.append("g")
764
+ .attr("class", "bottom-insight-card footer-source-card")
765
+ .attr("transform", `translate(${Math.max(34, width * 0.07)}, ${footerY})`);
766
+ const footerW = width - Math.max(34, width * 0.07) * 2;
767
+ footer.append("rect")
768
+ .attr("class", "footer-card-background")
769
+ .attr("x", 0)
770
+ .attr("y", 0)
771
+ .attr("width", footerW)
772
+ .attr("height", footerH)
773
+ .attr("rx", 11)
774
+ .attr("fill", "rgba(255,255,255,0.86)")
775
+ .attr("stroke", "#bed7ee")
776
+ .attr("stroke-width", 1);
777
+
778
+ const footerIcon = footer.append("g")
779
+ .attr("class", "footer-icon-slot")
780
+ .attr("transform", "translate(58, 36)");
781
+ footerIcon.append("path")
782
+ .attr("d", "M-24 18 L-24 3 L-15 3 L-15 18 M-9 18 L-9 -8 L0 -8 L0 18 M6 18 L6 -22 L18 -22 L18 18")
783
+ .attr("fill", "none")
784
+ .attr("stroke", blue)
785
+ .attr("stroke-width", 5)
786
+ .attr("stroke-linejoin", "round");
787
+ footerIcon.append("path")
788
+ .attr("d", "M-26 -4 L-9 -16 L0 -8 L21 -31")
789
+ .attr("fill", "none")
790
+ .attr("stroke", "#0f8fd0")
791
+ .attr("stroke-width", 5)
792
+ .attr("stroke-linecap", "round")
793
+ .attr("stroke-linejoin", "round");
794
+ footerIcon.append("path")
795
+ .attr("d", "M21 -31 L20 -15 M21 -31 L6 -28")
796
+ .attr("fill", "none")
797
+ .attr("stroke", "#0f8fd0")
798
+ .attr("stroke-width", 5)
799
+ .attr("stroke-linecap", "round");
800
+
801
+ footer.append("line")
802
+ .attr("class", "footer-divider")
803
+ .attr("x1", 102)
804
+ .attr("x2", 102)
805
+ .attr("y1", 14)
806
+ .attr("y2", footerH - 14)
807
+ .attr("stroke", "#bfd3e8")
808
+ .attr("stroke-width", 1.4);
809
+
810
+ const highY = rows.reduce((best, row) => row.y > best.y ? row : best, rows[0]);
811
+ const highY2 = rows.reduce((best, row) => row.y2 > best.y2 ? row : best, rows[0]);
812
+ const insightText = `${fitText(highY.category, 20)} leads ${fitText(yLabel, 24)}; ${fitText(highY2.category, 20)} leads ${fitText(y2Label, 24)}.`;
813
+ const insightLines = wrapLines(insightText, Math.max(34, Math.floor((footerW - 190) / 7)), 2);
814
+ insightLines.forEach((line, index) => {
815
+ footer.append("text")
816
+ .attr("class", "footer-insight-title")
817
+ .attr("x", 122)
818
+ .attr("y", 25 + index * 15)
819
+ .attr("fill", navy)
820
+ .style("font-family", annotationFamily)
821
+ .style("font-size", "13.5px")
822
+ .style("font-weight", "900")
823
+ .text(line);
824
+ });
825
+ footer.append("text")
826
+ .attr("class", "source-row source-text")
827
+ .attr("x", 122)
828
+ .attr("y", insightLines.length > 1 ? 58 : 51)
829
+ .attr("fill", mutedText)
830
+ .style("font-family", annotationFamily)
831
+ .style("font-size", "10.5px")
832
+ .text(fitText(`Source: ${fitText(xLabel, 20)} order is ${sortMode}; ${fitText(y2Label, 22)} uses circle size.`, Math.max(38, Math.floor((footerW - 245) / 5.6))));
833
+
834
+ const footerDecor = footer.append("g")
835
+ .attr("class", "footer-decorative-image-slot wave-icon-slot")
836
+ .attr("transform", `translate(${footerW - 72}, 39)`);
837
+ footerDecor.append("path")
838
+ .attr("d", "M-36 6 C-22 -13,-8 19,8 -1 S28 -12,38 4")
839
+ .attr("fill", "none")
840
+ .attr("stroke", "#93c5e8")
841
+ .attr("stroke-width", 5)
842
+ .attr("stroke-linecap", "round");
843
+ footerDecor.append("path")
844
+ .attr("d", "M-32 18 C-16 1,-2 29,16 10 S30 3,40 15")
845
+ .attr("fill", "none")
846
+ .attr("stroke", "#57aee2")
847
+ .attr("stroke-width", 4)
848
+ .attr("stroke-linecap", "round");
849
+ footerDecor.append("circle")
850
+ .attr("cx", 35)
851
+ .attr("cy", -17)
852
+ .attr("r", 10)
853
+ .attr("fill", "#9bcdf0");
854
+
855
+ return svg.node();
856
+ }
modules/chart_engine/template/d3-js/bar_chart/vertical_bar_chart_13.js ADDED
@@ -0,0 +1,652 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Vertical Bar Chart",
5
+ "chart_name": "vertical_bar_chart_13",
6
+ "is_composite": true,
7
+ "required_fields": ["x", "y", "y2"],
8
+ "required_fields_type": [["categorical", "temporal"], ["numerical"], ["numerical"]],
9
+ "required_fields_range": [[2, 18], ["-inf", "inf"], [0, "inf"]],
10
+ "required_fields_icons": [],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": [],
13
+ "required_other_colors": ["primary", "secondary"],
14
+ "supported_effects": ["radius_corner", "spacing"],
15
+ "min_height": 400,
16
+ "min_width": 400,
17
+ "background": "no",
18
+ "icon_mark": "none",
19
+ "icon_label": "side",
20
+ "has_x_axis": "yes",
21
+ "has_y_axis": "yes"
22
+ }
23
+ REQUIREMENTS_END
24
+ */
25
+
26
+ function makeChart(containerSelector, data) {
27
+ const jsonData = data;
28
+ const chartData = jsonData.data?.data || [];
29
+ const dataColumns = chartUtils.schema.columns(jsonData);
30
+ const colorResolver = chartUtils.color.resolver(jsonData);
31
+ const chartUtilsFormatSample = chartUtils.format.autoText;
32
+ const chartUtilsStandard = {
33
+ schema: chartUtils.schema,
34
+ format: chartUtils.format,
35
+ text: chartUtils.text,
36
+ color: chartUtils.color,
37
+ legendLayout: chartUtils.legend.layout,
38
+ legendDraw: chartUtils.legend.draw,
39
+ random: chartUtils.random.generator(jsonData, "dev-wz-style")
40
+ };
41
+ const standardChannels = chartUtils.schema.channels(jsonData, {
42
+ x: { fallbackIndex: 0 },
43
+ y: { fallbackIndex: 1 },
44
+ y2: { fallbackIndex: 2 },
45
+ y3: { fallbackIndex: 3 },
46
+ size: { fallbackIndex: 2 },
47
+ group: { fallbackIndex: 2 },
48
+ group2: { fallbackIndex: 3 },
49
+ group3: { fallbackIndex: 4 }
50
+ });
51
+ const variables = jsonData.variables || {};
52
+ const typography = jsonData.typography || {
53
+ label: { font_family: "Arial", font_size: "14px", font_weight: 500 },
54
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: 400 }
55
+ };
56
+ const sourceColors = jsonData.colors || {
57
+ text_color: "#1E2A3A",
58
+ other: { primary: "#2468C9", secondary: "#F35F72" }
59
+ };
60
+
61
+ const dimensionColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
62
+ const valueColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
63
+ const valueColumn2 = chartUtils.schema.channel(jsonData, "y2", { fallbackIndex: 2 }).raw || {};
64
+ const dimensionField = dimensionColumn.name;
65
+ const valueField = valueColumn.name;
66
+ const valueField2 = valueColumn2.name;
67
+
68
+ d3.select(containerSelector).html("");
69
+
70
+ const baseWidth = Number(variables.width) || 600;
71
+ const width = Math.max(860, baseWidth);
72
+ const height = Math.max(780, Number(variables.height) || 600);
73
+ const svg = d3.select(containerSelector)
74
+ .append("svg")
75
+ .attr("width", "100%")
76
+ .attr("height", height)
77
+ .attr("viewBox", `0 0 ${width} ${height}`)
78
+ .attr("preserveAspectRatio", "xMidYMid meet")
79
+ .attr("style", "max-width: 100%; height: auto;")
80
+ .attr("xmlns", "http://www.w3.org/2000/svg")
81
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
82
+
83
+ const labelFont = typography.label || {};
84
+ const annotationFont = typography.annotation || {};
85
+ const labelFontFamily = labelFont.font_family || "Arial";
86
+ const annotationFontFamily = annotationFont.font_family || labelFontFamily;
87
+ const labelFontWeight = labelFont.font_weight || 500;
88
+ const annotationFontWeight = annotationFont.font_weight || 500;
89
+ const baseLabelSize = parseFloat(labelFont.font_size) || 14;
90
+ const baseAnnotationSize = parseFloat(annotationFont.font_size) || 12;
91
+ const navy = "#061A4C";
92
+ const muted = "#3C465A";
93
+ const barColor = "#2468C9";
94
+ const secondaryColor = "#F35F72";
95
+ const softRed = "#FFF1F3";
96
+ const axisColor = "#2D3545";
97
+ const gridColor = "#DDE3EA";
98
+
99
+ const canvas = document.createElement("canvas");
100
+ const context = canvas.getContext("2d");
101
+ const measureLabelText = (text, fontSize, weight = labelFontWeight, family = labelFontFamily) => {
102
+ context.font = `${weight} ${fontSize}px ${family}`;
103
+ return chartUtils.text.contextWidth(context, String(text ?? ""));
104
+ };
105
+
106
+ const unitText = (unit) => {
107
+ const raw = String(unit ?? "").trim();
108
+ return raw === "none" ? "" : raw;
109
+ };
110
+ const yUnit = unitText(valueColumn.unit);
111
+ const y2Unit = unitText(valueColumn2.unit);
112
+ const readableFieldName = value => String(value || "")
113
+ .replace(/_/g, " ")
114
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
115
+ .replace(/\s+/g, " ")
116
+ .trim();
117
+
118
+ const formatLocalValue = value => {
119
+ const numeric = Number(value);
120
+ if (!Number.isFinite(numeric)) return String(value ?? "");
121
+ const absValue = Math.abs(numeric);
122
+ if (absValue >= 1000000000) return d3.format("~g")(numeric / 1000000000) + "B";
123
+ if (absValue >= 1000000) return d3.format("~g")(numeric / 1000000) + "M";
124
+ if (absValue >= 1000) return d3.format("~g")(numeric / 1000) + "K";
125
+ return d3.format(",.4~g")(numeric);
126
+ };
127
+
128
+ const formatValueWithUnit = (value, unit, signed = false) => {
129
+ const numeric = Number(value);
130
+ const formatted = formatLocalValue(numeric);
131
+ const sign = signed && numeric > 0 ? "+" : "";
132
+ if (!unit) return `${sign}${formatted}`;
133
+ if (unit === "$") return `${sign}$${formatted}`;
134
+ if (unit === "%") return `${sign}${formatted}%`;
135
+ return `${sign}${formatted} ${unit}`;
136
+ };
137
+
138
+ const formatCompactValue = (value, unit, signed = false) => {
139
+ const numeric = Number(value);
140
+ const formatted = formatLocalValue(numeric);
141
+ const sign = signed && numeric > 0 ? "+" : "";
142
+ if (unit === "$") return `${sign}$${formatted}`;
143
+ if (unit === "%") return `${sign}${formatted}%`;
144
+ return `${sign}${formatted}`;
145
+ };
146
+
147
+ const legendLabel = (fieldName, unit) => {
148
+ const label = readableFieldName(fieldName);
149
+ if (!unit || unit === "%" || unit === "$") return label;
150
+ return `${label} (${unit})`;
151
+ };
152
+
153
+ const labelTokens = text => String(text ?? "")
154
+ .replace(/[()[\]{}]/g, " ")
155
+ .replace(/[,:;/\\|_-]+/g, " ")
156
+ .split(/\s+/)
157
+ .map(token => token.trim())
158
+ .filter(Boolean);
159
+
160
+ const temporalKey = value => {
161
+ if (value instanceof Date) return value.getTime();
162
+ if (typeof value === "number") return value;
163
+ const str = String(value ?? "").trim();
164
+ if (/^-?\d+(\.\d+)?$/.test(str)) return +str;
165
+ const parsed = Date.parse(str);
166
+ if (!Number.isNaN(parsed)) return parsed;
167
+ const yearMatch = str.match(/^-?\d{1,6}/);
168
+ return yearMatch ? +yearMatch[0] : null;
169
+ };
170
+
171
+ const compactLabelBase = (value, isTemporal) => {
172
+ const str = String(value ?? "").trim();
173
+ if (isTemporal) return str;
174
+ const parentheticalWithSuffix = str.match(/\(([^()]{2,})\)\s*[-–—]\s*(.{2,})$/);
175
+ if (parentheticalWithSuffix) return `${parentheticalWithSuffix[1].trim()} ${parentheticalWithSuffix[2].trim()}`;
176
+ const parenthetical = str.match(/\(([^()]{2,})\)\s*$/);
177
+ if (parenthetical) return parenthetical[1].trim();
178
+ const tokens = labelTokens(str);
179
+ if (tokens.length >= 4) return `${tokens[0]} ${tokens[tokens.length - 1]}`;
180
+ return str;
181
+ };
182
+
183
+ const suffixLabel = (value, tokenCount) => {
184
+ const tokens = labelTokens(value);
185
+ if (!tokens.length) return String(value ?? "");
186
+ return tokens.slice(Math.max(0, tokens.length - tokenCount)).join(" ");
187
+ };
188
+
189
+ const buildDisplayLabels = (values, isTemporal) => {
190
+ const labels = values.map(value => compactLabelBase(value, isTemporal));
191
+ const counts = new Map();
192
+ labels.forEach(label => counts.set(label.toLowerCase(), (counts.get(label.toLowerCase()) || 0) + 1));
193
+ return labels.map((label, index) => {
194
+ if ((counts.get(label.toLowerCase()) || 0) === 1) return label;
195
+ for (let tokenCount = 1; tokenCount <= 4; tokenCount++) {
196
+ const candidate = suffixLabel(values[index], tokenCount);
197
+ const duplicate = values.some((other, otherIndex) =>
198
+ otherIndex !== index && suffixLabel(other, tokenCount).toLowerCase() === candidate.toLowerCase()
199
+ );
200
+ if (!duplicate) return candidate;
201
+ }
202
+ return String(values[index]);
203
+ });
204
+ };
205
+
206
+ const splitLabelLines = (text, maxWidth, fontSize, maxLines = 2, weight = labelFontWeight) => {
207
+ const words = labelTokens(text);
208
+ const raw = String(text ?? "");
209
+ if (words.length <= 1) return [raw];
210
+ const lines = [];
211
+ let current = "";
212
+ for (const word of words) {
213
+ const next = current ? `${current} ${word}` : word;
214
+ if (measureLabelText(next, fontSize, weight, labelFontFamily) <= maxWidth || !current) {
215
+ current = next;
216
+ } else {
217
+ lines.push(current);
218
+ current = word;
219
+ }
220
+ if (lines.length === maxLines - 1) break;
221
+ }
222
+ if (current && lines.length < maxLines) lines.push(current);
223
+ return lines.slice(0, maxLines);
224
+ };
225
+
226
+ const normalizedData = chartData
227
+ .map((d, index) => ({
228
+ ...d,
229
+ __index: index,
230
+ __dimension: d[dimensionField],
231
+ __y: +d[valueField],
232
+ __y2: Math.max(0, +d[valueField2] || 0),
233
+ __temporalKey: temporalKey(d[dimensionField])
234
+ }))
235
+ .filter(d => d.__dimension !== undefined && Number.isFinite(d.__y) && Number.isFinite(d.__y2));
236
+
237
+ const validTemporalKeys = normalizedData.filter(d => d.__temporalKey !== null).length;
238
+ const isTemporal = dimensionColumn.data_type === "temporal" || (
239
+ normalizedData.length > 0 && validTemporalKeys === normalizedData.length
240
+ );
241
+ const sortedData = [...normalizedData].sort((a, b) => {
242
+ if (isTemporal) return d3.ascending(a.__temporalKey, b.__temporalKey) || d3.ascending(a.__index, b.__index);
243
+ return d3.descending(a.__y, b.__y) || d3.ascending(a.__index, b.__index);
244
+ });
245
+
246
+ if (!sortedData.length) return svg.node();
247
+
248
+ const displayLabels = buildDisplayLabels(sortedData.map(d => d.__dimension), isTemporal);
249
+ sortedData.forEach((d, index) => {
250
+ d.__displayDimension = displayLabels[index] || d.__dimension;
251
+ });
252
+
253
+ const count = sortedData.length;
254
+ const denseCategories = count > 12;
255
+ const crowdedCategories = count > 9;
256
+ const longYAxisUnit = Boolean(yUnit && yUnit !== "%" && yUnit !== "$" && yUnit.length > 3);
257
+ const margin = { top: 66, right: 36, bottom: 226, left: longYAxisUnit ? 108 : 72 };
258
+ const innerWidth = width - margin.left - margin.right;
259
+ const plotTop = margin.top + 54;
260
+ const plotBottom = height - margin.bottom;
261
+ const plotHeight = plotBottom - plotTop;
262
+ const labelY = plotBottom + 32;
263
+ const secondaryY = plotBottom + 86;
264
+ const takeawayY = height - 118;
265
+ const sourceY = height - 20;
266
+
267
+ const xScale = d3.scaleBand()
268
+ .domain(sortedData.map((d, i) => `${i}-${d.__dimension}`))
269
+ .range([margin.left, margin.left + innerWidth])
270
+ .padding(count > 12 ? 0.18 : count > 8 ? 0.22 : 0.28);
271
+
272
+ const minY = Math.min(0, d3.min(sortedData, d => d.__y) || 0);
273
+ const maxY = Math.max(0, d3.max(sortedData, d => d.__y) || 0);
274
+ const yExtent = maxY - minY || 1;
275
+ const yScale = d3.scaleLinear()
276
+ .domain([minY - yExtent * 0.06, maxY + yExtent * 0.08])
277
+ .range([plotBottom, plotTop])
278
+ .nice(5);
279
+ const zeroY = yScale(0);
280
+
281
+ const maxY2 = d3.max(sortedData, d => d.__y2) || 1;
282
+ const badgeW = denseCategories
283
+ ? Math.max(38, Math.min(76, xScale.bandwidth()))
284
+ : Math.max(42, Math.min(92, xScale.bandwidth() * 1.20));
285
+ const badgeH = denseCategories ? 44 : 46;
286
+ const maxMarkerSide = Math.max(8, Math.min(19, badgeH * 0.35, badgeW * 0.28));
287
+ const markerSideScale = d3.scaleSqrt()
288
+ .domain([0, maxY2])
289
+ .range([5, maxMarkerSide]);
290
+
291
+ const defs = svg.append("defs");
292
+ const barGradient = defs.append("linearGradient")
293
+ .attr("id", "vertical13BarGradient")
294
+ .attr("x1", "0%")
295
+ .attr("x2", "0%")
296
+ .attr("y1", "100%")
297
+ .attr("y2", "0%");
298
+ barGradient.append("stop").attr("offset", "0%").attr("stop-color", "#67C4F4");
299
+ barGradient.append("stop").attr("offset", "100%").attr("stop-color", "#1561C9");
300
+ const barShadow = defs.append("filter")
301
+ .attr("id", "vertical13BarShadow")
302
+ .attr("x", "-20%")
303
+ .attr("y", "-18%")
304
+ .attr("width", "140%")
305
+ .attr("height", "140%");
306
+ barShadow.append("feDropShadow")
307
+ .attr("dx", 0)
308
+ .attr("dy", 4)
309
+ .attr("stdDeviation", 3)
310
+ .attr("flood-color", "#1A5FA9")
311
+ .attr("flood-opacity", 0.18);
312
+ const iconGradient = defs.append("linearGradient")
313
+ .attr("id", "vertical13IconGradient")
314
+ .attr("x1", "0%")
315
+ .attr("x2", "100%")
316
+ .attr("y1", "100%")
317
+ .attr("y2", "0%");
318
+ iconGradient.append("stop").attr("offset", "0%").attr("stop-color", "#0C57BB");
319
+ iconGradient.append("stop").attr("offset", "100%").attr("stop-color", "#4FA7F4");
320
+
321
+ svg.append("rect")
322
+ .attr("class", "chart-canvas reference-chart-canvas")
323
+ .attr("x", 0)
324
+ .attr("y", 0)
325
+ .attr("width", width)
326
+ .attr("height", height)
327
+ .attr("fill", "transparent");
328
+
329
+ const legend = svg.append("g").attr("class", "reference-legend");
330
+ const legendItems = [
331
+ { label: legendLabel(valueField, yUnit), color: barColor, className: "reference-primary-legend-dot" },
332
+ { label: legendLabel(valueField2, y2Unit), color: secondaryColor, className: "reference-secondary-legend-dot" }
333
+ ];
334
+ const legendFont = Math.max(11, Math.min(13, baseAnnotationSize));
335
+ const legendWidths = legendItems.map(item => 20 + measureLabelText(item.label, legendFont, 500, annotationFontFamily));
336
+ const legendGap = denseCategories ? 42 : 52;
337
+ const totalLegendWidth = legendWidths.reduce((a, b) => a + b, 0) + legendGap;
338
+ let legendX = margin.left + (innerWidth - totalLegendWidth) / 2;
339
+ legendItems.forEach((item, index) => {
340
+ const x = legendX + legendWidths.slice(0, index).reduce((a, b) => a + b, 0) + legendGap * index;
341
+ const group = legend.append("g")
342
+ .attr("class", "reference-legend-item")
343
+ .attr("transform", `translate(${x}, ${margin.top})`);
344
+ group.append("circle")
345
+ .attr("class", item.className)
346
+ .attr("cx", 0)
347
+ .attr("cy", 0)
348
+ .attr("r", 8)
349
+ .attr("fill", item.color);
350
+ group.append("text")
351
+ .attr("x", 18)
352
+ .attr("y", 1)
353
+ .attr("dominant-baseline", "middle")
354
+ .style("font-family", annotationFontFamily)
355
+ .style("font-size", `${legendFont}px`)
356
+ .style("font-weight", 500)
357
+ .style("fill", muted)
358
+ .text(item.label);
359
+ });
360
+
361
+ const chartG = svg.append("g").attr("class", "reference-chart-body");
362
+ chartG.append("line")
363
+ .attr("class", "reference-y-axis")
364
+ .attr("x1", margin.left)
365
+ .attr("x2", margin.left)
366
+ .attr("y1", plotTop)
367
+ .attr("y2", plotBottom)
368
+ .attr("stroke", "#AEB8C4")
369
+ .attr("stroke-width", 1.2);
370
+ chartG.append("line")
371
+ .attr("class", "reference-x-axis")
372
+ .attr("x1", margin.left)
373
+ .attr("x2", margin.left + innerWidth)
374
+ .attr("y1", zeroY)
375
+ .attr("y2", zeroY)
376
+ .attr("stroke", "#7B8592")
377
+ .attr("stroke-width", 1.3);
378
+
379
+ const tickValues = yScale.ticks(5);
380
+ tickValues.forEach(tick => {
381
+ const y = yScale(tick);
382
+ chartG.append("line")
383
+ .attr("class", "reference-grid-line")
384
+ .attr("x1", margin.left)
385
+ .attr("x2", margin.left + innerWidth)
386
+ .attr("y1", y)
387
+ .attr("y2", y)
388
+ .attr("stroke", gridColor)
389
+ .attr("stroke-width", tick === 0 ? 0 : 1)
390
+ .attr("stroke-dasharray", "4 5")
391
+ .attr("opacity", tick === 0 ? 0 : 0.78);
392
+ chartG.append("text")
393
+ .attr("class", "axis-tick")
394
+ .attr("x", margin.left - 12)
395
+ .attr("y", y)
396
+ .attr("text-anchor", "end")
397
+ .attr("dominant-baseline", "middle")
398
+ .style("font-family", annotationFontFamily)
399
+ .style("font-size", `${longYAxisUnit ? Math.max(10, Math.min(12, baseAnnotationSize)) : Math.max(11, baseAnnotationSize)}px`)
400
+ .style("font-weight", 500)
401
+ .style("fill", muted)
402
+ .text(formatValueWithUnit(tick, yUnit));
403
+ });
404
+
405
+ chartG.append("text")
406
+ .attr("class", "reference-y-axis-title")
407
+ .attr("x", -(plotTop + plotHeight / 2))
408
+ .attr("y", longYAxisUnit ? 18 : 24)
409
+ .attr("transform", "rotate(-90)")
410
+ .attr("text-anchor", "middle")
411
+ .style("font-family", labelFontFamily)
412
+ .style("font-size", `${longYAxisUnit ? Math.max(12, baseLabelSize - 1) : Math.max(14, baseLabelSize)}px`)
413
+ .style("font-weight", 500)
414
+ .style("fill", muted)
415
+ .text(readableFieldName(valueField));
416
+
417
+ const labelMaxWidth = Math.max(28, xScale.bandwidth() * (denseCategories ? 0.86 : crowdedCategories ? 1.05 : 1.35));
418
+ const xLabelFont = denseCategories
419
+ ? Math.max(6.3, Math.min(8.4, xScale.bandwidth() / 5.8))
420
+ : crowdedCategories
421
+ ? Math.max(7.6, Math.min(11, xScale.bandwidth() / 5))
422
+ : Math.max(7.5, Math.min(14, xScale.bandwidth() / 3.8));
423
+ const valueFont = denseCategories
424
+ ? Math.max(8, Math.min(13, xScale.bandwidth() / 3.3))
425
+ : Math.max(8, Math.min(20, xScale.bandwidth() / 2.6));
426
+ const badgeFont = denseCategories
427
+ ? Math.max(6.2, Math.min(10, badgeW / 5.6))
428
+ : Math.max(7, Math.min(14, badgeW / 4.6));
429
+ const barWidth = Math.max(9, Math.min(78, xScale.bandwidth() * 0.72));
430
+ const compactPrimaryLabels = denseCategories || longYAxisUnit;
431
+ const compactSecondaryLabels = denseCategories || Boolean(y2Unit && y2Unit !== "%" && y2Unit !== "$");
432
+
433
+ sortedData.forEach((d, index) => {
434
+ const key = `${index}-${d.__dimension}`;
435
+ const centerX = xScale(key) + xScale.bandwidth() / 2;
436
+ const yValue = d.__y;
437
+ const yBar = yScale(yValue);
438
+ const barY = Math.min(yBar, zeroY);
439
+ const barH = Math.max(0, Math.abs(zeroY - yBar));
440
+ const row = chartG.append("g")
441
+ .attr("class", "reference-data-item")
442
+ .attr("data-source-index", d.__index)
443
+ .attr("data-category", d.__dimension)
444
+ .attr("data-y", d.__y)
445
+ .attr("data-y2", d.__y2);
446
+
447
+ row.append("rect")
448
+ .attr("class", "bar mark reference-primary-bar")
449
+ .attr("data-source-index", d.__index)
450
+ .attr("data-category", d.__dimension)
451
+ .attr("data-value", d.__y)
452
+ .attr("x", centerX - barWidth / 2)
453
+ .attr("y", barY)
454
+ .attr("width", barWidth)
455
+ .attr("height", barH)
456
+ .attr("rx", variables.radius_corner === false ? 0 : Math.min(13, barWidth / 2))
457
+ .attr("ry", variables.radius_corner === false ? 0 : Math.min(13, barWidth / 2))
458
+ .attr("fill", yValue >= 0 ? "url(#vertical13BarGradient)" : secondaryColor)
459
+ .style("filter", "url(#vertical13BarShadow)");
460
+
461
+ const barLabel = compactPrimaryLabels
462
+ ? formatCompactValue(yValue, yUnit, true)
463
+ : formatValueWithUnit(yValue, yUnit, true);
464
+ const labelInside = barH > valueFont * 2.2
465
+ && measureLabelText(barLabel, valueFont, 800, annotationFontFamily) <= Math.max(barWidth * 1.35, xScale.bandwidth() * 0.78);
466
+ row.append("text")
467
+ .attr("class", "bar-value value reference-primary-value")
468
+ .attr("data-source-index", d.__index)
469
+ .attr("data-category", d.__dimension)
470
+ .attr("data-role", "primary")
471
+ .attr("data-value", d.__y)
472
+ .attr("x", centerX)
473
+ .attr("y", labelInside ? (yValue >= 0 ? barY + valueFont * 1.22 : barY + barH - valueFont * 0.45) : (yValue >= 0 ? barY - 6 : barY + barH + valueFont + 4))
474
+ .attr("text-anchor", "middle")
475
+ .style("font-family", annotationFontFamily)
476
+ .style("font-size", `${valueFont}px`)
477
+ .style("font-weight", 800)
478
+ .style("fill", labelInside ? "#FFFFFF" : navy)
479
+ .text(barLabel);
480
+
481
+ const labelText = row.append("text")
482
+ .attr("class", "label reference-category-label")
483
+ .attr("data-source-index", d.__index)
484
+ .attr("data-category", d.__dimension)
485
+ .attr("x", centerX)
486
+ .attr("y", labelY)
487
+ .attr("text-anchor", "middle")
488
+ .style("font-family", labelFontFamily)
489
+ .style("font-size", `${xLabelFont}px`)
490
+ .style("font-weight", 600)
491
+ .style("fill", navy);
492
+ splitLabelLines(d.__displayDimension, labelMaxWidth, xLabelFont, 2, 600).forEach((line, lineIndex) => {
493
+ labelText.append("tspan")
494
+ .attr("x", centerX)
495
+ .attr("dy", lineIndex === 0 ? 0 : `${xLabelFont * 1.12}px`)
496
+ .text(line);
497
+ });
498
+
499
+ const markerSide = markerSideScale(d.__y2);
500
+ const badgeX = centerX - badgeW / 2;
501
+ const badgeY = secondaryY - badgeH / 2;
502
+ row.append("rect")
503
+ .attr("class", "secondary-badge reference-secondary-badge")
504
+ .attr("x", badgeX)
505
+ .attr("y", badgeY)
506
+ .attr("width", badgeW)
507
+ .attr("height", badgeH)
508
+ .attr("rx", 9)
509
+ .attr("ry", 9)
510
+ .attr("fill", secondaryColor)
511
+ .attr("fill-opacity", 0.95);
512
+
513
+ const iconRadius = denseCategories ? 6 : 7;
514
+ const iconCx = badgeX + Math.max(denseCategories ? 10 : 12, badgeW * (denseCategories ? 0.27 : 0.25));
515
+ const iconCy = secondaryY;
516
+ row.append("path")
517
+ .attr("class", "reference-secondary-icon")
518
+ .attr("d", `M${iconCx - iconRadius},${iconCy + 4} A${iconRadius + 1},${iconRadius + 1} 0 0 1 ${iconCx + iconRadius},${iconCy + 4}`)
519
+ .attr("fill", "none")
520
+ .attr("stroke", "#FFFFFF")
521
+ .attr("stroke-width", 2)
522
+ .attr("stroke-linecap", "round");
523
+ row.append("line")
524
+ .attr("class", "reference-secondary-icon")
525
+ .attr("x1", iconCx)
526
+ .attr("x2", iconCx + (denseCategories ? 4 : 5))
527
+ .attr("y1", iconCy + 3)
528
+ .attr("y2", iconCy - 4)
529
+ .attr("stroke", "#FFFFFF")
530
+ .attr("stroke-width", 2)
531
+ .attr("stroke-linecap", "round");
532
+ row.append("rect")
533
+ .attr("class", "secondary-mark")
534
+ .attr("data-source-index", d.__index)
535
+ .attr("data-category", d.__dimension)
536
+ .attr("data-value", d.__y2)
537
+ .attr("data-side", markerSide)
538
+ .attr("x", iconCx - markerSide / 2)
539
+ .attr("y", iconCy + 8 - markerSide / 2)
540
+ .attr("width", markerSide)
541
+ .attr("height", markerSide)
542
+ .attr("rx", 2)
543
+ .attr("ry", 2)
544
+ .attr("fill", softRed)
545
+ .attr("fill-opacity", 0.74);
546
+
547
+ row.append("text")
548
+ .attr("class", "secondary-value value reference-secondary-value")
549
+ .attr("data-source-index", d.__index)
550
+ .attr("data-category", d.__dimension)
551
+ .attr("data-role", "secondary")
552
+ .attr("data-value", d.__y2)
553
+ .attr("x", badgeX + badgeW * (denseCategories ? 0.67 : 0.62))
554
+ .attr("y", secondaryY + 1)
555
+ .attr("text-anchor", "middle")
556
+ .attr("dominant-baseline", "middle")
557
+ .style("font-family", annotationFontFamily)
558
+ .style("font-size", `${badgeFont}px`)
559
+ .style("font-weight", 800)
560
+ .style("fill", "#FFFFFF")
561
+ .text(compactSecondaryLabels
562
+ ? formatCompactValue(d.__y2, y2Unit)
563
+ : formatValueWithUnit(d.__y2, y2Unit));
564
+ });
565
+
566
+ const takeaway = svg.append("g")
567
+ .attr("class", "reference-takeaway-card")
568
+ .attr("transform", `translate(42, ${takeawayY})`);
569
+ const takeawayW = width - 84;
570
+ const takeawayH = 88;
571
+ takeaway.append("rect")
572
+ .attr("x", 0)
573
+ .attr("y", 0)
574
+ .attr("width", takeawayW)
575
+ .attr("height", takeawayH)
576
+ .attr("rx", 13)
577
+ .attr("ry", 13)
578
+ .attr("fill", "#F4FAFF")
579
+ .attr("stroke", "#C9DEEF")
580
+ .attr("stroke-width", 1.1);
581
+
582
+ const icon = takeaway.append("g")
583
+ .attr("class", "reference-footer-icon-slot")
584
+ .attr("transform", "translate(76, 44)");
585
+ icon.append("circle")
586
+ .attr("r", 37)
587
+ .attr("fill", "url(#vertical13IconGradient)")
588
+ .attr("stroke", "#0A57B7")
589
+ .attr("stroke-width", 1.1);
590
+ icon.append("path")
591
+ .attr("d", "M-22,14 L-8,0 L5,10 L24,-13")
592
+ .attr("fill", "none")
593
+ .attr("stroke", "#FFFFFF")
594
+ .attr("stroke-width", 5)
595
+ .attr("stroke-linecap", "round")
596
+ .attr("stroke-linejoin", "round");
597
+ icon.append("path")
598
+ .attr("d", "M12,-14 L25,-14 L25,-1")
599
+ .attr("fill", "none")
600
+ .attr("stroke", "#FFFFFF")
601
+ .attr("stroke-width", 5)
602
+ .attr("stroke-linecap", "round")
603
+ .attr("stroke-linejoin", "round");
604
+
605
+ const takeawayTitle = "KEY TAKEAWAY";
606
+ let insight = jsonData.metadata?.main_insight
607
+ || jsonData.titles?.sub_title
608
+ || `${readableFieldName(valueField)} and ${readableFieldName(valueField2)} are shown together.`;
609
+ const insightMax = takeawayW - 170;
610
+ const insightFont = 15;
611
+ if (measureLabelText(insight, insightFont, 500, annotationFontFamily) > insightMax * 1.8) {
612
+ insight = `${readableFieldName(valueField)} bars and ${readableFieldName(valueField2)} badges use source data.`;
613
+ }
614
+ const insightLines = splitLabelLines(insight, insightMax, insightFont, 2, 500);
615
+ takeaway.append("text")
616
+ .attr("class", "reference-takeaway-title")
617
+ .attr("x", 150)
618
+ .attr("y", 31)
619
+ .style("font-family", labelFontFamily)
620
+ .style("font-size", "18px")
621
+ .style("font-weight", 800)
622
+ .style("fill", "#0A4EA4")
623
+ .text(takeawayTitle);
624
+ const insightText = takeaway.append("text")
625
+ .attr("class", "reference-takeaway-text")
626
+ .attr("x", 150)
627
+ .attr("y", 56)
628
+ .style("font-family", annotationFontFamily)
629
+ .style("font-size", `${insightFont}px`)
630
+ .style("font-weight", 500)
631
+ .style("fill", navy);
632
+ insightLines.forEach((line, lineIndex) => {
633
+ insightText.append("tspan")
634
+ .attr("x", 150)
635
+ .attr("dy", lineIndex === 0 ? 0 : `${insightFont * 1.25}px`)
636
+ .text(line);
637
+ });
638
+
639
+ svg.append("text")
640
+ .attr("class", "reference-source-note")
641
+ .attr("x", width / 2)
642
+ .attr("y", sourceY)
643
+ .attr("text-anchor", "middle")
644
+ .style("font-family", annotationFontFamily)
645
+ .style("font-size", "11px")
646
+ .style("font-style", "italic")
647
+ .style("font-weight", 400)
648
+ .style("fill", "#68717E")
649
+ .text(`Source: ${readableFieldName(valueField)} and ${readableFieldName(valueField2)} source data`);
650
+
651
+ return svg.node();
652
+ }
modules/chart_engine/template/d3-js/bar_chart/vertical_group_bar_chart_11.js ADDED
@@ -0,0 +1,597 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Vertical Group Bar Chart",
5
+ "chart_name": "vertical_group_bar_chart_11",
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": [],
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
+ // *** BEGIN ADDITION: Define vertical gradients for groups ***
150
+ const leftGroupColor = colorResolver.field(leftBarGroup, 0, { fallback: "#4269d0" }).value; // Default left color
151
+ const rightGroupColor = colorResolver.field(rightBarGroup, 0, { fallback: "#ff725c" }).value; // Default right color
152
+
153
+ // Gradient for the left group
154
+ const gradientLeft = defs.append("linearGradient")
155
+ .attr("id", "gradient-left")
156
+ .attr("x1", "0%")
157
+ .attr("y1", "0%") // Top
158
+ .attr("x2", "0%")
159
+ .attr("y2", "100%"); // Bottom
160
+
161
+ gradientLeft.append("stop")
162
+ .attr("offset", "0%")
163
+ .attr("stop-color", colorResolver.variant(leftGroupColor, { mode: "darker", amount: 0.3 }));
164
+
165
+ gradientLeft.append("stop")
166
+ .attr("offset", "50%")
167
+ .attr("stop-color", colorResolver.variant(leftGroupColor, { mode: "brighter", amount: 2.5 }));
168
+
169
+ gradientLeft.append("stop")
170
+ .attr("offset", "100%")
171
+ .attr("stop-color", colorResolver.variant(leftGroupColor, { mode: "darker", amount: 0.3 }));
172
+
173
+ // Gradient for the right group
174
+ const gradientRight = defs.append("linearGradient")
175
+ .attr("id", "gradient-right")
176
+ .attr("x1", "0%")
177
+ .attr("y1", "0%") // Top
178
+ .attr("x2", "0%")
179
+ .attr("y2", "100%"); // Bottom
180
+
181
+ gradientRight.append("stop")
182
+ .attr("offset", "0%")
183
+ .attr("stop-color", colorResolver.variant(rightGroupColor, { mode: "darker", amount: 0.3 }));
184
+
185
+ gradientRight.append("stop")
186
+ .attr("offset", "50%")
187
+ .attr("stop-color", colorResolver.variant(rightGroupColor, { mode: "brighter", amount: 2.5 }));
188
+
189
+ gradientRight.append("stop")
190
+ .attr("offset", "100%")
191
+ .attr("stop-color", colorResolver.variant(rightGroupColor, { mode: "darker", amount: 0.3 }));
192
+ // *** END ADDITION ***
193
+
194
+ // ---------- 7. 创建图表区域 ----------
195
+ const chart = svg.append("g")
196
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
197
+
198
+ // ---------- 8. 创建比例尺 ----------
199
+ // X比例尺(分类)用于主分类
200
+ const xScale = d3.scaleBand()
201
+ .domain(xValues)
202
+ .range([0, innerWidth])
203
+ .padding(0.2);
204
+
205
+ // 分组比例尺,用于每个类别内的细分
206
+ const groupScale = d3.scaleBand()
207
+ .domain([0, 1]) // 只有两个柱子,左侧和右侧
208
+ .range([0, xScale.bandwidth()])
209
+ .padding(0.2); // 同一维度柱子之间的间隙,增加间隔
210
+
211
+ // Y比例尺(数值)- 直接使用最大值映射到可用高度
212
+ const dataMax = d3.max(useData, d => +d[yField]) || 100;
213
+ const yScale = d3.scaleLinear()
214
+ .domain([0, dataMax])
215
+ .range([innerHeight, 0]);
216
+
217
+ // ---------- 9. 测量文本并计算字体大小 ----------
218
+
219
+ // 添加数值格式化函数
220
+ // 数值标签的可用宽度 = bar宽度 + 间距
221
+ const barWidth = groupScale.bandwidth();
222
+ const valueSpacing = xScale.bandwidth() * 0.1;
223
+ const valueLabelAvailableWidth = barWidth; // 每个值标签只能占据其柱子的宽度
224
+
225
+ // 维度标签的可用宽度 = 两个bar宽度 + bar间距
226
+ const dimensionLabelAvailableWidth = xScale.bandwidth();
227
+
228
+ // 计算最长的数值标签和维度标签
229
+ let maxValueLabelWidth = 0;
230
+ let maxDimensionLabelWidth = 0;
231
+ // 计算所有数值标签的最大宽度
232
+ useData.forEach(d => {
233
+ const textWidth = chartUtils.text.measure(svg, chartUtils.format.scaledText(+d[yField], { unit: yUnit }), {
234
+ fontFamily: typography.label.font_family,
235
+ fontSize: typography.label.font_size,
236
+ fontWeight: typography.label.font_weight
237
+ }).width;
238
+ maxValueLabelWidth = Math.max(maxValueLabelWidth, textWidth);
239
+ });
240
+
241
+ // 计算所有维度标签的最大宽度
242
+ xValues.forEach(xValue => {
243
+ const textWidth = chartUtils.text.measure(svg, xValue, {
244
+ fontFamily: typography.label.font_family,
245
+ fontSize: typography.label.font_size,
246
+ fontWeight: typography.label.font_weight
247
+ }).width;
248
+ maxDimensionLabelWidth = Math.max(maxDimensionLabelWidth, textWidth);
249
+ });
250
+
251
+ // 计算需要的字体缩放比例
252
+ const valueFontScale = Math.min(1, valueLabelAvailableWidth / maxValueLabelWidth);
253
+ const dimensionFontScale = Math.min(1, dimensionLabelAvailableWidth / maxDimensionLabelWidth);
254
+
255
+ // 计算实际使用的字体大小
256
+ const valueFontSize = Math.max(8, parseInt(typography.label.font_size) * valueFontScale); // 最小字体8px
257
+ const dimensionFontSize = Math.max(8, parseInt(typography.label.font_size) * dimensionFontScale);
258
+
259
+ // 计算动态文本大小的函数
260
+ const calculateFontSize = (text, maxWidth, baseSize = 12) => {
261
+ // 估算每个字符的平均��度 (假设为baseSize的60%)
262
+ const avgCharWidth = baseSize * 0.6;
263
+ // 计算文本的估计宽度
264
+ const textWidth = text.length * avgCharWidth;
265
+ // 如果文本宽度小于最大宽度,返回基础大小
266
+ if (textWidth < maxWidth) {
267
+ return baseSize;
268
+ }
269
+ // 否则,按比例缩小字体大小
270
+ return Math.max(10, Math.floor(baseSize * (maxWidth / textWidth)));
271
+ };
272
+
273
+ // ---------- 10. 创建坐标轴 (原来的第9步) ----------
274
+ // 创建x轴组,用于添加刻度标签
275
+ const xAxisGroup = chart.append("g")
276
+ .attr("class", "x-axis")
277
+ .attr("transform", `translate(0, ${innerHeight})`);
278
+
279
+ // 第一步:找出最长的标签并计算统一的字体大小
280
+ let maxLabelLength = 0;
281
+ const allLabels = xValues.map(d => d.toString());
282
+
283
+ // 找出最长的标签
284
+ const longestLabel = allLabels.reduce((a, b) => a.length > b.length ? a : b, "");
285
+
286
+ // 使用最长标签计算合适的统一字体大小
287
+ const labelMaxWidth = xScale.bandwidth()*1.3;
288
+ const uniformFontSize = calculateFontSize(longestLabel, labelMaxWidth, parseInt(typography.label.font_size));
289
+
290
+ // 绘制x轴标签
291
+ xAxisGroup.selectAll(".x-label")
292
+ .data(xValues)
293
+ .enter()
294
+ .append("text")
295
+ .attr("class", "x-label")
296
+ .attr("x", d => xScale(d) + xScale.bandwidth() / 2)
297
+ .attr("y", 20)
298
+ .attr("text-anchor", "middle")
299
+ .style("font-family", typography.label.font_family)
300
+ .style("font-size", `${uniformFontSize}px`) // 应用统一的字体大小
301
+ .style("font-weight", typography.label.font_weight)
302
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
303
+ .text(d => d)
304
+ .each(function(d) {
305
+ const text = d3.select(this);
306
+ const labelText = d.toString();
307
+ const labelWidth = chartUtils.text.measure(null, labelText, {
308
+ fontFamily: typography.label.font_family,
309
+ fontSize: uniformFontSize,
310
+ fontWeight: typography.label.font_weight
311
+ }).width;
312
+
313
+ // 检查使用统一字体大小后,文本是否仍然超过可用宽度
314
+ if (labelWidth > labelMaxWidth) {
315
+ // 如果仍然太长,应用文本换行
316
+ wrapText(text, labelText, labelMaxWidth, 1.1);
317
+ }
318
+ });
319
+
320
+ // 文本换行助手函数
321
+ function wrapText(text, str, width, lineHeight) {
322
+ const words = str.split(/\s+/).reverse();
323
+ let word;
324
+ let line = [];
325
+ let lineNumber = 0;
326
+ const y = text.attr("y");
327
+ const dy = parseFloat(text.attr("dy") || 0);
328
+
329
+ // 先清空文本
330
+ text.text(null);
331
+
332
+ // 处理文本
333
+ let tspans = [];
334
+
335
+ // 如果没有空格可分割,按字符分割
336
+ if (words.length <= 1) {
337
+ const chars = str.split('');
338
+ let currentLine = '';
339
+
340
+ for (let i = 0; i < chars.length; i++) {
341
+ currentLine += chars[i];
342
+
343
+ // 测量候选文本宽度
344
+ const isOverflow = chartUtils.text.measure(null, currentLine, {
345
+ fontFamily: text.style("font-family"),
346
+ fontSize: parseFloat(text.style("font-size")),
347
+ fontWeight: text.style("font-weight")
348
+ }).width > width;
349
+
350
+ if (isOverflow && currentLine.length > 1) {
351
+ // 当前行过长,回退一个字符并换行
352
+ currentLine = currentLine.slice(0, -1);
353
+
354
+ // 添加到tspans数组
355
+ tspans.push(currentLine);
356
+
357
+ // 重新开始下一行
358
+ currentLine = chars[i];
359
+ lineNumber++;
360
+ }
361
+ }
362
+
363
+ // 添加最后一行
364
+ if (currentLine.length > 0) {
365
+ tspans.push(currentLine);
366
+ }
367
+ } else {
368
+ // 处理有空格的文本
369
+ let currentLine = [];
370
+
371
+ while (word = words.pop()) {
372
+ currentLine.push(word);
373
+
374
+ // 测量候选文本宽度
375
+ const isOverflow = chartUtils.text.measure(null, currentLine.join(" "), {
376
+ fontFamily: text.style("font-family"),
377
+ fontSize: parseFloat(text.style("font-size")),
378
+ fontWeight: text.style("font-weight")
379
+ }).width > width;
380
+
381
+ if (isOverflow && currentLine.length > 1) {
382
+ // 回退一个词
383
+ currentLine.pop();
384
+
385
+ // 添加到tspans数组
386
+ tspans.push(currentLine.join(" "));
387
+
388
+ // 重新开始下一行
389
+ currentLine = [word];
390
+ lineNumber++;
391
+ }
392
+ }
393
+
394
+ // 添加最后一行
395
+ if (currentLine.length > 0) {
396
+ tspans.push(currentLine.join(" "));
397
+ }
398
+ }
399
+
400
+ // 计算总行数
401
+ const totalLines = tspans.length;
402
+
403
+ // 计算垂直居中的起始位置
404
+ // 对于单行文本,y位置保持不变
405
+ // 对于多行文本,需要向上偏移以保持垂直居中
406
+ let startY = y;
407
+ if (totalLines > 1) {
408
+ // 向上偏移半行距离 * (总行数-1)
409
+ startY = parseFloat(y) - (lineHeight * (totalLines - 1) / 2);
410
+ }
411
+
412
+ // 创建所有行的tspan元素
413
+ tspans.forEach((lineText, i) => {
414
+ text.append("tspan")
415
+ .attr("x", text.attr("x"))
416
+ .attr("y", startY)
417
+ .attr("dy", `${i * lineHeight}em`)
418
+ .text(lineText);
419
+ });
420
+ }
421
+
422
+ // ---------- 11. 绘制图例 (原来的第10步) ----------
423
+ // 创建临时文本元素计算文本宽度
424
+ const legendItems = legendData.map(item => ({
425
+ label: item.key,
426
+ color: item.color,
427
+ }));
428
+ chartUtils.legend.centered(svg, legendItems, {}, width / 2, 30, {
429
+ markerShape: "rect",
430
+ markerSize: 15,
431
+ labelGap: 5,
432
+ itemGap: legendSpacing,
433
+ fontSize: 12,
434
+ fontFamily: typography.label.font_family,
435
+ fontWeight: typography.label.font_weight,
436
+ textColor: colorResolver.text({ fallback: "#333333" }).value,
437
+ markerRadius: variables.has_rounded_corners ? 2 : 0,
438
+ });
439
+
440
+ // 绘制条形图和标签
441
+ xValues.forEach(xValue => {
442
+ // 获取当前x类别的数据
443
+ const xData = useData.filter(d => d[xField] === xValue);
444
+
445
+ // 获取左侧柱子的数据
446
+ const leftBarData = xData.find(d => d[groupField] === leftBarGroup);
447
+
448
+ // 获取右侧柱子的数据
449
+ const rightBarData = xData.find(d => d[groupField] === rightBarGroup);
450
+
451
+ // 计算左侧柱子的位置和高度
452
+ const leftBarX = xScale(xValue);
453
+ let leftBarY, leftBarHeight, leftValue;
454
+
455
+ if (leftBarData) {
456
+ leftValue = leftBarData[yField];
457
+ leftBarHeight = innerHeight - yScale(leftValue);
458
+ leftBarY = yScale(leftValue);
459
+
460
+ // 绘制左侧柱子
461
+ chart.append("rect")
462
+ .attr("class", "bar left-bar")
463
+ .attr("x", leftBarX)
464
+ .attr("y", leftBarY)
465
+ .attr("width", barWidth)
466
+ .attr("height", leftBarHeight)
467
+ .attr("fill", "url(#gradient-left)")
468
+ .attr("rx", barWidth/2)
469
+ .attr("ry", barWidth/2)
470
+ .attr("stroke", variables.has_stroke ? "#555" : "none")
471
+ .attr("stroke-width", variables.has_stroke ? 1 : 0)
472
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "none");
473
+
474
+
475
+
476
+ // 绘制左侧柱子顶部的标签
477
+ // 首先计算标签文本宽度并调整字体大小
478
+ const leftValueText = chartUtils.format.scaledText(leftValue, { unit: yUnit });
479
+ let leftLabelFontSize = valueFontSize; // 默认使用之前计算的字体大小
480
+
481
+ let leftTextWidth = chartUtils.text.measure(chart, leftValueText, {
482
+ fontFamily: typography.label.font_family,
483
+ fontSize: `${leftLabelFontSize}px`,
484
+ fontWeight: "bold"
485
+ }).width;
486
+ // 最大允许宽度为柱子宽度的1.1倍
487
+ const maxLabelWidth = barWidth * 1.1;
488
+
489
+ // 如果文本宽度超过允许值,动态缩小字体
490
+ if (leftTextWidth > maxLabelWidth) {
491
+ // 按比例计算新字体大小
492
+ leftLabelFontSize = Math.max(4, leftLabelFontSize * (maxLabelWidth / leftTextWidth));
493
+ leftTextWidth = chartUtils.text.measure(chart, leftValueText, {
494
+ fontFamily: typography.label.font_family,
495
+ fontSize: `${leftLabelFontSize}px`,
496
+ fontWeight: "bold"
497
+ }).width;
498
+ }
499
+
500
+ // 使用调整后的字体大小绘制标签
501
+ chart.append("text")
502
+ .attr("class", "bar-label")
503
+ .attr("x", leftBarX + barWidth / 2)
504
+ .attr("y", leftBarY - 5)
505
+ .attr("text-anchor", "middle")
506
+ .style("font-family", typography.label.font_family)
507
+ .style("font-size", `${leftLabelFontSize}px`) // 使用动态调整的字体大小
508
+ .style("font-weight", "bold")
509
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
510
+ .text(leftValueText);
511
+ }
512
+
513
+ // 计算右侧柱子的位置和高度
514
+ // 使用groupScale正确计算右侧柱子的位置,确保两个柱子之间有间距
515
+ const rightBarX = leftBarX + barWidth + xScale.bandwidth() * 0.1; // 添加额外间距为柱子宽度的10%
516
+ let rightBarY, rightBarHeight, rightValue;
517
+
518
+ if (rightBarData) {
519
+ rightValue = rightBarData[yField];
520
+ rightBarHeight = innerHeight - yScale(rightValue);
521
+ rightBarY = yScale(rightValue);
522
+
523
+ // 绘制右侧柱子
524
+ chart.append("rect")
525
+ .attr("class", "bar right-bar")
526
+ .attr("x", rightBarX)
527
+ .attr("y", rightBarY)
528
+ .attr("width", barWidth)
529
+ .attr("height", rightBarHeight)
530
+ .attr("fill", "url(#gradient-right)")
531
+ .attr("rx", variables.has_rounded_corners ? 4 : 0)
532
+ .attr("ry", variables.has_rounded_corners ? 4 : 0)
533
+ .attr("stroke", variables.has_stroke ? "#555" : "none")
534
+ .attr("stroke-width", variables.has_stroke ? 1 : 0)
535
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "none");
536
+
537
+ // 绘制右侧柱子顶部的标签
538
+ // 首先计算标签文本宽度并调整字体大小
539
+ const rightValueText = chartUtils.format.scaledText(rightValue, { unit: yUnit });
540
+ let rightLabelFontSize = valueFontSize; // 默认使用之前计算的字体大小
541
+
542
+ let rightTextWidth = chartUtils.text.measure(chart, rightValueText, {
543
+ fontFamily: typography.label.font_family,
544
+ fontSize: `${rightLabelFontSize}px`,
545
+ fontWeight: "bold"
546
+ }).width;
547
+ // 最大允许宽度为柱子宽度的1.1倍
548
+ const rightMaxLabelWidth = barWidth * 1.1;
549
+
550
+ // 如果文本宽度超过允许值,动态缩小字体
551
+ if (rightTextWidth > rightMaxLabelWidth) {
552
+ // 按比例计算新字体大小
553
+ rightLabelFontSize = Math.max(4, rightLabelFontSize * (rightMaxLabelWidth / rightTextWidth));
554
+ rightTextWidth = chartUtils.text.measure(chart, rightValueText, {
555
+ fontFamily: typography.label.font_family,
556
+ fontSize: `${rightLabelFontSize}px`,
557
+ fontWeight: "bold"
558
+ }).width;
559
+ }
560
+
561
+ // 使用调整后的字体大小绘制标签
562
+ chart.append("text")
563
+ .attr("class", "bar-label")
564
+ .attr("x", rightBarX + barWidth / 2)
565
+ .attr("y", rightBarY - 5)
566
+ .attr("text-anchor", "middle")
567
+ .style("font-family", typography.label.font_family)
568
+ .style("font-size", `${rightLabelFontSize}px`) // 使用动态调整的字体大小
569
+ .style("font-weight", "bold")
570
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
571
+ .text(rightValueText);
572
+ }
573
+ });
574
+
575
+ // *** BEGIN ADDITION: Draw icons below X-axis labels ***
576
+ const iconSize = 30; // Define icon size
577
+ const iconY = innerHeight + 45; // Y position below labels (innerHeight + label space + padding + half icon size)
578
+
579
+ xValues.forEach(xValue => {
580
+ if (images.field && images.field[xValue]) {
581
+ const iconX = xScale(xValue) + xScale.bandwidth() / 2; // Center X under the label
582
+
583
+ chart.append("image")
584
+ .attr("class", "category-icon-below") // New class name
585
+ .attr("x", iconX - iconSize / 2)
586
+ .attr("y", iconY - iconSize / 2)
587
+ .attr("width", iconSize)
588
+ .attr("height", iconSize)
589
+ .attr("preserveAspectRatio", "xMidYMid meet")
590
+ .attr("xlink:href", images.field[xValue]);
591
+ }
592
+ });
593
+ // *** END ADDITION ***
594
+
595
+ // 返回SVG节点
596
+ return svg.node();
597
+ }
modules/chart_engine/template/d3-js/bar_chart/vertical_group_bar_chart_12.js ADDED
@@ -0,0 +1,857 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Vertical Group Bar Chart",
5
+ "chart_name": "vertical_group_bar_chart_12",
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
+ const textColor = colorResolver.text({ fallback: colors.text_color || "#33413a" }).value;
61
+ const backgroundColor = colors.background_color || "#fbf6ea";
62
+ const titleGreen = colors.other?.secondary || "#0f4a2b";
63
+ const defaultColors = ["#b94728", "#d9a722"];
64
+
65
+ const groupColorFor = (group, index) => {
66
+ const direct = colors.field && colors.field[group];
67
+ if (direct) return direct;
68
+ const available = colors.available_colors || [];
69
+ return available[index % available.length] || defaultColors[index % defaultColors.length];
70
+ };
71
+
72
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
73
+ const d3Color = (value, fallback) => d3.color(value) || d3.color(fallback);
74
+ const relativeLuminance = (value, fallback = "#ffffff") => {
75
+ const c = d3Color(value, fallback);
76
+ if (!c) return 1;
77
+ const channel = (v) => {
78
+ const s = v / 255;
79
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
80
+ };
81
+ return 0.2126 * channel(c.r) + 0.7152 * channel(c.g) + 0.0722 * channel(c.b);
82
+ };
83
+ const lighten = (value, amount, fallback) => {
84
+ const c = d3Color(value, fallback);
85
+ return c ? c.brighter(amount).formatHex() : fallback;
86
+ };
87
+ const darken = (value, amount, fallback) => {
88
+ const c = d3Color(value, fallback);
89
+ return c ? c.darker(amount).formatHex() : fallback;
90
+ };
91
+ const slug = (value) => String(value).replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-|-$/g, "") || "group";
92
+
93
+ // 清除容器
94
+ d3.select(containerSelector).html("");
95
+
96
+ // ---------- 2. 尺寸和布局设置 ----------
97
+ // 设置图表尺寸和边距
98
+ const width = variables.width || 800;
99
+ const height = variables.height || 500;
100
+
101
+ // 边距:上,右,下,左
102
+ const margin = {
103
+ top: 112,
104
+ right: 18,
105
+ bottom: 88,
106
+ left: 62
107
+ };
108
+
109
+ // 计算实际绘图区域大小
110
+ const innerWidth = width - margin.left - margin.right;
111
+ const innerHeight = height - margin.top - margin.bottom;
112
+
113
+ // ---------- 3. 提取字段名称和单位 ----------
114
+ let xField, yField, groupField;
115
+ let xUnit = "", yUnit = "";
116
+
117
+ // 安全提取字段名称
118
+ const xChannel = chartUtils.schema.channel(jsonData, "x");
119
+ const yChannel = chartUtils.schema.channel(jsonData, "y");
120
+ const groupChannel = chartUtils.schema.channel(jsonData, "group");
121
+
122
+ xField = xChannel.key;
123
+ yField = yChannel.key;
124
+ groupField = groupChannel.key;
125
+
126
+ // 获取字段单位
127
+ xUnit = xChannel.unit;
128
+ yUnit = yChannel.unit;
129
+
130
+ // ---------- 4. 数据处理 ----------
131
+ // 使用提供的数据
132
+ let useData = chartData;
133
+
134
+ // 获取x轴和分组的唯一值;time-like fields render chronologically by default.
135
+ const rawXValues = [...new Set(useData.map(d => d[xField]))];
136
+ const timeLikeName = /(year|date|month|time|season|semester|quarter)/i.test(xField || "");
137
+ const monthIndex = {
138
+ jan: 0, january: 0, feb: 1, february: 1, mar: 2, march: 2, apr: 3, april: 3,
139
+ may: 4, jun: 5, june: 5, jul: 6, july: 6, aug: 7, august: 7, sep: 8, sept: 8,
140
+ september: 8, oct: 9, october: 9, nov: 10, november: 10, dec: 11, december: 11
141
+ };
142
+ const timeKey = (value) => {
143
+ const s = String(value).trim();
144
+ if (/^\d{4}$/.test(s)) return +s;
145
+ const lower = s.toLowerCase();
146
+ if (monthIndex[lower] !== undefined) return monthIndex[lower];
147
+ const parsed = Date.parse(s);
148
+ return Number.isNaN(parsed) ? null : parsed;
149
+ };
150
+ const xValues = timeLikeName && rawXValues.every(d => timeKey(d) !== null)
151
+ ? rawXValues.slice().sort((a, b) => timeKey(a) - timeKey(b))
152
+ : rawXValues;
153
+ let groupValues = [...new Set(useData.map(d => d[groupField]))];
154
+
155
+ // 如果组的数量不符合要求,给出警告
156
+ if (groupValues.length !== 2) {
157
+ console.warn("此图表需要恰好2个组字段");
158
+ }
159
+
160
+ // 第一个组是左侧柱子,第二个组是右侧柱子
161
+ const leftBarGroup = groupValues[0];
162
+ const rightBarGroup = groupValues[1];
163
+ const groups = [leftBarGroup, rightBarGroup];
164
+ const groupColors = groups.map((group, index) => groupColorFor(group, index));
165
+ const legendTextColor = relativeLuminance(backgroundColor) < 0.35 ? "#f8f4e8" : textColor;
166
+ const legendData = groups.map((group, index) => ({
167
+ key: group,
168
+ color: groupColors[index],
169
+ unit: yUnit
170
+ }));
171
+ const legendSpacing = 24;
172
+
173
+ // ---------- 5. 创建SVG容器 ----------
174
+ const svg = d3.select(containerSelector)
175
+ .append("svg")
176
+ .attr("width", "100%")
177
+ .attr("height", height)
178
+ .attr("viewBox", `0 0 ${width} ${height}`)
179
+ .attr("style", "max-width: 100%; height: auto;")
180
+ .attr("xmlns", "http://www.w3.org/2000/svg")
181
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
182
+
183
+ // ---------- 6. 创建视觉效果 ----------
184
+ const defs = svg.append("defs");
185
+
186
+ defs.append("linearGradient")
187
+ .attr("id", "vg12-background")
188
+ .attr("x1", "0%")
189
+ .attr("y1", "0%")
190
+ .attr("x2", "0%")
191
+ .attr("y2", "100%")
192
+ .selectAll("stop")
193
+ .data([
194
+ { offset: "0%", color: lighten(backgroundColor, 0.55, "#fffaf0") },
195
+ { offset: "100%", color: backgroundColor }
196
+ ])
197
+ .enter()
198
+ .append("stop")
199
+ .attr("offset", d => d.offset)
200
+ .attr("stop-color", d => d.color);
201
+
202
+ svg.append("rect")
203
+ .attr("class", "chart-background")
204
+ .attr("x", 0)
205
+ .attr("y", 0)
206
+ .attr("width", width)
207
+ .attr("height", height)
208
+ .attr("fill", "none");
209
+
210
+ // 如果需要,创建阴影滤镜
211
+ if (variables.has_shadow) {
212
+ const filter = defs.append("filter")
213
+ .attr("id", "shadow")
214
+ .attr("filterUnits", "userSpaceOnUse")
215
+ .attr("width", "200%")
216
+ .attr("height", "200%");
217
+
218
+ filter.append("feGaussianBlur")
219
+ .attr("in", "SourceAlpha")
220
+ .attr("stdDeviation", 3);
221
+
222
+ filter.append("feOffset")
223
+ .attr("dx", 2)
224
+ .attr("dy", 2)
225
+ .attr("result", "offsetblur");
226
+
227
+ const feMerge = filter.append("feMerge");
228
+ feMerge.append("feMergeNode");
229
+ feMerge.append("feMergeNode").attr("in", "SourceGraphic");
230
+ }
231
+
232
+ groups.forEach((group, i) => {
233
+ const groupColor = groupColors[i];
234
+ const gradient = defs.append("linearGradient")
235
+ .attr("id", `bar-gradient-${i}`)
236
+ .attr("x1", "0%")
237
+ .attr("y1", "0%")
238
+ .attr("x2", "0%")
239
+ .attr("y2", "100%");
240
+
241
+ gradient.append("stop")
242
+ .attr("offset", "0%")
243
+ .attr("stop-color", lighten(groupColor, 0.45, defaultColors[i]))
244
+ .attr("stop-opacity", 1);
245
+ gradient.append("stop")
246
+ .attr("offset", "100%")
247
+ .attr("stop-color", darken(groupColor, 0.2, defaultColors[i]))
248
+ .attr("stop-opacity", 1);
249
+ });
250
+
251
+ const panel = {
252
+ x: 10,
253
+ y: 74,
254
+ width: width - 20,
255
+ height: height - 88,
256
+ radius: 18
257
+ };
258
+
259
+ svg.append("rect")
260
+ .attr("class", "chart-panel")
261
+ .attr("x", panel.x)
262
+ .attr("y", panel.y)
263
+ .attr("width", panel.width)
264
+ .attr("height", panel.height)
265
+ .attr("rx", panel.radius)
266
+ .attr("ry", panel.radius)
267
+ .attr("fill", "#fffdf6")
268
+ .attr("stroke", "#e2d7bf")
269
+ .attr("stroke-width", 1.5)
270
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "drop-shadow(0 2px 4px rgba(86, 68, 38, 0.14))");
271
+
272
+ // ---------- 7. 创建图表区域 ----------
273
+ const chart = svg.append("g")
274
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
275
+
276
+ // ---------- 8. 创建比例尺 ----------
277
+ // X比例尺(分类)用于主分类
278
+ const xScale = d3.scaleBand()
279
+ .domain(xValues)
280
+ .range([0, innerWidth])
281
+ .padding(0.10);
282
+
283
+ // 分组比例尺,用于每个类别内的细分
284
+ const groupScale = d3.scaleBand()
285
+ .domain([0, 1]) // 只有两个柱子,左侧和右侧
286
+ .range([0, xScale.bandwidth()])
287
+ .padding(0.2); // 同一维度柱子之间的间隙,增加间隔
288
+
289
+ // Y比例尺(数值)- 直接使用最大值映射到可用高度
290
+ const dataMax = d3.max(useData, d => +d[yField]) || 100;
291
+ const yScale = d3.scaleLinear()
292
+ .domain([0, dataMax * 1.12])
293
+ .nice(5)
294
+ .range([innerHeight, 0]);
295
+
296
+ // ---------- 9. 文本宽度计算和字体大小调整 (移动到这里,在绘制坐标轴之前) ----------
297
+ // 添加数值格式化函数
298
+
299
+ // 创建格式化函数
300
+ const formattedValue = (value) => {
301
+ const numeric = +value || 0;
302
+ if (Math.abs(numeric) < 10 && Math.abs(numeric - Math.round(numeric)) > 1e-6) {
303
+ return d3.format(".2f")(numeric).replace(/\.?0+$/, "");
304
+ }
305
+ return d3.format(",.0f")(numeric);
306
+ };
307
+
308
+ // 数值标签的可用宽度 = bar宽度 + 间距
309
+ const barWidth = Math.min(30, Math.max(18, xScale.bandwidth() * 0.40));
310
+ const pairGap = Math.max(4, xScale.bandwidth() * 0.07);
311
+ const valueSpacing = xScale.bandwidth() * 0.1;
312
+ const valueLabelAvailableWidth = Math.max(barWidth * 2.2, xScale.bandwidth() * 0.62);
313
+
314
+ // 维度标签的可用宽度 = 两个bar宽度 + bar间距
315
+ const dimensionLabelAvailableWidth = xScale.bandwidth();
316
+
317
+ // 计算最长的数值标签和维度标签
318
+ let maxValueLabelWidth = 0;
319
+ let maxDimensionLabelWidth = 0;
320
+
321
+ // 计算所有数值标签的最大宽度
322
+ useData.forEach(d => {
323
+ const textWidth = chartUtils.text.measure(svg, formattedValue(+d[yField]), {
324
+ fontFamily: typography.label.font_family,
325
+ fontSize: typography.label.font_size,
326
+ fontWeight: "bold"
327
+ }).width;
328
+ maxValueLabelWidth = Math.max(maxValueLabelWidth, textWidth);
329
+ });
330
+
331
+ // 计算所有维度标签的最大宽度
332
+ xValues.forEach(xValue => {
333
+ const textWidth = chartUtils.text.measure(svg, xValue, {
334
+ fontFamily: typography.label.font_family,
335
+ fontSize: typography.label.font_size,
336
+ fontWeight: "bold"
337
+ }).width;
338
+ maxDimensionLabelWidth = Math.max(maxDimensionLabelWidth, textWidth);
339
+ });
340
+
341
+ // 计算需要的字体缩放比例
342
+ const valueFontScale = Math.min(1, valueLabelAvailableWidth / maxValueLabelWidth);
343
+ const dimensionFontScale = Math.min(1, dimensionLabelAvailableWidth / maxDimensionLabelWidth);
344
+
345
+ // 计算实际使用的字体大小
346
+ const valueFontSize = Math.max(10, Math.min(12, parseInt(typography.label.font_size) * valueFontScale));
347
+ const dimensionFontSize = Math.max(8, parseInt(typography.label.font_size) * dimensionFontScale);
348
+
349
+ // 计算动态文本大小的函数
350
+ const calculateFontSize = (text, maxWidth, baseSize = 12) => {
351
+ // 估算每个字符的平均宽度 (假设为baseSize的60%)
352
+ const avgCharWidth = baseSize * 0.6;
353
+ // 计算文本的估计宽度
354
+ const textWidth = text.length * avgCharWidth;
355
+ // 如果文本宽度小于最大宽度,返回基础大小
356
+ if (textWidth < maxWidth) {
357
+ return baseSize;
358
+ }
359
+ // 否则,按比例缩小字体大小
360
+ return Math.max(10, Math.floor(baseSize * (maxWidth / textWidth)));
361
+ };
362
+
363
+ const roundedTopBarPath = (x, y, w, h, r) => {
364
+ const radius = clamp(Math.min(r, w / 2, h), 0, 4);
365
+ if (h <= 0) return "";
366
+ return [
367
+ `M${x},${y + h}`,
368
+ `L${x},${y + radius}`,
369
+ `Q${x},${y} ${x + radius},${y}`,
370
+ `L${x + w - radius},${y}`,
371
+ `Q${x + w},${y} ${x + w},${y + radius}`,
372
+ `L${x + w},${y + h}`,
373
+ "Z"
374
+ ].join(" ");
375
+ };
376
+
377
+ const yTicks = yScale.ticks(5);
378
+ chart.append("g")
379
+ .attr("class", "y-grid")
380
+ .selectAll("line")
381
+ .data(yTicks)
382
+ .enter()
383
+ .append("line")
384
+ .attr("x1", 0)
385
+ .attr("x2", innerWidth)
386
+ .attr("y1", d => yScale(d))
387
+ .attr("y2", d => yScale(d))
388
+ .attr("stroke", "#dfd3bd")
389
+ .attr("stroke-width", 1)
390
+ .attr("stroke-dasharray", "3 4");
391
+
392
+ chart.append("line")
393
+ .attr("class", "y-axis-line")
394
+ .attr("x1", 0)
395
+ .attr("x2", 0)
396
+ .attr("y1", 0)
397
+ .attr("y2", innerHeight)
398
+ .attr("stroke", "#3e4846")
399
+ .attr("stroke-width", 1.2);
400
+
401
+ chart.append("line")
402
+ .attr("class", "baseline")
403
+ .attr("x1", 0)
404
+ .attr("x2", innerWidth)
405
+ .attr("y1", innerHeight)
406
+ .attr("y2", innerHeight)
407
+ .attr("stroke", "#3e4846")
408
+ .attr("stroke-width", 1.2);
409
+
410
+ chart.append("g")
411
+ .attr("class", "y-tick-labels")
412
+ .selectAll("text")
413
+ .data(yTicks)
414
+ .enter()
415
+ .append("text")
416
+ .attr("x", -10)
417
+ .attr("y", d => yScale(d) + 4)
418
+ .attr("text-anchor", "end")
419
+ .style("font-family", typography.label.font_family)
420
+ .style("font-size", "12px")
421
+ .style("font-weight", 500)
422
+ .style("fill", textColor)
423
+ .text(d => chartUtils.format.autoText(d));
424
+
425
+ chart.append("text")
426
+ .attr("class", "y-axis-title")
427
+ .attr("transform", `translate(${-52}, ${innerHeight / 2}) rotate(-90)`)
428
+ .attr("text-anchor", "middle")
429
+ .style("font-family", typography.label.font_family)
430
+ .style("font-size", "14px")
431
+ .style("font-weight", 600)
432
+ .style("fill", textColor)
433
+ .text(`${yField}${yUnit ? ` (${yUnit})` : ""}`);
434
+
435
+ // ---------- 10. 创建坐标轴 (原来的第9步) ----------
436
+ // 创建x轴组,用于添加刻度标签
437
+ const xAxisGroup = chart.append("g")
438
+ .attr("class", "x-axis")
439
+ .attr("transform", `translate(0, ${innerHeight + 50})`);
440
+
441
+ // 第一步:找出最长的标签并计算统一的字体大小
442
+ let maxLabelLength = 0;
443
+ const allLabels = xValues.map(d => d.toString());
444
+
445
+ // 找出最长的标签
446
+ const longestLabel = allLabels.reduce((a, b) => a.length > b.length ? a : b, "");
447
+
448
+ // 使用最长标签计算合适的统一字体大小
449
+ const labelMaxWidth = xScale.bandwidth()*1.3;
450
+ const uniformFontSize = calculateFontSize(longestLabel, labelMaxWidth, parseInt(typography.label.font_size));
451
+
452
+ // 绘制x轴标签
453
+ xAxisGroup.selectAll(".x-label")
454
+ .data(xValues)
455
+ .enter()
456
+ .append("text")
457
+ .attr("class", "x-label")
458
+ .attr("x", d => xScale(d) + xScale.bandwidth() / 2)
459
+ .attr("y", 12)
460
+ .attr("text-anchor", "middle")
461
+ .style("font-family", typography.label.font_family)
462
+ .style("font-size", `${uniformFontSize}px`) // 应用统一的字体大小
463
+ .style("font-weight", typography.label.font_weight)
464
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
465
+ .text(d => d)
466
+ .each(function(d) {
467
+ const text = d3.select(this);
468
+ const labelText = d.toString();
469
+ const labelWidth = chartUtils.text.measure(null, labelText, {
470
+ fontFamily: typography.label.font_family,
471
+ fontSize: uniformFontSize,
472
+ fontWeight: typography.label.font_weight
473
+ }).width;
474
+
475
+ // 检查使用统一字体大小后,文本是否仍然超过可用宽度
476
+ if (labelWidth > labelMaxWidth) {
477
+ // 如果仍然太长,应用文本换行
478
+ wrapText(text, labelText, labelMaxWidth, 1.1);
479
+ }
480
+ });
481
+
482
+ // 文本换行助手函数
483
+ function wrapText(text, str, width, lineHeight) {
484
+ const words = str.split(/\s+/).reverse();
485
+ let word;
486
+ let line = [];
487
+ let lineNumber = 0;
488
+ const y = text.attr("y");
489
+ const dy = parseFloat(text.attr("dy") || 0);
490
+
491
+ // 先清空文本
492
+ text.text(null);
493
+
494
+ // 处理文本
495
+ let tspans = [];
496
+
497
+ // 如果没有空格可分割,按字符分割
498
+ if (words.length <= 1) {
499
+ const chars = str.split('');
500
+ let currentLine = '';
501
+
502
+ for (let i = 0; i < chars.length; i++) {
503
+ currentLine += chars[i];
504
+
505
+ // 测量候选文本宽度
506
+ const isOverflow = chartUtils.text.measure(null, currentLine, {
507
+ fontFamily: text.style("font-family"),
508
+ fontSize: parseFloat(text.style("font-size")),
509
+ fontWeight: text.style("font-weight")
510
+ }).width > width;
511
+
512
+ if (isOverflow && currentLine.length > 1) {
513
+ // 当前行过长,回退一个字符并换行
514
+ currentLine = currentLine.slice(0, -1);
515
+
516
+ // 添加到tspans数组
517
+ tspans.push(currentLine);
518
+
519
+ // 重新开始下一行
520
+ currentLine = chars[i];
521
+ lineNumber++;
522
+ }
523
+ }
524
+
525
+ // 添加最后一行
526
+ if (currentLine.length > 0) {
527
+ tspans.push(currentLine);
528
+ }
529
+ } else {
530
+ // 处理有空格的文本
531
+ let currentLine = [];
532
+
533
+ while (word = words.pop()) {
534
+ currentLine.push(word);
535
+
536
+ // 测量候选文本宽度
537
+ const isOverflow = chartUtils.text.measure(null, currentLine.join(" "), {
538
+ fontFamily: text.style("font-family"),
539
+ fontSize: parseFloat(text.style("font-size")),
540
+ fontWeight: text.style("font-weight")
541
+ }).width > width;
542
+
543
+ if (isOverflow && currentLine.length > 1) {
544
+ // 回退一个词
545
+ currentLine.pop();
546
+
547
+ // 添加到tspans数组
548
+ tspans.push(currentLine.join(" "));
549
+
550
+ // 重新开始下一行
551
+ currentLine = [word];
552
+ lineNumber++;
553
+ }
554
+ }
555
+
556
+ // 添加最后一行
557
+ if (currentLine.length > 0) {
558
+ tspans.push(currentLine.join(" "));
559
+ }
560
+ }
561
+
562
+ // 计算总行数
563
+ const totalLines = tspans.length;
564
+
565
+ // 计算垂直居中的起始位置
566
+ // 对于单行文本,y位置保持不变
567
+ // 对于多行文本,需要向上偏移以保持垂直居中
568
+ let startY = y;
569
+ if (totalLines > 1) {
570
+ // 向上偏移半行距离 * (总行数-1)
571
+ startY = parseFloat(y) - (lineHeight * (totalLines - 1) / 2);
572
+ }
573
+
574
+ // 创建所有行的tspan元素
575
+ tspans.forEach((lineText, i) => {
576
+ text.append("tspan")
577
+ .attr("x", text.attr("x"))
578
+ .attr("y", startY)
579
+ .attr("dy", `${i * lineHeight}em`)
580
+ .text(lineText);
581
+ });
582
+ }
583
+
584
+ // ---------- 11. 绘制图例 (selected-reference standalone pill) ----------
585
+ const legendPill = {
586
+ width: Math.min(width - 120, Math.max(300, groups.join("").length * 9 + 215)),
587
+ height: 50,
588
+ y: 18
589
+ };
590
+ legendPill.x = (width - legendPill.width) / 2;
591
+ const legend = svg.append("g")
592
+ .attr("class", "legend reference-legend-pill")
593
+ .attr("data-reference-relationship", "independent-centered-between-subtitle-and-chart-card")
594
+ .attr("transform", `translate(${legendPill.x}, ${legendPill.y})`);
595
+
596
+ legend.append("rect")
597
+ .attr("class", "legend-pill-bg")
598
+ .attr("x", 0)
599
+ .attr("y", 0)
600
+ .attr("width", legendPill.width)
601
+ .attr("height", legendPill.height)
602
+ .attr("rx", 10)
603
+ .attr("ry", 10)
604
+ .attr("fill", "#fffdf7")
605
+ .attr("stroke", "#e2d7bf")
606
+ .attr("stroke-width", 1.2)
607
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "drop-shadow(0 1.5px 3px rgba(86, 68, 38, 0.14))");
608
+
609
+ const drawLegendMarker = (g, cx, cy, color, index) => {
610
+ const marker = g.append("g")
611
+ .attr("class", "reserved-asset-slot legend-marker-slot")
612
+ .attr("data-asset-slot", "legend-marker")
613
+ .attr("data-asset-anchor", groups[index])
614
+ .attr("data-asset-bbox", `${cx - 13},${cy - 16},26,32`)
615
+ .attr("data-collision-avoid", "legend-label,legend-pill")
616
+ .attr("data-no-embedded-raster", "true")
617
+ .attr("data-no-copied-reference-art", "true");
618
+ marker.append("path")
619
+ .attr("d", `M${cx},${cy - 16} C${cx + 11},${cy - 2} ${cx + 12},${cy + 12} ${cx},${cy + 15} C${cx - 12},${cy + 12} ${cx - 11},${cy - 2} ${cx},${cy - 16} Z`)
620
+ .attr("fill", color)
621
+ .attr("stroke", darken(color, 0.35, color))
622
+ .attr("stroke-width", 0.8);
623
+ marker.append("path")
624
+ .attr("d", `M${cx + 5},${cy + 2} C${cx + 5},${cy + 8} ${cx + 1},${cy + 11} ${cx - 5},${cy + 10}`)
625
+ .attr("fill", "none")
626
+ .attr("stroke", "#ffffff")
627
+ .attr("stroke-width", 2)
628
+ .attr("stroke-linecap", "round")
629
+ .attr("opacity", 0.9);
630
+ };
631
+
632
+ const legendItemWidth = legendPill.width / 2;
633
+ legendData.forEach((itemData, index) => {
634
+ const itemX = index * legendItemWidth + 26;
635
+ const itemGroup = legend.append("g")
636
+ .attr("class", "legend-item")
637
+ .attr("transform", `translate(${itemX}, 0)`);
638
+ drawLegendMarker(itemGroup, 0, 25, itemData.color, index);
639
+ itemGroup.append("text")
640
+ .attr("x", 22)
641
+ .attr("y", 22)
642
+ .attr("dominant-baseline", "middle")
643
+ .attr("fill", textColor)
644
+ .style("font-family", typography.label.font_family)
645
+ .style("font-size", "13px")
646
+ .style("font-weight", 700)
647
+ .text(itemData.key);
648
+ itemGroup.append("text")
649
+ .attr("x", 22)
650
+ .attr("y", 38)
651
+ .attr("dominant-baseline", "middle")
652
+ .attr("fill", textColor)
653
+ .style("font-family", typography.label.font_family)
654
+ .style("font-size", "10px")
655
+ .style("font-weight", 500)
656
+ .text(yUnit ? `(${yUnit})` : "");
657
+ });
658
+
659
+ const categoryTotals = xValues.map(xValue => {
660
+ const rows = useData.filter(d => d[xField] === xValue);
661
+ return {
662
+ xValue,
663
+ total: d3.sum(rows, d => +d[yField] || 0),
664
+ firstValue: +(rows.find(d => d[groupField] === leftBarGroup)?.[yField] || 0),
665
+ complete: groups.every(group => rows.some(d => d[groupField] === group))
666
+ };
667
+ });
668
+ const highlightDatum = categoryTotals
669
+ .filter(d => d.complete)
670
+ .sort((a, b) => d3.descending(a.firstValue || a.total, b.firstValue || b.total))[0];
671
+ if (highlightDatum && xScale.bandwidth() > 35) {
672
+ const hx = xScale(highlightDatum.xValue) + xScale.bandwidth() * 0.07;
673
+ const hw = xScale.bandwidth() * 0.86;
674
+ chart.insert("rect", ".y-grid")
675
+ .attr("class", "max-category-highlight")
676
+ .attr("x", hx)
677
+ .attr("y", -10)
678
+ .attr("width", hw)
679
+ .attr("height", innerHeight + 12)
680
+ .attr("rx", 10)
681
+ .attr("ry", 10)
682
+ .attr("fill", "#dcebd3")
683
+ .attr("stroke", "#9fbe8e")
684
+ .attr("stroke-width", 1)
685
+ .attr("opacity", 0.45);
686
+ }
687
+
688
+ // 绘制条形图和标签
689
+ xValues.forEach(xValue => {
690
+ // 获取当前x类别的数据
691
+ const xData = useData.filter(d => d[xField] === xValue);
692
+
693
+ // 获取左侧柱子的数据
694
+ const leftBarData = xData.find(d => d[groupField] === leftBarGroup);
695
+
696
+ // 获取右侧柱子的数据
697
+ const rightBarData = xData.find(d => d[groupField] === rightBarGroup);
698
+
699
+ // 计算左侧柱子的位置和高度
700
+ const clusterCenter = xScale(xValue) + xScale.bandwidth() / 2;
701
+ const leftBarX = clusterCenter - barWidth - pairGap / 2;
702
+ let leftBarY, leftBarHeight, leftValue;
703
+
704
+ // --- Variables to track label/bar positions for icon placement ---
705
+ let minBarTopY = innerHeight;
706
+ let minExternalLabelTopY = innerHeight;
707
+ let leftLabelIsOutside = false; // Track label position
708
+ let rightLabelIsOutside = false;
709
+ // -------------------------------------------------------------
710
+
711
+ if (leftBarData) {
712
+ leftValue = leftBarData[yField];
713
+ leftBarHeight = innerHeight - yScale(leftValue);
714
+ leftBarY = yScale(leftValue);
715
+ minBarTopY = Math.min(minBarTopY, leftBarY);
716
+
717
+ // 绘制左侧柱子
718
+ chart.append("path")
719
+ .attr("class", "bar left-bar")
720
+ .attr("data-group", leftBarGroup)
721
+ .attr("data-x", xValue)
722
+ .attr("data-y", leftValue)
723
+ .attr("d", roundedTopBarPath(leftBarX, leftBarY, barWidth, leftBarHeight, Math.min(3, barWidth * 0.16)))
724
+ .attr("fill", "url(#bar-gradient-0)")
725
+ .attr("stroke", variables.has_stroke ? "#555" : "none")
726
+ .attr("stroke-width", variables.has_stroke ? 1 : 0)
727
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "none");
728
+
729
+ // 绘制左侧柱子顶部的标签
730
+ const leftValueText = formattedValue(leftValue);
731
+ let leftLabelFontSize = valueFontSize; // 默认使用之前计算的字体大小
732
+ let leftTextWidth = chartUtils.text.measure(chart, leftValueText, {
733
+ fontFamily: typography.label.font_family,
734
+ fontSize: `${leftLabelFontSize}px`,
735
+ fontWeight: "bold"
736
+ }).width;
737
+ const maxLabelWidth = Math.max(barWidth * 2.2, xScale.bandwidth() * 0.62);
738
+ if (leftTextWidth > maxLabelWidth) {
739
+ leftLabelFontSize = Math.max(8, leftLabelFontSize * (maxLabelWidth / leftTextWidth));
740
+ }
741
+ // *** 修改: 判断标签是否能放入 Bar 内 ***
742
+ const labelHeightRequired = leftLabelFontSize + 10; // 字体高度 + 上下 padding
743
+ let leftLabelY, leftLabelColor, leftDominantBaseline = "auto"; // Default baseline
744
+
745
+ leftLabelY = Math.max(14, leftBarY - 7);
746
+ leftLabelColor = groupColors[0];
747
+ leftDominantBaseline = "auto";
748
+ minExternalLabelTopY = Math.min(minExternalLabelTopY, leftLabelY - leftLabelFontSize);
749
+ leftLabelIsOutside = true;
750
+
751
+ chart.append("text")
752
+ .attr("class", "bar-label left-label")
753
+ .attr("x", leftBarX + barWidth / 2)
754
+ .attr("y", leftLabelY) // 使用计算的 Y
755
+ .attr("dominant-baseline", leftDominantBaseline) // 设置 baseline
756
+ .attr("text-anchor", "middle")
757
+ .style("font-family", typography.label.font_family)
758
+ .style("font-size", `${leftLabelFontSize}px`)
759
+ .style("font-weight", "bold")
760
+ .style("fill", leftLabelColor) // 使用计算的颜色
761
+ .text(leftValueText);
762
+ } else {
763
+ leftValue = 0;
764
+ leftBarHeight = 0;
765
+ leftBarY = innerHeight;
766
+ }
767
+
768
+ const rightBarX = leftBarX + barWidth + pairGap;
769
+ let rightBarY, rightBarHeight, rightValue;
770
+
771
+ if (rightBarData) {
772
+ rightValue = rightBarData[yField];
773
+ rightBarHeight = innerHeight - yScale(rightValue);
774
+ rightBarY = yScale(rightValue);
775
+ minBarTopY = Math.min(minBarTopY, rightBarY);
776
+
777
+ // 绘制右侧柱子
778
+ chart.append("path")
779
+ .attr("class", "bar right-bar")
780
+ .attr("data-group", rightBarGroup)
781
+ .attr("data-x", xValue)
782
+ .attr("data-y", rightValue)
783
+ .attr("d", roundedTopBarPath(rightBarX, rightBarY, barWidth, rightBarHeight, Math.min(3, barWidth * 0.16)))
784
+ .attr("fill", "url(#bar-gradient-1)")
785
+ .attr("stroke", variables.has_stroke ? "#555" : "none")
786
+ .attr("stroke-width", variables.has_stroke ? 1 : 0)
787
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "none");
788
+
789
+ // 绘制右侧柱子顶部的标签
790
+ const rightValueText = formattedValue(rightValue);
791
+ let rightLabelFontSize = valueFontSize; // 默认使用之前计算的字体大小
792
+ let rightTextWidth = chartUtils.text.measure(chart, rightValueText, {
793
+ fontFamily: typography.label.font_family,
794
+ fontSize: `${rightLabelFontSize}px`,
795
+ fontWeight: "bold"
796
+ }).width;
797
+ const rightMaxLabelWidth = Math.max(barWidth * 2.2, xScale.bandwidth() * 0.62);
798
+ if (rightTextWidth > rightMaxLabelWidth) {
799
+ rightLabelFontSize = Math.max(8, rightLabelFontSize * (rightMaxLabelWidth / rightTextWidth));
800
+ }
801
+ // *** 修改: 判断标签是否能放入 Bar 内 ***
802
+ const rightLabelHeightRequired = rightLabelFontSize + 10; // 字体高度 + 上下 padding
803
+ let rightLabelY, rightLabelColor, rightDominantBaseline = "auto";
804
+
805
+ rightLabelY = Math.max(14, rightBarY - 7);
806
+ rightLabelColor = groupColors[1];
807
+ rightDominantBaseline = "auto";
808
+ minExternalLabelTopY = Math.min(minExternalLabelTopY, rightLabelY - rightLabelFontSize);
809
+ rightLabelIsOutside = true;
810
+
811
+ chart.append("text")
812
+ .attr("class", "bar-label right-label")
813
+ .attr("x", rightBarX + barWidth / 2)
814
+ .attr("y", rightLabelY) // 使用计算的 Y
815
+ .attr("dominant-baseline", rightDominantBaseline) // 设置 baseline
816
+ .attr("text-anchor", "middle")
817
+ .style("font-family", typography.label.font_family)
818
+ .style("font-size", `${rightLabelFontSize}px`)
819
+ .style("font-weight", "bold")
820
+ .style("fill", rightLabelColor) // 使用计算的颜色
821
+ .text(rightValueText);
822
+ } else {
823
+ rightValue = 0;
824
+ rightBarHeight = 0;
825
+ rightBarY = innerHeight;
826
+ }
827
+
828
+ const iconSize = clamp(xScale.bandwidth() * 0.64, 25, 38);
829
+ const iconX = xScale(xValue) + xScale.bandwidth() / 2;
830
+ const iconY = innerHeight + 28;
831
+ const slot = chart.append("g")
832
+ .attr("class", "reserved-asset-slot category-icon-slot")
833
+ .attr("data-asset-slot", "category-icon")
834
+ .attr("data-asset-anchor", String(xValue))
835
+ .attr("data-asset-bbox", `${iconX - iconSize / 2},${iconY - iconSize / 2},${iconSize},${iconSize}`)
836
+ .attr("data-collision-avoid", "bars,value-labels,x-labels,panel-border")
837
+ .attr("data-no-embedded-raster", "true")
838
+ .attr("data-no-copied-reference-art", "true");
839
+ slot.append("circle")
840
+ .attr("cx", iconX)
841
+ .attr("cy", iconY)
842
+ .attr("r", iconSize / 2)
843
+ .attr("fill", lighten(backgroundColor, 0.4, "#fff6d9"))
844
+ .attr("stroke", "#cdbb91")
845
+ .attr("stroke-width", 1.2);
846
+ slot.append("circle")
847
+ .attr("cx", iconX)
848
+ .attr("cy", iconY)
849
+ .attr("r", Math.max(2.4, iconSize / 10))
850
+ .attr("fill", titleGreen)
851
+ .attr("opacity", 0.45);
852
+
853
+ }); // --- END xValues.forEach ---
854
+
855
+ // 返回SVG节点
856
+ return svg.node();
857
+ }
modules/chart_engine/template/d3-js/bar_chart/vertical_group_bar_chart_13.js ADDED
@@ -0,0 +1,1198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Vertical Group Bar Chart",
5
+ "chart_name": "vertical_group_bar_chart_13",
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": [],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": ["group"],
13
+ "required_other_colors": ["primary"],
14
+ "supported_effects": [],
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
+ // 垂直分组条形图实现 - 手绘风格
28
+ function makeChart(containerSelector, data) {
29
+ const colorResolver = chartUtils.color.resolver(data);
30
+ // ---------- 1. 数据准备 ----------
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
+ // Use a sketch-like font for labels
37
+ label: { font_family: "'Comic Sans MS', cursive", font_size: "12px", font_weight: "normal" },
38
+ description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
39
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
40
+ };
41
+ const colors = jsonData.colors || {
42
+ text_color: "#333333",
43
+ field: {},
44
+ other: {
45
+ primary: "#4682B4" // Default primary color (less relevant now)
46
+ }
47
+ };
48
+ const images = jsonData.images || { field: {}, other: {} };
49
+ const dataColumns = chartUtils.schema.columns(jsonData);
50
+
51
+ typography.subtitle = typography.subtitle || typography.description;
52
+
53
+ // Sketch style doesn't usually use rounded corners or gradients
54
+ variables.has_rounded_corners = false; // Force disable rounded corners for sketch style
55
+ variables.has_gradient = false; // Force disable gradient
56
+ variables.has_shadow = variables.has_shadow || false;
57
+ variables.has_stroke = variables.has_stroke || false;
58
+ variables.has_spacing = variables.has_spacing || false;
59
+
60
+ d3.select(containerSelector).html("");
61
+
62
+ // ---------- 2. 尺寸和布局设置 ----------
63
+ const width = variables.width || 800;
64
+ const height = variables.height || 500;
65
+ const margin = {
66
+ top: 100, right: 30, bottom: 100, // Increased bottom margin for sketch labels + icons
67
+ left: 30
68
+ };
69
+ const innerWidth = width - margin.left - margin.right;
70
+ const innerHeight = height - margin.top - margin.bottom;
71
+
72
+ // ---------- 3. 提取字段名称和单位 ----------
73
+ let xField, yField, groupField;
74
+ let xUnit = "", yUnit = "";
75
+ const xChannel = chartUtils.schema.channel(jsonData, "x");
76
+ const yChannel = chartUtils.schema.channel(jsonData, "y");
77
+ const groupChannel = chartUtils.schema.channel(jsonData, "group");
78
+ xField = xChannel.key;
79
+ yField = yChannel.key;
80
+ groupField = groupChannel.key;
81
+ xUnit = xChannel.unit;
82
+ yUnit = yChannel.unit;
83
+
84
+ // ---------- 4. 数据处理 ----------
85
+ let useData = chartData;
86
+ const xValues = [...new Set(useData.map(d => d[xField]))];
87
+ let groupValues = [...new Set(useData.map(d => d[groupField]))];
88
+ if (groupValues.length !== 2) {
89
+ console.warn("此图表需要恰好2个组字段");
90
+ // Handle error or default behavior if groups != 2
91
+ if (groupValues.length < 2) return; // Stop if not enough groups
92
+ groupValues = groupValues.slice(0, 2); // Take first two if more than 2
93
+ }
94
+ const leftBarGroup = groupValues[0];
95
+ const rightBarGroup = groupValues[1];
96
+
97
+ // 获取分组颜色,若未在colors.field中定义,则使用默认颜色
98
+ const leftGroupColor = colorResolver.field(leftBarGroup, 0, { fallback: "#4269d0" }).value;
99
+ const rightGroupColor = colorResolver.field(rightBarGroup, 0, { fallback: "#ff725c" }).value;
100
+
101
+ return renderReferenceAligned();
102
+
103
+ function renderReferenceAligned() {
104
+ d3.select(containerSelector).html("");
105
+
106
+ const width = variables.width || 800;
107
+ const height = variables.height || 500;
108
+ const textColor = colorResolver.text({ fallback: colors.text_color || "#26352c" }).value;
109
+ const bgColor = "#fbf8ea";
110
+ const darkGreen = "#0f4b27";
111
+ const redAccent = "#d83a20";
112
+ const orange = "#f4b52a";
113
+ const orangeDark = "#d95a06";
114
+ const green = "#4d9a46";
115
+ const greenDark = "#0d6a3f";
116
+
117
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
118
+ const d3Color = (value, fallback) => d3.color(value) || d3.color(fallback);
119
+ const lighten = (value, amount, fallback) => {
120
+ const c = d3Color(value, fallback);
121
+ return c ? c.brighter(amount).formatHex() : fallback;
122
+ };
123
+ const darken = (value, amount, fallback) => {
124
+ const c = d3Color(value, fallback);
125
+ return c ? c.darker(amount).formatHex() : fallback;
126
+ };
127
+ const slug = (value) => String(value).replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-|-$/g, "") || "group";
128
+ const fontFamily = typography.label?.font_family || "Arial, sans-serif";
129
+ const titleFamily = typography.title?.font_family || "Arial, sans-serif";
130
+
131
+ const measure = (text, size, weight = 500, family = fontFamily) =>
132
+ chartUtils.text.measure(null, String(text), {
133
+ fontFamily: family,
134
+ fontSize: size,
135
+ fontWeight: weight
136
+ }).width;
137
+ const fitFont = (text, maxWidth, base, min, weight = 700, family = titleFamily) => {
138
+ const w = measure(text, base, weight, family);
139
+ return Math.max(min, Math.min(base, base * (maxWidth / Math.max(w, 1))));
140
+ };
141
+ const ellipsizeText = (text, maxWidth, size, weight = 800, family = fontFamily) => {
142
+ const value = String(text || "");
143
+ if (measure(value, size, weight, family) <= maxWidth) return value;
144
+ let trimmed = value;
145
+ while (trimmed.length > 1 && measure(`${trimmed}...`, size, weight, family) > maxWidth) {
146
+ trimmed = trimmed.slice(0, -1).trimEnd();
147
+ }
148
+ return trimmed.length > 1 ? `${trimmed}...` : value.slice(0, 1);
149
+ };
150
+ const wrapLines = (text, maxWidth, size, weight = 500, family = fontFamily, maxLines = 3) => {
151
+ const words = String(text || "").split(/\s+/).filter(Boolean);
152
+ if (!words.length) return [];
153
+ const lines = [];
154
+ let line = "";
155
+ words.forEach(word => {
156
+ const next = line ? `${line} ${word}` : word;
157
+ if (measure(next, size, weight, family) <= maxWidth || !line) {
158
+ line = next;
159
+ } else {
160
+ lines.push(line);
161
+ line = word;
162
+ }
163
+ });
164
+ if (line) lines.push(line);
165
+ if (lines.length <= maxLines) return lines;
166
+ const kept = lines.slice(0, maxLines);
167
+ while (measure(`${kept[maxLines - 1]}...`, size, weight, family) > maxWidth && kept[maxLines - 1].length > 4) {
168
+ kept[maxLines - 1] = kept[maxLines - 1].slice(0, -1);
169
+ }
170
+ kept[maxLines - 1] = `${kept[maxLines - 1]}...`;
171
+ return kept;
172
+ };
173
+ const addTextLines = (parent, lines, x, y, opts = {}) => {
174
+ const size = opts.size || 12;
175
+ const lineHeight = opts.lineHeight || size * 1.18;
176
+ const g = parent.append("g").attr("class", opts.class || "wrapped-text");
177
+ lines.forEach((line, index) => {
178
+ g.append("text")
179
+ .attr("x", x)
180
+ .attr("y", y + index * lineHeight)
181
+ .attr("text-anchor", opts.anchor || "start")
182
+ .attr("dominant-baseline", opts.baseline || "hanging")
183
+ .style("font-family", opts.family || fontFamily)
184
+ .style("font-size", `${size}px`)
185
+ .style("font-weight", opts.weight || 500)
186
+ .style("fill", opts.fill || textColor)
187
+ .text(line);
188
+ });
189
+ return g;
190
+ };
191
+ const formatValue = (value) => {
192
+ const numeric = +value || 0;
193
+ if (Math.abs(numeric) < 1) return d3.format(".3f")(numeric).replace(/\.?0+$/, "");
194
+ if (Math.abs(numeric) < 10) return d3.format(".2f")(numeric).replace(/\.?0+$/, "");
195
+ return d3.format(",.0f")(numeric);
196
+ };
197
+ const prettifyMetricLabel = (value) => String(value || "")
198
+ .replace(/[_-]+/g, " ")
199
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
200
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
201
+ .replace(/([a-zA-Z])(\d)/g, "$1 $2")
202
+ .replace(/(\d)([a-zA-Z])/g, "$1 $2")
203
+ .replace(/\s+/g, " ")
204
+ .trim();
205
+ const smallValueAxis = (maxValue) => {
206
+ if (maxValue <= 0.12) return { max: Math.ceil(maxValue / 0.02) * 0.02, step: 0.02 };
207
+ if (maxValue <= 0.4) return { max: Math.ceil(maxValue / 0.05) * 0.05, step: 0.05 };
208
+ if (maxValue <= 1) return { max: Math.ceil(maxValue / 0.1) * 0.1, step: 0.1 };
209
+ return null;
210
+ };
211
+ const roundedTopBarPath = (x, y, w, h, r) => {
212
+ const radius = clamp(Math.min(r, w / 2, h), 0, 3);
213
+ if (h <= 0) return "";
214
+ return [
215
+ `M${x},${y + h}`,
216
+ `L${x},${y + radius}`,
217
+ `Q${x},${y} ${x + radius},${y}`,
218
+ `L${x + w - radius},${y}`,
219
+ `Q${x + w},${y} ${x + w},${y + radius}`,
220
+ `L${x + w},${y + h}`,
221
+ "Z"
222
+ ].join(" ");
223
+ };
224
+
225
+ const rawXValues = [...new Set(useData.map(d => d[xField]))];
226
+ const timeLikeName = /(year|date|month|time|season|semester|quarter)/i.test(xField || "");
227
+ const monthIndex = {
228
+ jan: 0, january: 0, feb: 1, february: 1, mar: 2, march: 2, apr: 3, april: 3,
229
+ may: 4, jun: 5, june: 5, jul: 6, july: 6, aug: 7, august: 7, sep: 8, sept: 8,
230
+ september: 8, oct: 9, october: 9, nov: 10, november: 10, dec: 11, december: 11
231
+ };
232
+ const timeKey = (value) => {
233
+ const s = String(value).trim();
234
+ if (/^\d{4}$/.test(s)) return +s;
235
+ const lower = s.toLowerCase();
236
+ if (monthIndex[lower] !== undefined) return monthIndex[lower];
237
+ const parsed = Date.parse(s);
238
+ return Number.isNaN(parsed) ? null : parsed;
239
+ };
240
+ const xValues = timeLikeName && rawXValues.every(d => timeKey(d) !== null)
241
+ ? rawXValues.slice().sort((a, b) => timeKey(a) - timeKey(b))
242
+ : rawXValues;
243
+ const groups = groupValues.slice(0, 2);
244
+ const averages = groups.map(group => ({
245
+ group,
246
+ average: d3.mean(useData.filter(d => d[groupField] === group), d => +d[yField] || 0) || 0
247
+ }));
248
+ const lowGroup = (averages[0].average <= averages[1].average ? averages[0] : averages[1]).group;
249
+ const highGroup = (lowGroup === groups[0] ? groups[1] : groups[0]);
250
+ const legendGroups = [lowGroup, highGroup];
251
+ const barGroups = [highGroup, lowGroup];
252
+ const colorFor = (group) => {
253
+ const name = String(group).toLowerCase();
254
+ if (name.includes("organic") || group === lowGroup) return group === lowGroup ? green : colorResolver.field(group, 0, { fallback: green }).value;
255
+ if (name.includes("conventional") || group === highGroup) return group === highGroup ? orange : colorResolver.field(group, 1, { fallback: orange }).value;
256
+ return colorResolver.field(group, groups.indexOf(group), { fallback: groups.indexOf(group) === 0 ? green : orange }).value;
257
+ };
258
+ const groupColor = new Map(legendGroups.map(group => [group, colorFor(group)]));
259
+ groupColor.set(highGroup, colorFor(highGroup));
260
+ groupColor.set(lowGroup, colorFor(lowGroup));
261
+
262
+ const svg = d3.select(containerSelector)
263
+ .append("svg")
264
+ .attr("width", "100%")
265
+ .attr("height", height)
266
+ .attr("viewBox", `0 0 ${width} ${height}`)
267
+ .attr("style", "max-width: 100%; height: auto;")
268
+ .attr("xmlns", "http://www.w3.org/2000/svg")
269
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
270
+
271
+ const defs = svg.append("defs");
272
+ defs.append("linearGradient")
273
+ .attr("id", "vg13-background")
274
+ .attr("x1", "0%")
275
+ .attr("y1", "0%")
276
+ .attr("x2", "100%")
277
+ .attr("y2", "100%")
278
+ .selectAll("stop")
279
+ .data([
280
+ { offset: "0%", color: "#fffdf4" },
281
+ { offset: "68%", color: bgColor },
282
+ { offset: "100%", color: "#edf6d7" }
283
+ ])
284
+ .enter()
285
+ .append("stop")
286
+ .attr("offset", d => d.offset)
287
+ .attr("stop-color", d => d.color);
288
+ svg.append("rect")
289
+ .attr("class", "chart-background")
290
+ .attr("x", 0)
291
+ .attr("y", 0)
292
+ .attr("width", width)
293
+ .attr("height", height)
294
+ .attr("fill", "url(#vg13-background)");
295
+
296
+ legendGroups.forEach((group, index) => {
297
+ const color = groupColor.get(group) || (index ? orange : green);
298
+ const pattern = defs.append("pattern")
299
+ .attr("id", `vg13-hatch-${slug(group)}`)
300
+ .attr("width", 7)
301
+ .attr("height", 7)
302
+ .attr("patternUnits", "userSpaceOnUse")
303
+ .attr("patternTransform", "rotate(45)");
304
+ pattern.append("rect").attr("width", 7).attr("height", 7).attr("fill", color);
305
+ pattern.append("path")
306
+ .attr("d", "M -1,2 L 8,2 M -1,5 L 8,5")
307
+ .attr("stroke", "#fff7cf")
308
+ .attr("stroke-width", 1.1)
309
+ .attr("opacity", 0.9);
310
+ });
311
+
312
+ const rangeText = (() => {
313
+ const parsed = xValues.map(timeKey).filter(d => d !== null);
314
+ if (parsed.length === xValues.length && xValues.length > 1 && /^\d{4}$/.test(String(xValues[0]))) {
315
+ return ` from ${xValues[0]} to ${xValues[xValues.length - 1]}`;
316
+ }
317
+ return "";
318
+ })();
319
+ const primaryPesticide = /pesticide\s*residue/i.test(prettifyMetricLabel(yField))
320
+ && legendGroups.some(group => /organic/i.test(String(group)))
321
+ && legendGroups.some(group => /conventional/i.test(String(group)));
322
+ const titleSpec = primaryPesticide
323
+ ? {
324
+ line1: "Conventional Strawberries",
325
+ line2: "Have Higher Pesticide Residue",
326
+ subtitle: "Pesticide residue for conventional and organic strawberries from 2010 to 2023 (measured in ppm)."
327
+ }
328
+ : {
329
+ line1: `${prettifyMetricLabel(yField)} Comparison:`,
330
+ line2: `${String(highGroup)} vs. ${String(lowGroup)}`,
331
+ subtitle: `${prettifyMetricLabel(yField)} for ${String(highGroup)} and ${String(lowGroup)}${rangeText}${yUnit ? `, measured in ${yUnit}` : ""}.`
332
+ };
333
+ const headerLeft = width * 0.05;
334
+ const titleMaxW = width * 0.72;
335
+ const titleLine1Size = fitFont(titleSpec.line1, titleMaxW, clamp(height * 0.048, 42, 62), 24, 900, titleFamily);
336
+ const titleLine2Size = fitFont(titleSpec.line2, titleMaxW, clamp(height * 0.045, 38, 58), 22, 900, titleFamily);
337
+ svg.append("text")
338
+ .attr("class", "header-title-line header-title-primary")
339
+ .attr("x", headerLeft)
340
+ .attr("y", height * 0.035)
341
+ .attr("dominant-baseline", "hanging")
342
+ .style("font-family", titleFamily)
343
+ .style("font-size", `${titleLine1Size}px`)
344
+ .style("font-weight", 900)
345
+ .style("fill", darkGreen)
346
+ .text(titleSpec.line1);
347
+ svg.append("text")
348
+ .attr("class", "header-title-line header-title-accent")
349
+ .attr("x", headerLeft)
350
+ .attr("y", height * 0.095)
351
+ .attr("dominant-baseline", "hanging")
352
+ .style("font-family", titleFamily)
353
+ .style("font-size", `${titleLine2Size}px`)
354
+ .style("font-weight", 900)
355
+ .style("fill", redAccent)
356
+ .text(titleSpec.line2);
357
+ const dividerY = height * 0.137;
358
+ svg.append("line")
359
+ .attr("class", "header-divider")
360
+ .attr("x1", headerLeft)
361
+ .attr("x2", width * 0.32)
362
+ .attr("y1", dividerY)
363
+ .attr("y2", dividerY)
364
+ .attr("stroke", "#24733f")
365
+ .attr("stroke-width", 2);
366
+ svg.append("line")
367
+ .attr("class", "header-divider")
368
+ .attr("x1", width * 0.395)
369
+ .attr("x2", width * 0.61)
370
+ .attr("y1", dividerY)
371
+ .attr("y2", dividerY)
372
+ .attr("stroke", "#24733f")
373
+ .attr("stroke-width", 2);
374
+ const ornament = svg.append("g")
375
+ .attr("class", "reserved-asset-slot title-ornament-slot")
376
+ .attr("data-asset-slot", "title-ornament")
377
+ .attr("data-asset-bbox", `${width * 0.35},${dividerY - 15},${width * 0.04},30`)
378
+ .attr("data-asset-anchor", "header-divider-center")
379
+ .attr("data-collision-avoid", "title,subtitle,hero-produce-art")
380
+ .attr("data-no-embedded-raster", "true")
381
+ .attr("data-no-copied-reference-art", "true");
382
+ ornament.append("ellipse")
383
+ .attr("cx", width * 0.365)
384
+ .attr("cy", dividerY)
385
+ .attr("rx", 11)
386
+ .attr("ry", 7)
387
+ .attr("fill", "#8fbd78");
388
+ addTextLines(svg, wrapLines(titleSpec.subtitle, width * 0.58, clamp(height * 0.018, 16, 24), 500, fontFamily, 2), headerLeft, height * 0.160, {
389
+ class: "header-subtitle",
390
+ size: clamp(height * 0.018, 16, 24),
391
+ lineHeight: clamp(height * 0.028, 24, 35),
392
+ fill: textColor,
393
+ weight: 500
394
+ });
395
+ const heroX = width * 0.765;
396
+ const heroY = height * 0.020;
397
+ const heroW = width * 0.185;
398
+ const heroH = height * 0.190;
399
+ const hero = svg.append("g")
400
+ .attr("class", "reserved-asset-slot hero-produce-slot")
401
+ .attr("data-asset-slot", "hero-produce-art")
402
+ .attr("data-asset-bbox", `${heroX},${heroY},${heroW},${heroH}`)
403
+ .attr("data-asset-anchor", "header-top-right")
404
+ .attr("data-collision-avoid", "title,subtitle,comparison-card")
405
+ .attr("data-no-embedded-raster", "true")
406
+ .attr("data-no-copied-reference-art", "true");
407
+ hero.append("circle")
408
+ .attr("cx", heroX + heroW * 0.58)
409
+ .attr("cy", heroY + heroH * 0.44)
410
+ .attr("r", heroW * 0.38)
411
+ .attr("fill", "#eef6ce")
412
+ .attr("stroke", "#a8c287")
413
+ .attr("stroke-width", 2)
414
+ .attr("stroke-dasharray", "7 6")
415
+ .attr("opacity", 0.75);
416
+ hero.append("circle")
417
+ .attr("cx", heroX + heroW * 0.34)
418
+ .attr("cy", heroY + heroH * 0.56)
419
+ .attr("r", heroW * 0.15)
420
+ .attr("fill", "#d7edbb")
421
+ .attr("stroke", "#78a86d")
422
+ .attr("stroke-width", 1.6)
423
+ .attr("opacity", 0.72);
424
+
425
+ const card = { x: width * 0.05, y: height * 0.226, w: width * 0.90, h: height * 0.114 };
426
+ svg.append("rect")
427
+ .attr("class", "comparison-card")
428
+ .attr("x", card.x)
429
+ .attr("y", card.y)
430
+ .attr("width", card.w)
431
+ .attr("height", card.h)
432
+ .attr("rx", 14)
433
+ .attr("ry", 14)
434
+ .attr("fill", "#fffdf2")
435
+ .attr("stroke", "#dfdfbf")
436
+ .attr("stroke-width", 1)
437
+ .style("filter", "drop-shadow(0 2px 5px rgba(80,90,50,0.12))");
438
+ const hazard = svg.append("g")
439
+ .attr("class", "reserved-asset-slot hazard-product-slot")
440
+ .attr("data-asset-slot", "hazard-product-art")
441
+ .attr("data-asset-bbox", `${card.x + 14},${card.y + 12},${card.w * 0.19},${card.h - 24}`)
442
+ .attr("data-asset-anchor", "comparison-card-left")
443
+ .attr("data-collision-avoid", "comparison-card-text,legend")
444
+ .attr("data-no-embedded-raster", "true")
445
+ .attr("data-no-copied-reference-art", "true");
446
+ hazard.append("rect")
447
+ .attr("x", card.x + 44)
448
+ .attr("y", card.y + 20)
449
+ .attr("width", card.w * 0.08)
450
+ .attr("height", card.h - 38)
451
+ .attr("rx", 6)
452
+ .attr("fill", "#89c77e")
453
+ .attr("stroke", greenDark)
454
+ .attr("opacity", 0.65);
455
+ [0, 1, 2].forEach(i => {
456
+ const cx = card.x + 25;
457
+ const cy = card.y + 23 + i * 20;
458
+ svg.append("path")
459
+ .attr("class", "comparison-warning-mark")
460
+ .attr("d", `M${cx - 5},${cy - 5} L${cx + 5},${cy + 5} M${cx + 5},${cy - 5} L${cx - 5},${cy + 5}`)
461
+ .attr("stroke", redAccent)
462
+ .attr("stroke-width", 3)
463
+ .attr("stroke-linecap", "round");
464
+ });
465
+ const infoMidX = card.x + card.w * 0.33;
466
+ const infoRightX = card.x + card.w * 0.62;
467
+ svg.append("line")
468
+ .attr("x1", card.x + card.w * 0.58)
469
+ .attr("x2", card.x + card.w * 0.58)
470
+ .attr("y1", card.y + 18)
471
+ .attr("y2", card.y + card.h - 18)
472
+ .attr("stroke", "#a8c09b")
473
+ .attr("stroke-width", 2)
474
+ .attr("stroke-dasharray", "2 5");
475
+ const avgText = (group) => `${formatValue(averages.find(d => d.group === group)?.average || 0)}${yUnit ? ` ${yUnit}` : ""} avg`;
476
+ const drawGroupSummary = (x, group, isHigh) => {
477
+ const color = groupColor.get(group) || (isHigh ? orange : green);
478
+ svg.append("circle")
479
+ .attr("class", "comparison-card-swatch")
480
+ .attr("cx", x)
481
+ .attr("cy", card.y + card.h * 0.36)
482
+ .attr("r", 18)
483
+ .attr("fill", isHigh ? `url(#vg13-hatch-${slug(group)})` : "#f3fff1")
484
+ .attr("stroke", color)
485
+ .attr("stroke-width", 2);
486
+ svg.append("text")
487
+ .attr("x", x + 32)
488
+ .attr("y", card.y + card.h * 0.26)
489
+ .attr("dominant-baseline", "hanging")
490
+ .style("font-family", titleFamily)
491
+ .style("font-size", `${clamp(height * 0.025, 12, 16)}px`)
492
+ .style("font-weight", 800)
493
+ .style("fill", isHigh ? orangeDark : greenDark)
494
+ .text(String(group));
495
+ addTextLines(svg, wrapLines(isHigh ? `Higher residue (${avgText(group)})` : `Lower residue (${avgText(group)})`, card.w * 0.20, 12, 500, fontFamily, 2), x + 32, card.y + card.h * 0.52, {
496
+ class: "comparison-card-description",
497
+ size: clamp(height * 0.018, 9, 12),
498
+ lineHeight: clamp(height * 0.023, 11, 14),
499
+ fill: textColor,
500
+ weight: 500
501
+ });
502
+ };
503
+ drawGroupSummary(infoMidX, lowGroup, false);
504
+ drawGroupSummary(infoRightX, highGroup, true);
505
+
506
+ const panel = { x: width * 0.031, y: height * 0.353, w: width * 0.938, h: height * 0.473 };
507
+ svg.append("rect")
508
+ .attr("class", "chart-panel")
509
+ .attr("x", panel.x)
510
+ .attr("y", panel.y)
511
+ .attr("width", panel.w)
512
+ .attr("height", panel.h)
513
+ .attr("rx", 15)
514
+ .attr("ry", 15)
515
+ .attr("fill", "#fffef5")
516
+ .attr("stroke", "#d8dfb4")
517
+ .attr("stroke-width", 1.1)
518
+ .style("filter", "drop-shadow(0 2px 5px rgba(80,90,50,0.10))");
519
+ svg.append("text")
520
+ .attr("class", "y-axis-panel-title")
521
+ .attr("x", panel.x + 12)
522
+ .attr("y", panel.y + 16)
523
+ .attr("dominant-baseline", "hanging")
524
+ .style("font-family", fontFamily)
525
+ .style("font-size", `${clamp(height * 0.020, 10, 13)}px`)
526
+ .style("font-weight", 800)
527
+ .style("fill", textColor)
528
+ .text(`${prettifyMetricLabel(yField)}${yUnit ? ` (${yUnit})` : ""}`);
529
+
530
+ const legend = svg.append("g")
531
+ .attr("class", "legend chart-owned-legend")
532
+ .attr("data-reference-relationship", "independent-centered-above-plot")
533
+ .attr("transform", `translate(${panel.x + panel.w * 0.39},${panel.y + 26})`);
534
+ legendGroups.forEach((group, index) => {
535
+ const lx = index * panel.w * 0.19;
536
+ legend.append("rect")
537
+ .attr("x", lx)
538
+ .attr("y", -7)
539
+ .attr("width", 17)
540
+ .attr("height", 17)
541
+ .attr("rx", 3)
542
+ .attr("fill", `url(#vg13-hatch-${slug(group)})`)
543
+ .attr("stroke", groupColor.get(group))
544
+ .attr("stroke-width", 1.3);
545
+ legend.append("text")
546
+ .attr("x", lx + 27)
547
+ .attr("y", 2)
548
+ .attr("dominant-baseline", "middle")
549
+ .style("font-family", fontFamily)
550
+ .style("font-size", `${clamp(height * 0.020, 10, 13)}px`)
551
+ .style("font-weight", 800)
552
+ .style("fill", group === highGroup ? orangeDark : greenDark)
553
+ .text(String(group));
554
+ });
555
+
556
+ const plot = {
557
+ x: panel.x + panel.w * 0.065,
558
+ y: panel.y + panel.h * 0.11,
559
+ w: panel.w * 0.89,
560
+ h: panel.h * 0.815
561
+ };
562
+ const xScale2 = d3.scaleBand()
563
+ .domain(xValues)
564
+ .range([plot.x, plot.x + plot.w])
565
+ .padding(0.10);
566
+ const dataMax = d3.max(useData, d => +d[yField]) || 100;
567
+ const smallAxis = smallValueAxis(dataMax);
568
+ const yMax = smallAxis ? smallAxis.max : d3.scaleLinear().domain([0, dataMax * 1.14]).nice(5).domain()[1];
569
+ const yScale2 = d3.scaleLinear()
570
+ .domain([0, yMax])
571
+ .range([plot.y + plot.h, plot.y]);
572
+ const yTicks = smallAxis
573
+ ? d3.range(0, yMax + smallAxis.step / 2, smallAxis.step)
574
+ : yScale2.ticks(5);
575
+ yTicks.forEach(tick => {
576
+ const y = yScale2(tick);
577
+ svg.append("line")
578
+ .attr("class", "gridline")
579
+ .attr("x1", plot.x)
580
+ .attr("x2", plot.x + plot.w)
581
+ .attr("y1", y)
582
+ .attr("y2", y)
583
+ .attr("stroke", "#d9d9bf")
584
+ .attr("stroke-dasharray", "3 4")
585
+ .attr("stroke-width", 0.8);
586
+ svg.append("text")
587
+ .attr("class", "y-tick")
588
+ .attr("x", plot.x - 9)
589
+ .attr("y", y + 4)
590
+ .attr("text-anchor", "end")
591
+ .style("font-family", fontFamily)
592
+ .style("font-size", `${clamp(height * 0.017, 8, 11)}px`)
593
+ .style("fill", textColor)
594
+ .text(formatValue(tick));
595
+ });
596
+ svg.append("line")
597
+ .attr("class", "y-axis-line")
598
+ .attr("x1", plot.x)
599
+ .attr("x2", plot.x)
600
+ .attr("y1", plot.y)
601
+ .attr("y2", plot.y + plot.h)
602
+ .attr("stroke", "#2f7141")
603
+ .attr("stroke-width", 1.1);
604
+ svg.append("line")
605
+ .attr("class", "baseline")
606
+ .attr("x1", plot.x)
607
+ .attr("x2", plot.x + plot.w)
608
+ .attr("y1", plot.y + plot.h)
609
+ .attr("y2", plot.y + plot.h)
610
+ .attr("stroke", "#2f7141")
611
+ .attr("stroke-width", 1.1);
612
+
613
+ const band = xScale2.bandwidth();
614
+ const barW = clamp(band * 0.26, 4, 14);
615
+ const gap = clamp(band * 0.08, 2, 5);
616
+ const labelSize = clamp(height * 0.018, 8, 12);
617
+ xValues.forEach(xValue => {
618
+ const rows = useData.filter(d => d[xField] === xValue);
619
+ const center = xScale2(xValue) + band / 2;
620
+ const highRow = rows.find(d => d[groupField] === highGroup);
621
+ const lowRow = rows.find(d => d[groupField] === lowGroup);
622
+ const positions = [
623
+ { group: highGroup, row: highRow, x: center - barW - gap / 2, color: groupColor.get(highGroup), labelColor: orangeDark },
624
+ { group: lowGroup, row: lowRow, x: center + gap / 2, color: groupColor.get(lowGroup), labelColor: greenDark }
625
+ ];
626
+ positions.forEach(item => {
627
+ if (!item.row) return;
628
+ const value = +item.row[yField] || 0;
629
+ const y = yScale2(value);
630
+ const h = plot.y + plot.h - y;
631
+ svg.append("path")
632
+ .attr("class", `bar grouped-bar ${item.group === highGroup ? "high-bar" : "low-bar"}`)
633
+ .attr("data-group", item.group)
634
+ .attr("data-x", xValue)
635
+ .attr("data-y", value)
636
+ .attr("data-mark-endcap", "flat-baseline-slightly-rounded-top")
637
+ .attr("data-baseline-edge", "flat-square")
638
+ .attr("data-value-edge", "horizontal-flat-with-minor-top-corner-radius")
639
+ .attr("data-stacked-join", "not-applicable-grouped-bars-separated")
640
+ .attr("d", roundedTopBarPath(item.x, y, barW, Math.max(0, h), Math.min(2, barW * 0.14)))
641
+ .attr("fill", `url(#vg13-hatch-${slug(item.group)})`)
642
+ .attr("stroke", darken(item.color, 0.25, item.color))
643
+ .attr("stroke-width", 0.45);
644
+ svg.append("text")
645
+ .attr("class", "bar-value-label")
646
+ .attr("x", item.x + barW / 2)
647
+ .attr("y", Math.max(plot.y + 10, y - 5))
648
+ .attr("text-anchor", "middle")
649
+ .style("font-family", fontFamily)
650
+ .style("font-size", `${labelSize}px`)
651
+ .style("font-weight", 800)
652
+ .style("fill", item.labelColor)
653
+ .text(formatValue(value));
654
+ });
655
+ const xText = String(xValue);
656
+ const compactTimeLabel = /^\d{4}(-\d{2})?$/.test(xText) || timeKey(xText) !== null;
657
+ const tileMaxW = compactTimeLabel ? 44 : Math.min(78, band * 0.94);
658
+ const tileW = clamp(band * 0.92, 22, tileMaxW);
659
+ const tileH = clamp(height * 0.044, 18, 26);
660
+ const tileX = center - tileW / 2;
661
+ const tileY = panel.y + panel.h - tileH - 7;
662
+ svg.append("rect")
663
+ .attr("class", "x-label-tile")
664
+ .attr("data-reference-relationship", "centered-on-band-contained-label")
665
+ .attr("data-full-label", xText)
666
+ .attr("x", tileX)
667
+ .attr("y", tileY)
668
+ .attr("width", tileW)
669
+ .attr("height", tileH)
670
+ .attr("rx", 3)
671
+ .attr("ry", 3)
672
+ .attr("fill", greenDark)
673
+ .attr("stroke", "#0a4a2f")
674
+ .attr("stroke-width", 0.7);
675
+ const xSize = fitFont(xText, tileW - 6, clamp(height * 0.020, 9, 13), 6, 800, fontFamily);
676
+ const shortLabel = xText.length <= 8;
677
+ const fittedXText = (compactTimeLabel || shortLabel) ? xText : ellipsizeText(xText, tileW - 7, xSize, 800, fontFamily);
678
+ const xLabel = svg.append("text")
679
+ .attr("class", "x-label")
680
+ .attr("data-full-label", xText)
681
+ .attr("x", center)
682
+ .attr("y", tileY + tileH / 2 + 1)
683
+ .attr("text-anchor", "middle")
684
+ .attr("dominant-baseline", "middle")
685
+ .style("font-family", fontFamily)
686
+ .style("font-size", `${xSize}px`)
687
+ .style("font-weight", 800)
688
+ .style("fill", "#ffffff")
689
+ .text(fittedXText);
690
+ xLabel.append("title").text(xText);
691
+ });
692
+
693
+ return svg.node();
694
+ }
695
+
696
+
697
+ // ---------- 5. 创建SVG容器 ----------
698
+ const svg = d3.select(containerSelector)
699
+ .append("svg")
700
+ .attr("width", "100%")
701
+ .attr("height", height)
702
+ .attr("viewBox", `0 0 ${width} ${height}`)
703
+ .attr("style", "max-width: 100%; height: auto;")
704
+ .attr("xmlns", "http://www.w3.org/2000/svg")
705
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
706
+
707
+ // ---------- 6. 创建视觉效果 (Patterns for Sketch Style) ----------
708
+ const defs = svg.append("defs");
709
+
710
+ // --- Sketch Pattern for Left Bars ---
711
+ const patternLeft = defs.append("pattern")
712
+ .attr("id", "pattern-sketch-left")
713
+ .attr("width", 8) // Pattern tile size
714
+ .attr("height", 8)
715
+ .attr("patternUnits", "userSpaceOnUse")
716
+ .attr("patternTransform", "rotate(45)"); // Rotate lines
717
+
718
+ // Background color for the pattern tile
719
+ patternLeft.append("rect")
720
+ .attr("width", 8)
721
+ .attr("height", 8)
722
+ .attr("fill", leftGroupColor);
723
+
724
+ // Sketch lines (white in example image)
725
+ patternLeft.append("path")
726
+ .attr("d", "M -1,2 l 10,0 M -1,5 l 10,0 M -1,8 l 10,0") // Horizontal lines, rotation makes them diagonal
727
+ .attr("stroke", "#FFFFFF") // White lines like example
728
+ .attr("stroke-width", 0.8); // Thinner lines
729
+
730
+ // --- Sketch Pattern for Right Bars ---
731
+ const patternRight = defs.append("pattern")
732
+ .attr("id", "pattern-sketch-right")
733
+ .attr("width", 8)
734
+ .attr("height", 8)
735
+ .attr("patternUnits", "userSpaceOnUse")
736
+ .attr("patternTransform", "rotate(45)");
737
+
738
+ patternRight.append("rect")
739
+ .attr("width", 8)
740
+ .attr("height", 8)
741
+ .attr("fill", rightGroupColor);
742
+
743
+ patternRight.append("path")
744
+ .attr("d", "M -1,2 l 10,0 M -1,5 l 10,0 M -1,8 l 10,0")
745
+ .attr("stroke", "#FFFFFF")
746
+ .attr("stroke-width", 0.8);
747
+
748
+ // *** 添加: 新的 Sketch Pattern for Label Backgrounds ***
749
+ const patternLabelSketch = defs.append("pattern")
750
+ .attr("id", "pattern-label-sketch") // 新 ID
751
+ .attr("width", 8) // 可以与 bar pattern 保持一致或调整
752
+ .attr("height", 8)
753
+ .attr("patternUnits", "userSpaceOnUse")
754
+ .attr("patternTransform", "rotate(45)"); // 保持斜线
755
+
756
+ // 添加白色背景矩形
757
+ patternLabelSketch.append("rect")
758
+ .attr("width", 8)
759
+ .attr("height", 8)
760
+ .attr("fill", "#FFFFFF"); // 白色背景
761
+
762
+ // 添加灰色斜线
763
+ patternLabelSketch.append("path")
764
+ .attr("d", "M -1,2 l 10,0 M -1,5 l 10,0 M -1,8 l 10,0") // 与 bar pattern 线条一致
765
+ .attr("stroke", "#CCCCCC") // *** 修改: 灰色线条 ***
766
+ .attr("stroke-width", 0.6); // 可以调整线条粗细
767
+
768
+ // Shadow Filter (if enabled)
769
+ if (variables.has_shadow) {
770
+ // ... (shadow filter code remains the same as original)
771
+ const filter = defs.append("filter")
772
+ .attr("id", "shadow")
773
+ .attr("filterUnits", "userSpaceOnUse")
774
+ .attr("width", "200%")
775
+ .attr("height", "200%");
776
+
777
+ filter.append("feGaussianBlur")
778
+ .attr("in", "SourceAlpha")
779
+ .attr("stdDeviation", 3);
780
+
781
+ filter.append("feOffset")
782
+ .attr("dx", 2)
783
+ .attr("dy", 2)
784
+ .attr("result", "offsetblur");
785
+
786
+ const feMerge = filter.append("feMerge");
787
+ feMerge.append("feMergeNode");
788
+ feMerge.append("feMergeNode").attr("in", "SourceGraphic");
789
+ }
790
+
791
+ // ---------- 7. 创建图表区域 ----------
792
+ const chart = svg.append("g")
793
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
794
+
795
+ // ---------- 8. 创建比例尺 ----------
796
+ const xScale = d3.scaleBand()
797
+ .domain(xValues)
798
+ .range([0, innerWidth])
799
+ .padding(0.2);
800
+
801
+ const groupScale = d3.scaleBand()
802
+ .domain([0, 1]) // Left (0) and Right (1) bars within a group
803
+ .range([0, xScale.bandwidth()])
804
+ .padding(variables.has_spacing ? 0.2 : 0.1); // Adjust spacing within group
805
+
806
+ const dataMax = d3.max(useData, d => +d[yField]) || 100;
807
+ const yScale = d3.scaleLinear()
808
+ .domain([0, dataMax * 1.1]) // Add some top padding for labels
809
+ .range([innerHeight, 0]);
810
+
811
+ const barWidth = groupScale.bandwidth();
812
+
813
+ // ---------- 9. 文本格式化 ----------
814
+ // 添加数值格式化函数
815
+
816
+ // 完整格式化函数(包含单位)
817
+ const formatValueWithUnit = (value) => `${chartUtils.format.autoText(value)}${yUnit}`;
818
+
819
+ // 计算动态文本大小的函数
820
+ const calculateFontSize = (text, maxWidth, baseSize = 12) => {
821
+ // 估算每个字符的平均宽度 (假设为baseSize的60%)
822
+ const avgCharWidth = baseSize * 0.6;
823
+ // 计算文本的估计宽度
824
+ const textWidth = text.length * avgCharWidth;
825
+ // 如果文本宽度小于最大宽度,返回基础大小
826
+ if (textWidth < maxWidth) {
827
+ return baseSize;
828
+ }
829
+ // 否则,按比例缩小字体大小
830
+ return Math.max(10, Math.floor(baseSize * (maxWidth / textWidth)));
831
+ };
832
+ function wrapText(text, str, width, lineHeight) {
833
+ const words = str.split(/\s+/).reverse();
834
+ let word;
835
+ let line = [];
836
+ let lineNumber = 0;
837
+ const y = text.attr("y");
838
+ const dy = parseFloat(text.attr("dy") || 0);
839
+
840
+ // 先清空文本
841
+ text.text(null);
842
+
843
+ // 处理文本
844
+ let tspans = [];
845
+
846
+ // 如果没有空格可分割,按字符分割
847
+ if (words.length <= 1) {
848
+ const chars = str.split('');
849
+ let currentLine = '';
850
+
851
+ for (let i = 0; i < chars.length; i++) {
852
+ currentLine += chars[i];
853
+
854
+ // 测量候选文本宽度
855
+ const isOverflow = chartUtils.text.measure(null, currentLine, {
856
+ fontFamily: text.style("font-family"),
857
+ fontSize: parseFloat(text.style("font-size")),
858
+ fontWeight: text.style("font-weight")
859
+ }).width > width;
860
+
861
+ if (isOverflow && currentLine.length > 1) {
862
+ // 当前行过长,回退一个字符并换行
863
+ currentLine = currentLine.slice(0, -1);
864
+
865
+ // 添加到tspans数组
866
+ tspans.push(currentLine);
867
+
868
+ // 重新开始下一行
869
+ currentLine = chars[i];
870
+ lineNumber++;
871
+ }
872
+ }
873
+
874
+ // 添加最后一行
875
+ if (currentLine.length > 0) {
876
+ tspans.push(currentLine);
877
+ }
878
+ } else {
879
+ // 处理有空格的文本
880
+ let currentLine = [];
881
+
882
+ while (word = words.pop()) {
883
+ currentLine.push(word);
884
+
885
+ // 测量候选文本宽度
886
+ const isOverflow = chartUtils.text.measure(null, currentLine.join(" "), {
887
+ fontFamily: text.style("font-family"),
888
+ fontSize: parseFloat(text.style("font-size")),
889
+ fontWeight: text.style("font-weight")
890
+ }).width > width;
891
+
892
+ if (isOverflow && currentLine.length > 1) {
893
+ // 回退一个词
894
+ currentLine.pop();
895
+
896
+ // 添加到tspans数组
897
+ tspans.push(currentLine.join(" "));
898
+
899
+ // 重新开始下一行
900
+ currentLine = [word];
901
+ lineNumber++;
902
+ }
903
+ }
904
+
905
+ // 添加最后一行
906
+ if (currentLine.length > 0) {
907
+ tspans.push(currentLine.join(" "));
908
+ }
909
+ }
910
+
911
+ // 计算总行数
912
+ const totalLines = tspans.length;
913
+
914
+ // 计算垂直居中的起始位置
915
+ // 对于单行文本,y位置保持不变
916
+ // 对于多行文本,需要向上偏移以保持垂直居中
917
+ let startY = y;
918
+ if (totalLines > 1) {
919
+ // 向上偏移半行距离 * (总行数-1)
920
+ startY = parseFloat(y) - (lineHeight * (totalLines - 1) / 2);
921
+ }
922
+
923
+ // 创建所有行的tspan元素
924
+ tspans.forEach((lineText, i) => {
925
+ text.append("tspan")
926
+ .attr("x", text.attr("x"))
927
+ .attr("y", startY)
928
+ .attr("dy", `${i * lineHeight}em`)
929
+ .text(lineText);
930
+ });
931
+ }
932
+
933
+ // ---------- 10. 创建X坐标轴标签 (新逻辑) ----------
934
+ const xAxisGroup = chart.append("g")
935
+ .attr("class", "x-axis")
936
+ .attr("transform", `translate(0, ${innerHeight})`);
937
+
938
+ // 计算标签最大宽度和统一字体大小 (同 chart_12)
939
+ const labelMaxWidth = xScale.bandwidth() * 1.1; // 允许稍微宽一点以便背景有空间
940
+ const longestLabel = xValues.reduce((a, b) => a.toString().length > b.toString().length ? a.toString() : b.toString(), "");
941
+ const baseFontSize = parseInt(typography.label.font_size);
942
+ const uniformFontSize = calculateFontSize(longestLabel, labelMaxWidth * 0.9, baseFontSize); // 计算字体时用稍小的宽度
943
+
944
+ // 定义手绘背景的内边距
945
+ const sketchPadding = 5;
946
+
947
+ // 绘制X轴标签组
948
+ xAxisGroup.selectAll(".x-label-group")
949
+ .data(xValues)
950
+ .enter()
951
+ .append("g")
952
+ .attr("class", "x-label-group")
953
+ .attr("transform", d => `translate(${xScale(d) + xScale.bandwidth() / 2}, 25)`) // Group 定位不变
954
+ .each(function(d) {
955
+ const group = d3.select(this);
956
+ const textContent = d.toString();
957
+
958
+ // 1. 创建文本元素
959
+ const text = group.append("text")
960
+ .attr("class", "x-label")
961
+ .attr("x", 0) // 相对于 group 居中
962
+ .attr("y", 0) // 相对于 group 垂直基线 (会被 wrapText 调整)
963
+ .attr("text-anchor", "middle")
964
+ .style("font-family", "'Comic Sans MS', cursive")
965
+ .style("font-size", `${uniformFontSize}px`) // 应用统一字体
966
+ .style("font-weight", typography.label.font_weight)
967
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
968
+ .text(textContent);
969
+
970
+ // 2. 检查宽度并应用换行 (同 chart_12)
971
+ // 注意: wrapText 需要在 chart_13 中可用
972
+ const textWidth = chartUtils.text.measure(null, textContent, {
973
+ fontFamily: "'Comic Sans MS', cursive",
974
+ fontSize: uniformFontSize,
975
+ fontWeight: typography.label.font_weight
976
+ }).width;
977
+ if (textWidth > labelMaxWidth) {
978
+ wrapText(text, textContent, labelMaxWidth, 1.1); // 1.1 是行高倍数
979
+ }
980
+
981
+ // 3. 根据最终文本行计算背景边界框
982
+ const renderedLines = text.selectAll("tspan").nodes().map(node => d3.select(node).text());
983
+ const labelLines = renderedLines.length ? renderedLines : [text.text()];
984
+ const measureOptions = {
985
+ fontFamily: "'Comic Sans MS', cursive",
986
+ fontSize: uniformFontSize,
987
+ fontWeight: typography.label.font_weight
988
+ };
989
+ const bboxWidth = Math.max(...labelLines.map(line => chartUtils.text.measure(null, line, measureOptions).width), 0);
990
+ const bboxHeight = labelLines.length > 1
991
+ ? uniformFontSize + (labelLines.length - 1) * uniformFontSize * 1.1
992
+ : chartUtils.text.measure(null, labelLines[0], measureOptions).height;
993
+ const bbox = {
994
+ width: bboxWidth,
995
+ height: bboxHeight,
996
+ x: -bboxWidth / 2,
997
+ y: labelLines.length > 1 ? -bboxHeight / 2 : -bboxHeight * 0.8
998
+ };
999
+
1000
+ // 4. 计算手绘背景尺寸和位置
1001
+ const bgWidth = bbox.width + 2 * sketchPadding;
1002
+ const bgHeight = bbox.height + 1.5 * sketchPadding;
1003
+ // bgX/bgY 相对于 group 的 (0,0) 点计算
1004
+ const bgX = bbox.x - sketchPadding;
1005
+ const bgY = bbox.y - sketchPadding * 0.75; // 调整 Y 使背景居中
1006
+
1007
+ // 5. 插入手绘背景矩形 (在文本之前)
1008
+ group.insert("rect", "text")
1009
+ .attr("class", "label-background")
1010
+ .attr("x", bgX)
1011
+ .attr("y", bgY)
1012
+ .attr("width", bgWidth)
1013
+ .attr("height", bgHeight)
1014
+ .attr("fill", "url(#pattern-label-sketch)")
1015
+ .attr("stroke", "#AAAAAA")
1016
+ .attr("stroke-width", 0.5);
1017
+ });
1018
+
1019
+ // ---------- 11. 绘制图例 ----------
1020
+ const legendItems = legendData.map(item => ({
1021
+ label: item.key,
1022
+ color: item.pattern || item.color,
1023
+ }));
1024
+ chartUtils.legend.centered(svg, legendItems, {}, width / 2, 30, {
1025
+ markerShape: "rect",
1026
+ markerSize: 15,
1027
+ labelGap: 5,
1028
+ itemGap: 20,
1029
+ fontSize: 12,
1030
+ fontFamily: typography.label.font_family || "'Comic Sans MS', cursive",
1031
+ textColor: colorResolver.text({ fallback: "#333333" }).value,
1032
+ });
1033
+
1034
+ // ---------- 12. 绘制条形图和数值标签 (带手绘背景) ----------
1035
+ xValues.forEach(xValue => {
1036
+ const xData = useData.filter(d => d[xField] === xValue);
1037
+ const leftBarData = xData.find(d => d[groupField] === leftBarGroup);
1038
+ const rightBarData = xData.find(d => d[groupField] === rightBarGroup);
1039
+
1040
+ const baseFontSizeValue = parseInt(typography.label.font_size); // 数值标签基础大小
1041
+ const valueLabelMaxWidth = barWidth * 1.1; // 数值标签最大宽度
1042
+
1043
+ // --- Left Bar ---
1044
+ if (leftBarData) {
1045
+ const leftValue = +leftBarData[yField];
1046
+ if (isNaN(leftValue)) return; // Skip if value is not a number
1047
+
1048
+ const leftBarHeight = innerHeight - yScale(leftValue);
1049
+ const leftBarY = yScale(leftValue);
1050
+ const leftBarX = xScale(xValue) + groupScale(0); // Use groupScale for position
1051
+
1052
+ // Draw Bar
1053
+ chart.append("rect")
1054
+ .attr("class", "bar left-bar")
1055
+ .attr("x", leftBarX)
1056
+ .attr("y", leftBarY)
1057
+ .attr("width", barWidth)
1058
+ .attr("height", Math.max(0, leftBarHeight)) // Ensure height is not negative
1059
+ .attr("fill", "url(#pattern-sketch-left)") // Use sketch pattern
1060
+ .attr("stroke", variables.has_stroke ? "#555" : "none")
1061
+ .attr("stroke-width", variables.has_stroke ? 1 : 0)
1062
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "none");
1063
+
1064
+ // --- 修改: 绘制带背景的数值标签 ---
1065
+ if (leftBarHeight > 5) {
1066
+ const labelX = leftBarX + barWidth / 2;
1067
+ const labelY = leftBarY - 8;
1068
+ const textContent = formatValueWithUnit(leftValue);
1069
+
1070
+ // 使用临时 group 来绘制标签和背景
1071
+ const labelGroup = chart.append("g")
1072
+ .attr("class", "value-label-group left-value-label");
1073
+
1074
+ // 复用字体大小调整逻辑 (可以封装成函数)
1075
+ let currentFontSize = baseFontSizeValue;
1076
+ let bbox = chartUtils.text.measure(labelGroup, textContent, {
1077
+ fontFamily: "'Comic Sans MS', cursive",
1078
+ fontSize: baseFontSizeValue,
1079
+ fontWeight: "bold"
1080
+ });
1081
+ if (bbox.width > valueLabelMaxWidth - 2 * sketchPadding) {
1082
+ currentFontSize = Math.max(8, baseFontSizeValue * ((valueLabelMaxWidth - 2 * sketchPadding) / bbox.width));
1083
+ }
1084
+
1085
+ // 获取最终尺寸
1086
+ bbox = chartUtils.text.measure(labelGroup, textContent, {
1087
+ fontFamily: "'Comic Sans MS', cursive",
1088
+ fontSize: currentFontSize,
1089
+ fontWeight: "bold"
1090
+ });
1091
+
1092
+ const bgWidth = bbox.width + 2 * sketchPadding;
1093
+ const bgHeight = bbox.height + 1.5 * sketchPadding;
1094
+ const bgX = labelX - bgWidth / 2;
1095
+ const bgY = labelY - bgHeight + sketchPadding / 2;
1096
+
1097
+ labelGroup.append("rect")
1098
+ .attr("x", bgX)
1099
+ .attr("y", bgY)
1100
+ .attr("width", bgWidth)
1101
+ .attr("height", bgHeight)
1102
+ .attr("fill", "url(#pattern-label-sketch)")
1103
+ .attr("stroke", "#AAAAAA")
1104
+ .attr("stroke-width", 0.5);
1105
+
1106
+ labelGroup.append("text")
1107
+ .attr("class", "bar-label")
1108
+ .attr("x", labelX)
1109
+ .attr("y", labelY)
1110
+ .attr("text-anchor", "middle")
1111
+ .style("font-family", "'Comic Sans MS', cursive")
1112
+ .style("font-size", `${currentFontSize}px`)
1113
+ .style("font-weight", "bold")
1114
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
1115
+ .attr("dominant-baseline", "alphabetic")
1116
+ .text(textContent);
1117
+ }
1118
+ }
1119
+
1120
+ // --- Right Bar --- (类似地修改数值标签)
1121
+ if (rightBarData) {
1122
+ const rightValue = +rightBarData[yField];
1123
+ if (isNaN(rightValue)) return; // Skip if value is not a number
1124
+
1125
+ const rightBarHeight = innerHeight - yScale(rightValue);
1126
+ const rightBarY = yScale(rightValue);
1127
+ const rightBarX = xScale(xValue) + groupScale(1); // Use groupScale for position
1128
+
1129
+ // Draw Bar
1130
+ chart.append("rect")
1131
+ .attr("class", "bar right-bar")
1132
+ .attr("x", rightBarX)
1133
+ .attr("y", rightBarY)
1134
+ .attr("width", barWidth)
1135
+ .attr("height", Math.max(0, rightBarHeight)) // Ensure height is not negative
1136
+ .attr("fill", "url(#pattern-sketch-right)") // Use sketch pattern
1137
+ .attr("stroke", variables.has_stroke ? "#555" : "none")
1138
+ .attr("stroke-width", variables.has_stroke ? 1 : 0)
1139
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "none");
1140
+
1141
+ // --- 修改: 绘制带背景的数值标签 ---
1142
+ if (rightBarHeight > 5) {
1143
+ const labelX = rightBarX + barWidth / 2;
1144
+ const labelY = rightBarY - 8;
1145
+ const textContent = formatValueWithUnit(rightValue);
1146
+
1147
+ const labelGroup = chart.append("g")
1148
+ .attr("class", "value-label-group right-value-label");
1149
+
1150
+ // 复用字体大小调整逻辑
1151
+ let currentFontSize = baseFontSizeValue;
1152
+ let bbox = chartUtils.text.measure(labelGroup, textContent, {
1153
+ fontFamily: "'Comic Sans MS', cursive",
1154
+ fontSize: baseFontSizeValue,
1155
+ fontWeight: "bold"
1156
+ });
1157
+ if (bbox.width > valueLabelMaxWidth - 2 * sketchPadding) {
1158
+ currentFontSize = Math.max(8, baseFontSizeValue * ((valueLabelMaxWidth - 2 * sketchPadding) / bbox.width));
1159
+ }
1160
+
1161
+ bbox = chartUtils.text.measure(labelGroup, textContent, {
1162
+ fontFamily: "'Comic Sans MS', cursive",
1163
+ fontSize: currentFontSize,
1164
+ fontWeight: "bold"
1165
+ });
1166
+
1167
+ const bgWidth = bbox.width + 2 * sketchPadding;
1168
+ const bgHeight = bbox.height + 1.5 * sketchPadding;
1169
+ const bgX = labelX - bgWidth / 2;
1170
+ const bgY = labelY - bgHeight + sketchPadding / 2;
1171
+
1172
+ labelGroup.append("rect")
1173
+ .attr("x", bgX)
1174
+ .attr("y", bgY)
1175
+ .attr("width", bgWidth)
1176
+ .attr("height", bgHeight)
1177
+ .attr("fill", "url(#pattern-label-sketch)")
1178
+ .attr("stroke", "#AAAAAA")
1179
+ .attr("stroke-width", 0.5);
1180
+
1181
+ labelGroup.append("text")
1182
+ .attr("class", "bar-label")
1183
+ .attr("x", labelX)
1184
+ .attr("y", labelY)
1185
+ .attr("text-anchor", "middle")
1186
+ .style("font-family", "'Comic Sans MS', cursive")
1187
+ .style("font-size", `${currentFontSize}px`)
1188
+ .style("font-weight", "bold")
1189
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
1190
+ .attr("dominant-baseline", "alphabetic")
1191
+ .text(textContent);
1192
+ }
1193
+ }
1194
+ });
1195
+
1196
+ // ---------- 14. 返回SVG节点 ----------
1197
+ return svg.node();
1198
+ }
modules/chart_engine/template/d3-js/bar_chart/vertical_pictorial_percentage_bar_chart_01.js ADDED
@@ -0,0 +1,444 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Pictorial Percentage Bar Chart",
5
+ "chart_name": "vertical_pictorial_percentage_bar_chart_01",
6
+ "is_composite": false,
7
+ "required_fields": ["x", "y"],
8
+ "required_fields_type": [["categorical"], ["numerical"]],
9
+ "required_fields_range": [[2, 10], [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
27
+ function makeChart(containerSelector, data) {
28
+ const colorResolver = chartUtils.color.resolver(data);
29
+ // ---------- 1. 数据准备 ----------
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: "28px", font_weight: 700 },
36
+ label: { font_family: "Arial", font_size: "16px", font_weight: 500 },
37
+ description: { font_family: "Arial", font_size: "16px", font_weight: 500 },
38
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: 400 }
39
+ };
40
+ const colors = jsonData.colors || {
41
+ text_color: "#000000",
42
+ other: { primary: "#4682B4", secondary: "#FF7F50" }
43
+ };
44
+ const images = jsonData.images || { field: {}, other: {} }; // 图像(国旗等)
45
+ const dataColumns = chartUtils.schema.columns(jsonData); // 使用data_columns
46
+
47
+
48
+ // 清空容器
49
+ d3.select(containerSelector).html("");
50
+
51
+ // ---------- 2. 提取字段名称 ----------
52
+ let xField, yField;
53
+
54
+ // 安全地提取字段名称
55
+ const xChannel = chartUtils.schema.channel(jsonData, "x");
56
+ const yChannel = chartUtils.schema.channel(jsonData, "y");
57
+
58
+ xField = xChannel.key;
59
+ yField = yChannel.key;
60
+
61
+ // 获取字段单位(如果存在)
62
+ let dimensionUnit = "";
63
+ let valueUnit = ""; // 默认为百分比
64
+
65
+ if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
66
+ dimensionUnit = chartUtils.schema.channel(jsonData, "x").unit;
67
+ }
68
+
69
+ if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
70
+ valueUnit = chartUtils.schema.channel(jsonData, "y").unit;
71
+ }
72
+ // 添加数值格式化函数
73
+ // ---------- 3. 数据处理 ----------
74
+ // 按照y值降序排序数据
75
+ const sortedData = [...chartData].sort((a, b) => b[yField] - a[yField]);
76
+
77
+ // ---------- 4. 尺寸和布局设置 ----------
78
+ const width = variables.width || 800;
79
+ const height = variables.height || 600;
80
+
81
+ // 边距: 上, 右, 下, 左
82
+ const margin = {
83
+ top: 120, // 减少顶部空间
84
+ right: 30,
85
+ bottom: 80, // 国旗和标签的空间
86
+ left: 30
87
+ };
88
+
89
+ // 计算实际绘图区域尺寸
90
+ const innerWidth = width - margin.left - margin.right;
91
+ const innerHeight = height - margin.top - margin.bottom;
92
+
93
+ // ---------- 5. 创建SVG容器 ----------
94
+ const svg = d3.select(containerSelector)
95
+ .append("svg")
96
+ .attr("width", width)
97
+ .attr("height", height)
98
+ .attr("viewBox", `0 0 ${width} ${height}`)
99
+ .attr("style", "max-width: 100%; height: auto;")
100
+ .attr("xmlns", "http://www.w3.org/2000/svg")
101
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
102
+
103
+
104
+ // 创建主图表组
105
+ const chart = svg.append("g")
106
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
107
+
108
+ // ---------- 6. 创建比例尺 ----------
109
+ // X轴比例尺(瓶子之间的间隔)
110
+ const xScale = d3.scaleBand()
111
+ .domain(sortedData.map(d => d[xField]))
112
+ .range([0, innerWidth])
113
+ .padding(0.2);
114
+
115
+ // Y轴比例尺(瓶子的高度)
116
+ const yMax = d3.max(sortedData, d => +d[yField]);
117
+ const yScale = d3.scaleLinear()
118
+ .domain([0, yMax * 1.1]) // 添加10%的空间
119
+ .range([innerHeight, 0]);
120
+
121
+ // ---------- 7. 创建瓶子和蒙版 ----------
122
+ // 定义瓶子的宽度和高度 (不修改这部分)
123
+ const bottleWidth = xScale.bandwidth();
124
+ const bottleHeight = Math.max(bottleWidth * 3, innerHeight * 0.5);
125
+
126
+ // 获取瓶子的主要颜色
127
+ const bottleColor = colorResolver.other("primary", { fallback: "#4CAF50" }).value;
128
+
129
+ // 定义国旗和文本的空间
130
+ const flagTextHeight = 50;
131
+
132
+ // 计算瓶子底部应在的位置 - 让瓶子底部接近国旗
133
+ const bottleBottomY = innerHeight - 10; // 瓶子底部位置,紧贴国旗上方
134
+ const bottleTopY = bottleBottomY - bottleHeight; // 瓶子顶部位置
135
+
136
+ // 创建一个数组来存储所有的值标签元素引用
137
+ const valueLabels = [];
138
+
139
+ const bottleImageSVG = `<svg viewBox="0 0 200 500" xmlns="http://www.w3.org/2000/svg">
140
+ <path d="M60,280 L60,450 Q100,480 140,450 L140,280 Z" fill="#8BC34A"/>
141
+
142
+ <path d="M60,230 L60,280 L140,280 L140,230 Z" fill="#FDD835"/>
143
+ <circle cx="100" cy="255" r="25" fill="#FDD835"/>
144
+
145
+ <path d="M75,50 Q100,40 125,50 L125,70 Q100,80 75,70 Z" fill="#555555"/>
146
+ <path d="M80,70 L60,230 L140,230 L120,70 Z" fill="#555555"/>
147
+
148
+ <path d="M80,70 Q100,60 120,70 L120,70 Q100,80 80,70 Z" fill="#555555"/>
149
+ </svg>`;
150
+
151
+ // 辅助函数:将字符串转换为 base64
152
+ function svgToBase64(svg) {
153
+ // 移除所有换行符和多余的空格
154
+ const cleanSvg = svg.replace(/\s+/g, ' ').trim();
155
+ // 使用 encodeURIComponent 处理 Unicode 字符
156
+ const encoded = encodeURIComponent(cleanSvg);
157
+ return 'data:image/svg+xml;base64,' + btoa(unescape(encoded));
158
+ }
159
+
160
+ // 将 SVG 转换为 base64
161
+ const bottleImageBase64 = svgToBase64(bottleImageSVG);
162
+
163
+ // 从SVG的viewBox中获取实际比例
164
+ const svgViewBox = bottleImageSVG.match(/viewBox="([^"]+)"/)[1].split(' ').map(Number);
165
+ const svgWidth = svgViewBox[2];
166
+ const svgHeight = svgViewBox[3];
167
+ const svgAspectRatio = svgHeight / svgWidth;
168
+
169
+ // 计算基础间距(在 makeChart 函数开始处添加)
170
+ const baseBottleWidth = xScale.bandwidth();
171
+ const baseBottleHeight = baseBottleWidth * svgAspectRatio;
172
+ const spacingAfterBottle = baseBottleWidth * 0.1; // 瓶子和图标之间的间距
173
+ const spacingBetweenIconLabel = baseBottleWidth * 0.15; // 图标和标签之间的间距
174
+
175
+ // 开始添加瓶子图像和蒙版
176
+ chart.selectAll(".bottle-group")
177
+ .data(sortedData)
178
+ .enter()
179
+ .append("g")
180
+ .attr("class", "bottle-group")
181
+ .attr("transform", d => `translate(${xScale(d[xField])}, ${bottleTopY})`)
182
+ .each(function(d, i) {
183
+ const group = d3.select(this);
184
+ const bottleWidth = xScale.bandwidth();
185
+ // 根据SVG的实际比例计算瓶子高度
186
+ const bottleHeight = bottleWidth * svgAspectRatio;
187
+ // 判断是否为最大值
188
+ const isMaxValue = +d[yField] === yMax;
189
+ const bottlePercentage = isMaxValue ? 1 : Math.max(0, (+d[yField] / yMax));
190
+
191
+ // 定义裁剪路径
192
+ const clipId = `clip-${i}`;
193
+ group.append("clipPath")
194
+ .attr("id", clipId)
195
+ .append("rect")
196
+ .attr("x", 0)
197
+ .attr("y", bottleHeight * (1 - bottlePercentage))
198
+ .attr("width", bottleWidth)
199
+ .attr("height", bottleHeight * bottlePercentage);
200
+
201
+ // 添加半透明的背景瓶子
202
+ group.append("image")
203
+ .attr("xlink:href", bottleImageBase64)
204
+ .attr("width", bottleWidth)
205
+ .attr("height", bottleHeight)
206
+ .attr("preserveAspectRatio", "xMidYMid meet")
207
+ .style("opacity", 0.15);
208
+
209
+ // 添加裁剪后的前景瓶子
210
+ group.append("image")
211
+ .attr("xlink:href", bottleImageBase64)
212
+ .attr("width", bottleWidth)
213
+ .attr("height", bottleHeight)
214
+ .attr("preserveAspectRatio", "xMidYMid meet")
215
+ .attr("clip-path", `url(#${clipId})`);
216
+
217
+ // 计算标签方块的大小和位置
218
+ const squareSize = bottleWidth * 0.6;
219
+ const squareX = (bottleWidth - squareSize) / 2;
220
+ const squareY = bottleHeight * (2/3) - (squareSize / 2);
221
+
222
+ // 添加正方形背景
223
+ group.append("rect")
224
+ .attr("class", "value-bg")
225
+ .attr("x", squareX)
226
+ .attr("y", squareY)
227
+ .attr("width", squareSize)
228
+ .attr("height", squareSize)
229
+ .attr("fill", "#f5f5f5")
230
+ .attr("opacity", 0.8)
231
+ .attr("rx", 5)
232
+ .attr("ry", 5);
233
+
234
+ // 格式化值
235
+
236
+ const formattedValue = `${chartUtils.format.autoText(+d[yField])}${valueUnit}`;
237
+ // 我们将在下面计算实际字体大小,先使用基础大小创建文本元素
238
+ const baseValueFontSize = 16;
239
+
240
+ // 添加数值文本但先不设置字体大小
241
+ const valueText = group.append("text")
242
+ .attr("class", "value-label")
243
+ .attr("x", bottleWidth / 2)
244
+ .attr("y", squareY + (squareSize * 0.6)) // 先设置一个位置,稍后会调整
245
+ .attr("text-anchor", "middle")
246
+ .style("font-family", typography.label.font_family)
247
+ .style("font-weight", "bold")
248
+ .style("fill", "#333")
249
+ .style("font-size", `${baseValueFontSize}px`) // 先设置基础字体大小
250
+ .text(formattedValue);
251
+
252
+ // 将值标签添加到数组中,以便后续统一调整
253
+ valueLabels.push({
254
+ element: valueText,
255
+ value: formattedValue,
256
+ squareSize: squareSize
257
+ });
258
+ });
259
+
260
+ // ---------- 8. 添加国旗和国家名称 (修改后) ----------
261
+ const baseLabelFontSize = 14; // 基础字体大小
262
+ // 文本换行辅助函数 (添加 alignment 参数)
263
+ function wrapText(text, str, width, lineHeight = 1.1, alignment = 'middle') {
264
+ const words = str.split(/\s+/).reverse(); // 按空格分割单词
265
+ let word;
266
+ let line = [];
267
+ let lineNumber = 0;
268
+ const initialY = parseFloat(text.attr("y")); // 获取原始y坐标
269
+ const initialX = parseFloat(text.attr("x")); // 获取原始x坐标
270
+ const actualFontSize = parseFloat(text.style("font-size")); // 获取实际应用的字体大小
271
+
272
+ text.text(null); // 清空现有文本
273
+
274
+ let tspans = []; // 存储最终要渲染的行
275
+
276
+ // 优先按单词换行
277
+ if (words.length > 1) {
278
+ let currentLine = [];
279
+ while (word = words.pop()) {
280
+ currentLine.push(word);
281
+ const isOverflow = chartUtils.text.measure(null, currentLine.join(" "), {
282
+ fontFamily: text.style("font-family"),
283
+ fontSize: parseFloat(text.style("font-size")),
284
+ fontWeight: text.style("font-weight")
285
+ }).width > width;
286
+
287
+ if (isOverflow && currentLine.length > 1) {
288
+ currentLine.pop(); // 回退一个词
289
+ tspans.push(currentLine.join(" ")); // 添加完成的行
290
+ currentLine = [word]; // 新行以当前词开始
291
+ lineNumber++;
292
+ }
293
+ }
294
+ // 添加最后一行
295
+ if (currentLine.length > 0) {
296
+ tspans.push(currentLine.join(" "));
297
+ }
298
+ } else { // 如果没有空格或只有一个词,则按字符换行
299
+ const chars = str.split('');
300
+ let currentLine = '';
301
+ for (let i = 0; i < chars.length; i++) {
302
+ const nextLine = currentLine + chars[i];
303
+ const isOverflow = chartUtils.text.measure(null, nextLine, {
304
+ fontFamily: text.style("font-family"),
305
+ fontSize: parseFloat(text.style("font-size")),
306
+ fontWeight: text.style("font-weight")
307
+ }).width > width;
308
+
309
+ if (isOverflow && currentLine.length > 0) { // 如果加了新字符就超长了,并且当前行不为空
310
+ tspans.push(currentLine); // 添加当前行
311
+ currentLine = chars[i]; // 新行从这个字符开始
312
+ lineNumber++;
313
+ } else {
314
+ currentLine = nextLine; // 没超长就继续加字符
315
+ }
316
+ }
317
+ // 添加最后一行
318
+ if (currentLine.length > 0) {
319
+ tspans.push(currentLine);
320
+ }
321
+ }
322
+
323
+ // 计算总行数
324
+ const totalLines = tspans.length;
325
+ let startDy = 0;
326
+
327
+ // 根据对齐方式计算起始偏移
328
+ if (alignment === 'middle') {
329
+ // 垂直居中:向上移动半行*(总行数-1)
330
+ startDy = -( (totalLines - 1) * lineHeight / 2);
331
+ } else if (alignment === 'bottom') {
332
+ // 底部对齐:计算总高度,向上移动 总高度 - 单行高度(近似)
333
+ // 注意:em单位是相对于字体大小的,这里用 lineHeight * actualFontSize 近似计算像素高度
334
+ const totalHeightEm = totalLines * lineHeight;
335
+ startDy = -(totalHeightEm - lineHeight); // 将底部对齐到原始y
336
+ }
337
+ // 如果是 'top' 对齐,startDy 保持为 0,即第一行基线在原始y位置
338
+ // 其他对齐方式(如 'top')可以保持 startDy 为 0
339
+
340
+ // 创建所有行的tspan元素
341
+ tspans.forEach((lineText, i) => {
342
+ text.append("tspan")
343
+ .attr("x", initialX) // x坐标与父<text>相同
344
+ .attr("dy", (i === 0 ? startDy : lineHeight) + "em") // 第一行应用起始偏移,后续行应用行高
345
+ .text(lineText);
346
+ });
347
+ }
348
+ // 计算所有维度标签在基础字体大小下的宽度
349
+ const dimensionWidths = sortedData.map(d => chartUtils.text.measure(svg, d[xField], {
350
+ fontFamily: typography.label.font_family,
351
+ fontSize: baseLabelFontSize
352
+ }).width);
353
+
354
+ const maxAvailableWidth = xScale.bandwidth() * 1.2;
355
+
356
+ // 计算是否需要缩小字体以及缩放比例
357
+ const maxDimensionWidth = Math.max(...dimensionWidths);
358
+ const labelScaleFactor = maxDimensionWidth > maxAvailableWidth ? maxAvailableWidth / maxDimensionWidth : 1;
359
+
360
+ // 确定最终的统一字体大小(增大1.5倍)
361
+ const finalLabelFontSize = baseLabelFontSize * labelScaleFactor * 1.5;
362
+
363
+ // 开始添加国旗和标签组
364
+ chart.selectAll(".flag-label-group")
365
+ .data(sortedData)
366
+ .enter()
367
+ .append("g")
368
+ .attr("class", "flag-label-group")
369
+ // 根据实际瓶子高度调整位置
370
+ .attr("transform", d => `translate(${xScale(d[xField]) + baseBottleWidth / 2}, ${bottleTopY + baseBottleHeight + spacingAfterBottle})`)
371
+ .each(function(d, i) {
372
+ const group = d3.select(this);
373
+
374
+ // 计算图标尺寸
375
+ const iconWidth = baseBottleWidth;
376
+ const iconHeight = iconWidth * 0.75;
377
+
378
+ // 添加国旗(如果数据中提供了图片字段和对应的值)
379
+ if (images.field && images.field[d[xField]]) {
380
+ group.append("image")
381
+ .attr("xlink:href", images.field[d[xField]])
382
+ .attr("x", -iconWidth / 2)
383
+ .attr("y", 0)
384
+ .attr("width", iconWidth)
385
+ .attr("height", iconHeight)
386
+ .attr("preserveAspectRatio", "xMidYMid meet");
387
+ }
388
+
389
+ // 添加国家名称文本
390
+ const label = group.append("text")
391
+ .attr("class", "country-label")
392
+ .attr("x", 0)
393
+ .attr("y", iconHeight + spacingBetweenIconLabel)
394
+ .attr("text-anchor", "middle")
395
+ .style("font-family", typography.label.font_family)
396
+ .style("font-size", `${finalLabelFontSize}px`)
397
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
398
+ .text(d[xField]);
399
+
400
+ // 如果需要换行,重新应用换行
401
+ const labelMaxWidth = baseBottleWidth * 1.05;
402
+ if (chartUtils.text.measure(chart, d[xField], {
403
+ fontFamily: typography.label.font_family,
404
+ fontSize: finalLabelFontSize
405
+ }).width > labelMaxWidth) {
406
+ wrapText(label, d[xField].toString(), labelMaxWidth, 1.1, 'top');
407
+ }
408
+ });
409
+
410
+ // ---------- 9. 统一调整所有值标签的字体大小 ----------
411
+ // 创建临时文本元素来测量值标签文本宽度
412
+
413
+ const baseValueFontSize = 16;
414
+ // 计算所有值标签在基础字体大小下的实际宽度
415
+ const valueTextWidths = valueLabels.map(item => {
416
+ return chartUtils.text.measure(svg, item.value, {
417
+ fontFamily: typography.label.font_family,
418
+ fontSize: `${baseValueFontSize}px`,
419
+ fontWeight: "bold"
420
+ }).width;
421
+ });
422
+
423
+ // 计算每个标签所需的缩放比例(方形宽度 / 文本宽度)
424
+ const valueScaleFactors = valueLabels.map((item, i) => {
425
+ // 使用60%的方形宽度作为最大可用宽度
426
+ const maxWidth = item.squareSize * 1; // 留一些边距
427
+ return maxWidth / valueTextWidths[i];
428
+ });
429
+
430
+ // 取最小的缩放比例,确保所有值标签使用相同的字体大小
431
+ const minValueScaleFactor = Math.min(...valueScaleFactors, 1); // 不要放大,只缩小
432
+
433
+ // 计算最终的统一字体大小
434
+ const finalValueFontSize = Math.min(baseValueFontSize * minValueScaleFactor, baseValueFontSize);
435
+
436
+ // 应用统一的字体大小到所有值标签
437
+ valueLabels.forEach(item => {
438
+ item.element
439
+ .style("font-size", `${finalValueFontSize}px`);
440
+ });
441
+
442
+ // 返回SVG节点
443
+ return svg.node();
444
+ }
modules/chart_engine/template/d3-js/bar_chart/vertical_pictorial_percentage_bar_chart_02.js ADDED
@@ -0,0 +1,448 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Pictorial Percentage Bar Chart",
5
+ "chart_name": "vertical_pictorial_percentage_bar_chart_02",
6
+ "is_composite": false,
7
+ "required_fields": ["x", "y"],
8
+ "required_fields_type": [["categorical"], ["numerical"]],
9
+ "required_fields_range": [[2, 10], [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": "dark",
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
27
+ function makeChart(containerSelector, data) {
28
+ const colorResolver = chartUtils.color.resolver(data);
29
+ // ---------- 1. 数据准备 ----------
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: "28px", font_weight: 700 },
36
+ label: { font_family: "Arial", font_size: "16px", font_weight: 500 },
37
+ description: { font_family: "Arial", font_size: "16px", font_weight: 500 },
38
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: 400 }
39
+ };
40
+ const colors = jsonData.colors_dark || {
41
+ text_color: "#000000",
42
+ other: { primary: "#4682B4", secondary: "#FF7F50" }
43
+ };
44
+ const images = jsonData.images || { field: {}, other: {} }; // 图像(国旗等)
45
+ const dataColumns = chartUtils.schema.columns(jsonData); // 使用data_columns
46
+
47
+
48
+ // 清空容器
49
+ d3.select(containerSelector).html("");
50
+
51
+ // ---------- 2. 提取字段名称 ----------
52
+ let xField, yField;
53
+
54
+ // 安全地提取字段名称
55
+ const xChannel = chartUtils.schema.channel(jsonData, "x");
56
+ const yChannel = chartUtils.schema.channel(jsonData, "y");
57
+
58
+ xField = xChannel.key;
59
+ yField = yChannel.key;
60
+
61
+ // 获取字段单位(如果存在)
62
+ let dimensionUnit = "";
63
+ let valueUnit = ""; // 默认为百分比
64
+
65
+ if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
66
+ dimensionUnit = chartUtils.schema.channel(jsonData, "x").unit;
67
+ }
68
+
69
+ if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
70
+ valueUnit = chartUtils.schema.channel(jsonData, "y").unit;
71
+ }
72
+ // 添加数值格式化函数
73
+ // ---------- 3. 数据处理 ----------
74
+ // 按照y值降序排序数据
75
+ const sortedData = [...chartData].sort((a, b) => b[yField] - a[yField]);
76
+
77
+ // ---------- 4. 尺寸和布局设置 ----------
78
+ const width = variables.width || 800;
79
+ const height = variables.height || 600;
80
+
81
+ // 边距: 上, 右, 下, 左
82
+ const margin = {
83
+ top: 120, // 减少顶部空间
84
+ right: 30,
85
+ bottom: 80, // 国旗和标签的空间
86
+ left: 30
87
+ };
88
+
89
+ // 计算实际绘图区域尺寸
90
+ const innerWidth = width - margin.left - margin.right;
91
+ const innerHeight = height - margin.top - margin.bottom;
92
+
93
+ // ---------- 5. 创建SVG容器 ----------
94
+ const svg = d3.select(containerSelector)
95
+ .append("svg")
96
+ .attr("width", width)
97
+ .attr("height", height)
98
+ .attr("viewBox", `0 0 ${width} ${height}`)
99
+ .attr("style", "max-width: 100%; height: auto;")
100
+ .attr("xmlns", "http://www.w3.org/2000/svg")
101
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
102
+
103
+
104
+ // 创建主图表组
105
+ const chart = svg.append("g")
106
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
107
+
108
+ // ---------- 6. 创建比例尺 ----------
109
+ // X轴比例尺(瓶子之间的间隔)
110
+ const xScale = d3.scaleBand()
111
+ .domain(sortedData.map(d => d[xField]))
112
+ .range([0, innerWidth])
113
+ .padding(0.2);
114
+
115
+ // Y轴比例尺(瓶子的高度)
116
+ const yMax = d3.max(sortedData, d => +d[yField]);
117
+ const yScale = d3.scaleLinear()
118
+ .domain([0, yMax * 1.1]) // 添加10%的空间
119
+ .range([innerHeight, 0]);
120
+
121
+ // ---------- 7. 创建瓶子和蒙版 ----------
122
+ // 定义瓶子的宽度和高度 (不修改这部分)
123
+ const bottleWidth = xScale.bandwidth();
124
+ const bottleHeight = Math.max(bottleWidth * 3, innerHeight * 0.5);
125
+
126
+ // 获取瓶子的主要颜色
127
+ const bottleColor = colorResolver.other("primary", { fallback: "#4CAF50" }).value;
128
+
129
+ // 定义国旗和文本的空间
130
+ const flagTextHeight = 50;
131
+
132
+ // 计算瓶子底部应在的位置 - 让瓶子底部接近国旗
133
+ const bottleBottomY = innerHeight - 10; // 瓶子底部位置,紧贴国旗上方
134
+ const bottleTopY = bottleBottomY - bottleHeight; // 瓶子顶部位置
135
+
136
+ // 创建一个数组来存储所有的值标签元素引用
137
+ const valueLabels = [];
138
+ const bottleImageSVG = `<svg viewBox="0 0 200 500" xmlns="http://www.w3.org/2000/svg">
139
+ <!-- 瓶身主体 -->
140
+ <path d="M60,280 L60,450 Q100,480 140,450 L140,280 Z" fill="#2196F3" filter="url(#glow)"/>
141
+
142
+ <!-- 瓶颈过渡部分 -->
143
+ <path d="M60,230 L60,280 L140,280 L140,230 Z" fill="#64B5F6"/>
144
+ <circle cx="100" cy="255" r="25" fill="#64B5F6"/>
145
+
146
+ <!-- 瓶口 -->
147
+ <path d="M75,50 Q100,40 125,50 L125,70 Q100,80 75,70 Z" fill="#90CAF9"/>
148
+ <path d="M80,70 L60,230 L140,230 L120,70 Z" fill="#90CAF9"/>
149
+ <path d="M80,70 Q100,60 120,70 L120,70 Q100,80 80,70 Z" fill="#90CAF9"/>
150
+
151
+ <!-- 添加光泽效果 -->
152
+ <defs>
153
+ <linearGradient id="shine" x1="0%" y1="0%" x2="100%" y2="0%">
154
+ <stop offset="0%" style="stop-color:rgba(255,255,255,0.1)"/>
155
+ <stop offset="50%" style="stop-color:rgba(255,255,255,0.3)"/>
156
+ <stop offset="100%" style="stop-color:rgba(255,255,255,0.1)"/>
157
+ </linearGradient>
158
+ <filter id="glow">
159
+ <feGaussianBlur stdDeviation="2" result="blur"/>
160
+ <feComposite in="SourceGraphic" in2="blur" operator="over"/>
161
+ </filter>
162
+ </defs>
163
+
164
+ <!-- 添加光泽效果 -->
165
+ <path d="M70,280 L70,440 Q100,460 130,440 L130,280 Z" fill="url(#shine)" opacity="0.5"/>
166
+ </svg>`;
167
+
168
+ // 辅助函数:将字符串转换为 base64
169
+ function svgToBase64(svg) {
170
+ // 移除所有换行符和多余的空格
171
+ const cleanSvg = svg.replace(/\s+/g, ' ').trim();
172
+ // 使用 encodeURIComponent 处理 Unicode 字符
173
+ const encoded = encodeURIComponent(cleanSvg);
174
+ return 'data:image/svg+xml;base64,' + btoa(unescape(encoded));
175
+ }
176
+
177
+ // 将 SVG 转换为 base64
178
+ const bottleImageBase64 = svgToBase64(bottleImageSVG);
179
+
180
+ // 从SVG的viewBox中获取实际比例
181
+ const svgViewBox = bottleImageSVG.match(/viewBox="([^"]+)"/)[1].split(' ').map(Number);
182
+ const svgWidth = svgViewBox[2];
183
+ const svgHeight = svgViewBox[3];
184
+ const svgAspectRatio = svgHeight / svgWidth;
185
+
186
+ // 计算基础间距(在 makeChart 函数开始处添加)
187
+ const baseBottleWidth = xScale.bandwidth();
188
+ const baseBottleHeight = baseBottleWidth * svgAspectRatio;
189
+ const spacingAfterBottle = baseBottleWidth * 0.1; // 瓶子和图标之间的间距
190
+ const spacingBetweenIconLabel = baseBottleWidth * 0.15; // 图标和标签之间的间距
191
+
192
+ // 开始添加瓶子图像和蒙版
193
+ chart.selectAll(".bottle-group")
194
+ .data(sortedData)
195
+ .enter()
196
+ .append("g")
197
+ .attr("class", "bottle-group")
198
+ .attr("transform", d => `translate(${xScale(d[xField])}, ${bottleTopY})`)
199
+ .each(function(d, i) {
200
+ const group = d3.select(this);
201
+ const bottleWidth = xScale.bandwidth();
202
+ // 根据SVG的实际比例计算瓶子高度
203
+ const bottleHeight = bottleWidth * svgAspectRatio;
204
+ // 判断是否为最大值
205
+ const isMaxValue = +d[yField] === yMax;
206
+ const bottlePercentage = isMaxValue ? 1 : Math.max(0, (+d[yField] / yMax));
207
+
208
+ // 定义裁剪路径
209
+ const clipId = `clip-${i}`;
210
+ group.append("clipPath")
211
+ .attr("id", clipId)
212
+ .append("rect")
213
+ .attr("x", 0)
214
+ .attr("y", bottleHeight * (1 - bottlePercentage))
215
+ .attr("width", bottleWidth)
216
+ .attr("height", bottleHeight * bottlePercentage);
217
+
218
+ // 添加半透明的背景瓶子
219
+ group.append("image")
220
+ .attr("xlink:href", bottleImageBase64)
221
+ .attr("width", bottleWidth)
222
+ .attr("height", bottleHeight)
223
+ .attr("preserveAspectRatio", "xMidYMid meet")
224
+ .style("opacity", 0.15);
225
+
226
+ // 添加裁剪后的前景瓶子
227
+ group.append("image")
228
+ .attr("xlink:href", bottleImageBase64)
229
+ .attr("width", bottleWidth)
230
+ .attr("height", bottleHeight)
231
+ .attr("preserveAspectRatio", "xMidYMid meet")
232
+ .attr("clip-path", `url(#${clipId})`);
233
+
234
+ // 计算标签方块的大小和位置
235
+ const squareSize = bottleWidth * 0.6;
236
+ const squareX = (bottleWidth - squareSize) / 2;
237
+ const squareY = bottleHeight * (2/3) - (squareSize / 2);
238
+
239
+
240
+ // const formattedValue = `${chartUtils.format.autoText(+d[yField])}${valueUnit}`;
241
+ // // 我们将在下面计算实际字体大小,先使用基础大小创建文本元素
242
+ // const baseValueFontSize = 16;
243
+
244
+ // // 添加数值文本但先不设置字体大小
245
+ // const valueText = group.append("text")
246
+ // .attr("class", "value-label")
247
+ // .attr("x", bottleWidth / 2)
248
+ // .attr("y", squareY + (squareSize * 0.6)) // 先设置一个位置,稍后会调整
249
+ // .attr("text-anchor", "middle")
250
+ // .style("font-family", typography.label.font_family)
251
+ // .style("font-weight", "bold")
252
+ // .style("fill", "#fff")
253
+ // .style("font-size", `${baseValueFontSize}px`) // 先设置基础字体大小
254
+ // .text(formattedValue);
255
+
256
+ // // 将值标签添加到数组中,以便后续统一调整
257
+ // valueLabels.push({
258
+ // element: valueText,
259
+ // value: formattedValue,
260
+ // squareSize: squareSize
261
+ // });
262
+ });
263
+
264
+ // ---------- 8. 添加国旗和国家名称 (修改后) ----------
265
+ const baseLabelFontSize = 14; // 基础字体大小
266
+ // 文本换行辅助函数 (添加 alignment 参数)
267
+ function wrapText(text, str, width, lineHeight = 1.1, alignment = 'middle') {
268
+ const words = str.split(/\s+/).reverse(); // 按空格分割单词
269
+ let word;
270
+ let line = [];
271
+ let lineNumber = 0;
272
+ const initialY = parseFloat(text.attr("y")); // 获取原始y坐标
273
+ const initialX = parseFloat(text.attr("x")); // 获取原始x坐标
274
+ const actualFontSize = parseFloat(text.style("font-size")); // 获取实际应用的字体大小
275
+
276
+ text.text(null); // 清空现有文本
277
+
278
+ let tspans = []; // 存储最终要渲染的行
279
+
280
+ // 优先按单词换行
281
+ if (words.length > 1) {
282
+ let currentLine = [];
283
+ while (word = words.pop()) {
284
+ currentLine.push(word);
285
+ const isOverflow = chartUtils.text.measure(null, currentLine.join(" "), {
286
+ fontFamily: text.style("font-family"),
287
+ fontSize: parseFloat(text.style("font-size")),
288
+ fontWeight: text.style("font-weight")
289
+ }).width > width;
290
+
291
+ if (isOverflow && currentLine.length > 1) {
292
+ currentLine.pop(); // 回退一个词
293
+ tspans.push(currentLine.join(" ")); // 添加完成的行
294
+ currentLine = [word]; // 新行以当前词开始
295
+ lineNumber++;
296
+ }
297
+ }
298
+ // 添加最后一行
299
+ if (currentLine.length > 0) {
300
+ tspans.push(currentLine.join(" "));
301
+ }
302
+ } else { // 如果没有空格或只有一个词,则按字符换行
303
+ const chars = str.split('');
304
+ let currentLine = '';
305
+ for (let i = 0; i < chars.length; i++) {
306
+ const nextLine = currentLine + chars[i];
307
+ const isOverflow = chartUtils.text.measure(null, nextLine, {
308
+ fontFamily: text.style("font-family"),
309
+ fontSize: parseFloat(text.style("font-size")),
310
+ fontWeight: text.style("font-weight")
311
+ }).width > width;
312
+
313
+ if (isOverflow && currentLine.length > 0) { // 如果加了新字符就超长了,并且当前行不为空
314
+ tspans.push(currentLine); // 添加当前行
315
+ currentLine = chars[i]; // 新行从这个字符开始
316
+ lineNumber++;
317
+ } else {
318
+ currentLine = nextLine; // 没超长就继续加字符
319
+ }
320
+ }
321
+ // 添加最后一行
322
+ if (currentLine.length > 0) {
323
+ tspans.push(currentLine);
324
+ }
325
+ }
326
+
327
+ // 计算总行数
328
+ const totalLines = tspans.length;
329
+ let startDy = 0;
330
+
331
+ // 根据对齐方式计算起始偏移
332
+ if (alignment === 'middle') {
333
+ // 垂直居中:向上移动半行*(总行数-1)
334
+ startDy = -( (totalLines - 1) * lineHeight / 2);
335
+ } else if (alignment === 'bottom') {
336
+ // 底部对齐:计算总高度,向上移动 总高度 - 单行高度(近似)
337
+ // 注意:em单位是相对于字体大小的,这里用 lineHeight * actualFontSize 近似计算像素高度
338
+ const totalHeightEm = totalLines * lineHeight;
339
+ startDy = -(totalHeightEm - lineHeight); // 将底部对齐到原始y
340
+ }
341
+ // 如果是 'top' 对齐,startDy 保持为 0,即第一行基线在原始y位置
342
+ // 其他对齐方式(如 'top')可以保持 startDy 为 0
343
+
344
+ // 创建所有行的tspan元素
345
+ tspans.forEach((lineText, i) => {
346
+ text.append("tspan")
347
+ .attr("x", initialX) // x坐标与父<text>相同
348
+ .attr("dy", (i === 0 ? startDy : lineHeight) + "em") // 第一行应用起始偏移,后续行应用行高
349
+ .text(lineText);
350
+ });
351
+ }
352
+ // 计算所有维度标签在基础字体大小下的宽度
353
+ const dimensionWidths = sortedData.map(d => chartUtils.text.measure(svg, d[xField], {
354
+ fontFamily: typography.label.font_family,
355
+ fontSize: baseLabelFontSize
356
+ }).width);
357
+
358
+ const maxAvailableWidth = xScale.bandwidth() * 1.2;
359
+
360
+ // 计算是否需要缩小字体以及缩放比例
361
+ const maxDimensionWidth = Math.max(...dimensionWidths);
362
+ const labelScaleFactor = maxDimensionWidth > maxAvailableWidth ? maxAvailableWidth / maxDimensionWidth : 1;
363
+
364
+ // 确定最终的统一字体大小(增大1.5倍)
365
+ const finalLabelFontSize = baseLabelFontSize * labelScaleFactor * 1.5;
366
+
367
+ // 开始添加国旗和标签组
368
+ chart.selectAll(".flag-label-group")
369
+ .data(sortedData)
370
+ .enter()
371
+ .append("g")
372
+ .attr("class", "flag-label-group")
373
+ // 根据实际瓶子高度调整位置
374
+ .attr("transform", d => `translate(${xScale(d[xField]) + baseBottleWidth / 2}, ${bottleTopY + baseBottleHeight + spacingAfterBottle})`)
375
+ .each(function(d, i) {
376
+ const group = d3.select(this);
377
+
378
+ // 计算图标尺寸
379
+ const iconWidth = baseBottleWidth;
380
+ const iconHeight = iconWidth * 0.75;
381
+
382
+ // 添加国旗(如果数据中提供了图片字段和对应的值)
383
+ if (images.field && images.field[d[xField]]) {
384
+ group.append("image")
385
+ .attr("xlink:href", images.field[d[xField]])
386
+ .attr("x", -iconWidth / 2)
387
+ .attr("y", 0)
388
+ .attr("width", iconWidth)
389
+ .attr("height", iconHeight)
390
+ .attr("preserveAspectRatio", "xMidYMid meet");
391
+ }
392
+
393
+ // 添加国家名称文本
394
+ const label = group.append("text")
395
+ .attr("class", "country-label")
396
+ .attr("x", 0)
397
+ .attr("y", iconHeight + spacingBetweenIconLabel)
398
+ .attr("text-anchor", "middle")
399
+ .style("font-family", typography.label.font_family)
400
+ .style("font-size", `${finalLabelFontSize}px`)
401
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
402
+ .text(d[xField]);
403
+
404
+ // 如果需要换行,重新应用换行
405
+ const labelMaxWidth = baseBottleWidth * 1.05;
406
+ if (chartUtils.text.measure(chart, d[xField], {
407
+ fontFamily: typography.label.font_family,
408
+ fontSize: finalLabelFontSize
409
+ }).width > labelMaxWidth) {
410
+ wrapText(label, d[xField].toString(), labelMaxWidth, 1.1, 'top');
411
+ }
412
+ });
413
+
414
+ // ---------- 9. 统一调整所有值标签的字体大小 ----------
415
+ // 创建临时文本元素来测量值标签文本宽度
416
+
417
+ const baseValueFontSize = 16;
418
+ // 计算所有值标签在基础字体大小下的实际宽度
419
+ const valueTextWidths = valueLabels.map(item => {
420
+ return chartUtils.text.measure(svg, item.value, {
421
+ fontFamily: typography.label.font_family,
422
+ fontSize: `${baseValueFontSize}px`,
423
+ fontWeight: "bold"
424
+ }).width;
425
+ });
426
+
427
+ // 计算每个标签所需的缩放比例(方形宽度 / 文本宽度)
428
+ const valueScaleFactors = valueLabels.map((item, i) => {
429
+ // 使用60%的方形宽度作为最大可用宽度
430
+ const maxWidth = item.squareSize * 1; // 留一些边距
431
+ return maxWidth / valueTextWidths[i];
432
+ });
433
+
434
+ // 取最小的缩放比例,确保所有值标签使用相同的字体大小
435
+ const minValueScaleFactor = Math.min(...valueScaleFactors, 1); // 不要放大,只缩小
436
+
437
+ // 计算最终的统一字体大小
438
+ const finalValueFontSize = Math.min(baseValueFontSize * minValueScaleFactor, baseValueFontSize);
439
+
440
+ // 应用统一的字体大小到所有值标签
441
+ valueLabels.forEach(item => {
442
+ item.element
443
+ .style("font-size", `${finalValueFontSize}px`);
444
+ });
445
+
446
+ // 返回SVG节点
447
+ return svg.node();
448
+ }