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