Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- modules/chart_engine/template/d3-js/type17_diverging_bar_chart/diverging_bar_plain_chart_01.js +429 -0
- modules/chart_engine/template/d3-js/type17_diverging_bar_chart/diverging_bar_plain_chart_02.js +517 -0
- modules/chart_engine/template/d3-js/type17_diverging_bar_chart/diverging_bar_plain_chart_03.js +690 -0
- modules/chart_engine/template/d3-js/type17_diverging_bar_chart/diverging_bar_plain_chart_04.js +551 -0
- modules/chart_engine/template/d3-js/type17_diverging_bar_chart/diverging_bar_plain_chart_05.js +430 -0
- modules/chart_engine/template/d3-js/type17_diverging_bar_chart/diverging_bar_plain_chart_06.js +418 -0
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 |
+
}
|