Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files
modules/chart_engine/template/d3-js/type18_vertical_bar_chart_with_ circle/vertical_bar_chart_with_circle_plain_01.js
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Vertical Bar Chart With Circle",
|
| 5 |
+
"chart_name": "vertical_bar_chart_with_circle_plain_01",
|
| 6 |
+
"is_composite": true,
|
| 7 |
+
"required_fields": ["x", "y", "y2"],
|
| 8 |
+
"required_fields_type": [
|
| 9 |
+
["categorical"],
|
| 10 |
+
["numerical"],
|
| 11 |
+
["numerical"]
|
| 12 |
+
],
|
| 13 |
+
"required_fields_range": [[2, 12], ["-inf", "inf"], [0, "inf"]],
|
| 14 |
+
"required_fields_icons": ["x"],
|
| 15 |
+
"required_other_icons": [],
|
| 16 |
+
"required_fields_colors": ["x"],
|
| 17 |
+
"required_other_colors": ["primary","secondary"],
|
| 18 |
+
"supported_effects": ["radius_corner", "spacing"],
|
| 19 |
+
"min_height": 400,
|
| 20 |
+
"min_width": 400,
|
| 21 |
+
"background": "no",
|
| 22 |
+
"icon_mark": "none",
|
| 23 |
+
"icon_label": "side",
|
| 24 |
+
"has_x_axis": "no",
|
| 25 |
+
"has_y_axis": "no"
|
| 26 |
+
}
|
| 27 |
+
REQUIREMENTS_END
|
| 28 |
+
*/
|
| 29 |
+
|
| 30 |
+
// 垂直条形图与比例圆复合图表实现 - 使用D3.js vertical_bar_proportional_circle_area_chart_01
|
| 31 |
+
function makeChart(containerSelector, data) {
|
| 32 |
+
const colorResolver = chartUtils.color.resolver(data);
|
| 33 |
+
// ---------- 1. 数据准备阶段 ----------
|
| 34 |
+
|
| 35 |
+
// 提取数据和配置
|
| 36 |
+
const jsonData = data;
|
| 37 |
+
const chartData = jsonData.data.data;
|
| 38 |
+
const variables = jsonData.variables || {};
|
| 39 |
+
const typography = jsonData.typography || {
|
| 40 |
+
title: { font_family: "Arial", font_size: "28px", font_weight: 700 },
|
| 41 |
+
label: { font_family: "Arial", font_size: "16px", font_weight: 500 },
|
| 42 |
+
description: { font_family: "Arial", font_size: "16px", font_weight: 500 },
|
| 43 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: 400 }
|
| 44 |
+
};
|
| 45 |
+
const colors = jsonData.colors || {
|
| 46 |
+
text_color: "#000000",
|
| 47 |
+
background_color: "#FFFFFF",
|
| 48 |
+
other: { primary: "#008080", secondary: "#FF0000" }, // 默认颜色: 蓝绿色和红色
|
| 49 |
+
available_colors: ["#FFBF00"] // 默认: 黄色
|
| 50 |
+
};
|
| 51 |
+
const images = jsonData.images || { field: {}, other: {} };
|
| 52 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 53 |
+
|
| 54 |
+
// 数值单位规范
|
| 55 |
+
// 添加数值格式化函数
|
| 56 |
+
|
| 57 |
+
// *** 添加: 创建用于文本测量的Canvas Context ***
|
| 58 |
+
const canvas = document.createElement('canvas');
|
| 59 |
+
const context = canvas.getContext('2d');
|
| 60 |
+
|
| 61 |
+
// *** 修改: 提前提取字段名和单位 ***
|
| 62 |
+
const dimensionField = chartUtils.schema.channel(jsonData, "x").key ;
|
| 63 |
+
const valueField = chartUtils.schema.channel(jsonData, "y").key ;
|
| 64 |
+
const valueField2 = chartUtils.schema.channel(jsonData, "y2").key ;
|
| 65 |
+
|
| 66 |
+
let valueUnit = chartUtils.schema.channel(jsonData, "y").unit === "none" ? "" :
|
| 67 |
+
chartUtils.schema.channel(jsonData, "y").unit || ""; // 默认为百分号
|
| 68 |
+
let valueUnit2 = chartUtils.schema.channel(jsonData, "y2").unit === "none" ? "" :
|
| 69 |
+
chartUtils.schema.channel(jsonData, "y2").unit || "";
|
| 70 |
+
|
| 71 |
+
// 确保单位是字符串
|
| 72 |
+
valueUnit = valueUnit || "";
|
| 73 |
+
valueUnit2 = valueUnit2 || "";
|
| 74 |
+
|
| 75 |
+
// 清空容器
|
| 76 |
+
d3.select(containerSelector).html("");
|
| 77 |
+
|
| 78 |
+
// ---------- 2. 尺寸和布局设置 ----------
|
| 79 |
+
|
| 80 |
+
const width = variables.width || 800;
|
| 81 |
+
const height = variables.height || 600;
|
| 82 |
+
|
| 83 |
+
// 边距 - 减少底部边距,因为标签上移
|
| 84 |
+
const margin = { top: 90, right: 50, bottom: 30, left: 50 }; // *** 修改: 减少 bottom ***
|
| 85 |
+
const innerWidth = width - margin.left - margin.right;
|
| 86 |
+
const innerHeight = height - margin.top - margin.bottom; // *** 修改: 重新计算 innerHeight ***
|
| 87 |
+
|
| 88 |
+
// 定义中间区域的高度 (保持不变或按需调整)
|
| 89 |
+
const centralBandHeight = innerHeight * 0.20; // 中间区域占20%高度
|
| 90 |
+
|
| 91 |
+
// 上下条形图的总可用高度
|
| 92 |
+
const barAreaHeight = innerHeight - centralBandHeight;
|
| 93 |
+
|
| 94 |
+
// 按正负最大值比例分配高度
|
| 95 |
+
const maxPositiveY = d3.max(chartData, d => Math.max(0, +d[valueField])) || 0;
|
| 96 |
+
const maxNegativeY = Math.abs(d3.min(chartData, d => Math.min(0, +d[valueField])) || 0);
|
| 97 |
+
const totalMagnitudeRange = maxPositiveY + maxNegativeY;
|
| 98 |
+
|
| 99 |
+
let topBarAreaHeight;
|
| 100 |
+
let bottomBarAreaHeight;
|
| 101 |
+
|
| 102 |
+
if (totalMagnitudeRange > 0) {
|
| 103 |
+
topBarAreaHeight = barAreaHeight * (maxPositiveY / totalMagnitudeRange);
|
| 104 |
+
bottomBarAreaHeight = barAreaHeight * (maxNegativeY / totalMagnitudeRange);
|
| 105 |
+
} else {
|
| 106 |
+
topBarAreaHeight = barAreaHeight / 2;
|
| 107 |
+
bottomBarAreaHeight = barAreaHeight / 2;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
// *** 修改: 重新计算中间区域的 Y 坐标 ***
|
| 111 |
+
const centralBandTopY = margin.top + topBarAreaHeight;
|
| 112 |
+
const centralBandBottomY = centralBandTopY + centralBandHeight;
|
| 113 |
+
|
| 114 |
+
// 定义图标和标签尺寸 (保持不变)
|
| 115 |
+
const iconSize = 20;
|
| 116 |
+
const iconMargin = 3;
|
| 117 |
+
const labelMargin = 3;
|
| 118 |
+
const circlePadding = 5;
|
| 119 |
+
|
| 120 |
+
const baseFontSizeLabel = parseFloat(typography.label.font_size) || 14;
|
| 121 |
+
const baseFontSizeAnnotation = parseFloat(typography.annotation.font_size) || 12;
|
| 122 |
+
const minFontSize = 8;
|
| 123 |
+
|
| 124 |
+
// *** 修改: 仅在此处预估中间区域元素 Y 坐标 ***
|
| 125 |
+
const estDimensionLabelY = centralBandTopY + centralBandHeight * 0.30;
|
| 126 |
+
const estCircleY = centralBandTopY + centralBandHeight * 0.70;
|
| 127 |
+
|
| 128 |
+
// ---------- 4. 数据处理 ----------
|
| 129 |
+
|
| 130 |
+
// 转换数值类型
|
| 131 |
+
chartData.forEach(d => {
|
| 132 |
+
d[valueField] = +d[valueField];
|
| 133 |
+
d[valueField2] = +d[valueField2];
|
| 134 |
+
});
|
| 135 |
+
|
| 136 |
+
// *** 修改: 按 y 值 (valueField) 降序排序数据 ***
|
| 137 |
+
chartData.sort((a, b) => +b[valueField] - +a[valueField]);
|
| 138 |
+
|
| 139 |
+
// 获取排序后的维度列表
|
| 140 |
+
const dimensions = chartData.map(d => d[dimensionField]);
|
| 141 |
+
|
| 142 |
+
// *** 添加: 获取第一个维度的颜色用于图例 ***
|
| 143 |
+
let firstDimensionColor = "#888888"; // 默认灰色
|
| 144 |
+
if (chartData.length > 0) {
|
| 145 |
+
const firstDimension = chartData[0][dimensionField];
|
| 146 |
+
firstDimensionColor = colorResolver.field(firstDimension, 0, { fallback: firstDimensionColor }).value;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// ---------- 5. 创建比例尺 ----------
|
| 150 |
+
|
| 151 |
+
// X轴比例尺 (类别)
|
| 152 |
+
const xScale = d3.scaleBand()
|
| 153 |
+
.domain(dimensions)
|
| 154 |
+
.range([0, innerWidth])
|
| 155 |
+
.padding(0.2); // 柱子之间的间距
|
| 156 |
+
|
| 157 |
+
// *** 修改: 更新 Y 轴比例尺范围以适应中间区域 ***
|
| 158 |
+
const yScalePositive = d3.scaleLinear()
|
| 159 |
+
.domain([0, maxPositiveY === 0 ? 1 : maxPositiveY])
|
| 160 |
+
.range([centralBandTopY, margin.top]); // 从中间区域顶部到图表顶部边距
|
| 161 |
+
|
| 162 |
+
const yScaleNegative = d3.scaleLinear()
|
| 163 |
+
.domain([0, maxNegativeY === 0 ? 1 : maxNegativeY])
|
| 164 |
+
.range([centralBandBottomY, height - margin.bottom]); // 从中间区域底部到图表底部边距
|
| 165 |
+
|
| 166 |
+
// 圆形面积比例尺 (y2值)
|
| 167 |
+
const maxValue2 = d3.max(chartData, d => +d[valueField2]) || 0;
|
| 168 |
+
const minRadius = 2;
|
| 169 |
+
|
| 170 |
+
// *** 修改: 计算精确的中间区域 Y 坐标和最大半径 ***
|
| 171 |
+
const dimensionLabelY = centralBandTopY + centralBandHeight * 0.30; // 最终维度标签Y
|
| 172 |
+
const circleY = centralBandTopY + centralBandHeight * 0.70; // 最终圆圈Y
|
| 173 |
+
|
| 174 |
+
const maxRadiusFromBarWidth = xScale.bandwidth() / 2 * 0.9;
|
| 175 |
+
// *** 修改: 使用基础字体大小估算圆圈区域顶部 Y 坐标 ***
|
| 176 |
+
const actualCircleAreaTopY = dimensionLabelY + baseFontSizeLabel + labelMargin; // 使用 baseFontSizeLabel 进行布局估算
|
| 177 |
+
const circleAreaHeight = centralBandBottomY - actualCircleAreaTopY;
|
| 178 |
+
const maxCircleRadiusAvailableBasedOnHeight = Math.max(0, (circleAreaHeight * 0.9) / 2);
|
| 179 |
+
const maxCircleRadiusAvailable = Math.min(maxCircleRadiusAvailableBasedOnHeight, maxRadiusFromBarWidth);
|
| 180 |
+
const maxRadius = Math.max(minRadius, maxCircleRadiusAvailable);
|
| 181 |
+
|
| 182 |
+
const radiusScale = d3.scaleSqrt()
|
| 183 |
+
.domain([0, maxValue2])
|
| 184 |
+
.range([minRadius, maxRadius]);
|
| 185 |
+
|
| 186 |
+
// *** 添加: 文本换行辅助函数 ***
|
| 187 |
+
function wrapText(textElement, text, width, x, y, fontSize, fontWeight, fontFamily) {
|
| 188 |
+
textElement.each(function() {
|
| 189 |
+
let words = text.split(/\s+/).reverse(),
|
| 190 |
+
word,
|
| 191 |
+
line = [],
|
| 192 |
+
lineNumber = 0,
|
| 193 |
+
lineHeight = 1.1, // ems
|
| 194 |
+
tspan = textElement.text(null).append("tspan").attr("x", x).attr("y", y),
|
| 195 |
+
dy = 0; // Initial dy for first line
|
| 196 |
+
|
| 197 |
+
// Estimate width function (simple version)
|
| 198 |
+
const estimate = (txt) => txt.length * fontSize * 0.6;
|
| 199 |
+
|
| 200 |
+
// Check total estimated width first
|
| 201 |
+
if (measureTextWidth(text, fontSize, fontWeight, fontFamily) <= width) {
|
| 202 |
+
tspan.text(text);
|
| 203 |
+
return; // No wrapping needed
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
// Try to wrap (limit to max 2 lines for simplicity here)
|
| 207 |
+
let lines = [];
|
| 208 |
+
let currentLine = "";
|
| 209 |
+
while (word = words.pop()) {
|
| 210 |
+
line.push(word);
|
| 211 |
+
currentLine = line.join(" ");
|
| 212 |
+
if (measureTextWidth(currentLine, fontSize, fontWeight, fontFamily) > width && line.length > 1) {
|
| 213 |
+
// Line exceeds width, back up one word
|
| 214 |
+
line.pop(); // remove the word that broke the limit
|
| 215 |
+
lines.push(line.join(" ")); // Add the previous line
|
| 216 |
+
line = [word]; // Start new line with the current word
|
| 217 |
+
if (lines.length >= 1) { // Stop after creating the first line break potential
|
| 218 |
+
line = [word].concat(words.reverse()); // Put remaining words on the second line
|
| 219 |
+
break;
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
lines.push(line.join(" ")); // Add the last line
|
| 224 |
+
|
| 225 |
+
// Render the lines (max 4)
|
| 226 |
+
lines.slice(0, 4).forEach((lineText, i) => {
|
| 227 |
+
if (i > 0) dy = lineHeight;
|
| 228 |
+
tspan = textElement.append("tspan")
|
| 229 |
+
.attr("x", x)
|
| 230 |
+
.attr("y", y)
|
| 231 |
+
.attr("dy", `${dy}em`)
|
| 232 |
+
.text(lineText);
|
| 233 |
+
});
|
| 234 |
+
// If more than 2 lines would be needed, the second line might truncate
|
| 235 |
+
|
| 236 |
+
});
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
// *** 修改: 字体大小计算函数 -> 精确测量 ***
|
| 240 |
+
function measureTextWidth(text, fontSize, fontWeight, fontFamily) {
|
| 241 |
+
return chartUtils.text.measure(null, text, { fontFamily: fontFamily, fontSize: fontSize, fontWeight: fontWeight }).width;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
let minDimensionLabelRatio = 1.0;
|
| 245 |
+
let minCircleLabelRatio = 1.0;
|
| 246 |
+
let minBarLabelRatio = 1.0;
|
| 247 |
+
|
| 248 |
+
const maxDimensionLabelWidth = xScale.bandwidth() * 0.98;
|
| 249 |
+
const maxCircleLabelWidth = xScale.bandwidth() * 1.03;
|
| 250 |
+
const maxBarLabelWidth = xScale.bandwidth();
|
| 251 |
+
|
| 252 |
+
// *** 注意: 这里的 finalDimensionFontSize, finalCircleFontSize, finalBarFontSize
|
| 253 |
+
// 是在循环之后计算的,但在循环内部以及半径计算中可能需要提前估算或使用基础值。
|
| 254 |
+
// 当前代码在半径计算中使用了 finalDimensionFontSize || baseFontSizeLabel
|
| 255 |
+
|
| 256 |
+
chartData.forEach(d => {
|
| 257 |
+
// 维度标签
|
| 258 |
+
const dimensionText = String(d[dimensionField]); // Ensure string
|
| 259 |
+
// *** 修改: 使用精确宽度计算 ***
|
| 260 |
+
let currentWidth = measureTextWidth(dimensionText, baseFontSizeLabel, typography.label.font_weight, typography.label.font_family);
|
| 261 |
+
if (currentWidth > maxDimensionLabelWidth) {
|
| 262 |
+
minDimensionLabelRatio = Math.min(minDimensionLabelRatio, maxDimensionLabelWidth / currentWidth);
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
// 圆圈标签
|
| 266 |
+
const circleText = chartUtils.format.autoText(+d[valueField2]) + valueUnit2; // *** 修改: 单位放在后面 ***
|
| 267 |
+
currentWidth = measureTextWidth(circleText, baseFontSizeLabel, typography.label.font_weight, typography.label.font_family);
|
| 268 |
+
// *** 修改: 圆圈标签宽度只受bar宽度限制 ***
|
| 269 |
+
const effectiveMaxCircleWidth = maxCircleLabelWidth; // Remove constraint from maxAllowedCircleWidth
|
| 270 |
+
if (currentWidth > effectiveMaxCircleWidth) {
|
| 271 |
+
minCircleLabelRatio = Math.min(minCircleLabelRatio, effectiveMaxCircleWidth / currentWidth);
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
// 条形图标签
|
| 275 |
+
const barValue = d[valueField];
|
| 276 |
+
const barText = (barValue > 0 ? "+" : "") + chartUtils.format.autoText(barValue) + valueUnit;
|
| 277 |
+
// *** 修改: 使用精确宽度计算 ***
|
| 278 |
+
currentWidth = measureTextWidth(barText, baseFontSizeAnnotation, typography.annotation.font_weight, typography.annotation.font_family);
|
| 279 |
+
if (currentWidth > maxBarLabelWidth) {
|
| 280 |
+
minBarLabelRatio = Math.min(minBarLabelRatio, maxBarLabelWidth / currentWidth);
|
| 281 |
+
}
|
| 282 |
+
});
|
| 283 |
+
|
| 284 |
+
// 计算最终字体大小 (应用最小比例并限制最小值)
|
| 285 |
+
const finalDimensionFontSize = Math.max(minFontSize, baseFontSizeLabel * minDimensionLabelRatio);
|
| 286 |
+
const finalCircleFontSize = Math.max(minFontSize, baseFontSizeLabel * minCircleLabelRatio);
|
| 287 |
+
const finalBarFontSize = Math.max(minFontSize, baseFontSizeAnnotation * minBarLabelRatio);
|
| 288 |
+
|
| 289 |
+
// ---------- 6. 创建SVG容器 ----------
|
| 290 |
+
|
| 291 |
+
const svg = d3.select(containerSelector)
|
| 292 |
+
.append("svg")
|
| 293 |
+
.attr("width", "100%")
|
| 294 |
+
.attr("height", height)
|
| 295 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 296 |
+
.attr("preserveAspectRatio", "xMidYMid meet") // 保持宽高比
|
| 297 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 298 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 299 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
// ---------- 7. 创建主图表组 ----------
|
| 304 |
+
|
| 305 |
+
const g = svg.append("g")
|
| 306 |
+
.attr("transform", `translate(${margin.left}, 0)`); // Y方向不移动,因为Y坐标已包含margin.top
|
| 307 |
+
const legendY = margin.top / 2;
|
| 308 |
+
const legendSquareSize = 12;
|
| 309 |
+
const legendCircleRadius = 6;
|
| 310 |
+
const legendPadding = 15;
|
| 311 |
+
const legendItemPadding = 5;
|
| 312 |
+
const yName = valueField;
|
| 313 |
+
const y2Name = valueField2;
|
| 314 |
+
const legendSymbolColor = firstDimensionColor;
|
| 315 |
+
chartUtils.legend.mixedCentered(svg, [
|
| 316 |
+
{ markerShape: "rect", markerSize: legendSquareSize, color: legendSymbolColor, label: yName },
|
| 317 |
+
{ markerShape: "circle", markerSize: legendCircleRadius, color: legendSymbolColor, label: y2Name },
|
| 318 |
+
], {
|
| 319 |
+
centerX: margin.left + innerWidth / 2,
|
| 320 |
+
y: legendY,
|
| 321 |
+
itemGap: legendPadding,
|
| 322 |
+
markerTextGap: legendItemPadding,
|
| 323 |
+
fontSize: parseFloat(typography.annotation.font_size),
|
| 324 |
+
fontFamily: typography.annotation.font_family,
|
| 325 |
+
fontWeight: typography.annotation.font_weight,
|
| 326 |
+
textColor: colorResolver.text({ fallback: "#000000" }).value,
|
| 327 |
+
});
|
| 328 |
+
|
| 329 |
+
// ---------- 9. 绘制图表元素 (循环处理每个数据点) ----------
|
| 330 |
+
|
| 331 |
+
chartData.forEach((d, i) => {
|
| 332 |
+
const x = xScale(d[dimensionField]); // 获取当前类别的X坐标
|
| 333 |
+
const barWidth = xScale.bandwidth(); // 获取条形的宽度
|
| 334 |
+
const centerX = x + barWidth / 2; // 当前类别的中心X坐标
|
| 335 |
+
|
| 336 |
+
// *** 添加: 确定当前维度的颜色 ***
|
| 337 |
+
const dimension = d[dimensionField];
|
| 338 |
+
let dimensionColor = "#888888"; // 默认灰色
|
| 339 |
+
dimensionColor = colorResolver.field(dimension, 0, { fallback: dimensionColor }).value;
|
| 340 |
+
|
| 341 |
+
// 9.1 绘制垂直条形图 (y值)
|
| 342 |
+
const yValue = d[valueField];
|
| 343 |
+
let barHeight, barY;
|
| 344 |
+
|
| 345 |
+
// *** 修改: 使用更新后的 yScale 和边界计算高度/位置 ***
|
| 346 |
+
if (yValue > 0) {
|
| 347 |
+
barY = yScalePositive(yValue);
|
| 348 |
+
barHeight = centralBandTopY - barY; // 高度是中间区域顶部到 scale 输出的差值
|
| 349 |
+
} else if (yValue < 0) {
|
| 350 |
+
barY = centralBandBottomY; // 负值条从中间区域底部开始
|
| 351 |
+
const scaledY = yScaleNegative(Math.abs(yValue));
|
| 352 |
+
barHeight = scaledY - barY; // 高度是 scale 输出和中间区域底部的差值
|
| 353 |
+
} else {
|
| 354 |
+
barHeight = 0;
|
| 355 |
+
barY = centralBandTopY;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
if (barHeight < 0) barHeight = 0;
|
| 359 |
+
|
| 360 |
+
if (barHeight > 0) { // 只有高度大于0才绘制
|
| 361 |
+
g.append("rect")
|
| 362 |
+
.attr("x", x)
|
| 363 |
+
.attr("width", barWidth)
|
| 364 |
+
.attr("fill", dimensionColor)
|
| 365 |
+
.attr("y", barY)
|
| 366 |
+
.attr("height", barHeight)
|
| 367 |
+
.attr("rx", 5)
|
| 368 |
+
.attr("ry", 5);
|
| 369 |
+
|
| 370 |
+
// 9.2 添加条形图数值标签 (y值)
|
| 371 |
+
const labelText = (yValue > 0 ? "+" : "") + chartUtils.format.autoText(yValue) + valueUnit;
|
| 372 |
+
// *** 修改: 标签 Y 坐标逻辑不变,但依赖的 barY/barHeight 已更新 ***
|
| 373 |
+
const labelY = (yValue >= 0) ? barY - 5 : barY + barHeight + (finalBarFontSize * 0.8);
|
| 374 |
+
const textAnchor = "middle";
|
| 375 |
+
|
| 376 |
+
g.append("text")
|
| 377 |
+
.attr("x", centerX)
|
| 378 |
+
.attr("y", labelY)
|
| 379 |
+
.attr("text-anchor", textAnchor)
|
| 380 |
+
.style("font-family", typography.annotation.font_family)
|
| 381 |
+
.style("font-size", `${finalBarFontSize}px`)
|
| 382 |
+
.style("font-weight", typography.annotation.font_weight)
|
| 383 |
+
.style("fill", colorResolver.text({ fallback: "#000000" }).value)
|
| 384 |
+
.text(labelText);
|
| 385 |
+
|
| 386 |
+
// 图标位置逻辑不变,依赖 labelY
|
| 387 |
+
if (images.field && images.field[d[dimensionField]]) {
|
| 388 |
+
const iconTargetY = labelY - (finalBarFontSize * (yValue >= 0 ? 0.7 : 0.1));
|
| 389 |
+
const iconY = iconTargetY - iconMargin - iconSize;
|
| 390 |
+
|
| 391 |
+
g.append("image")
|
| 392 |
+
.attr("x", centerX - iconSize / 2)
|
| 393 |
+
.attr("y", iconY)
|
| 394 |
+
.attr("width", iconSize)
|
| 395 |
+
.attr("height", iconSize)
|
| 396 |
+
.attr("xlink:href", images.field[d[dimensionField]])
|
| 397 |
+
.attr("preserveAspectRatio","xMidYMid meet");
|
| 398 |
+
}
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
// 9.3 绘制圆圈 (y2值)
|
| 402 |
+
const circleRadius = radiusScale(+d[valueField2]);
|
| 403 |
+
|
| 404 |
+
g.append("circle")
|
| 405 |
+
.attr("cx", centerX)
|
| 406 |
+
.attr("cy", circleY) // *** 修改: 使用新的 circleY ***
|
| 407 |
+
.attr("r", circleRadius)
|
| 408 |
+
.attr("fill", dimensionColor)
|
| 409 |
+
.attr("opacity", 0.8);
|
| 410 |
+
|
| 411 |
+
// 9.4 添加圆圈数值标签 (y2值) - 位置和颜色根据大小调整
|
| 412 |
+
const circleLabelText = chartUtils.format.autoText(+d[valueField2]) + valueUnit2;
|
| 413 |
+
const textWidth = measureTextWidth(circleLabelText, finalCircleFontSize, typography.label.font_weight, typography.label.font_family);
|
| 414 |
+
const isCircleBigEnough = textWidth < (circleRadius * 1.6);
|
| 415 |
+
let labelColor, labelActualY, labelDy;
|
| 416 |
+
|
| 417 |
+
if (isCircleBigEnough && circleRadius > minFontSize / 2) {
|
| 418 |
+
labelColor = "#ffffff";
|
| 419 |
+
labelActualY = circleY; // *** 修改: 内部标签使用新的 circleY ***
|
| 420 |
+
labelDy = "0.35em";
|
| 421 |
+
} else {
|
| 422 |
+
labelColor = colorResolver.text({ fallback: "#000000" }).value;
|
| 423 |
+
// *** 修改: 外部标签基于新的 circleY 定位 ***
|
| 424 |
+
labelActualY = circleY + circleRadius + (finalCircleFontSize * 0.6);
|
| 425 |
+
labelDy = "0em";
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
g.append("text")
|
| 429 |
+
.attr("x", centerX)
|
| 430 |
+
.attr("y", labelActualY) // *** 修改: 使用计算出的 labelActualY ***
|
| 431 |
+
.attr("dy", labelDy)
|
| 432 |
+
.attr("text-anchor", "middle")
|
| 433 |
+
.style("font-family", typography.label.font_family)
|
| 434 |
+
.style("font-size", `${finalCircleFontSize}px`)
|
| 435 |
+
.style("font-weight", typography.label.font_weight)
|
| 436 |
+
.style("fill", labelColor)
|
| 437 |
+
.text(circleLabelText);
|
| 438 |
+
|
| 439 |
+
// 9.5 添加维度标签 (x值)
|
| 440 |
+
// 图标已移走
|
| 441 |
+
g.append("text")
|
| 442 |
+
.attr("x", centerX)
|
| 443 |
+
.attr("y", dimensionLabelY) // *** 修改: 使用新的 dimensionLabelY ***
|
| 444 |
+
.attr("text-anchor", "middle")
|
| 445 |
+
.style("font-family", typography.label.font_family)
|
| 446 |
+
.style("font-size", `${finalDimensionFontSize}px`)
|
| 447 |
+
.style("font-weight", typography.label.font_weight)
|
| 448 |
+
.style("fill", colorResolver.text({ fallback: "#000000" }).value)
|
| 449 |
+
.call(wrapText, d[dimensionField], maxDimensionLabelWidth, centerX, dimensionLabelY, finalDimensionFontSize, typography.label.font_weight, typography.label.font_family);
|
| 450 |
+
|
| 451 |
+
});
|
| 452 |
+
|
| 453 |
+
// ---------- 10. 返回SVG节点 ----------
|
| 454 |
+
return svg.node();
|
| 455 |
+
}
|
modules/chart_engine/template/d3-js/type18_vertical_bar_chart_with_ circle/vertical_bar_chart_with_circle_plain_02.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Vertical Bar Chart With Circle",
|
| 5 |
+
"chart_name": "vertical_bar_chart_with_circle_plain_02",
|
| 6 |
+
"is_composite": true,
|
| 7 |
+
"required_fields": ["x", "y", "y2"],
|
| 8 |
+
"required_fields_type": [["categorical"], ["numerical"], ["numerical"]],
|
| 9 |
+
"required_fields_range": [[2, 12], ["-inf", "inf"], [0, "inf"]],
|
| 10 |
+
"required_fields_icons": ["x"],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": [],
|
| 13 |
+
"required_other_colors": [],
|
| 14 |
+
"supported_effects": [],
|
| 15 |
+
"min_height": 400,
|
| 16 |
+
"min_width": 400,
|
| 17 |
+
"background": "no",
|
| 18 |
+
"icon_mark": "none",
|
| 19 |
+
"icon_label": "side",
|
| 20 |
+
"has_x_axis": "no",
|
| 21 |
+
"has_y_axis": "no"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
// 垂直条形图与比例圆复合图表实现 - 使用D3.js vertical_bar_chart_with_circle_plain_02 半圆
|
| 27 |
+
function makeChart(containerSelector, data) {
|
| 28 |
+
const colorResolver = chartUtils.color.resolver(data);
|
| 29 |
+
// ---------- 1. 数据准备阶段 ----------
|
| 30 |
+
const jsonData = data;
|
| 31 |
+
const chartData = jsonData.data.data;
|
| 32 |
+
const variables = jsonData.variables || {};
|
| 33 |
+
const typography = jsonData.typography || {
|
| 34 |
+
title: { font_family: "Arial", font_size: "28px", font_weight: 700 },
|
| 35 |
+
label: { font_family: "Arial", font_size: "16px", font_weight: 500 },
|
| 36 |
+
description: { font_family: "Arial", font_size: "16px", font_weight: 500 },
|
| 37 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: 400 }
|
| 38 |
+
};
|
| 39 |
+
const colors = jsonData.colors || {
|
| 40 |
+
text_color: "#000000",
|
| 41 |
+
other: { primary: "#008080", secondary: "#FF0000" },
|
| 42 |
+
available_colors: ["#FFBF00"]
|
| 43 |
+
};
|
| 44 |
+
const images = jsonData.images || { field: {}, other: {} };
|
| 45 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 46 |
+
|
| 47 |
+
// 创建文本测量Context
|
| 48 |
+
const context = document.createElement('canvas').getContext('2d');
|
| 49 |
+
|
| 50 |
+
// 提取字段名和单位
|
| 51 |
+
const dimensionField = chartUtils.schema.channel(jsonData, "x").key;
|
| 52 |
+
const valueField = chartUtils.schema.channel(jsonData, "y").key;
|
| 53 |
+
const valueField2 = chartUtils.schema.channel(jsonData, "y2").key;
|
| 54 |
+
const valueUnit = chartUtils.schema.channel(jsonData, "y").unit === "none" ? "" : (chartUtils.schema.channel(jsonData, "y").unit || "");
|
| 55 |
+
const valueUnit2 = chartUtils.schema.channel(jsonData, "y2").unit === "none" ? "" : (chartUtils.schema.channel(jsonData, "y2").unit || "");
|
| 56 |
+
|
| 57 |
+
// 数值格式化函数
|
| 58 |
+
|
| 59 |
+
d3.select(containerSelector).html("");
|
| 60 |
+
|
| 61 |
+
// ---------- 2. 尺寸和布局设置 ----------
|
| 62 |
+
const width = variables.width || 800;
|
| 63 |
+
const height = variables.height || 600;
|
| 64 |
+
const margin = { top: 90, right: 50, bottom: 60, left: 50 };
|
| 65 |
+
const innerWidth = width - margin.left - margin.right;
|
| 66 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 67 |
+
const centralBandHeight = innerHeight * 0.20;
|
| 68 |
+
const barAreaHeight = innerHeight - centralBandHeight;
|
| 69 |
+
|
| 70 |
+
// 按正负最大值比例分配高度
|
| 71 |
+
const maxPositiveY = d3.max(chartData, d => Math.max(0, +d[valueField])) || 0;
|
| 72 |
+
const maxNegativeY = Math.abs(d3.min(chartData, d => Math.min(0, +d[valueField])) || 0);
|
| 73 |
+
const totalMagnitudeRange = maxPositiveY + maxNegativeY;
|
| 74 |
+
const topBarAreaHeight = totalMagnitudeRange > 0 ? barAreaHeight * (maxPositiveY / totalMagnitudeRange) : barAreaHeight / 2;
|
| 75 |
+
const bottomBarAreaHeight = barAreaHeight - topBarAreaHeight;
|
| 76 |
+
|
| 77 |
+
// 计算关键Y坐标
|
| 78 |
+
const centralBandTopY = margin.top + topBarAreaHeight;
|
| 79 |
+
const centralBandBottomY = centralBandTopY + centralBandHeight;
|
| 80 |
+
|
| 81 |
+
// 布局参数
|
| 82 |
+
const iconSize = 20;
|
| 83 |
+
const baseFontSizeLabel = parseFloat(typography.label.font_size) || 14;
|
| 84 |
+
const baseFontSizeAnnotation = parseFloat(typography.annotation.font_size) || 12;
|
| 85 |
+
const minFontSize = 8;
|
| 86 |
+
|
| 87 |
+
// 计算元素Y坐标
|
| 88 |
+
const iconY = centralBandTopY + 5 + iconSize / 2;
|
| 89 |
+
const dimensionLabelY = iconY + iconSize / 2 + 3 + baseFontSizeLabel / 2;
|
| 90 |
+
const circleAreaHeight = centralBandBottomY - (dimensionLabelY + baseFontSizeLabel / 2 + 3) - 5;
|
| 91 |
+
const maxCircleRadiusAvailable = Math.min((circleAreaHeight * 0.9) / 2, innerWidth / 2);
|
| 92 |
+
|
| 93 |
+
// ---------- 3. 数据处理和比例尺 ----------
|
| 94 |
+
chartData.forEach(d => { d[valueField] = +d[valueField]; d[valueField2] = +d[valueField2]; });
|
| 95 |
+
chartData.sort((a, b) => +b[valueField] - +a[valueField]);
|
| 96 |
+
const dimensions = chartData.map(d => d[dimensionField]);
|
| 97 |
+
|
| 98 |
+
const xScale = d3.scaleBand().domain(dimensions).range([0, innerWidth]).padding(0.2);
|
| 99 |
+
const yScalePositive = d3.scaleLinear().domain([0, maxPositiveY || 1]).range([centralBandTopY, centralBandTopY - topBarAreaHeight]);
|
| 100 |
+
const yScaleNegative = d3.scaleLinear().domain([0, maxNegativeY || 1]).range([centralBandBottomY, centralBandBottomY + bottomBarAreaHeight]);
|
| 101 |
+
const radiusScale = d3.scaleSqrt().domain([0, d3.max(chartData, d => +d[valueField2]) || 0]).range([2, Math.max(2, maxCircleRadiusAvailable)]);
|
| 102 |
+
|
| 103 |
+
// ---------- 4. 辅助函数 ----------
|
| 104 |
+
// 文本宽度测量
|
| 105 |
+
const measureTextWidth = (text, fontSize, fontWeight, fontFamily) => {
|
| 106 |
+
return chartUtils.text.measure(null, text, { fontFamily: fontFamily, fontSize: fontSize, fontWeight: fontWeight }).width;
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
// 文本换行函数
|
| 110 |
+
function wrapText(textElement, text, width, x, y, fontSize, fontWeight, fontFamily, alignment = 'middle') {
|
| 111 |
+
textElement.each(function() {
|
| 112 |
+
const element = d3.select(this);
|
| 113 |
+
const words = text.split(/\s+/).reverse();
|
| 114 |
+
const lineHeight = 1.3;
|
| 115 |
+
|
| 116 |
+
element.text(null);
|
| 117 |
+
let tspans = [];
|
| 118 |
+
|
| 119 |
+
if (words.length > 1) {
|
| 120 |
+
let currentLine = [];
|
| 121 |
+
let word;
|
| 122 |
+
while (word = words.pop()) {
|
| 123 |
+
currentLine.push(word);
|
| 124 |
+
if (measureTextWidth(currentLine.join(" "), fontSize, fontWeight, fontFamily) > width && currentLine.length > 1) {
|
| 125 |
+
currentLine.pop();
|
| 126 |
+
tspans.push(currentLine.join(" "));
|
| 127 |
+
currentLine = [word];
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
if (currentLine.length > 0) tspans.push(currentLine.join(" "));
|
| 131 |
+
} else {
|
| 132 |
+
let currentLine = '';
|
| 133 |
+
for (let char of text.split('')) {
|
| 134 |
+
const nextLine = currentLine + char;
|
| 135 |
+
if (measureTextWidth(nextLine, fontSize, fontWeight, fontFamily) > width && currentLine.length > 0) {
|
| 136 |
+
tspans.push(currentLine);
|
| 137 |
+
currentLine = char;
|
| 138 |
+
} else {
|
| 139 |
+
currentLine = nextLine;
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
if (currentLine.length > 0) tspans.push(currentLine);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
// 计算对齐偏移
|
| 146 |
+
const totalLines = tspans.length;
|
| 147 |
+
let startDy = 0;
|
| 148 |
+
if (alignment === 'middle') startDy = -((totalLines - 1) * lineHeight / 2);
|
| 149 |
+
else if (alignment === 'bottom') startDy = -(totalLines * lineHeight - lineHeight);
|
| 150 |
+
|
| 151 |
+
tspans.forEach((lineText, i) => {
|
| 152 |
+
element.append("tspan")
|
| 153 |
+
.attr("x", x)
|
| 154 |
+
.attr("dy", (i === 0 ? startDy : lineHeight) + "em")
|
| 155 |
+
.text(lineText);
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
element.attr("data-lines", totalLines);
|
| 159 |
+
});
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
// 计算字体大小
|
| 163 |
+
let minDimensionLabelRatio = 1.0, minCircleLabelRatio = 1.0, minBarLabelRatio = 1.0;
|
| 164 |
+
const maxDimensionLabelWidth = xScale.bandwidth() * 0.95;
|
| 165 |
+
const maxCircleLabelWidth = xScale.bandwidth() * 1.03;
|
| 166 |
+
const maxBarLabelWidth = xScale.bandwidth();
|
| 167 |
+
|
| 168 |
+
chartData.forEach(d => {
|
| 169 |
+
// 维度标签
|
| 170 |
+
let currentWidth = measureTextWidth(String(d[dimensionField]), baseFontSizeLabel, typography.label.font_weight, typography.label.font_family);
|
| 171 |
+
if (currentWidth > maxDimensionLabelWidth) minDimensionLabelRatio = Math.min(minDimensionLabelRatio, maxDimensionLabelWidth / currentWidth);
|
| 172 |
+
|
| 173 |
+
// 圆圈标签
|
| 174 |
+
currentWidth = measureTextWidth(chartUtils.format.scaledText(+d[valueField2]) + (valueUnit2 ? ` ${valueUnit2}` : ''), baseFontSizeLabel, typography.label.font_weight, typography.label.font_family);
|
| 175 |
+
if (currentWidth > maxCircleLabelWidth) minCircleLabelRatio = Math.min(minCircleLabelRatio, maxCircleLabelWidth / currentWidth);
|
| 176 |
+
|
| 177 |
+
// 条形图标签
|
| 178 |
+
currentWidth = measureTextWidth((+d[valueField] > 0 ? "+" : "") + chartUtils.format.scaledText(+d[valueField]) + (valueUnit ? ` ${valueUnit}` : ''), baseFontSizeAnnotation, typography.annotation.font_weight, typography.annotation.font_family);
|
| 179 |
+
if (currentWidth > maxBarLabelWidth) minBarLabelRatio = Math.min(minBarLabelRatio, maxBarLabelWidth / currentWidth);
|
| 180 |
+
});
|
| 181 |
+
|
| 182 |
+
const finalDimensionFontSize = Math.max(minFontSize, baseFontSizeLabel * minDimensionLabelRatio);
|
| 183 |
+
const finalCircleFontSize = Math.max(minFontSize, baseFontSizeLabel * minCircleLabelRatio);
|
| 184 |
+
const finalBarFontSize = Math.max(minFontSize, baseFontSizeAnnotation * minBarLabelRatio);
|
| 185 |
+
|
| 186 |
+
// ---------- 5. 创建SVG容器 ----------
|
| 187 |
+
const svg = d3.select(containerSelector)
|
| 188 |
+
.append("svg")
|
| 189 |
+
.attr("width", "100%")
|
| 190 |
+
.attr("height", height)
|
| 191 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 192 |
+
.attr("preserveAspectRatio", "xMidYMid meet")
|
| 193 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 194 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 195 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 196 |
+
|
| 197 |
+
const g = svg.append("g").attr("transform", `translate(${margin.left}, 0)`);
|
| 198 |
+
|
| 199 |
+
// ---------- 6. 添加图例 ----------
|
| 200 |
+
const positiveColor = colorResolver.other("primary", { fallback: "#008080" }).value;
|
| 201 |
+
const negativeColor = colorResolver.other("secondary", { fallback: "#FF0000" }).value;
|
| 202 |
+
const circleLegendColor = colorResolver.available(0, { fallback: "#FFBF00" }).value;
|
| 203 |
+
const legendY = margin.top - 30;
|
| 204 |
+
chartUtils.legend.segmentsCentered(svg, [
|
| 205 |
+
{ type: "rect", size: 12, color: positiveColor },
|
| 206 |
+
{ type: "rect", size: 12, color: negativeColor },
|
| 207 |
+
{ type: "text", label: valueField },
|
| 208 |
+
{ type: "circle", size: 12, radius: 6, color: circleLegendColor },
|
| 209 |
+
{ type: "text", label: valueField2 },
|
| 210 |
+
], {
|
| 211 |
+
centerX: margin.left + innerWidth / 2,
|
| 212 |
+
y: legendY,
|
| 213 |
+
itemGap: 5,
|
| 214 |
+
sectionGap: 5,
|
| 215 |
+
fontSize: 12,
|
| 216 |
+
fontFamily: typography.annotation.font_family,
|
| 217 |
+
textColor: colorResolver.text({ fallback: "#333333" }).value,
|
| 218 |
+
});
|
| 219 |
+
// ---------- 7. 绘制图表元素 ----------
|
| 220 |
+
// 计算维度标签最大高度
|
| 221 |
+
let maxLabelHeight = 0;
|
| 222 |
+
const dimensionLabels = chartData.map((d, i) => {
|
| 223 |
+
const tempText = g.append("text").style("visibility", "hidden")
|
| 224 |
+
.call(wrapText, d[dimensionField], maxDimensionLabelWidth, 0, 0, finalDimensionFontSize, typography.label.font_weight, typography.label.font_family, 'top');
|
| 225 |
+
const lineCount = parseInt(tempText.attr("data-lines") || "1");
|
| 226 |
+
const height = lineCount * finalDimensionFontSize * 1.3;
|
| 227 |
+
maxLabelHeight = Math.max(maxLabelHeight, height);
|
| 228 |
+
tempText.remove();
|
| 229 |
+
return { lineCount, height };
|
| 230 |
+
});
|
| 231 |
+
|
| 232 |
+
const uniformCircleY = dimensionLabelY + maxLabelHeight + 10;
|
| 233 |
+
|
| 234 |
+
// 绘制所有元素
|
| 235 |
+
chartData.forEach((d, i) => {
|
| 236 |
+
const x = xScale(d[dimensionField]);
|
| 237 |
+
const barWidth = xScale.bandwidth();
|
| 238 |
+
const centerX = x + barWidth / 2;
|
| 239 |
+
const yValue = d[valueField];
|
| 240 |
+
|
| 241 |
+
// 绘制条形图
|
| 242 |
+
if (yValue !== 0) {
|
| 243 |
+
let barY, barHeight, barColor;
|
| 244 |
+
if (yValue > 0) {
|
| 245 |
+
barY = yScalePositive(yValue);
|
| 246 |
+
barHeight = centralBandTopY - barY;
|
| 247 |
+
barColor = colorResolver.other("primary", { fallback: "#008080" }).value;
|
| 248 |
+
} else {
|
| 249 |
+
barY = centralBandBottomY;
|
| 250 |
+
barHeight = yScaleNegative(Math.abs(yValue)) - barY;
|
| 251 |
+
barColor = colorResolver.other("secondary", { fallback: "#FF0000" }).value;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
if (barHeight > 0) {
|
| 255 |
+
g.append("rect").attr("class", "mark").attr("x", x).attr("width", barWidth).attr("fill", barColor)
|
| 256 |
+
.attr("y", barY).attr("height", barHeight).attr("rx", barWidth / 2).attr("ry", barWidth / 2);
|
| 257 |
+
|
| 258 |
+
// 条形数值标签
|
| 259 |
+
const labelText = (yValue > 0 ? "+" : "") + chartUtils.format.scaledText(yValue) + (valueUnit ? ` ${valueUnit}` : '');
|
| 260 |
+
const labelY = yValue >= 0 ? barY - 5 : barY + barHeight + finalBarFontSize * 0.8;
|
| 261 |
+
g.append("text").attr("class", "value").attr("x", centerX).attr("y", labelY).attr("text-anchor", "middle")
|
| 262 |
+
.style("font-family", typography.annotation.font_family).style("font-size", `${finalBarFontSize}px`)
|
| 263 |
+
.style("font-weight", typography.annotation.font_weight).style("fill", colorResolver.text({ fallback: "#000000" }).value).text(labelText);
|
| 264 |
+
}
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
// 维度标签
|
| 268 |
+
g.append("text").attr("class", "label").attr("x", centerX).attr("y", dimensionLabelY).attr("text-anchor", "middle")
|
| 269 |
+
.style("font-family", typography.label.font_family).style("font-size", `${finalDimensionFontSize}px`)
|
| 270 |
+
.style("font-weight", typography.label.font_weight).style("fill", colorResolver.text({ fallback: "#000000" }).value)
|
| 271 |
+
.call(wrapText, d[dimensionField], maxDimensionLabelWidth, centerX, dimensionLabelY, finalDimensionFontSize, typography.label.font_weight, typography.label.font_family, 'top');
|
| 272 |
+
|
| 273 |
+
// 圆圈
|
| 274 |
+
const circleRadius = radiusScale(+d[valueField2]);
|
| 275 |
+
g.append("circle").attr("class", "mark").attr("cx", centerX).attr("cy", uniformCircleY).attr("r", circleRadius)
|
| 276 |
+
.attr("fill", colorResolver.available(0, { fallback: "#FFBF00" }).value).attr("opacity", 0.8);
|
| 277 |
+
|
| 278 |
+
// 圆圈数值标签
|
| 279 |
+
const circleLabelText = chartUtils.format.scaledText(+d[valueField2]) + (valueUnit2 ? ` ${valueUnit2}` : '');
|
| 280 |
+
g.append("text").attr("class", "value").attr("x", centerX).attr("y", uniformCircleY).attr("dy", "0.35em").attr("text-anchor", "middle")
|
| 281 |
+
.style("font-family", typography.label.font_family).style("font-size", `${finalCircleFontSize}px`)
|
| 282 |
+
.style("font-weight", typography.label.font_weight).style("fill", colorResolver.text({ fallback: "#000000" }).value).text(circleLabelText);
|
| 283 |
+
|
| 284 |
+
// 图标
|
| 285 |
+
if (images.field && images.field[d[dimensionField]]) {
|
| 286 |
+
g.append("image").attr("class", "image").attr("x", centerX - iconSize / 2).attr("y", iconY - iconSize / 2)
|
| 287 |
+
.attr("width", iconSize).attr("height", iconSize).attr("xlink:href", images.field[d[dimensionField]]).attr("preserveAspectRatio", "xMidYMid meet");
|
| 288 |
+
}
|
| 289 |
+
});
|
| 290 |
+
|
| 291 |
+
return svg.node();
|
| 292 |
+
}
|