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

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/type17_diverging_bar_chart/diverging_bar_plain_chart_01.js ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Diverging Bar Chart",
5
+ "chart_name": "diverging_bar_plain_chart_01",
6
+ "is_composite": false,
7
+ "required_fields": ["x", "y"],
8
+ "required_fields_type": [["categorical"], ["numerical"]],
9
+ "required_fields_range": [[2, 20], ["-inf", "inf"]],
10
+ "required_fields_icons": [],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": [],
13
+ "required_other_colors": ["primary","secondary"],
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
+ // 双向水平条形图实现 - 使用D3.js
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
+ variables.has_rounded_corners = variables.has_rounded_corners || false;
47
+ variables.has_shadow = variables.has_shadow || false;
48
+ variables.has_gradient = variables.has_gradient || false;
49
+ variables.has_stroke = variables.has_stroke || false;
50
+ variables.has_spacing = variables.has_spacing || false;
51
+
52
+ // 数值格式化函数
53
+
54
+ // 清空容器
55
+ d3.select(containerSelector).html("");
56
+
57
+ // ---------- 2. 尺寸和布局设置 ----------
58
+
59
+ // 设置图表总尺寸
60
+ const width = variables.width || 800;
61
+ const height = variables.height || 600;
62
+
63
+ // ---------- 3. 提取字段名和单位 ----------
64
+
65
+ const dimensionChannel = chartUtils.schema.channel(jsonData, "x", { fallbackKey: "dimension" });
66
+ const valueChannel = chartUtils.schema.channel(jsonData, "y", { fallbackKey: "value" });
67
+ const dimensionField = dimensionChannel.key;
68
+ const valueField = valueChannel.key;
69
+ const dimensionUnit = dimensionChannel.unit;
70
+ const valueUnit = valueChannel.unit;
71
+
72
+ // ---------- 4. 数据处理 ----------
73
+
74
+ // 修改:按数值升序排序数据(负值在上,正值在下)
75
+ chartData.sort((a, b) => +a[valueField] - +b[valueField]);
76
+
77
+ // 分离正负值
78
+ const positiveData = chartData.filter(d => +d[valueField] >= 0);
79
+ const negativeData = chartData.filter(d => +d[valueField] < 0);
80
+
81
+ // 所有维度值按排序后的顺序
82
+ const sortedDimensions = chartData.map(d => d[dimensionField]);
83
+
84
+ // ---------- 5. 测量文本尺寸 ----------
85
+
86
+ // 测量所有维度标签的宽度
87
+ let maxDimensionLabelWidth = 0;
88
+ let maxValueLabelWidth = 0;
89
+
90
+ // 测量每个维度标签的宽度
91
+ chartData.forEach(d => {
92
+ const dimension = d[dimensionField];
93
+ const textWidth = chartUtils.text.measure(null, dimension, {
94
+ fontFamily: typography.label.font_family,
95
+ fontSize: typography.label.font_size,
96
+ fontWeight: typography.label.font_weight
97
+ }).width;
98
+ maxDimensionLabelWidth = Math.max(maxDimensionLabelWidth, textWidth);
99
+ });
100
+
101
+ chartData.forEach(d => {
102
+ const value = d[valueField];
103
+ const formattedValue = value >= 0 ?
104
+ `+${chartUtils.format.autoText(value)}${valueUnit}` :
105
+ `${chartUtils.format.autoText(value)}${valueUnit}`;
106
+ const textWidth = chartUtils.text.measure(null, formattedValue, {
107
+ fontFamily: typography.annotation.font_family,
108
+ fontSize: typography.annotation.font_size,
109
+ fontWeight: typography.annotation.font_weight
110
+ }).width;
111
+ maxValueLabelWidth = Math.max(maxValueLabelWidth, textWidth);
112
+ });
113
+
114
+ // ---------- 6. 计算内部绘图区域尺寸和布局 ----------
115
+
116
+ // 初始化绘图区域边距
117
+ const labelPadding = 5; // 标签与条形之间的间距
118
+ const valuePadding = 5; // 数值与条形之间的间距
119
+
120
+ // 计算需要的空间
121
+ const maxNegativeValue = d3.min(negativeData, d => +d[valueField]) || 0;
122
+ const maxPositiveValue = d3.max(positiveData, d => +d[valueField]) || 0;
123
+
124
+ // 设置边距,考虑文本宽度
125
+ const margin = {
126
+ top: 60, // 顶部留出标题空间
127
+ right: 0,
128
+ bottom: 30, // 底部边距
129
+ left: 0
130
+ };
131
+
132
+ // 计算内部绘图区域尺寸
133
+ const innerWidth = width - margin.left - margin.right; // 图表占总宽度的2/3
134
+ const innerHeight = height - margin.top - margin.bottom;
135
+
136
+ // 计算条形的额外间距
137
+ const barPadding = variables.has_spacing ? 0.3 : 0.25;
138
+
139
+ // Y轴比例尺(用于维度)
140
+ const yScale = d3.scaleBand()
141
+ .domain(sortedDimensions)
142
+ .range([0, innerHeight])
143
+ .padding(barPadding);
144
+
145
+ // 计算最大绝对值
146
+ const maxAbsValue = Math.max(Math.abs(maxNegativeValue), Math.abs(maxPositiveValue)) * 1.05; // 添加5%边距
147
+
148
+ // 计算负值栏最左侧位置(包括标签)
149
+ const negLabelSpace = maxDimensionLabelWidth - 30;
150
+
151
+ // 计算正值栏最右侧位置(包括标签)
152
+ const posLabelSpace = maxDimensionLabelWidth - 30;
153
+
154
+ // 创建从负值到正值的比例尺
155
+ const xScale = d3.scaleLinear()
156
+ .domain([-Math.abs(maxNegativeValue), Math.abs(maxPositiveValue)])
157
+ .range([negLabelSpace, innerWidth - posLabelSpace]);
158
+
159
+ // 中心点位置
160
+ const center = xScale(0);
161
+
162
+ // ---------- 7. 创建SVG容器 ----------
163
+
164
+ const svg = d3.select(containerSelector)
165
+ .append("svg")
166
+ .attr("width", "100%")
167
+ .attr("height", height)
168
+ .attr("viewBox", `0 0 ${width} ${height}`)
169
+ .attr("style", "max-width: 100%; height: auto;")
170
+ .attr("xmlns", "http://www.w3.org/2000/svg")
171
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
172
+
173
+ // // 如果有背景颜色设置,添加背景
174
+ // if (colorResolver.background({ fallback: "#FFFFFF" }).value) {
175
+ // svg.style("background-color", colorResolver.background({ fallback: "#FFFFFF" }).value);
176
+ // }
177
+
178
+ // 添加defs用于视觉效果
179
+ const defs = svg.append("defs");
180
+
181
+ // 添加阴影滤镜(如果启用)
182
+ if (variables.has_shadow) {
183
+ const filter = defs.append("filter")
184
+ .attr("id", "shadow")
185
+ .attr("filterUnits", "userSpaceOnUse")
186
+ .attr("width", "200%")
187
+ .attr("height", "200%");
188
+
189
+ filter.append("feGaussianBlur")
190
+ .attr("in", "SourceAlpha")
191
+ .attr("stdDeviation", 3);
192
+
193
+ filter.append("feOffset")
194
+ .attr("dx", 2)
195
+ .attr("dy", 2)
196
+ .attr("result", "offsetblur");
197
+
198
+ const feMerge = filter.append("feMerge");
199
+ feMerge.append("feMergeNode");
200
+ feMerge.append("feMergeNode").attr("in", "SourceGraphic");
201
+ }
202
+
203
+ // 添加渐变(如果启用)
204
+ if (variables.has_gradient) {
205
+ // 正值渐变
206
+ const positiveGradient = defs.append("linearGradient")
207
+ .attr("id", "positive-gradient")
208
+ .attr("x1", "0%")
209
+ .attr("y1", "0%")
210
+ .attr("x2", "100%")
211
+ .attr("y2", "0%");
212
+
213
+ const positiveColor = colorResolver.other("positive", { fallback: "#90EE90" }).value;
214
+
215
+ positiveGradient.append("stop")
216
+ .attr("offset", "0%")
217
+ .attr("stop-color", colorResolver.variant(positiveColor, { mode: "brighter", amount: 0.5 }));
218
+
219
+ positiveGradient.append("stop")
220
+ .attr("offset", "100%")
221
+ .attr("stop-color", colorResolver.variant(positiveColor, { mode: "darker", amount: 0.3 }));
222
+
223
+ // 负值渐变
224
+ const negativeGradient = defs.append("linearGradient")
225
+ .attr("id", "negative-gradient")
226
+ .attr("x1", "100%")
227
+ .attr("y1", "0%")
228
+ .attr("x2", "0%")
229
+ .attr("y2", "0%");
230
+
231
+ const negativeColor = colorResolver.other("negative", { fallback: "#C0C0C0" }).value;
232
+
233
+ negativeGradient.append("stop")
234
+ .attr("offset", "0%")
235
+ .attr("stop-color", colorResolver.variant(negativeColor, { mode: "brighter", amount: 0.5 }));
236
+
237
+ negativeGradient.append("stop")
238
+ .attr("offset", "100%")
239
+ .attr("stop-color", colorResolver.variant(negativeColor, { mode: "darker", amount: 0.3 }));
240
+ }
241
+
242
+ // ---------- 8. 获取颜色 ----------
243
+
244
+ // 获取正负值条形颜色的辅助函数
245
+ const getPositiveColor = () => {
246
+ if (colorResolver.other("primary").value) return colorResolver.other("primary").value;
247
+ return colorResolver.available(0, { fallback: "#4682B4" }).value; // 默认蓝色
248
+ };
249
+
250
+ const getNegativeColor = () => {
251
+ if (colorResolver.other("secondary").value) return colorResolver.other("secondary").value;
252
+ return colorResolver.available(1, { fallback: "#5F9EA0" }).value; // 默认青绿色
253
+ };
254
+
255
+ // 获取描边颜色的辅助函数
256
+ const getStrokeColor = () => {
257
+ const stroke = colorResolver.other("stroke_color", { fallback: "" }).value;
258
+ if (stroke) return stroke;
259
+ return colorResolver.available(0, { fallback: "#333333" }).value;
260
+ };
261
+
262
+ // ---------- 9. 创建主图表组 ----------
263
+
264
+ const g = svg.append("g")
265
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
266
+
267
+ // 可选:添加中心线
268
+ g.append("line")
269
+ .attr("x1", center)
270
+ .attr("y1", 0)
271
+ .attr("x2", center)
272
+ .attr("y2", innerHeight)
273
+ .attr("stroke", "#000") // 将 #ccc 改为 #000
274
+ .attr("stroke-width", 1)
275
+ .style("opacity", 0.5);
276
+
277
+ // ---------- 10. 绘制条形和标签 ----------
278
+
279
+ // 为每个维度绘制条形和标签
280
+ chartData.forEach(d => {
281
+ const dimension = d[dimensionField];
282
+ const value = d[valueField];
283
+ const barHeight = yScale.bandwidth();
284
+
285
+ // 确定条形颜色:正值使用正值颜色,负值使用负值颜色
286
+ const barColor = value >= 0 ?
287
+ (variables.has_gradient ? "url(#positive-gradient)" : getPositiveColor()) :
288
+ (variables.has_gradient ? "url(#negative-gradient)" : getNegativeColor());
289
+
290
+ // 格式化数值标签(添加加号或保留负号)
291
+ const formattedValue = value >= 0 ?
292
+ `+${chartUtils.format.autoText(value)}${valueUnit}` :
293
+ `${chartUtils.format.autoText(value)}${valueUnit}`;
294
+
295
+ // 横条开始位置和宽度
296
+ let barX = center;
297
+ let barWidth = 0;
298
+
299
+ if (value >= 0) {
300
+ // 正值条形向右延伸
301
+ barX = center;
302
+ barWidth = xScale(value) - center;
303
+ } else {
304
+ // 负值条形向左延伸
305
+ barWidth = center - xScale(value);
306
+ barX = center - barWidth;
307
+ }
308
+
309
+ // 绘制条形
310
+ if (variables.has_rounded_corners) {
311
+ // 使用路径绘制具有选择性圆角的条形
312
+ const radius = 5; // 圆角半径
313
+ const y = yScale(dimension);
314
+ const height = barHeight;
315
+ // 当 |value| 很小、barWidth < radius 时,固定半径会让圆角控制点越过中心线,
316
+ // 把 bar 画到 0 线另一侧。这里把横向半径 clamp 到 barWidth,避免越线。
317
+ const rx = Math.min(radius, barWidth);
318
+ const ry = Math.min(radius, height / 2);
319
+
320
+ if (value >= 0) {
321
+ // 正值条形:只在右侧有圆角
322
+ const path = [
323
+ `M ${barX},${y}`, // 左上角起点
324
+ `L ${barX + barWidth - rx},${y}`, // 到右上角圆角起点
325
+ `Q ${barX + barWidth},${y} ${barX + barWidth},${y + ry}`, // 右上角圆角
326
+ `L ${barX + barWidth},${y + height - ry}`, // 到右下角圆角起点
327
+ `Q ${barX + barWidth},${y + height} ${barX + barWidth - rx},${y + height}`, // 右下角圆角
328
+ `L ${barX},${y + height}`, // 到左下角
329
+ `Z` // 闭合路径
330
+ ].join(' ');
331
+
332
+ g.append("path")
333
+ .attr("d", path)
334
+ .attr("fill", barColor)
335
+ .style("stroke", variables.has_stroke ? getStrokeColor() : "none")
336
+ .style("stroke-width", variables.has_stroke ? 1 : 0)
337
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "none");
338
+ } else {
339
+ // 负值条形:只在左侧有圆角
340
+ const path = [
341
+ `M ${barX + rx},${y}`, // 左上角圆角起点
342
+ `Q ${barX},${y} ${barX},${y + ry}`, // 左上角圆角
343
+ `L ${barX},${y + height - ry}`, // 到左下角圆角起点
344
+ `Q ${barX},${y + height} ${barX + rx},${y + height}`, // 左下角圆角
345
+ `L ${barX + barWidth},${y + height}`, // 到右下角
346
+ `L ${barX + barWidth},${y}`, // 到右上角
347
+ `Z` // 闭合路径
348
+ ].join(' ');
349
+
350
+ g.append("path")
351
+ .attr("d", path)
352
+ .attr("fill", barColor)
353
+ .style("stroke", variables.has_stroke ? getStrokeColor() : "none")
354
+ .style("stroke-width", variables.has_stroke ? 1 : 0)
355
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "none");
356
+ }
357
+ } else {
358
+ // 没有圆角时使用普通矩形
359
+ g.append("rect")
360
+ .attr("x", barX)
361
+ .attr("y", yScale(dimension))
362
+ .attr("width", barWidth)
363
+ .attr("height", barHeight)
364
+ .attr("fill", barColor)
365
+ .style("stroke", variables.has_stroke ? getStrokeColor() : "none")
366
+ .style("stroke-width", variables.has_stroke ? 1 : 0)
367
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "none");
368
+ }
369
+
370
+ // 绘制维度标签
371
+ if (value >= 0) {
372
+ // 正值:标签在条形左侧
373
+ g.append("text")
374
+ .attr("x", center - labelPadding)
375
+ .attr("y", yScale(dimension) + barHeight / 2)
376
+ .attr("dy", "0.35em")
377
+ .attr("text-anchor", "end")
378
+ .style("font-family", typography.label.font_family)
379
+ .style("font-size", typography.label.font_size)
380
+ .style("font-weight", typography.label.font_weight)
381
+ .style("fill", colorResolver.text({ fallback: "#000000" }).value)
382
+ .text(dimension);
383
+ } else {
384
+ // 负值:标签在条形右侧
385
+ g.append("text")
386
+ .attr("x", center + labelPadding)
387
+ .attr("y", yScale(dimension) + barHeight / 2)
388
+ .attr("dy", "0.35em")
389
+ .attr("text-anchor", "start")
390
+ .style("font-family", typography.label.font_family)
391
+ .style("font-size", typography.label.font_size)
392
+ .style("font-weight", typography.label.font_weight)
393
+ .style("fill", colorResolver.text({ fallback: "#000000" }).value)
394
+ .text(dimension);
395
+ }
396
+
397
+ // 绘制数值标签
398
+ if (value >= 0) {
399
+ // 正值:数值标签在条形右侧
400
+ g.append("text")
401
+ .attr("x", barX + barWidth + valuePadding)
402
+ .attr("y", yScale(dimension) + barHeight / 2)
403
+ .attr("dy", "0.35em")
404
+ .attr("text-anchor", "start")
405
+ .style("font-family", typography.annotation.font_family)
406
+ .style("font-size", `${Math.min(20,Math.max(barHeight * 0.6, parseFloat(typography.annotation.font_size)))}px`)
407
+ .style("font-weight", typography.annotation.font_weight)
408
+ .style("fill", colorResolver.text({ fallback: "#000000" }).value)
409
+ .text(formattedValue);
410
+ } else {
411
+ // 负值:数值标签在条形左侧
412
+ g.append("text")
413
+ .attr("x", barX - valuePadding)
414
+ .attr("y", yScale(dimension) + barHeight / 2)
415
+ .attr("dy", "0.35em")
416
+ .attr("text-anchor", "end")
417
+ .style("font-family", typography.annotation.font_family)
418
+ .style("font-size", `${Math.min(20,Math.max(barHeight * 0.6, parseFloat(typography.annotation.font_size)))}px`)
419
+ .style("font-weight", typography.annotation.font_weight)
420
+ .style("fill", colorResolver.text({ fallback: "#000000" }).value)
421
+ .text(formattedValue);
422
+ }
423
+ });
424
+
425
+
426
+
427
+ // 返回SVG节点
428
+ return svg.node();
429
+ }
modules/chart_engine/template/d3-js/type17_diverging_bar_chart/diverging_bar_plain_chart_02.js ADDED
@@ -0,0 +1,517 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Diverging Bar Chart",
5
+ "chart_name": "diverging_bar_plain_chart_02",
6
+ "is_composite": false,
7
+ "required_fields": ["x", "y"],
8
+ "required_fields_type": [["categorical"], ["numerical"]],
9
+ "required_fields_range": [[2, 20], ["-inf", "inf"]],
10
+ "required_fields_icons": [],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": [],
13
+ "required_other_colors": ["primary", "secondary"],
14
+ "supported_effects": ["shadow", "radius_corner", "gradient", "stroke", "spacing"],
15
+ "min_height": 500,
16
+ "min_width": 650,
17
+ "background": "no",
18
+ "icon_mark": "none",
19
+ "icon_label": "none",
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 rawData = (jsonData.data && jsonData.data.data) || [];
29
+ const variables = jsonData.variables || {};
30
+ const typography = jsonData.typography || {};
31
+ const sourceColors = jsonData.colors || {};
32
+ const dataColumns = chartUtils.schema.columns(jsonData);
33
+ const colorResolver = chartUtils.color.resolver(jsonData);
34
+ const chartUtilsFormatSample = chartUtils.format.autoText;
35
+ const chartUtilsTextSample = chartUtils.text.estimate;
36
+ const chartUtilsStandard = {
37
+ schema: chartUtils.schema,
38
+ format: chartUtils.format,
39
+ text: chartUtils.text,
40
+ color: chartUtils.color,
41
+ legendLayout: chartUtils.legend.layout,
42
+ legendDraw: chartUtils.legend.draw,
43
+ random: chartUtils.random.generator(jsonData, "dev-wz-style")
44
+ };
45
+ const standardChannels = chartUtils.schema.channels(jsonData, {
46
+ x: { fallbackIndex: 0 },
47
+ y: { fallbackIndex: 1 },
48
+ y2: { fallbackIndex: 2 },
49
+ y3: { fallbackIndex: 3 },
50
+ size: { fallbackIndex: 2 },
51
+ group: { fallbackIndex: 2 },
52
+ group2: { fallbackIndex: 3 },
53
+ group3: { fallbackIndex: 4 }
54
+ });
55
+
56
+ d3.select(containerSelector).html("");
57
+
58
+ const xColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
59
+ const yColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
60
+ const dimensionField = xColumn.name;
61
+ const valueField = yColumn.name;
62
+ const dimensionUnit = (xColumn.unit || "").trim();
63
+ const valueUnit = (yColumn.unit || "").trim();
64
+
65
+ const width = Number(variables.width) || 720;
66
+ const height = Number(variables.height) || 560;
67
+ const textColor = sourceColors.text_color || "#243042";
68
+ const canvasColor = "#f2f8fc";
69
+ const cardColor = "#ffffff";
70
+ const inkColor = "#16233d";
71
+ const mutedText = "#65748a";
72
+ const gridColor = "#d4e0ec";
73
+ const axisColor = "#33445f";
74
+ const positiveColor = "#38a856";
75
+ const positiveSoft = "#dff3df";
76
+ const negativeColor = "#ef3f4f";
77
+ const negativeSoft = "#fde1e4";
78
+ const labelFamily = (typography.label && typography.label.font_family) || "Arial, sans-serif";
79
+ const annotationFamily = (typography.annotation && typography.annotation.font_family) || labelFamily;
80
+ const labelWeight = (typography.label && typography.label.font_weight) || 600;
81
+ const valueWeight = (typography.annotation && typography.annotation.font_weight) || 600;
82
+ const baseLabelSize = parseFloat(typography.label && typography.label.font_size) || 12;
83
+ const baseValueSize = parseFloat(typography.annotation && typography.annotation.font_size) || 11;
84
+
85
+ const svg = d3.select(containerSelector)
86
+ .append("svg")
87
+ .attr("width", "100%")
88
+ .attr("height", height)
89
+ .attr("viewBox", `0 0 ${width} ${height}`)
90
+ .attr("style", "max-width: 100%; height: auto;")
91
+ .attr("xmlns", "http://www.w3.org/2000/svg")
92
+ .attr("class", "diverging-bar-root");
93
+
94
+ const defs = svg.append("defs");
95
+ const cardShadow = defs.append("filter")
96
+ .attr("id", "diverging_bar_02_card_shadow")
97
+ .attr("x", "-8%")
98
+ .attr("y", "-10%")
99
+ .attr("width", "116%")
100
+ .attr("height", "124%");
101
+ cardShadow.append("feDropShadow")
102
+ .attr("dx", 0)
103
+ .attr("dy", 2)
104
+ .attr("stdDeviation", 2.6)
105
+ .attr("flood-color", "#4d6078")
106
+ .attr("flood-opacity", 0.13);
107
+ const positiveGradient = defs.append("linearGradient")
108
+ .attr("id", "diverging_bar_02_positive_gradient")
109
+ .attr("x1", "0%")
110
+ .attr("x2", "100%")
111
+ .attr("y1", "0%")
112
+ .attr("y2", "0%");
113
+ positiveGradient.append("stop").attr("offset", "0%").attr("stop-color", "#a7daa0");
114
+ positiveGradient.append("stop").attr("offset", "100%").attr("stop-color", positiveColor);
115
+ const negativeGradient = defs.append("linearGradient")
116
+ .attr("id", "diverging_bar_02_negative_gradient")
117
+ .attr("x1", "0%")
118
+ .attr("x2", "100%")
119
+ .attr("y1", "0%")
120
+ .attr("y2", "0%");
121
+ negativeGradient.append("stop").attr("offset", "0%").attr("stop-color", negativeColor);
122
+ negativeGradient.append("stop").attr("offset", "100%").attr("stop-color", "#f6a2aa");
123
+
124
+ function rgba(hex, opacity) {
125
+ const clean = String(hex || "").replace("#", "");
126
+ if (clean.length !== 6) return hex;
127
+ const r = parseInt(clean.slice(0, 2), 16);
128
+ const g = parseInt(clean.slice(2, 4), 16);
129
+ const b = parseInt(clean.slice(4, 6), 16);
130
+ return `rgba(${r}, ${g}, ${b}, ${opacity})`;
131
+ }
132
+
133
+ function showMessage(message) {
134
+ svg.append("text")
135
+ .attr("x", width / 2)
136
+ .attr("y", height / 2)
137
+ .attr("text-anchor", "middle")
138
+ .attr("fill", textColor)
139
+ .style("font-family", labelFamily)
140
+ .style("font-size", "16px")
141
+ .text(message);
142
+ return svg.node();
143
+ }
144
+
145
+ if (!dimensionField || !valueField) {
146
+ return showMessage("Missing diverging bar fields");
147
+ }
148
+
149
+ function cleanLabel(value) {
150
+ return String(value ?? "")
151
+ .replace(/_/g, " ")
152
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
153
+ .replace(/\s+/g, " ")
154
+ .trim();
155
+ }
156
+
157
+ function humanizeField(value) {
158
+ return cleanLabel(value).replace(/\b\w/g, letter => letter.toUpperCase());
159
+ }
160
+
161
+ function parseTemporal(value) {
162
+ if (value instanceof Date) return +value;
163
+ if (typeof value === "number") return +new Date(value, 0, 1);
164
+ const text = String(value ?? "").trim();
165
+ const yearRange = text.match(/^(-?\d{4})\s*[-–]\s*(-?\d{4})$/);
166
+ if (yearRange) return +new Date(Number(yearRange[1]), 0, 1);
167
+ const quarter = text.match(/^(\d{4})[-\s]?Q([1-4])$/i) || text.match(/^Q([1-4])\s+(\d{4})$/i);
168
+ if (quarter && quarter[1].length === 4) return +new Date(Number(quarter[1]), (Number(quarter[2]) - 1) * 3, 1);
169
+ if (quarter) return +new Date(Number(quarter[2]), (Number(quarter[1]) - 1) * 3, 1);
170
+ const yearMonth = text.match(/^(\d{4})[-/](\d{1,2})$/);
171
+ if (yearMonth) return +new Date(Number(yearMonth[1]), Number(yearMonth[2]) - 1, 1);
172
+ const fullDate = text.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/);
173
+ if (fullDate) return +new Date(Number(fullDate[1]), Number(fullDate[2]) - 1, Number(fullDate[3]));
174
+ if (/^-?\d{1,4}$/.test(text)) return +new Date(Number(text), 0, 1);
175
+ const parsed = +new Date(text);
176
+ return Number.isFinite(parsed) ? parsed : null;
177
+ }
178
+
179
+ function parseOrdinal(value) {
180
+ const text = String(value ?? "").trim().toLowerCase();
181
+ let match = text.match(/^(\d+(?:\.\d+)?)\s*(?:-|to|–)\s*\d+(?:\.\d+)?/);
182
+ if (match) return Number(match[1]);
183
+ match = text.match(/^(\d+(?:\.\d+)?)\s*\+$/);
184
+ if (match) return Number(match[1]);
185
+ match = text.match(/^(-?\d+(?:\.\d+)?)(?:st|nd|rd|th)?\b/);
186
+ if (match) return Number(match[1]);
187
+ const ranked = [
188
+ [/very\s+low|lowest|low|basic|entry/, 1],
189
+ [/medium|moderate|average|standard/, 2],
190
+ [/very\s+high|highest|high|advanced|premium/, 3]
191
+ ];
192
+ for (const [pattern, rank] of ranked) {
193
+ if (pattern.test(text)) return rank;
194
+ }
195
+ return null;
196
+ }
197
+
198
+ function formatNumber(value) {
199
+ const abs = Math.abs(value);
200
+ if (abs >= 1000000000) return d3.format(".3s")(value).replace("G", "B");
201
+ if (abs >= 1000000) return d3.format(".3s")(value);
202
+ if (abs >= 1000) return d3.format(".3s")(value);
203
+ if (abs >= 100) return d3.format(",.0f")(value);
204
+ if (abs >= 10) return d3.format(",.1f")(value).replace(/\.0$/, "");
205
+ return d3.format(",.2f")(value).replace(/\.?0+$/, "");
206
+ }
207
+
208
+ function formatLocalValue(value, includePlus = false) {
209
+ const sign = value > 0 && includePlus ? "+" : "";
210
+ const numberText = `${sign}${formatNumber(value)}`;
211
+ if (!valueUnit || valueUnit === "none") return numberText;
212
+ const prefixMatch = valueUnit.match(/^([$€£¥])(.+)?$/);
213
+ if (prefixMatch) {
214
+ const raw = formatNumber(Math.abs(value));
215
+ const signText = value < 0 ? "-" : (includePlus && value > 0 ? "+" : "");
216
+ return `${signText}${prefixMatch[1]}${raw}${prefixMatch[2] || ""}`;
217
+ }
218
+ if (valueUnit === "%") return `${numberText}%`;
219
+ return `${numberText} ${valueUnit}`;
220
+ }
221
+
222
+ function compactLabel(value, maxChars) {
223
+ const clean = cleanLabel(value);
224
+ if (clean.length <= maxChars) return clean;
225
+ const dashParts = clean.split(/\s+-\s+/).filter(Boolean);
226
+ if (dashParts.length > 1) {
227
+ const suffix = dashParts[dashParts.length - 1];
228
+ const prefixWords = dashParts.slice(0, -1).join(" ").split(/\s+/).filter(Boolean);
229
+ const candidate = `${prefixWords.slice(0, 2).join(" ")} - ${suffix}`;
230
+ if (candidate.length <= maxChars) return candidate;
231
+ }
232
+ const words = clean.split(/\s+/).filter(Boolean);
233
+ if (words.length > 1) {
234
+ const firstLast = `${words[0]} ${words[words.length - 1]}`;
235
+ if (firstLast.length <= maxChars) return firstLast;
236
+ }
237
+ return `${clean.slice(0, maxChars - 1).trim()}...`;
238
+ }
239
+
240
+ const rows = rawData.map((row, index) => {
241
+ const value = Number(row[valueField]);
242
+ const label = row[dimensionField];
243
+ return {
244
+ ...row,
245
+ __index: index,
246
+ __label: cleanLabel(label),
247
+ __value: Number.isFinite(value) ? value : null,
248
+ __temporal: parseTemporal(label),
249
+ __ordinal: parseOrdinal(label)
250
+ };
251
+ }).filter(row => row.__label && row.__value !== null);
252
+
253
+ if (!rows.length) {
254
+ return showMessage("Not enough data for diverging bars");
255
+ }
256
+
257
+ const temporalX = xColumn.data_type === "temporal" || rows.every(row => row.__temporal !== null);
258
+ const ordinalX = !temporalX && rows.every(row => row.__ordinal !== null);
259
+ const sortedData = rows.slice().sort((a, b) => {
260
+ if (temporalX) return d3.ascending(a.__temporal, b.__temporal) || d3.ascending(a.__index, b.__index);
261
+ if (ordinalX) return d3.ascending(a.__ordinal, b.__ordinal) || d3.ascending(a.__index, b.__index);
262
+ return d3.ascending(a.__value, b.__value) || d3.ascending(a.__label, b.__label);
263
+ }).map((row, index) => ({ ...row, __id: index + 1, __rank: index }));
264
+ const orderMode = temporalX ? "temporal" : (ordinalX ? "ordinal" : "value_ascending");
265
+ const signSplitIndex = sortedData.findIndex(row => row.__value >= 0);
266
+ const hasGroupedSplit = signSplitIndex > 0
267
+ && signSplitIndex < sortedData.length
268
+ && sortedData.slice(0, signSplitIndex).every(row => row.__value < 0)
269
+ && sortedData.slice(signSplitIndex).every(row => row.__value >= 0);
270
+
271
+ const maxAbs = Math.max(1, d3.max(sortedData, row => Math.abs(row.__value)) || 1);
272
+ const minValue = d3.min(sortedData, row => row.__value) || 0;
273
+ const maxValue = d3.max(sortedData, row => row.__value) || 0;
274
+ const hasNegative = sortedData.some(row => row.__value < 0);
275
+ const hasPositive = sortedData.some(row => row.__value >= 0);
276
+ const xScale = d3.scaleLinear()
277
+ .domain([-maxAbs, maxAbs])
278
+ .nice(5)
279
+ .range([0, 1]);
280
+
281
+ const cardX = 18;
282
+ const cardY = 18;
283
+ const cardWidth = width - cardX * 2;
284
+ const cardHeight = height - cardY * 2;
285
+ const headerHeight = 58;
286
+ const footerHeight = 48;
287
+ const margin = {
288
+ top: cardY + headerHeight,
289
+ right: cardX + 56,
290
+ bottom: height - (cardY + cardHeight - footerHeight),
291
+ left: cardX + 56
292
+ };
293
+ const chartWidth = Math.max(260, width - margin.left - margin.right);
294
+ const chartHeight = Math.max(210, height - margin.top - margin.bottom);
295
+ xScale.range([0, chartWidth]);
296
+ const sectionGap = hasGroupedSplit ? Math.min(34, Math.max(22, chartHeight * 0.065)) : 0;
297
+ const yScale = d3.scaleBand()
298
+ .domain(sortedData.map(row => row.__id))
299
+ .range([0, chartHeight - sectionGap])
300
+ .padding(sortedData.length > 14 ? 0.18 : 0.26);
301
+ const zeroX = xScale(0);
302
+ const rowHeight = yScale.bandwidth();
303
+ const labelFontSize = Math.max(8.5, Math.min(baseLabelSize, rowHeight * 0.44));
304
+ const valueFontSize = Math.max(8, Math.min(baseValueSize, rowHeight * 0.42));
305
+ const labelMaxChars = Math.max(8, Math.min(16, Math.floor(chartWidth * 0.18 / (labelFontSize * 0.55))));
306
+ function rowY(row) {
307
+ const extra = hasGroupedSplit && row.__rank >= signSplitIndex ? sectionGap : 0;
308
+ return yScale(row.__id) + extra;
309
+ }
310
+
311
+ svg.append("rect")
312
+ .attr("class", "chart-canvas")
313
+ .attr("x", 0)
314
+ .attr("y", 0)
315
+ .attr("width", width)
316
+ .attr("height", height)
317
+ .attr("fill", canvasColor);
318
+
319
+ svg.append("rect")
320
+ .attr("class", "chart-style-panel")
321
+ .attr("x", cardX)
322
+ .attr("y", cardY)
323
+ .attr("width", cardWidth)
324
+ .attr("height", cardHeight)
325
+ .attr("rx", 10)
326
+ .attr("ry", 10)
327
+ .attr("fill", cardColor)
328
+ .attr("stroke", "#d6e2f0")
329
+ .attr("stroke-width", 1.1)
330
+ .attr("filter", "url(#diverging_bar_02_card_shadow)");
331
+
332
+ const plot = svg.append("g")
333
+ .attr("class", "diverging-bar-chart")
334
+ .attr("data-tag", "chart")
335
+ .attr("data-count", sortedData.length)
336
+ .attr("data-order-mode", orderMode)
337
+ .attr("data-min-value", minValue)
338
+ .attr("data-max-value", maxValue)
339
+ .attr("data-max-abs", maxAbs)
340
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
341
+
342
+ const ticks = Array.from(new Set(xScale.ticks(5).map(tick => Number(tick.toPrecision(10)))));
343
+ ticks.forEach(tick => {
344
+ const x = xScale(tick);
345
+ plot.append("line")
346
+ .attr("class", "gridline")
347
+ .attr("data-value", tick)
348
+ .attr("x1", x)
349
+ .attr("x2", x)
350
+ .attr("y1", 0)
351
+ .attr("y2", chartHeight)
352
+ .attr("stroke", tick === 0 ? axisColor : gridColor)
353
+ .attr("stroke-width", tick === 0 ? 1.35 : 1)
354
+ .attr("stroke-dasharray", tick === 0 ? "none" : "3,5")
355
+ .attr("opacity", tick === 0 ? 0.78 : 0.92);
356
+ plot.append("text")
357
+ .attr("class", "axis-tick")
358
+ .attr("data-value", tick)
359
+ .attr("x", x)
360
+ .attr("y", chartHeight + 18)
361
+ .attr("text-anchor", "middle")
362
+ .attr("fill", mutedText)
363
+ .style("font-family", annotationFamily)
364
+ .style("font-size", `${Math.max(8, valueFontSize * 0.92)}px`)
365
+ .text(formatLocalValue(tick, false));
366
+ });
367
+
368
+ plot.append("line")
369
+ .attr("class", "zero-line")
370
+ .attr("x1", zeroX)
371
+ .attr("x2", zeroX)
372
+ .attr("y1", -8)
373
+ .attr("y2", chartHeight)
374
+ .attr("stroke", axisColor)
375
+ .attr("stroke-width", 1.8)
376
+ .attr("opacity", 0.9);
377
+
378
+ if (hasGroupedSplit) {
379
+ const splitY = yScale(sortedData[signSplitIndex].__id) - yScale.step() * 0.5 + sectionGap * 0.5;
380
+ plot.append("line")
381
+ .attr("class", "sign-section-divider")
382
+ .attr("x1", 0)
383
+ .attr("x2", chartWidth)
384
+ .attr("y1", splitY)
385
+ .attr("y2", splitY)
386
+ .attr("stroke", "#cfdbe8")
387
+ .attr("stroke-width", 1)
388
+ .attr("stroke-dasharray", "4,5");
389
+ }
390
+
391
+ function sectionBadge(x, y, color, softColor, label, symbol) {
392
+ const group = plot.append("g")
393
+ .attr("class", "sign-section-badge")
394
+ .attr("transform", `translate(${x}, ${y})`);
395
+ group.append("circle")
396
+ .attr("cx", 0)
397
+ .attr("cy", 0)
398
+ .attr("r", 10)
399
+ .attr("fill", color)
400
+ .attr("opacity", 0.96);
401
+ group.append("text")
402
+ .attr("x", 0)
403
+ .attr("y", 0.5)
404
+ .attr("text-anchor", "middle")
405
+ .attr("dominant-baseline", "middle")
406
+ .attr("fill", "#ffffff")
407
+ .style("font-family", annotationFamily)
408
+ .style("font-size", "12px")
409
+ .style("font-weight", 800)
410
+ .text(symbol);
411
+ group.append("rect")
412
+ .attr("x", 14)
413
+ .attr("y", -11)
414
+ .attr("width", Math.max(106, label.length * 7.4 + 22))
415
+ .attr("height", 22)
416
+ .attr("rx", 11)
417
+ .attr("fill", softColor);
418
+ group.append("text")
419
+ .attr("x", 25)
420
+ .attr("y", 0.5)
421
+ .attr("dominant-baseline", "middle")
422
+ .attr("fill", color)
423
+ .style("font-family", labelFamily)
424
+ .style("font-size", `${Math.max(8.5, Math.min(baseLabelSize, 11))}px`)
425
+ .style("font-weight", 800)
426
+ .text(label);
427
+ }
428
+
429
+ if (hasNegative) {
430
+ sectionBadge(Math.max(10, zeroX - 150), -30, negativeColor, negativeSoft, "Negative values", "-");
431
+ }
432
+ if (hasPositive && !hasGroupedSplit) {
433
+ sectionBadge(Math.min(chartWidth - 150, zeroX + 26), -30, positiveColor, positiveSoft, "Positive values", "+");
434
+ }
435
+ if (hasGroupedSplit) {
436
+ const splitY = yScale(sortedData[signSplitIndex].__id) - yScale.step() * 0.5 + sectionGap * 0.5;
437
+ sectionBadge(Math.min(chartWidth - 150, zeroX + 26), splitY, positiveColor, positiveSoft, "Positive values", "+");
438
+ }
439
+
440
+ const rowGroups = plot.selectAll(".bar-row")
441
+ .data(sortedData)
442
+ .enter()
443
+ .append("g")
444
+ .attr("class", "bar-row")
445
+ .attr("data-label", d => d.__label)
446
+ .attr("data-value", d => d.__value)
447
+ .attr("data-index", d => d.__index)
448
+ .attr("data-order", d => d.__id)
449
+ .attr("data-sign", d => d.__value >= 0 ? "positive" : "negative")
450
+ .attr("transform", d => `translate(0, ${rowY(d)})`);
451
+
452
+ rowGroups.append("text")
453
+ .attr("class", "category-label")
454
+ .attr("data-label", d => d.__label)
455
+ .attr("data-value", d => d.__value)
456
+ .attr("x", d => d.__value >= 0 ? zeroX - 12 : zeroX + 12)
457
+ .attr("y", rowHeight / 2)
458
+ .attr("text-anchor", d => d.__value >= 0 ? "end" : "start")
459
+ .attr("dominant-baseline", "middle")
460
+ .attr("fill", inkColor)
461
+ .style("font-family", labelFamily)
462
+ .style("font-size", `${labelFontSize}px`)
463
+ .style("font-weight", labelWeight)
464
+ .text(d => compactLabel(d.__label, labelMaxChars));
465
+
466
+ rowGroups.append("rect")
467
+ .attr("class", d => `diverging-bar data-point mark ${d.__value >= 0 ? "positive-bar" : "negative-bar"}`)
468
+ .attr("data-tag", "mark")
469
+ .attr("data-label", d => d.__label)
470
+ .attr("data-value", d => d.__value)
471
+ .attr("data-order", d => d.__id)
472
+ .attr("x", d => d.__value >= 0 ? zeroX : xScale(d.__value))
473
+ .attr("y", Math.max(0, rowHeight * 0.13))
474
+ .attr("width", d => Math.max(1, Math.abs(xScale(d.__value) - zeroX)))
475
+ .attr("height", Math.max(3, rowHeight * 0.74))
476
+ .attr("rx", Math.min(rowHeight / 2, 9))
477
+ .attr("ry", Math.min(rowHeight / 2, 9))
478
+ .attr("fill", d => d.__value >= 0 ? "url(#diverging_bar_02_positive_gradient)" : "url(#diverging_bar_02_negative_gradient)")
479
+ .attr("opacity", 0.96)
480
+ .attr("stroke", variables.has_stroke ? "#ffffff" : "none")
481
+ .attr("stroke-width", variables.has_stroke ? 0.8 : 0);
482
+
483
+ rowGroups.append("text")
484
+ .attr("class", "value-label")
485
+ .attr("data-label", d => d.__label)
486
+ .attr("data-value", d => d.__value)
487
+ .attr("x", d => {
488
+ const end = xScale(d.__value);
489
+ const pad = 8;
490
+ if (d.__value >= 0) return Math.min(chartWidth, end + pad);
491
+ return Math.max(0, end - pad);
492
+ })
493
+ .attr("y", rowHeight / 2)
494
+ .attr("text-anchor", d => d.__value >= 0 ? "start" : "end")
495
+ .attr("dominant-baseline", "middle")
496
+ .attr("fill", d => d.__value >= 0 ? positiveColor : negativeColor)
497
+ .style("font-family", annotationFamily)
498
+ .style("font-size", `${valueFontSize}px`)
499
+ .style("font-weight", 800)
500
+ .text(d => formatLocalValue(d.__value, true));
501
+
502
+ svg.append("text")
503
+ .attr("class", "axis-title")
504
+ .attr("x", margin.left + chartWidth / 2)
505
+ .attr("y", cardY + cardHeight - 16)
506
+ .attr("text-anchor", "middle")
507
+ .attr("dominant-baseline", "middle")
508
+ .attr("fill", axisColor)
509
+ .style("font-family", annotationFamily)
510
+ .style("font-size", `${Math.max(8.5, baseValueSize * 0.86)}px`)
511
+ .style("font-weight", 700)
512
+ .text(valueUnit && valueUnit !== "none"
513
+ ? `${humanizeField(valueField)} (${valueUnit})`
514
+ : humanizeField(valueField));
515
+
516
+ return svg.node();
517
+ }
modules/chart_engine/template/d3-js/type17_diverging_bar_chart/diverging_bar_plain_chart_03.js ADDED
@@ -0,0 +1,690 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Horizontal Diverging Bar Chart",
5
+ "chart_name": "horizontal_diverging_bar_chart_04",
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": ["x"],
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": "styled",
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
+ function makeChart(containerSelector, data) {
28
+ const jsonData = data;
29
+ const rawRows = (jsonData.data && jsonData.data.data) || [];
30
+ const variables = jsonData.variables || {};
31
+ const typography = jsonData.typography || {};
32
+ const colors = jsonData.colors || {};
33
+ const images = jsonData.images || { field: {}, other: {} };
34
+ const colorResolver = chartUtils.color.resolver(jsonData);
35
+ const columns = chartUtils.schema.columns(jsonData);
36
+
37
+ const dimensionChannel = chartUtils.schema.channel(jsonData, "x");
38
+ const valueChannel = chartUtils.schema.channel(jsonData, "y");
39
+ const groupChannel = chartUtils.schema.channel(jsonData, "group");
40
+ const dimensionField = dimensionChannel.key;
41
+ const valueField = valueChannel.key;
42
+ const groupField = groupChannel.key;
43
+ const dimensionColumn = columns.find(c => c.name === dimensionField) || {};
44
+ const valueUnit = valueChannel.unit && valueChannel.unit !== "none" ? valueChannel.unit : "";
45
+
46
+ d3.select(containerSelector).html("");
47
+
48
+ const width = 1566;
49
+ const height = 1004;
50
+ const bgColor = "#E2F1F6";
51
+ const textColor = colorResolver.text({ fallback: "#12304f" }).value || "#12304f";
52
+ const titleColor = "#08264A";
53
+ const mutedText = "#45515A";
54
+ const fieldImages = (images && images.field) || {};
55
+ const primaryImage = images && images.other && images.other.primary;
56
+
57
+ const titleText = (jsonData.titles && jsonData.titles.main_title) ||
58
+ (jsonData.metadata && jsonData.metadata.title) ||
59
+ "Diverging Comparison";
60
+ const subtitleText = (jsonData.titles && jsonData.titles.sub_title) ||
61
+ (jsonData.metadata && jsonData.metadata.description) ||
62
+ (jsonData.description || "");
63
+
64
+ const isTemporal = dimensionColumn.data_type === "temporal" ||
65
+ rawRows.every(d => /^\d{4}$/.test(String(d[dimensionField])));
66
+ function parseTemporal(value) {
67
+ const raw = String(value);
68
+ if (/^\d{4}$/.test(raw)) return +raw;
69
+ const parsed = Date.parse(raw);
70
+ return Number.isFinite(parsed) ? parsed : raw;
71
+ }
72
+
73
+ const groups = [...new Set(rawRows.map(d => d[groupField]).filter(d => d !== undefined))].slice(0, 2);
74
+ const leftGroup = groups[0] || "Left";
75
+ const rightGroup = groups[1] || "Right";
76
+ let dimensions = [...new Set(rawRows.map(d => d[dimensionField]).filter(d => d !== undefined))];
77
+ if (isTemporal) {
78
+ dimensions = dimensions.sort((a, b) => {
79
+ const av = parseTemporal(a);
80
+ const bv = parseTemporal(b);
81
+ if (typeof av === "number" && typeof bv === "number") return av - bv;
82
+ return String(a).localeCompare(String(b));
83
+ });
84
+ }
85
+
86
+ const lookup = new Map();
87
+ rawRows.forEach((d, i) => {
88
+ lookup.set(`${d[dimensionField]}|||${d[groupField]}`, { ...d, __inputIndex: i, __value: +d[valueField] || 0 });
89
+ });
90
+ const rows = dimensions.map((dimension, index) => ({
91
+ dimension,
92
+ index,
93
+ left: lookup.get(`${dimension}|||${leftGroup}`),
94
+ right: lookup.get(`${dimension}|||${rightGroup}`)
95
+ })).filter(row => row.left || row.right);
96
+ const rowCount = Math.max(1, rows.length);
97
+ const allValues = rawRows.map(d => +d[valueField] || 0);
98
+ const maxValue = d3.max(allValues) || 1;
99
+
100
+ function groupColor(group, fallback, side) {
101
+ const resolved = colorResolver.field(group, 0, { fallbackKey: side === "left" ? "primary" : "secondary" }).value;
102
+ return resolved || fallback;
103
+ }
104
+ const leftColor = colorResolver.variant(groupColor(leftGroup, "#A50F15", "left"), { mode: "darker", amount: 0.04 });
105
+ const rightColor = colorResolver.variant(groupColor(rightGroup, "#164C82", "right"), { mode: "darker", amount: 0.42 });
106
+
107
+ function safeId(value) {
108
+ return String(value || "").replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "item";
109
+ }
110
+
111
+ function humanize(text) {
112
+ return String(text == null ? "" : text)
113
+ .replace(/_/g, " ")
114
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
115
+ .replace(/\s*-\s*/g, " - ")
116
+ .replace(/\s+/g, " ")
117
+ .trim();
118
+ }
119
+
120
+ const svg = d3.select(containerSelector)
121
+ .append("svg")
122
+ .attr("width", width)
123
+ .attr("height", height)
124
+ .attr("viewBox", `0 0 ${width} ${height}`)
125
+ .attr("preserveAspectRatio", "xMidYMid meet")
126
+ .attr("xmlns", "http://www.w3.org/2000/svg")
127
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
128
+ .attr("style", "max-width: 100%; height: auto;");
129
+
130
+ const defs = svg.append("defs");
131
+ const bg = defs.append("linearGradient")
132
+ .attr("id", "hdbc04-bg")
133
+ .attr("x1", "0%").attr("y1", "0%")
134
+ .attr("x2", "100%").attr("y2", "100%");
135
+ bg.append("stop").attr("offset", "0%").attr("stop-color", "#F6FCFD");
136
+ bg.append("stop").attr("offset", "55%").attr("stop-color", bgColor);
137
+ bg.append("stop").attr("offset", "100%").attr("stop-color", "#D9EEF5");
138
+
139
+ function addGradient(id, base, side) {
140
+ const gradient = defs.append("linearGradient")
141
+ .attr("id", id)
142
+ .attr("x1", side === "left" ? "100%" : "0%")
143
+ .attr("x2", side === "left" ? "0%" : "100%")
144
+ .attr("y1", "0%")
145
+ .attr("y2", "0%");
146
+ gradient.append("stop")
147
+ .attr("offset", "0%")
148
+ .attr("stop-color", colorResolver.variant(base, { mode: "darker", amount: 0.12 }));
149
+ gradient.append("stop")
150
+ .attr("offset", "100%")
151
+ .attr("stop-color", colorResolver.variant(base, { mode: "brighter", amount: 0.10 }));
152
+ }
153
+ addGradient("hdbc04-left-bar", leftColor, "left");
154
+ addGradient("hdbc04-right-bar", rightColor, "right");
155
+
156
+ const shadow = defs.append("filter")
157
+ .attr("id", "hdbc04-soft-shadow")
158
+ .attr("x", "-25%").attr("y", "-25%")
159
+ .attr("width", "150%").attr("height", "150%");
160
+ shadow.append("feDropShadow")
161
+ .attr("dx", 0)
162
+ .attr("dy", 4)
163
+ .attr("stdDeviation", 5)
164
+ .attr("flood-color", "#163246")
165
+ .attr("flood-opacity", 0.16);
166
+
167
+ svg.append("rect")
168
+ .attr("class", "poster-background")
169
+ .attr("x", 0)
170
+ .attr("y", 0)
171
+ .attr("width", width)
172
+ .attr("height", height)
173
+ .attr("rx", 28)
174
+ .attr("fill", "url(#hdbc04-bg)");
175
+ svg.append("rect")
176
+ .attr("x", 1.5)
177
+ .attr("y", 1.5)
178
+ .attr("width", width - 3)
179
+ .attr("height", height - 3)
180
+ .attr("rx", 28)
181
+ .attr("fill", "none")
182
+ .attr("stroke", "#D4EAF1")
183
+ .attr("stroke-width", 3);
184
+
185
+ function measure(text, fontSize, fontWeight = 400, family = "Arial, Helvetica, sans-serif") {
186
+ return chartUtils.text.measure(svg, String(text || ""), {
187
+ fontFamily: family,
188
+ fontSize: `${fontSize}px`,
189
+ fontWeight
190
+ }).width;
191
+ }
192
+
193
+ function wrapLines(text, maxWidth, fontSize, fontWeight, maxLines, family) {
194
+ const words = String(text || "").split(/\s+/).filter(Boolean);
195
+ const lines = [];
196
+ let line = "";
197
+ words.forEach(word => {
198
+ const candidate = line ? `${line} ${word}` : word;
199
+ if (line && measure(candidate, fontSize, fontWeight, family) > maxWidth) {
200
+ lines.push(line);
201
+ line = word;
202
+ } else {
203
+ line = candidate;
204
+ }
205
+ });
206
+ if (line) lines.push(line);
207
+ const visible = lines.slice(0, maxLines);
208
+ if (lines.length > maxLines && visible.length) {
209
+ let last = visible[visible.length - 1];
210
+ while (last.length > 4 && measure(`${last}...`, fontSize, fontWeight, family) > maxWidth) {
211
+ last = last.split(/\s+/).slice(0, -1).join(" ");
212
+ if (!last) break;
213
+ }
214
+ visible[visible.length - 1] = `${last || visible[visible.length - 1]}...`;
215
+ }
216
+ return visible;
217
+ }
218
+
219
+ function addWrappedText(parent, text, x, y, maxWidth, options) {
220
+ const cfg = {
221
+ fontFamily: "Arial, Helvetica, sans-serif",
222
+ fontSize: 24,
223
+ fontWeight: 400,
224
+ fill: textColor,
225
+ anchor: "start",
226
+ lineHeight: 1.18,
227
+ maxLines: 3,
228
+ transform: value => value,
229
+ ...options
230
+ };
231
+ const lines = wrapLines(cfg.transform(text), maxWidth, cfg.fontSize, cfg.fontWeight, cfg.maxLines, cfg.fontFamily);
232
+ const el = parent.append("text")
233
+ .attr("x", x)
234
+ .attr("y", y)
235
+ .attr("text-anchor", cfg.anchor)
236
+ .style("font-family", cfg.fontFamily)
237
+ .style("font-size", `${cfg.fontSize}px`)
238
+ .style("font-weight", cfg.fontWeight)
239
+ .style("fill", cfg.fill)
240
+ .style("letter-spacing", "0");
241
+ lines.forEach((line, i) => {
242
+ el.append("tspan")
243
+ .attr("x", x)
244
+ .attr("dy", i === 0 ? 0 : `${cfg.lineHeight}em`)
245
+ .text(line);
246
+ });
247
+ return el;
248
+ }
249
+
250
+ function formatValue(value) {
251
+ const abs = Math.abs(value);
252
+ let text;
253
+ if (abs >= 1000) {
254
+ const scaled = value / 1000;
255
+ text = `${Number.isInteger(scaled) ? scaled.toFixed(0) : scaled.toFixed(abs >= 10000 ? 0 : 2).replace(/0+$/, "").replace(/\.$/, "")}K`;
256
+ } else if (abs >= 100) {
257
+ text = chartUtils.format.autoText(value);
258
+ } else if (abs >= 10) {
259
+ text = Number.isInteger(value) ? String(value) : value.toFixed(1).replace(/0$/, "").replace(/\.$/, "");
260
+ } else {
261
+ text = chartUtils.format.autoText(value);
262
+ }
263
+ return `${text}${valueUnit && !text.endsWith(valueUnit) ? valueUnit : ""}`;
264
+ }
265
+
266
+ const leftColumn = { x: 78, y: 66, w: 440, h: 828 };
267
+ const imageSlot = { x: leftColumn.x + 30, y: leftColumn.y + 28, w: 340, h: 356 };
268
+ const titleY = leftColumn.y + 470;
269
+
270
+ const contextGroup = svg.append("g")
271
+ .attr("class", "asset-slot left-context-image-slot")
272
+ .attr("data-asset-slot", "left-context-image")
273
+ .attr("data-reserved-slot", "left_context_image_slot")
274
+ .attr("data-asset-source-policy", primaryImage ? "input_asset" : "image2_asset_or_neutral_placeholder")
275
+ .attr("data-anchor", "left-title-safe-area-above-title")
276
+ .attr("data-collision-rule", "stay inside left column; avoid title, subtitle, group headers, bars, and category labels")
277
+ .attr("data-bbox", `${imageSlot.x},${imageSlot.y},${imageSlot.w},${imageSlot.h}`);
278
+ contextGroup.append("circle")
279
+ .attr("cx", imageSlot.x + imageSlot.w / 2)
280
+ .attr("cy", imageSlot.y + imageSlot.h / 2)
281
+ .attr("r", Math.min(imageSlot.w, imageSlot.h) * 0.47)
282
+ .attr("fill", "#CDEAF1")
283
+ .attr("opacity", 0.72);
284
+ if (primaryImage) {
285
+ const clipId = "hdbc04-left-context-clip";
286
+ defs.append("clipPath").attr("id", clipId)
287
+ .append("circle")
288
+ .attr("cx", imageSlot.x + imageSlot.w / 2)
289
+ .attr("cy", imageSlot.y + imageSlot.h / 2)
290
+ .attr("r", Math.min(imageSlot.w, imageSlot.h) * 0.47);
291
+ contextGroup.append("image")
292
+ .attr("x", imageSlot.x + 12)
293
+ .attr("y", imageSlot.y + 12)
294
+ .attr("width", imageSlot.w - 24)
295
+ .attr("height", imageSlot.h - 24)
296
+ .attr("preserveAspectRatio", "xMidYMid meet")
297
+ .attr("clip-path", `url(#${clipId})`)
298
+ .attr("opacity", 0.95)
299
+ .attr("href", primaryImage)
300
+ .attr("xlink:href", primaryImage);
301
+ } else {
302
+ contextGroup.append("rect")
303
+ .attr("x", imageSlot.x + 45)
304
+ .attr("y", imageSlot.y + 60)
305
+ .attr("width", imageSlot.w - 90)
306
+ .attr("height", imageSlot.h - 120)
307
+ .attr("rx", 18)
308
+ .attr("fill", "none")
309
+ .attr("stroke", "#91B0BC")
310
+ .attr("stroke-dasharray", "10 8")
311
+ .attr("opacity", 0.55);
312
+ }
313
+
314
+ const titleFamily = (typography.title && typography.title.font_family) || "Arial, Helvetica, sans-serif";
315
+ addWrappedText(svg, titleText, leftColumn.x, titleY, leftColumn.w, {
316
+ fontFamily: titleFamily,
317
+ fontSize: 36,
318
+ fontWeight: 900,
319
+ fill: titleColor,
320
+ maxLines: 4,
321
+ lineHeight: 1.12,
322
+ transform: value => String(value || "").toUpperCase()
323
+ });
324
+ svg.append("line")
325
+ .attr("x1", leftColumn.x)
326
+ .attr("x2", leftColumn.x + 58)
327
+ .attr("y1", titleY + 142)
328
+ .attr("y2", titleY + 142)
329
+ .attr("stroke", "#1E7892")
330
+ .attr("stroke-width", 4);
331
+ addWrappedText(svg, subtitleText, leftColumn.x, titleY + 192, leftColumn.w - 28, {
332
+ fontFamily: "Arial, Helvetica, sans-serif",
333
+ fontSize: 26,
334
+ fontWeight: 400,
335
+ fill: mutedText,
336
+ maxLines: 4,
337
+ lineHeight: 1.25
338
+ });
339
+
340
+ const plot = {
341
+ left: 512,
342
+ right: 1512,
343
+ top: 150,
344
+ bottom: 860
345
+ };
346
+ const rowBand = (plot.bottom - plot.top) / rowCount;
347
+ const rowGap = Math.max(5, Math.min(12, rowBand * 0.15));
348
+ const barH = Math.max(17, Math.min(56, rowBand - rowGap));
349
+ const chipR = Math.max(17, Math.min(31, barH * 0.55));
350
+ const labelGap = 16;
351
+ const iconX = 938;
352
+ const leftInnerX = 888;
353
+ const rightInnerX = 1100;
354
+ const categoryLabelX = iconX + chipR + 14;
355
+ const categoryW = Math.max(76, rightInnerX - categoryLabelX - 8);
356
+ const leftMinX = plot.left + 120;
357
+ const rightMaxX = plot.right - 110;
358
+ const leftMaxValue = d3.max(rawRows.filter(d => d[groupField] === leftGroup), d => +d[valueField] || 0) || maxValue;
359
+ const rightMaxValue = d3.max(rawRows.filter(d => d[groupField] === rightGroup), d => +d[valueField] || 0) || maxValue;
360
+ const leftScale = d3.scaleLinear().domain([0, leftMaxValue]).range([0, 315]);
361
+ const rightScale = d3.scaleLinear().domain([0, rightMaxValue]).range([0, 365]);
362
+
363
+ function groupIcon(group, x, y, color, side, options = {}) {
364
+ const r = options.radius || 27;
365
+ const visible = options.visible !== false;
366
+ const bbox = `${(x - r).toFixed(1)},${(y - r).toFixed(1)},${(r * 2).toFixed(1)},${(r * 2).toFixed(1)}`;
367
+ const slot = svg.append("g")
368
+ .attr("class", "asset-slot group-icon-slot")
369
+ .attr("data-asset-slot", "group-icon")
370
+ .attr("data-group", String(group))
371
+ .attr("data-asset-source-policy", fieldImages[group] ? "input_asset" : "neutral_placeholder")
372
+ .attr("data-anchor", `${side}-group-header`)
373
+ .attr("data-collision-rule", "stay in header row above plot bars; keep at least 16px clear of measured group-title bbox; hide optional badge if no collision-free slot exists")
374
+ .attr("data-bbox", bbox)
375
+ .attr("data-collision-result", visible ? "visible_clear" : "hidden_no_collision_free_header_slot");
376
+ if (!visible) {
377
+ slot.append("circle")
378
+ .attr("cx", x)
379
+ .attr("cy", y)
380
+ .attr("r", r)
381
+ .attr("fill", "none")
382
+ .attr("stroke", "none");
383
+ return;
384
+ }
385
+ slot.append("circle")
386
+ .attr("cx", x)
387
+ .attr("cy", y)
388
+ .attr("r", r)
389
+ .attr("fill", colorResolver.variant(color, { mode: "brighter", amount: 0.85 }))
390
+ .attr("opacity", 0.96);
391
+ if (fieldImages[group]) {
392
+ slot.append("image")
393
+ .attr("x", x - r * 0.68)
394
+ .attr("y", y - r * 0.68)
395
+ .attr("width", r * 1.36)
396
+ .attr("height", r * 1.36)
397
+ .attr("preserveAspectRatio", "xMidYMid meet")
398
+ .attr("href", fieldImages[group])
399
+ .attr("xlink:href", fieldImages[group]);
400
+ } else {
401
+ slot.append("text")
402
+ .attr("x", x)
403
+ .attr("y", y + 1)
404
+ .attr("text-anchor", "middle")
405
+ .attr("dominant-baseline", "middle")
406
+ .style("font-family", "Arial, Helvetica, sans-serif")
407
+ .style("font-size", "22px")
408
+ .style("font-weight", 800)
409
+ .style("fill", color)
410
+ .text(String(group).trim().charAt(0).toUpperCase());
411
+ }
412
+ }
413
+
414
+ function addHeader(group, x, color, side) {
415
+ const y = 96;
416
+ const label = String(group || "").toUpperCase();
417
+ const minGap = 18;
418
+ const badgeR = label.length <= 18 ? 27 : 22;
419
+ let lines;
420
+ let fontSize;
421
+ let lineHeight;
422
+ if (label.length <= 18) {
423
+ lines = [label];
424
+ fontSize = 31;
425
+ lineHeight = fontSize * 1.02;
426
+ } else {
427
+ lines = wrapLines(label, 205, 17, 900, 2, "Arial, Helvetica, sans-serif");
428
+ fontSize = 17;
429
+ lineHeight = fontSize * 1.02;
430
+ }
431
+ const lineWidths = lines.map(line => measure(line, fontSize, 900, "Arial, Helvetica, sans-serif"));
432
+ const textLeft = d3.min(lineWidths, w => x - w / 2) || x;
433
+ const textRight = d3.max(lineWidths, w => x + w / 2) || x;
434
+ const textTop = label.length <= 18 ? y - fontSize : y - 9 - fontSize;
435
+ const textBottom = (label.length <= 18 ? y : y - 9) + Math.max(1, lines.length - 1) * lineHeight + 4;
436
+ let badgeX = textLeft - minGap - badgeR;
437
+ const headerSafeLeft = side === "left" ? plot.left + 6 : rightInnerX + 8;
438
+ const headerSafeRight = side === "left" ? leftInnerX - 6 : plot.right - 8;
439
+ let badgeVisible = true;
440
+ if (badgeX - badgeR < headerSafeLeft) {
441
+ const rightCandidate = textRight + minGap + badgeR;
442
+ if (rightCandidate + badgeR <= headerSafeRight) {
443
+ badgeX = rightCandidate;
444
+ } else {
445
+ badgeVisible = false;
446
+ badgeX = Math.max(headerSafeLeft + badgeR, Math.min(headerSafeRight - badgeR, textLeft - minGap - badgeR));
447
+ }
448
+ }
449
+ const badgeY = label.length <= 18 ? y - 6 : (textTop + textBottom) / 2 - 1;
450
+ groupIcon(group, badgeX, badgeY, color, side, { radius: badgeR, visible: badgeVisible });
451
+ if (label.length <= 18) {
452
+ svg.append("text")
453
+ .attr("x", x)
454
+ .attr("y", y)
455
+ .attr("text-anchor", "middle")
456
+ .style("font-family", "Arial, Helvetica, sans-serif")
457
+ .style("font-size", "31px")
458
+ .style("font-weight", 900)
459
+ .style("fill", color)
460
+ .style("letter-spacing", "0")
461
+ .text(label);
462
+ } else {
463
+ const el = svg.append("text")
464
+ .attr("x", x)
465
+ .attr("y", y - 9)
466
+ .attr("text-anchor", "middle")
467
+ .style("font-family", "Arial, Helvetica, sans-serif")
468
+ .style("font-size", "17px")
469
+ .style("font-weight", 900)
470
+ .style("fill", color)
471
+ .style("letter-spacing", "0");
472
+ lines.forEach((line, i) => {
473
+ el.append("tspan")
474
+ .attr("x", x)
475
+ .attr("dy", i === 0 ? 0 : `${lineHeight / fontSize}em`)
476
+ .text(line);
477
+ });
478
+ el.attr("data-header-text-bbox", `${textLeft.toFixed(1)},${textTop.toFixed(1)},${(textRight - textLeft).toFixed(1)},${(textBottom - textTop).toFixed(1)}`)
479
+ .attr("data-header-badge-gap", String(minGap));
480
+ }
481
+ }
482
+ addHeader(leftGroup, 752, leftColor, "left");
483
+ addHeader(rightGroup, 1294, rightColor, "right");
484
+
485
+ rows.forEach((row, index) => {
486
+ const y0 = plot.top + index * rowBand;
487
+ const cy = y0 + rowBand / 2;
488
+ svg.append("line")
489
+ .attr("class", "row-guide")
490
+ .attr("x1", plot.left + 42)
491
+ .attr("x2", plot.right - 38)
492
+ .attr("y1", y0 + rowBand)
493
+ .attr("y2", y0 + rowBand)
494
+ .attr("stroke", "#C5D9DF")
495
+ .attr("stroke-width", 1)
496
+ .attr("stroke-dasharray", "3 5")
497
+ .attr("opacity", 0.62);
498
+ });
499
+
500
+ function roundedOuterBarPath(xInner, y, barWidth, h, side) {
501
+ const r = Math.min(h / 2, 14, Math.max(2, barWidth / 2));
502
+ if (side === "left") {
503
+ const xOuter = xInner - barWidth;
504
+ return [
505
+ `M ${xInner} ${y}`,
506
+ `L ${xOuter + r} ${y}`,
507
+ `Q ${xOuter} ${y} ${xOuter} ${y + r}`,
508
+ `L ${xOuter} ${y + h - r}`,
509
+ `Q ${xOuter} ${y + h} ${xOuter + r} ${y + h}`,
510
+ `L ${xInner} ${y + h}`,
511
+ "Z"
512
+ ].join(" ");
513
+ }
514
+ const xOuter = xInner + barWidth;
515
+ return [
516
+ `M ${xInner} ${y}`,
517
+ `L ${xOuter - r} ${y}`,
518
+ `Q ${xOuter} ${y} ${xOuter} ${y + r}`,
519
+ `L ${xOuter} ${y + h - r}`,
520
+ `Q ${xOuter} ${y + h} ${xOuter - r} ${y + h}`,
521
+ `L ${xInner} ${y + h}`,
522
+ "Z"
523
+ ].join(" ");
524
+ }
525
+
526
+ function addValueLabel(value, endpoint, cy, side, barWidth) {
527
+ const text = formatValue(value);
528
+ const fontSize = Math.max(17, Math.min(28, barH * 0.52));
529
+ const labelWidth = measure(text, fontSize, 800);
530
+ if (Math.abs(value) < 1e-9) {
531
+ svg.append("text")
532
+ .attr("class", `zero-value ${side}-zero-value`)
533
+ .attr("x", side === "left" ? leftInnerX - 18 : rightInnerX + 18)
534
+ .attr("y", cy)
535
+ .attr("dy", "0.35em")
536
+ .attr("text-anchor", side === "left" ? "end" : "start")
537
+ .style("font-family", "Arial, Helvetica, sans-serif")
538
+ .style("font-size", `${fontSize}px`)
539
+ .style("font-weight", 800)
540
+ .style("fill", side === "left" ? leftColor : rightColor)
541
+ .text(text);
542
+ return;
543
+ }
544
+ if (side === "left") {
545
+ const shortLeftBar = barWidth < labelWidth + 72;
546
+ const externalX = Math.max(plot.left + 4, endpoint - labelGap);
547
+ const internalX = shortLeftBar ? endpoint + barWidth / 2 : leftInnerX - 20;
548
+ const internalStart = shortLeftBar ? internalX - labelWidth / 2 : internalX - labelWidth;
549
+ const externalEnd = externalX;
550
+ const renderInternal = internalStart - externalEnd > 8 || !shortLeftBar;
551
+ if (renderInternal) {
552
+ svg.append("text")
553
+ .attr("class", "value-label left-value-label")
554
+ .attr("x", internalX)
555
+ .attr("y", cy)
556
+ .attr("dy", "0.35em")
557
+ .attr("text-anchor", shortLeftBar ? "middle" : "end")
558
+ .style("font-family", "Arial, Helvetica, sans-serif")
559
+ .style("font-size", `${fontSize}px`)
560
+ .style("font-weight", 800)
561
+ .style("fill", "#FFFFFF")
562
+ .text(text);
563
+ }
564
+ svg.append("text")
565
+ .attr("class", "outer-value left-outer-value")
566
+ .attr("x", externalX)
567
+ .attr("y", cy)
568
+ .attr("dy", "0.35em")
569
+ .attr("text-anchor", "end")
570
+ .style("font-family", "Arial, Helvetica, sans-serif")
571
+ .style("font-size", `${fontSize}px`)
572
+ .style("font-weight", 800)
573
+ .style("fill", leftColor)
574
+ .text(text);
575
+ } else {
576
+ const inside = barWidth > labelWidth + 48;
577
+ svg.append("text")
578
+ .attr("class", "value-label right-value-label")
579
+ .attr("x", inside ? rightInnerX + barWidth - 20 : endpoint + labelGap)
580
+ .attr("y", cy)
581
+ .attr("dy", "0.35em")
582
+ .attr("text-anchor", inside ? "end" : "start")
583
+ .style("font-family", "Arial, Helvetica, sans-serif")
584
+ .style("font-size", `${fontSize}px`)
585
+ .style("font-weight", 800)
586
+ .style("fill", inside ? "#FFFFFF" : rightColor)
587
+ .text(text);
588
+ }
589
+ }
590
+
591
+ function addCategoryChip(row, cy) {
592
+ const dimension = row.dimension;
593
+ const slot = svg.append("g")
594
+ .attr("class", "asset-slot category-icon-slot")
595
+ .attr("data-asset-slot", "category-icon")
596
+ .attr("data-category", String(dimension))
597
+ .attr("data-asset-source-policy", fieldImages[dimension] ? "input_asset" : "neutral_placeholder")
598
+ .attr("data-anchor", "row-centerline-before-category-label")
599
+ .attr("data-collision-rule", "stay inside central label column and avoid bars/value labels")
600
+ .attr("data-bbox", `${(iconX - chipR).toFixed(1)},${(cy - chipR).toFixed(1)},${(chipR * 2).toFixed(1)},${(chipR * 2).toFixed(1)}`);
601
+ slot.append("circle")
602
+ .attr("cx", iconX)
603
+ .attr("cy", cy)
604
+ .attr("r", chipR)
605
+ .attr("fill", "#FFFFFF")
606
+ .attr("stroke", "#E6ECEE")
607
+ .attr("stroke-width", 1.2)
608
+ .attr("filter", "url(#hdbc04-soft-shadow)");
609
+ if (fieldImages[dimension]) {
610
+ slot.append("image")
611
+ .attr("x", iconX - chipR * 0.62)
612
+ .attr("y", cy - chipR * 0.62)
613
+ .attr("width", chipR * 1.24)
614
+ .attr("height", chipR * 1.24)
615
+ .attr("preserveAspectRatio", "xMidYMid meet")
616
+ .attr("href", fieldImages[dimension])
617
+ .attr("xlink:href", fieldImages[dimension]);
618
+ } else {
619
+ slot.append("text")
620
+ .attr("x", iconX)
621
+ .attr("y", cy + 1)
622
+ .attr("text-anchor", "middle")
623
+ .attr("dominant-baseline", "middle")
624
+ .style("font-family", "Arial, Helvetica, sans-serif")
625
+ .style("font-size", `${Math.max(12, chipR * 0.58)}px`)
626
+ .style("font-weight", 800)
627
+ .style("fill", "#607D8B")
628
+ .text(String(dimension).trim().charAt(0).toUpperCase());
629
+ }
630
+ }
631
+
632
+ rows.forEach(row => {
633
+ const cy = plot.top + row.index * rowBand + rowBand / 2;
634
+ const y = cy - barH / 2;
635
+ const leftValue = row.left ? +row.left[valueField] || 0 : 0;
636
+ const rightValue = row.right ? +row.right[valueField] || 0 : 0;
637
+ const leftW = leftValue > 0 ? Math.max(3, leftScale(leftValue)) : 0;
638
+ const rightW = rightValue > 0 ? Math.max(3, rightScale(rightValue)) : 0;
639
+
640
+ if (leftW > 0) {
641
+ svg.append("path")
642
+ .attr("class", "bar left-bar")
643
+ .attr("d", roundedOuterBarPath(leftInnerX, y, leftW, barH, "left"))
644
+ .attr("fill", "url(#hdbc04-left-bar)")
645
+ .attr("data-category", String(row.dimension))
646
+ .attr("data-group", String(leftGroup))
647
+ .attr("data-value", leftValue)
648
+ .attr("data-cap-geometry", "flat-inner-edge-rounded-outer-value-edge");
649
+ }
650
+ if (rightW > 0) {
651
+ svg.append("path")
652
+ .attr("class", "bar right-bar")
653
+ .attr("d", roundedOuterBarPath(rightInnerX, y, rightW, barH, "right"))
654
+ .attr("fill", "url(#hdbc04-right-bar)")
655
+ .attr("data-category", String(row.dimension))
656
+ .attr("data-group", String(rightGroup))
657
+ .attr("data-value", rightValue)
658
+ .attr("data-cap-geometry", "flat-inner-edge-rounded-outer-value-edge");
659
+ }
660
+
661
+ addValueLabel(leftValue, leftInnerX - leftW, cy, "left", leftW);
662
+ addValueLabel(rightValue, rightInnerX + rightW, cy, "right", rightW);
663
+ addCategoryChip(row, cy);
664
+
665
+ const labelText = humanize(row.dimension);
666
+ const label = svg.append("text")
667
+ .attr("class", "category-label")
668
+ .attr("x", categoryLabelX)
669
+ .attr("y", cy)
670
+ .attr("dy", "0.35em")
671
+ .attr("text-anchor", "start")
672
+ .style("font-family", "Arial, Helvetica, sans-serif")
673
+ .style("font-size", `${Math.max(16, Math.min(24, rowBand * 0.30))}px`)
674
+ .style("font-weight", 600)
675
+ .style("fill", "#181F24")
676
+ .style("letter-spacing", "0")
677
+ .text(labelText);
678
+ const estimated = measure(labelText, Math.max(16, Math.min(24, rowBand * 0.30)), 600);
679
+ if (estimated > categoryW) {
680
+ label.attr("textLength", categoryW).attr("lengthAdjust", "spacingAndGlyphs");
681
+ }
682
+ });
683
+
684
+ svg.append("g")
685
+ .attr("class", "ignored-bottom-wrapper-policy")
686
+ .attr("data-ignored-element", "bottom_source_takeaway_footer")
687
+ .attr("data-policy", "not-rendered");
688
+
689
+ return svg.node();
690
+ }
modules/chart_engine/template/d3-js/type17_diverging_bar_chart/diverging_bar_plain_chart_04.js ADDED
@@ -0,0 +1,551 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Diverging Bar Chart",
5
+ "chart_name": "diverging_bar_plain_chart_04",
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": "styled",
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 split_bar_chart02
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 || 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
+
55
+ // 清空容器 - 在添加新图表前移除可能存在的内容
56
+ d3.select(containerSelector).html("");
57
+
58
+ // ---------- 2. 尺寸和布局设置 ----------
59
+
60
+ // 设置图表总尺寸和边距
61
+ const width = variables.width; // 图表总宽度
62
+ const height = variables.height; // 图表总高度
63
+ // 边距:top-顶部,right-右侧,bottom-底部,left-左侧
64
+ const margin = { top: 100, right: 70, bottom: 40, left: 70 };
65
+
66
+ // 计算实际绘图区域尺寸
67
+ const innerWidth = width - margin.left - margin.right; // 绘图区宽度
68
+ const innerHeight = height - margin.top - margin.bottom; // 绘图区高度
69
+
70
+ // 设置各部分位置参数
71
+ const centerX = margin.left + innerWidth / 2; // 图表中心X坐标
72
+ const topAreaHeight = 60; // 标题和标签区域高度
73
+ const barPadding = 8; // 条形图之间的间距
74
+
75
+ // ---------- 3. 提取字段名和单位 ----------
76
+
77
+ // 根据数据列顺序提取字段名
78
+ const dimensionField = chartUtils.schema.channel(jsonData, "x").key;
79
+ const valueField = chartUtils.schema.channel(jsonData, "y").key;
80
+ const groupField = chartUtils.schema.channel(jsonData, "group").key;
81
+
82
+ // 获取字段单位(如果存在)
83
+ let dimensionUnit = "";
84
+ let valueUnit = ""; // 默认为百分比
85
+ let groupUnit = "";
86
+
87
+ if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
88
+ dimensionUnit = chartUtils.schema.channel(jsonData, "x").unit;
89
+ }
90
+
91
+ if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
92
+ valueUnit = chartUtils.schema.channel(jsonData, "y").unit;
93
+ }
94
+ if (chartUtils.schema.channel(jsonData, "group").unit!== "none") {
95
+ groupUnit = chartUtils.schema.channel(jsonData, "group").unit;
96
+ }
97
+
98
+ // ---------- 4. 数据处理 ----------
99
+
100
+ // 获取唯一维度值和分组值
101
+ const allDimensions = [...new Set(chartData.map(d => d[dimensionField]))];
102
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
103
+
104
+ // 不对维度进行排序,直接使用原始顺序
105
+ const dimensions = [...allDimensions];
106
+
107
+ // 使用前两个分组作为左右两侧显示数据
108
+ const leftGroup = groups[0]; // 左侧分组
109
+ const rightGroup = groups[1]; // 右侧分组
110
+
111
+ // 如果存在第三个分组,可以用作总计分组(用于显示右侧的总数值)
112
+ const totalGroup = groups.length > 2 ? groups[2] : null;
113
+
114
+ // ---------- 5. 动态计算维度标签区域宽度 ----------
115
+
116
+ // 计算最大标签宽度
117
+ let maxLabelWidth = 0;
118
+ dimensions.forEach(dimension => {
119
+ // 格式化维度名称(附加单位,如果有)
120
+ const formattedDimension = dimensionUnit ?
121
+ `${dimension}${dimensionUnit}` :
122
+ `${dimension}`;
123
+
124
+ const textWidth = chartUtils.text.measure(null, formattedDimension, {
125
+ fontFamily: typography.label.font_family,
126
+ fontSize: typography.label.font_size,
127
+ fontWeight: typography.label.font_weight
128
+ }).width;
129
+ const totalWidth = textWidth + 5;
130
+
131
+ maxLabelWidth = Math.max(maxLabelWidth, totalWidth);
132
+ });
133
+
134
+ // 为计算出的宽度添加边距,确保有足够空间
135
+ const dimensionLabelWidth = Math.max(maxLabelWidth + 5, 80); // 最小值为80像素
136
+
137
+ // ---------- 6. 创建SVG容器 ----------
138
+
139
+ const svg = d3.select(containerSelector)
140
+ .append("svg")
141
+ .attr("width", "100%")
142
+ .attr("height", height)
143
+ .attr("viewBox", `0 0 ${width} ${height}`)
144
+ .attr("style", "max-width: 100%; height: auto;")
145
+ .attr("xmlns", "http://www.w3.org/2000/svg")
146
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
147
+
148
+ // ---------- 6.1 创建视觉效果 ----------
149
+
150
+ const defs = svg.append("defs");
151
+
152
+ const getColor = (group) => {
153
+ return colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
154
+ };
155
+
156
+ if (variables.has_shadow) {
157
+ const filter = defs.append("filter")
158
+ .attr("id", "shadow")
159
+ .attr("filterUnits", "userSpaceOnUse")
160
+ .attr("width", "200%")
161
+ .attr("height", "200%");
162
+
163
+ filter.append("feGaussianBlur")
164
+ .attr("in", "SourceAlpha")
165
+ .attr("stdDeviation", 4);
166
+
167
+ filter.append("feOffset")
168
+ .attr("dx", 4)
169
+ .attr("dy", 4)
170
+ .attr("result", "offsetblur");
171
+
172
+ const feMerge = filter.append("feMerge");
173
+ feMerge.append("feMergeNode");
174
+ feMerge.append("feMergeNode").attr("in", "SourceGraphic");
175
+ }
176
+
177
+ if (variables.has_gradient) {
178
+ const leftGradientId = `gradient-${leftGroup.replace(/\s+/g, '-').toLowerCase()}`;
179
+ const leftBaseColor = getColor(leftGroup);
180
+
181
+ const leftGradient = defs.append("linearGradient")
182
+ .attr("id", leftGradientId)
183
+ .attr("x1", "0%")
184
+ .attr("y1", "0%")
185
+ .attr("x2", "100%")
186
+ .attr("y2", "0%");
187
+
188
+ leftGradient.append("stop")
189
+ .attr("offset", "0%")
190
+ .attr("stop-color", colorResolver.variant(leftBaseColor, { mode: "brighter", amount: 1.2 }));
191
+
192
+ leftGradient.append("stop")
193
+ .attr("offset", "100%")
194
+ .attr("stop-color", colorResolver.variant(leftBaseColor, { mode: "darker", amount: 0.7 }));
195
+
196
+ const rightGradientId = `gradient-${rightGroup.replace(/\s+/g, '-').toLowerCase()}`;
197
+ const rightBaseColor = getColor(rightGroup);
198
+
199
+ const rightGradient = defs.append("linearGradient")
200
+ .attr("id", rightGradientId)
201
+ .attr("x1", "100%")
202
+ .attr("y1", "0%")
203
+ .attr("x2", "0%")
204
+ .attr("y2", "0%");
205
+
206
+ rightGradient.append("stop")
207
+ .attr("offset", "0%")
208
+ .attr("stop-color", colorResolver.variant(rightBaseColor, { mode: "brighter", amount: 1.2 }));
209
+
210
+ rightGradient.append("stop")
211
+ .attr("offset", "100%")
212
+ .attr("stop-color", colorResolver.variant(rightBaseColor, { mode: "darker", amount: 0.7 }));
213
+ }
214
+
215
+ // ---------- 7. 添加左右组标签 ----------
216
+
217
+ const legendSquareSize = 12;
218
+ const legendSpacing = 5;
219
+ const legendFontFamily = typography.label.font_family;
220
+ const legendFontSize = typography.label.font_size || "14px";
221
+ const legendFontWeight = typography.label.font_weight || "bold";
222
+
223
+ // 格式化组标签
224
+ const formattedLeftGroup = groupUnit ? `${leftGroup}${groupUnit}` : `${leftGroup}`;
225
+ const formattedRightGroup = groupUnit ? `${rightGroup}${groupUnit}` : `${rightGroup}`;
226
+ const labelMeasureOptions = {
227
+ fontFamily: legendFontFamily,
228
+ fontSize: legendFontSize,
229
+ fontWeight: legendFontWeight,
230
+ };
231
+ const leftTextWidth = chartUtils.text.measure(svg, formattedLeftGroup, labelMeasureOptions).width;
232
+ const rightTextWidth = chartUtils.text.measure(svg, formattedRightGroup, labelMeasureOptions).width;
233
+ const legendBaselineY = margin.top - 10;
234
+
235
+ chartUtils.legend.pair(svg, [
236
+ {
237
+ x: margin.left + innerWidth / 4 - (leftTextWidth + legendSquareSize + legendSpacing) / 2,
238
+ y: legendBaselineY - legendSquareSize / 2,
239
+ label: formattedLeftGroup,
240
+ color: getColor(leftGroup),
241
+ },
242
+ {
243
+ x: margin.left + innerWidth * 3 / 4 - (rightTextWidth + legendSquareSize + legendSpacing) / 2,
244
+ y: legendBaselineY - legendSquareSize / 2,
245
+ label: formattedRightGroup,
246
+ color: getColor(rightGroup),
247
+ },
248
+ ], {
249
+ markerSize: legendSquareSize,
250
+ labelGap: legendSpacing,
251
+ fontFamily: legendFontFamily,
252
+ fontSize: legendFontSize,
253
+ fontWeight: legendFontWeight,
254
+ textColor: colorResolver.text({ fallback: "#333333" }).value,
255
+ className: "group-label-legend",
256
+ });
257
+
258
+ // ---------- 8. 创建绘图组 ----------
259
+
260
+ const g = svg.append("g")
261
+ .attr("transform", `translate(${margin.left},${margin.top})`);
262
+
263
+ // ---------- 9. 创建比例尺 ----------
264
+
265
+ const yScale = d3.scaleBand()
266
+ .domain(dimensions)
267
+ .range([0, innerHeight])
268
+ .padding(variables.has_spacing ? 0.4 : 0.3);
269
+
270
+ const maxLeftValue = d3.max(chartData.filter(d => d[groupField] === leftGroup), d => +d[valueField]);
271
+ const maxRightValue = d3.max(chartData.filter(d => d[groupField] === rightGroup), d => +d[valueField]);
272
+
273
+ const leftXScale = d3.scaleLinear()
274
+ .domain([0, maxLeftValue])
275
+ .range([innerWidth / 2 - dimensionLabelWidth/2, 0]);
276
+
277
+ const rightXScale = d3.scaleLinear()
278
+ .domain([0, maxRightValue])
279
+ .range([0, innerWidth / 2 - dimensionLabelWidth/2]);
280
+
281
+ const getStrokeColor = () => {
282
+ const stroke = colorResolver.other("stroke_color", { fallback: "" }).value;
283
+ if (stroke) return stroke;
284
+ return colorResolver.available(0, { fallback: "#333333" }).value;
285
+ };
286
+ const strokeColor = getStrokeColor();
287
+
288
+ // ---------- 9. 添加交替行背景 ----------
289
+ if (jsonData.variation?.background === "styled") {
290
+ dimensions.forEach((dimension, i) => {
291
+ if (i % 2 === 0) {
292
+ g.append("rect")
293
+ .attr("x", -margin.left/2)
294
+ .attr("y", yScale(dimension))
295
+ .attr("width", innerWidth + margin.left/2 + margin.right/2)
296
+ .attr("height", yScale.bandwidth())
297
+ .attr("class","background")
298
+ .attr("fill", "#f5f5f5")
299
+ .attr("opacity", 0.8);
300
+ }
301
+ });
302
+ }
303
+
304
+ // ---------- 10. 绘制维度标签和图标 ----------
305
+
306
+ dimensions.forEach(dimension => {
307
+ const yPos = yScale(dimension) + yScale.bandwidth() / 2;
308
+
309
+
310
+
311
+ const formattedDimension = dimensionUnit ? `${dimension}${dimensionUnit}` : `${dimension}`;
312
+
313
+ // If not showing icons, center the text only
314
+ const startX = innerWidth/2;
315
+
316
+ // Add centered text without icon
317
+ g.append("text")
318
+ .attr("x", startX)
319
+ .attr("y", yPos)
320
+ .attr("dy", "0.35em")
321
+ .attr("text-anchor", "middle")
322
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
323
+ .style("font-family", typography.label.font_family)
324
+ .style("font-size", typography.label.font_size)
325
+ .style("font-weight", typography.label.font_weight)
326
+ .text(formattedDimension);
327
+
328
+ });
329
+
330
+ // ---------- 11. 绘制左侧条形图 ----------
331
+
332
+ // 为每个维度绘制左侧条形
333
+ dimensions.forEach(dimension => {
334
+ // 查找数据点
335
+ const dataPoint = chartData.find(d =>
336
+ d[dimensionField] === dimension && d[groupField] === leftGroup
337
+ );
338
+
339
+ if (dataPoint) {
340
+ // 计算条形宽度
341
+ const barWidth = innerWidth/2 - dimensionLabelWidth/2 - leftXScale(+dataPoint[valueField]);
342
+
343
+ // Y位置
344
+ const yPos = yScale(dimension);
345
+
346
+ // 绘制条形
347
+ g.append("rect")
348
+ .attr("x", leftXScale(+dataPoint[valueField])) // 左侧起点
349
+ .attr("y", yPos) // 条形顶部
350
+ .attr("width", barWidth) // 条形宽度
351
+ .attr("height", yScale.bandwidth()) // 条形高度
352
+ .attr("fill", variables.has_gradient ?
353
+ `url(#gradient-${leftGroup.replace(/\s+/g, '-').toLowerCase()})` :
354
+ getColor(leftGroup)
355
+ )
356
+ .attr("rx", variables.has_rounded_corners ? 4 : 0) // 圆角半径(如果启用)
357
+ .attr("ry", variables.has_rounded_corners ? 4 : 0)
358
+ .style("stroke", variables.has_stroke ? strokeColor : "none") // 边框
359
+ .style("stroke-width", variables.has_stroke ? 1 : 0)
360
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "none") // 阴影
361
+ .on("mouseover", function(event, d) {
362
+ d3.select(this)
363
+ .transition()
364
+ .duration(100)
365
+ .attr("opacity", 0.8);
366
+ })
367
+ .on("mouseout", function(event, d) {
368
+ d3.select(this)
369
+ .transition()
370
+ .duration(100)
371
+ .attr("opacity", 1);
372
+ });
373
+
374
+ // 绘制数值标签(附加单位,如果有)
375
+ const formattedValue = valueUnit ?
376
+ `${chartUtils.format.autoText(+dataPoint[valueField])}${valueUnit}` :
377
+ `${chartUtils.format.autoText(+dataPoint[valueField])}`;
378
+
379
+ const textWidth = chartUtils.text.measure(g, formattedValue, {
380
+ fontFamily: typography.annotation.font_family,
381
+ fontSize: typography.annotation.font_size,
382
+ fontWeight: typography.annotation.font_weight
383
+ }).width;
384
+
385
+ // 计算文本位置 - 默认在条形中间
386
+ let textX = leftXScale(+dataPoint[valueField]) + barWidth/2;
387
+
388
+ // 确保文本末尾不超出条形右侧边界
389
+ // 如果文本一半宽度超出右边界,则调整位置
390
+ const rightBoundary = leftXScale(+dataPoint[valueField]) + barWidth;
391
+ if (textX + textWidth/2 > rightBoundary) {
392
+ textX = rightBoundary - textWidth/2 - 2; // 减去2像素的安全边距
393
+ }
394
+ g.append("text")
395
+ .attr("class", "label")
396
+ .attr("x", leftXScale(+dataPoint[valueField])-3) // 条形左边缘(不加偏移,确保紧挨)
397
+ .attr("y", yPos + yScale.bandwidth()/2)
398
+ .attr("dy", "0.35em")
399
+ .attr("text-anchor", "end") // 右对齐,使文本右边缘与x点对齐
400
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value) // 使用普通文本颜色
401
+ .style("font-family", typography.annotation.font_family)
402
+ .style("font-size", typography.annotation.font_size)
403
+ .style("font-weight", typography.annotation.font_weight)
404
+ .style("pointer-events", "none")
405
+ .text(formattedValue);
406
+ // 如果条形太窄,无法容纳文本(小于文本宽度),则不显示文本
407
+ // if (barWidth < textWidth) {
408
+ // // 将文本放在条形外部左侧,文本右边缘紧挨条形左边缘
409
+ // g.append("text")
410
+ // .attr("class", "label")
411
+ // .attr("x", leftXScale(+dataPoint[valueField])-3) // 条形左边缘(不加偏移,确保紧挨)
412
+ // .attr("y", yPos + yScale.bandwidth()/2)
413
+ // .attr("dy", "0.35em")
414
+ // .attr("text-anchor", "end") // 右对齐,使文本右边缘与x点对齐
415
+ // .style("fill", colorResolver.text({ fallback: "#333333" }).value) // 使用普通文本颜色
416
+ // .style("font-family", typography.annotation.font_family)
417
+ // .style("font-size", typography.annotation.font_size)
418
+ // .style("font-weight", typography.annotation.font_weight)
419
+ // .style("pointer-events", "none")
420
+ // .text(formattedValue);
421
+ // } else {
422
+ // // 正常在条形内显示文本
423
+ // g.append("text")
424
+ // .attr("class", "label")
425
+ // .attr("x", textX)
426
+ // .attr("y", yPos + yScale.bandwidth()/2)
427
+ // .attr("dy", "0.35em")
428
+ // .attr("text-anchor", "middle")
429
+ // .style("fill", "#ffffff") // 白色文本
430
+ // .style("font-family", typography.annotation.font_family)
431
+ // .style("font-size", typography.annotation.font_size)
432
+ // .style("font-weight", typography.annotation.font_weight)
433
+ // .style("pointer-events", "none")
434
+ // .text(formattedValue);
435
+ // }
436
+ }
437
+ });
438
+
439
+ // ---------- 12. 绘制右侧条形图 ----------
440
+
441
+ // 为每个维度绘制右侧条形
442
+ dimensions.forEach(dimension => {
443
+ // 查找数据点
444
+ const dataPoint = chartData.find(d =>
445
+ d[dimensionField] === dimension && d[groupField] === rightGroup
446
+ );
447
+
448
+ if (dataPoint) {
449
+ // 计算条形宽度
450
+ const barWidth = rightXScale(+dataPoint[valueField]);
451
+
452
+ // Y位置
453
+ const yPos = yScale(dimension);
454
+
455
+ // 条形的左边界位置
456
+ const barLeft = innerWidth/2 + dimensionLabelWidth/2;
457
+
458
+ // 绘制条形 - 应用与左侧相同的视觉效果
459
+ g.append("rect")
460
+ .attr("x", barLeft) // 从中间右侧开始
461
+ .attr("y", yPos) // 条形顶部
462
+ .attr("width", barWidth) // 条形宽度
463
+ .attr("height", yScale.bandwidth()) // 条形高度
464
+ .attr("fill", variables.has_gradient ?
465
+ `url(#gradient-${rightGroup.replace(/\s+/g, '-').toLowerCase()})` :
466
+ getColor(rightGroup)
467
+ )
468
+ .attr("rx", variables.has_rounded_corners ? 4 : 0) // 圆角半径(如果启用)
469
+ .attr("ry", variables.has_rounded_corners ? 4 : 0)
470
+ .style("stroke", variables.has_stroke ? strokeColor : "none") // 边框
471
+ .style("stroke-width", variables.has_stroke ? 1 : 0)
472
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "none") // 阴影
473
+ .on("mouseover", function(event, d) {
474
+ d3.select(this)
475
+ .transition()
476
+ .duration(100)
477
+ .attr("opacity", 0.8);
478
+ })
479
+ .on("mouseout", function(event, d) {
480
+ d3.select(this)
481
+ .transition()
482
+ .duration(100)
483
+ .attr("opacity", 1);
484
+ });
485
+
486
+ // 绘制数值标签(附加单位,如果有)
487
+ const formattedValue = valueUnit ?
488
+ `${chartUtils.format.autoText(+dataPoint[valueField])}${valueUnit}` :
489
+ `${chartUtils.format.autoText(+dataPoint[valueField])}`;
490
+
491
+ const textWidth = chartUtils.text.measure(g, formattedValue, {
492
+ fontFamily: typography.annotation.font_family,
493
+ fontSize: typography.annotation.font_size,
494
+ fontWeight: typography.annotation.font_weight
495
+ }).width;
496
+
497
+ // 计算文本位置 - 默认在条形中间
498
+ let textX = barLeft + barWidth/2;
499
+
500
+ // 确保文本起始位置不超出条形左侧边界
501
+ if (textX - textWidth/2 < barLeft) {
502
+ textX = barLeft + textWidth/2 + 2; // 加上2像素的安全边距
503
+ }
504
+ g.append("text")
505
+ .attr("class", "label")
506
+ .attr("x", barLeft + barWidth + 5) // 条形右边缘(不加偏移,确保紧挨)
507
+ .attr("y", yPos + yScale.bandwidth()/2)
508
+ .attr("dy", "0.35em")
509
+ .attr("text-anchor", "start") // 左对齐,使文本左边缘与x点对齐
510
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value) // 使用普通文本颜色
511
+ .style("font-family", typography.annotation.font_family)
512
+ .style("font-size", typography.annotation.font_size)
513
+ .style("font-weight", typography.annotation.font_weight)
514
+ .style("pointer-events", "none")
515
+ .text(formattedValue);
516
+ // 如果条形太窄,无法容纳文本(小于文本宽度),则不显示文本
517
+ // if (barWidth < textWidth) {
518
+ // // 将文本放在条形外部右侧,文本左边缘紧挨条形右边缘
519
+ // g.append("text")
520
+ // .attr("class", "label")
521
+ // .attr("x", barLeft + barWidth + 5) // 条形右边缘(不加偏移,确保紧挨)
522
+ // .attr("y", yPos + yScale.bandwidth()/2)
523
+ // .attr("dy", "0.35em")
524
+ // .attr("text-anchor", "start") // 左对齐,使文本左边缘与x点对齐
525
+ // .style("fill", colorResolver.text({ fallback: "#333333" }).value) // 使用普通文本颜色
526
+ // .style("font-family", typography.annotation.font_family)
527
+ // .style("font-size", typography.annotation.font_size)
528
+ // .style("font-weight", typography.annotation.font_weight)
529
+ // .style("pointer-events", "none")
530
+ // .text(formattedValue);
531
+ // } else {
532
+ // // 正常在条形内显示文本
533
+ // g.append("text")
534
+ // .attr("class", "label")
535
+ // .attr("x", textX)
536
+ // .attr("y", yPos + yScale.bandwidth()/2)
537
+ // .attr("dy", "0.35em")
538
+ // .attr("text-anchor", "middle")
539
+ // .style("fill", "#ffffff") // 白色文本
540
+ // .style("font-family", typography.annotation.font_family)
541
+ // .style("font-size", typography.annotation.font_size)
542
+ // .style("font-weight", typography.annotation.font_weight)
543
+ // .style("pointer-events", "none")
544
+ // .text(formattedValue);
545
+ // }
546
+ }
547
+ });
548
+
549
+ // 返回SVG节点
550
+ return svg.node();
551
+ }
modules/chart_engine/template/d3-js/type17_diverging_bar_chart/diverging_bar_plain_chart_05.js ADDED
@@ -0,0 +1,430 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Diverging Bar Chart",
5
+ "chart_name": "diverging_bar_plain_chart_05",
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
+ // 水平对比型条形图实现 - 使用D3.js horizontal_split_bar_chart_01
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
+ d3.select(containerSelector).html("");
49
+
50
+ // 数值格式化函数
51
+
52
+ // ---------- 2. 尺寸和布局设置 ----------
53
+
54
+ // 设置图表总尺寸和边距
55
+ const width = variables.width; // 图表总宽度
56
+ const height = variables.height; // 图表总高度
57
+ // 边距:top-顶部,right-右侧,bottom-底部,left-左侧
58
+ const margin = { top: 100, right: 30, bottom: 40, left: 30 };
59
+
60
+ // 计算实际绘图区域尺寸
61
+ const innerWidth = width - margin.left - margin.right; // 绘图区宽度
62
+ const innerHeight = height - margin.top - margin.bottom; // 绘图区高度
63
+
64
+ // 设置各部分位置参数
65
+ const centerX = margin.left + innerWidth / 2; // 图表中心X坐标
66
+ const topAreaHeight = 60; // 标题和标签区域高度
67
+ const barPadding = 8; // 条形图之间的间距
68
+
69
+ // ---------- 3. 提取字段名和单位 ----------
70
+
71
+ // 根据数据列顺序提取字段名
72
+ const dimensionField = chartUtils.schema.channel(jsonData, "x").key;
73
+ const valueField = chartUtils.schema.channel(jsonData, "y").key;
74
+ const groupField = chartUtils.schema.channel(jsonData, "group").key;
75
+
76
+ // 获取字段单位(如果存在)
77
+ let dimensionUnit = "";
78
+ let valueUnit = ""; // 默认为百分比
79
+ let groupUnit = "";
80
+
81
+ if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
82
+ dimensionUnit = chartUtils.schema.channel(jsonData, "x").unit;
83
+ }
84
+
85
+ if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
86
+ valueUnit = chartUtils.schema.channel(jsonData, "y").unit;
87
+ }
88
+ if (chartUtils.schema.channel(jsonData, "group").unit!== "none") {
89
+ groupUnit = chartUtils.schema.channel(jsonData, "group").unit;
90
+ }
91
+
92
+ // ---------- 4. 数据处理 ----------
93
+
94
+ // 获取唯一维度值和分组值
95
+ const allDimensions = [...new Set(chartData.map(d => d[dimensionField]))];
96
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
97
+
98
+ // 不对维度进行排序,直接使用原始顺序
99
+ const dimensions = [...allDimensions];
100
+
101
+ // 使用前两个分组作为左右两侧显示数据
102
+ const leftGroup = groups[0]; // 左侧分组
103
+ const rightGroup = groups[1]; // 右侧分组
104
+
105
+ // 检查是否所有维度都同时具有左右两侧分组的数据
106
+ const isDataComplete = dimensions.every(dimension => {
107
+ const hasLeftData = chartData.some(d => d[dimensionField] === dimension && d[groupField] === leftGroup);
108
+ const hasRightData = chartData.some(d => d[dimensionField] === dimension && d[groupField] === rightGroup);
109
+ return hasLeftData && hasRightData;
110
+ });
111
+
112
+ // 如果数据不完整,显示错误信息并返回
113
+ if (!isDataComplete) {
114
+ d3.select(containerSelector)
115
+ .append("div")
116
+ .style("color", "orange") // 使用警告色
117
+ .style("text-align", "center")
118
+ .style("padding", "20px")
119
+ .style("font-family", typography?.label?.font_family || "Arial")
120
+ .style("font-size", typography?.label?.font_size || "12px")
121
+ .html("存在缺少左侧或右侧分组数据的维度,无法生成图表。");
122
+ return; // 停止执行
123
+ }
124
+
125
+ // ---------- 5. 动态计算维度标签区域宽度 ----------
126
+
127
+ // 计算最大标签宽度
128
+ let maxLabelWidth = 0;
129
+ dimensions.forEach(dimension => {
130
+ // 格式化维度名称(附加单位,如果有)
131
+ const formattedDimension = dimensionUnit ?
132
+ `${dimension}${dimensionUnit}` :
133
+ `${dimension}`;
134
+
135
+ const textWidth = chartUtils.text.measure(null, formattedDimension, {
136
+ fontFamily: typography.label.font_family,
137
+ fontSize: typography.label.font_size,
138
+ fontWeight: typography.label.font_weight
139
+ }).width;
140
+ const totalWidth = textWidth + 5; // 只考虑文本宽度和少量边距
141
+
142
+ maxLabelWidth = Math.max(maxLabelWidth, totalWidth);
143
+ });
144
+
145
+ // 为计算出的宽度添加边距,确保有足够空间
146
+ const dimensionLabelWidth = Math.max(maxLabelWidth + 5, 80); // 最小值为80像素
147
+
148
+ // ---------- 6. 创建SVG容器 ----------
149
+
150
+ const svg = d3.select(containerSelector)
151
+ .append("svg")
152
+ .attr("width", "100%")
153
+ .attr("height", height)
154
+ .attr("viewBox", `0 0 ${width} ${height}`)
155
+ .attr("style", "max-width: 100%; height: auto;")
156
+ .attr("xmlns", "http://www.w3.org/2000/svg")
157
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
158
+
159
+ // ---------- 6.1 创建视觉效果 ----------
160
+
161
+ const defs = svg.append("defs");
162
+
163
+ // 创建基于 group 的颜色比例尺
164
+ const colorScale = colorResolver.scale(groups, { palette: "category10" });
165
+
166
+ // 创建斜线纹理模式 - 每个 group 一个
167
+ const patternDensity = 6; // 固定斜线密度
168
+ const patternStrokeWidth = 1.5; // 固定斜线宽度
169
+ groups.forEach((group, i) => {
170
+ // 为每个组获取颜色
171
+ const groupColor = colorScale(group);
172
+
173
+ // 创建斜线纹理模式
174
+ const patternId = `pattern-group-${group.replace(/[^a-zA-Z0-9]/g, '-')}-${i}`; // 基于 group 生成 ID
175
+ const pattern = defs.append("pattern")
176
+ .attr("id", patternId)
177
+ .attr("patternUnits", "userSpaceOnUse")
178
+ .attr("width", patternDensity)
179
+ .attr("height", patternDensity)
180
+ .attr("patternTransform", "rotate(45)");
181
+
182
+ // 添加背景矩形
183
+ pattern.append("rect")
184
+ .attr("width", patternDensity)
185
+ .attr("height", patternDensity)
186
+ .attr("fill", groupColor) // 使用 group 的颜色
187
+ .attr("opacity", 0.8);
188
+
189
+ // 添加斜线
190
+ pattern.append("line")
191
+ .attr("x1", 0)
192
+ .attr("y1", 0)
193
+ .attr("x2", 0)
194
+ .attr("y2", patternDensity)
195
+ .attr("stroke", "white")
196
+ .attr("stroke-width", patternStrokeWidth)
197
+ .attr("opacity", 0.6);
198
+ });
199
+
200
+ // ---------- 7. 添加左右组标签 ----------
201
+
202
+ const formattedLeftGroup = groupUnit ? `${leftGroup}${groupUnit}` : `${leftGroup}`;
203
+ svg.append("text")
204
+ .attr("x", margin.left + innerWidth / 4)
205
+ .attr("y", margin.top - 10)
206
+ .attr("text-anchor", "middle")
207
+ .style("font-family", typography.label.font_family)
208
+ .style("font-size", typography.label.font_size)
209
+ .style("font-weight", typography.label.font_weight)
210
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
211
+ .text(formattedLeftGroup);
212
+
213
+ const formattedRightGroup = groupUnit ? `${rightGroup}${groupUnit}` : `${rightGroup}`;
214
+ svg.append("text")
215
+ .attr("x", margin.left + innerWidth * 3 / 4)
216
+ .attr("y", margin.top - 10)
217
+ .attr("text-anchor", "middle")
218
+ .style("font-family", typography.label.font_family)
219
+ .style("font-size", typography.label.font_size)
220
+ .style("font-weight", typography.label.font_weight)
221
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
222
+ .text(formattedRightGroup);
223
+
224
+ // ---------- 8. 创建绘图组 ----------
225
+
226
+ const g = svg.append("g")
227
+ .attr("transform", `translate(${margin.left},${margin.top})`);
228
+
229
+ // ---------- 9. 创建比例尺 ----------
230
+
231
+ const yScale = d3.scaleBand()
232
+ .domain(dimensions) // 使用过滤后的维度
233
+ .range([0, innerHeight])
234
+ .padding(0.3); // 使用固定 padding
235
+
236
+ // 计算全局最大值以统一比例尺
237
+ const maxValue = d3.max(chartData, d => +d[valueField]);
238
+
239
+ const leftXScale = d3.scaleLinear()
240
+ .domain([0, maxValue]) // 使用全局最大值
241
+ .range([innerWidth / 2 - dimensionLabelWidth/2, 0]);
242
+
243
+ const rightXScale = d3.scaleLinear()
244
+ .domain([0, maxValue]) // 使用全局最大值
245
+ .range([0, innerWidth / 2 - dimensionLabelWidth/2]);
246
+
247
+ // ---------- 10. 绘制维度标签和图标 ----------
248
+
249
+ dimensions.forEach(dimension => {
250
+ const yPos = yScale(dimension) + yScale.bandwidth() / 2;
251
+
252
+ const formattedDimension = dimensionUnit ? `${dimension}${dimensionUnit}` : `${dimension}`;
253
+
254
+ // 始终将文本居中绘制在中间区域
255
+ const startX = innerWidth / 2; // X 坐标始终为中心点
256
+ g.append("text")
257
+ .attr("x", startX)
258
+ .attr("y", yPos)
259
+ .attr("dy", "0.35em")
260
+ .attr("text-anchor", "middle") // 始终居中对齐
261
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
262
+ .style("font-family", typography.label.font_family)
263
+ .style("font-size", typography.label.font_size)
264
+ .style("font-weight", typography.label.font_weight)
265
+ .text(formattedDimension);
266
+ });
267
+
268
+ // ---------- 11. 绘制左侧条形图 ----------
269
+
270
+ dimensions.forEach(dimension => {
271
+ const dataPoint = chartData.find(d =>
272
+ d[dimensionField] === dimension && d[groupField] === leftGroup
273
+ );
274
+
275
+ if (dataPoint) {
276
+ const barWidth = innerWidth/2 - dimensionLabelWidth/2 - leftXScale(+dataPoint[valueField]);
277
+ const yPos = yScale(dimension);
278
+
279
+ // 移除梯形相关的变量
280
+ // const slopeLeft = 5;
281
+ const xStart = leftXScale(+dataPoint[valueField]); // 最左侧 X 坐标
282
+ const barHeight = yScale.bandwidth();
283
+ const radius = barHeight / 2; // 半圆半径
284
+ const xEnd = xStart + barWidth; // 右侧 X 坐标 (靠近中心)
285
+
286
+ // 生成左侧条形图路径 (借鉴新逻辑: 右直左半圆/椭圆弧)
287
+ let pathDataLeft;
288
+ // 处理零或负值,避免绘制错误
289
+ if (barWidth <= 0) {
290
+ pathDataLeft = ""; // 不绘制任何路径
291
+ } else if (barWidth < radius) {
292
+
293
+ pathDataLeft = `M ${xEnd},${yPos} L ${xEnd},${yPos + barHeight} A ${barWidth},${radius} 0 0 1 ${xEnd},${yPos} Z`;
294
+ } else {
295
+
296
+ pathDataLeft = `M ${xEnd},${yPos} L ${xEnd},${yPos + barHeight} L ${xStart + radius},${yPos + barHeight} A ${radius},${radius} 0 0 1 ${xStart + radius},${yPos} Z`;
297
+ }
298
+
299
+ // 应用纹理填充 - 基于 group
300
+ const patternId = `pattern-group-${leftGroup.replace(/[^a-zA-Z0-9]/g, '-')}-${groups.indexOf(leftGroup)}`;
301
+ g.append("path")
302
+ .attr("d", pathDataLeft)
303
+ .attr("fill", `url(#${patternId})`) // 应用 group 的纹理
304
+ .on("mouseover", function(event, d) {
305
+ d3.select(this)
306
+ .transition()
307
+ .duration(100)
308
+ .attr("opacity", 0.8);
309
+ })
310
+ .on("mouseout", function(event, d) {
311
+ d3.select(this)
312
+ .transition()
313
+ .duration(100)
314
+ .attr("opacity", 1);
315
+ });
316
+
317
+ // ------- 以下是数值标签原逻辑,不作改动 -------
318
+ const formattedValue = valueUnit ?
319
+ `${chartUtils.format.autoText(+dataPoint[valueField])}${valueUnit}` :
320
+ `${chartUtils.format.autoText(+dataPoint[valueField])}`;
321
+
322
+ const valueLabelFontSize = `${Math.min(20,Math.max(barHeight * 0.6, parseFloat(typography.annotation.font_size)))}px`;
323
+ const textWidth = chartUtils.text.measure(g, formattedValue, {
324
+ fontFamily: typography.annotation.font_family,
325
+ fontSize: valueLabelFontSize,
326
+ fontWeight: typography.annotation.font_weight
327
+ }).width;
328
+
329
+ let textX = leftXScale(+dataPoint[valueField]) - 10;
330
+ const rightBoundary = leftXScale(+dataPoint[valueField]) + barWidth;
331
+ if (textX + textWidth/2 > rightBoundary) {
332
+ textX = rightBoundary - textWidth/2 - 2;
333
+ }
334
+ g.append("text")
335
+ .attr("class", "label")
336
+ .attr("x", leftXScale(+dataPoint[valueField]) - 10)
337
+ .attr("y", yPos + yScale.bandwidth()/2)
338
+ .attr("dy", "0.35em")
339
+ .attr("text-anchor", "end")
340
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
341
+ .style("font-family", typography.annotation.font_family)
342
+ .style("font-size", `${Math.min(20,Math.max(barHeight * 0.6, parseFloat(typography.annotation.font_size)))}px`)
343
+ .style("font-weight", typography.annotation.font_weight)
344
+ .style("pointer-events", "none")
345
+ .text(formattedValue);
346
+ }
347
+ });
348
+
349
+ // ---------- 12. 绘制右侧条形图 ----------
350
+
351
+ dimensions.forEach(dimension => {
352
+ const dataPoint = chartData.find(d =>
353
+ d[dimensionField] === dimension && d[groupField] === rightGroup
354
+ );
355
+
356
+ if (dataPoint) {
357
+ const barWidth = rightXScale(+dataPoint[valueField]);
358
+ const yPos = yScale(dimension);
359
+ const barLeft = innerWidth/2 + dimensionLabelWidth/2; // 左侧 X 坐标 (靠近中心)
360
+
361
+ // 移除梯形相关的变量
362
+ // const slopeRight = 5;
363
+ const barHeight = yScale.bandwidth();
364
+ const radius = barHeight / 2; // 半圆半径
365
+ const xEnd = barLeft + barWidth; // 右侧 X 坐标
366
+
367
+ // 生成右侧条形图路径 (借鉴新逻辑: 左直右半圆/椭圆弧)
368
+ let pathDataRight;
369
+ // 处理零或负值,避免绘制错误
370
+ if (barWidth <= 0) {
371
+ pathDataRight = ""; // 不绘制任何路径
372
+ } else if (barWidth < radius) {
373
+ pathDataRight = `M ${barLeft},${yPos} A ${barWidth},${radius} 0 0 1 ${barLeft},${yPos + barHeight} Z`;
374
+ } else {
375
+ pathDataRight = `M ${barLeft},${yPos} H ${xEnd - radius} A ${radius},${radius} 0 0 1 ${xEnd - radius},${yPos + barHeight} H ${barLeft} Z`;
376
+ }
377
+
378
+ // 应用纹理填充 - 基于 group
379
+ const patternId = `pattern-group-${rightGroup.replace(/[^a-zA-Z0-9]/g, '-')}-${groups.indexOf(rightGroup)}`;
380
+ g.append("path")
381
+ .attr("d", pathDataRight)
382
+ .attr("fill", `url(#${patternId})`) // 应用 group 的纹理
383
+ .on("mouseover", function(event, d) {
384
+ d3.select(this)
385
+ .transition()
386
+ .duration(100)
387
+ .attr("opacity", 0.8);
388
+ })
389
+ .on("mouseout", function(event, d) {
390
+ d3.select(this)
391
+ .transition()
392
+ .duration(100)
393
+ .attr("opacity", 1);
394
+ });
395
+
396
+ // ------- 以下是数值标签原逻辑,不作改动 -------
397
+ const formattedValue = valueUnit ?
398
+ `${chartUtils.format.autoText(+dataPoint[valueField])}${valueUnit}` :
399
+ `${chartUtils.format.autoText(+dataPoint[valueField])}`;
400
+
401
+ const valueLabelFontSize = `${Math.min(20,Math.max(barHeight * 0.6, parseFloat(typography.annotation.font_size)))}px`;
402
+ const textWidth = chartUtils.text.measure(g, formattedValue, {
403
+ fontFamily: typography.annotation.font_family,
404
+ fontSize: valueLabelFontSize,
405
+ fontWeight: typography.annotation.font_weight
406
+ }).width;
407
+
408
+ let textX = barLeft + barWidth + 10;
409
+ if (textX - textWidth/2 < barLeft) {
410
+ textX = barLeft + textWidth/2 + 2;
411
+ }
412
+ g.append("text")
413
+ .attr("class", "label")
414
+ .attr("x", barLeft + barWidth + 10)
415
+ .attr("y", yPos + yScale.bandwidth()/2)
416
+ .attr("dy", "0.35em")
417
+ .attr("text-anchor", "start")
418
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
419
+ .style("font-family", typography.annotation.font_family)
420
+ .style("font-size", `${Math.min(20,Math.max(barHeight * 0.6, parseFloat(typography.annotation.font_size)))}px`)
421
+ .style("font-weight", typography.annotation.font_weight)
422
+ .style("pointer-events", "none")
423
+ .text(formattedValue);
424
+
425
+ }
426
+ });
427
+
428
+ // 返回SVG节点
429
+ return svg.node();
430
+ }
modules/chart_engine/template/d3-js/type17_diverging_bar_chart/diverging_bar_plain_chart_06.js ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Diverging Bar Chart",
5
+ "chart_name": "diverging_bar_plain_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, 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
+ // 水平对比型条形图实现 - 使用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 || 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
+
55
+ // 清空容器 - 在添加新图表前移除可能存在的内容
56
+ d3.select(containerSelector).html("");
57
+
58
+ // ---------- 2. 尺寸和布局设置 ----------
59
+
60
+ // 设置图表总尺寸和边距
61
+ const width = variables.width; // 图表总宽度
62
+ const height = variables.height; // 图表总高度
63
+ // 边距:top-顶部,right-右侧,bottom-底部,left-左侧
64
+ const margin = { top: 100, right: 70, bottom: 40, left: 70 };
65
+
66
+ // 计算实际绘图区域尺寸
67
+ const innerWidth = width - margin.left - margin.right; // 绘图区宽度
68
+ const innerHeight = height - margin.top - margin.bottom; // 绘图区高度
69
+
70
+ // 设置各部分位置参数
71
+ const centerX = margin.left + innerWidth / 2; // 图表中心X坐标
72
+ const topAreaHeight = 60; // 标题和标签区域高度
73
+ const barPadding = 8; // 条形图之间的间距
74
+
75
+ // ---------- 3. 提取字段名和单位 ----------
76
+
77
+ // 根据数据列顺序提取字段名
78
+ const dimensionField = chartUtils.schema.channel(jsonData, "x").key;
79
+ const valueField = chartUtils.schema.channel(jsonData, "y").key;
80
+ const groupField = chartUtils.schema.channel(jsonData, "group").key;
81
+
82
+ // 获取字段单位(如果存在)
83
+ let dimensionUnit = "";
84
+ let valueUnit = ""; // 默认为百分比
85
+ let groupUnit = "";
86
+
87
+ if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
88
+ dimensionUnit = chartUtils.schema.channel(jsonData, "x").unit;
89
+ }
90
+
91
+ if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
92
+ valueUnit = chartUtils.schema.channel(jsonData, "y").unit;
93
+ }
94
+ if (chartUtils.schema.channel(jsonData, "group").unit!== "none") {
95
+ groupUnit = chartUtils.schema.channel(jsonData, "group").unit;
96
+ }
97
+
98
+ // ---------- 4. 数据处理 ----------
99
+
100
+ // 获取唯一维度值和分组值
101
+ const allDimensions = [...new Set(chartData.map(d => d[dimensionField]))];
102
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
103
+
104
+ // 不对维度进行排序,直接使用原始顺序
105
+ const dimensions = [...allDimensions];
106
+
107
+ // 使用前两个分组作为左右两侧显示数据
108
+ const leftGroup = groups[0]; // 左侧分组
109
+ const rightGroup = groups[1]; // 右侧分组
110
+
111
+ // 找出两侧最大值,以确定棒子数量
112
+ const leftMax = d3.max(
113
+ chartData.filter(d => d[groupField] === leftGroup),
114
+ d => Math.abs(+d[valueField])
115
+ ) || 0;
116
+
117
+ const rightMax = d3.max(
118
+ chartData.filter(d => d[groupField] === rightGroup),
119
+ d => Math.abs(+d[valueField])
120
+ ) || 0;
121
+
122
+ // 取两侧绝对值最大的值
123
+ const overallMax = Math.max(leftMax, rightMax);
124
+
125
+ // 棒子数量计算
126
+ const maxBars = 20; // 最大显示20个棒子
127
+ // 计算每个棒子代表的数值(向上取整确保覆盖所有值)
128
+ const valuePerBar = Math.ceil(overallMax / maxBars);
129
+
130
+ // ---------- 5. 动态计算维度标签区域宽度 ----------
131
+
132
+ // 计算最大标签宽度
133
+ let maxLabelWidth = 0;
134
+ dimensions.forEach(dimension => {
135
+ // 格式化维度名称(附加单位,如果有)
136
+ const formattedDimension = dimensionUnit ?
137
+ `${dimension}${dimensionUnit}` :
138
+ `${dimension}`;
139
+
140
+ const textWidth = chartUtils.text.measure(null, formattedDimension, {
141
+ fontFamily: typography.label.font_family,
142
+ fontSize: typography.label.font_size,
143
+ fontWeight: typography.label.font_weight
144
+ }).width;
145
+ // 直接使用计算出的宽度
146
+ maxLabelWidth = Math.max(maxLabelWidth, textWidth);
147
+ });
148
+
149
+ // 为计算出的宽度添加边距,确保有足够空间
150
+ const labelSidePadding = 15; // 标签两侧各留15像素的间距
151
+ const centerLabelAreaWidth = maxLabelWidth + labelSidePadding * 2; // 中心标签区域的总宽度
152
+
153
+ // ---------- 6. 创建SVG容器 ----------
154
+
155
+ const svg = d3.select(containerSelector)
156
+ .append("svg")
157
+ .attr("width", "100%")
158
+ .attr("height", height)
159
+ .attr("viewBox", `0 0 ${width} ${height}`)
160
+ .attr("style", "max-width: 100%; height: auto;")
161
+ .attr("xmlns", "http://www.w3.org/2000/svg")
162
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
163
+
164
+ // ---------- 6.1 创建视觉效果 ----------
165
+
166
+ const defs = svg.append("defs");
167
+
168
+ const getColor = (group) => {
169
+ return colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
170
+ };
171
+
172
+ if (variables.has_shadow) {
173
+ const filter = defs.append("filter")
174
+ .attr("id", "shadow")
175
+ .attr("filterUnits", "userSpaceOnUse")
176
+ .attr("width", "200%")
177
+ .attr("height", "200%");
178
+
179
+ filter.append("feGaussianBlur")
180
+ .attr("in", "SourceAlpha")
181
+ .attr("stdDeviation", 4);
182
+
183
+ filter.append("feOffset")
184
+ .attr("dx", 4)
185
+ .attr("dy", 4)
186
+ .attr("result", "offsetblur");
187
+
188
+ const feMerge = filter.append("feMerge");
189
+ feMerge.append("feMergeNode");
190
+ feMerge.append("feMergeNode").attr("in", "SourceGraphic");
191
+ }
192
+
193
+ // 为左右两侧创建渐变
194
+ const leftGradientId = `gradient-${leftGroup.replace(/\s+/g, '-').toLowerCase()}`;
195
+ const leftBaseColor = getColor(leftGroup);
196
+
197
+ const leftGradient = defs.append("linearGradient")
198
+ .attr("id", leftGradientId)
199
+ .attr("x1", "0%")
200
+ .attr("y1", "0%")
201
+ .attr("x2", "100%")
202
+ .attr("y2", "0%");
203
+
204
+ leftGradient.append("stop")
205
+ .attr("offset", "0%")
206
+ .attr("stop-color", colorResolver.variant(leftBaseColor, { mode: "darker", amount: 0.7 }));
207
+
208
+ leftGradient.append("stop")
209
+ .attr("offset", "100%")
210
+ .attr("stop-color", colorResolver.variant(leftBaseColor, { mode: "brighter", amount: 1.2 }));
211
+
212
+ const rightGradientId = `gradient-${rightGroup.replace(/\s+/g, '-').toLowerCase()}`;
213
+ const rightBaseColor = getColor(rightGroup);
214
+
215
+ const rightGradient = defs.append("linearGradient")
216
+ .attr("id", rightGradientId)
217
+ .attr("x1", "0%")
218
+ .attr("y1", "0%")
219
+ .attr("x2", "100%")
220
+ .attr("y2", "0%");
221
+
222
+ rightGradient.append("stop")
223
+ .attr("offset", "0%")
224
+ .attr("stop-color", colorResolver.variant(rightBaseColor, { mode: "brighter", amount: 1.2 }));
225
+
226
+ rightGradient.append("stop")
227
+ .attr("offset", "100%")
228
+ .attr("stop-color", colorResolver.variant(rightBaseColor, { mode: "darker", amount: 0.7 }));
229
+
230
+ // ---------- 7. 添加左右组标签 ----------
231
+
232
+ // 图例方块尺寸
233
+ const legendSquareSize = 12;
234
+ const legendSpacing = 5;
235
+ const formattedLeftGroup = groupUnit ? `${leftGroup}${groupUnit}` : `${leftGroup}`;
236
+ const formattedRightGroup = groupUnit ? `${rightGroup}${groupUnit}` : `${rightGroup}`;
237
+ const leftTextWidth = chartUtils.text.measure(svg, formattedLeftGroup, {
238
+ fontSize: 14,
239
+ fontFamily: typography.label.font_family,
240
+ fontWeight: "bold",
241
+ }).width;
242
+ const leftLegendX = centerX - 50 - leftTextWidth - legendSquareSize - legendSpacing;
243
+ const rightLegendX = centerX + 50;
244
+ chartUtils.legend.pair(svg, [
245
+ { label: formattedLeftGroup, color: getColor(leftGroup), x: leftLegendX, y: margin.top - 35 },
246
+ { label: formattedRightGroup, color: getColor(rightGroup), x: rightLegendX, y: margin.top - 35 },
247
+ ], {
248
+ markerSize: legendSquareSize,
249
+ labelGap: legendSpacing,
250
+ fontSize: 14,
251
+ fontFamily: typography.label.font_family,
252
+ fontWeight: "bold",
253
+ textColor: colorResolver.text({ fallback: "#333333" }).value,
254
+ });
255
+ // ---------- 8. 绘制比例尺和中线 ----------
256
+
257
+ // 文字颜色
258
+ const textColor = colorResolver.text({ fallback: "#333333" }).value;
259
+
260
+ // 中线 - 保持在中点
261
+ svg.append("line")
262
+ .attr("x1", margin.left + innerWidth / 2) // 使用绝对中心点
263
+ .attr("y1", margin.top)
264
+ .attr("x2", margin.left + innerWidth / 2) // 使用绝对中心点
265
+ .attr("y2", height - margin.bottom)
266
+ .attr("stroke", "#CCCCCC")
267
+ .attr("stroke-width", 1)
268
+ .attr("stroke-dasharray", "3,3");
269
+
270
+ // ---------- 9. 设置比例尺 ----------
271
+
272
+ // Y轴比例尺 - 用于不同维度的垂直位置
273
+ const yScale = d3.scaleBand()
274
+ .domain(dimensions)
275
+ .range([0, innerHeight])
276
+ .padding(0.2); // 条形图之间的间距
277
+
278
+ // 计算棒子尺寸
279
+ const barHeight = yScale.bandwidth();
280
+
281
+ // 棒子配置
282
+ const barWidth = 8; // 单个棒子宽度
283
+ const barSpacing = 3; // 棒子之间的间距
284
+
285
+ // 计算值到棒子的映射函数
286
+ const valueToBars = (value) => {
287
+ return Math.min(maxBars, Math.ceil(Math.abs(value) / valuePerBar));
288
+ };
289
+
290
+ // 中间标签区宽度计算已移至上方
291
+
292
+ // ---------- 10. 绘制主图表组 ----------
293
+
294
+ const g = svg.append("g")
295
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
296
+
297
+ // 获取描边颜色(如果启用描边)
298
+ const getStrokeColor = (barColor) => {
299
+ return variables.has_stroke ? colorResolver.variant(barColor, { mode: "darker", amount: 0.5 }) : "none";
300
+ };
301
+
302
+ // 获取填充颜色(考虑渐变)
303
+ const getLeftFill = () => {
304
+ return variables.has_gradient ? `url(#${leftGradientId})` : getColor(leftGroup);
305
+ };
306
+
307
+ const getRightFill = () => {
308
+ return variables.has_gradient ? `url(#${rightGradientId})` : getColor(rightGroup);
309
+ };
310
+
311
+ // 为每个维度绘制数据棒子和标签
312
+ dimensions.forEach((dimension, i) => {
313
+ const y = yScale(dimension);
314
+ const barY = y + (barHeight - barHeight * 0.6) / 2; // 垂直居中(高度为60%行高)
315
+ const finalBarHeight = barHeight * 0.6; // 行高的60%
316
+
317
+ // 找出此维度下的左右两侧数据
318
+ const leftData = chartData.find(d => d[dimensionField] === dimension && d[groupField] === leftGroup);
319
+ const rightData = chartData.find(d => d[dimensionField] === dimension && d[groupField] === rightGroup);
320
+
321
+ // 添加维度标签 - 居中显示在图表中心 (基于innerWidth)
322
+ g.append("text")
323
+ .attr("x", innerWidth / 2)
324
+ .attr("y", y + barHeight / 2)
325
+ .attr("dy", "0.35em")
326
+ .attr("text-anchor", "middle")
327
+ .style("font-family", typography.label.font_family)
328
+ .style("font-size", typography.label.font_size)
329
+ .style("font-weight", typography.label.font_weight)
330
+ .style("fill", textColor)
331
+ .text(dimension);
332
+
333
+ // 绘制左侧棒子
334
+ if (leftData && leftData[valueField] !== undefined) {
335
+ const value = Math.abs(+leftData[valueField]);
336
+ const barCount = valueToBars(value);
337
+
338
+ // 计算左侧起始位置,从中间标签区域左侧开始
339
+ const leftStartX = innerWidth / 2 - centerLabelAreaWidth / 2;
340
+
341
+ // 绘制每个棒子
342
+ for (let j = 0; j < barCount; j++) {
343
+ const barX = leftStartX - (j + 1) * (barWidth + barSpacing);
344
+
345
+ g.append("rect")
346
+ .attr("x", barX)
347
+ .attr("y", barY)
348
+ .attr("width", barWidth)
349
+ .attr("height", finalBarHeight)
350
+ .attr("fill", getLeftFill())
351
+ .attr("rx", barWidth / 2)
352
+ .attr("ry", barWidth / 2)
353
+ .style("stroke", getStrokeColor(getColor(leftGroup)))
354
+ .style("stroke-width", variables.has_stroke ? 1 : 0)
355
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "none");
356
+ }
357
+
358
+ // 添加左侧数值标签
359
+ const formattedValue = valueUnit ? `${chartUtils.format.autoText(value)}${valueUnit}` : `${chartUtils.format.autoText(value)}`;
360
+ const lastBarX = leftStartX - barCount * (barWidth + barSpacing) - 5;
361
+
362
+ g.append("text")
363
+ .attr("x", lastBarX)
364
+ .attr("y", y + barHeight / 2)
365
+ .attr("dy", "0.35em")
366
+ .attr("text-anchor", "end")
367
+ .style("font-family", typography.annotation.font_family)
368
+ .style("font-size", typography.annotation.font_size)
369
+ .style("font-weight", typography.annotation.font_weight)
370
+ .style("fill", textColor)
371
+ .text(formattedValue);
372
+ }
373
+
374
+ // 绘制右侧棒子
375
+ if (rightData && rightData[valueField] !== undefined) {
376
+ const value = Math.abs(+rightData[valueField]);
377
+ const barCount = valueToBars(value);
378
+
379
+ // 计算右侧起始位置,从中间标签区域右侧开始
380
+ const rightStartX = innerWidth / 2 + centerLabelAreaWidth / 2;
381
+
382
+ // 绘制每个棒子
383
+ for (let j = 0; j < barCount; j++) {
384
+ const barX = rightStartX + j * (barWidth + barSpacing);
385
+
386
+ g.append("rect")
387
+ .attr("x", barX)
388
+ .attr("y", barY)
389
+ .attr("width", barWidth)
390
+ .attr("height", finalBarHeight)
391
+ .attr("fill", getRightFill())
392
+ .attr("rx", barWidth / 2)
393
+ .attr("ry", barWidth / 2)
394
+ .style("stroke", getStrokeColor(getColor(rightGroup)))
395
+ .style("stroke-width", variables.has_stroke ? 1 : 0)
396
+ .style("filter", variables.has_shadow ? "url(#shadow)" : "none");
397
+ }
398
+
399
+ // 添加右侧数值标签
400
+ const formattedValue = valueUnit ? `${chartUtils.format.autoText(value)}${valueUnit}` : `${chartUtils.format.autoText(value)}`;
401
+ const lastBarX = rightStartX + barCount * (barWidth + barSpacing) + 5;
402
+
403
+ g.append("text")
404
+ .attr("x", lastBarX)
405
+ .attr("y", y + barHeight / 2)
406
+ .attr("dy", "0.35em")
407
+ .attr("text-anchor", "start")
408
+ .style("font-family", typography.annotation.font_family)
409
+ .style("font-size", typography.annotation.font_size)
410
+ .style("font-weight", typography.annotation.font_weight)
411
+ .style("fill", textColor)
412
+ .text(formattedValue);
413
+ }
414
+ });
415
+
416
+ // 返回SVG节点
417
+ return svg.node();
418
+ }