Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files
modules/chart_engine/template/d3-js/type49_donut_chart/donut_chart_icons_01.js
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Donut Chart",
|
| 5 |
+
"chart_name": "donut_chart_icons_01",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y"],
|
| 8 |
+
"required_fields_type": [["categorical"], ["numerical"]],
|
| 9 |
+
"required_fields_range": [[2, 20], [0, "inf"]],
|
| 10 |
+
"required_fields_icons": ["x"],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": ["x"],
|
| 13 |
+
"required_other_colors": [],
|
| 14 |
+
"supported_effects": ["shadow", "radius_corner", "stroke"],
|
| 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 dataColumns = chartUtils.schema.columns(jsonData);
|
| 35 |
+
|
| 36 |
+
const typography = jsonData.typography || {
|
| 37 |
+
title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
|
| 38 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 39 |
+
description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 40 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 41 |
+
};
|
| 42 |
+
let colors = jsonData.colors || {
|
| 43 |
+
text_color: "#333333",
|
| 44 |
+
field: {},
|
| 45 |
+
other: {
|
| 46 |
+
primary: "#4682B4" // 默认主色调
|
| 47 |
+
}
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
// 提取字段名称
|
| 51 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 52 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 53 |
+
// Set dimensions and margins
|
| 54 |
+
const width = variables.width;
|
| 55 |
+
const height = variables.height;
|
| 56 |
+
const margin = { top: 40, right: 40, bottom: 40, left: 40 };
|
| 57 |
+
const chartWidth = width - margin.left - margin.right;
|
| 58 |
+
const chartHeight = height - margin.top - margin.bottom;
|
| 59 |
+
|
| 60 |
+
// Create SVG
|
| 61 |
+
const svg = d3.select(containerSelector)
|
| 62 |
+
.append("svg")
|
| 63 |
+
.attr("width", "100%")
|
| 64 |
+
.attr("height", height)
|
| 65 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 66 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 67 |
+
.attr("xmlns", "http://www.w3.org/2000/svg");
|
| 68 |
+
|
| 69 |
+
// Calculate center point and max radius
|
| 70 |
+
const centerX = width / 2;
|
| 71 |
+
const centerY = height / 2;
|
| 72 |
+
const maxRadius = Math.min(chartWidth, chartHeight) / 2;
|
| 73 |
+
|
| 74 |
+
// Create a root group
|
| 75 |
+
const g = svg.append("g")
|
| 76 |
+
.attr("transform", `translate(${centerX}, ${centerY})`)
|
| 77 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 78 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 79 |
+
|
| 80 |
+
// 创建饼图生成器
|
| 81 |
+
const pie = d3.pie()
|
| 82 |
+
.value(d => +d[yField])
|
| 83 |
+
.sort(null);
|
| 84 |
+
|
| 85 |
+
// 创建弧形生成器
|
| 86 |
+
const arc = d3.arc()
|
| 87 |
+
.innerRadius(maxRadius*0.5)
|
| 88 |
+
.outerRadius(maxRadius)
|
| 89 |
+
.padAngle(0)
|
| 90 |
+
.cornerRadius(5);
|
| 91 |
+
|
| 92 |
+
// 计算每个组的百分比
|
| 93 |
+
const total = d3.sum(chartData, d => +d[yField]);
|
| 94 |
+
const dataWithPercentages = chartData.map(d => ({
|
| 95 |
+
...d,
|
| 96 |
+
percentage: (+d[yField] / total) * 100
|
| 97 |
+
}));
|
| 98 |
+
const sectors = pie(dataWithPercentages);
|
| 99 |
+
|
| 100 |
+
// 存储图像位置信息,用于后续避免标签重叠
|
| 101 |
+
const imagePositions = [];
|
| 102 |
+
|
| 103 |
+
// 计算避免标签与图像重叠的位置
|
| 104 |
+
function calculateLabelPosition(d, iconCentroid, iconWidth, innerRadius, outerRadius, textWidth, textHeight) {
|
| 105 |
+
// 计算扇区中心角度
|
| 106 |
+
const angle = (d.startAngle + d.endAngle) / 2;
|
| 107 |
+
|
| 108 |
+
// 计算基础文本位置(在内圆和外圆之间)
|
| 109 |
+
const labelRadius = (innerRadius + outerRadius) / 2;
|
| 110 |
+
const x = Math.sin(angle) * labelRadius;
|
| 111 |
+
const y = -Math.cos(angle) * labelRadius;
|
| 112 |
+
|
| 113 |
+
// 计算文本边界框
|
| 114 |
+
const textBBox = {
|
| 115 |
+
x: x - textWidth / 2,
|
| 116 |
+
y: y - textHeight / 2,
|
| 117 |
+
width: textWidth,
|
| 118 |
+
height: textHeight
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
// 计算图像边界框
|
| 122 |
+
const iconBBox = {
|
| 123 |
+
x: iconCentroid[0] - iconWidth / 2,
|
| 124 |
+
y: iconCentroid[1] - iconWidth / 2,
|
| 125 |
+
width: iconWidth,
|
| 126 |
+
height: iconWidth
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
+
// 检查边界框是否重叠
|
| 130 |
+
// 计算重叠程度
|
| 131 |
+
let overlapX = 0;
|
| 132 |
+
let overlapY = 0;
|
| 133 |
+
|
| 134 |
+
// 计算X轴重叠
|
| 135 |
+
if (textBBox.x < iconBBox.x + iconBBox.width && iconBBox.x < textBBox.x + textBBox.width) {
|
| 136 |
+
// 找出重叠部分的左边界和右边界
|
| 137 |
+
const leftOverlap = Math.max(textBBox.x, iconBBox.x);
|
| 138 |
+
const rightOverlap = Math.min(textBBox.x + textBBox.width, iconBBox.x + iconBBox.width);
|
| 139 |
+
overlapX = rightOverlap - leftOverlap;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
// 计算Y轴重叠
|
| 143 |
+
if (textBBox.y < iconBBox.y + iconBBox.height && iconBBox.y < textBBox.y + textBBox.height) {
|
| 144 |
+
// 找出重叠部分的上边界和下边界
|
| 145 |
+
const topOverlap = Math.max(textBBox.y, iconBBox.y);
|
| 146 |
+
const bottomOverlap = Math.min(textBBox.y + textBBox.height, iconBBox.y + iconBBox.height);
|
| 147 |
+
overlapY = bottomOverlap - topOverlap;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
// 只有当X轴和Y轴都有重叠时,才算是真正的重叠
|
| 151 |
+
const overlap = overlapX > 0 && overlapY > 0;
|
| 152 |
+
|
| 153 |
+
if (overlap) {
|
| 154 |
+
// 计算重叠面积占文本面积的比例
|
| 155 |
+
const overlapArea = overlapX * overlapY;
|
| 156 |
+
const textArea = textWidth * textHeight;
|
| 157 |
+
const overlapRatio = overlapArea / textArea;
|
| 158 |
+
|
| 159 |
+
// 确定最小安全距离,基于重叠比例和图像尺寸
|
| 160 |
+
const minSafeDistance = iconWidth/2 + 5; // 最小基础安全距离
|
| 161 |
+
// 根据重叠比例增加安全距离,重叠越大,距离越远
|
| 162 |
+
const additionalDistance = Math.max(30 * overlapRatio, 10);
|
| 163 |
+
const safetyDistance = minSafeDistance + additionalDistance;
|
| 164 |
+
|
| 165 |
+
// 计算当前标签中心到图像中心的距离
|
| 166 |
+
const currentDistance = Math.sqrt(
|
| 167 |
+
Math.pow(x - iconCentroid[0], 2) +
|
| 168 |
+
Math.pow(y - iconCentroid[1], 2)
|
| 169 |
+
);
|
| 170 |
+
|
| 171 |
+
// 如果当前位置与图标重叠,尝试沿圆周寻找合适的位置
|
| 172 |
+
if (currentDistance < safetyDistance) {
|
| 173 |
+
// 在当前角度的基础上,尝试在圆周方向上偏移
|
| 174 |
+
// 尝试不同的偏移角度,寻找最佳位置
|
| 175 |
+
const maxOffset = Math.PI / 4; // 最大偏移±45度
|
| 176 |
+
const steps = 16; // 增加尝试步数
|
| 177 |
+
|
| 178 |
+
let bestPosition = null;
|
| 179 |
+
let bestDistance = -1;
|
| 180 |
+
let bestOverlapRatio = Infinity; // 记录最小重叠比例
|
| 181 |
+
|
| 182 |
+
// 在扇区角度范围内,以步进方式尝试不同位置
|
| 183 |
+
const sectorWidth = d.endAngle - d.startAngle;
|
| 184 |
+
// 使用扇区宽度或最大偏移值中较小的一个作为搜索范围
|
| 185 |
+
const searchRange = Math.max(Math.min(maxOffset, sectorWidth * 0.8), Math.PI / 12);
|
| 186 |
+
const angularStep = searchRange / steps;
|
| 187 |
+
|
| 188 |
+
// 同时尝试不同的半径
|
| 189 |
+
const radiusVariations = [
|
| 190 |
+
labelRadius * 0.8, // 向内收缩
|
| 191 |
+
labelRadius, // 原始半径
|
| 192 |
+
labelRadius * 1.1, // 稍微向外扩展
|
| 193 |
+
labelRadius * 1.2 // 更多向外扩展
|
| 194 |
+
];
|
| 195 |
+
|
| 196 |
+
// 扩大搜索范围
|
| 197 |
+
for (let i = -steps; i <= steps; i++) {
|
| 198 |
+
// 计算偏移角度
|
| 199 |
+
const offsetAngle = angle + i * angularStep;
|
| 200 |
+
|
| 201 |
+
// 对每种半径变化尝试
|
| 202 |
+
for (const testRadius of radiusVariations) {
|
| 203 |
+
// 计算新位置
|
| 204 |
+
const newX = Math.sin(offsetAngle) * testRadius;
|
| 205 |
+
const newY = -Math.cos(offsetAngle) * testRadius;
|
| 206 |
+
|
| 207 |
+
// 计算新文本边界框
|
| 208 |
+
const newTextBBox = {
|
| 209 |
+
x: newX - textWidth / 2,
|
| 210 |
+
y: newY - textHeight / 2,
|
| 211 |
+
width: textWidth,
|
| 212 |
+
height: textHeight
|
| 213 |
+
};
|
| 214 |
+
|
| 215 |
+
// 计算与图标的距离
|
| 216 |
+
const dist = Math.sqrt(
|
| 217 |
+
Math.pow(newX - iconCentroid[0], 2) +
|
| 218 |
+
Math.pow(newY - iconCentroid[1], 2)
|
| 219 |
+
);
|
| 220 |
+
|
| 221 |
+
// 检查新位置是否与图标重叠
|
| 222 |
+
let newOverlapX = 0;
|
| 223 |
+
let newOverlapY = 0;
|
| 224 |
+
|
| 225 |
+
if (newTextBBox.x < iconBBox.x + iconBBox.width && iconBBox.x < newTextBBox.x + newTextBBox.width) {
|
| 226 |
+
const leftOverlap = Math.max(newTextBBox.x, iconBBox.x);
|
| 227 |
+
const rightOverlap = Math.min(newTextBBox.x + newTextBBox.width, iconBBox.x + iconBBox.width);
|
| 228 |
+
newOverlapX = rightOverlap - leftOverlap;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
if (newTextBBox.y < iconBBox.y + iconBBox.height && iconBBox.y < newTextBBox.y + newTextBBox.height) {
|
| 232 |
+
const topOverlap = Math.max(newTextBBox.y, iconBBox.y);
|
| 233 |
+
const bottomOverlap = Math.min(newTextBBox.y + newTextBBox.height, iconBBox.y + iconBBox.height);
|
| 234 |
+
newOverlapY = bottomOverlap - topOverlap;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
const newOverlap = newOverlapX > 0 && newOverlapY > 0;
|
| 238 |
+
|
| 239 |
+
if (!newOverlap) {
|
| 240 |
+
// 如果没有重叠,计算这个位置相对于理想位置的距离偏移
|
| 241 |
+
// 理想位置是沿着原角度的中间半径
|
| 242 |
+
const idealX = Math.sin(angle) * labelRadius;
|
| 243 |
+
const idealY = -Math.cos(angle) * labelRadius;
|
| 244 |
+
const deviationFromIdeal = Math.sqrt(
|
| 245 |
+
Math.pow(newX - idealX, 2) +
|
| 246 |
+
Math.pow(newY - idealY, 2)
|
| 247 |
+
);
|
| 248 |
+
|
| 249 |
+
// 如果是首次找到无重叠位置,或者这个位置比之前找到的更接近理想位置
|
| 250 |
+
if (bestPosition === null || deviationFromIdeal < bestDistance) {
|
| 251 |
+
bestPosition = [newX, newY];
|
| 252 |
+
bestDistance = deviationFromIdeal;
|
| 253 |
+
bestOverlapRatio = 0;
|
| 254 |
+
}
|
| 255 |
+
} else if (bestOverlapRatio > 0) {
|
| 256 |
+
// 如果有重叠但比之前找到的重叠度更小
|
| 257 |
+
const overlapArea = newOverlapX * newOverlapY;
|
| 258 |
+
const textArea = textWidth * textHeight;
|
| 259 |
+
const overlapRatio = overlapArea / textArea;
|
| 260 |
+
|
| 261 |
+
if (overlapRatio < bestOverlapRatio) {
|
| 262 |
+
// 如果重叠程度更小,也许是个好选择
|
| 263 |
+
bestPosition = [newX, newY];
|
| 264 |
+
bestDistance = dist;
|
| 265 |
+
bestOverlapRatio = overlapRatio;
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
// 如果找到了更好的位置,返回它
|
| 272 |
+
if (bestPosition !== null) {
|
| 273 |
+
return bestPosition;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
// 如果没有找到合适的圆周位置,则尝试调整半径
|
| 277 |
+
// 根据角度所在象限决定向内还是向外移动
|
| 278 |
+
const extraDistance = safetyDistance - currentDistance;
|
| 279 |
+
let adjustedRadius;
|
| 280 |
+
|
| 281 |
+
if (angle >= 0 && angle < Math.PI) {
|
| 282 |
+
// 右半边,向外移动
|
| 283 |
+
adjustedRadius = labelRadius + extraDistance + 10; // 额外10像素作为缓冲
|
| 284 |
+
} else {
|
| 285 |
+
// 左半边,向内移动(如果内径足够大)或向外移动(如果内径太小)
|
| 286 |
+
if (innerRadius > textWidth) {
|
| 287 |
+
adjustedRadius = Math.max(innerRadius * 0.6, labelRadius - extraDistance - 10);
|
| 288 |
+
} else {
|
| 289 |
+
adjustedRadius = labelRadius + extraDistance + 10;
|
| 290 |
+
}
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
return [
|
| 294 |
+
Math.sin(angle) * adjustedRadius,
|
| 295 |
+
-Math.cos(angle) * adjustedRadius
|
| 296 |
+
];
|
| 297 |
+
}
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
return [x, y];
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
// 估算文本宽度和高度的函数
|
| 304 |
+
function estimateTextDimensions(text, fontSize) {
|
| 305 |
+
return chartUtils.text.estimate(text, { fontSize });
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
// 计算扇区最大可用宽度
|
| 309 |
+
function calculateSectorMaxWidth(d, radius) {
|
| 310 |
+
// 计算扇区角度
|
| 311 |
+
const sectorAngle = d.endAngle - d.startAngle;
|
| 312 |
+
// 在弧的中间位置计算最大宽度
|
| 313 |
+
// 弧长 = 角度 * 半径,取80%作为安全尺寸
|
| 314 |
+
return sectorAngle * radius * 0.8;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
// 截断或调整文本以适应最大宽度
|
| 318 |
+
function fitTextToWidth(text, fontSize, maxWidth) {
|
| 319 |
+
const dimensions = estimateTextDimensions(text, fontSize);
|
| 320 |
+
if (dimensions.width <= maxWidth) {
|
| 321 |
+
return { text, fontSize };
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
// 如果文本太长,先尝试缩小字体
|
| 325 |
+
const minFontSize = 10; // 最小可接受的字体大小
|
| 326 |
+
if (fontSize > minFontSize) {
|
| 327 |
+
const newFontSize = Math.max(minFontSize, fontSize * (maxWidth / dimensions.width));
|
| 328 |
+
const newDimensions = estimateTextDimensions(text, newFontSize);
|
| 329 |
+
if (newDimensions.width <= maxWidth) {
|
| 330 |
+
return { text, fontSize: newFontSize };
|
| 331 |
+
}
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
// 如果缩小字体后仍然超出,则截断文本
|
| 335 |
+
const charWidth = fontSize * 0.6;
|
| 336 |
+
const maxChars = Math.floor(maxWidth / charWidth) - 2; // 减2留出省略号空间
|
| 337 |
+
if (maxChars < 3) { // 如果连3个字符都放不下
|
| 338 |
+
return { text: "...", fontSize };
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
return { text: text.substring(0, maxChars) + "...", fontSize };
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
for (let i = 0; i < sectors.length; i++) {
|
| 345 |
+
const d = sectors[i];
|
| 346 |
+
// 绘制甜甜圈图的各个部分
|
| 347 |
+
const path = g.append("path")
|
| 348 |
+
.attr("fill", colorResolver.field(d.data[xField], 0, { fallbackKey: "primary" }).value)
|
| 349 |
+
.attr("stroke", "none")
|
| 350 |
+
.attr("stroke-width", 2)
|
| 351 |
+
.attr("d", arc(d));
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
const outerLength = (d.endAngle - d.startAngle) * maxRadius;
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
let iconWidth = Math.min(outerLength / 3, 150);
|
| 358 |
+
let iconHeight = iconWidth;
|
| 359 |
+
if (iconWidth > 20) {
|
| 360 |
+
const iconArc = d3.arc()
|
| 361 |
+
.innerRadius(maxRadius)
|
| 362 |
+
.outerRadius(maxRadius);
|
| 363 |
+
|
| 364 |
+
// 创建剪切路径
|
| 365 |
+
const clipId = `clip-${i}`;
|
| 366 |
+
const defs = g.append("defs");
|
| 367 |
+
const clipPath = defs.append("clipPath")
|
| 368 |
+
.attr("id", clipId);
|
| 369 |
+
|
| 370 |
+
// 确保剪切路径有正确的位置和尺寸
|
| 371 |
+
const [cx, cy] = iconArc.centroid(d);
|
| 372 |
+
clipPath.append("circle")
|
| 373 |
+
.attr("cx", cx)
|
| 374 |
+
.attr("cy", cy)
|
| 375 |
+
.attr("r", iconWidth / 2);
|
| 376 |
+
|
| 377 |
+
// 添加白色背景圆
|
| 378 |
+
const circle = g.append("circle")
|
| 379 |
+
.attr("cx", cx)
|
| 380 |
+
.attr("cy", cy)
|
| 381 |
+
.attr("r", iconWidth / 2 + 3)
|
| 382 |
+
.attr("fill", "white")
|
| 383 |
+
.attr("stroke", colorResolver.field(d.data[xField], 0, { fallbackKey: "primary" }).value)
|
| 384 |
+
.attr("stroke-width", 2);
|
| 385 |
+
|
| 386 |
+
// 使用剪切路径裁剪图片
|
| 387 |
+
const icon = g.append("image")
|
| 388 |
+
.attr("xlink:href", jsonData.images.field[d.data[xField]])
|
| 389 |
+
.attr("clip-path", `url(#${clipId})`)
|
| 390 |
+
.attr("x", cx - iconWidth / 2)
|
| 391 |
+
.attr("y", cy - iconHeight / 2)
|
| 392 |
+
.attr("width", iconWidth)
|
| 393 |
+
.attr("height", iconHeight);
|
| 394 |
+
|
| 395 |
+
// 存储图像位置信息
|
| 396 |
+
imagePositions.push({
|
| 397 |
+
centroid: [cx, cy],
|
| 398 |
+
width: iconWidth,
|
| 399 |
+
height: iconHeight
|
| 400 |
+
});
|
| 401 |
+
|
| 402 |
+
let displayTextCategory = d.data[xField];
|
| 403 |
+
let displayTextNumerical = d.data.percentage >= 2 ? `${chartUtils.format.percent(d.data.percentage).text} (${chartUtils.format.autoText(+d.data[yField])})` : '';
|
| 404 |
+
let categoryFontSize = 20;
|
| 405 |
+
let numericalFontSize = 20;
|
| 406 |
+
|
| 407 |
+
// 计算扇区的最大可用宽度
|
| 408 |
+
const maxAvailableWidth = calculateSectorMaxWidth(d, (maxRadius + maxRadius*0.5) / 2);
|
| 409 |
+
|
| 410 |
+
// 调整类别文本和字体大小以适应扇区
|
| 411 |
+
const fittedCategory = fitTextToWidth(displayTextCategory, categoryFontSize, maxAvailableWidth);
|
| 412 |
+
displayTextCategory = fittedCategory.text;
|
| 413 |
+
categoryFontSize = fittedCategory.fontSize;
|
| 414 |
+
|
| 415 |
+
// 调整数值文本和字体大小
|
| 416 |
+
const fittedNumerical = fitTextToWidth(displayTextNumerical, numericalFontSize, maxAvailableWidth);
|
| 417 |
+
displayTextNumerical = fittedNumerical.text;
|
| 418 |
+
numericalFontSize = fittedNumerical.fontSize;
|
| 419 |
+
|
| 420 |
+
// 估算文本尺寸
|
| 421 |
+
const categoryDimensions = estimateTextDimensions(displayTextCategory, categoryFontSize);
|
| 422 |
+
const numericalDimensions = estimateTextDimensions(displayTextNumerical, numericalFontSize);
|
| 423 |
+
// 计算标签总高度(包括两行文本和间距)
|
| 424 |
+
const labelHeight = categoryDimensions.height + numericalDimensions.height + 5;
|
| 425 |
+
// 取两行文本中较宽的一个作为标签宽度
|
| 426 |
+
const labelWidth = Math.max(categoryDimensions.width, numericalDimensions.width);
|
| 427 |
+
|
| 428 |
+
// 对于大图标,调整标签区域以避免重叠
|
| 429 |
+
const labelInnerRadius = maxRadius * 0.4; // 稍微向内收缩内半径
|
| 430 |
+
const labelOuterRadius = maxRadius * 1.1; // 稍微向外扩展外半径
|
| 431 |
+
|
| 432 |
+
// 计算避免与图像重叠的标签位置
|
| 433 |
+
const labelPosition = calculateLabelPosition(
|
| 434 |
+
d,
|
| 435 |
+
[cx, cy],
|
| 436 |
+
iconWidth,
|
| 437 |
+
labelInnerRadius,
|
| 438 |
+
labelOuterRadius,
|
| 439 |
+
labelWidth,
|
| 440 |
+
labelHeight
|
| 441 |
+
);
|
| 442 |
+
|
| 443 |
+
const textCategory = g.append("text")
|
| 444 |
+
.attr("transform", `translate(${labelPosition})`)
|
| 445 |
+
.attr("text-anchor", "middle")
|
| 446 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 447 |
+
.style("font-family", typography.label.font_family)
|
| 448 |
+
.style("font-size", categoryFontSize)
|
| 449 |
+
.text(displayTextCategory);
|
| 450 |
+
|
| 451 |
+
const textNumerical = g.append("text")
|
| 452 |
+
.attr("transform", `translate(0,20) translate(${labelPosition})`)
|
| 453 |
+
.attr("text-anchor", "middle")
|
| 454 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 455 |
+
.style("font-family", typography.label.font_family)
|
| 456 |
+
.style("font-size", numericalFontSize)
|
| 457 |
+
.text(displayTextNumerical);
|
| 458 |
+
} else {
|
| 459 |
+
iconWidth = 20;
|
| 460 |
+
iconHeight = 20;
|
| 461 |
+
const iconArc = d3.arc()
|
| 462 |
+
.innerRadius(maxRadius+30)
|
| 463 |
+
.outerRadius(maxRadius+30);
|
| 464 |
+
|
| 465 |
+
// 创建剪切路径
|
| 466 |
+
const clipId = `clip-${i}`;
|
| 467 |
+
const defs = g.append("defs");
|
| 468 |
+
const clipPath = defs.append("clipPath")
|
| 469 |
+
.attr("id", clipId);
|
| 470 |
+
|
| 471 |
+
// 确保剪切路径有正确的位置和尺寸
|
| 472 |
+
const [cx, cy] = iconArc.centroid(d);
|
| 473 |
+
clipPath.append("circle")
|
| 474 |
+
.attr("cx", cx)
|
| 475 |
+
.attr("cy", cy)
|
| 476 |
+
.attr("r", iconWidth / 2);
|
| 477 |
+
|
| 478 |
+
// 添加白色背景圆
|
| 479 |
+
const circle = g.append("circle")
|
| 480 |
+
.attr("cx", cx)
|
| 481 |
+
.attr("cy", cy)
|
| 482 |
+
.attr("r", iconWidth / 2 + 3)
|
| 483 |
+
.attr("fill", "white")
|
| 484 |
+
.attr("stroke", colorResolver.field(d.data[xField], 0, { fallbackKey: "primary" }).value)
|
| 485 |
+
.attr("stroke-width", 2);
|
| 486 |
+
|
| 487 |
+
// 使用剪切路径裁剪图片
|
| 488 |
+
const icon = g.append("image")
|
| 489 |
+
.attr("xlink:href", jsonData.images.field[d.data[xField]])
|
| 490 |
+
.attr("clip-path", `url(#${clipId})`)
|
| 491 |
+
.attr("x", cx - iconWidth / 2)
|
| 492 |
+
.attr("y", cy - iconHeight / 2)
|
| 493 |
+
.attr("width", iconWidth)
|
| 494 |
+
.attr("height", iconHeight);
|
| 495 |
+
|
| 496 |
+
// 存储图像位置信息
|
| 497 |
+
imagePositions.push({
|
| 498 |
+
centroid: [cx, cy],
|
| 499 |
+
width: iconWidth,
|
| 500 |
+
height: iconHeight
|
| 501 |
+
});
|
| 502 |
+
|
| 503 |
+
let displayTextCategory = d.data[xField];
|
| 504 |
+
let displayTextNumerical = `${chartUtils.format.percent(d.data.percentage).text} (${chartUtils.format.autoText(+d.data[yField])})`;
|
| 505 |
+
let categoryFontSize = 20;
|
| 506 |
+
let numericalFontSize = 20;
|
| 507 |
+
|
| 508 |
+
// 计算扇区的最大可用宽度
|
| 509 |
+
const maxAvailableWidth = calculateSectorMaxWidth(d, maxRadius + 30);
|
| 510 |
+
|
| 511 |
+
// 调整类别文本和字体大小以适应扇区
|
| 512 |
+
const fittedCategory = fitTextToWidth(displayTextCategory, categoryFontSize, maxAvailableWidth);
|
| 513 |
+
displayTextCategory = fittedCategory.text;
|
| 514 |
+
categoryFontSize = fittedCategory.fontSize;
|
| 515 |
+
|
| 516 |
+
// 调整数值文本和字体大小
|
| 517 |
+
const fittedNumerical = fitTextToWidth(displayTextNumerical, numericalFontSize, maxAvailableWidth);
|
| 518 |
+
displayTextNumerical = fittedNumerical.text;
|
| 519 |
+
numericalFontSize = fittedNumerical.fontSize;
|
| 520 |
+
|
| 521 |
+
// 估算文本尺寸
|
| 522 |
+
const categoryDimensions = estimateTextDimensions(displayTextCategory, categoryFontSize);
|
| 523 |
+
const numericalDimensions = estimateTextDimensions(displayTextNumerical, numericalFontSize);
|
| 524 |
+
// 计算标签总高度(包括两行文本和间距)
|
| 525 |
+
const labelHeight = categoryDimensions.height + numericalDimensions.height + 5;
|
| 526 |
+
// 取两行文本中较宽的一个作为标签宽度
|
| 527 |
+
const labelWidth = Math.max(categoryDimensions.width, numericalDimensions.width);
|
| 528 |
+
|
| 529 |
+
// 对于小图标情况,使用更大的内外半径差,给标签更多空间
|
| 530 |
+
const labelInnerRadius = maxRadius + 10; // 向外偏移一点
|
| 531 |
+
const labelOuterRadius = maxRadius + 70; // 更大的外半径给更多空间
|
| 532 |
+
|
| 533 |
+
// 计算不重叠的标签位置
|
| 534 |
+
const labelPosition = calculateLabelPosition(
|
| 535 |
+
d,
|
| 536 |
+
[cx, cy],
|
| 537 |
+
iconWidth,
|
| 538 |
+
labelInnerRadius,
|
| 539 |
+
labelOuterRadius,
|
| 540 |
+
labelWidth,
|
| 541 |
+
labelHeight
|
| 542 |
+
);
|
| 543 |
+
|
| 544 |
+
const fillColor = colorResolver.field(d.data[xField], 0, { fallbackKey: "primary" }).value;
|
| 545 |
+
|
| 546 |
+
const textCategory = g.append("text")
|
| 547 |
+
.attr("transform", `translate(${labelPosition})`)
|
| 548 |
+
.attr("text-anchor", "middle")
|
| 549 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 550 |
+
.style("font-family", typography.label.font_family)
|
| 551 |
+
.style("font-size", categoryFontSize)
|
| 552 |
+
.text(displayTextCategory);
|
| 553 |
+
|
| 554 |
+
const textNumerical = g.append("text")
|
| 555 |
+
.attr("transform", `translate(0,20) translate(${labelPosition})`)
|
| 556 |
+
.attr("text-anchor", "middle")
|
| 557 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 558 |
+
.style("font-family", typography.label.font_family)
|
| 559 |
+
.style("font-size", numericalFontSize)
|
| 560 |
+
.text(displayTextNumerical);
|
| 561 |
+
}
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
// 加入label
|
| 565 |
+
const label = g.append("text")
|
| 566 |
+
.attr("transform", `translate(${centerX}, ${centerY})`)
|
| 567 |
+
.attr("text-anchor", "middle")
|
| 568 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 569 |
+
.style("font-family", typography.label.font_family)
|
| 570 |
+
.style("font-size", typography.label.font_size)
|
| 571 |
+
.text(jsonData.title);
|
| 572 |
+
|
| 573 |
+
// 添加图例 - 放在图表上方
|
| 574 |
+
const legendGroup = svg.append("g")
|
| 575 |
+
.attr("transform", `translate(0, -85)`);
|
| 576 |
+
|
| 577 |
+
// 计算字段名宽度并添加间距
|
| 578 |
+
const titleWidth = xField.length * 10;
|
| 579 |
+
const titleMargin = 15;
|
| 580 |
+
|
| 581 |
+
let xs = [...new Set(chartData.map(d => d[xField]))];
|
| 582 |
+
|
| 583 |
+
const legendSize = chartUtils.legend.draw(legendGroup, xs, colors, {
|
| 584 |
+
x: titleWidth + titleMargin,
|
| 585 |
+
y: 0,
|
| 586 |
+
fontSize: 14,
|
| 587 |
+
fontWeight: "bold",
|
| 588 |
+
align: "left",
|
| 589 |
+
maxWidth: chartWidth - titleWidth - titleMargin,
|
| 590 |
+
shape: "rect",
|
| 591 |
+
});
|
| 592 |
+
|
| 593 |
+
// 添加字段名称
|
| 594 |
+
legendGroup.append("text")
|
| 595 |
+
.attr("x", 0)
|
| 596 |
+
.attr("y", legendSize.height / 2)
|
| 597 |
+
.attr("dominant-baseline", "middle")
|
| 598 |
+
.attr("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 599 |
+
.style("font-size", "16px")
|
| 600 |
+
.style("font-weight", "bold")
|
| 601 |
+
.text(xField);
|
| 602 |
+
|
| 603 |
+
// 将图例组向上移动 height/2, 并居中
|
| 604 |
+
legendGroup.attr("transform", `translate(${(chartWidth - legendSize.width - titleWidth - titleMargin) / 2}, ${-legendSize.height / 2 - 20})`);
|
| 605 |
+
|
| 606 |
+
return svg.node();
|
| 607 |
+
}
|
modules/chart_engine/template/d3-js/type49_donut_chart/donut_chart_icons_02.js
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Donut Chart",
|
| 5 |
+
"chart_name": "donut_chart_icons_02",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y"],
|
| 8 |
+
"required_fields_type": [["categorical"], ["numerical"]],
|
| 9 |
+
"required_fields_range": [[2, 20], [0, "inf"]],
|
| 10 |
+
"required_fields_icons": ["x"],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": ["x"],
|
| 13 |
+
"required_other_colors": [],
|
| 14 |
+
"supported_effects": ["shadow", "radius_corner", "stroke"],
|
| 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 dataColumns = chartUtils.schema.columns(jsonData);
|
| 35 |
+
|
| 36 |
+
const typography = jsonData.typography || {
|
| 37 |
+
title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
|
| 38 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 39 |
+
description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 40 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 41 |
+
};
|
| 42 |
+
let colors = jsonData.colors || {
|
| 43 |
+
text_color: "#333333",
|
| 44 |
+
field: {},
|
| 45 |
+
other: {
|
| 46 |
+
primary: "#4682B4" // 默认主色调
|
| 47 |
+
}
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
// 提取字段名称
|
| 51 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 52 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 53 |
+
// Set dimensions and margins
|
| 54 |
+
const width = variables.width;
|
| 55 |
+
const height = variables.height;
|
| 56 |
+
const margin = { top: 40, right: 40, bottom: 40, left: 40 };
|
| 57 |
+
const chartWidth = width - margin.left - margin.right;
|
| 58 |
+
const chartHeight = height - margin.top - margin.bottom;
|
| 59 |
+
|
| 60 |
+
// Create SVG
|
| 61 |
+
const svg = d3.select(containerSelector)
|
| 62 |
+
.append("svg")
|
| 63 |
+
.attr("width", "100%")
|
| 64 |
+
.attr("height", height)
|
| 65 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 66 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 67 |
+
.attr("xmlns", "http://www.w3.org/2000/svg");
|
| 68 |
+
|
| 69 |
+
// Calculate center point and max radius
|
| 70 |
+
const centerX = width / 2;
|
| 71 |
+
const centerY = height / 2 + 100;
|
| 72 |
+
const maxRadius = Math.min(chartWidth, chartHeight) / 2;
|
| 73 |
+
|
| 74 |
+
// Create a root group
|
| 75 |
+
const g = svg.append("g")
|
| 76 |
+
.attr("transform", `translate(${centerX}, ${centerY})`)
|
| 77 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 78 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 79 |
+
|
| 80 |
+
// 创建饼图生成器
|
| 81 |
+
const pie = d3.pie()
|
| 82 |
+
.value(d => +d[yField])
|
| 83 |
+
.sort(null)
|
| 84 |
+
.startAngle(0) // 设置起始角度为-90度
|
| 85 |
+
.endAngle(2*Math.PI) // 设置结束角度为90度
|
| 86 |
+
|
| 87 |
+
// 创建弧形生成器
|
| 88 |
+
const arc = d3.arc()
|
| 89 |
+
.innerRadius(maxRadius*0.5)
|
| 90 |
+
.outerRadius(maxRadius)
|
| 91 |
+
.padAngle(0)
|
| 92 |
+
.cornerRadius(5);
|
| 93 |
+
|
| 94 |
+
// 计算每个组的百分比
|
| 95 |
+
const total = d3.sum(chartData, d => +d[yField]);
|
| 96 |
+
const dataWithPercentages = chartData.map(d => ({
|
| 97 |
+
...d,
|
| 98 |
+
percentage: (+d[yField] / total) * 100
|
| 99 |
+
}));
|
| 100 |
+
const sectors = pie(dataWithPercentages);
|
| 101 |
+
|
| 102 |
+
// 存储图像位置信息,用于后续避免标签重叠
|
| 103 |
+
const imagePositions = [];
|
| 104 |
+
|
| 105 |
+
// 计算避免标签与图像重叠的位置
|
| 106 |
+
function calculateLabelPosition(d, iconCentroid, iconWidth, innerRadius, outerRadius, textWidth, textHeight, isLargeIcon) {
|
| 107 |
+
// 计算扇区中心角度
|
| 108 |
+
const angle = (d.startAngle + d.endAngle) / 2;
|
| 109 |
+
|
| 110 |
+
// 计算图标半径,添加额外边距
|
| 111 |
+
const iconRadius = iconWidth / 2 + 5;
|
| 112 |
+
|
| 113 |
+
// 增加安全距离,确保标签和图标不重叠
|
| 114 |
+
const safetyDistance = isLargeIcon ?
|
| 115 |
+
iconRadius + Math.max(textWidth, textHeight) / 1.5 :
|
| 116 |
+
iconRadius * 2;
|
| 117 |
+
|
| 118 |
+
// 根据扇区角度调整标签位置
|
| 119 |
+
let labelRadius;
|
| 120 |
+
|
| 121 |
+
// 根据扇区的角度位置采用不同的放置策略
|
| 122 |
+
if (angle >= 0 && angle <= Math.PI / 4) {
|
| 123 |
+
// 右上角,标签放在外圈右侧
|
| 124 |
+
labelRadius = outerRadius + safetyDistance / 2.5;
|
| 125 |
+
} else if (angle > Math.PI / 4 && angle <= 3 * Math.PI / 4) {
|
| 126 |
+
// 上半部,标签放在较远的位置
|
| 127 |
+
labelRadius = outerRadius + safetyDistance / 2;
|
| 128 |
+
} else if (angle > 3 * Math.PI / 4 && angle <= 5 * Math.PI / 4) {
|
| 129 |
+
// 左半部,标签靠近内圈
|
| 130 |
+
labelRadius = Math.max(innerRadius - safetyDistance / 1.8, innerRadius * 0.4);
|
| 131 |
+
} else if (angle > 5 * Math.PI / 4 && angle <= 7 * Math.PI / 4) {
|
| 132 |
+
// 下半部,标签放在较远的位置
|
| 133 |
+
labelRadius = outerRadius + safetyDistance / 2;
|
| 134 |
+
} else {
|
| 135 |
+
// 右下角,标签放在外圈右侧
|
| 136 |
+
labelRadius = outerRadius + safetyDistance / 2.5;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
// 基于角度和半径计算最终位置
|
| 140 |
+
const x = Math.sin(angle) * labelRadius;
|
| 141 |
+
const y = -Math.cos(angle) * labelRadius;
|
| 142 |
+
|
| 143 |
+
// 额外检查:检测是否与图标有重叠
|
| 144 |
+
const distance = Math.sqrt(
|
| 145 |
+
Math.pow(x - iconCentroid[0], 2) +
|
| 146 |
+
Math.pow(y - iconCentroid[1], 2)
|
| 147 |
+
);
|
| 148 |
+
|
| 149 |
+
// 如果距离太近,再增加一些距离
|
| 150 |
+
if (distance < safetyDistance) {
|
| 151 |
+
const extraFactor = 2.0; // 增加额外的安全系数
|
| 152 |
+
const extraDistance = (safetyDistance - distance) * extraFactor;
|
| 153 |
+
|
| 154 |
+
// 根据角度决定向哪个方向移动更多
|
| 155 |
+
if ((angle >= 0 && angle <= Math.PI / 4) || (angle > 7 * Math.PI / 4 && angle <= 2 * Math.PI)) {
|
| 156 |
+
// 右侧区域,向外移动
|
| 157 |
+
labelRadius += extraDistance;
|
| 158 |
+
} else if (angle > Math.PI / 4 && angle <= 3 * Math.PI / 4) {
|
| 159 |
+
// 上方区域,向上移动
|
| 160 |
+
labelRadius += extraDistance;
|
| 161 |
+
} else if (angle > 3 * Math.PI / 4 && angle <= 5 * Math.PI / 4) {
|
| 162 |
+
// 左侧区域,优先向内移动
|
| 163 |
+
if (innerRadius > textWidth * 1.2) {
|
| 164 |
+
labelRadius = Math.max(innerRadius * 0.35, labelRadius - extraDistance);
|
| 165 |
+
} else {
|
| 166 |
+
labelRadius += extraDistance;
|
| 167 |
+
}
|
| 168 |
+
} else {
|
| 169 |
+
// 下方区域,向下移动
|
| 170 |
+
labelRadius += extraDistance;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
// 重新计算位置
|
| 174 |
+
return [
|
| 175 |
+
Math.sin(angle) * labelRadius,
|
| 176 |
+
-Math.cos(angle) * labelRadius
|
| 177 |
+
];
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
return [x, y];
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
// 估算文本宽度和高度的函数
|
| 184 |
+
function estimateTextDimensions(text, fontSize) {
|
| 185 |
+
return chartUtils.text.estimate(text, { fontSize });
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
// 计算扇区最大可用宽度
|
| 189 |
+
function calculateSectorMaxWidth(d, radius) {
|
| 190 |
+
// 计算扇区角度
|
| 191 |
+
const sectorAngle = d.endAngle - d.startAngle;
|
| 192 |
+
// 在弧的中间位置计算最大宽度
|
| 193 |
+
// 弧长 = 角度 * 半径,取80%作为安全尺寸
|
| 194 |
+
return sectorAngle * radius * 0.8;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
// 调整文本以适应最大宽度 - 只缩小字体,不截断文本
|
| 198 |
+
function fitTextToWidth(text, fontSize, maxWidth) {
|
| 199 |
+
const dimensions = estimateTextDimensions(text, fontSize);
|
| 200 |
+
if (dimensions.width <= maxWidth) {
|
| 201 |
+
return { text, fontSize };
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
// 如果文本太长,尝试缩小字体
|
| 205 |
+
const minFontSize = 8; // 最小可接受的字体大小
|
| 206 |
+
if (fontSize > minFontSize) {
|
| 207 |
+
const newFontSize = Math.max(minFontSize, fontSize * (maxWidth / dimensions.width));
|
| 208 |
+
return { text, fontSize: newFontSize };
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
return { text, fontSize: minFontSize };
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
// 计算标签位置 - 确保标签始终在外部,不会出现在饼图内部
|
| 215 |
+
function calculateOuterLabelPosition(d, outerRadius) {
|
| 216 |
+
// 计算扇区中心角度
|
| 217 |
+
const angle = (d.startAngle + d.endAngle) / 2;
|
| 218 |
+
|
| 219 |
+
// 确保标签始终在外圈,添加更大的边距确保不会出现在内部
|
| 220 |
+
// 增加倍数从1.2到1.5,确保标签彻底在外部
|
| 221 |
+
const labelRadius = outerRadius * 1.5;
|
| 222 |
+
|
| 223 |
+
// 基于角度和半径计算最终位置
|
| 224 |
+
return [
|
| 225 |
+
Math.sin(angle) * labelRadius,
|
| 226 |
+
-Math.cos(angle) * labelRadius
|
| 227 |
+
];
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
// 检查扇区是否有足够空间放置图标
|
| 231 |
+
function hasEnoughSpaceForIcon(d, innerRadius) {
|
| 232 |
+
// 计算扇区的内圆弧长度
|
| 233 |
+
const sectorAngle = d.endAngle - d.startAngle;
|
| 234 |
+
const arcLength = sectorAngle * innerRadius;
|
| 235 |
+
|
| 236 |
+
// 严格判断:扇区必须足够大才显示图标
|
| 237 |
+
// 最小要求提高到30像素,确保图标有足够空间
|
| 238 |
+
return arcLength >= 30;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
for (let i = 0; i < sectors.length; i++) {
|
| 242 |
+
const d = sectors[i];
|
| 243 |
+
// 绘制甜甜圈图的各个部分
|
| 244 |
+
const path = g.append("path")
|
| 245 |
+
.attr("fill", colorResolver.field(d.data[xField], 0, { fallbackKey: "primary" }).value)
|
| 246 |
+
.attr("stroke", "#FFFFFF")
|
| 247 |
+
.attr("stroke-width", 2)
|
| 248 |
+
.attr("d", arc(d));
|
| 249 |
+
|
| 250 |
+
// 检查扇区是否有足够空间放置图标
|
| 251 |
+
const innerRadius = maxRadius * 0.75;
|
| 252 |
+
const hasSpace = hasEnoughSpaceForIcon(d, innerRadius);
|
| 253 |
+
|
| 254 |
+
// 只有当扇区空间足够大时才显示图标
|
| 255 |
+
if (hasSpace) {
|
| 256 |
+
// 计算扇区的内圆弧长度
|
| 257 |
+
const sectorAngle = d.endAngle - d.startAngle;
|
| 258 |
+
const arcLength = sectorAngle * innerRadius;
|
| 259 |
+
|
| 260 |
+
// 根据弧长计算图标大小,保持最小和最大限制
|
| 261 |
+
const minIconSize = 16; // 最小图标尺寸
|
| 262 |
+
const maxIconSize = 50; // 最大图标尺寸
|
| 263 |
+
const baseIconSize = Math.min(maxIconSize, Math.max(minIconSize, arcLength * 0.5));
|
| 264 |
+
|
| 265 |
+
// 设置图标尺寸
|
| 266 |
+
const iconWidth = baseIconSize;
|
| 267 |
+
const iconHeight = baseIconSize;
|
| 268 |
+
|
| 269 |
+
// 计算图标位置 - 确保图标在内圆弧上居中
|
| 270 |
+
const angle = (d.startAngle + d.endAngle) / 2;
|
| 271 |
+
const iconRadius = innerRadius; // 确保图标位于内圆弧之上
|
| 272 |
+
const cx = Math.sin(angle) * iconRadius;
|
| 273 |
+
const cy = -Math.cos(angle) * iconRadius;
|
| 274 |
+
|
| 275 |
+
// 创建剪切路径
|
| 276 |
+
const clipId = `clip-${i}`;
|
| 277 |
+
const defs = g.append("defs");
|
| 278 |
+
const clipPath = defs.append("clipPath")
|
| 279 |
+
.attr("id", clipId);
|
| 280 |
+
|
| 281 |
+
clipPath.append("circle")
|
| 282 |
+
.attr("cx", cx)
|
| 283 |
+
.attr("cy", cy)
|
| 284 |
+
.attr("r", iconWidth / 2);
|
| 285 |
+
|
| 286 |
+
// 添加白色背景圆
|
| 287 |
+
g.append("circle")
|
| 288 |
+
.attr("cx", cx)
|
| 289 |
+
.attr("cy", cy)
|
| 290 |
+
.attr("r", iconWidth / 2 + 3)
|
| 291 |
+
.attr("fill", "white")
|
| 292 |
+
.attr("stroke", colorResolver.field(d.data[xField], 0, { fallbackKey: "primary" }).value)
|
| 293 |
+
.attr("stroke-width", 2);
|
| 294 |
+
|
| 295 |
+
// 使用剪切路径裁剪图片
|
| 296 |
+
g.append("image")
|
| 297 |
+
.attr("xlink:href", jsonData.images.field[d.data[xField]])
|
| 298 |
+
.attr("clip-path", `url(#${clipId})`)
|
| 299 |
+
.attr("x", cx - iconWidth / 2)
|
| 300 |
+
.attr("y", cy - iconHeight / 2)
|
| 301 |
+
.attr("width", iconWidth)
|
| 302 |
+
.attr("height", iconHeight);
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
// 标签处理 - 所有标签都放在外部
|
| 306 |
+
// 准备显示的文本
|
| 307 |
+
let displayTextCategory = d.data[xField];
|
| 308 |
+
let displayTextValue = `${chartUtils.format.percent(d.data.percentage).text} (${chartUtils.format.autoText(+d.data[yField])})`;
|
| 309 |
+
|
| 310 |
+
// 设置初始字体大小
|
| 311 |
+
let categoryFontSize = 14;
|
| 312 |
+
let valueFontSize = 12;
|
| 313 |
+
|
| 314 |
+
// 计算标签位置 - 确保标签在外部
|
| 315 |
+
const labelPosition = calculateOuterLabelPosition(d, maxRadius);
|
| 316 |
+
|
| 317 |
+
// 计算最大可用宽度 - 外部标签可以有更多空间
|
| 318 |
+
const outerSpaceWidth = width * 0.4; // 增加到宽度的40%
|
| 319 |
+
|
| 320 |
+
// 调整文本字体大小以确保文本完整显示
|
| 321 |
+
const fittedCategory = fitTextToWidth(displayTextCategory, categoryFontSize, outerSpaceWidth);
|
| 322 |
+
const fittedValue = fitTextToWidth(displayTextValue, valueFontSize, outerSpaceWidth);
|
| 323 |
+
|
| 324 |
+
// 设置文本颜色
|
| 325 |
+
const fillColor = colorResolver.field(d.data[xField], 0, { fallbackKey: "primary" }).value;
|
| 326 |
+
// 计算文本颜色 - 使用黑色作为默认颜色,确保可读性
|
| 327 |
+
const textColor = "#000000";
|
| 328 |
+
|
| 329 |
+
// 添加标签 - 类别名称
|
| 330 |
+
g.append("text")
|
| 331 |
+
.attr("transform", `translate(${labelPosition})`)
|
| 332 |
+
.attr("text-anchor", "middle")
|
| 333 |
+
.style("fill", textColor)
|
| 334 |
+
.style("font-family", typography.label.font_family)
|
| 335 |
+
.style("font-size", `${fittedCategory.fontSize}px`)
|
| 336 |
+
.style("font-weight", "bold")
|
| 337 |
+
.text(fittedCategory.text);
|
| 338 |
+
|
| 339 |
+
// 添加标签 - 百分比和原始值
|
| 340 |
+
g.append("text")
|
| 341 |
+
.attr("transform", `translate(${labelPosition[0]}, ${labelPosition[1] + fittedCategory.fontSize + 4})`)
|
| 342 |
+
.attr("text-anchor", "middle")
|
| 343 |
+
.style("fill", textColor)
|
| 344 |
+
.style("font-family", typography.label.font_family)
|
| 345 |
+
.style("font-size", `${fittedValue.fontSize}px`)
|
| 346 |
+
.text(fittedValue.text);
|
| 347 |
+
|
| 348 |
+
// 添加辅助线连接扇区和标签
|
| 349 |
+
const arcCentroid = arc.centroid(d);
|
| 350 |
+
|
| 351 |
+
// 连接线起点位于扇区中心位置
|
| 352 |
+
const lineStartX = arcCentroid[0];
|
| 353 |
+
const lineStartY = arcCentroid[1];
|
| 354 |
+
|
| 355 |
+
// 连接线终点位于标签起始位置的90%处,避免直接连到文字
|
| 356 |
+
const lineEndX = labelPosition[0] * 0.9;
|
| 357 |
+
const lineEndY = labelPosition[1] * 0.9;
|
| 358 |
+
|
| 359 |
+
g.append("line")
|
| 360 |
+
.attr("x1", lineStartX)
|
| 361 |
+
.attr("y1", lineStartY)
|
| 362 |
+
.attr("x2", lineEndX)
|
| 363 |
+
.attr("y2", lineEndY)
|
| 364 |
+
.attr("stroke", "#888888")
|
| 365 |
+
.attr("stroke-width", 1)
|
| 366 |
+
.attr("stroke-dasharray", "3,3");
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
// 加入label
|
| 370 |
+
const label = g.append("text")
|
| 371 |
+
.attr("transform", `translate(${centerX}, ${centerY})`)
|
| 372 |
+
.attr("text-anchor", "middle")
|
| 373 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 374 |
+
.style("font-family", typography.label.font_family)
|
| 375 |
+
.style("font-size", typography.label.font_size)
|
| 376 |
+
.text(jsonData.title);
|
| 377 |
+
|
| 378 |
+
// 添加图例 - 放在图表上方
|
| 379 |
+
const legendGroup = svg.append("g");
|
| 380 |
+
|
| 381 |
+
// 计算字段名宽度并添加间距
|
| 382 |
+
const titleWidth = xField.length * 10;
|
| 383 |
+
const titleMargin = 15;
|
| 384 |
+
|
| 385 |
+
let xs = [...new Set(chartData.map(d => d[xField]))];
|
| 386 |
+
|
| 387 |
+
const legendSize = chartUtils.legend.draw(legendGroup, xs, colors, {
|
| 388 |
+
x: titleWidth + titleMargin,
|
| 389 |
+
y: 0,
|
| 390 |
+
fontSize: 14,
|
| 391 |
+
fontWeight: "bold",
|
| 392 |
+
align: "left",
|
| 393 |
+
maxWidth: chartWidth - titleWidth - titleMargin,
|
| 394 |
+
shape: "rect",
|
| 395 |
+
});
|
| 396 |
+
|
| 397 |
+
// 添加字段名称
|
| 398 |
+
legendGroup.append("text")
|
| 399 |
+
.attr("x", 0)
|
| 400 |
+
.attr("y", legendSize.height / 2)
|
| 401 |
+
.attr("dominant-baseline", "middle")
|
| 402 |
+
.attr("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 403 |
+
.style("font-size", "16px")
|
| 404 |
+
.style("font-weight", "bold")
|
| 405 |
+
.text(xField);
|
| 406 |
+
|
| 407 |
+
// 将图例组向上移动 height/2, 并居中
|
| 408 |
+
legendGroup.attr("transform", `translate(${(chartWidth - legendSize.width - titleWidth - titleMargin) / 2}, ${-legendSize.height / 2 - 50})`);
|
| 409 |
+
|
| 410 |
+
return svg.node();
|
| 411 |
+
}
|
modules/chart_engine/template/d3-js/type49_donut_chart/donut_chart_icons_03.js
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Donut Chart",
|
| 5 |
+
"chart_name": "donut_chart_icons_03",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y"],
|
| 8 |
+
"required_fields_type": [["categorical"], ["numerical"]],
|
| 9 |
+
"required_fields_range": [[2, 20], [0, "inf"]],
|
| 10 |
+
"required_fields_icons": ["x"],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": ["x"],
|
| 13 |
+
"required_other_colors": [],
|
| 14 |
+
"supported_effects": ["shadow", "radius_corner", "stroke"],
|
| 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 dataColumns = chartUtils.schema.columns(jsonData);
|
| 35 |
+
|
| 36 |
+
const typography = jsonData.typography || {
|
| 37 |
+
title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
|
| 38 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 39 |
+
description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 40 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 41 |
+
};
|
| 42 |
+
let colors = jsonData.colors || {
|
| 43 |
+
text_color: "#333333",
|
| 44 |
+
field: {},
|
| 45 |
+
other: {
|
| 46 |
+
primary: "#4682B4" // 默认主色调
|
| 47 |
+
}
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
// 提取字段名称
|
| 51 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 52 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 53 |
+
// Set dimensions and margins
|
| 54 |
+
const width = variables.width;
|
| 55 |
+
const height = variables.height;
|
| 56 |
+
const margin = { top: 40, right: 40, bottom: 40, left: 40 };
|
| 57 |
+
const chartWidth = width - margin.left - margin.right;
|
| 58 |
+
const chartHeight = height - margin.top - margin.bottom;
|
| 59 |
+
|
| 60 |
+
// Create SVG
|
| 61 |
+
const svg = d3.select(containerSelector)
|
| 62 |
+
.append("svg")
|
| 63 |
+
.attr("width", "100%")
|
| 64 |
+
.attr("height", height)
|
| 65 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 66 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 67 |
+
.attr("xmlns", "http://www.w3.org/2000/svg");
|
| 68 |
+
|
| 69 |
+
// Calculate center point and max radius
|
| 70 |
+
const centerX = width / 2;
|
| 71 |
+
const centerY = height / 2;
|
| 72 |
+
const maxRadius = Math.min(chartWidth, chartHeight) / 2;
|
| 73 |
+
|
| 74 |
+
// Create a root group
|
| 75 |
+
const g = svg.append("g")
|
| 76 |
+
.attr("transform", `translate(${centerX}, ${centerY})`)
|
| 77 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 78 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 79 |
+
|
| 80 |
+
// 创建饼图生成器
|
| 81 |
+
const pie = d3.pie()
|
| 82 |
+
.value(d => +d[yField])
|
| 83 |
+
.sort(null);
|
| 84 |
+
|
| 85 |
+
// 创建弧形生成器
|
| 86 |
+
const arc = d3.arc()
|
| 87 |
+
.innerRadius(maxRadius*0.5)
|
| 88 |
+
.outerRadius(maxRadius)
|
| 89 |
+
.padAngle(0)
|
| 90 |
+
.cornerRadius(5);
|
| 91 |
+
|
| 92 |
+
// 计算每个组的百分比
|
| 93 |
+
const total = d3.sum(chartData, d => +d[yField]);
|
| 94 |
+
const dataWithPercentages = chartData.map(d => ({
|
| 95 |
+
...d,
|
| 96 |
+
percentage: (+d[yField] / total) * 100
|
| 97 |
+
}));
|
| 98 |
+
const sectors = pie(dataWithPercentages);
|
| 99 |
+
|
| 100 |
+
// 计算标签位置的函数
|
| 101 |
+
function calculateLabelPosition(d, iconCentroid, iconWidth, innerRadius, outerRadius, textWidth, textHeight, isLargeIcon) {
|
| 102 |
+
// 计算扇区中心角度
|
| 103 |
+
const angle = (d.startAngle + d.endAngle) / 2;
|
| 104 |
+
|
| 105 |
+
// 计算图标半径,添加额外边距
|
| 106 |
+
const iconRadius = iconWidth / 2 + 5;
|
| 107 |
+
|
| 108 |
+
// 增加安全距离,确保标签和图标不重叠
|
| 109 |
+
const safetyDistance = 20;
|
| 110 |
+
|
| 111 |
+
// 所有标签都放在外圈,统一使用较大的半径
|
| 112 |
+
const labelRadius = outerRadius + safetyDistance;
|
| 113 |
+
|
| 114 |
+
// 基于角度和半径计算最终位置
|
| 115 |
+
const x = Math.sin(angle) * labelRadius;
|
| 116 |
+
const y = -Math.cos(angle) * labelRadius;
|
| 117 |
+
|
| 118 |
+
// 额外检查:检测是否与图标有重叠
|
| 119 |
+
const distance = Math.sqrt(
|
| 120 |
+
Math.pow(x - iconCentroid[0], 2) +
|
| 121 |
+
Math.pow(y - iconCentroid[1], 2)
|
| 122 |
+
);
|
| 123 |
+
|
| 124 |
+
// 如果距离太近,再增加一些距离
|
| 125 |
+
if (distance < safetyDistance) {
|
| 126 |
+
const extraFactor = 1.5; // 增加额外的安全系数
|
| 127 |
+
const extraDistance = (safetyDistance - distance) * extraFactor;
|
| 128 |
+
labelRadius += extraDistance;
|
| 129 |
+
|
| 130 |
+
// 重新计算位置
|
| 131 |
+
return [
|
| 132 |
+
Math.sin(angle) * labelRadius,
|
| 133 |
+
-Math.cos(angle) * labelRadius
|
| 134 |
+
];
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
return [x, y];
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
// 估算文本宽度和高度的函数
|
| 141 |
+
function estimateTextDimensions(text, fontSize) {
|
| 142 |
+
return chartUtils.text.estimate(text, { fontSize });
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
// 计算扇区最大可用宽度
|
| 146 |
+
function calculateSectorMaxWidth(d, radius) {
|
| 147 |
+
// 计算扇区角度
|
| 148 |
+
const sectorAngle = d.endAngle - d.startAngle;
|
| 149 |
+
// 在弧的中间位置计算最大宽度
|
| 150 |
+
// 弧长 = 角度 * 半径,取80%作为安全尺寸
|
| 151 |
+
return Math.min(sectorAngle * radius * 0.8, 60); // 添加硬限制60
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
// 截断或调整文本以适应最大宽度
|
| 155 |
+
function fitTextToWidth(text, fontSize, maxWidth) {
|
| 156 |
+
const dimensions = estimateTextDimensions(text, fontSize);
|
| 157 |
+
if (dimensions.width <= maxWidth) {
|
| 158 |
+
return { text, fontSize };
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
// 如果文本太长,先尝试缩小字体
|
| 162 |
+
const minFontSize = 10; // 最小可接受的字体大小
|
| 163 |
+
if (fontSize > minFontSize) {
|
| 164 |
+
const newFontSize = Math.max(minFontSize, fontSize * (maxWidth / dimensions.width));
|
| 165 |
+
const newDimensions = estimateTextDimensions(text, newFontSize);
|
| 166 |
+
if (newDimensions.width <= maxWidth) {
|
| 167 |
+
return { text, fontSize: newFontSize };
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
// 如果缩小字体后仍然超出,则截断文本
|
| 172 |
+
const charWidth = fontSize * 0.6;
|
| 173 |
+
const maxChars = Math.floor(maxWidth / charWidth) - 2; // 减2留出省略号空间
|
| 174 |
+
if (maxChars < 3) { // 如果连3个字符都放不下
|
| 175 |
+
return { text: "...", fontSize };
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
return { text: text.substring(0, maxChars) + "...", fontSize };
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
for (let i = 0; i < sectors.length; i++) {
|
| 182 |
+
const d = sectors[i];
|
| 183 |
+
// 绘制甜甜圈图的各个部分
|
| 184 |
+
const path = g.append("path")
|
| 185 |
+
.attr("fill", colorResolver.field(d.data[xField], 0, { fallbackKey: "primary" }).value)
|
| 186 |
+
.attr("stroke", "#FFFFFF")
|
| 187 |
+
.attr("stroke-width", 2)
|
| 188 |
+
.attr("d", arc(d));
|
| 189 |
+
|
| 190 |
+
// 计算扇区中心点
|
| 191 |
+
// 创建一个专门用于计算扇区中心的弧生成器
|
| 192 |
+
const arcCentroid = d3.arc()
|
| 193 |
+
.innerRadius((maxRadius*0.5 + maxRadius) / 2) // 使用内外半径的中点
|
| 194 |
+
.outerRadius((maxRadius*0.5 + maxRadius) / 2);
|
| 195 |
+
|
| 196 |
+
// 获取扇区中心点坐标
|
| 197 |
+
const [cx, cy] = arcCentroid.centroid(d);
|
| 198 |
+
|
| 199 |
+
// 计算图标尺寸,根据扇区大小调整
|
| 200 |
+
const angleSize = d.endAngle - d.startAngle;
|
| 201 |
+
const iconSizeBase = Math.min(angleSize * maxRadius * 0.3, 60);
|
| 202 |
+
let iconWidth = Math.max(iconSizeBase, 20); // 至少20像素
|
| 203 |
+
let iconHeight = iconWidth;
|
| 204 |
+
|
| 205 |
+
// 创建剪切路径
|
| 206 |
+
const clipId = `clip-${i}`;
|
| 207 |
+
const defs = g.append("defs");
|
| 208 |
+
const clipPath = defs.append("clipPath")
|
| 209 |
+
.attr("id", clipId);
|
| 210 |
+
|
| 211 |
+
// 设置剪切路径
|
| 212 |
+
clipPath.append("circle")
|
| 213 |
+
.attr("cx", cx)
|
| 214 |
+
.attr("cy", cy)
|
| 215 |
+
.attr("r", iconWidth / 2);
|
| 216 |
+
|
| 217 |
+
// 添加白色背景圆
|
| 218 |
+
const circle = g.append("circle")
|
| 219 |
+
.attr("cx", cx)
|
| 220 |
+
.attr("cy", cy)
|
| 221 |
+
.attr("r", iconWidth / 2 + 3)
|
| 222 |
+
.attr("fill", "white")
|
| 223 |
+
.attr("stroke", colorResolver.field(d.data[xField], 0, { fallbackKey: "primary" }).value)
|
| 224 |
+
.attr("stroke-width", 2);
|
| 225 |
+
|
| 226 |
+
// 使用剪切路径裁剪图片
|
| 227 |
+
const icon = g.append("image")
|
| 228 |
+
.attr("xlink:href", jsonData.images.field[d.data[xField]])
|
| 229 |
+
.attr("clip-path", `url(#${clipId})`)
|
| 230 |
+
.attr("x", cx - iconWidth / 2)
|
| 231 |
+
.attr("y", cy - iconHeight / 2)
|
| 232 |
+
.attr("width", iconWidth)
|
| 233 |
+
.attr("height", iconHeight);
|
| 234 |
+
|
| 235 |
+
// 计算标签位置(在外部)
|
| 236 |
+
const labelPosition = calculateLabelPosition(
|
| 237 |
+
d,
|
| 238 |
+
[cx, cy],
|
| 239 |
+
iconWidth,
|
| 240 |
+
maxRadius*0.5,
|
| 241 |
+
maxRadius,
|
| 242 |
+
iconWidth,
|
| 243 |
+
iconHeight,
|
| 244 |
+
true
|
| 245 |
+
);
|
| 246 |
+
|
| 247 |
+
// 准备显示文本
|
| 248 |
+
let displayTextCategory = d.data[xField];
|
| 249 |
+
let displayTextNumerical = chartUtils.format.percent(d.data.percentage).text;
|
| 250 |
+
|
| 251 |
+
// 设置字体大小
|
| 252 |
+
let categoryFontSize = 20;
|
| 253 |
+
let numericalFontSize = 20;
|
| 254 |
+
|
| 255 |
+
// 估算文本尺寸
|
| 256 |
+
const categoryDimensions = estimateTextDimensions(displayTextCategory, categoryFontSize);
|
| 257 |
+
const numericalDimensions = estimateTextDimensions(displayTextNumerical, numericalFontSize);
|
| 258 |
+
// 计算标签总高度(包括两行文本和间距)
|
| 259 |
+
const labelHeight = categoryDimensions.height + numericalDimensions.height + 5;
|
| 260 |
+
// 取两行文本中较宽的一个作为标签宽度
|
| 261 |
+
const labelWidth = Math.max(categoryDimensions.width, numericalDimensions.width);
|
| 262 |
+
|
| 263 |
+
// 根据角度决定文本对齐方式
|
| 264 |
+
const angle = (d.startAngle + d.endAngle) / 2;
|
| 265 |
+
let textAnchor;
|
| 266 |
+
if (angle < Math.PI/8 || angle > 15*Math.PI/8) {
|
| 267 |
+
textAnchor = "middle"; // 正上方和正下方,居中对齐
|
| 268 |
+
} else if (angle >= Math.PI/8 && angle < 7*Math.PI/8) {
|
| 269 |
+
textAnchor = "start"; // 右侧区域,左对齐
|
| 270 |
+
} else if (angle >= 7*Math.PI/8 && angle < 9*Math.PI/8) {
|
| 271 |
+
textAnchor = "middle"; // 正左方,居中对齐
|
| 272 |
+
} else if (angle >= 9*Math.PI/8 && angle < 15*Math.PI/8) {
|
| 273 |
+
textAnchor = "end"; // 左侧区域,右对齐
|
| 274 |
+
} else {
|
| 275 |
+
textAnchor = "middle"; // 默认值
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
// 确定文本颜色
|
| 279 |
+
const textColor = colorResolver.text({ fallback: "#333333" }).value;
|
| 280 |
+
|
| 281 |
+
// 添加文本标签
|
| 282 |
+
// 添加类别文本
|
| 283 |
+
const textCategory = g.append("text")
|
| 284 |
+
.attr("transform", `translate(${labelPosition})`)
|
| 285 |
+
.attr("text-anchor", textAnchor)
|
| 286 |
+
.style("fill", textColor)
|
| 287 |
+
.style("font-family", typography.label.font_family)
|
| 288 |
+
.style("font-size", `${categoryFontSize}px`)
|
| 289 |
+
.text(displayTextCategory);
|
| 290 |
+
|
| 291 |
+
// 添加数值文本(在类别下方)
|
| 292 |
+
const textNumerical = g.append("text")
|
| 293 |
+
.attr("transform", `translate(0,20) translate(${labelPosition})`)
|
| 294 |
+
.attr("text-anchor", textAnchor)
|
| 295 |
+
.style("fill", textColor)
|
| 296 |
+
.style("font-family", typography.label.font_family)
|
| 297 |
+
.style("font-size", `${numericalFontSize}px`)
|
| 298 |
+
.style("font-weight", "bold")
|
| 299 |
+
.text(displayTextNumerical);
|
| 300 |
+
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
// 加入label
|
| 304 |
+
const label = g.append("text")
|
| 305 |
+
.attr("transform", `translate(${centerX}, ${centerY})`)
|
| 306 |
+
.attr("text-anchor", "middle")
|
| 307 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 308 |
+
.style("font-family", typography.label.font_family)
|
| 309 |
+
.style("font-size", typography.label.font_size)
|
| 310 |
+
.text(jsonData.title);
|
| 311 |
+
|
| 312 |
+
// 添加图例 - 放在图表上方
|
| 313 |
+
const legendGroup = svg.append("g")
|
| 314 |
+
.attr("transform", `translate(0, -50)`);
|
| 315 |
+
|
| 316 |
+
// 计算字段名宽度并添加间距
|
| 317 |
+
const titleWidth = xField.length * 10;
|
| 318 |
+
const titleMargin = 15;
|
| 319 |
+
|
| 320 |
+
let xs = [...new Set(chartData.map(d => d[xField]))];
|
| 321 |
+
|
| 322 |
+
const legendSize = chartUtils.legend.draw(legendGroup, xs, colors, {
|
| 323 |
+
x: titleWidth + titleMargin,
|
| 324 |
+
y: 0,
|
| 325 |
+
fontSize: 14,
|
| 326 |
+
fontWeight: "bold",
|
| 327 |
+
align: "left",
|
| 328 |
+
maxWidth: chartWidth - titleWidth - titleMargin,
|
| 329 |
+
shape: "rect",
|
| 330 |
+
});
|
| 331 |
+
|
| 332 |
+
// 添加字段名称
|
| 333 |
+
legendGroup.append("text")
|
| 334 |
+
.attr("x", 0)
|
| 335 |
+
.attr("y", legendSize.height / 2)
|
| 336 |
+
.attr("dominant-baseline", "middle")
|
| 337 |
+
.attr("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 338 |
+
.style("font-size", "16px")
|
| 339 |
+
.style("font-weight", "bold")
|
| 340 |
+
.text(xField);
|
| 341 |
+
|
| 342 |
+
// 将图例组向上移动 height/2, 并居中
|
| 343 |
+
legendGroup.attr("transform", `translate(${(chartWidth - legendSize.width - titleWidth - titleMargin) / 2}, ${-legendSize.height / 2 - 20})`);
|
| 344 |
+
|
| 345 |
+
return svg.node();
|
| 346 |
+
}
|