Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- modules/chart_engine/template/d3-js/bar_chart/bottle_bar_chart_02.js.bug +0 -0
- modules/chart_engine/template/d3-js/bar_chart/horizontal_bar_chart_0.js.bug +212 -0
- modules/chart_engine/template/d3-js/bar_chart/horizontal_diverging_bar_chart_11.js +444 -0
- modules/chart_engine/template/d3-js/bar_chart/horizontal_diverging_bar_chart_12.js +596 -0
- modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_06.js +345 -0
- modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_07.js +433 -0
- modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_10.js +379 -0
- modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_11.js +393 -0
- modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_12.js +556 -0
- modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_13.js +252 -0
- modules/chart_engine/template/d3-js/bar_chart/horizontal_pictorial_bar_chart_01.js +515 -0
- modules/chart_engine/template/d3-js/bar_chart/vertical_bar_chart_07.js +489 -0
- modules/chart_engine/template/d3-js/bar_chart/vertical_bar_chart_08.js +529 -0
- modules/chart_engine/template/d3-js/bar_chart/vertical_bar_chart_12.js +856 -0
- modules/chart_engine/template/d3-js/bar_chart/vertical_bar_chart_13.js +652 -0
- modules/chart_engine/template/d3-js/bar_chart/vertical_group_bar_chart_11.js +597 -0
- modules/chart_engine/template/d3-js/bar_chart/vertical_group_bar_chart_12.js +857 -0
- modules/chart_engine/template/d3-js/bar_chart/vertical_group_bar_chart_13.js +1198 -0
- modules/chart_engine/template/d3-js/bar_chart/vertical_pictorial_percentage_bar_chart_01.js +444 -0
- modules/chart_engine/template/d3-js/bar_chart/vertical_pictorial_percentage_bar_chart_02.js +448 -0
modules/chart_engine/template/d3-js/bar_chart/bottle_bar_chart_02.js.bug
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
modules/chart_engine/template/d3-js/bar_chart/horizontal_bar_chart_0.js.bug
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Horizontal Bar Chart",
|
| 5 |
+
"chart_name": "horizontal_bar_chart_0",
|
| 6 |
+
"required_fields": ["x", "y"],
|
| 7 |
+
"required_fields_type": [["categorical"], ["numerical"]],
|
| 8 |
+
"required_fields_range": [[3, 20], [0, 100000]],
|
| 9 |
+
"required_fields_icons": [],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": [],
|
| 12 |
+
"required_other_colors": [],
|
| 13 |
+
"supported_effects": ["gradient", "opacity"],
|
| 14 |
+
"min_height": 300,
|
| 15 |
+
"min_width": 300,
|
| 16 |
+
"background": "light",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "none",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes"
|
| 21 |
+
}
|
| 22 |
+
REQUIREMENTS_END
|
| 23 |
+
*/
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
// ---------- 1. 数据准备阶段 ----------
|
| 28 |
+
|
| 29 |
+
// 提取数据和配置
|
| 30 |
+
const jsonData = data; // 完整的JSON数据对象
|
| 31 |
+
const chartData = jsonData.data.data; // 实际数据点数组
|
| 32 |
+
const variables = jsonData.variables || {}; // 图表配置
|
| 33 |
+
const typography = jsonData.typography || { // 字体设置,如果不存在则使用默认值
|
| 34 |
+
title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
|
| 35 |
+
label: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 36 |
+
description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 37 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 38 |
+
};
|
| 39 |
+
const colors = jsonData.colors || {
|
| 40 |
+
text_color: "#333333",
|
| 41 |
+
other: {
|
| 42 |
+
primary: "#D32F2F", // Red for "Still active"
|
| 43 |
+
secondary: "#AAAAAA", // Gray for "Ended"
|
| 44 |
+
background: "#F0F0F0"
|
| 45 |
+
}
|
| 46 |
+
}; // 颜色设置
|
| 47 |
+
const dataColumns = jsonData.data.columns || []; // 数据列定义
|
| 48 |
+
|
| 49 |
+
// 设置视觉效果变量的默认值
|
| 50 |
+
variables.has_shadow = variables.has_shadow || false;
|
| 51 |
+
variables.has_stroke = variables.has_stroke || false;
|
| 52 |
+
|
| 53 |
+
// 清空容器
|
| 54 |
+
d3.select(containerSelector).html("");
|
| 55 |
+
|
| 56 |
+
// ---------- 2. 尺寸和布局设置 ----------
|
| 57 |
+
|
| 58 |
+
// 设置图表总尺寸
|
| 59 |
+
const width = variables.width || 300;
|
| 60 |
+
const height = variables.height || 400;
|
| 61 |
+
|
| 62 |
+
// 设置边距
|
| 63 |
+
const margin = {
|
| 64 |
+
top: 50,
|
| 65 |
+
right: 30,
|
| 66 |
+
bottom: 80,
|
| 67 |
+
left: 40
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
// 计算实际绘图区域大小
|
| 71 |
+
const chartWidth = width - margin.left - margin.right;
|
| 72 |
+
const chartHeight = height - margin.top - margin.bottom;
|
| 73 |
+
|
| 74 |
+
// ---------- 3. 提取字段名和单位 ----------
|
| 75 |
+
|
| 76 |
+
// 根据数据列获取字段名
|
| 77 |
+
const xField = dataColumns.find(col => col.role === "x")?.name || "period";
|
| 78 |
+
const yField = dataColumns.find(col => col.role === "y")?.name || "value";
|
| 79 |
+
|
| 80 |
+
// 获取字段单位(如果存在)
|
| 81 |
+
let xUnit = "";
|
| 82 |
+
let yUnit = "";
|
| 83 |
+
let groupUnit = "";
|
| 84 |
+
|
| 85 |
+
if (dataColumns.find(col => col.role === "x")?.unit !== "none") {
|
| 86 |
+
xUnit = dataColumns.find(col => col.role === "x").unit;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
if (dataColumns.find(col => col.role === "y")?.unit !== "none") {
|
| 90 |
+
yUnit = dataColumns.find(col => col.role === "y").unit;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
// ---------- 4. 数据处理 ----------
|
| 95 |
+
|
| 96 |
+
// 处理数据,确保数据格式正确
|
| 97 |
+
const processedData = chartData.map(d => ({
|
| 98 |
+
category: d[xField],
|
| 99 |
+
value: +d[yField] // 确保转换为数字
|
| 100 |
+
})).sort((a, b) => b.value - a.value); // 按值降序排序
|
| 101 |
+
|
| 102 |
+
// ---------- 5. 创建比例尺 ----------
|
| 103 |
+
|
| 104 |
+
// Y轴比例尺 - 使用分类数据
|
| 105 |
+
const yScale = d3.scaleBand()
|
| 106 |
+
.domain(processedData.map(d => d.category))
|
| 107 |
+
.range([0, chartHeight])
|
| 108 |
+
.padding(0.3);
|
| 109 |
+
|
| 110 |
+
// X轴比例尺 - 使用数值
|
| 111 |
+
const xScale = d3.scaleLinear()
|
| 112 |
+
.domain([0, d3.max(processedData, d => d.value)])
|
| 113 |
+
.range([0, chartWidth])
|
| 114 |
+
.nice();
|
| 115 |
+
|
| 116 |
+
// 颜色比例尺
|
| 117 |
+
const colorScale = d3.scaleOrdinal()
|
| 118 |
+
.domain(processedData.map(d => d.category))
|
| 119 |
+
.range([colors.other.primary, colors.other.primary]);
|
| 120 |
+
|
| 121 |
+
// ---------- 6. 创建SVG容器 ----------
|
| 122 |
+
|
| 123 |
+
const svg = d3.select(containerSelector)
|
| 124 |
+
.append("svg")
|
| 125 |
+
.attr("width", "100%")
|
| 126 |
+
.attr("height", height)
|
| 127 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 128 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 129 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 130 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 131 |
+
|
| 132 |
+
// 添加图表主体容器
|
| 133 |
+
const chartGroup = svg.append("g")
|
| 134 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 135 |
+
|
| 136 |
+
// ---------- 7. 绘制图表元素 ----------
|
| 137 |
+
|
| 138 |
+
// 添加X轴(隐藏)
|
| 139 |
+
const xAxis = d3.axisBottom(xScale)
|
| 140 |
+
.ticks(5)
|
| 141 |
+
.tickFormat(d => d + (yUnit ? ` ${yUnit}` : ''))
|
| 142 |
+
.tickSize(0) // 移除刻度线
|
| 143 |
+
.tickPadding(10); // 增加文字和轴的间距
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
chartGroup.append("g")
|
| 147 |
+
.attr("class", "x-axis")
|
| 148 |
+
.attr("transform", `translate(0, ${chartHeight})`)
|
| 149 |
+
.call(xAxis)
|
| 150 |
+
.call(g => g.select(".domain").remove()) // 移除轴线
|
| 151 |
+
.selectAll("text")
|
| 152 |
+
.style("font-family", typography.label.font_family)
|
| 153 |
+
.style("font-size", typography.label.font_size)
|
| 154 |
+
.style("fill", colors.text_color)
|
| 155 |
+
.style("opacity", 0); // 隐藏X轴文本
|
| 156 |
+
|
| 157 |
+
// 添加Y轴(不显示刻度线)
|
| 158 |
+
const yAxis = d3.axisLeft(yScale)
|
| 159 |
+
.tickSize(0); // 移除刻度线
|
| 160 |
+
|
| 161 |
+
chartGroup.append("g")
|
| 162 |
+
.attr("class", "y-axis")
|
| 163 |
+
.call(yAxis)
|
| 164 |
+
.call(g => g.select(".domain").remove()) // 移除轴线
|
| 165 |
+
.selectAll("text")
|
| 166 |
+
.style("font-family", typography.label.font_family)
|
| 167 |
+
.style("font-size", typography.label.font_size)
|
| 168 |
+
.style("fill", colors.text_color);
|
| 169 |
+
|
| 170 |
+
// 添加条形
|
| 171 |
+
const bars = chartGroup.selectAll(".bar")
|
| 172 |
+
.data(processedData)
|
| 173 |
+
.enter()
|
| 174 |
+
.append("rect")
|
| 175 |
+
.attr("class", "bar")
|
| 176 |
+
.attr("y", d => yScale(d.category))
|
| 177 |
+
.attr("x", 0)
|
| 178 |
+
.attr("height", yScale.bandwidth())
|
| 179 |
+
.attr("width", d => xScale(d.value)) // 直接设置最终宽度
|
| 180 |
+
.attr("fill", d => colorScale(d.category));
|
| 181 |
+
|
| 182 |
+
// 添加数值格式化函数
|
| 183 |
+
const formatValue = (value) => {
|
| 184 |
+
if (value >= 1000000000) {
|
| 185 |
+
return d3.format("~g")(value / 1000000000) + "B";
|
| 186 |
+
} else if (value >= 1000000) {
|
| 187 |
+
return d3.format("~g")(value / 1000000) + "M";
|
| 188 |
+
} else if (value >= 1000) {
|
| 189 |
+
return d3.format("~g")(value / 1000) + "K";
|
| 190 |
+
} else {
|
| 191 |
+
return d3.format("~g")(value);
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
// 添加数值标签
|
| 196 |
+
const labels = chartGroup.selectAll(".label")
|
| 197 |
+
.data(processedData)
|
| 198 |
+
.enter()
|
| 199 |
+
.append("text")
|
| 200 |
+
.attr("class", "label")
|
| 201 |
+
.attr("y", d => yScale(d.category) + yScale.bandwidth() / 2)
|
| 202 |
+
.attr("x", d => xScale(d.value) + 5)
|
| 203 |
+
.attr("dy", ".35em")
|
| 204 |
+
.style("font-family", typography.label.font_family)
|
| 205 |
+
.style("font-size", typography.label.font_size)
|
| 206 |
+
.style("fill", colors.text_color)
|
| 207 |
+
.text(d => `${formatValue(d.value)}${yUnit ? ` ${yUnit}` : ''}`)
|
| 208 |
+
.style("opacity", 1); // 直接设置为可见
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
return svg.node();
|
| 212 |
+
}
|
modules/chart_engine/template/d3-js/bar_chart/horizontal_diverging_bar_chart_11.js
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Horizontal Diverging Bar Chart",
|
| 5 |
+
"chart_name": "horizontal_diverging_bar_chart_11",
|
| 6 |
+
"chart_for": "comparison",
|
| 7 |
+
"is_composite": false,
|
| 8 |
+
"required_fields": ["x", "y", "group"],
|
| 9 |
+
"required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
|
| 10 |
+
"required_fields_range": [[2, 20], [0, "inf"], [2, 2]],
|
| 11 |
+
"required_fields_icons": [],
|
| 12 |
+
"required_other_icons": [],
|
| 13 |
+
"required_fields_colors": ["group"],
|
| 14 |
+
"required_other_colors": [],
|
| 15 |
+
"supported_effects": ["shadow", "radius_corner", "gradient", "stroke", "spacing"],
|
| 16 |
+
"min_height": 400,
|
| 17 |
+
"min_width": 400,
|
| 18 |
+
"background": "none",
|
| 19 |
+
"icon_mark": "none",
|
| 20 |
+
"icon_label": "side",
|
| 21 |
+
"has_x_axis": "no",
|
| 22 |
+
"has_y_axis": "no"
|
| 23 |
+
}
|
| 24 |
+
REQUIREMENTS_END
|
| 25 |
+
*/
|
| 26 |
+
|
| 27 |
+
// Reference-local card layout for selected GPT image-2 sample_00 variant_03.
|
| 28 |
+
function makeChart(containerSelector, data) {
|
| 29 |
+
const jsonData = data;
|
| 30 |
+
const rawData = Array.isArray(jsonData.data) ? jsonData.data : (jsonData.data && jsonData.data.data) || [];
|
| 31 |
+
const variables = jsonData.variables || {};
|
| 32 |
+
const typography = jsonData.typography || {
|
| 33 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 34 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 35 |
+
};
|
| 36 |
+
const width = Math.max(+variables.width || 714, 1180);
|
| 37 |
+
const height = Math.min(Math.max(+variables.height || 520, 500), 540);
|
| 38 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 39 |
+
|
| 40 |
+
d3.select(containerSelector).html("");
|
| 41 |
+
|
| 42 |
+
const dimensionChannel = chartUtils.schema.channel(jsonData, "x");
|
| 43 |
+
const valueChannel = chartUtils.schema.channel(jsonData, "y");
|
| 44 |
+
const groupChannel = chartUtils.schema.channel(jsonData, "group");
|
| 45 |
+
const dimensionField = dimensionChannel.key;
|
| 46 |
+
const valueField = valueChannel.key;
|
| 47 |
+
const groupField = groupChannel.key;
|
| 48 |
+
const dimensionUnit = dimensionChannel.unit && dimensionChannel.unit !== "none" ? dimensionChannel.unit : "";
|
| 49 |
+
const valueUnit = valueChannel.unit && valueChannel.unit !== "none" ? valueChannel.unit : "";
|
| 50 |
+
const groupUnit = groupChannel.unit && groupChannel.unit !== "none" ? groupChannel.unit : "";
|
| 51 |
+
|
| 52 |
+
const groups = [...new Set(rawData.map(d => d[groupField]))].slice(0, 2);
|
| 53 |
+
const dimensions = [...new Set(rawData.map(d => d[dimensionField]))].sort((a, b) => {
|
| 54 |
+
const na = +a;
|
| 55 |
+
const nb = +b;
|
| 56 |
+
if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb;
|
| 57 |
+
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" });
|
| 58 |
+
});
|
| 59 |
+
const leftGroup = groups[0];
|
| 60 |
+
const rightGroup = groups[1];
|
| 61 |
+
const complete = groups.length === 2 && dimensions.every(dimension =>
|
| 62 |
+
rawData.some(d => d[dimensionField] === dimension && d[groupField] === leftGroup) &&
|
| 63 |
+
rawData.some(d => d[dimensionField] === dimension && d[groupField] === rightGroup)
|
| 64 |
+
);
|
| 65 |
+
|
| 66 |
+
if (!complete) {
|
| 67 |
+
d3.select(containerSelector)
|
| 68 |
+
.append("div")
|
| 69 |
+
.style("color", "#b87312")
|
| 70 |
+
.style("text-align", "center")
|
| 71 |
+
.style("padding", "20px")
|
| 72 |
+
.style("font-family", typography.label.font_family || "Arial")
|
| 73 |
+
.style("font-size", typography.label.font_size || "12px")
|
| 74 |
+
.text("Missing paired group data for horizontal diverging bars.");
|
| 75 |
+
return;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const values = rawData.map(d => Math.max(0, +d[valueField] || 0));
|
| 79 |
+
const maxValue = d3.max(values) || 1;
|
| 80 |
+
const titleText = [
|
| 81 |
+
jsonData.title,
|
| 82 |
+
jsonData.titles && jsonData.titles.main_title,
|
| 83 |
+
jsonData.title_text,
|
| 84 |
+
jsonData.meta && jsonData.meta.title
|
| 85 |
+
].filter(Boolean).join(" ");
|
| 86 |
+
|
| 87 |
+
const leftColor = "#1762d4";
|
| 88 |
+
const leftLight = "#eaf2ff";
|
| 89 |
+
const rightColor = "#d79519";
|
| 90 |
+
const rightLight = "#fff4dc";
|
| 91 |
+
const textColor = colorResolver.text({ fallback: "#12275c" }).value;
|
| 92 |
+
|
| 93 |
+
const svg = d3.select(containerSelector)
|
| 94 |
+
.append("svg")
|
| 95 |
+
.attr("width", "100%")
|
| 96 |
+
.attr("height", height)
|
| 97 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 98 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 99 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 100 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
|
| 101 |
+
.attr("data-chart-variation", "horizontal_diverging_bar_chart_11")
|
| 102 |
+
.attr("data-reference-layout", "sample_00_gpt_image_2_03");
|
| 103 |
+
|
| 104 |
+
const defs = svg.append("defs");
|
| 105 |
+
const filter = defs.append("filter")
|
| 106 |
+
.attr("id", "hdbar11-card-shadow")
|
| 107 |
+
.attr("x", "-8%")
|
| 108 |
+
.attr("y", "-10%")
|
| 109 |
+
.attr("width", "116%")
|
| 110 |
+
.attr("height", "124%");
|
| 111 |
+
filter.append("feDropShadow")
|
| 112 |
+
.attr("dx", 0)
|
| 113 |
+
.attr("dy", 6)
|
| 114 |
+
.attr("stdDeviation", 8)
|
| 115 |
+
.attr("flood-color", "#6780ad")
|
| 116 |
+
.attr("flood-opacity", 0.18);
|
| 117 |
+
|
| 118 |
+
const makeGradient = (id, stops) => {
|
| 119 |
+
const grad = defs.append("linearGradient").attr("id", id).attr("x1", "0%").attr("x2", "100%").attr("y1", "0%").attr("y2", "0%");
|
| 120 |
+
stops.forEach(stop => grad.append("stop").attr("offset", stop.offset).attr("stop-color", stop.color).attr("stop-opacity", stop.opacity || 1));
|
| 121 |
+
};
|
| 122 |
+
makeGradient("hdbar11-blue-gradient", [
|
| 123 |
+
{ offset: "0%", color: "#a8c8ff" },
|
| 124 |
+
{ offset: "100%", color: leftColor }
|
| 125 |
+
]);
|
| 126 |
+
makeGradient("hdbar11-gold-gradient", [
|
| 127 |
+
{ offset: "0%", color: "#f4c257" },
|
| 128 |
+
{ offset: "100%", color: "#d68d12" }
|
| 129 |
+
]);
|
| 130 |
+
|
| 131 |
+
const makeHatch = (id, baseFill) => {
|
| 132 |
+
const pattern = defs.append("pattern")
|
| 133 |
+
.attr("id", id)
|
| 134 |
+
.attr("patternUnits", "userSpaceOnUse")
|
| 135 |
+
.attr("width", 7)
|
| 136 |
+
.attr("height", 7)
|
| 137 |
+
.attr("patternTransform", "rotate(45)");
|
| 138 |
+
pattern.append("rect").attr("width", 7).attr("height", 7).attr("fill", baseFill);
|
| 139 |
+
pattern.append("line")
|
| 140 |
+
.attr("x1", 0)
|
| 141 |
+
.attr("y1", 0)
|
| 142 |
+
.attr("x2", 0)
|
| 143 |
+
.attr("y2", 7)
|
| 144 |
+
.attr("stroke", "#ffffff")
|
| 145 |
+
.attr("stroke-width", 1.25)
|
| 146 |
+
.attr("opacity", 0.35);
|
| 147 |
+
};
|
| 148 |
+
makeHatch("hdbar11-blue-hatch", "url(#hdbar11-blue-gradient)");
|
| 149 |
+
makeHatch("hdbar11-gold-hatch", "url(#hdbar11-gold-gradient)");
|
| 150 |
+
|
| 151 |
+
const cardX = Math.max(6, width * 0.018);
|
| 152 |
+
const cardY = Math.max(6, height * 0.02);
|
| 153 |
+
const cardW = width - cardX * 2;
|
| 154 |
+
const cardH = height - cardY * 2;
|
| 155 |
+
const padX = Math.max(28, Math.min(48, width * 0.04));
|
| 156 |
+
const headerH = Math.max(58, Math.min(76, cardH * 0.16));
|
| 157 |
+
const rowTop = cardY + headerH + 18;
|
| 158 |
+
const rowBottom = cardY + cardH - 18;
|
| 159 |
+
const rowAreaH = Math.max(100, rowBottom - rowTop);
|
| 160 |
+
const rowStep = rowAreaH / dimensions.length;
|
| 161 |
+
const barH = Math.max(10, Math.min(22, rowStep * 0.42));
|
| 162 |
+
const chipR = Math.max(11, Math.min(18, rowStep * 0.34));
|
| 163 |
+
const centerX = width / 2;
|
| 164 |
+
const maxDimensionChars = d3.max(dimensions, d => String(dimensionUnit ? `${d}${dimensionUnit}` : d).length) || 1;
|
| 165 |
+
const compactCenterLabels = maxDimensionChars <= 4;
|
| 166 |
+
const centerGap = compactCenterLabels
|
| 167 |
+
? chipR * 2 + Math.max(28, width * 0.034)
|
| 168 |
+
: Math.min(width * 0.22, Math.max(chipR * 2 + 44, maxDimensionChars * 6.6 + 30));
|
| 169 |
+
const centerChipW = compactCenterLabels ? chipR * 2 : Math.max(chipR * 2 + 20, centerGap - 22);
|
| 170 |
+
const labelW = Math.max(88, Math.min(118, width * 0.1));
|
| 171 |
+
const leftTrackX = cardX + padX + labelW + 10;
|
| 172 |
+
const leftTrackEnd = centerX - centerGap / 2;
|
| 173 |
+
const rightTrackX = centerX + centerGap / 2;
|
| 174 |
+
const rightLabelW = labelW;
|
| 175 |
+
const rightTrackEnd = cardX + cardW - padX - rightLabelW - 10;
|
| 176 |
+
const trackW = Math.max(36, Math.min(leftTrackEnd - leftTrackX, rightTrackEnd - rightTrackX));
|
| 177 |
+
const leftValueX = Math.max(cardX + padX + labelW - 8, leftTrackX - 20);
|
| 178 |
+
const rightValueX = Math.min(cardX + cardW - padX - rightLabelW + 8, rightTrackX + trackW + 20);
|
| 179 |
+
const labelFont = Math.max(11, Math.min(16, rowStep * 0.29));
|
| 180 |
+
const valueFont = Math.max(11, Math.min(17, rowStep * 0.34));
|
| 181 |
+
|
| 182 |
+
svg.append("rect")
|
| 183 |
+
.attr("class", "hdbar11-chart-card")
|
| 184 |
+
.attr("x", cardX)
|
| 185 |
+
.attr("y", cardY)
|
| 186 |
+
.attr("width", cardW)
|
| 187 |
+
.attr("height", cardH)
|
| 188 |
+
.attr("rx", 14)
|
| 189 |
+
.attr("fill", "#ffffff")
|
| 190 |
+
.attr("stroke", "#e3e9f4")
|
| 191 |
+
.attr("stroke-width", 1)
|
| 192 |
+
.attr("filter", "url(#hdbar11-card-shadow)");
|
| 193 |
+
|
| 194 |
+
const headerY = cardY + Math.max(29, headerH * 0.58);
|
| 195 |
+
const leftHeaderX = cardX + padX + Math.max(118, Math.min(150, cardW * 0.2));
|
| 196 |
+
const rightHeaderX = cardX + cardW - padX - Math.max(118, Math.min(150, cardW * 0.2));
|
| 197 |
+
|
| 198 |
+
const groupLabel = group => {
|
| 199 |
+
let label = `${group}${groupUnit || ""}`.trim();
|
| 200 |
+
if (/developer/i.test(titleText) && !/developer/i.test(label)) label = `${label} Developers`;
|
| 201 |
+
return label.toUpperCase();
|
| 202 |
+
};
|
| 203 |
+
|
| 204 |
+
const addHeaderSlot = (x, y, side, color) => {
|
| 205 |
+
const slot = svg.append("g")
|
| 206 |
+
.attr("class", `hdbar11-header-icon-slot hdbar11-${side}-icon-slot`)
|
| 207 |
+
.attr("data-asset-slot", `${side}-group-icon`)
|
| 208 |
+
.attr("data-slot-policy", "neutral_placeholder")
|
| 209 |
+
.attr("data-slot-anchor", `${side}-group-header`)
|
| 210 |
+
.attr("data-slot-bbox", `${Math.round(x - 14)},${Math.round(y - 14)},28,28`)
|
| 211 |
+
.attr("data-slot-mask", "circle")
|
| 212 |
+
.attr("data-slot-collision", "inside-chart-card-header-row")
|
| 213 |
+
.attr("data-no-embedded-raster", "true")
|
| 214 |
+
.attr("data-no-copied-reference-art", "true");
|
| 215 |
+
slot.append("circle")
|
| 216 |
+
.attr("cx", x)
|
| 217 |
+
.attr("cy", y)
|
| 218 |
+
.attr("r", 13)
|
| 219 |
+
.attr("fill", "#ffffff")
|
| 220 |
+
.attr("stroke", color)
|
| 221 |
+
.attr("stroke-width", 2);
|
| 222 |
+
slot.append("circle")
|
| 223 |
+
.attr("cx", x)
|
| 224 |
+
.attr("cy", y)
|
| 225 |
+
.attr("r", 3.2)
|
| 226 |
+
.attr("fill", color)
|
| 227 |
+
.attr("opacity", 0.52);
|
| 228 |
+
};
|
| 229 |
+
|
| 230 |
+
addHeaderSlot(leftHeaderX - Math.max(84, Math.min(104, cardW * 0.14)), headerY - 1, "left", leftColor);
|
| 231 |
+
addHeaderSlot(rightHeaderX - Math.max(84, Math.min(104, cardW * 0.14)), headerY - 1, "right", rightColor);
|
| 232 |
+
|
| 233 |
+
svg.append("text")
|
| 234 |
+
.attr("x", leftHeaderX + 12)
|
| 235 |
+
.attr("y", headerY + 5)
|
| 236 |
+
.attr("text-anchor", "middle")
|
| 237 |
+
.style("font-family", typography.label.font_family || "Arial")
|
| 238 |
+
.style("font-size", `${Math.max(11, Math.min(14, width * 0.018))}px`)
|
| 239 |
+
.style("font-weight", 800)
|
| 240 |
+
.style("fill", leftColor)
|
| 241 |
+
.text(groupLabel(leftGroup));
|
| 242 |
+
|
| 243 |
+
svg.append("text")
|
| 244 |
+
.attr("x", rightHeaderX + 16)
|
| 245 |
+
.attr("y", headerY + 5)
|
| 246 |
+
.attr("text-anchor", "middle")
|
| 247 |
+
.style("font-family", typography.label.font_family || "Arial")
|
| 248 |
+
.style("font-size", `${Math.max(11, Math.min(14, width * 0.018))}px`)
|
| 249 |
+
.style("font-weight", 800)
|
| 250 |
+
.style("fill", rightColor)
|
| 251 |
+
.text(groupLabel(rightGroup));
|
| 252 |
+
|
| 253 |
+
svg.append("text")
|
| 254 |
+
.attr("x", centerX)
|
| 255 |
+
.attr("y", headerY + 5)
|
| 256 |
+
.attr("text-anchor", "middle")
|
| 257 |
+
.style("font-family", typography.label.font_family || "Arial")
|
| 258 |
+
.style("font-size", `${Math.max(10, Math.min(13, width * 0.017))}px`)
|
| 259 |
+
.style("font-weight", 700)
|
| 260 |
+
.style("letter-spacing", "0")
|
| 261 |
+
.style("fill", "#202848")
|
| 262 |
+
.attr("textLength", String(dimensionField || "Category").length > 10 ? Math.min(centerGap - 12, 100) : null)
|
| 263 |
+
.attr("lengthAdjust", String(dimensionField || "Category").length > 10 ? "spacingAndGlyphs" : null)
|
| 264 |
+
.text(String(dimensionField || "Category").toUpperCase());
|
| 265 |
+
|
| 266 |
+
svg.append("line")
|
| 267 |
+
.attr("x1", cardX + padX)
|
| 268 |
+
.attr("x2", cardX + cardW - padX)
|
| 269 |
+
.attr("y1", cardY + headerH)
|
| 270 |
+
.attr("y2", cardY + headerH)
|
| 271 |
+
.attr("stroke", "#e2e7f0")
|
| 272 |
+
.attr("stroke-width", 1);
|
| 273 |
+
|
| 274 |
+
const leftByDimension = new Map(rawData.filter(d => d[groupField] === leftGroup).map(d => [d[dimensionField], d]));
|
| 275 |
+
const rightByDimension = new Map(rawData.filter(d => d[groupField] === rightGroup).map(d => [d[dimensionField], d]));
|
| 276 |
+
const leftMaxValue = d3.max([...leftByDimension.values()], d => +d[valueField] || 0) || maxValue;
|
| 277 |
+
const rightMaxValue = d3.max([...rightByDimension.values()], d => +d[valueField] || 0) || maxValue;
|
| 278 |
+
|
| 279 |
+
const formatDimension = value => dimensionUnit ? `${value}${dimensionUnit}` : `${value}`;
|
| 280 |
+
const formatValueParts = value => {
|
| 281 |
+
const numeric = +value || 0;
|
| 282 |
+
if (/usd|\$|dollar/i.test(valueUnit) && Math.abs(numeric) >= 1000) {
|
| 283 |
+
return { number: `${chartUtils.format.autoText(numeric / 1000)}K`, unit: "USD" };
|
| 284 |
+
}
|
| 285 |
+
const formatted = chartUtils.format.autoText(numeric);
|
| 286 |
+
return { number: formatted, unit: valueUnit };
|
| 287 |
+
};
|
| 288 |
+
|
| 289 |
+
const addValueLabel = (x, y, value, color, anchor) => {
|
| 290 |
+
const parts = formatValueParts(value);
|
| 291 |
+
const text = svg.append("text")
|
| 292 |
+
.attr("class", "hdbar11-value-label")
|
| 293 |
+
.attr("x", x)
|
| 294 |
+
.attr("y", y + 1)
|
| 295 |
+
.attr("dy", "0.35em")
|
| 296 |
+
.attr("text-anchor", anchor)
|
| 297 |
+
.style("font-family", typography.annotation.font_family || "Arial")
|
| 298 |
+
.style("font-weight", 800)
|
| 299 |
+
.style("font-size", `${valueFont}px`)
|
| 300 |
+
.style("fill", color)
|
| 301 |
+
.attr("data-value", value)
|
| 302 |
+
.attr("data-label-anchor", "bar-outer-endpoint")
|
| 303 |
+
.attr("data-coordinate-rule", "attached-row-centerline");
|
| 304 |
+
text.append("tspan").text(parts.number);
|
| 305 |
+
if (parts.unit) {
|
| 306 |
+
text.append("tspan")
|
| 307 |
+
.style("font-size", `${Math.max(8, valueFont * 0.58)}px`)
|
| 308 |
+
.style("font-weight", 700)
|
| 309 |
+
.attr("dx", 3)
|
| 310 |
+
.text(parts.unit);
|
| 311 |
+
}
|
| 312 |
+
};
|
| 313 |
+
|
| 314 |
+
dimensions.forEach((dimension, index) => {
|
| 315 |
+
const rowCenterY = rowTop + index * rowStep + rowStep / 2;
|
| 316 |
+
const guideY = rowTop + index * rowStep;
|
| 317 |
+
const leftDatum = leftByDimension.get(dimension);
|
| 318 |
+
const rightDatum = rightByDimension.get(dimension);
|
| 319 |
+
const leftValue = +leftDatum[valueField] || 0;
|
| 320 |
+
const rightValue = +rightDatum[valueField] || 0;
|
| 321 |
+
const leftBarW = Math.max(3, trackW * Math.min(1, 0.38 + 0.62 * Math.sqrt(Math.max(0, leftValue) / leftMaxValue)));
|
| 322 |
+
const rightBarW = Math.max(3, trackW * Math.min(1, 0.30 + 0.70 * Math.sqrt(Math.max(0, rightValue) / rightMaxValue)));
|
| 323 |
+
const leftBarX = leftTrackX;
|
| 324 |
+
const rightBarX = rightTrackX;
|
| 325 |
+
const barY = rowCenterY - barH / 2;
|
| 326 |
+
const radius = barH / 2;
|
| 327 |
+
|
| 328 |
+
svg.append("line")
|
| 329 |
+
.attr("class", "hdbar11-row-guide")
|
| 330 |
+
.attr("x1", cardX + padX)
|
| 331 |
+
.attr("x2", cardX + cardW - padX)
|
| 332 |
+
.attr("y1", guideY)
|
| 333 |
+
.attr("y2", guideY)
|
| 334 |
+
.attr("stroke", "#e4eaf4")
|
| 335 |
+
.attr("stroke-width", 1)
|
| 336 |
+
.attr("stroke-dasharray", index % 2 === 0 ? "3 3" : null)
|
| 337 |
+
.attr("opacity", 0.86);
|
| 338 |
+
|
| 339 |
+
svg.append("rect")
|
| 340 |
+
.attr("class", "hdbar11-left-track")
|
| 341 |
+
.attr("x", leftTrackX)
|
| 342 |
+
.attr("y", barY)
|
| 343 |
+
.attr("width", trackW)
|
| 344 |
+
.attr("height", barH)
|
| 345 |
+
.attr("rx", radius)
|
| 346 |
+
.attr("fill", leftLight);
|
| 347 |
+
svg.append("rect")
|
| 348 |
+
.attr("class", "hdbar11-right-track")
|
| 349 |
+
.attr("x", rightTrackX)
|
| 350 |
+
.attr("y", barY)
|
| 351 |
+
.attr("width", trackW)
|
| 352 |
+
.attr("height", barH)
|
| 353 |
+
.attr("rx", radius)
|
| 354 |
+
.attr("fill", rightLight);
|
| 355 |
+
|
| 356 |
+
svg.append("rect")
|
| 357 |
+
.attr("class", "hdbar11-bar hdbar11-left-bar")
|
| 358 |
+
.attr("x", leftBarX)
|
| 359 |
+
.attr("y", barY)
|
| 360 |
+
.attr("width", leftBarW)
|
| 361 |
+
.attr("height", barH)
|
| 362 |
+
.attr("rx", radius)
|
| 363 |
+
.attr("fill", "url(#hdbar11-blue-hatch)")
|
| 364 |
+
.attr("data-category", dimension)
|
| 365 |
+
.attr("data-group", leftGroup)
|
| 366 |
+
.attr("data-value", leftValue)
|
| 367 |
+
.attr("data-endpoint-shape", "rounded-pill-like")
|
| 368 |
+
.attr("data-coordinate-rule", "row-centerline-left-of-center-chip");
|
| 369 |
+
svg.append("rect")
|
| 370 |
+
.attr("class", "hdbar11-bar hdbar11-right-bar")
|
| 371 |
+
.attr("x", rightBarX)
|
| 372 |
+
.attr("y", barY)
|
| 373 |
+
.attr("width", rightBarW)
|
| 374 |
+
.attr("height", barH)
|
| 375 |
+
.attr("rx", radius)
|
| 376 |
+
.attr("fill", "url(#hdbar11-gold-hatch)")
|
| 377 |
+
.attr("data-category", dimension)
|
| 378 |
+
.attr("data-group", rightGroup)
|
| 379 |
+
.attr("data-value", rightValue)
|
| 380 |
+
.attr("data-endpoint-shape", "rounded-pill-like")
|
| 381 |
+
.attr("data-coordinate-rule", "row-centerline-right-of-center-chip");
|
| 382 |
+
|
| 383 |
+
if (compactCenterLabels) {
|
| 384 |
+
svg.append("circle")
|
| 385 |
+
.attr("class", "hdbar11-center-chip")
|
| 386 |
+
.attr("cx", centerX)
|
| 387 |
+
.attr("cy", rowCenterY)
|
| 388 |
+
.attr("r", chipR)
|
| 389 |
+
.attr("fill", "#ffffff")
|
| 390 |
+
.attr("stroke", "#dbe3ef")
|
| 391 |
+
.attr("stroke-width", 1)
|
| 392 |
+
.attr("filter", "url(#hdbar11-card-shadow)")
|
| 393 |
+
.attr("data-coordinate-rule", "centerline-aligned")
|
| 394 |
+
.attr("data-category", dimension);
|
| 395 |
+
} else {
|
| 396 |
+
svg.append("rect")
|
| 397 |
+
.attr("class", "hdbar11-center-chip hdbar11-center-chip-pill")
|
| 398 |
+
.attr("x", centerX - centerChipW / 2)
|
| 399 |
+
.attr("y", rowCenterY - chipR)
|
| 400 |
+
.attr("width", centerChipW)
|
| 401 |
+
.attr("height", chipR * 2)
|
| 402 |
+
.attr("rx", chipR)
|
| 403 |
+
.attr("fill", "#ffffff")
|
| 404 |
+
.attr("stroke", "#dbe3ef")
|
| 405 |
+
.attr("stroke-width", 1)
|
| 406 |
+
.attr("filter", "url(#hdbar11-card-shadow)")
|
| 407 |
+
.attr("data-coordinate-rule", "centerline-aligned-pill")
|
| 408 |
+
.attr("data-category", dimension);
|
| 409 |
+
}
|
| 410 |
+
const formattedDimension = formatDimension(dimension);
|
| 411 |
+
const dimensionSafeWidth = centerChipW - 9;
|
| 412 |
+
const needsFit = formattedDimension.length * labelFont * 0.56 > dimensionSafeWidth;
|
| 413 |
+
svg.append("text")
|
| 414 |
+
.attr("class", "hdbar11-center-chip-label")
|
| 415 |
+
.attr("x", centerX)
|
| 416 |
+
.attr("y", rowCenterY + 1)
|
| 417 |
+
.attr("dy", "0.35em")
|
| 418 |
+
.attr("text-anchor", "middle")
|
| 419 |
+
.style("font-family", typography.label.font_family || "Arial")
|
| 420 |
+
.style("font-size", `${labelFont}px`)
|
| 421 |
+
.style("font-weight", 800)
|
| 422 |
+
.style("fill", textColor)
|
| 423 |
+
.attr("textLength", needsFit ? dimensionSafeWidth : null)
|
| 424 |
+
.attr("lengthAdjust", needsFit ? "spacingAndGlyphs" : null)
|
| 425 |
+
.text(formattedDimension);
|
| 426 |
+
|
| 427 |
+
addValueLabel(leftValueX, rowCenterY, leftValue, leftColor, "end");
|
| 428 |
+
addValueLabel(rightValueX, rowCenterY, rightValue, rightColor, "start");
|
| 429 |
+
});
|
| 430 |
+
|
| 431 |
+
svg.append("line")
|
| 432 |
+
.attr("class", "hdbar11-center-divider")
|
| 433 |
+
.attr("x1", centerX)
|
| 434 |
+
.attr("x2", centerX)
|
| 435 |
+
.attr("y1", rowTop - 4)
|
| 436 |
+
.attr("y2", rowBottom + 2)
|
| 437 |
+
.attr("stroke", "#d7deea")
|
| 438 |
+
.attr("stroke-width", 1.1)
|
| 439 |
+
.attr("stroke-dasharray", "2 6")
|
| 440 |
+
.attr("opacity", 0.8)
|
| 441 |
+
.lower();
|
| 442 |
+
|
| 443 |
+
return svg.node();
|
| 444 |
+
}
|
modules/chart_engine/template/d3-js/bar_chart/horizontal_diverging_bar_chart_12.js
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Horizontal Diverging Bar Chart",
|
| 5 |
+
"chart_name": "horizontal_diverging_bar_chart_12",
|
| 6 |
+
"chart_for": "comparison",
|
| 7 |
+
"is_composite": false,
|
| 8 |
+
"required_fields": ["x", "y", "group"],
|
| 9 |
+
"required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
|
| 10 |
+
"required_fields_range": [[2, 20], [0, "inf"], [2, 2]],
|
| 11 |
+
"required_fields_icons": [],
|
| 12 |
+
"required_other_icons": [],
|
| 13 |
+
"required_fields_colors": ["group"],
|
| 14 |
+
"required_other_colors": ["primary"],
|
| 15 |
+
"supported_effects": ["shadow", "radius_corner", "gradient", "stroke", "spacing"],
|
| 16 |
+
"min_height": 400,
|
| 17 |
+
"min_width": 400,
|
| 18 |
+
"background": "no",
|
| 19 |
+
"icon_mark": "none",
|
| 20 |
+
"icon_label": "none",
|
| 21 |
+
"has_x_axis": "no",
|
| 22 |
+
"has_y_axis": "no"
|
| 23 |
+
}
|
| 24 |
+
REQUIREMENTS_END
|
| 25 |
+
*/
|
| 26 |
+
|
| 27 |
+
function makeChart(containerSelector, data) {
|
| 28 |
+
const jsonData = data;
|
| 29 |
+
const chartData = (jsonData.data && jsonData.data.data) || [];
|
| 30 |
+
const variables = jsonData.variables || {};
|
| 31 |
+
const columns = (jsonData.data && jsonData.data.columns) || [];
|
| 32 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 33 |
+
|
| 34 |
+
d3.select(containerSelector).html("");
|
| 35 |
+
|
| 36 |
+
const width = variables.width || 1024;
|
| 37 |
+
const height = variables.height || 1536;
|
| 38 |
+
const svg = d3.select(containerSelector)
|
| 39 |
+
.append("svg")
|
| 40 |
+
.attr("width", "100%")
|
| 41 |
+
.attr("height", height)
|
| 42 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 43 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 44 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 45 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 46 |
+
|
| 47 |
+
const defs = svg.append("defs");
|
| 48 |
+
defs.append("linearGradient")
|
| 49 |
+
.attr("id", "hd12-bg")
|
| 50 |
+
.attr("x1", "0%")
|
| 51 |
+
.attr("y1", "0%")
|
| 52 |
+
.attr("x2", "0%")
|
| 53 |
+
.attr("y2", "100%")
|
| 54 |
+
.call(g => {
|
| 55 |
+
g.append("stop").attr("offset", "0%").attr("stop-color", "#fbfff8");
|
| 56 |
+
g.append("stop").attr("offset", "62%").attr("stop-color", "#ffffff");
|
| 57 |
+
g.append("stop").attr("offset", "100%").attr("stop-color", "#f4f7ef");
|
| 58 |
+
});
|
| 59 |
+
defs.append("linearGradient")
|
| 60 |
+
.attr("id", "hd12-left-grad")
|
| 61 |
+
.attr("x1", "0%")
|
| 62 |
+
.attr("y1", "0%")
|
| 63 |
+
.attr("x2", "100%")
|
| 64 |
+
.attr("y2", "0%")
|
| 65 |
+
.call(g => {
|
| 66 |
+
g.append("stop").attr("offset", "0%").attr("stop-color", "#7fbd2f");
|
| 67 |
+
g.append("stop").attr("offset", "100%").attr("stop-color", "#2d7f20");
|
| 68 |
+
});
|
| 69 |
+
defs.append("linearGradient")
|
| 70 |
+
.attr("id", "hd12-right-grad")
|
| 71 |
+
.attr("x1", "0%")
|
| 72 |
+
.attr("y1", "0%")
|
| 73 |
+
.attr("x2", "100%")
|
| 74 |
+
.attr("y2", "0%")
|
| 75 |
+
.call(g => {
|
| 76 |
+
g.append("stop").attr("offset", "0%").attr("stop-color", "#0f74d7");
|
| 77 |
+
g.append("stop").attr("offset", "100%").attr("stop-color", "#074aa9");
|
| 78 |
+
});
|
| 79 |
+
const shadow = defs.append("filter")
|
| 80 |
+
.attr("id", "hd12-soft-shadow")
|
| 81 |
+
.attr("x", "-20%")
|
| 82 |
+
.attr("y", "-20%")
|
| 83 |
+
.attr("width", "140%")
|
| 84 |
+
.attr("height", "140%");
|
| 85 |
+
shadow.append("feDropShadow")
|
| 86 |
+
.attr("dx", 0)
|
| 87 |
+
.attr("dy", 8)
|
| 88 |
+
.attr("stdDeviation", 10)
|
| 89 |
+
.attr("flood-color", "#24420e")
|
| 90 |
+
.attr("flood-opacity", 0.11);
|
| 91 |
+
|
| 92 |
+
svg.append("rect")
|
| 93 |
+
.attr("width", width)
|
| 94 |
+
.attr("height", height)
|
| 95 |
+
.attr("fill", "url(#hd12-bg)");
|
| 96 |
+
|
| 97 |
+
const channel = (name, fallbackIndex) => {
|
| 98 |
+
try {
|
| 99 |
+
const resolved = chartUtils.schema.channel(jsonData, name);
|
| 100 |
+
if (resolved && resolved.key) return resolved;
|
| 101 |
+
} catch (e) {}
|
| 102 |
+
const col = columns[fallbackIndex] || {};
|
| 103 |
+
return { key: col.name, unit: col.unit || "" };
|
| 104 |
+
};
|
| 105 |
+
const dimensionChannel = channel("x", 0);
|
| 106 |
+
const valueChannel = channel("y", 1);
|
| 107 |
+
const groupChannel = channel("group", 2);
|
| 108 |
+
const dimensionField = dimensionChannel.key;
|
| 109 |
+
const valueField = valueChannel.key;
|
| 110 |
+
const groupField = groupChannel.key;
|
| 111 |
+
const valueUnit = valueChannel.unit && valueChannel.unit !== "none" ? valueChannel.unit : "";
|
| 112 |
+
|
| 113 |
+
const groups = Array.from(new Set(chartData.map(d => d[groupField]))).slice(0, 2);
|
| 114 |
+
const leftGroup = groups[0] || "Group A";
|
| 115 |
+
const rightGroup = groups[1] || "Group B";
|
| 116 |
+
const dimensionsInInput = Array.from(new Set(chartData.map(d => d[dimensionField])));
|
| 117 |
+
const temporalNames = ["year", "date", "month", "season", "semester", "quarter", "time"];
|
| 118 |
+
const dimensionName = String(dimensionField || "").toLowerCase();
|
| 119 |
+
const dimensionColumn = columns.find(c => c.name === dimensionField) || {};
|
| 120 |
+
const isTemporal = temporalNames.some(token => dimensionName.includes(token)) ||
|
| 121 |
+
String(dimensionColumn.data_type || "").toLowerCase().includes("temporal");
|
| 122 |
+
const monthIndex = {
|
| 123 |
+
jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3, apr: 4, april: 4,
|
| 124 |
+
may: 5, jun: 6, june: 6, jul: 7, july: 7, aug: 8, august: 8, sep: 9,
|
| 125 |
+
sept: 9, september: 9, oct: 10, october: 10, nov: 11, november: 11,
|
| 126 |
+
dec: 12, december: 12
|
| 127 |
+
};
|
| 128 |
+
const temporalKey = (value) => {
|
| 129 |
+
if (typeof value === "number") return value;
|
| 130 |
+
const raw = String(value || "").trim();
|
| 131 |
+
const numeric = Number(raw.replace(/[^0-9.-]/g, ""));
|
| 132 |
+
if (!Number.isNaN(numeric) && raw.match(/[0-9]/)) return numeric;
|
| 133 |
+
return monthIndex[raw.toLowerCase()] || raw.toLowerCase();
|
| 134 |
+
};
|
| 135 |
+
const dimensions = isTemporal
|
| 136 |
+
? dimensionsInInput.slice().sort((a, b) => d3.ascending(temporalKey(a), temporalKey(b)))
|
| 137 |
+
: dimensionsInInput.slice();
|
| 138 |
+
|
| 139 |
+
const rows = dimensions.map(dimension => {
|
| 140 |
+
const left = chartData.find(d => d[dimensionField] === dimension && d[groupField] === leftGroup);
|
| 141 |
+
const right = chartData.find(d => d[dimensionField] === dimension && d[groupField] === rightGroup);
|
| 142 |
+
return {
|
| 143 |
+
dimension,
|
| 144 |
+
left,
|
| 145 |
+
right,
|
| 146 |
+
leftValue: left ? Math.abs(+left[valueField]) || 0 : 0,
|
| 147 |
+
rightValue: right ? Math.abs(+right[valueField]) || 0 : 0
|
| 148 |
+
};
|
| 149 |
+
});
|
| 150 |
+
const leftMax = d3.max(rows, d => d.leftValue) || 1;
|
| 151 |
+
const rightMax = d3.max(rows, d => d.rightValue) || 1;
|
| 152 |
+
|
| 153 |
+
const getGroupColor = (group, index, fallback) => {
|
| 154 |
+
try {
|
| 155 |
+
return colorResolver.field(group, index, { fallbackKey: "primary" }).value || fallback;
|
| 156 |
+
} catch (e) {
|
| 157 |
+
return fallback;
|
| 158 |
+
}
|
| 159 |
+
};
|
| 160 |
+
const leftColor = getGroupColor(leftGroup, 0, "#4e9a1d");
|
| 161 |
+
const rightColor = getGroupColor(rightGroup, 1, "#0b63c7");
|
| 162 |
+
const titleDark = "#082746";
|
| 163 |
+
const greenDark = "#185d20";
|
| 164 |
+
const mutedText = "#3f4a48";
|
| 165 |
+
|
| 166 |
+
const formatValue = (value) => {
|
| 167 |
+
if (!Number.isFinite(value)) return "";
|
| 168 |
+
let text;
|
| 169 |
+
if (valueUnit === "$") {
|
| 170 |
+
const absValue = Math.abs(value);
|
| 171 |
+
if (absValue >= 1000000) {
|
| 172 |
+
text = `${(value / 1000000).toFixed(value % 1000000 === 0 ? 0 : 1)}M`;
|
| 173 |
+
} else if (absValue >= 1000) {
|
| 174 |
+
text = `${(value / 1000).toFixed(value % 1000 === 0 ? 0 : 1)}k`;
|
| 175 |
+
} else {
|
| 176 |
+
text = Number.isInteger(value) ? String(value) : value.toFixed(1).replace(/\.0$/, "");
|
| 177 |
+
}
|
| 178 |
+
return `$${text}`;
|
| 179 |
+
}
|
| 180 |
+
if (valueUnit === "%") {
|
| 181 |
+
text = Math.abs(value % 1) > 0.001 ? value.toFixed(1) : value.toFixed(0);
|
| 182 |
+
return `${text}%`;
|
| 183 |
+
}
|
| 184 |
+
if (valueUnit.toLowerCase() === "ppm") {
|
| 185 |
+
text = value < 0.1 ? value.toFixed(3).replace(/0+$/, "").replace(/\.$/, "") : value.toFixed(2);
|
| 186 |
+
return `${text} ppm`;
|
| 187 |
+
}
|
| 188 |
+
if (Number.isInteger(value)) {
|
| 189 |
+
text = String(value);
|
| 190 |
+
} else if (Math.abs(value) < 1) {
|
| 191 |
+
text = value.toFixed(3).replace(/0+$/, "").replace(/\.$/, "");
|
| 192 |
+
} else {
|
| 193 |
+
text = value.toFixed(1).replace(/\.0$/, "");
|
| 194 |
+
}
|
| 195 |
+
return valueUnit ? `${text}${valueUnit}` : text;
|
| 196 |
+
};
|
| 197 |
+
|
| 198 |
+
const wrapWords = (text, maxChars, maxLines) => {
|
| 199 |
+
const words = String(text || "").split(/\s+/).filter(Boolean);
|
| 200 |
+
const lines = [];
|
| 201 |
+
let line = "";
|
| 202 |
+
words.forEach(word => {
|
| 203 |
+
const next = line ? `${line} ${word}` : word;
|
| 204 |
+
if (next.length > maxChars && line) {
|
| 205 |
+
lines.push(line);
|
| 206 |
+
line = word;
|
| 207 |
+
} else {
|
| 208 |
+
line = next;
|
| 209 |
+
}
|
| 210 |
+
});
|
| 211 |
+
if (line) lines.push(line);
|
| 212 |
+
if (maxLines && lines.length > maxLines) {
|
| 213 |
+
const clipped = lines.slice(0, maxLines);
|
| 214 |
+
clipped[maxLines - 1] = clipped[maxLines - 1].replace(/\s+\S+$/, "");
|
| 215 |
+
return clipped;
|
| 216 |
+
}
|
| 217 |
+
return lines;
|
| 218 |
+
};
|
| 219 |
+
|
| 220 |
+
const titleSource = (jsonData.titles && jsonData.titles.main_title) ||
|
| 221 |
+
(jsonData.metadata && jsonData.metadata.title) || "Diverging Comparison";
|
| 222 |
+
const subtitleSource = (jsonData.titles && jsonData.titles.sub_title) ||
|
| 223 |
+
(jsonData.metadata && jsonData.metadata.description) || "";
|
| 224 |
+
const hasSplit = titleSource.toLowerCase().indexOf(" has ");
|
| 225 |
+
const kicker = hasSplit > 0 ? titleSource.slice(0, hasSplit) : wrapWords(titleSource, 28, 1)[0];
|
| 226 |
+
const displayTitle = hasSplit > 0 ? titleSource.slice(hasSplit + 1) : titleSource.replace(kicker, "").trim() || titleSource;
|
| 227 |
+
const titleLines = wrapWords(displayTitle, 22, 2);
|
| 228 |
+
const subtitleLines = wrapWords(subtitleSource, 52, 4);
|
| 229 |
+
|
| 230 |
+
const centerX = width / 2;
|
| 231 |
+
const page = svg.append("g").attr("class", "hd12-page");
|
| 232 |
+
|
| 233 |
+
page.append("rect")
|
| 234 |
+
.attr("x", 194)
|
| 235 |
+
.attr("y", 42)
|
| 236 |
+
.attr("width", 636)
|
| 237 |
+
.attr("height", 50)
|
| 238 |
+
.attr("rx", 25)
|
| 239 |
+
.attr("fill", "#0c6a35");
|
| 240 |
+
page.append("text")
|
| 241 |
+
.attr("x", centerX)
|
| 242 |
+
.attr("y", 76)
|
| 243 |
+
.attr("text-anchor", "middle")
|
| 244 |
+
.attr("font-family", "Arial, sans-serif")
|
| 245 |
+
.attr("font-size", 24)
|
| 246 |
+
.attr("font-weight", 800)
|
| 247 |
+
.attr("letter-spacing", 3)
|
| 248 |
+
.attr("fill", "#ffffff")
|
| 249 |
+
.text(String(kicker || "").toUpperCase());
|
| 250 |
+
|
| 251 |
+
const titleY = 142;
|
| 252 |
+
titleLines.forEach((line, i) => {
|
| 253 |
+
page.append("text")
|
| 254 |
+
.attr("x", 370)
|
| 255 |
+
.attr("y", titleY + i * 70)
|
| 256 |
+
.attr("text-anchor", "middle")
|
| 257 |
+
.attr("font-family", "Georgia, 'Times New Roman', serif")
|
| 258 |
+
.attr("font-size", titleLines.length > 1 ? 64 : 72)
|
| 259 |
+
.attr("font-weight", 800)
|
| 260 |
+
.attr("fill", i === 0 ? titleDark : greenDark)
|
| 261 |
+
.text(line);
|
| 262 |
+
});
|
| 263 |
+
|
| 264 |
+
const dividerY = titleY + titleLines.length * 70 + 22;
|
| 265 |
+
page.append("line").attr("x1", 282).attr("x2", 470).attr("y1", dividerY).attr("y2", dividerY)
|
| 266 |
+
.attr("stroke", "#6ea448").attr("stroke-width", 2);
|
| 267 |
+
page.append("line").attr("x1", 554).attr("x2", 742).attr("y1", dividerY).attr("y2", dividerY)
|
| 268 |
+
.attr("stroke", "#6ea448").attr("stroke-width", 2);
|
| 269 |
+
page.append("circle").attr("cx", centerX).attr("cy", dividerY).attr("r", 10).attr("fill", "#5d9d35").attr("opacity", 0.9);
|
| 270 |
+
|
| 271 |
+
const subtitleY = dividerY + 52;
|
| 272 |
+
subtitleLines.forEach((line, i) => {
|
| 273 |
+
page.append("text")
|
| 274 |
+
.attr("x", centerX)
|
| 275 |
+
.attr("y", subtitleY + i * 36)
|
| 276 |
+
.attr("text-anchor", "middle")
|
| 277 |
+
.attr("font-family", "Arial, sans-serif")
|
| 278 |
+
.attr("font-size", 23)
|
| 279 |
+
.attr("font-weight", 500)
|
| 280 |
+
.attr("fill", mutedText)
|
| 281 |
+
.text(line);
|
| 282 |
+
});
|
| 283 |
+
|
| 284 |
+
const headerSlot = page.append("g")
|
| 285 |
+
.attr("class", "reserved-asset-slot hd12-header-topic-art-slot")
|
| 286 |
+
.attr("data-asset-slot", "header-topic-art")
|
| 287 |
+
.attr("data-reserved-slot", "header_topic_art")
|
| 288 |
+
.attr("data-slot-policy", "image2_asset")
|
| 289 |
+
.attr("data-asset-source-policy", "image2-generated")
|
| 290 |
+
.attr("data-anchor", "upper-right-header-safe-area")
|
| 291 |
+
.attr("data-bbox", "842 266 126 126")
|
| 292 |
+
.attr("data-collision-rule", "stay-right-of-title-and-above-legend-table;shrink-or-hide-for-long-title")
|
| 293 |
+
.attr("data-mask", "rounded-rect")
|
| 294 |
+
.attr("data-no-embedded-raster", "true")
|
| 295 |
+
.attr("data-no-copied-reference-art", "true")
|
| 296 |
+
.attr("transform", "translate(842,266)");
|
| 297 |
+
headerSlot.append("rect")
|
| 298 |
+
.attr("width", 126)
|
| 299 |
+
.attr("height", 126)
|
| 300 |
+
.attr("rx", 32)
|
| 301 |
+
.attr("fill", "#eaf5df")
|
| 302 |
+
.attr("stroke", "#9ac56d")
|
| 303 |
+
.attr("stroke-width", 2)
|
| 304 |
+
.attr("stroke-dasharray", "8 8")
|
| 305 |
+
.attr("opacity", 0.62);
|
| 306 |
+
headerSlot.append("circle").attr("cx", 45).attr("cy", 68).attr("r", 30).attr("fill", "#77aa43").attr("opacity", 0.18);
|
| 307 |
+
headerSlot.append("rect").attr("x", 78).attr("y", 42).attr("width", 30).attr("height", 56).attr("rx", 12).attr("fill", "#245f2b").attr("opacity", 0.12);
|
| 308 |
+
|
| 309 |
+
const legendX = 104;
|
| 310 |
+
const legendY = 470;
|
| 311 |
+
const legendW = 816;
|
| 312 |
+
const legendH = 86;
|
| 313 |
+
page.append("rect")
|
| 314 |
+
.attr("x", legendX)
|
| 315 |
+
.attr("y", legendY)
|
| 316 |
+
.attr("width", legendW)
|
| 317 |
+
.attr("height", legendH)
|
| 318 |
+
.attr("rx", 24)
|
| 319 |
+
.attr("fill", "#ffffff")
|
| 320 |
+
.attr("stroke", "#d9e4d5")
|
| 321 |
+
.attr("stroke-width", 1.4)
|
| 322 |
+
.attr("filter", "url(#hd12-soft-shadow)");
|
| 323 |
+
page.append("line")
|
| 324 |
+
.attr("x1", legendX + legendW / 2)
|
| 325 |
+
.attr("x2", legendX + legendW / 2)
|
| 326 |
+
.attr("y1", legendY + 18)
|
| 327 |
+
.attr("y2", legendY + legendH - 18)
|
| 328 |
+
.attr("stroke", "#c8d3d2")
|
| 329 |
+
.attr("stroke-width", 1.5);
|
| 330 |
+
|
| 331 |
+
const drawLegendItem = (x, group, color, slotName) => {
|
| 332 |
+
const item = page.append("g").attr("transform", `translate(${x},${legendY + legendH / 2})`);
|
| 333 |
+
const slot = item.append("g")
|
| 334 |
+
.attr("class", "reserved-asset-slot hd12-legend-group-icon-slot")
|
| 335 |
+
.attr("data-asset-slot", "legend-group-icon")
|
| 336 |
+
.attr("data-reserved-slot", slotName)
|
| 337 |
+
.attr("data-slot-policy", "neutral_placeholder")
|
| 338 |
+
.attr("data-asset-source-policy", "neutral-placeholder")
|
| 339 |
+
.attr("data-anchor", `legend-${slotName}`)
|
| 340 |
+
.attr("data-bbox", `${x - 28} ${legendY + 17} 56 52`)
|
| 341 |
+
.attr("data-collision-rule", "stay-inside-legend-card-and-avoid-label")
|
| 342 |
+
.attr("data-no-embedded-raster", "true")
|
| 343 |
+
.attr("data-no-copied-reference-art", "true");
|
| 344 |
+
slot.append("circle").attr("r", 25).attr("fill", color).attr("opacity", 0.18);
|
| 345 |
+
slot.append("circle").attr("r", 18).attr("fill", color).attr("opacity", 0.9);
|
| 346 |
+
item.append("text")
|
| 347 |
+
.attr("x", 46)
|
| 348 |
+
.attr("y", -4)
|
| 349 |
+
.attr("font-family", "Arial, sans-serif")
|
| 350 |
+
.attr("font-size", 22)
|
| 351 |
+
.attr("font-weight", 800)
|
| 352 |
+
.attr("fill", titleDark)
|
| 353 |
+
.text(group);
|
| 354 |
+
item.append("text")
|
| 355 |
+
.attr("x", 46)
|
| 356 |
+
.attr("y", 25)
|
| 357 |
+
.attr("font-family", "Arial, sans-serif")
|
| 358 |
+
.attr("font-size", 17)
|
| 359 |
+
.attr("fill", "#4a5552")
|
| 360 |
+
.text(valueUnit ? `(${valueUnit})` : "value");
|
| 361 |
+
};
|
| 362 |
+
drawLegendItem(148, leftGroup, leftColor, "legend_left_group_icon");
|
| 363 |
+
drawLegendItem(562, rightGroup, rightColor, "legend_right_group_icon");
|
| 364 |
+
|
| 365 |
+
const tableX = 64;
|
| 366 |
+
const tableY = 625;
|
| 367 |
+
const tableW = 896;
|
| 368 |
+
const tableH = Math.min(850, height - tableY - 92);
|
| 369 |
+
const headerH = 60;
|
| 370 |
+
const rowH = (tableH - headerH) / Math.max(rows.length, 1);
|
| 371 |
+
const dimW = 270;
|
| 372 |
+
const leftW = 345;
|
| 373 |
+
const rightW = tableW - dimW - leftW;
|
| 374 |
+
const leftX = tableX + dimW;
|
| 375 |
+
const rightX = leftX + leftW;
|
| 376 |
+
|
| 377 |
+
page.append("rect")
|
| 378 |
+
.attr("x", tableX)
|
| 379 |
+
.attr("y", tableY)
|
| 380 |
+
.attr("width", tableW)
|
| 381 |
+
.attr("height", tableH)
|
| 382 |
+
.attr("rx", 20)
|
| 383 |
+
.attr("fill", "#ffffff")
|
| 384 |
+
.attr("stroke", "#c8d8c8")
|
| 385 |
+
.attr("stroke-width", 1.4)
|
| 386 |
+
.attr("filter", "url(#hd12-soft-shadow)");
|
| 387 |
+
page.append("clipPath")
|
| 388 |
+
.attr("id", "hd12-table-clip")
|
| 389 |
+
.append("rect")
|
| 390 |
+
.attr("x", tableX)
|
| 391 |
+
.attr("y", tableY)
|
| 392 |
+
.attr("width", tableW)
|
| 393 |
+
.attr("height", tableH)
|
| 394 |
+
.attr("rx", 20);
|
| 395 |
+
const table = page.append("g").attr("clip-path", "url(#hd12-table-clip)");
|
| 396 |
+
table.append("rect").attr("x", tableX).attr("y", tableY).attr("width", dimW).attr("height", headerH).attr("fill", "#06482f");
|
| 397 |
+
table.append("rect").attr("x", leftX).attr("y", tableY).attr("width", leftW).attr("height", headerH).attr("fill", "#0b5c30");
|
| 398 |
+
table.append("rect").attr("x", rightX).attr("y", tableY).attr("width", rightW).attr("height", headerH).attr("fill", "#075f9f");
|
| 399 |
+
|
| 400 |
+
const headerText = (x, y, text, anchor) => {
|
| 401 |
+
table.append("text")
|
| 402 |
+
.attr("x", x)
|
| 403 |
+
.attr("y", y)
|
| 404 |
+
.attr("text-anchor", anchor || "middle")
|
| 405 |
+
.attr("font-family", "Arial, sans-serif")
|
| 406 |
+
.attr("font-size", 19)
|
| 407 |
+
.attr("font-weight", 800)
|
| 408 |
+
.attr("letter-spacing", 1.2)
|
| 409 |
+
.attr("fill", "#ffffff")
|
| 410 |
+
.text(text);
|
| 411 |
+
};
|
| 412 |
+
const groupHeaderText = (x, group) => {
|
| 413 |
+
const groupLines = wrapWords(String(group).toUpperCase(), 22, 2);
|
| 414 |
+
groupLines.forEach((line, index) => {
|
| 415 |
+
table.append("text")
|
| 416 |
+
.attr("x", x)
|
| 417 |
+
.attr("y", tableY + 25 + index * 19)
|
| 418 |
+
.attr("text-anchor", "middle")
|
| 419 |
+
.attr("font-family", "Arial, sans-serif")
|
| 420 |
+
.attr("font-size", 17)
|
| 421 |
+
.attr("font-weight", 800)
|
| 422 |
+
.attr("letter-spacing", 0.8)
|
| 423 |
+
.attr("fill", "#ffffff")
|
| 424 |
+
.text(line);
|
| 425 |
+
});
|
| 426 |
+
if (valueUnit) {
|
| 427 |
+
table.append("text")
|
| 428 |
+
.attr("x", x)
|
| 429 |
+
.attr("y", tableY + 52)
|
| 430 |
+
.attr("text-anchor", "middle")
|
| 431 |
+
.attr("font-family", "Arial, sans-serif")
|
| 432 |
+
.attr("font-size", 14)
|
| 433 |
+
.attr("font-weight", 700)
|
| 434 |
+
.attr("fill", "#dbeee0")
|
| 435 |
+
.text(`(${valueUnit})`);
|
| 436 |
+
}
|
| 437 |
+
};
|
| 438 |
+
headerText(tableX + dimW / 2, tableY + 38, String(dimensionField || "State").toUpperCase());
|
| 439 |
+
groupHeaderText(leftX + leftW / 2, leftGroup);
|
| 440 |
+
groupHeaderText(rightX + rightW / 2, rightGroup);
|
| 441 |
+
|
| 442 |
+
[leftX, rightX].forEach(x => {
|
| 443 |
+
table.append("line")
|
| 444 |
+
.attr("x1", x)
|
| 445 |
+
.attr("x2", x)
|
| 446 |
+
.attr("y1", tableY)
|
| 447 |
+
.attr("y2", tableY + tableH)
|
| 448 |
+
.attr("stroke", "#d9e2da")
|
| 449 |
+
.attr("stroke-width", 1.2);
|
| 450 |
+
});
|
| 451 |
+
|
| 452 |
+
const barH = Math.max(16, Math.min(28, rowH * 0.34));
|
| 453 |
+
const leftBarX = leftX + 26;
|
| 454 |
+
const leftBarMaxW = leftW - 165;
|
| 455 |
+
const rightBarX = rightX + 25;
|
| 456 |
+
const rightBarMaxW = rightW - 220;
|
| 457 |
+
const labelFont = rowH < 58 ? 18 : 22;
|
| 458 |
+
const valueFont = rowH < 58 ? 16 : 21;
|
| 459 |
+
|
| 460 |
+
rows.forEach((row, i) => {
|
| 461 |
+
const rowY = tableY + headerH + i * rowH;
|
| 462 |
+
const rowCenter = rowY + rowH / 2;
|
| 463 |
+
table.append("rect")
|
| 464 |
+
.attr("x", tableX)
|
| 465 |
+
.attr("y", rowY)
|
| 466 |
+
.attr("width", tableW)
|
| 467 |
+
.attr("height", rowH)
|
| 468 |
+
.attr("fill", i % 2 === 0 ? "#fbfdf8" : "#ffffff");
|
| 469 |
+
table.append("line")
|
| 470 |
+
.attr("x1", tableX)
|
| 471 |
+
.attr("x2", tableX + tableW)
|
| 472 |
+
.attr("y1", rowY)
|
| 473 |
+
.attr("y2", rowY)
|
| 474 |
+
.attr("stroke", "#dfe7db")
|
| 475 |
+
.attr("stroke-width", 1);
|
| 476 |
+
|
| 477 |
+
const slot = table.append("g")
|
| 478 |
+
.attr("class", "reserved-asset-slot hd12-row-topic-icon-slot")
|
| 479 |
+
.attr("data-asset-slot", "row-topic-icon")
|
| 480 |
+
.attr("data-reserved-slot", `row_topic_icon_${i + 1}`)
|
| 481 |
+
.attr("data-slot-policy", "image2_asset")
|
| 482 |
+
.attr("data-asset-source-policy", "image2-generated")
|
| 483 |
+
.attr("data-anchor", `row-${i + 1}-centerline`)
|
| 484 |
+
.attr("data-bbox", `${tableX + 30} ${rowCenter - 27} 54 54`)
|
| 485 |
+
.attr("data-collision-rule", "stay-inside-dimension-column-and-avoid-label-text")
|
| 486 |
+
.attr("data-mask", "circle")
|
| 487 |
+
.attr("data-no-embedded-raster", "true")
|
| 488 |
+
.attr("data-no-copied-reference-art", "true")
|
| 489 |
+
.attr("transform", `translate(${tableX + 57},${rowCenter})`);
|
| 490 |
+
slot.append("circle").attr("r", 27).attr("fill", i % 2 === 0 ? "#e3f2cd" : "#e5f4f8");
|
| 491 |
+
slot.append("circle").attr("r", 16).attr("fill", i % 2 === 0 ? "#7ab546" : "#5ba4bd").attr("opacity", 0.18);
|
| 492 |
+
|
| 493 |
+
const dimLabel = String(row.dimension);
|
| 494 |
+
const dimLines = wrapWords(dimLabel, rowH < 58 ? 14 : 16, 2);
|
| 495 |
+
dimLines.forEach((line, lineIndex) => {
|
| 496 |
+
table.append("text")
|
| 497 |
+
.attr("x", tableX + 104)
|
| 498 |
+
.attr("y", rowCenter + (lineIndex - (dimLines.length - 1) / 2) * (labelFont + 2) + 7)
|
| 499 |
+
.attr("font-family", "Arial, sans-serif")
|
| 500 |
+
.attr("font-size", labelFont)
|
| 501 |
+
.attr("font-weight", 800)
|
| 502 |
+
.attr("fill", titleDark)
|
| 503 |
+
.text(line);
|
| 504 |
+
});
|
| 505 |
+
|
| 506 |
+
const leftWValue = Math.max(10, (row.leftValue / leftMax) * leftBarMaxW);
|
| 507 |
+
const rightWValue = Math.max(10, (row.rightValue / rightMax) * rightBarMaxW);
|
| 508 |
+
const leftY = rowCenter - barH / 2;
|
| 509 |
+
const rx = Math.min(12, barH / 2);
|
| 510 |
+
|
| 511 |
+
table.append("rect")
|
| 512 |
+
.attr("x", leftBarX)
|
| 513 |
+
.attr("y", leftY)
|
| 514 |
+
.attr("width", leftWValue)
|
| 515 |
+
.attr("height", barH)
|
| 516 |
+
.attr("rx", rx)
|
| 517 |
+
.attr("ry", rx)
|
| 518 |
+
.attr("fill", "url(#hd12-left-grad)");
|
| 519 |
+
table.append("text")
|
| 520 |
+
.attr("x", Math.min(leftBarX + leftWValue + 16, leftX + leftW - 120))
|
| 521 |
+
.attr("y", rowCenter + 7)
|
| 522 |
+
.attr("font-family", "Arial, sans-serif")
|
| 523 |
+
.attr("font-size", valueFont)
|
| 524 |
+
.attr("font-weight", 800)
|
| 525 |
+
.attr("fill", "#2c7a13")
|
| 526 |
+
.text(formatValue(row.leftValue));
|
| 527 |
+
|
| 528 |
+
table.append("rect")
|
| 529 |
+
.attr("x", rightBarX)
|
| 530 |
+
.attr("y", leftY)
|
| 531 |
+
.attr("width", rightWValue)
|
| 532 |
+
.attr("height", barH)
|
| 533 |
+
.attr("rx", rx)
|
| 534 |
+
.attr("ry", rx)
|
| 535 |
+
.attr("fill", "url(#hd12-right-grad)");
|
| 536 |
+
table.append("text")
|
| 537 |
+
.attr("x", Math.min(rightBarX + rightWValue + 18, tableX + tableW - 155))
|
| 538 |
+
.attr("y", rowCenter + 7)
|
| 539 |
+
.attr("font-family", "Arial, sans-serif")
|
| 540 |
+
.attr("font-size", valueFont)
|
| 541 |
+
.attr("font-weight", 800)
|
| 542 |
+
.attr("fill", "#075fbd")
|
| 543 |
+
.text(formatValue(row.rightValue));
|
| 544 |
+
|
| 545 |
+
if (row.leftValue === leftMax || row.rightValue === rightMax) {
|
| 546 |
+
const isLeft = row.leftValue === leftMax;
|
| 547 |
+
const badgeX = isLeft ? leftX + leftW - 8 : tableX + tableW - 34;
|
| 548 |
+
const badgeColor = isLeft ? "#0d7140" : "#0a67c7";
|
| 549 |
+
const badge = table.append("g")
|
| 550 |
+
.attr("class", "hd12-max-value-badge")
|
| 551 |
+
.attr("data-annotation", "max-value")
|
| 552 |
+
.attr("data-group", isLeft ? leftGroup : rightGroup)
|
| 553 |
+
.attr("data-dimension", row.dimension)
|
| 554 |
+
.attr("data-anchor", "row-centerline-value-end")
|
| 555 |
+
.attr("data-collision-rule", "clamp-inside-table-edge-and-avoid-value-label")
|
| 556 |
+
.attr("transform", `translate(${badgeX},${rowCenter})`);
|
| 557 |
+
badge.append("rect")
|
| 558 |
+
.attr("x", -14)
|
| 559 |
+
.attr("y", -14)
|
| 560 |
+
.attr("width", 28)
|
| 561 |
+
.attr("height", 28)
|
| 562 |
+
.attr("rx", 6)
|
| 563 |
+
.attr("fill", badgeColor);
|
| 564 |
+
badge.append("text")
|
| 565 |
+
.attr("x", 0)
|
| 566 |
+
.attr("y", 7)
|
| 567 |
+
.attr("text-anchor", "middle")
|
| 568 |
+
.attr("font-family", "Arial, sans-serif")
|
| 569 |
+
.attr("font-size", 18)
|
| 570 |
+
.attr("font-weight", 900)
|
| 571 |
+
.attr("fill", "#ffffff")
|
| 572 |
+
.attr("data-chart-native-marker", "max")
|
| 573 |
+
.text("★");
|
| 574 |
+
}
|
| 575 |
+
});
|
| 576 |
+
|
| 577 |
+
table.append("line")
|
| 578 |
+
.attr("x1", tableX)
|
| 579 |
+
.attr("x2", tableX + tableW)
|
| 580 |
+
.attr("y1", tableY + tableH)
|
| 581 |
+
.attr("y2", tableY + tableH)
|
| 582 |
+
.attr("stroke", "#c8d8c8")
|
| 583 |
+
.attr("stroke-width", 1.4);
|
| 584 |
+
|
| 585 |
+
page.append("text")
|
| 586 |
+
.attr("x", centerX)
|
| 587 |
+
.attr("y", height - 42)
|
| 588 |
+
.attr("text-anchor", "middle")
|
| 589 |
+
.attr("font-family", "Arial, sans-serif")
|
| 590 |
+
.attr("font-size", 14)
|
| 591 |
+
.attr("fill", "#8b968d")
|
| 592 |
+
.attr("data-ignored-bottom-wrapper", "takeaway-source-footer-omitted")
|
| 593 |
+
.text("");
|
| 594 |
+
|
| 595 |
+
return svg.node();
|
| 596 |
+
}
|
modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_06.js
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Horizontal Group Bar Chart",
|
| 5 |
+
"chart_name": "horizontal_group_bar_chart_06",
|
| 6 |
+
"chart_for": "comparison",
|
| 7 |
+
"is_composite": false,
|
| 8 |
+
"required_fields": ["x", "y", "group"],
|
| 9 |
+
"required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
|
| 10 |
+
"required_fields_range": [[2, 20], [0, "inf"], [2, 5]],
|
| 11 |
+
"required_fields_icons": [],
|
| 12 |
+
"required_other_icons": [],
|
| 13 |
+
"required_fields_colors": ["group"],
|
| 14 |
+
"required_other_colors": ["primary"],
|
| 15 |
+
"supported_effects": ["shadow", "radius_corner", "gradient", "stroke", "spacing"],
|
| 16 |
+
"min_height": 400,
|
| 17 |
+
"min_width": 400,
|
| 18 |
+
"background": "no",
|
| 19 |
+
"icon_mark": "none",
|
| 20 |
+
"icon_label": "none",
|
| 21 |
+
"has_x_axis": "no",
|
| 22 |
+
"has_y_axis": "no"
|
| 23 |
+
}
|
| 24 |
+
REQUIREMENTS_END
|
| 25 |
+
*/
|
| 26 |
+
|
| 27 |
+
// 水平分组条形图实现 - 使用D3.js horizontal_split_bar_chart_06
|
| 28 |
+
function makeChart(containerSelector, data) {
|
| 29 |
+
// ---------- 1. 数据准备阶段 ----------
|
| 30 |
+
|
| 31 |
+
// 提取数据和配置
|
| 32 |
+
const jsonData = data; // 完整的JSON数据对象
|
| 33 |
+
const chartData = jsonData.data.data; // 实际数据点数组
|
| 34 |
+
const variables = jsonData.variables || {}; // 图表配置
|
| 35 |
+
const typography = jsonData.typography || { // 字体设置,如果不存在则使用默认值
|
| 36 |
+
title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
|
| 37 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 38 |
+
description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 39 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 40 |
+
};
|
| 41 |
+
const colors = jsonData.colors || { text_color: "#333333" }; // 颜色设置
|
| 42 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 43 |
+
const images = jsonData.images || { field: {}, other: {} }; // 图像设置
|
| 44 |
+
const dataColumns = chartUtils.schema.columns(jsonData); // 数据列定义
|
| 45 |
+
|
| 46 |
+
// 设置视觉效果变量的默认值
|
| 47 |
+
variables.has_rounded_corners = variables.has_rounded_corners || false;
|
| 48 |
+
|
| 49 |
+
// 清空容器
|
| 50 |
+
d3.select(containerSelector).html("");
|
| 51 |
+
|
| 52 |
+
// ---------- 2. 尺寸和布局设置 ----------
|
| 53 |
+
|
| 54 |
+
// 设置图表总尺寸
|
| 55 |
+
const width = variables.width || 800;
|
| 56 |
+
const height = variables.height || 600;
|
| 57 |
+
|
| 58 |
+
// 设置边距
|
| 59 |
+
const margin = {
|
| 60 |
+
top: 100, // 顶部留出标题空间
|
| 61 |
+
right: 30, // 右侧足够显示数值
|
| 62 |
+
bottom: 60, // 底部边距
|
| 63 |
+
left: 60 // 左侧初始空间,用于维度标签和图标
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
// ---------- 3. 提取字段名和单位 ----------
|
| 67 |
+
|
| 68 |
+
// 根据数据列顺序提取字段名
|
| 69 |
+
const dimensionField = chartUtils.schema.channel(jsonData, "x").key;
|
| 70 |
+
const valueField = chartUtils.schema.channel(jsonData, "y").key;
|
| 71 |
+
const groupField = chartUtils.schema.channel(jsonData, "group").key;
|
| 72 |
+
|
| 73 |
+
// 获取字段单位(如果存在)
|
| 74 |
+
let dimensionUnit = "";
|
| 75 |
+
let valueUnit = "";
|
| 76 |
+
let groupUnit = "";
|
| 77 |
+
|
| 78 |
+
if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
|
| 79 |
+
dimensionUnit = chartUtils.schema.channel(jsonData, "x").unit;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
|
| 83 |
+
valueUnit = chartUtils.schema.channel(jsonData, "y").unit;
|
| 84 |
+
}
|
| 85 |
+
if (chartUtils.schema.channel(jsonData, "group").unit !== "none") {
|
| 86 |
+
groupUnit = chartUtils.schema.channel(jsonData, "group").unit;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// ---------- 4. 数据处理 ----------
|
| 90 |
+
|
| 91 |
+
// 添加数值格式化函数
|
| 92 |
+
|
| 93 |
+
// 获取唯一维度值和分组值
|
| 94 |
+
const dimensions = [...new Set(chartData.map(d => d[dimensionField]))];
|
| 95 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 96 |
+
|
| 97 |
+
// 不对数据进行排序,使用原始顺序
|
| 98 |
+
|
| 99 |
+
// ---------- 5. 计算标签宽度 ----------
|
| 100 |
+
|
| 101 |
+
// 创建临时SVG来测量文本宽度
|
| 102 |
+
const tempSvg = d3.select(containerSelector)
|
| 103 |
+
.append("svg")
|
| 104 |
+
.attr("width", 0)
|
| 105 |
+
.attr("height", 0)
|
| 106 |
+
.style("visibility", "hidden");
|
| 107 |
+
|
| 108 |
+
// 初始填充值(会根据barHeight动态调整)
|
| 109 |
+
// const flagPadding = 5; // 不再需要图标填充
|
| 110 |
+
|
| 111 |
+
// 计算最大维度标签宽度
|
| 112 |
+
let maxLabelWidth = 0;
|
| 113 |
+
dimensions.forEach(dimension => {
|
| 114 |
+
// 格式化维度名称(附加单位,如果有)
|
| 115 |
+
const formattedDimension = dimensionUnit ?
|
| 116 |
+
`${dimension}${dimensionUnit}` :
|
| 117 |
+
`${dimension}`;
|
| 118 |
+
|
| 119 |
+
const textWidth = chartUtils.text.measure(tempSvg, formattedDimension, {
|
| 120 |
+
fontFamily: typography.label.font_family,
|
| 121 |
+
fontSize: typography.label.font_size,
|
| 122 |
+
fontWeight: typography.label.font_weight
|
| 123 |
+
}).width;
|
| 124 |
+
const totalWidth = textWidth; // 只考虑文本宽度
|
| 125 |
+
|
| 126 |
+
maxLabelWidth = Math.max(maxLabelWidth, totalWidth);
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
// ��算最大数值标签宽度
|
| 130 |
+
let maxValueWidth = 0;
|
| 131 |
+
chartData.forEach(d => {
|
| 132 |
+
const formattedValue = valueUnit ?
|
| 133 |
+
`${chartUtils.format.autoText(+d[valueField])}${valueUnit}` :
|
| 134 |
+
`${chartUtils.format.autoText(+d[valueField])}`;
|
| 135 |
+
|
| 136 |
+
const textWidth = chartUtils.text.measure(tempSvg, formattedValue, {
|
| 137 |
+
fontFamily: typography.annotation.font_family,
|
| 138 |
+
fontSize: typography.annotation.font_size,
|
| 139 |
+
fontWeight: typography.annotation.font_weight
|
| 140 |
+
}).width;
|
| 141 |
+
|
| 142 |
+
maxValueWidth = Math.max(maxValueWidth, textWidth);
|
| 143 |
+
});
|
| 144 |
+
// 删除临时SVG
|
| 145 |
+
tempSvg.remove();
|
| 146 |
+
|
| 147 |
+
// 根据标签宽度调整左边距(添加一些边距)
|
| 148 |
+
margin.left = Math.max(margin.left, maxLabelWidth + 20);
|
| 149 |
+
margin.right = Math.max(margin.right, maxValueWidth + 20);
|
| 150 |
+
|
| 151 |
+
// 计算内部绘图区域尺寸
|
| 152 |
+
const innerWidth = width - margin.left - margin.right;
|
| 153 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 154 |
+
|
| 155 |
+
// ---------- 6. 创建SVG容器 ----------
|
| 156 |
+
|
| 157 |
+
const svg = d3.select(containerSelector)
|
| 158 |
+
.append("svg")
|
| 159 |
+
.attr("width", "100%")
|
| 160 |
+
.attr("height", height)
|
| 161 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 162 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 163 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 164 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 165 |
+
|
| 166 |
+
// 添加defs用于视觉效果
|
| 167 |
+
const defs = svg.append("defs");
|
| 168 |
+
|
| 169 |
+
// 创建斜线纹理模式
|
| 170 |
+
const patternDensity = 6; // 固定斜线密度
|
| 171 |
+
const patternStrokeWidth = 1.5; // 固定斜线宽度
|
| 172 |
+
groups.forEach((group, i) => {
|
| 173 |
+
// 为每个组获取颜色
|
| 174 |
+
const groupColor = colorResolver.field(group, i, { palette: "category10" }).value;
|
| 175 |
+
|
| 176 |
+
// 创建斜线纹理模式
|
| 177 |
+
const patternId = `pattern-${group.replace(/\\s+/g, '-')}-${i}`; // 确保 ID 唯一
|
| 178 |
+
const pattern = defs.append("pattern")
|
| 179 |
+
.attr("id", patternId)
|
| 180 |
+
.attr("patternUnits", "userSpaceOnUse")
|
| 181 |
+
.attr("width", patternDensity) // 使用固定值
|
| 182 |
+
.attr("height", patternDensity) // 使用固定值
|
| 183 |
+
.attr("patternTransform", "rotate(45)");
|
| 184 |
+
|
| 185 |
+
// 添加背景矩形
|
| 186 |
+
pattern.append("rect")
|
| 187 |
+
.attr("width", patternDensity) // 使用固定值
|
| 188 |
+
.attr("height", patternDensity) // 使用固定值
|
| 189 |
+
.attr("fill", groupColor)
|
| 190 |
+
.attr("opacity", 0.8);
|
| 191 |
+
|
| 192 |
+
// 添加斜线
|
| 193 |
+
pattern.append("line")
|
| 194 |
+
.attr("x1", 0)
|
| 195 |
+
.attr("y1", 0)
|
| 196 |
+
.attr("x2", 0)
|
| 197 |
+
.attr("y2", patternDensity) // 使用固定值
|
| 198 |
+
.attr("stroke", "white")
|
| 199 |
+
.attr("stroke-width", patternStrokeWidth) // 使用固定值
|
| 200 |
+
.attr("opacity", 0.6);
|
| 201 |
+
});
|
| 202 |
+
|
| 203 |
+
// ---------- 7. 创建比例尺 ----------
|
| 204 |
+
|
| 205 |
+
// 计算条形的额外间距 - 使用固定值
|
| 206 |
+
const barPadding = 0.1; // 设置固定边距
|
| 207 |
+
|
| 208 |
+
// Y轴比例尺(用于维度)- 使用原始顺序
|
| 209 |
+
const yScale = d3.scaleBand()
|
| 210 |
+
.domain(dimensions)
|
| 211 |
+
.range([0, innerHeight])
|
| 212 |
+
.padding(barPadding);
|
| 213 |
+
|
| 214 |
+
// X轴比例尺(用于数值)
|
| 215 |
+
const xScale = d3.scaleLinear()
|
| 216 |
+
.domain([0, d3.max(chartData, d => +d[valueField])])
|
| 217 |
+
.range([0, innerWidth]);
|
| 218 |
+
|
| 219 |
+
// 颜色比例尺(用于组)
|
| 220 |
+
const colorScale = colorResolver.scale(groups, { palette: "category10" });
|
| 221 |
+
chartUtils.legend.centered(svg, groups, {
|
| 222 |
+
colorScale,
|
| 223 |
+
colorResolver,
|
| 224 |
+
colors,
|
| 225 |
+
}, width / 2, margin.top / 2, {
|
| 226 |
+
markerShape: "rect",
|
| 227 |
+
markerSize: 15,
|
| 228 |
+
labelGap: 5,
|
| 229 |
+
itemGap: legendPadding,
|
| 230 |
+
fontSize: typography.label.font_size,
|
| 231 |
+
fontFamily: typography.label.font_family,
|
| 232 |
+
fontWeight: typography.label.font_weight,
|
| 233 |
+
textColor: colorResolver.text({ fallback: "#333333" }).value,
|
| 234 |
+
markerRadius: variables.has_rounded_corners ? 2 : 0,
|
| 235 |
+
});
|
| 236 |
+
// ---------- 9. 创建主图表组 ----------
|
| 237 |
+
|
| 238 |
+
const g = svg.append("g")
|
| 239 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 240 |
+
|
| 241 |
+
// ---------- 12. 为每个维度绘制分组条形 ----------
|
| 242 |
+
|
| 243 |
+
dimensions.forEach(dimension => {
|
| 244 |
+
// 获取此维度下的所有数据点
|
| 245 |
+
const dimensionData = chartData.filter(d => d[dimensionField] === dimension);
|
| 246 |
+
|
| 247 |
+
if (dimensionData.length > 0) {
|
| 248 |
+
const barHeight = yScale.bandwidth();
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
const labelY = yScale(dimension) + barHeight / 2;
|
| 252 |
+
|
| 253 |
+
// 添加维度标签
|
| 254 |
+
g.append("text")
|
| 255 |
+
// .attr("x", flagX - 5) // 旧的基于图标位置的 X 坐标
|
| 256 |
+
.attr("x", -10) // 设置新的 X 坐标,例如在 Y 轴左侧 10px 处
|
| 257 |
+
.attr("y", labelY)
|
| 258 |
+
.attr("dy", "0.35em")
|
| 259 |
+
.attr("text-anchor", "end")
|
| 260 |
+
.style("font-family", typography.label.font_family)
|
| 261 |
+
.style("font-size", typography.label.font_size)
|
| 262 |
+
.style("font-weight", typography.label.font_weight)
|
| 263 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 264 |
+
.text(dimension);
|
| 265 |
+
|
| 266 |
+
// 计算每个组的条形高度
|
| 267 |
+
const groupBarHeight = barHeight / groups.length;
|
| 268 |
+
|
| 269 |
+
// 固定圆角半径
|
| 270 |
+
const cornerRadius = 3;
|
| 271 |
+
|
| 272 |
+
// 绘制每个组的条形
|
| 273 |
+
groups.forEach((group, groupIndex) => {
|
| 274 |
+
// 找到此维度和此组的数据点
|
| 275 |
+
const dataPoint = dimensionData.find(d => d[groupField] === group);
|
| 276 |
+
|
| 277 |
+
if (dataPoint) {
|
| 278 |
+
const value = +dataPoint[valueField];
|
| 279 |
+
const barWidth = xScale(value);
|
| 280 |
+
|
| 281 |
+
// 计算垂直位置 - 这里是关键修改,确保条形不重叠
|
| 282 |
+
const groupY = yScale(dimension) + (groupIndex * groupBarHeight);
|
| 283 |
+
|
| 284 |
+
// 绘制条形 - 使用 path 实现右侧圆角
|
| 285 |
+
let pathData;
|
| 286 |
+
// 仅在条形足够宽和高时应用圆角
|
| 287 |
+
if (barWidth >= cornerRadius && groupBarHeight >= 2 * cornerRadius) {
|
| 288 |
+
pathData = `
|
| 289 |
+
M 0,${groupY}
|
| 290 |
+
L ${barWidth - cornerRadius},${groupY}
|
| 291 |
+
A ${cornerRadius},${cornerRadius} 0 0 1 ${barWidth},${groupY + cornerRadius}
|
| 292 |
+
L ${barWidth},${groupY + groupBarHeight - cornerRadius}
|
| 293 |
+
A ${cornerRadius},${cornerRadius} 0 0 1 ${barWidth - cornerRadius},${groupY + groupBarHeight}
|
| 294 |
+
L 0,${groupY + groupBarHeight}
|
| 295 |
+
Z
|
| 296 |
+
`;
|
| 297 |
+
} else {
|
| 298 |
+
// 对于太窄或太矮的条形,绘制普通矩形
|
| 299 |
+
pathData = `
|
| 300 |
+
M 0,${groupY}
|
| 301 |
+
L ${barWidth},${groupY}
|
| 302 |
+
L ${barWidth},${groupY + groupBarHeight}
|
| 303 |
+
L 0,${groupY + groupBarHeight}
|
| 304 |
+
Z
|
| 305 |
+
`;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
const bar = g.append("path") // 使用 path 替代 rect
|
| 309 |
+
.attr("d", pathData)
|
| 310 |
+
.on("mouseover", function() {
|
| 311 |
+
d3.select(this).attr("opacity", 0.8);
|
| 312 |
+
})
|
| 313 |
+
.on("mouseout", function() {
|
| 314 |
+
d3.select(this).attr("opacity", 1);
|
| 315 |
+
});
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
// 应用填充:始终使用纹理
|
| 320 |
+
const patternIdBar = `pattern-${group.replace(/\\s+/g, '-')}-${groupIndex}`;
|
| 321 |
+
bar.attr("fill", `url(#${patternIdBar})`);
|
| 322 |
+
|
| 323 |
+
// 添加数值标签
|
| 324 |
+
const formattedValue = valueUnit ?
|
| 325 |
+
`${chartUtils.format.autoText(value)}${valueUnit}` :
|
| 326 |
+
`${chartUtils.format.autoText(value)}`;
|
| 327 |
+
|
| 328 |
+
g.append("text")
|
| 329 |
+
.attr("x", barWidth + 5)
|
| 330 |
+
.attr("y", groupY + groupBarHeight / 2)
|
| 331 |
+
.attr("dy", "0.35em")
|
| 332 |
+
.attr("text-anchor", "start")
|
| 333 |
+
.style("font-family", typography.annotation.font_family)
|
| 334 |
+
.style("font-size", `${Math.max(groupBarHeight * 0.5, parseFloat(typography.annotation.font_size))}px`)
|
| 335 |
+
.style("font-weight", typography.annotation.font_weight)
|
| 336 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 337 |
+
.text(formattedValue);
|
| 338 |
+
}
|
| 339 |
+
});
|
| 340 |
+
}
|
| 341 |
+
});
|
| 342 |
+
|
| 343 |
+
// 返回SVG节点
|
| 344 |
+
return svg.node();
|
| 345 |
+
}
|
modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_07.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Horizontal Group Bar Chart",
|
| 5 |
+
"chart_name": "horizontal_group_bar_chart_07",
|
| 6 |
+
"chart_for": "comparison",
|
| 7 |
+
"is_composite": false,
|
| 8 |
+
"required_fields": ["x", "y", "group"],
|
| 9 |
+
"required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
|
| 10 |
+
"required_fields_range": [[2, 20], [0, "inf"], [2,5]],
|
| 11 |
+
"required_fields_icons": ["x"],
|
| 12 |
+
"required_other_icons": [],
|
| 13 |
+
"required_fields_colors": ["group"],
|
| 14 |
+
"required_other_colors": ["primary"],
|
| 15 |
+
"supported_effects": ["shadow", "radius_corner", "gradient", "stroke", "spacing"],
|
| 16 |
+
"min_height": 400,
|
| 17 |
+
"min_width": 400,
|
| 18 |
+
"background": "no",
|
| 19 |
+
"icon_mark": "none",
|
| 20 |
+
"icon_label": "side",
|
| 21 |
+
"has_x_axis": "no",
|
| 22 |
+
"has_y_axis": "no"
|
| 23 |
+
}
|
| 24 |
+
REQUIREMENTS_END
|
| 25 |
+
*/
|
| 26 |
+
|
| 27 |
+
// 水平分组条形图实现 - 使用D3.js - 胶囊数量表示数值
|
| 28 |
+
function makeChart(containerSelector, data) {
|
| 29 |
+
// ---------- 1. 数据准备阶段 ----------
|
| 30 |
+
|
| 31 |
+
// 提取数据和配置
|
| 32 |
+
const jsonData = data; // 完整的JSON数据对象
|
| 33 |
+
const chartData = jsonData.data.data; // 实际数据点数组
|
| 34 |
+
const variables = jsonData.variables || {}; // 图表配置
|
| 35 |
+
const typography = jsonData.typography || { // 字体设置,如果不存在则使用默认值
|
| 36 |
+
title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
|
| 37 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 38 |
+
description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 39 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 40 |
+
};
|
| 41 |
+
const colors = jsonData.colors || { text_color: "#333333" }; // 颜色设置
|
| 42 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 43 |
+
const images = jsonData.images || { field: {}, other: {} }; // 图像设置
|
| 44 |
+
const dataColumns = chartUtils.schema.columns(jsonData); // 数据列定义
|
| 45 |
+
|
| 46 |
+
// 设置视觉效果变量的默认值 - 圆角始终启用
|
| 47 |
+
// variables.has_rounded_corners = variables.has_rounded_corners || false;
|
| 48 |
+
variables.has_shadow = variables.has_shadow !== undefined ? variables.has_shadow : false;
|
| 49 |
+
variables.has_gradient = variables.has_gradient !== undefined ? variables.has_gradient : false; // 虽然不用渐变填充,但保留效果选项
|
| 50 |
+
variables.has_stroke = variables.has_stroke !== undefined ? variables.has_stroke : false;
|
| 51 |
+
variables.has_spacing = variables.has_spacing !== undefined ? variables.has_spacing : true; // 胶囊间需要间距
|
| 52 |
+
|
| 53 |
+
// 清空容器
|
| 54 |
+
d3.select(containerSelector).html("");
|
| 55 |
+
|
| 56 |
+
// ---------- 2. 尺寸和布局设置 ----------
|
| 57 |
+
|
| 58 |
+
// 设置图表总尺寸
|
| 59 |
+
const width = variables.width || 800;
|
| 60 |
+
const height = variables.height || 600;
|
| 61 |
+
|
| 62 |
+
// 设置边距 - 右侧需要重新计算
|
| 63 |
+
const margin = {
|
| 64 |
+
top: 100, // 顶部留出标题空间
|
| 65 |
+
right: 30, // 初始右侧边距,稍后调整
|
| 66 |
+
bottom: 60, // 底部边距
|
| 67 |
+
left: 60 // 左侧初始空间,用于维度标签和图标
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
// ---------- 3. 提取字段名和单位 ----------
|
| 71 |
+
|
| 72 |
+
// 根据数据列顺序提取字段名
|
| 73 |
+
const dimensionField = chartUtils.schema.channel(jsonData, "x").key;
|
| 74 |
+
const valueField = chartUtils.schema.channel(jsonData, "y").key;
|
| 75 |
+
const groupField = chartUtils.schema.channel(jsonData, "group").key;
|
| 76 |
+
|
| 77 |
+
// 获取字段单位(如果存在)
|
| 78 |
+
let dimensionUnit = "";
|
| 79 |
+
let valueUnit = "";
|
| 80 |
+
let groupUnit = "";
|
| 81 |
+
|
| 82 |
+
if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
|
| 83 |
+
dimensionUnit = chartUtils.schema.channel(jsonData, "x").unit;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
|
| 87 |
+
valueUnit = chartUtils.schema.channel(jsonData, "y").unit;
|
| 88 |
+
}
|
| 89 |
+
if (chartUtils.schema.channel(jsonData, "group").unit !== "none") {
|
| 90 |
+
groupUnit = chartUtils.schema.channel(jsonData, "group").unit;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// ---------- 4. 数据处理与值缩放 ----------
|
| 94 |
+
|
| 95 |
+
// 获取唯一维度值和分组值
|
| 96 |
+
const dimensions = [...new Set(chartData.map(d => d[dimensionField]))];
|
| 97 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 98 |
+
|
| 99 |
+
// 找出最大值,以确定胶囊数量
|
| 100 |
+
const overallMax = d3.max(chartData, d => Math.abs(+d[valueField])) || 0;
|
| 101 |
+
|
| 102 |
+
// 胶囊数量计算
|
| 103 |
+
const maxCapsules = 30; // 每组最多显示30个胶囊
|
| 104 |
+
// 计算每个胶囊代表的数值(处理 overallMax 为 0 的情况)
|
| 105 |
+
const valuePerCapsule = overallMax > 0 ? Math.ceil(overallMax / maxCapsules) : 1;
|
| 106 |
+
|
| 107 |
+
// 计算值到胶囊数量的映射函数
|
| 108 |
+
const valueToCapsules = (value) => {
|
| 109 |
+
if (valuePerCapsule === 0) return 0; // 避免除以零
|
| 110 |
+
return Math.min(maxCapsules, Math.ceil(Math.abs(value) / valuePerCapsule));
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
// 添加数值格式化函数
|
| 114 |
+
|
| 115 |
+
// ---------- 5. 计算标签与布局 ----------
|
| 116 |
+
|
| 117 |
+
// 图标和胶囊尺寸配置
|
| 118 |
+
const iconSizeRatio = 0.6; // 图标高度占维度带宽的比例
|
| 119 |
+
const maxIconSize = 25; // 图标最大尺寸
|
| 120 |
+
const iconPadding = 5; // 图标和标签/胶囊之间的间距
|
| 121 |
+
const capsuleWidth = 5; // 单个胶囊宽度
|
| 122 |
+
const capsuleSpacing = 2; // 胶囊之间的间距
|
| 123 |
+
const labelMeasureOptions = {
|
| 124 |
+
fontFamily: typography.label.font_family,
|
| 125 |
+
fontSize: typography.label.font_size,
|
| 126 |
+
fontWeight: typography.label.font_weight
|
| 127 |
+
};
|
| 128 |
+
const annotationMeasureOptions = {
|
| 129 |
+
fontFamily: typography.annotation.font_family,
|
| 130 |
+
fontSize: typography.annotation.font_size,
|
| 131 |
+
fontWeight: typography.annotation.font_weight
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
// 计算最大维度标签宽度
|
| 135 |
+
let maxDimLabelWidth = 0;
|
| 136 |
+
dimensions.forEach(dimension => {
|
| 137 |
+
const formattedDimension = dimensionUnit ? `${dimension}${dimensionUnit}` : `${dimension}`;
|
| 138 |
+
const textWidth = chartUtils.text.measure(null, formattedDimension, labelMeasureOptions).width;
|
| 139 |
+
maxDimLabelWidth = Math.max(maxDimLabelWidth, textWidth);
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
// 计算最大数值标签宽度 (基于最大可能值和单位)
|
| 143 |
+
let maxValueLabelWidth = 0;
|
| 144 |
+
const maxPossibleValue = overallMax;
|
| 145 |
+
// 使用 formatValue 和 valueUnit 来计算最大值标签宽度
|
| 146 |
+
const formattedMaxValueWithUnit = `${chartUtils.format.autoText(maxPossibleValue)}${valueUnit}`;
|
| 147 |
+
maxValueLabelWidth = chartUtils.text.measure(null, formattedMaxValueWithUnit, annotationMeasureOptions).width;
|
| 148 |
+
|
| 149 |
+
// 计算图例项宽度和总宽度
|
| 150 |
+
const legendItemWidths = [];
|
| 151 |
+
let totalLegendWidth = 0;
|
| 152 |
+
const legendPadding = 15; // 图例项之间的间距 (增加间距)
|
| 153 |
+
const legendCapsuleWidth = 12; // 图例胶囊宽度
|
| 154 |
+
const legendCapsuleHeight = 8; // 图例胶囊高度
|
| 155 |
+
const legendTextPadding = 5; // 图例胶囊和文本之间的间距
|
| 156 |
+
|
| 157 |
+
groups.forEach(group => {
|
| 158 |
+
const textWidth = chartUtils.text.measure(null, group, labelMeasureOptions).width;
|
| 159 |
+
// 图例项宽度 = 胶囊宽度 + 间距 + 文本宽度 + 右侧间距
|
| 160 |
+
const legendItemWidth = legendCapsuleWidth + legendTextPadding + textWidth + legendPadding;
|
| 161 |
+
legendItemWidths.push(legendItemWidth);
|
| 162 |
+
totalLegendWidth += legendItemWidth;
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
// 重新计算左边距以包含图标
|
| 166 |
+
// 左边距 = 最大标签宽度 + 标签与图标间距 + 图标最大宽度 + 图标与条形图间距
|
| 167 |
+
const labelIconPadding = 5; // 标签和图标之间的间距
|
| 168 |
+
const iconBarPadding = 10; // 图标和条形图开始位置的间距
|
| 169 |
+
margin.left = Math.max(margin.left, maxDimLabelWidth + labelIconPadding + maxIconSize + iconBarPadding);
|
| 170 |
+
|
| 171 |
+
// 计算所需的最大右侧宽度 (保持不变)
|
| 172 |
+
const maxCapsulesWidth = maxCapsules * (capsuleWidth + capsuleSpacing);
|
| 173 |
+
const requiredRightWidth = maxCapsulesWidth + maxValueLabelWidth + 10; // 10px 额外间距
|
| 174 |
+
margin.right = Math.max(margin.right, requiredRightWidth);
|
| 175 |
+
|
| 176 |
+
// 计算内部绘图区域尺寸
|
| 177 |
+
const innerWidth = width - margin.left - margin.right;
|
| 178 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 179 |
+
|
| 180 |
+
// ---------- 6. 创建SVG容器与效果 ----------
|
| 181 |
+
|
| 182 |
+
const svg = d3.select(containerSelector)
|
| 183 |
+
.append("svg")
|
| 184 |
+
.attr("width", "100%")
|
| 185 |
+
.attr("height", height)
|
| 186 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 187 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 188 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 189 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 190 |
+
|
| 191 |
+
// 添加defs用于视觉效果 (保留阴影和纹理)
|
| 192 |
+
const defs = svg.append("defs");
|
| 193 |
+
|
| 194 |
+
const getColor = (group) => {
|
| 195 |
+
if (colorResolver.field(group, 0, { fallbackKey: "primary" }).value) {
|
| 196 |
+
return colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
if (variables.has_shadow) {
|
| 202 |
+
const filter = defs.append("filter")
|
| 203 |
+
.attr("id", "shadow")
|
| 204 |
+
.attr("filterUnits", "userSpaceOnUse")
|
| 205 |
+
.attr("width", "200%")
|
| 206 |
+
.attr("height", "200%");
|
| 207 |
+
|
| 208 |
+
filter.append("feGaussianBlur")
|
| 209 |
+
.attr("in", "SourceAlpha")
|
| 210 |
+
.attr("stdDeviation", 3);
|
| 211 |
+
|
| 212 |
+
filter.append("feOffset")
|
| 213 |
+
.attr("dx", 2)
|
| 214 |
+
.attr("dy", 2)
|
| 215 |
+
.attr("result", "offsetblur");
|
| 216 |
+
|
| 217 |
+
const feMerge = filter.append("feMerge");
|
| 218 |
+
feMerge.append("feMergeNode");
|
| 219 |
+
feMerge.append("feMergeNode").attr("in", "SourceGraphic");
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
// 创建斜线纹理模式 (包含渐变背景)
|
| 223 |
+
const patternDensity = 6; // 固定斜线密度
|
| 224 |
+
const patternStrokeWidth = 1.5; // 固定斜线宽度
|
| 225 |
+
groups.forEach((group, i) => {
|
| 226 |
+
const groupColor = getColor(group);
|
| 227 |
+
const patternId = `pattern-${group.replace(/[^a-zA-Z0-9]/g, '-')}-${i}`; // 基�� group 生成 ID
|
| 228 |
+
const gradientId = `pattern-gradient-${group.replace(/[^a-zA-Z0-9]/g, '-')}-${i}`; // 渐变 ID
|
| 229 |
+
|
| 230 |
+
// 1. 定义 pattern 内使用的渐变
|
| 231 |
+
const gradient = defs.append("linearGradient")
|
| 232 |
+
.attr("id", gradientId)
|
| 233 |
+
.attr("x1", "0%").attr("y1", "0%")
|
| 234 |
+
.attr("x2", "100%").attr("y2", "0%"); // 水平从左到右
|
| 235 |
+
|
| 236 |
+
// 渐变颜色:从深到亮
|
| 237 |
+
gradient.append("stop").attr("offset", "0%").attr("stop-color", colorResolver.variant(groupColor, { mode: "darker", amount: 0.7 }));
|
| 238 |
+
gradient.append("stop").attr("offset", "100%").attr("stop-color", colorResolver.variant(groupColor, { mode: "brighter", amount: 1.2 }));
|
| 239 |
+
|
| 240 |
+
// 2. 定义 pattern
|
| 241 |
+
const pattern = defs.append("pattern")
|
| 242 |
+
.attr("id", patternId)
|
| 243 |
+
.attr("patternUnits", "userSpaceOnUse")
|
| 244 |
+
.attr("width", patternDensity)
|
| 245 |
+
.attr("height", patternDensity)
|
| 246 |
+
.attr("patternTransform", "rotate(45)");
|
| 247 |
+
|
| 248 |
+
// 3. 在 pattern 内添加使用上面定义的渐变的背景矩形
|
| 249 |
+
pattern.append("rect")
|
| 250 |
+
.attr("width", patternDensity)
|
| 251 |
+
.attr("height", patternDensity)
|
| 252 |
+
.attr("fill", `url(#${gradientId})`); // 使用 pattern 内的渐变填充
|
| 253 |
+
|
| 254 |
+
// 4. 在 pattern 内添加白色斜线 (覆盖在渐变背景上)
|
| 255 |
+
pattern.append("line")
|
| 256 |
+
.attr("x1", 0).attr("y1", 0).attr("x2", 0).attr("y2", patternDensity)
|
| 257 |
+
.attr("stroke", "white").attr("stroke-width", patternStrokeWidth).attr("opacity", 0.6);
|
| 258 |
+
});
|
| 259 |
+
|
| 260 |
+
// ---------- 7. 创建比例尺 ----------
|
| 261 |
+
|
| 262 |
+
// Y轴比例尺(用于维度)- 使用原始顺序
|
| 263 |
+
const yScale = d3.scaleBand()
|
| 264 |
+
.domain(dimensions)
|
| 265 |
+
.range([0, innerHeight])
|
| 266 |
+
.padding(0.3); // 维度组之间的间距
|
| 267 |
+
|
| 268 |
+
// ---------- 8. 创建图例 ----------
|
| 269 |
+
|
| 270 |
+
// 添加图例 (修改为水平胶囊,与数据条一致,并实现自动换行)
|
| 271 |
+
const legendGroup = svg.append("g")
|
| 272 |
+
.attr("class", "legend")
|
| 273 |
+
.attr("transform", `translate(${margin.left}, ${margin.top *0.7})`); // 从左边距开始
|
| 274 |
+
|
| 275 |
+
// 图例项样式参数
|
| 276 |
+
const legendCapsuleHeightH = 10; // 水平胶囊的高度
|
| 277 |
+
const legendCapsuleWidthH = 15; // 水平胶囊的宽度
|
| 278 |
+
const legendVerticalSpacing = 10; // 行之间的垂直间距
|
| 279 |
+
const rowHeight = legendCapsuleHeightH + legendVerticalSpacing;
|
| 280 |
+
|
| 281 |
+
// 测量图例项宽度
|
| 282 |
+
groups.forEach(group => {
|
| 283 |
+
const textWidth = chartUtils.text.measure(svg, group, labelMeasureOptions).width;
|
| 284 |
+
// 图例项宽度 = 胶囊宽度 + 间距 + 文本宽度
|
| 285 |
+
const itemWidth = legendCapsuleWidthH + legendTextPadding + textWidth;
|
| 286 |
+
legendItemWidths.push(itemWidth);
|
| 287 |
+
});
|
| 288 |
+
|
| 289 |
+
chartUtils.legend.draw(svg, groups, {
|
| 290 |
+
color: (group, index) => `url(#pattern-${group.replace(/[^a-zA-Z0-9]/g, '-')}-${index})`,
|
| 291 |
+
colorResolver,
|
| 292 |
+
colors,
|
| 293 |
+
}, {
|
| 294 |
+
x: margin.left,
|
| 295 |
+
y: margin.top * 0.7,
|
| 296 |
+
maxWidth: width - 100,
|
| 297 |
+
markerShape: "capsule",
|
| 298 |
+
markerWidth: legendCapsuleWidthH,
|
| 299 |
+
markerHeight: legendCapsuleHeightH,
|
| 300 |
+
labelGap: legendTextPadding,
|
| 301 |
+
itemGap: legendPadding,
|
| 302 |
+
rowGap: legendVerticalSpacing,
|
| 303 |
+
fontSize: typography.label.font_size,
|
| 304 |
+
fontFamily: typography.label.font_family,
|
| 305 |
+
fontWeight: typography.label.font_weight,
|
| 306 |
+
textColor: colorResolver.text({ fallback: "#333333" }).value,
|
| 307 |
+
});
|
| 308 |
+
|
| 309 |
+
// ---------- 9. 创建主图表组 ----------
|
| 310 |
+
|
| 311 |
+
const g = svg.append("g")
|
| 312 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 313 |
+
|
| 314 |
+
// 获取描边颜色(如果启用描边)
|
| 315 |
+
const getStrokeColor = (barColor) => {
|
| 316 |
+
return variables.has_stroke ? colorResolver.variant(barColor, { mode: "darker", amount: 0.5 }) : "none";
|
| 317 |
+
};
|
| 318 |
+
|
| 319 |
+
// ---------- 10. 为每个维度绘制分组胶囊 ----------
|
| 320 |
+
|
| 321 |
+
dimensions.forEach(dimension => {
|
| 322 |
+
// 获取此维度下的所有数据点
|
| 323 |
+
const dimensionData = chartData.filter(d => d[dimensionField] === dimension);
|
| 324 |
+
|
| 325 |
+
if (dimensionData.length > 0) {
|
| 326 |
+
const dimensionY = yScale(dimension);
|
| 327 |
+
const dimensionBandwidth = yScale.bandwidth();
|
| 328 |
+
|
| 329 |
+
// 计算每个组的胶囊行高度 (考虑组间间距)
|
| 330 |
+
const groupSpacingRatio = 0.1; // 组间距占行高的比例
|
| 331 |
+
const numGroups = groups.length;
|
| 332 |
+
const totalSpacingHeight = dimensionBandwidth * groupSpacingRatio * (numGroups > 1 ? numGroups - 1 : 0);
|
| 333 |
+
const availableGroupHeight = dimensionBandwidth - totalSpacingHeight;
|
| 334 |
+
const groupCapsuleHeight = availableGroupHeight / numGroups;
|
| 335 |
+
const finalCapsuleHeight = Math.max(4, groupCapsuleHeight); // 最小高度 4px
|
| 336 |
+
const groupSpacing = dimensionBandwidth * groupSpacingRatio; // 实际间距像素值
|
| 337 |
+
|
| 338 |
+
// 计算图标尺寸和位置
|
| 339 |
+
const iconSize = Math.min(maxIconSize, dimensionBandwidth * iconSizeRatio);
|
| 340 |
+
const iconY = dimensionY + (dimensionBandwidth - iconSize) / 2; // 图标垂直居中
|
| 341 |
+
const iconAvailable = images.field && images.field[dimension];
|
| 342 |
+
|
| 343 |
+
// --- Corrected Icon and Label Positioning ---
|
| 344 |
+
let finalLabelX;
|
| 345 |
+
const labelCenterY = dimensionY + dimensionBandwidth / 2;
|
| 346 |
+
|
| 347 |
+
if (iconAvailable) {
|
| 348 |
+
// Position icon just left of the bar area (x=0)
|
| 349 |
+
const iconX = -iconBarPadding - iconSize;
|
| 350 |
+
// Position label left of the icon
|
| 351 |
+
finalLabelX = iconX - labelIconPadding;
|
| 352 |
+
|
| 353 |
+
// 添加维度图标
|
| 354 |
+
g.append("image")
|
| 355 |
+
.attr("x", iconX)
|
| 356 |
+
.attr("y", iconY)
|
| 357 |
+
.attr("width", iconSize)
|
| 358 |
+
.attr("height", iconSize)
|
| 359 |
+
.attr("preserveAspectRatio", "xMidYMid meet")
|
| 360 |
+
.attr("xlink:href", images.field[dimension]);
|
| 361 |
+
} else {
|
| 362 |
+
// Position label just left of the bar area (x=0)
|
| 363 |
+
finalLabelX = -iconBarPadding;
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
// 添加维度标签 (使用计算好的 finalLabelX)
|
| 367 |
+
g.append("text")
|
| 368 |
+
.attr("x", finalLabelX)
|
| 369 |
+
.attr("y", labelCenterY)
|
| 370 |
+
.attr("dy", "0.35em")
|
| 371 |
+
.attr("text-anchor", "end")
|
| 372 |
+
.style("font-family", typography.label.font_family)
|
| 373 |
+
.style("font-size", typography.label.font_size)
|
| 374 |
+
.style("font-weight", typography.label.font_weight)
|
| 375 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 376 |
+
.text(dimension);
|
| 377 |
+
|
| 378 |
+
// --- End of Corrected Positioning ---
|
| 379 |
+
|
| 380 |
+
// 绘制每个组的胶囊
|
| 381 |
+
groups.forEach((group, groupIndex) => {
|
| 382 |
+
const dataPoint = dimensionData.find(d => d[groupField] === group);
|
| 383 |
+
|
| 384 |
+
if (dataPoint) {
|
| 385 |
+
const value = +dataPoint[valueField];
|
| 386 |
+
const capsuleCount = valueToCapsules(value);
|
| 387 |
+
|
| 388 |
+
// 计算当前组胶囊的垂直起始位置 (包含组间距)
|
| 389 |
+
const groupY = dimensionY + (groupIndex * (finalCapsuleHeight + groupSpacing));
|
| 390 |
+
|
| 391 |
+
// 绘制胶囊
|
| 392 |
+
for (let j = 0; j < capsuleCount; j++) {
|
| 393 |
+
const capsuleX = j * (capsuleWidth + capsuleSpacing);
|
| 394 |
+
|
| 395 |
+
g.append("rect")
|
| 396 |
+
.attr("x", capsuleX)
|
| 397 |
+
.attr("y", groupY)
|
| 398 |
+
.attr("width", capsuleWidth)
|
| 399 |
+
.attr("height", finalCapsuleHeight)
|
| 400 |
+
// 应用包含渐变的纹理填充
|
| 401 |
+
.attr("fill", `url(#pattern-${group.replace(/[^a-zA-Z0-9]/g, '-')}-${groupIndex})`)
|
| 402 |
+
// 圆角
|
| 403 |
+
.attr("rx", capsuleWidth / 2)
|
| 404 |
+
.attr("ry", capsuleWidth / 2) // 使用宽度的一半确保胶囊状
|
| 405 |
+
.style("stroke", getStrokeColor(getColor(group)))
|
| 406 |
+
.style("stroke-width", variables.has_stroke ? 1 : 0)
|
| 407 |
+
.style("filter", variables.has_shadow ? "url(#shadow)" : "none");
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
// 添加数值标签
|
| 411 |
+
const formattedValue = `${chartUtils.format.autoText(value)}${valueUnit}`;
|
| 412 |
+
// 定位在最后一个胶囊之后
|
| 413 |
+
const lastCapsuleEnd = capsuleCount * (capsuleWidth + capsuleSpacing) - capsuleSpacing; // 最后一个胶囊的结束X坐标 (如果 count > 0)
|
| 414 |
+
const labelX = (capsuleCount > 0 ? lastCapsuleEnd : 0) + 5; // 如果没有胶囊,标签从0开始,否则从最后一个胶囊后5px开始
|
| 415 |
+
|
| 416 |
+
g.append("text")
|
| 417 |
+
.attr("x", labelX)
|
| 418 |
+
.attr("y", groupY + finalCapsuleHeight / 2) // 垂直居中于胶囊行
|
| 419 |
+
.attr("dy", "0.35em")
|
| 420 |
+
.attr("text-anchor", "start")
|
| 421 |
+
.style("font-family", typography.annotation.font_family)
|
| 422 |
+
.style("font-size", `${Math.max(finalCapsuleHeight * 0.5, parseFloat(typography.annotation.font_size))}px`)
|
| 423 |
+
.style("font-weight", typography.annotation.font_weight)
|
| 424 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 425 |
+
.text(formattedValue);
|
| 426 |
+
}
|
| 427 |
+
});
|
| 428 |
+
}
|
| 429 |
+
});
|
| 430 |
+
|
| 431 |
+
// 返回SVG节点
|
| 432 |
+
return svg.node();
|
| 433 |
+
}
|
modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_10.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Horizontal Group Bar Chart",
|
| 5 |
+
"chart_name": "horizontal_group_bar_chart_10",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y", "group"],
|
| 8 |
+
"required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
|
| 9 |
+
"required_fields_range": [[2, 20], [0, "inf"], [2, 5]],
|
| 10 |
+
"required_fields_icons": ["x"],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": ["group"],
|
| 13 |
+
"required_other_colors": ["primary"],
|
| 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 //horizontal_grouped_bar_chart_05
|
| 27 |
+
function makeChart(containerSelector, data) {
|
| 28 |
+
// ---------- 1. 数据准备阶段 ----------
|
| 29 |
+
|
| 30 |
+
// 提取数据和配置
|
| 31 |
+
const jsonData = data; // 完整的JSON数据对象
|
| 32 |
+
const chartData = jsonData.data.data; // 实际数据点数组
|
| 33 |
+
const variables = jsonData.variables || {}; // 图表配置
|
| 34 |
+
const typography = jsonData.typography || { // 字体设置,如果不存在则使用默认值
|
| 35 |
+
title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
|
| 36 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 37 |
+
description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 38 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 39 |
+
};
|
| 40 |
+
const colors = jsonData.colors || { text_color: "#333333" }; // 颜色设置
|
| 41 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 42 |
+
const images = jsonData.images || { field: {}, other: {} }; // 图像设置
|
| 43 |
+
const dataColumns = chartUtils.schema.columns(jsonData); // 数据列定义
|
| 44 |
+
|
| 45 |
+
// 清空容器
|
| 46 |
+
d3.select(containerSelector).html("");
|
| 47 |
+
|
| 48 |
+
// ---------- 2. 尺寸和布局设置 ----------
|
| 49 |
+
|
| 50 |
+
// 设置图表总尺寸
|
| 51 |
+
const width = variables.width || 800;
|
| 52 |
+
const height = variables.height || 600;
|
| 53 |
+
|
| 54 |
+
// 设置边距
|
| 55 |
+
const margin = {
|
| 56 |
+
top: 40, // 顶部留出标题空间
|
| 57 |
+
right: 60, // 右侧足够显示数值和分组标签
|
| 58 |
+
bottom: 30, // 底部边距
|
| 59 |
+
left: 40 // 左侧空间减少,因为我们不显示维度文本,只显示图标
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
// ---------- 3. 提取字段名和单位 ----------
|
| 63 |
+
|
| 64 |
+
// 根据数据列顺序提取字段名
|
| 65 |
+
const dimensionField = chartUtils.schema.channel(jsonData, "x").key;
|
| 66 |
+
const valueField = chartUtils.schema.channel(jsonData, "y").key;
|
| 67 |
+
|
| 68 |
+
const primaryGroupField = chartUtils.schema.channel(jsonData, "group").key;
|
| 69 |
+
|
| 70 |
+
// 获取字段单位(如果存在)
|
| 71 |
+
let dimensionUnit = "";
|
| 72 |
+
let valueUnit = "";
|
| 73 |
+
|
| 74 |
+
if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
|
| 75 |
+
dimensionUnit = chartUtils.schema.channel(jsonData, "x").unit;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
|
| 79 |
+
valueUnit = chartUtils.schema.channel(jsonData, "y").unit;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// ---------- 4. 数据处理 ----------
|
| 83 |
+
|
| 84 |
+
// 添加数值格式化函数
|
| 85 |
+
|
| 86 |
+
// 获取唯一维度值和分组值
|
| 87 |
+
const dimensions = [...new Set(chartData.map(d => d[dimensionField]))];
|
| 88 |
+
const primaryGroups = [...new Set(chartData.map(d => d[primaryGroupField]))];
|
| 89 |
+
|
| 90 |
+
// 按主要分组和数值进行分组和排序
|
| 91 |
+
const groupedData = {};
|
| 92 |
+
primaryGroups.forEach(group => {
|
| 93 |
+
// 过滤属于此组的数据
|
| 94 |
+
const groupItems = chartData.filter(d => d[primaryGroupField] === group);
|
| 95 |
+
|
| 96 |
+
// 按数值降序排序
|
| 97 |
+
const sortedItems = [...groupItems].sort((a, b) => +b[valueField] - +a[valueField]);
|
| 98 |
+
|
| 99 |
+
groupedData[group] = sortedItems;
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
// ---------- 5. 计算标签宽度 ----------
|
| 103 |
+
|
| 104 |
+
// 图标尺寸
|
| 105 |
+
const flagWidth = 20;
|
| 106 |
+
const flagHeight = 15;
|
| 107 |
+
const flagPadding = 5;
|
| 108 |
+
const labelMeasureOptions = {
|
| 109 |
+
fontFamily: typography.label.font_family,
|
| 110 |
+
fontSize: typography.label.font_size,
|
| 111 |
+
fontWeight: typography.label.font_weight
|
| 112 |
+
};
|
| 113 |
+
const annotationMeasureOptions = {
|
| 114 |
+
fontFamily: typography.annotation.font_family,
|
| 115 |
+
fontSize: typography.annotation.font_size,
|
| 116 |
+
fontWeight: typography.annotation.font_weight
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
// 计算最大数值标签宽度
|
| 120 |
+
let maxValueWidth = 0;
|
| 121 |
+
chartData.forEach(d => {
|
| 122 |
+
const formattedValue = valueUnit ?
|
| 123 |
+
`${chartUtils.format.autoText(+d[valueField])}${valueUnit}` :
|
| 124 |
+
`${chartUtils.format.autoText(+d[valueField])}`;
|
| 125 |
+
|
| 126 |
+
const textWidth = chartUtils.text.measure(null, formattedValue, annotationMeasureOptions).width;
|
| 127 |
+
|
| 128 |
+
maxValueWidth = Math.max(maxValueWidth, textWidth);
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
// *** BEGIN ADDITION: Calculate Max Dimension Label Width ***
|
| 132 |
+
let maxDimensionLabelWidth = 0;
|
| 133 |
+
dimensions.forEach(dimension => {
|
| 134 |
+
const formattedDimension = dimensionUnit ?
|
| 135 |
+
`${dimension}${dimensionUnit}` :
|
| 136 |
+
`${dimension}`;
|
| 137 |
+
|
| 138 |
+
const textWidth = chartUtils.text.measure(null, formattedDimension, labelMeasureOptions).width;
|
| 139 |
+
maxDimensionLabelWidth = Math.max(maxDimensionLabelWidth, textWidth);
|
| 140 |
+
});
|
| 141 |
+
// *** END ADDITION ***
|
| 142 |
+
|
| 143 |
+
// 计算分组注释的最大宽度
|
| 144 |
+
let maxGroupNoteWidth = 0;
|
| 145 |
+
primaryGroups.forEach(group => {
|
| 146 |
+
const textWidth = chartUtils.text.measure(null, group, labelMeasureOptions).width;
|
| 147 |
+
|
| 148 |
+
maxGroupNoteWidth = Math.max(maxGroupNoteWidth, textWidth);
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
// ** MODIFY ** 根据图标和最大标签尺寸调整左边距
|
| 152 |
+
const baseLeftPadding = 10; // Base padding to the left of the labels
|
| 153 |
+
margin.left = maxDimensionLabelWidth + flagPadding + flagWidth + flagPadding + baseLeftPadding;
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
// ---------- 6. 计算分组布局 ----------
|
| 157 |
+
|
| 158 |
+
// 计算内部绘图区域尺寸
|
| 159 |
+
const innerWidth = width - margin.left - margin.right;
|
| 160 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 161 |
+
|
| 162 |
+
// 计算合适的条形高度,确保所有条形在可用高度内可见
|
| 163 |
+
// 计算总条形数量和组数
|
| 164 |
+
let totalBars = 0;
|
| 165 |
+
primaryGroups.forEach(group => {
|
| 166 |
+
totalBars += groupedData[group].length;
|
| 167 |
+
});
|
| 168 |
+
|
| 169 |
+
const numGroups = primaryGroups.length;
|
| 170 |
+
|
| 171 |
+
// 计算组间间距的总高度
|
| 172 |
+
const groupPaddingRatio = 0.15; // 组间距占总高度的比例
|
| 173 |
+
const groupPadding = innerHeight * groupPaddingRatio / (numGroups + 1); // 分配给每个组间距
|
| 174 |
+
const totalGroupPadding = groupPadding * (numGroups - 1);
|
| 175 |
+
|
| 176 |
+
// 计算可用于条形的总高度
|
| 177 |
+
const availableForBars = innerHeight - totalGroupPadding;
|
| 178 |
+
|
| 179 |
+
// 计算条形高度和条形间距
|
| 180 |
+
const barPaddingRatio = 0.15; // Use fixed padding ratio, remove spacing effect logic
|
| 181 |
+
|
| 182 |
+
// 计算理想的条形高度(包括间距)
|
| 183 |
+
const idealBarUnitHeight = availableForBars / totalBars;
|
| 184 |
+
|
| 185 |
+
// 初始计算实际条形高度(不包括间距)
|
| 186 |
+
let calculatedBarHeight = idealBarUnitHeight * (1 - barPaddingRatio);
|
| 187 |
+
|
| 188 |
+
// 限制最大条形高度
|
| 189 |
+
const barHeight = Math.min(calculatedBarHeight, 40);
|
| 190 |
+
|
| 191 |
+
// 根据最终的条形高度计算条形间距,以保持总单元高度
|
| 192 |
+
const barPadding = idealBarUnitHeight - barHeight;
|
| 193 |
+
|
| 194 |
+
// 计算每组的高度和偏移量
|
| 195 |
+
const groupHeights = {};
|
| 196 |
+
const groupOffsets = {};
|
| 197 |
+
|
| 198 |
+
let currentOffset = margin.top;
|
| 199 |
+
primaryGroups.forEach(group => {
|
| 200 |
+
const itemCount = groupedData[group].length;
|
| 201 |
+
// 计算当前组的总高度(包括所有条形和条形间距)
|
| 202 |
+
const groupHeight = itemCount * barHeight + (itemCount - 1) * barPadding;
|
| 203 |
+
|
| 204 |
+
groupHeights[group] = groupHeight;
|
| 205 |
+
groupOffsets[group] = currentOffset;
|
| 206 |
+
|
| 207 |
+
// 更新下一组的起始位置
|
| 208 |
+
currentOffset += groupHeight + groupPadding;
|
| 209 |
+
});
|
| 210 |
+
|
| 211 |
+
// ---------- 7. 创建SVG容器 ----------
|
| 212 |
+
|
| 213 |
+
const svg = d3.select(containerSelector)
|
| 214 |
+
.append("svg")
|
| 215 |
+
.attr("width", "100%")
|
| 216 |
+
.attr("height", height) // 使用定义的高度,不进行自动调整
|
| 217 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 218 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 219 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 220 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 221 |
+
|
| 222 |
+
// ---------- 8. 创建比例尺 ----------
|
| 223 |
+
|
| 224 |
+
// X轴比例尺(用于数值)
|
| 225 |
+
const xScale = d3.scaleLinear()
|
| 226 |
+
.domain([0, d3.max(chartData, d => +d[valueField]) * 1.05]) // 添加5%边距
|
| 227 |
+
.range([0, innerWidth]);
|
| 228 |
+
|
| 229 |
+
// ---------- 9. 创建主图表组 ----------
|
| 230 |
+
|
| 231 |
+
const g = svg.append("g")
|
| 232 |
+
.attr("transform", `translate(${margin.left}, 0)`);
|
| 233 |
+
|
| 234 |
+
// ---------- 10. 渲染每个分组 ----------
|
| 235 |
+
|
| 236 |
+
// 获取条形颜色的辅助函数
|
| 237 |
+
const getBarColor = (group) => {
|
| 238 |
+
return colorResolver.field(group, 0, { fallbackKey: "primary", fallback: "#3ca951" }).value;
|
| 239 |
+
};
|
| 240 |
+
|
| 241 |
+
// 在条形左侧添加灰色竖线
|
| 242 |
+
g.append("line")
|
| 243 |
+
.attr("x1", 0)
|
| 244 |
+
.attr("y1", margin.top)
|
| 245 |
+
.attr("x2", 0)
|
| 246 |
+
.attr("y2", height - margin.bottom)
|
| 247 |
+
.attr("stroke", "#cccccc")
|
| 248 |
+
.attr("stroke-width", 3);
|
| 249 |
+
// 遍历每个主要分组,添加背景矩形
|
| 250 |
+
primaryGroups.forEach(group => {
|
| 251 |
+
const groupData = groupedData[group];
|
| 252 |
+
const groupOffset = groupOffsets[group];
|
| 253 |
+
const groupHeight = groupHeights[group];
|
| 254 |
+
const barColor = getBarColor(group);
|
| 255 |
+
|
| 256 |
+
// 添加分组注释
|
| 257 |
+
if (groupData.length > 0) {
|
| 258 |
+
const lastItemIndex = groupData.length - 1;
|
| 259 |
+
const lastItemY = groupOffset + lastItemIndex * (barHeight + barPadding) + barHeight / 2;
|
| 260 |
+
|
| 261 |
+
g.append("text")
|
| 262 |
+
.attr("x", width - margin.left - 10)
|
| 263 |
+
.attr("y", lastItemY)
|
| 264 |
+
.attr("dy", "0.35em")
|
| 265 |
+
.attr("text-anchor", "end")
|
| 266 |
+
.style("font-family", typography.label.font_family) // 使用label字体
|
| 267 |
+
.style("font-size", typography.label.font_size)
|
| 268 |
+
.style("font-weight", typography.label.font_weight)
|
| 269 |
+
.style("fill", colorResolver.text({ fallback: "#000000" }).value)
|
| 270 |
+
|
| 271 |
+
.text(group);
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
// 为组中的每个项目绘制条形和标签
|
| 275 |
+
groupData.forEach((dataPoint, index) => {
|
| 276 |
+
// 计算条形的y坐标,考虑到条形间距
|
| 277 |
+
const yPos = groupOffset + index * (barHeight + barPadding);
|
| 278 |
+
const barWidth = xScale(+dataPoint[valueField]);
|
| 279 |
+
|
| 280 |
+
// Draw the bar using a simple rect element
|
| 281 |
+
g.append("rect") // Revert back to rect
|
| 282 |
+
.attr("x", 0)
|
| 283 |
+
.attr("y", yPos)
|
| 284 |
+
.attr("width", barWidth)
|
| 285 |
+
.attr("height", barHeight)
|
| 286 |
+
.attr("fill", barColor) // Use solid bar color
|
| 287 |
+
.attr("rx", barHeight/6)
|
| 288 |
+
.attr("ry", barHeight/6)
|
| 289 |
+
.on("mouseover", function() {
|
| 290 |
+
d3.select(this).attr("opacity", 0.8);
|
| 291 |
+
})
|
| 292 |
+
.on("mouseout", function() {
|
| 293 |
+
d3.select(this).attr("opacity", 1);
|
| 294 |
+
});
|
| 295 |
+
|
| 296 |
+
// 提取维度
|
| 297 |
+
const dimension = dataPoint[dimensionField];
|
| 298 |
+
|
| 299 |
+
// 添加国家/维度图标 (Positioned left of bar)
|
| 300 |
+
const flagX = -flagWidth - flagPadding; // Icon right edge at -flagPadding
|
| 301 |
+
const flagY = yPos + (barHeight - flagHeight) / 2;
|
| 302 |
+
|
| 303 |
+
if (images.field && images.field[dimension]) {
|
| 304 |
+
g.append("image")
|
| 305 |
+
.attr("x", flagX)
|
| 306 |
+
.attr("y", flagY)
|
| 307 |
+
.attr("width", flagWidth)
|
| 308 |
+
.attr("height", flagHeight)
|
| 309 |
+
.attr("preserveAspectRatio","xMidYMid meet")
|
| 310 |
+
.attr("xlink:href", images.field[dimension]);
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
// *** BEGIN ADDITION: Add Dimension Label (Left of Icon, Right Aligned) ***
|
| 314 |
+
const labelX = flagX - flagPadding; // Label right edge next to icon's left edge
|
| 315 |
+
const labelY = yPos + barHeight / 2; // Vertically centered with bar
|
| 316 |
+
const formattedDimension = dimensionUnit ?
|
| 317 |
+
`${dimension}${dimensionUnit}` :
|
| 318 |
+
`${dimension}`;
|
| 319 |
+
|
| 320 |
+
g.append("text")
|
| 321 |
+
.attr("class", "dimension-label")
|
| 322 |
+
.attr("x", labelX)
|
| 323 |
+
.attr("y", labelY)
|
| 324 |
+
.attr("dy", "0.35em")
|
| 325 |
+
.attr("text-anchor", "end") // Right align text
|
| 326 |
+
.style("font-family", typography.label.font_family)
|
| 327 |
+
.style("font-size", typography.label.font_size)
|
| 328 |
+
.style("font-weight", typography.label.font_weight)
|
| 329 |
+
.style("fill", colorResolver.text({ fallback: "#000000" }).value)
|
| 330 |
+
.text(formattedDimension);
|
| 331 |
+
// *** END ADDITION ***
|
| 332 |
+
|
| 333 |
+
// 添加数值标签
|
| 334 |
+
const formattedValue = valueUnit ?
|
| 335 |
+
`${chartUtils.format.autoText(+dataPoint[valueField])}${valueUnit}` :
|
| 336 |
+
`${chartUtils.format.autoText(+dataPoint[valueField])}`;
|
| 337 |
+
|
| 338 |
+
// --- BEGIN MODIFICATION: Conditional Label Placement ---
|
| 339 |
+
const valueLabelFontSize = `${barHeight * 0.7}px`;
|
| 340 |
+
const textWidth = chartUtils.text.measure(g, formattedValue, {
|
| 341 |
+
fontFamily: typography.annotation.font_family,
|
| 342 |
+
fontSize: valueLabelFontSize,
|
| 343 |
+
fontWeight: typography.annotation.font_weight
|
| 344 |
+
}).width;
|
| 345 |
+
|
| 346 |
+
const labelPadding = 10; // Padding for inside label (5px each side)
|
| 347 |
+
|
| 348 |
+
// Decide label position and color
|
| 349 |
+
let valueLabelX, valueLabelAnchor, valueLabelFill;
|
| 350 |
+
if (textWidth + labelPadding < barWidth) {
|
| 351 |
+
// Place inside, centered, white
|
| 352 |
+
valueLabelX = barWidth / 2;
|
| 353 |
+
valueLabelAnchor = "middle";
|
| 354 |
+
valueLabelFill = "white";
|
| 355 |
+
} else {
|
| 356 |
+
// Place outside, right, default color
|
| 357 |
+
valueLabelX = barWidth + 5;
|
| 358 |
+
valueLabelAnchor = "start";
|
| 359 |
+
valueLabelFill = colorResolver.text({ fallback: "#000000" }).value;
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
// Create the actual value label with calculated properties
|
| 363 |
+
g.append("text")
|
| 364 |
+
.attr("x", valueLabelX)
|
| 365 |
+
.attr("y", yPos + barHeight / 2)
|
| 366 |
+
.attr("dy", "0.35em")
|
| 367 |
+
.attr("text-anchor", valueLabelAnchor)
|
| 368 |
+
.style("font-family", typography.annotation.font_family)
|
| 369 |
+
.style("font-size", `${barHeight * 0.7}px`)
|
| 370 |
+
.style("font-weight", typography.annotation.font_weight)
|
| 371 |
+
.style("fill", valueLabelFill)
|
| 372 |
+
.text(formattedValue);
|
| 373 |
+
// --- END MODIFICATION ---
|
| 374 |
+
});
|
| 375 |
+
});
|
| 376 |
+
|
| 377 |
+
// 返回SVG节点
|
| 378 |
+
return svg.node();
|
| 379 |
+
}
|
modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_11.js
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Horizontal Group Bar Chart",
|
| 5 |
+
"chart_name": "horizontal_group_bar_chart_11",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y", "group"],
|
| 8 |
+
"required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
|
| 9 |
+
"required_fields_range": [[2, 20], [0, "inf"], [2, 5]],
|
| 10 |
+
"required_fields_icons": ["x"],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": ["group"],
|
| 13 |
+
"required_other_colors": ["primary"],
|
| 14 |
+
"supported_effects": ["gradient"],
|
| 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 //horizontal_grouped_bar_chart_05
|
| 27 |
+
function makeChart(containerSelector, data) {
|
| 28 |
+
// ---------- 1. 数据准备阶段 ----------
|
| 29 |
+
|
| 30 |
+
// 提取数据和配置
|
| 31 |
+
const jsonData = data; // 完整的JSON数据对象
|
| 32 |
+
const chartData = jsonData.data.data; // 实际数据点数组
|
| 33 |
+
const variables = jsonData.variables || {}; // 图表配置
|
| 34 |
+
const typography = jsonData.typography || { // 字体设置,如果不存在则使用默认值
|
| 35 |
+
title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
|
| 36 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 37 |
+
description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 38 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 39 |
+
};
|
| 40 |
+
const colors = jsonData.colors || { text_color: "#333333" }; // 颜色设置
|
| 41 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 42 |
+
const images = jsonData.images || { field: {}, other: {} }; // 图像设置
|
| 43 |
+
const dataColumns = chartUtils.schema.columns(jsonData); // 数据列定义
|
| 44 |
+
|
| 45 |
+
// 清空容器
|
| 46 |
+
d3.select(containerSelector).html("");
|
| 47 |
+
|
| 48 |
+
// ---------- 2. 尺寸和布局设置 ----------
|
| 49 |
+
|
| 50 |
+
// 设置图表总尺寸
|
| 51 |
+
const width = variables.width || 800;
|
| 52 |
+
const height = variables.height || 600;
|
| 53 |
+
|
| 54 |
+
// 设置边距
|
| 55 |
+
const margin = {
|
| 56 |
+
top: 40, // 顶部留出标题空间
|
| 57 |
+
right: 60, // 右侧足够显示数值和分组标签
|
| 58 |
+
bottom: 30, // 底部边距
|
| 59 |
+
left: 40 // 左侧空间减少,因为我们不显示维度文本,只显示图标
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
// ---------- 3. 提取字段名和单位 ----------
|
| 63 |
+
|
| 64 |
+
// 根据数据列顺序提取字段名
|
| 65 |
+
const dimensionField = chartUtils.schema.channel(jsonData, "x").key;
|
| 66 |
+
const valueField = chartUtils.schema.channel(jsonData, "y").key;
|
| 67 |
+
|
| 68 |
+
const primaryGroupField = chartUtils.schema.channel(jsonData, "group").key;
|
| 69 |
+
|
| 70 |
+
// 获取字段单位(如果存在)
|
| 71 |
+
let dimensionUnit = "";
|
| 72 |
+
let valueUnit = "";
|
| 73 |
+
|
| 74 |
+
if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
|
| 75 |
+
dimensionUnit = chartUtils.schema.channel(jsonData, "x").unit;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
|
| 79 |
+
valueUnit = chartUtils.schema.channel(jsonData, "y").unit;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// ---------- 4. 数据处理 ----------
|
| 83 |
+
|
| 84 |
+
// 添加数值格式化函数
|
| 85 |
+
|
| 86 |
+
// 获取唯一维度值和分组值
|
| 87 |
+
const dimensions = [...new Set(chartData.map(d => d[dimensionField]))];
|
| 88 |
+
const primaryGroups = [...new Set(chartData.map(d => d[primaryGroupField]))];
|
| 89 |
+
|
| 90 |
+
// 按主要分组和数值进行分组和排序
|
| 91 |
+
const groupedData = {};
|
| 92 |
+
primaryGroups.forEach(group => {
|
| 93 |
+
// 过滤属于此组的数据
|
| 94 |
+
const groupItems = chartData.filter(d => d[primaryGroupField] === group);
|
| 95 |
+
|
| 96 |
+
// 按数值降序排序
|
| 97 |
+
const sortedItems = [...groupItems].sort((a, b) => +b[valueField] - +a[valueField]);
|
| 98 |
+
|
| 99 |
+
groupedData[group] = sortedItems;
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
// ---------- 5. 计算标签宽度 ----------
|
| 103 |
+
|
| 104 |
+
// 图标尺寸
|
| 105 |
+
const flagWidth = 20;
|
| 106 |
+
const flagHeight = 15;
|
| 107 |
+
const flagPadding = 5;
|
| 108 |
+
const labelMeasureOptions = {
|
| 109 |
+
fontFamily: typography.label.font_family,
|
| 110 |
+
fontSize: typography.label.font_size,
|
| 111 |
+
fontWeight: typography.label.font_weight
|
| 112 |
+
};
|
| 113 |
+
const annotationMeasureOptions = {
|
| 114 |
+
fontFamily: typography.annotation.font_family,
|
| 115 |
+
fontSize: typography.annotation.font_size,
|
| 116 |
+
fontWeight: typography.annotation.font_weight
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
// 计算最大数值标签宽度
|
| 120 |
+
let maxValueWidth = 0;
|
| 121 |
+
chartData.forEach(d => {
|
| 122 |
+
const formattedValue = valueUnit ?
|
| 123 |
+
`${chartUtils.format.autoText(+d[valueField])}${valueUnit}` :
|
| 124 |
+
`${chartUtils.format.autoText(+d[valueField])}`;
|
| 125 |
+
|
| 126 |
+
const textWidth = chartUtils.text.measure(null, formattedValue, annotationMeasureOptions).width;
|
| 127 |
+
|
| 128 |
+
maxValueWidth = Math.max(maxValueWidth, textWidth);
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
// *** BEGIN ADDITION: Calculate Max Dimension Label Width ***
|
| 132 |
+
let maxDimensionLabelWidth = 0;
|
| 133 |
+
dimensions.forEach(dimension => {
|
| 134 |
+
const formattedDimension = dimensionUnit ?
|
| 135 |
+
`${dimension}${dimensionUnit}` :
|
| 136 |
+
`${dimension}`;
|
| 137 |
+
|
| 138 |
+
const textWidth = chartUtils.text.measure(null, formattedDimension, labelMeasureOptions).width;
|
| 139 |
+
maxDimensionLabelWidth = Math.max(maxDimensionLabelWidth, textWidth);
|
| 140 |
+
});
|
| 141 |
+
// *** END ADDITION ***
|
| 142 |
+
|
| 143 |
+
// 计算分组注释的最大宽度 - Now used for Legend Items
|
| 144 |
+
let totalLegendWidth = 0;
|
| 145 |
+
const legendItemWidths = [];
|
| 146 |
+
const legendSwatchSize = 15;
|
| 147 |
+
const legendSwatchPadding = 5;
|
| 148 |
+
const legendItemSpacing = 15; // Spacing between legend items
|
| 149 |
+
|
| 150 |
+
primaryGroups.forEach(group => {
|
| 151 |
+
const textWidth = chartUtils.text.measure(null, group, labelMeasureOptions).width;
|
| 152 |
+
const itemWidth = legendSwatchSize + legendSwatchPadding + textWidth + legendItemSpacing;
|
| 153 |
+
legendItemWidths.push(itemWidth);
|
| 154 |
+
totalLegendWidth += itemWidth;
|
| 155 |
+
});
|
| 156 |
+
// Adjust total width (remove spacing after last item)
|
| 157 |
+
if (totalLegendWidth > 0) {
|
| 158 |
+
totalLegendWidth -= legendItemSpacing;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
// --- BEGIN LEGEND MARGIN ADJUSTMENT ---
|
| 162 |
+
const legendHeight = legendSwatchSize + 20; // Swatch height + top/bottom padding
|
| 163 |
+
margin.top += legendHeight;
|
| 164 |
+
// --- END LEGEND MARGIN ADJUSTMENT ---
|
| 165 |
+
|
| 166 |
+
// ** MODIFY ** 根据图标和最大标签尺寸调整左边距
|
| 167 |
+
const baseLeftPadding = 10; // Base padding to the left of the labels
|
| 168 |
+
margin.left = maxDimensionLabelWidth + flagPadding + flagWidth + flagPadding + baseLeftPadding;
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
// ---------- 6. 计算分组布局 ----------
|
| 172 |
+
|
| 173 |
+
// 计算内部绘图区域尺寸
|
| 174 |
+
const innerWidth = width - margin.left - margin.right;
|
| 175 |
+
let innerHeight = height - margin.top - margin.bottom; // Recalculate innerHeight
|
| 176 |
+
|
| 177 |
+
// 计算合适的条形高度,确保所有条形在可用高度内可见
|
| 178 |
+
// 计算总条形数量和组数
|
| 179 |
+
let totalBars = 0;
|
| 180 |
+
primaryGroups.forEach(group => {
|
| 181 |
+
totalBars += groupedData[group].length;
|
| 182 |
+
});
|
| 183 |
+
|
| 184 |
+
const numGroups = primaryGroups.length;
|
| 185 |
+
|
| 186 |
+
// 计算组间间距的总高度
|
| 187 |
+
const groupPaddingRatio = 0.15; // 组间距占总高度的比例
|
| 188 |
+
const groupPadding = innerHeight * groupPaddingRatio / (numGroups + 1); // 分配给每个组间距
|
| 189 |
+
const totalGroupPadding = groupPadding * (numGroups - 1);
|
| 190 |
+
|
| 191 |
+
// 计算可用于条形的总高度
|
| 192 |
+
const availableForBars = innerHeight - totalGroupPadding;
|
| 193 |
+
|
| 194 |
+
// 计算条形高度和条形间距
|
| 195 |
+
const barPaddingRatio = 0.15; // Use fixed padding ratio, remove spacing effect logic
|
| 196 |
+
|
| 197 |
+
// 计算理想的条形高度(包括间距)
|
| 198 |
+
const idealBarUnitHeight = availableForBars / totalBars;
|
| 199 |
+
|
| 200 |
+
// 实际条形高度(不包括间距)
|
| 201 |
+
const barHeight = idealBarUnitHeight * (1 - barPaddingRatio);
|
| 202 |
+
const barPadding = idealBarUnitHeight * barPaddingRatio;
|
| 203 |
+
|
| 204 |
+
// 计算每组的高度和偏移量
|
| 205 |
+
const groupHeights = {};
|
| 206 |
+
const groupOffsets = {};
|
| 207 |
+
|
| 208 |
+
let currentOffset = margin.top;
|
| 209 |
+
primaryGroups.forEach(group => {
|
| 210 |
+
const itemCount = groupedData[group].length;
|
| 211 |
+
// 计算当前组的总高度(包括所有条形和条形间距)
|
| 212 |
+
const groupHeight = itemCount * barHeight + (itemCount - 1) * barPadding;
|
| 213 |
+
|
| 214 |
+
groupHeights[group] = groupHeight;
|
| 215 |
+
groupOffsets[group] = currentOffset;
|
| 216 |
+
|
| 217 |
+
// 更新下一组的起始位置
|
| 218 |
+
currentOffset += groupHeight + groupPadding;
|
| 219 |
+
});
|
| 220 |
+
|
| 221 |
+
// ---------- 7. 创建SVG容器 ----------
|
| 222 |
+
|
| 223 |
+
const svg = d3.select(containerSelector)
|
| 224 |
+
.append("svg")
|
| 225 |
+
.attr("width", "100%")
|
| 226 |
+
.attr("height", height) // 使用定义的高度,不进行自动调整
|
| 227 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 228 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 229 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 230 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 231 |
+
|
| 232 |
+
// --- BEGIN Gradient Definitions ---
|
| 233 |
+
const defs = svg.append("defs");
|
| 234 |
+
|
| 235 |
+
// Moved getBarColor definition here (Needed for gradient definition)
|
| 236 |
+
const getBarColor = (group) => {
|
| 237 |
+
return colorResolver.field(group, 0, { fallbackKey: "primary", fallback: "#3ca951" }).value;
|
| 238 |
+
};
|
| 239 |
+
|
| 240 |
+
primaryGroups.forEach(group => {
|
| 241 |
+
const groupColor = getBarColor(group);
|
| 242 |
+
const gradientId = `bar-gradient-${group.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '')}`;
|
| 243 |
+
|
| 244 |
+
const gradient = defs.append("linearGradient")
|
| 245 |
+
.attr("id", gradientId)
|
| 246 |
+
.attr("x1", "0%")
|
| 247 |
+
.attr("y1", "0%")
|
| 248 |
+
.attr("x2", "100%") // Horizontal gradient
|
| 249 |
+
.attr("y2", "0%");
|
| 250 |
+
|
| 251 |
+
// 3-stop gradient: dark -> bright -> dark
|
| 252 |
+
gradient.append("stop")
|
| 253 |
+
.attr("offset", "0%")
|
| 254 |
+
.attr("stop-color", colorResolver.variant(groupColor, { mode: "darker", amount: 0.3 }));
|
| 255 |
+
|
| 256 |
+
gradient.append("stop")
|
| 257 |
+
.attr("offset", "50%")
|
| 258 |
+
.attr("stop-color", colorResolver.variant(groupColor, { mode: "brighter", amount: 1.5 })); // Adjusted brightness a bit from 2.5
|
| 259 |
+
|
| 260 |
+
gradient.append("stop")
|
| 261 |
+
.attr("offset", "100%")
|
| 262 |
+
.attr("stop-color", colorResolver.variant(groupColor, { mode: "darker", amount: 0.3 }));
|
| 263 |
+
});
|
| 264 |
+
// --- END Gradient Definitions ---
|
| 265 |
+
|
| 266 |
+
// --- BEGIN LEGEND CREATION ---
|
| 267 |
+
// getBarColor is defined above now
|
| 268 |
+
chartUtils.legend.centered(svg, primaryGroups, {
|
| 269 |
+
color: group => getBarColor(group),
|
| 270 |
+
colorResolver,
|
| 271 |
+
colors,
|
| 272 |
+
}, width / 2, legendHeight / 2 + 80, {
|
| 273 |
+
markerShape: "rect",
|
| 274 |
+
markerSize: 15,
|
| 275 |
+
labelGap: 5,
|
| 276 |
+
itemGap: 15,
|
| 277 |
+
fontSize: typography.label.font_size,
|
| 278 |
+
fontFamily: typography.label.font_family,
|
| 279 |
+
fontWeight: typography.label.font_weight,
|
| 280 |
+
textColor: colorResolver.text({ fallback: "#333333" }).value,
|
| 281 |
+
markerRadius: variables.has_rounded_corners ? 2 : 0,
|
| 282 |
+
});
|
| 283 |
+
|
| 284 |
+
// ---------- 8. 创建比例尺 ----------
|
| 285 |
+
|
| 286 |
+
// X轴比例尺(用于数值)
|
| 287 |
+
const xScale = d3.scaleLinear()
|
| 288 |
+
.domain([0, d3.max(chartData, d => +d[valueField]) * 1.05]) // 添加5%边距
|
| 289 |
+
.range([0, innerWidth]);
|
| 290 |
+
|
| 291 |
+
// ---------- 9. 创建主图表组 ----------
|
| 292 |
+
|
| 293 |
+
const g = svg.append("g")
|
| 294 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`); // Adjust group transform for new margin
|
| 295 |
+
|
| 296 |
+
// ---------- 10. 渲染每个分组 ----------
|
| 297 |
+
|
| 298 |
+
// 遍历每个主要分组,添加背景矩形
|
| 299 |
+
primaryGroups.forEach(group => {
|
| 300 |
+
const groupData = groupedData[group];
|
| 301 |
+
const groupOffset = groupOffsets[group];
|
| 302 |
+
const groupHeight = groupHeights[group];
|
| 303 |
+
const barColor = getBarColor(group);
|
| 304 |
+
|
| 305 |
+
// 为组中的每个项目绘制条形和标签
|
| 306 |
+
groupData.forEach((dataPoint, index) => {
|
| 307 |
+
// 计算条形的y坐标,考虑到条形间距
|
| 308 |
+
const yPos = groupOffset + index * (barHeight + barPadding);
|
| 309 |
+
const barWidth = xScale(+dataPoint[valueField]);
|
| 310 |
+
|
| 311 |
+
// Draw the bar using a simple rect element
|
| 312 |
+
g.append("rect") // Revert back to rect
|
| 313 |
+
.attr("x", 0)
|
| 314 |
+
.attr("y", yPos)
|
| 315 |
+
.attr("width", barWidth)
|
| 316 |
+
.attr("height", barHeight)
|
| 317 |
+
// Apply gradient fill
|
| 318 |
+
.attr("fill", `url(#bar-gradient-${group.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '')})`)
|
| 319 |
+
.attr("rx", barHeight/6)
|
| 320 |
+
.attr("ry", barHeight/6)
|
| 321 |
+
.on("mouseover", function() {
|
| 322 |
+
d3.select(this).attr("opacity", 0.8);
|
| 323 |
+
})
|
| 324 |
+
.on("mouseout", function() {
|
| 325 |
+
d3.select(this).attr("opacity", 1);
|
| 326 |
+
});
|
| 327 |
+
|
| 328 |
+
// 提取维度
|
| 329 |
+
const dimension = dataPoint[dimensionField];
|
| 330 |
+
|
| 331 |
+
// 添加国家/维度图标 (Positioned left of bar)
|
| 332 |
+
const flagX = -flagWidth - flagPadding; // Icon right edge at -flagPadding
|
| 333 |
+
const flagY = yPos + (barHeight - flagHeight) / 2;
|
| 334 |
+
|
| 335 |
+
if (images.field && images.field[dimension]) {
|
| 336 |
+
g.append("image")
|
| 337 |
+
.attr("x", flagX)
|
| 338 |
+
.attr("y", flagY)
|
| 339 |
+
.attr("width", flagWidth)
|
| 340 |
+
.attr("height", flagHeight)
|
| 341 |
+
.attr("preserveAspectRatio","xMidYMid meet")
|
| 342 |
+
.attr("xlink:href", images.field[dimension]);
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
// *** BEGIN ADDITION: Add Dimension Label (Left of Icon, Right Aligned) ***
|
| 346 |
+
const labelX = flagX - flagPadding; // Label right edge next to icon's left edge
|
| 347 |
+
const labelY = yPos + barHeight / 2; // Vertically centered with bar
|
| 348 |
+
const formattedDimension = dimensionUnit ?
|
| 349 |
+
`${dimension}${dimensionUnit}` :
|
| 350 |
+
`${dimension}`;
|
| 351 |
+
|
| 352 |
+
g.append("text")
|
| 353 |
+
.attr("class", "dimension-label")
|
| 354 |
+
.attr("x", labelX)
|
| 355 |
+
.attr("y", labelY)
|
| 356 |
+
.attr("dy", "0.35em")
|
| 357 |
+
.attr("text-anchor", "end") // Right align text
|
| 358 |
+
.style("font-family", typography.label.font_family)
|
| 359 |
+
.style("font-size", typography.label.font_size)
|
| 360 |
+
.style("font-weight", typography.label.font_weight)
|
| 361 |
+
.style("fill", colorResolver.text({ fallback: "#000000" }).value)
|
| 362 |
+
.text(formattedDimension);
|
| 363 |
+
// *** END ADDITION ***
|
| 364 |
+
|
| 365 |
+
// 添加数值标签
|
| 366 |
+
const formattedValue = valueUnit ?
|
| 367 |
+
`${chartUtils.format.autoText(+dataPoint[valueField])}${valueUnit}` :
|
| 368 |
+
`${chartUtils.format.autoText(+dataPoint[valueField])}`;
|
| 369 |
+
|
| 370 |
+
// --- BEGIN MODIFICATION: Force Label Outside ---
|
| 371 |
+
|
| 372 |
+
const valueLabelX = barWidth + 5;
|
| 373 |
+
const valueLabelAnchor = "start";
|
| 374 |
+
const valueLabelFill = colorResolver.text({ fallback: "#000000" }).value;
|
| 375 |
+
|
| 376 |
+
// Create the actual value label with fixed properties
|
| 377 |
+
g.append("text")
|
| 378 |
+
.attr("x", valueLabelX)
|
| 379 |
+
.attr("y", yPos + barHeight / 2)
|
| 380 |
+
.attr("dy", "0.35em")
|
| 381 |
+
.attr("text-anchor", valueLabelAnchor)
|
| 382 |
+
.style("font-family", typography.annotation.font_family)
|
| 383 |
+
.style("font-size", `${barHeight * 0.7}px`)
|
| 384 |
+
.style("font-weight", typography.annotation.font_weight)
|
| 385 |
+
.style("fill", valueLabelFill)
|
| 386 |
+
.text(formattedValue);
|
| 387 |
+
// --- END MODIFICATION ---
|
| 388 |
+
});
|
| 389 |
+
});
|
| 390 |
+
|
| 391 |
+
// 返回SVG节点
|
| 392 |
+
return svg.node();
|
| 393 |
+
}
|
modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_12.js
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Horizontal Group Bar Chart",
|
| 5 |
+
"chart_name": "horizontal_group_bar_chart_12",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y", "group"],
|
| 8 |
+
"required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
|
| 9 |
+
"required_fields_range": [[2, 30], [0, "inf"], [2, 10]],
|
| 10 |
+
"required_fields_icons": ["x"],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": ["group"],
|
| 13 |
+
"required_other_colors": [],
|
| 14 |
+
"supported_effects": [],
|
| 15 |
+
"min_height": 400,
|
| 16 |
+
"min_width": 400,
|
| 17 |
+
"background": "styled",
|
| 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 horizontal_grouped_bar_chart_01
|
| 27 |
+
function makeChart(containerSelector, data) {
|
| 28 |
+
// ---------- 1. 数据准备阶段 ----------
|
| 29 |
+
|
| 30 |
+
// 提取数据和配置
|
| 31 |
+
const jsonData = data; // 完整的JSON数据对象
|
| 32 |
+
const chartData = jsonData.data.data; // 实际数据点数组
|
| 33 |
+
const variables = jsonData.variables || {}; // 图表配置
|
| 34 |
+
const typography = jsonData.typography || { // 字体设置,如果不存在则使用默认值
|
| 35 |
+
title: { font_family: "'Comic Sans MS', cursive", font_size: "18px", font_weight: "bold" },
|
| 36 |
+
label: { font_family: "'Comic Sans MS', cursive", font_size: "12px", font_weight: "normal" },
|
| 37 |
+
description: { font_family: "'Comic Sans MS', cursive", font_size: "14px", font_weight: "normal" },
|
| 38 |
+
annotation: { font_family: "'Comic Sans MS', cursive", font_size: "12px", font_weight: "normal" }
|
| 39 |
+
};
|
| 40 |
+
const colors = jsonData.colors || { text_color: "#333333" }; // 颜色设置
|
| 41 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 42 |
+
const images = jsonData.images || { field: {}, other: {} }; // 图像设置
|
| 43 |
+
const dataColumns = chartUtils.schema.columns(jsonData); // 数据列定义
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
// 清空容器
|
| 48 |
+
d3.select(containerSelector).html("");
|
| 49 |
+
|
| 50 |
+
// ---------- 2. 尺寸和布局设置 ----------
|
| 51 |
+
|
| 52 |
+
// 设置图表总尺寸和边距
|
| 53 |
+
const width = variables.width || 800;
|
| 54 |
+
const height = variables.height || 600;
|
| 55 |
+
|
| 56 |
+
// 为标题和副标题预留空间,即使不显示它们
|
| 57 |
+
const titleHeight = 70; // 为标题预留至少70的高度
|
| 58 |
+
|
| 59 |
+
// 分析图表区域底部边距
|
| 60 |
+
const bottomMargin = 80;
|
| 61 |
+
|
| 62 |
+
// 初始设置边距
|
| 63 |
+
const margin = {
|
| 64 |
+
top: titleHeight, // 顶部留出标题空间
|
| 65 |
+
right: 80, // 右侧足够显示数值
|
| 66 |
+
bottom: bottomMargin, // 底部边距
|
| 67 |
+
left: 150 // 左侧暂时设为最小值,稍后会根据标签长度调整
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
// ---------- 3. 提取字段名和单位 ----------
|
| 71 |
+
const dimensionField = chartUtils.schema.channel(jsonData, "x").key;
|
| 72 |
+
const valueField = chartUtils.schema.channel(jsonData, "y").key;
|
| 73 |
+
const groupField = chartUtils.schema.channel(jsonData, "group").key;
|
| 74 |
+
|
| 75 |
+
// 获取字段单位(如果存在)
|
| 76 |
+
let dimensionUnit = "";
|
| 77 |
+
let valueUnit = "";
|
| 78 |
+
let groupUnit = "";
|
| 79 |
+
|
| 80 |
+
if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
|
| 81 |
+
dimensionUnit = chartUtils.schema.channel(jsonData, "x").unit;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
|
| 85 |
+
valueUnit = chartUtils.schema.channel(jsonData, "y").unit;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
if (chartUtils.schema.channel(jsonData, "group").unit !== "none") {
|
| 89 |
+
groupUnit = chartUtils.schema.channel(jsonData, "group").unit;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
// ---------- 4. 数据处理 ----------
|
| 94 |
+
|
| 95 |
+
// 获取维度(如Gen Z, Millennials等)和分组(如$50 or more, $1-49, Nothing)
|
| 96 |
+
const dimensions = [...new Set(chartData.map(d => d[dimensionField]))];
|
| 97 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 98 |
+
|
| 99 |
+
// ---------- 5. 计算标签和图标空间 ----------
|
| 100 |
+
|
| 101 |
+
// 创建临时SVG用于测量文本宽度
|
| 102 |
+
const tempSvg = d3.select(containerSelector)
|
| 103 |
+
.append("svg")
|
| 104 |
+
.attr("width", 0)
|
| 105 |
+
.attr("height", 0)
|
| 106 |
+
.style("visibility", "hidden");
|
| 107 |
+
|
| 108 |
+
// 计算维度标签最大宽度
|
| 109 |
+
let maxDimensionWidth = 0;
|
| 110 |
+
dimensions.forEach(dimension => {
|
| 111 |
+
const textWidth = chartUtils.text.measure(tempSvg, dimension, {
|
| 112 |
+
fontFamily: "'Comic Sans MS', cursive",
|
| 113 |
+
fontSize: typography.label.font_size
|
| 114 |
+
}).width;
|
| 115 |
+
maxDimensionWidth = Math.max(maxDimensionWidth, textWidth);
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
// 计算分组标签最大宽度
|
| 119 |
+
let maxGroupWidth = 0;
|
| 120 |
+
groups.forEach(group => {
|
| 121 |
+
const textWidth = chartUtils.text.measure(tempSvg, group, {
|
| 122 |
+
fontFamily: "'Comic Sans MS', cursive",
|
| 123 |
+
fontSize: typography.label.font_size,
|
| 124 |
+
fontWeight: "bold"
|
| 125 |
+
}).width;
|
| 126 |
+
maxGroupWidth = Math.max(maxGroupWidth, textWidth);
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
// 删��临时SVG
|
| 130 |
+
tempSvg.remove();
|
| 131 |
+
|
| 132 |
+
// === 修改点:结合维度标签和组标签最大宽度,重新计算margin.left ===
|
| 133 |
+
const maxLabelWidth = Math.max(maxDimensionWidth, maxGroupWidth);
|
| 134 |
+
const sketchPadding = 5; // 背景内边距
|
| 135 |
+
const dimensionLabelWidth = maxLabelWidth + 2 * sketchPadding + 20; // 标签宽度 + 背景padding + 额外空间
|
| 136 |
+
margin.left = Math.max(margin.left, dimensionLabelWidth + 20);
|
| 137 |
+
|
| 138 |
+
// 计算实际绘图区域尺寸
|
| 139 |
+
const innerWidth = width - margin.left - margin.right;
|
| 140 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 141 |
+
|
| 142 |
+
// *** 修改: 移除间距相关的计算 ***
|
| 143 |
+
// const groupPadding = innerHeight * (variables.has_spacing ? 0.08 : 0.06); // 移除组间距
|
| 144 |
+
const groupHeight = innerHeight / groups.length; // 每组的原始高度
|
| 145 |
+
|
| 146 |
+
// *** 修改: 移除间距对barHeight的影响 ***
|
| 147 |
+
// const barHeight = (groupHeight - groupPadding) / dimensions.length;
|
| 148 |
+
const barHeight = groupHeight / dimensions.length; // 维度在组内均分高度
|
| 149 |
+
// const actualBarHeight = variables.has_spacing ? barHeight * 0.65 : barHeight * 0.75; // 移除间距影响
|
| 150 |
+
const actualBarHeight = barHeight * 0.75; // 固定条形实际高度比例
|
| 151 |
+
|
| 152 |
+
// 标志尺寸
|
| 153 |
+
const iconWidth = actualBarHeight * 0.9;
|
| 154 |
+
const iconHeight = iconWidth;
|
| 155 |
+
const iconRightPadding = iconWidth * 0.4; // 图标右侧到柱子的间距
|
| 156 |
+
|
| 157 |
+
// ---------- 6. 创建SVG容器 ----------
|
| 158 |
+
|
| 159 |
+
const svg = d3.select(containerSelector)
|
| 160 |
+
.append("svg")
|
| 161 |
+
.attr("width", "100%")
|
| 162 |
+
.attr("height", height)
|
| 163 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 164 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 165 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 166 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 167 |
+
|
| 168 |
+
// 创建defs用于滤镜和渐变
|
| 169 |
+
const defs = svg.append("defs");
|
| 170 |
+
|
| 171 |
+
// ---------- 6.1 创建视觉效果 ----------
|
| 172 |
+
|
| 173 |
+
// --- Sketch Pattern for Left Bars (or even index groups) ---
|
| 174 |
+
const patternLeft = defs.append("pattern")
|
| 175 |
+
.attr("id", "pattern-sketch-left")
|
| 176 |
+
.attr("width", 8)
|
| 177 |
+
.attr("height", 8)
|
| 178 |
+
.attr("patternUnits", "userSpaceOnUse")
|
| 179 |
+
.attr("patternTransform", "rotate(45)");
|
| 180 |
+
|
| 181 |
+
patternLeft.append("rect")
|
| 182 |
+
.attr("width", 8)
|
| 183 |
+
.attr("height", 8)
|
| 184 |
+
.attr("fill", "#4269d0"); // 使用一个基色,具体颜色会被覆盖
|
| 185 |
+
|
| 186 |
+
patternLeft.append("path")
|
| 187 |
+
.attr("d", "M -1,2 l 10,0 M -1,5 l 10,0 M -1,8 l 10,0")
|
| 188 |
+
.attr("stroke", "#FFFFFF")
|
| 189 |
+
.attr("stroke-width", 0.8);
|
| 190 |
+
|
| 191 |
+
// --- Sketch Pattern for Right Bars (or odd index groups) ---
|
| 192 |
+
const patternRight = defs.append("pattern")
|
| 193 |
+
.attr("id", "pattern-sketch-right")
|
| 194 |
+
.attr("width", 8)
|
| 195 |
+
.attr("height", 8)
|
| 196 |
+
.attr("patternUnits", "userSpaceOnUse")
|
| 197 |
+
.attr("patternTransform", "rotate(-45)"); // 反向旋转
|
| 198 |
+
|
| 199 |
+
patternRight.append("rect")
|
| 200 |
+
.attr("width", 8)
|
| 201 |
+
.attr("height", 8)
|
| 202 |
+
.attr("fill", "#ff725c"); // 使用另一个基色
|
| 203 |
+
|
| 204 |
+
patternRight.append("path")
|
| 205 |
+
.attr("d", "M -1,2 l 10,0 M -1,5 l 10,0 M -1,8 l 10,0")
|
| 206 |
+
.attr("stroke", "#FFFFFF")
|
| 207 |
+
.attr("stroke-width", 0.8);
|
| 208 |
+
|
| 209 |
+
// --- Sketch Pattern for Label Backgrounds ---
|
| 210 |
+
const patternLabelSketch = defs.append("pattern")
|
| 211 |
+
.attr("id", "pattern-label-sketch")
|
| 212 |
+
.attr("width", 8)
|
| 213 |
+
.attr("height", 8)
|
| 214 |
+
.attr("patternUnits", "userSpaceOnUse")
|
| 215 |
+
.attr("patternTransform", "rotate(45)");
|
| 216 |
+
|
| 217 |
+
patternLabelSketch.append("rect")
|
| 218 |
+
.attr("width", 8)
|
| 219 |
+
.attr("height", 8)
|
| 220 |
+
.attr("fill", "#FFFFFF");
|
| 221 |
+
|
| 222 |
+
patternLabelSketch.append("path")
|
| 223 |
+
.attr("d", "M -1,2 l 10,0 M -1,5 l 10,0 M -1,8 l 10,0")
|
| 224 |
+
.attr("stroke", "#CCCCCC")
|
| 225 |
+
.attr("stroke-width", 0.6);
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
// ---------- 7. 创建比例尺 ----------
|
| 229 |
+
|
| 230 |
+
// 计算最大值用于X轴比例尺
|
| 231 |
+
const maxValue = d3.max(chartData, d => +d[valueField]);
|
| 232 |
+
|
| 233 |
+
// 添加数值格式化函数
|
| 234 |
+
|
| 235 |
+
// 值的X比例尺
|
| 236 |
+
const xScale = d3.scaleLinear()
|
| 237 |
+
.domain([0, maxValue * 1.1]) // 添加10%边距
|
| 238 |
+
.range([0, innerWidth]);
|
| 239 |
+
|
| 240 |
+
// ---------- 8. 创建主图表组 ----------
|
| 241 |
+
|
| 242 |
+
const g = svg.append("g")
|
| 243 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 244 |
+
|
| 245 |
+
// ---------- 9. 辅助函数 ----------
|
| 246 |
+
|
| 247 |
+
// 获取组颜色
|
| 248 |
+
function getGroupColor(group) {
|
| 249 |
+
// 优先使用指定颜色
|
| 250 |
+
if (colorResolver.field(group, 0, { fallbackKey: "primary" }).value) {
|
| 251 |
+
return colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
// 如果有可用颜色数组,按索引使用
|
| 255 |
+
const groupIndex = groups.indexOf(group);
|
| 256 |
+
const availableColor = colorResolver.available(groupIndex, { cycle: true, usePalette: false }).value;
|
| 257 |
+
if (availableColor) return availableColor;
|
| 258 |
+
|
| 259 |
+
// Provide default fallback colors if none are defined
|
| 260 |
+
const defaultColors = ["#4269d0", "#ff725c", "#4CAF50", "#FFC107", "#9C27B0", "#795548"];
|
| 261 |
+
return defaultColors[groupIndex % defaultColors.length];
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
// 获取描边颜色
|
| 265 |
+
const getStrokeColor = () => {
|
| 266 |
+
return "#555"; // 固定手绘描边颜色
|
| 267 |
+
};
|
| 268 |
+
|
| 269 |
+
// *** 添加: 计算字体大小的函数 (从 vertical_group_bar_chart_13 复制) ***
|
| 270 |
+
const calculateFontSize = (text, maxWidth, baseSize = 12) => {
|
| 271 |
+
const avgCharWidth = baseSize * 0.6;
|
| 272 |
+
const textWidth = text.length * avgCharWidth;
|
| 273 |
+
if (textWidth < maxWidth) {
|
| 274 |
+
return baseSize;
|
| 275 |
+
}
|
| 276 |
+
return Math.max(8, Math.floor(baseSize * (maxWidth / textWidth))); // 最小字体8px
|
| 277 |
+
};
|
| 278 |
+
|
| 279 |
+
// ========== 9. 添加交替行背景(新增) ==========
|
| 280 |
+
if (jsonData.variation?.background === "styled") {
|
| 281 |
+
// 每个 group 下有若干维度,故在每个 group 里为维度的"行"画背景
|
| 282 |
+
groups.forEach((group, groupIndex) => {
|
| 283 |
+
const groupStartY = groupIndex * groupHeight;
|
| 284 |
+
dimensions.forEach((dimension, dimIndex) => {
|
| 285 |
+
// 偶数行添加背景
|
| 286 |
+
if (dimIndex % 2 === 0) {
|
| 287 |
+
g.append("rect")
|
| 288 |
+
.attr("x", 0)
|
| 289 |
+
.attr("y", groupStartY + dimIndex * barHeight)
|
| 290 |
+
.attr("width", innerWidth)
|
| 291 |
+
.attr("height", barHeight)
|
| 292 |
+
.attr("class","background")
|
| 293 |
+
.attr("fill", "#f0f0f0") // 稍微调整背景色
|
| 294 |
+
.attr("opacity", 0.7); // 稍微调整透明度
|
| 295 |
+
}
|
| 296 |
+
});
|
| 297 |
+
});
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
// ---------- 10. 绘制图表 ----------
|
| 301 |
+
|
| 302 |
+
// 为每个组创建一个分组
|
| 303 |
+
groups.forEach((group, groupIndex) => {
|
| 304 |
+
// 计算该组的垂直位置
|
| 305 |
+
const groupStartY = groupIndex * (groupHeight);
|
| 306 |
+
|
| 307 |
+
// 筛选该组的数据
|
| 308 |
+
const groupData = chartData.filter(d => d[groupField] === group);
|
| 309 |
+
|
| 310 |
+
// *** 添加: 确定该组使用的手绘图案 ***
|
| 311 |
+
const barPatternId = groupIndex % 2 === 0 ? "url(#pattern-sketch-left)" : "url(#pattern-sketch-right)";
|
| 312 |
+
// *** 添加: 获取组的实际颜色,用于图案背景 (可选,如果需要动态颜色图案) ***
|
| 313 |
+
const groupBaseColor = getGroupColor(group);
|
| 314 |
+
// *** 可选: 更新对应 pattern 的 rect fill (如果需要动态颜色) ***
|
| 315 |
+
// d3.select(`#${barPatternId.slice(5, -1)} rect`).attr("fill", groupBaseColor);
|
| 316 |
+
|
| 317 |
+
// 为每个维度绘制条形
|
| 318 |
+
dimensions.forEach((dimension, dimIndex) => {
|
| 319 |
+
// 查找数据点
|
| 320 |
+
const dataPoint = groupData.find(d => d[dimensionField] === dimension);
|
| 321 |
+
|
| 322 |
+
if (dataPoint) {
|
| 323 |
+
// 条形的垂直位置
|
| 324 |
+
const barY = groupStartY + dimIndex * barHeight;
|
| 325 |
+
|
| 326 |
+
// 条形宽度
|
| 327 |
+
const barWidth = xScale(+dataPoint[valueField]);
|
| 328 |
+
|
| 329 |
+
// 创建标签和图标组
|
| 330 |
+
const labelGroup = g.append("g")
|
| 331 |
+
.attr("transform", `translate(0, ${barY + actualBarHeight / 2})`);
|
| 332 |
+
|
| 333 |
+
// 绘制图标
|
| 334 |
+
if (images.field && images.field[dimension]) {
|
| 335 |
+
// 图标位置:紧贴条形左侧
|
| 336 |
+
labelGroup.append("image")
|
| 337 |
+
.attr("x", -iconWidth - iconRightPadding)
|
| 338 |
+
.attr("y", -iconHeight/2)
|
| 339 |
+
.attr("width", iconWidth)
|
| 340 |
+
.attr("height", iconHeight)
|
| 341 |
+
.attr("preserveAspectRatio","xMidYMid meet")
|
| 342 |
+
.attr("xlink:href", images.field[dimension]);
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
// *** 修改: 绘制带手绘背景的维度标签 ***
|
| 346 |
+
const dimensionTextContent = dimension;
|
| 347 |
+
const dimensionLabelX = -iconWidth - iconRightPadding - 15; // 文本右边缘 X 坐标
|
| 348 |
+
const dimensionBaseFontSize = parseFloat(typography.label.font_size);
|
| 349 |
+
const dimensionMaxTextHeight = actualBarHeight * 0.8; // 限制最大高度
|
| 350 |
+
const dimensionFontSize = Math.min(dimensionBaseFontSize, dimensionMaxTextHeight);
|
| 351 |
+
|
| 352 |
+
const dimBBox = chartUtils.text.measure(labelGroup, dimensionTextContent, {
|
| 353 |
+
fontFamily: "'Comic Sans MS', cursive",
|
| 354 |
+
fontSize: `${dimensionFontSize}px`,
|
| 355 |
+
fontWeight: typography.label.font_weight
|
| 356 |
+
});
|
| 357 |
+
|
| 358 |
+
// 3. 计算背景尺寸和位置
|
| 359 |
+
const dimBgWidth = dimBBox.width + 2 * sketchPadding;
|
| 360 |
+
const dimBgHeight = dimBBox.height + 1.5 * sketchPadding;
|
| 361 |
+
const dimBgX = dimensionLabelX - dimBBox.width - sketchPadding; // 背景左上角X
|
| 362 |
+
const dimBgY = -dimBgHeight / 2; // 背景垂直居中
|
| 363 |
+
|
| 364 |
+
// 4. 绘制背景 (在文本之前插入)
|
| 365 |
+
labelGroup.insert("rect", ":first-child") // 插入为第一个子元素
|
| 366 |
+
.attr("x", dimBgX)
|
| 367 |
+
.attr("y", dimBgY)
|
| 368 |
+
.attr("width", dimBgWidth)
|
| 369 |
+
.attr("height", dimBgHeight)
|
| 370 |
+
.attr("fill", "url(#pattern-label-sketch)")
|
| 371 |
+
.attr("stroke", "#AAAAAA")
|
| 372 |
+
.attr("stroke-width", 0.5);
|
| 373 |
+
|
| 374 |
+
// 5. 绘制最终文本 (在背景之上)
|
| 375 |
+
labelGroup.append("text")
|
| 376 |
+
.attr("class", "dimension-label")
|
| 377 |
+
.attr("x", dimensionLabelX)
|
| 378 |
+
.attr("y", 0)
|
| 379 |
+
.attr("dy", "0.35em")
|
| 380 |
+
.attr("text-anchor", "end")
|
| 381 |
+
.style("font-family", "'Comic Sans MS', cursive") // *** 强制字体 ***
|
| 382 |
+
.style("font-size", `${dimensionFontSize}px`)
|
| 383 |
+
.style("font-weight", typography.label.font_weight)
|
| 384 |
+
.style("fill", colorResolver.text({ fallback: "#333" }).value)
|
| 385 |
+
.text(dimensionTextContent);
|
| 386 |
+
|
| 387 |
+
// *** 修改: 绘制条形,使用手绘图案和描边 ***
|
| 388 |
+
g.append("rect")
|
| 389 |
+
.attr("class", `bar group-${groupIndex}`)
|
| 390 |
+
.attr("x", 0)
|
| 391 |
+
.attr("y", barY)
|
| 392 |
+
.attr("width", barWidth)
|
| 393 |
+
.attr("height", actualBarHeight)
|
| 394 |
+
.attr("fill", barPatternId) // 应用手绘图案
|
| 395 |
+
.attr("rx", 5) // *** 强制圆角 ***
|
| 396 |
+
.attr("ry", 5) // *** 强制圆角 ***
|
| 397 |
+
.style("stroke", variables.has_stroke ? getStrokeColor() : "none") // 应用手绘描边
|
| 398 |
+
.style("stroke-width", variables.has_stroke ? 1 : 0) // 固定描边宽度
|
| 399 |
+
// .style("filter", variables.has_shadow ? "url(#shadow)" : "none") // *** 移除阴影应用 ***
|
| 400 |
+
.on("mouseover", function() {
|
| 401 |
+
d3.select(this).attr("opacity", 0.8);
|
| 402 |
+
})
|
| 403 |
+
.on("mouseout", function() {
|
| 404 |
+
d3.select(this).attr("opacity", 1);
|
| 405 |
+
});
|
| 406 |
+
|
| 407 |
+
// *** 修改: 绘制带手绘背景的数值标签 ***
|
| 408 |
+
const formattedValue = valueUnit ?
|
| 409 |
+
`${chartUtils.format.autoText(+dataPoint[valueField])}${valueUnit}` :
|
| 410 |
+
`${chartUtils.format.autoText(+dataPoint[valueField])}`;
|
| 411 |
+
const valueLabelBaseFontSize = Math.min(parseFloat(typography.annotation.font_size), actualBarHeight * 0.55); // 基础字体大小
|
| 412 |
+
|
| 413 |
+
// -- 创建一个组来容纳标签和背景 --
|
| 414 |
+
const valueLabelGroup = g.append("g")
|
| 415 |
+
.attr("class", "value-label-group");
|
| 416 |
+
|
| 417 |
+
const valueBBox = chartUtils.text.measure(valueLabelGroup, formattedValue, {
|
| 418 |
+
fontFamily: typography.annotation.font_family,
|
| 419 |
+
fontSize: `${valueLabelBaseFontSize}px`,
|
| 420 |
+
fontWeight: "bold"
|
| 421 |
+
});
|
| 422 |
+
|
| 423 |
+
// 3. 计算背景尺寸和位置 (相对于组的0,0) - 用于居中
|
| 424 |
+
const valueBgWidth = valueBBox.width + 2 * sketchPadding;
|
| 425 |
+
const valueBgHeight = valueBBox.height + 1.5 * sketchPadding;
|
| 426 |
+
// *** 修改: 背景定位改为相对中心 (0,0) ***
|
| 427 |
+
const valueBgX = -valueBgWidth / 2;
|
| 428 |
+
const valueBgY = -valueBgHeight / 2;
|
| 429 |
+
|
| 430 |
+
// 4. 绘制背景
|
| 431 |
+
valueLabelGroup.append("rect")
|
| 432 |
+
.attr("class", "value-label-background")
|
| 433 |
+
.attr("x", valueBgX)
|
| 434 |
+
.attr("y", valueBgY)
|
| 435 |
+
.attr("width", valueBgWidth)
|
| 436 |
+
.attr("height", valueBgHeight)
|
| 437 |
+
.attr("fill", "url(#pattern-label-sketch)")
|
| 438 |
+
.attr("stroke", "#AAAAAA")
|
| 439 |
+
.attr("stroke-width", 0.5);
|
| 440 |
+
|
| 441 |
+
// 5. 绘制最终文本 (在组内居中)
|
| 442 |
+
valueLabelGroup.append("text")
|
| 443 |
+
.attr("class", "value-label")
|
| 444 |
+
.attr("x", 0) // *** 文本X居中 ***
|
| 445 |
+
.attr("y", 0) // *** 文本Y基线调整 ***
|
| 446 |
+
.attr("dy", "0em") // 配合 dominant-baseline
|
| 447 |
+
.attr("text-anchor", "middle") // *** 文本水平居中 ***
|
| 448 |
+
.attr("dominant-baseline", "middle") // *** 文本垂直居中 ***
|
| 449 |
+
.style("fill", colorResolver.text({ fallback: "#333" }).value)
|
| 450 |
+
.style("font-family", "'Comic Sans MS', cursive") // *** 强制字体 ***
|
| 451 |
+
.style("font-size", `${valueLabelBaseFontSize}px`)
|
| 452 |
+
.style("font-weight", "bold")
|
| 453 |
+
.style("pointer-events", "none")
|
| 454 |
+
.text(formattedValue);
|
| 455 |
+
|
| 456 |
+
// 6. 定位整个组 (调整X坐标以考虑背景宽度)
|
| 457 |
+
// const valueLabelGroupX = barWidth + 8; // 旧的起始X
|
| 458 |
+
const valueLabelGroupX = barWidth + 8 + valueBgWidth / 2; // *** 调整组X位置,使其中心在 bar 右侧 8px 处 ***
|
| 459 |
+
const valueLabelGroupY = barY + actualBarHeight / 2; // 组的Y位置:条形垂直中心
|
| 460 |
+
valueLabelGroup.attr("transform", `translate(${valueLabelGroupX}, ${valueLabelGroupY})`);
|
| 461 |
+
}
|
| 462 |
+
});
|
| 463 |
+
});
|
| 464 |
+
|
| 465 |
+
// *** 添加: wrapText 辅助函数 (如果尚未存在,需要从 vertical_group_bar_chart_13 复制或确保可用) ***
|
| 466 |
+
// (假设 wrapText 函数已存在或已添加)
|
| 467 |
+
function wrapText(text, str, width, lineHeight) {
|
| 468 |
+
const words = str.split(/\s+/).reverse();
|
| 469 |
+
let word;
|
| 470 |
+
let line = [];
|
| 471 |
+
let lineNumber = 0;
|
| 472 |
+
const y = text.attr("y"); // 获取原始 y
|
| 473 |
+
const x = text.attr("x"); // 获取原始 x
|
| 474 |
+
const dy = parseFloat(text.attr("dy") || 0); // 获取原始 dy
|
| 475 |
+
|
| 476 |
+
text.text(null); // 清空现有文本
|
| 477 |
+
|
| 478 |
+
let tspans = [];
|
| 479 |
+
|
| 480 |
+
// 优先按单词换行
|
| 481 |
+
if (words.length > 1 && words.some(w => w.length > 0)) { // Check for actual words
|
| 482 |
+
let currentLine = [];
|
| 483 |
+
while (word = words.pop()) {
|
| 484 |
+
currentLine.push(word);
|
| 485 |
+
const isOverflow = chartUtils.text.measure(null, currentLine.join(" "), {
|
| 486 |
+
fontFamily: text.style("font-family"),
|
| 487 |
+
fontSize: parseFloat(text.style("font-size")),
|
| 488 |
+
fontWeight: text.style("font-weight")
|
| 489 |
+
}).width > width;
|
| 490 |
+
|
| 491 |
+
if (isOverflow && currentLine.length > 1) {
|
| 492 |
+
currentLine.pop();
|
| 493 |
+
tspans.push(currentLine.join(" "));
|
| 494 |
+
currentLine = [word];
|
| 495 |
+
lineNumber++;
|
| 496 |
+
}
|
| 497 |
+
}
|
| 498 |
+
if (currentLine.length > 0) {
|
| 499 |
+
tspans.push(currentLine.join(" "));
|
| 500 |
+
}
|
| 501 |
+
} else { // 按字符换行
|
| 502 |
+
const chars = str.split('');
|
| 503 |
+
let currentLine = '';
|
| 504 |
+
for (let i = 0; i < chars.length; i++) {
|
| 505 |
+
const nextLine = currentLine + chars[i];
|
| 506 |
+
const isOverflow = chartUtils.text.measure(null, nextLine, {
|
| 507 |
+
fontFamily: text.style("font-family"),
|
| 508 |
+
fontSize: parseFloat(text.style("font-size")),
|
| 509 |
+
fontWeight: text.style("font-weight")
|
| 510 |
+
}).width > width;
|
| 511 |
+
|
| 512 |
+
if (isOverflow && currentLine.length > 0) {
|
| 513 |
+
tspans.push(currentLine);
|
| 514 |
+
currentLine = chars[i];
|
| 515 |
+
lineNumber++;
|
| 516 |
+
} else {
|
| 517 |
+
currentLine = nextLine;
|
| 518 |
+
}
|
| 519 |
+
}
|
| 520 |
+
if (currentLine.length > 0) {
|
| 521 |
+
tspans.push(currentLine);
|
| 522 |
+
}
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
const totalLines = tspans.length;
|
| 526 |
+
// 调整垂直居中: 向上移动半行 * (总行数 - 1)
|
| 527 |
+
// dominant-baseline="hanging" 时,第一个 tspan 的 y 决定顶部位置
|
| 528 |
+
// dy 用于后续行的偏移
|
| 529 |
+
const firstLineYAdjust = - (totalLines - 1) / 2 * lineHeight;
|
| 530 |
+
|
| 531 |
+
tspans.forEach((lineText, i) => {
|
| 532 |
+
text.append("tspan")
|
| 533 |
+
.attr("x", x) // 保持原始 x
|
| 534 |
+
.attr("dy", (i === 0 ? firstLineYAdjust + dy : lineHeight) + "em") // 第一行调整基线,后续行应用行高
|
| 535 |
+
.text(lineText);
|
| 536 |
+
});
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
const equalGroupRectWidth = Math.min(innerWidth / groups.length, width * 0.8 / groups.length);
|
| 540 |
+
chartUtils.legend.labeledStrip(svg, groups.map((group, index) => ({
|
| 541 |
+
label: group,
|
| 542 |
+
fill: index % 2 === 0 ? "url(#pattern-sketch-left)" : "url(#pattern-sketch-right)",
|
| 543 |
+
})), {
|
| 544 |
+
centerWidth: width,
|
| 545 |
+
y: height - margin.bottom + 20,
|
| 546 |
+
itemWidth: equalGroupRectWidth,
|
| 547 |
+
showNumber: true,
|
| 548 |
+
labelFontFamily: "'Comic Sans MS', cursive",
|
| 549 |
+
numberFontFamily: "'Comic Sans MS', cursive",
|
| 550 |
+
labelFontSize: 12,
|
| 551 |
+
textColor: colorResolver.text({ fallback: "#333333" }).value,
|
| 552 |
+
wrapLabel: (node, label, w) => wrapText(node, label, w, 1.1),
|
| 553 |
+
});
|
| 554 |
+
|
| 555 |
+
return svg.node();
|
| 556 |
+
}
|
modules/chart_engine/template/d3-js/bar_chart/horizontal_group_bar_chart_13.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Horizontal Group Bar Chart",
|
| 5 |
+
"chart_name": "horizontal_group_bar_chart_13",
|
| 6 |
+
"chart_for": "comparison",
|
| 7 |
+
"is_composite": false,
|
| 8 |
+
"required_fields": ["x", "y", "group"],
|
| 9 |
+
"required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
|
| 10 |
+
"required_fields_range": [[2, 20], [0, "inf"], [2, 5]],
|
| 11 |
+
"required_fields_icons": [],
|
| 12 |
+
"required_other_icons": [],
|
| 13 |
+
"required_fields_colors": ["group"],
|
| 14 |
+
"required_other_colors": ["primary"],
|
| 15 |
+
"supported_effects": [],
|
| 16 |
+
"min_height": 400,
|
| 17 |
+
"min_width": 400,
|
| 18 |
+
"background": "no",
|
| 19 |
+
"icon_mark": "none",
|
| 20 |
+
"icon_label": "none",
|
| 21 |
+
"has_x_axis": "no",
|
| 22 |
+
"has_y_axis": "no"
|
| 23 |
+
}
|
| 24 |
+
REQUIREMENTS_END
|
| 25 |
+
*/
|
| 26 |
+
|
| 27 |
+
// 水平分组条形图实现 - 使用D3.js horizontal_split_bar_chart_06 (Applying Sketch Style)
|
| 28 |
+
function makeChart(containerSelector, data) {
|
| 29 |
+
// ---------- 1. 数据准备阶段 ----------
|
| 30 |
+
|
| 31 |
+
// 提取数据和配置
|
| 32 |
+
const jsonData = data; // 完整的JSON数据对象
|
| 33 |
+
const chartData = jsonData.data.data; // 实际数据点数组
|
| 34 |
+
const variables = jsonData.variables || {}; // 图表配置
|
| 35 |
+
const typography = jsonData.typography || { // 字体设置,如果不存在则使用默认值
|
| 36 |
+
title: { font_family: "'Comic Sans MS', cursive", font_size: "18px", font_weight: "bold" },
|
| 37 |
+
label: { font_family: "'Comic Sans MS', cursive", font_size: "12px", font_weight: "normal" },
|
| 38 |
+
description: { font_family: "'Comic Sans MS', cursive", font_size: "14px", font_weight: "normal" },
|
| 39 |
+
annotation: { font_family: "'Comic Sans MS', cursive", font_size: "12px", font_weight: "normal" }
|
| 40 |
+
};
|
| 41 |
+
const colors = jsonData.colors || { text_color: "#333333" }; // 颜色设置
|
| 42 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 43 |
+
const images = jsonData.images || { field: {}, other: {} }; // 图像设置
|
| 44 |
+
const dataColumns = chartUtils.schema.columns(jsonData); // 数据列定义
|
| 45 |
+
|
| 46 |
+
// 设置视觉效果变量的默认值
|
| 47 |
+
variables.has_stroke = variables.has_stroke !== undefined ? variables.has_stroke : true; // Default stroke to true for sketch
|
| 48 |
+
|
| 49 |
+
// 清空容器
|
| 50 |
+
d3.select(containerSelector).html("");
|
| 51 |
+
|
| 52 |
+
// ---------- 2. 尺寸和布局设置 ----------
|
| 53 |
+
|
| 54 |
+
// 设置图表总尺寸
|
| 55 |
+
const width = variables.width || 800;
|
| 56 |
+
const height = variables.height || 600;
|
| 57 |
+
|
| 58 |
+
// 设置边距
|
| 59 |
+
const margin = {
|
| 60 |
+
top: 100, // 顶部留出标题空间
|
| 61 |
+
right: 30, // 右侧足够显示数值
|
| 62 |
+
bottom: 60, // 底部边距
|
| 63 |
+
left: 60 // 左侧初始空间,会动态调整
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
// ---------- 3. 提取字段名和单位 ----------
|
| 67 |
+
|
| 68 |
+
// 根据数据列顺序提取字段名
|
| 69 |
+
const dimensionField = chartUtils.schema.channel(jsonData, "x").key;
|
| 70 |
+
const valueField = chartUtils.schema.channel(jsonData, "y").key;
|
| 71 |
+
const groupField = chartUtils.schema.channel(jsonData, "group").key;
|
| 72 |
+
|
| 73 |
+
// 获取字段单位(如果存在)
|
| 74 |
+
let dimensionUnit = "";
|
| 75 |
+
let valueUnit = "";
|
| 76 |
+
let groupUnit = "";
|
| 77 |
+
|
| 78 |
+
if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
|
| 79 |
+
dimensionUnit = chartUtils.schema.channel(jsonData, "x").unit;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
|
| 83 |
+
valueUnit = chartUtils.schema.channel(jsonData, "y").unit;
|
| 84 |
+
}
|
| 85 |
+
if (chartUtils.schema.channel(jsonData, "group").unit !== "none") {
|
| 86 |
+
groupUnit = chartUtils.schema.channel(jsonData, "group").unit;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// ---------- 4. 数据处理 ----------
|
| 90 |
+
|
| 91 |
+
// 获取唯一维度值和分组值
|
| 92 |
+
const dimensions = [...new Set(chartData.map(d => d[dimensionField]))];
|
| 93 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 94 |
+
|
| 95 |
+
// 不对数据进行排序,使用原始顺序
|
| 96 |
+
|
| 97 |
+
// ---------- 5. 计算标签宽度 (使用手绘字体) ----------
|
| 98 |
+
// *** 添加: 手绘背景内边距 ***
|
| 99 |
+
const sketchPadding = 5;
|
| 100 |
+
|
| 101 |
+
// 创建临时SVG来测量文本宽度
|
| 102 |
+
const tempSvg = d3.select(containerSelector)
|
| 103 |
+
.append("svg")
|
| 104 |
+
.attr("width", 0)
|
| 105 |
+
.attr("height", 0)
|
| 106 |
+
.style("visibility", "hidden");
|
| 107 |
+
|
| 108 |
+
// 计算最大维度标签宽度
|
| 109 |
+
let maxLabelWidth = 0;
|
| 110 |
+
dimensions.forEach(dimension => {
|
| 111 |
+
const formattedDimension = dimensionUnit ?
|
| 112 |
+
`${dimension}${dimensionUnit}` :
|
| 113 |
+
`${dimension}`;
|
| 114 |
+
|
| 115 |
+
const textWidth = chartUtils.text.measure(tempSvg, formattedDimension, {
|
| 116 |
+
fontFamily: "'Comic Sans MS', cursive",
|
| 117 |
+
fontSize: typography.label.font_size,
|
| 118 |
+
fontWeight: typography.label.font_weight
|
| 119 |
+
}).width;
|
| 120 |
+
// *** 修改: 考虑背景padding ***
|
| 121 |
+
const totalWidth = textWidth + 2 * sketchPadding; // 文本宽度 + 背景padding
|
| 122 |
+
|
| 123 |
+
maxLabelWidth = Math.max(maxLabelWidth, totalWidth);
|
| 124 |
+
});
|
| 125 |
+
|
| 126 |
+
// 计算最大数值标签宽度
|
| 127 |
+
let maxValueWidth = 0;
|
| 128 |
+
chartData.forEach(d => {
|
| 129 |
+
const formattedValue = valueUnit ?
|
| 130 |
+
`${d[valueField]}${valueUnit}` :
|
| 131 |
+
`${chartUtils.format.autoText(+d[valueField])}`;
|
| 132 |
+
|
| 133 |
+
const textWidth = chartUtils.text.measure(tempSvg, formattedValue, {
|
| 134 |
+
fontFamily: typography.annotation.font_family,
|
| 135 |
+
fontSize: typography.annotation.font_size,
|
| 136 |
+
fontWeight: typography.annotation.font_weight
|
| 137 |
+
}).width;
|
| 138 |
+
// *** 修改: 考虑背景padding ***
|
| 139 |
+
maxValueWidth = Math.max(maxValueWidth, textWidth + 2 * sketchPadding);
|
| 140 |
+
});
|
| 141 |
+
// 删除临时SVG
|
| 142 |
+
tempSvg.remove();
|
| 143 |
+
|
| 144 |
+
// 根据标签宽度调整左边距(添加一些边距)
|
| 145 |
+
margin.left = Math.max(margin.left, maxLabelWidth + 15); // 增加右侧空间
|
| 146 |
+
// 根据数值标签宽度调整右边距 (添加一些边距)
|
| 147 |
+
margin.right = Math.max(margin.right, maxValueWidth + 15);
|
| 148 |
+
|
| 149 |
+
// 计算实际绘图区域尺寸
|
| 150 |
+
const innerWidth = width - margin.left - margin.right;
|
| 151 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 152 |
+
|
| 153 |
+
// 添加数值格式化函数
|
| 154 |
+
|
| 155 |
+
// ---------- 6. 创建SVG容器 ----------
|
| 156 |
+
|
| 157 |
+
const svg = d3.select(containerSelector)
|
| 158 |
+
.append("svg")
|
| 159 |
+
.attr("width", "100%")
|
| 160 |
+
.attr("height", height)
|
| 161 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 162 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 163 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 164 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 165 |
+
|
| 166 |
+
// 添加defs用于视觉效果
|
| 167 |
+
const defs = svg.append("defs");
|
| 168 |
+
|
| 169 |
+
// *** 修改: 使用来自 vertical_group_bar_chart_13 的手绘图案定义 ***
|
| 170 |
+
// --- Sketch Pattern Definitions ---
|
| 171 |
+
const patternLeft = defs.append("pattern")
|
| 172 |
+
.attr("id", "pattern-sketch-left")
|
| 173 |
+
.attr("width", 8).attr("height", 8)
|
| 174 |
+
.attr("patternUnits", "userSpaceOnUse")
|
| 175 |
+
.attr("patternTransform", "rotate(45)");
|
| 176 |
+
patternLeft.append("rect").attr("width", 8).attr("height", 8).attr("fill", "#4269d0"); // Default color
|
| 177 |
+
patternLeft.append("path").attr("d", "M -1,2 l 10,0 M -1,5 l 10,0 M -1,8 l 10,0").attr("stroke", "#FFFFFF").attr("stroke-width", 0.8);
|
| 178 |
+
|
| 179 |
+
const patternRight = defs.append("pattern")
|
| 180 |
+
.attr("id", "pattern-sketch-right")
|
| 181 |
+
.attr("width", 8).attr("height", 8)
|
| 182 |
+
.attr("patternUnits", "userSpaceOnUse")
|
| 183 |
+
.attr("patternTransform", "rotate(-45)");
|
| 184 |
+
patternRight.append("rect").attr("width", 8).attr("height", 8).attr("fill", "#ff725c"); // Default color
|
| 185 |
+
patternRight.append("path").attr("d", "M -1,2 l 10,0 M -1,5 l 10,0 M -1,8 l 10,0").attr("stroke", "#FFFFFF").attr("stroke-width", 0.8);
|
| 186 |
+
|
| 187 |
+
const patternLabelSketch = defs.append("pattern")
|
| 188 |
+
.attr("id", "pattern-label-sketch")
|
| 189 |
+
.attr("width", 8).attr("height", 8)
|
| 190 |
+
.attr("patternUnits", "userSpaceOnUse")
|
| 191 |
+
.attr("patternTransform", "rotate(45)");
|
| 192 |
+
patternLabelSketch.append("rect").attr("width", 8).attr("height", 8).attr("fill", "#FFFFFF");
|
| 193 |
+
patternLabelSketch.append("path").attr("d", "M -1,2 l 10,0 M -1,5 l 10,0 M -1,8 l 10,0").attr("stroke", "#CCCCCC").attr("stroke-width", 0.6);
|
| 194 |
+
|
| 195 |
+
// ---------- 7. 创建比例尺 ----------
|
| 196 |
+
|
| 197 |
+
// 计算条形的额外间距 - 使用固定值
|
| 198 |
+
const barPadding = 0.1;
|
| 199 |
+
|
| 200 |
+
// Y轴比例尺(用于维度)- 使用原始顺序
|
| 201 |
+
const yScale = d3.scaleBand()
|
| 202 |
+
.domain(dimensions)
|
| 203 |
+
.range([0, innerHeight])
|
| 204 |
+
.padding(barPadding);
|
| 205 |
+
|
| 206 |
+
// X轴比例尺(用于数值)
|
| 207 |
+
const xScale = d3.scaleLinear()
|
| 208 |
+
.domain([0, d3.max(chartData, d => +d[valueField]) || 1]) // Ensure domain starts at 0 and handles empty data
|
| 209 |
+
.range([0, innerWidth]);
|
| 210 |
+
|
| 211 |
+
// 获取组颜色 - 用于动态更新图案背景色 (可选)
|
| 212 |
+
function getGroupColor(group) {
|
| 213 |
+
// 优先使用指定颜色
|
| 214 |
+
if (colorResolver.field(group, 0, { fallbackKey: "primary" }).value) {
|
| 215 |
+
return colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
|
| 216 |
+
}
|
| 217 |
+
// 如果有可用颜色数组,按索引使用
|
| 218 |
+
const groupIndex = groups.indexOf(group);
|
| 219 |
+
const availableColor = colorResolver.available(groupIndex, { cycle: true, usePalette: false }).value;
|
| 220 |
+
if (availableColor) return availableColor;
|
| 221 |
+
const primaryColor = colorResolver.other("primary").value;
|
| 222 |
+
if (primaryColor) {
|
| 223 |
+
// 可以基于 group 索引生成不同深浅的 primary 颜色
|
| 224 |
+
// 简单的亮度调整示例
|
| 225 |
+
const factor = 1 - (groupIndex * 0.15); // 索引越大越暗
|
| 226 |
+
return chartUtils.color.variant(
|
| 227 |
+
chartUtils.color.variant(primaryColor, { mode: "brighter", amount: factor * 0.5 }),
|
| 228 |
+
{ mode: "darker", amount: factor * 0.2 }
|
| 229 |
+
); // 示例调整
|
| 230 |
+
}
|
| 231 |
+
// 提供默认 fallback 颜色
|
| 232 |
+
const defaultColors = ["#4269d0", "#ff725c", "#4CAF50", "#FFC107", "#9C27B0"];
|
| 233 |
+
return defaultColors[groupIndex % defaultColors.length];
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
const equalGroupRectWidth = Math.min(innerWidth / groups.length, width * 0.8 / groups.length);
|
| 237 |
+
chartUtils.legend.labeledStrip(svg, groups.map(group => ({
|
| 238 |
+
label: group,
|
| 239 |
+
fill: getGroupColor(group),
|
| 240 |
+
})), {
|
| 241 |
+
centerWidth: width,
|
| 242 |
+
y: height - margin.bottom + 20,
|
| 243 |
+
itemWidth: equalGroupRectWidth,
|
| 244 |
+
labelFontFamily: typography.annotation.font_family,
|
| 245 |
+
numberFontFamily: typography.label.font_family,
|
| 246 |
+
labelFontSize: 12,
|
| 247 |
+
textColor: colorResolver.text({ fallback: "#333333" }).value,
|
| 248 |
+
wrapLabel: (node, label, w) => wrapText(node, label, w, 1.1),
|
| 249 |
+
});
|
| 250 |
+
|
| 251 |
+
return svg.node();
|
| 252 |
+
}
|
modules/chart_engine/template/d3-js/bar_chart/horizontal_pictorial_bar_chart_01.js
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Horizontal Bar Chart",
|
| 5 |
+
"chart_name": "horizontal_bar_chart_01",
|
| 6 |
+
"required_fields": ["x", "y"],
|
| 7 |
+
"required_fields_type": [["categorical"], ["numerical"]],
|
| 8 |
+
"required_fields_range": [[3, 20], [0, 100000]],
|
| 9 |
+
"required_fields_icons": ["x"],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": [],
|
| 12 |
+
"required_other_colors": [],
|
| 13 |
+
"supported_effects": ["gradient", "opacity"],
|
| 14 |
+
"min_height": 300,
|
| 15 |
+
"min_width": 300,
|
| 16 |
+
"background": "light",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "none",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes"
|
| 21 |
+
}
|
| 22 |
+
REQUIREMENTS_END
|
| 23 |
+
*/
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
const colorResolver = chartUtils.color.resolver(data);
|
| 28 |
+
|
| 29 |
+
const jsonData = data;
|
| 30 |
+
const chartData = jsonData.data.data;
|
| 31 |
+
const variables = jsonData.variables || {};
|
| 32 |
+
const typography = jsonData.typography || {
|
| 33 |
+
title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
|
| 34 |
+
label: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 35 |
+
description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 36 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 37 |
+
};
|
| 38 |
+
const colors = jsonData.colors || {
|
| 39 |
+
text_color: "#333333",
|
| 40 |
+
other: {
|
| 41 |
+
primary: "#0458A7",
|
| 42 |
+
secondary: "#6BAFEF",
|
| 43 |
+
background: "#F0F0F0"
|
| 44 |
+
}
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
variables.has_shadow = variables.has_shadow || false;
|
| 48 |
+
variables.has_stroke = variables.has_stroke || false;
|
| 49 |
+
|
| 50 |
+
d3.select(containerSelector).html("");
|
| 51 |
+
|
| 52 |
+
const width = Math.max(variables.width || 300, 860);
|
| 53 |
+
const height = Math.max(variables.height || 400, 520);
|
| 54 |
+
|
| 55 |
+
const xField = chartUtils.schema.channel(jsonData, "x").key || "period";
|
| 56 |
+
const yField = chartUtils.schema.channel(jsonData, "y").key || "value";
|
| 57 |
+
|
| 58 |
+
let xUnit = "";
|
| 59 |
+
let yUnit = "";
|
| 60 |
+
|
| 61 |
+
if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
|
| 62 |
+
xUnit = chartUtils.schema.channel(jsonData, "x").unit;
|
| 63 |
+
}
|
| 64 |
+
if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
|
| 65 |
+
yUnit = chartUtils.schema.channel(jsonData, "y").unit;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
const processedData = chartData.map(d => ({
|
| 69 |
+
category: d[xField],
|
| 70 |
+
value: +d[yField]
|
| 71 |
+
})).filter(d => Number.isFinite(d.value))
|
| 72 |
+
.sort((a, b) => b.value - a.value);
|
| 73 |
+
|
| 74 |
+
const rawTextColor = colorResolver.text({ fallback: colors.text_color || "#10214A" }).value;
|
| 75 |
+
const primaryColor = colorResolver.other("primary", { fallback: colors.other?.primary || "#0458A7" }).value;
|
| 76 |
+
const secondaryColor = "#5DA9F6";
|
| 77 |
+
const fontFamily = typography.label?.font_family || "Arial";
|
| 78 |
+
|
| 79 |
+
const svg = d3.select(containerSelector)
|
| 80 |
+
.append("svg")
|
| 81 |
+
.attr("width", "100%")
|
| 82 |
+
.attr("height", height)
|
| 83 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 84 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 85 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 86 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 87 |
+
|
| 88 |
+
const defs = svg.append("defs");
|
| 89 |
+
const uid = `hbar01_${Math.floor(Math.random() * 1e9)}`;
|
| 90 |
+
const blueGradient = defs.append("linearGradient")
|
| 91 |
+
.attr("id", `${uid}_blue`)
|
| 92 |
+
.attr("x1", "0%")
|
| 93 |
+
.attr("x2", "100%")
|
| 94 |
+
.attr("y1", "0%")
|
| 95 |
+
.attr("y2", "0%");
|
| 96 |
+
blueGradient.append("stop").attr("offset", "0%").attr("stop-color", colorResolver.variant(primaryColor, { mode: "darker", amount: 0.45 }) || "#0344A3");
|
| 97 |
+
blueGradient.append("stop").attr("offset", "100%").attr("stop-color", colorResolver.variant(primaryColor, { mode: "brighter", amount: 0.75 }) || "#1F8BFF");
|
| 98 |
+
|
| 99 |
+
const lightBlueGradient = defs.append("linearGradient")
|
| 100 |
+
.attr("id", `${uid}_light_blue`)
|
| 101 |
+
.attr("x1", "0%")
|
| 102 |
+
.attr("x2", "100%")
|
| 103 |
+
.attr("y1", "0%")
|
| 104 |
+
.attr("y2", "0%");
|
| 105 |
+
lightBlueGradient.append("stop").attr("offset", "0%").attr("stop-color", "#4EA5F5");
|
| 106 |
+
lightBlueGradient.append("stop").attr("offset", "100%").attr("stop-color", "#8EC8FF");
|
| 107 |
+
|
| 108 |
+
const grayGradient = defs.append("linearGradient")
|
| 109 |
+
.attr("id", `${uid}_gray`)
|
| 110 |
+
.attr("x1", "0%")
|
| 111 |
+
.attr("x2", "100%")
|
| 112 |
+
.attr("y1", "0%")
|
| 113 |
+
.attr("y2", "0%");
|
| 114 |
+
grayGradient.append("stop").attr("offset", "0%").attr("stop-color", "#AAB1BC");
|
| 115 |
+
grayGradient.append("stop").attr("offset", "100%").attr("stop-color", "#D3D7DE");
|
| 116 |
+
|
| 117 |
+
const hasThreePanelLayout = width >= 720;
|
| 118 |
+
const hasRoomForLegend = width >= 520;
|
| 119 |
+
const margin = {
|
| 120 |
+
top: Math.max(12, height * 0.035),
|
| 121 |
+
right: Math.max(16, width * 0.022),
|
| 122 |
+
bottom: Math.max(42, height * 0.08),
|
| 123 |
+
left: Math.max(16, width * 0.026)
|
| 124 |
+
};
|
| 125 |
+
const innerW = width - margin.left - margin.right;
|
| 126 |
+
const innerH = height - margin.top - margin.bottom;
|
| 127 |
+
const sideGap = hasThreePanelLayout ? Math.max(18, innerW * 0.024) : 0;
|
| 128 |
+
const leftSlotW = hasThreePanelLayout ? Math.min(230, Math.max(174, innerW * 0.265)) : 0;
|
| 129 |
+
const rightPanelW = hasThreePanelLayout ? Math.min(128, Math.max(108, innerW * 0.145)) : 0;
|
| 130 |
+
const chartX = hasThreePanelLayout ? leftSlotW + sideGap : 0;
|
| 131 |
+
const chartW = hasThreePanelLayout
|
| 132 |
+
? Math.max(380, innerW - leftSlotW - rightPanelW - sideGap * 2)
|
| 133 |
+
: innerW;
|
| 134 |
+
const legendW = hasThreePanelLayout ? rightPanelW : (hasRoomForLegend ? Math.min(92, innerW * 0.14) : 0);
|
| 135 |
+
const valueW = Math.min(82, chartW * 0.14);
|
| 136 |
+
const iconW = Math.min(48, Math.max(36, chartW * 0.075));
|
| 137 |
+
const labelW = Math.min(156, Math.max(112, chartW * 0.24));
|
| 138 |
+
const colGap = Math.max(7, chartW * 0.014);
|
| 139 |
+
const barX = iconW + labelW + colGap * 2;
|
| 140 |
+
const valueX = chartW - valueW;
|
| 141 |
+
const barW = Math.max(70, valueX - barX - colGap);
|
| 142 |
+
const chartH = innerH;
|
| 143 |
+
const rowStep = chartH / Math.max(1, processedData.length);
|
| 144 |
+
const rowBand = Math.max(22, rowStep);
|
| 145 |
+
const barH = Math.max(8, Math.min(22, rowStep * 0.34));
|
| 146 |
+
const iconR = Math.max(11, Math.min(22, rowStep * 0.34));
|
| 147 |
+
const labelFont = Math.max(10, Math.min(16, rowStep * 0.28, parseFloat(typography.label?.font_size) || 14));
|
| 148 |
+
const valueFont = Math.max(11, Math.min(19, rowStep * 0.34));
|
| 149 |
+
const tickFont = Math.max(8, Math.min(10, labelFont * 0.72));
|
| 150 |
+
|
| 151 |
+
const valueMax = d3.max(processedData, d => d.value) || 1;
|
| 152 |
+
const percentLike = yUnit === "%";
|
| 153 |
+
const useFullPercentDomain = percentLike && valueMax > 20;
|
| 154 |
+
const xMax = useFullPercentDomain ? Math.max(100, valueMax) : valueMax;
|
| 155 |
+
const xScale = d3.scaleLinear()
|
| 156 |
+
.domain([0, xMax])
|
| 157 |
+
.range([0, barW]);
|
| 158 |
+
|
| 159 |
+
const root = svg.append("g")
|
| 160 |
+
.attr("class", "horizontal-bar-chart-01")
|
| 161 |
+
.attr("data-reference-geometry", "left-slot-central-ranked-pill-bars-independent-right-panel")
|
| 162 |
+
.attr("data-no-copied-reference-art", "true")
|
| 163 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 164 |
+
|
| 165 |
+
if (hasThreePanelLayout) {
|
| 166 |
+
const leftSlotH = Math.max(220, chartH * 0.72);
|
| 167 |
+
const leftSlotY = Math.max(6, chartH - leftSlotH - 8);
|
| 168 |
+
const leftSlot = root.append("g")
|
| 169 |
+
.attr("class", "reserved-asset-slot horizontal-bar-01-left-image-panel-slot")
|
| 170 |
+
.attr("data-type", "image-slot")
|
| 171 |
+
.attr("data-asset-slot", "left-primary-image-panel")
|
| 172 |
+
.attr("data-reserved-slot", "horizontal_bar_01_left_primary_image_panel")
|
| 173 |
+
.attr("data-slot-policy", "image2_or_input_asset_reserved_only")
|
| 174 |
+
.attr("data-asset-source-policy", "image2_asset_or_input_asset;not-template-hardcoded")
|
| 175 |
+
.attr("data-anchor", "left-body-panel-below-title")
|
| 176 |
+
.attr("data-bbox", `0,${leftSlotY.toFixed(1)},${leftSlotW.toFixed(1)},${leftSlotH.toFixed(1)}`)
|
| 177 |
+
.attr("data-bbox-policy", "child rounded rectangle defines visible reserved image panel bbox")
|
| 178 |
+
.attr("data-mask-policy", "downstream image2/retrieval may replace only inside this visible neutral slot; code emits no embedded raster")
|
| 179 |
+
.attr("data-collision-rule", "stay left of central ranked chart and below title/subtitle; never overlap bars labels values or right panel")
|
| 180 |
+
.attr("data-no-embedded-raster", "true")
|
| 181 |
+
.attr("data-no-copied-reference-art", "true")
|
| 182 |
+
.attr("transform", `translate(0, ${leftSlotY})`);
|
| 183 |
+
|
| 184 |
+
const leftRadius = Math.min(18, leftSlotW * 0.08);
|
| 185 |
+
leftSlot.append("rect")
|
| 186 |
+
.attr("class", "left-image-panel-frame")
|
| 187 |
+
.attr("width", leftSlotW)
|
| 188 |
+
.attr("height", leftSlotH)
|
| 189 |
+
.attr("rx", leftRadius)
|
| 190 |
+
.attr("ry", leftRadius)
|
| 191 |
+
.attr("fill", "#F2F8FF")
|
| 192 |
+
.attr("stroke", "#C8DFF5")
|
| 193 |
+
.attr("stroke-width", 1.4);
|
| 194 |
+
|
| 195 |
+
leftSlot.append("rect")
|
| 196 |
+
.attr("class", "left-image-panel-mask-placeholder")
|
| 197 |
+
.attr("x", leftSlotW * 0.08)
|
| 198 |
+
.attr("y", leftSlotH * 0.08)
|
| 199 |
+
.attr("width", leftSlotW * 0.84)
|
| 200 |
+
.attr("height", leftSlotH * 0.74)
|
| 201 |
+
.attr("rx", leftRadius * 0.72)
|
| 202 |
+
.attr("ry", leftRadius * 0.72)
|
| 203 |
+
.attr("fill", "#E6F3FF")
|
| 204 |
+
.attr("stroke", "#9FC9F1")
|
| 205 |
+
.attr("stroke-width", 1.1)
|
| 206 |
+
.attr("stroke-dasharray", "7 6")
|
| 207 |
+
.attr("opacity", 0.84);
|
| 208 |
+
|
| 209 |
+
leftSlot.append("path")
|
| 210 |
+
.attr("class", "left-image-panel-neutral-landform")
|
| 211 |
+
.attr("d", `M ${leftSlotW * 0.08} ${leftSlotH * 0.63} C ${leftSlotW * 0.26} ${leftSlotH * 0.50}, ${leftSlotW * 0.39} ${leftSlotH * 0.68}, ${leftSlotW * 0.54} ${leftSlotH * 0.56} C ${leftSlotW * 0.70} ${leftSlotH * 0.44}, ${leftSlotW * 0.78} ${leftSlotH * 0.58}, ${leftSlotW * 0.92} ${leftSlotH * 0.48} L ${leftSlotW * 0.92} ${leftSlotH * 0.82} L ${leftSlotW * 0.08} ${leftSlotH * 0.82} Z`)
|
| 212 |
+
.attr("fill", "#BFE2FF")
|
| 213 |
+
.attr("opacity", 0.58);
|
| 214 |
+
|
| 215 |
+
leftSlot.append("circle")
|
| 216 |
+
.attr("class", "left-image-panel-neutral-anchor")
|
| 217 |
+
.attr("cx", leftSlotW * 0.5)
|
| 218 |
+
.attr("cy", leftSlotH * 0.42)
|
| 219 |
+
.attr("r", Math.min(leftSlotW, leftSlotH) * 0.07)
|
| 220 |
+
.attr("fill", primaryColor)
|
| 221 |
+
.attr("opacity", 0.18);
|
| 222 |
+
|
| 223 |
+
leftSlot.append("rect")
|
| 224 |
+
.attr("class", "left-image-panel-bottom-band")
|
| 225 |
+
.attr("x", 0)
|
| 226 |
+
.attr("y", leftSlotH * 0.82)
|
| 227 |
+
.attr("width", leftSlotW)
|
| 228 |
+
.attr("height", leftSlotH * 0.18)
|
| 229 |
+
.attr("rx", leftRadius)
|
| 230 |
+
.attr("ry", leftRadius)
|
| 231 |
+
.attr("fill", primaryColor)
|
| 232 |
+
.attr("opacity", 0.95);
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
const chartGroup = root.append("g")
|
| 236 |
+
.attr("class", "central-ranked-chart")
|
| 237 |
+
.attr("data-reference-relationship", "central-ranked-table")
|
| 238 |
+
.attr("data-anchor", "between-left-image-slot-and-right-art-panel")
|
| 239 |
+
.attr("transform", `translate(${chartX}, 0)`);
|
| 240 |
+
|
| 241 |
+
const rowLayer = chartGroup.append("g").attr("class", "row-layer");
|
| 242 |
+
const axisY = chartH + Math.min(32, margin.bottom * 0.58);
|
| 243 |
+
|
| 244 |
+
const wrapLabel = (text, maxWidth, fontSize, maxLines = 2) => {
|
| 245 |
+
const words = String(text ?? "").split(/\s+/).filter(Boolean);
|
| 246 |
+
const maxChars = Math.max(8, Math.floor(maxWidth / (fontSize * 0.55)));
|
| 247 |
+
const lines = [];
|
| 248 |
+
let line = "";
|
| 249 |
+
words.forEach(word => {
|
| 250 |
+
const next = line ? `${line} ${word}` : word;
|
| 251 |
+
if (next.length <= maxChars || !line) {
|
| 252 |
+
line = next;
|
| 253 |
+
} else {
|
| 254 |
+
lines.push(line);
|
| 255 |
+
line = word;
|
| 256 |
+
}
|
| 257 |
+
});
|
| 258 |
+
if (line) lines.push(line);
|
| 259 |
+
const limited = lines.slice(0, maxLines);
|
| 260 |
+
if (lines.length > maxLines && limited.length) {
|
| 261 |
+
const last = limited[limited.length - 1];
|
| 262 |
+
limited[limited.length - 1] = `${last.slice(0, Math.max(3, maxChars - 1)).trim()}...`;
|
| 263 |
+
}
|
| 264 |
+
return limited.length ? limited : [String(text ?? "")];
|
| 265 |
+
};
|
| 266 |
+
|
| 267 |
+
const initials = value => {
|
| 268 |
+
const cleaned = String(value || "").replace(/\([^)]*\)/g, " ").split(/[^A-Za-z0-9]+/).filter(Boolean);
|
| 269 |
+
const selected = cleaned.filter(w => !["and", "the", "of", "for", "with"].includes(w.toLowerCase())).slice(0, 2);
|
| 270 |
+
return (selected.map(w => w[0]).join("") || cleaned.join("").slice(0, 2) || "?").toUpperCase();
|
| 271 |
+
};
|
| 272 |
+
|
| 273 |
+
const isTraditional = category => {
|
| 274 |
+
const c = String(category).toLowerCase();
|
| 275 |
+
return c.includes("foot") || c.includes("vehicle") || c.includes("community") || c.includes("aerial");
|
| 276 |
+
};
|
| 277 |
+
|
| 278 |
+
const formatChartNumber = value => {
|
| 279 |
+
const compactUnit = yUnit === "%" || String(yUnit).startsWith("$");
|
| 280 |
+
return chartUtils.format.number(value, { unit: yUnit, spaceBeforeUnit: !compactUnit }).text;
|
| 281 |
+
};
|
| 282 |
+
const formatValue = d => formatChartNumber(d.value);
|
| 283 |
+
|
| 284 |
+
const rows = rowLayer.selectAll(".hbar01-row")
|
| 285 |
+
.data(processedData)
|
| 286 |
+
.enter()
|
| 287 |
+
.append("g")
|
| 288 |
+
.attr("class", "hbar01-row")
|
| 289 |
+
.attr("data-category", d => d.category)
|
| 290 |
+
.attr("data-value", d => d.value)
|
| 291 |
+
.attr("transform", (d, i) => `translate(0, ${i * rowStep})`);
|
| 292 |
+
|
| 293 |
+
rows.append("line")
|
| 294 |
+
.attr("class", "row-divider")
|
| 295 |
+
.attr("x1", iconW + colGap * 0.4)
|
| 296 |
+
.attr("x2", Math.min(valueX + valueW, innerW - legendW - colGap))
|
| 297 |
+
.attr("y1", rowStep)
|
| 298 |
+
.attr("y2", rowStep)
|
| 299 |
+
.attr("stroke", "#DDE7F1")
|
| 300 |
+
.attr("stroke-width", 1);
|
| 301 |
+
|
| 302 |
+
rows.append("circle")
|
| 303 |
+
.attr("class", "category-icon-slot")
|
| 304 |
+
.attr("data-asset-slot", "category-icon")
|
| 305 |
+
.attr("data-no-embedded-raster", "true")
|
| 306 |
+
.attr("data-no-copied-reference-art", "true")
|
| 307 |
+
.attr("data-anchor", "row-centerline")
|
| 308 |
+
.attr("data-collision-policy", "stay-left-of-label-column")
|
| 309 |
+
.attr("cx", iconW * 0.5)
|
| 310 |
+
.attr("cy", rowStep * 0.5)
|
| 311 |
+
.attr("r", iconR)
|
| 312 |
+
.attr("fill", "#EEF6FF")
|
| 313 |
+
.attr("stroke", "#CDE2F7")
|
| 314 |
+
.attr("stroke-width", 1.2);
|
| 315 |
+
|
| 316 |
+
rows.append("text")
|
| 317 |
+
.attr("class", "category-icon-initials")
|
| 318 |
+
.attr("x", iconW * 0.5)
|
| 319 |
+
.attr("y", rowStep * 0.5)
|
| 320 |
+
.attr("dy", "0.34em")
|
| 321 |
+
.attr("text-anchor", "middle")
|
| 322 |
+
.style("font-family", fontFamily)
|
| 323 |
+
.style("font-size", `${Math.max(8, iconR * 0.52)}px`)
|
| 324 |
+
.style("font-weight", 800)
|
| 325 |
+
.style("fill", primaryColor)
|
| 326 |
+
.text(d => initials(d.category));
|
| 327 |
+
|
| 328 |
+
rows.append("line")
|
| 329 |
+
.attr("class", "icon-label-divider")
|
| 330 |
+
.attr("x1", iconW + colGap * 0.55)
|
| 331 |
+
.attr("x2", iconW + colGap * 0.55)
|
| 332 |
+
.attr("y1", rowStep * 0.22)
|
| 333 |
+
.attr("y2", rowStep * 0.78)
|
| 334 |
+
.attr("stroke", "#D5E1ED")
|
| 335 |
+
.attr("stroke-width", 1);
|
| 336 |
+
|
| 337 |
+
rows.append("text")
|
| 338 |
+
.attr("class", "category-label")
|
| 339 |
+
.attr("x", iconW + colGap)
|
| 340 |
+
.attr("y", rowStep * 0.5)
|
| 341 |
+
.attr("dominant-baseline", "middle")
|
| 342 |
+
.style("font-family", fontFamily)
|
| 343 |
+
.style("font-size", `${labelFont}px`)
|
| 344 |
+
.style("font-weight", 800)
|
| 345 |
+
.style("fill", "#0D2147")
|
| 346 |
+
.each(function(d) {
|
| 347 |
+
const lines = wrapLabel(d.category, labelW, labelFont, rowStep < 30 ? 1 : 3);
|
| 348 |
+
const text = d3.select(this);
|
| 349 |
+
const lineH = labelFont * 1.08;
|
| 350 |
+
const firstY = -(lines.length - 1) * lineH / 2;
|
| 351 |
+
lines.forEach((line, index) => {
|
| 352 |
+
text.append("tspan")
|
| 353 |
+
.attr("x", iconW + colGap)
|
| 354 |
+
.attr("dy", index === 0 ? firstY : lineH)
|
| 355 |
+
.text(line);
|
| 356 |
+
});
|
| 357 |
+
});
|
| 358 |
+
|
| 359 |
+
rows.append("rect")
|
| 360 |
+
.attr("class", "bar-track")
|
| 361 |
+
.attr("x", barX)
|
| 362 |
+
.attr("y", rowStep * 0.5 - barH / 2)
|
| 363 |
+
.attr("width", barW)
|
| 364 |
+
.attr("height", barH)
|
| 365 |
+
.attr("rx", barH / 2)
|
| 366 |
+
.attr("ry", barH / 2)
|
| 367 |
+
.attr("fill", "#EDF2F7");
|
| 368 |
+
|
| 369 |
+
rows.append("rect")
|
| 370 |
+
.attr("class", "bar")
|
| 371 |
+
.attr("data-mark-shape", "pill-capsule")
|
| 372 |
+
.attr("data-baseline-edge", "rounded")
|
| 373 |
+
.attr("data-value-edge", "rounded")
|
| 374 |
+
.attr("x", barX)
|
| 375 |
+
.attr("y", rowStep * 0.5 - barH / 2)
|
| 376 |
+
.attr("width", d => Math.max(barH, xScale(d.value)))
|
| 377 |
+
.attr("height", barH)
|
| 378 |
+
.attr("rx", barH / 2)
|
| 379 |
+
.attr("ry", barH / 2)
|
| 380 |
+
.attr("fill", (d, i) => {
|
| 381 |
+
if (i < 4 || String(d.category).toLowerCase().includes("drone")) return `url(#${uid}_blue)`;
|
| 382 |
+
if (!isTraditional(d.category)) return `url(#${uid}_light_blue)`;
|
| 383 |
+
return `url(#${uid}_gray)`;
|
| 384 |
+
});
|
| 385 |
+
|
| 386 |
+
rows.append("text")
|
| 387 |
+
.attr("class", "value-label")
|
| 388 |
+
.attr("x", valueX + valueW)
|
| 389 |
+
.attr("y", rowStep * 0.5)
|
| 390 |
+
.attr("dy", "0.34em")
|
| 391 |
+
.attr("text-anchor", "end")
|
| 392 |
+
.style("font-family", fontFamily)
|
| 393 |
+
.style("font-size", `${valueFont}px`)
|
| 394 |
+
.style("font-weight", 800)
|
| 395 |
+
.style("fill", (d, i) => i < 4 ? primaryColor : "#0D2147")
|
| 396 |
+
.text(formatValue);
|
| 397 |
+
|
| 398 |
+
const freeScaleTickCount = Math.max(3, Math.min(5, Math.floor(barW / 90)));
|
| 399 |
+
const ticks = useFullPercentDomain ? [0, 20, 40, 60, 80, 100] : xScale.ticks(freeScaleTickCount);
|
| 400 |
+
const axis = chartGroup.append("g")
|
| 401 |
+
.attr("class", "x-axis")
|
| 402 |
+
.attr("transform", `translate(${barX}, ${axisY})`);
|
| 403 |
+
|
| 404 |
+
chartGroup.append("text")
|
| 405 |
+
.attr("class", "bar-column-heading")
|
| 406 |
+
.attr("x", barX + barW + valueW)
|
| 407 |
+
.attr("y", Math.max(0, -margin.top * 0.2))
|
| 408 |
+
.attr("text-anchor", "end")
|
| 409 |
+
.style("font-family", fontFamily)
|
| 410 |
+
.style("font-size", `${Math.max(9, tickFont * 1.08)}px`)
|
| 411 |
+
.style("font-weight", 800)
|
| 412 |
+
.style("letter-spacing", "0")
|
| 413 |
+
.style("fill", primaryColor)
|
| 414 |
+
.text(String(yField || "Value").replace(/([a-z])([A-Z])/g, "$1 $2").toUpperCase());
|
| 415 |
+
|
| 416 |
+
axis.selectAll(".tick-label")
|
| 417 |
+
.data(ticks)
|
| 418 |
+
.enter()
|
| 419 |
+
.append("text")
|
| 420 |
+
.attr("class", "tick-label")
|
| 421 |
+
.attr("x", d => xScale(d))
|
| 422 |
+
.attr("y", 0)
|
| 423 |
+
.attr("text-anchor", (d, i) => i === 0 ? "start" : (i === ticks.length - 1 ? "end" : "middle"))
|
| 424 |
+
.style("font-family", fontFamily)
|
| 425 |
+
.style("font-size", `${tickFont}px`)
|
| 426 |
+
.style("font-weight", 600)
|
| 427 |
+
.style("fill", "#7B8AA4")
|
| 428 |
+
.text(d => formatChartNumber(d));
|
| 429 |
+
|
| 430 |
+
if (legendW >= 72) {
|
| 431 |
+
const panelX = hasThreePanelLayout ? chartX + chartW + sideGap : innerW - legendW;
|
| 432 |
+
const panelH = hasThreePanelLayout ? Math.max(260, chartH - 10) : Math.min(chartH, Math.max(120, chartH * 0.56));
|
| 433 |
+
const panelY = hasThreePanelLayout ? 5 : Math.max(4, (chartH - panelH) * 0.08);
|
| 434 |
+
const legend = root.append("g")
|
| 435 |
+
.attr("class", "legend-side-panel")
|
| 436 |
+
.attr("data-reference-relationship", "independent")
|
| 437 |
+
.attr("data-asset-slot", "side-panel-art")
|
| 438 |
+
.attr("data-reserved-slot", "horizontal_bar_01_right_art_legend_panel")
|
| 439 |
+
.attr("data-slot-policy", "image2_asset_reserved_only")
|
| 440 |
+
.attr("data-asset-source-policy", "image2_asset;not-template-hardcoded")
|
| 441 |
+
.attr("data-no-embedded-raster", "true")
|
| 442 |
+
.attr("data-no-copied-reference-art", "true")
|
| 443 |
+
.attr("data-anchor", "right-safe-column")
|
| 444 |
+
.attr("data-bbox", `${panelX.toFixed(1)},${panelY.toFixed(1)},${legendW.toFixed(1)},${panelH.toFixed(1)}`)
|
| 445 |
+
.attr("data-bbox-policy", "child rounded rectangle defines visible right art and legend panel bbox")
|
| 446 |
+
.attr("data-mask-policy", "downstream image2 may replace only inside the upper reserved art area; code emits no embedded raster")
|
| 447 |
+
.attr("data-collision-policy", "avoid-value-label-column-and-central-bars")
|
| 448 |
+
.attr("transform", `translate(${panelX}, ${panelY})`);
|
| 449 |
+
|
| 450 |
+
legend.append("rect")
|
| 451 |
+
.attr("class", "legend-panel-bg")
|
| 452 |
+
.attr("width", legendW)
|
| 453 |
+
.attr("height", panelH)
|
| 454 |
+
.attr("rx", 12)
|
| 455 |
+
.attr("ry", 12)
|
| 456 |
+
.attr("fill", "#F2F8FF")
|
| 457 |
+
.attr("stroke", "#CAE0F6")
|
| 458 |
+
.attr("stroke-width", 1.2);
|
| 459 |
+
|
| 460 |
+
const legendItems = [
|
| 461 |
+
{ label: "High", color: primaryColor },
|
| 462 |
+
{ label: "Mid", color: secondaryColor },
|
| 463 |
+
{ label: "Base", color: "#AAB1BC" }
|
| 464 |
+
];
|
| 465 |
+
const legendBaseY = hasThreePanelLayout ? panelH - 70 : panelH - 58;
|
| 466 |
+
legend.selectAll(".legend-item")
|
| 467 |
+
.data(legendItems)
|
| 468 |
+
.enter()
|
| 469 |
+
.append("g")
|
| 470 |
+
.attr("class", "legend-item")
|
| 471 |
+
.attr("transform", (d, i) => `translate(${Math.max(10, legendW * 0.12)}, ${legendBaseY + i * 18})`)
|
| 472 |
+
.each(function(d) {
|
| 473 |
+
const item = d3.select(this);
|
| 474 |
+
item.append("rect")
|
| 475 |
+
.attr("width", 10)
|
| 476 |
+
.attr("height", 10)
|
| 477 |
+
.attr("rx", 2)
|
| 478 |
+
.attr("fill", d.color);
|
| 479 |
+
item.append("text")
|
| 480 |
+
.attr("x", 16)
|
| 481 |
+
.attr("y", 9)
|
| 482 |
+
.style("font-family", fontFamily)
|
| 483 |
+
.style("font-size", `${Math.max(8, tickFont)}px`)
|
| 484 |
+
.style("font-weight", 600)
|
| 485 |
+
.style("fill", "#25446C")
|
| 486 |
+
.text(d.label);
|
| 487 |
+
});
|
| 488 |
+
|
| 489 |
+
legend.append("rect")
|
| 490 |
+
.attr("class", "side-panel-art-placeholder")
|
| 491 |
+
.attr("data-asset-slot", "side-panel-art")
|
| 492 |
+
.attr("data-no-embedded-raster", "true")
|
| 493 |
+
.attr("data-no-copied-reference-art", "true")
|
| 494 |
+
.attr("x", legendW * 0.18)
|
| 495 |
+
.attr("y", 22)
|
| 496 |
+
.attr("width", legendW * 0.64)
|
| 497 |
+
.attr("height", Math.min(panelH * 0.34, legendW * 1.18))
|
| 498 |
+
.attr("rx", Math.min(16, legendW * 0.16))
|
| 499 |
+
.attr("fill", "#DDEEFF")
|
| 500 |
+
.attr("stroke", "#C4DDF8")
|
| 501 |
+
.attr("stroke-width", 1)
|
| 502 |
+
.attr("stroke-dasharray", "6 5")
|
| 503 |
+
.attr("opacity", 0.9);
|
| 504 |
+
|
| 505 |
+
legend.append("circle")
|
| 506 |
+
.attr("class", "side-panel-art-neutral-anchor")
|
| 507 |
+
.attr("cx", legendW * 0.5)
|
| 508 |
+
.attr("cy", 22 + Math.min(panelH * 0.34, legendW * 1.18) * 0.48)
|
| 509 |
+
.attr("r", Math.max(8, legendW * 0.12))
|
| 510 |
+
.attr("fill", primaryColor)
|
| 511 |
+
.attr("opacity", 0.16);
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
return svg.node();
|
| 515 |
+
}
|
modules/chart_engine/template/d3-js/bar_chart/vertical_bar_chart_07.js
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Vertical Bar Chart",
|
| 5 |
+
"chart_name": "vertical_bar_chart_07",
|
| 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": [],
|
| 13 |
+
"required_other_colors": ["primary"],
|
| 14 |
+
"supported_effects": ["shadow", "radius_corner", "gradient", "stroke", "spacing"],
|
| 15 |
+
"min_height": 400,
|
| 16 |
+
"min_width": 400,
|
| 17 |
+
"background": "no",
|
| 18 |
+
"icon_mark": "overlay",
|
| 19 |
+
"icon_label": "side",
|
| 20 |
+
"has_x_axis": "no",
|
| 21 |
+
"has_y_axis": "no"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
// 使用D3.js创建简单的垂直条形图
|
| 27 |
+
function makeChart(containerSelector, data) {
|
| 28 |
+
const colorResolver = chartUtils.color.resolver(data);
|
| 29 |
+
// ---------- 1. 数据准备 ----------
|
| 30 |
+
const jsonData = data; // 完整的JSON数据对象
|
| 31 |
+
const chartData = jsonData.data.data || []; // 图表数据
|
| 32 |
+
const variables = jsonData.variables || {}; // 图表配置
|
| 33 |
+
const typography = jsonData.typography || { // 字体设置
|
| 34 |
+
title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
|
| 35 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 36 |
+
description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 37 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 38 |
+
};
|
| 39 |
+
const colors = jsonData.colors || {
|
| 40 |
+
text_color: "#333333",
|
| 41 |
+
other: { primary: "#73D2C7", secondary: "#4682B4" }
|
| 42 |
+
};
|
| 43 |
+
const images = jsonData.images || { field: {}, other: {} }; // 图片(标志等)
|
| 44 |
+
const dataColumns = chartUtils.schema.columns(jsonData); // 数据列
|
| 45 |
+
|
| 46 |
+
// 设置视觉效果变量
|
| 47 |
+
variables.has_rounded_corners = variables.has_rounded_corners || false;
|
| 48 |
+
variables.has_shadow = variables.has_shadow || false;
|
| 49 |
+
variables.has_gradient = variables.has_gradient || false;
|
| 50 |
+
variables.has_stroke = variables.has_stroke || false;
|
| 51 |
+
variables.has_spacing = variables.has_spacing || false;
|
| 52 |
+
|
| 53 |
+
// 清空容器
|
| 54 |
+
d3.select(containerSelector).html("");
|
| 55 |
+
|
| 56 |
+
// ---------- 2. 尺寸和布局设置 ----------
|
| 57 |
+
|
| 58 |
+
// 设置图表尺寸和边距
|
| 59 |
+
const width = variables.width || 800;
|
| 60 |
+
const height = variables.height || 600;
|
| 61 |
+
|
| 62 |
+
// 边距: 上, 右, 下, 左
|
| 63 |
+
const margin = {
|
| 64 |
+
top: 60, // 柱子上方数值标签的空间
|
| 65 |
+
right: 30,
|
| 66 |
+
bottom: 150, // x轴和图标的空间
|
| 67 |
+
left: 60
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
// 计算实际图表区域尺寸
|
| 71 |
+
const innerWidth = width - margin.left - margin.right;
|
| 72 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 73 |
+
|
| 74 |
+
// ---------- 3. 提取字段名称和单位 ----------
|
| 75 |
+
|
| 76 |
+
let xField, yField;
|
| 77 |
+
let xUnit = "", yUnit = "";
|
| 78 |
+
|
| 79 |
+
// 安全提取字段名称
|
| 80 |
+
const xChannel = chartUtils.schema.channel(jsonData, "x");
|
| 81 |
+
const yChannel = chartUtils.schema.channel(jsonData, "y");
|
| 82 |
+
|
| 83 |
+
xField = xChannel.key;
|
| 84 |
+
yField = yChannel.key;
|
| 85 |
+
|
| 86 |
+
// 获取字段单位(如果存在)
|
| 87 |
+
if (xChannel.unit) {
|
| 88 |
+
xUnit = xChannel.unit;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
if (yChannel.unit) {
|
| 92 |
+
yUnit = yChannel.unit;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// ---------- 4. 数据处理 ----------
|
| 96 |
+
|
| 97 |
+
// 获取唯一的x轴值
|
| 98 |
+
const xValues = [...new Set(chartData.map(d => d[xField]))];
|
| 99 |
+
|
| 100 |
+
// 创建按y值降序排序的数据
|
| 101 |
+
const sortedData = chartData.map(d => ({
|
| 102 |
+
x: d[xField],
|
| 103 |
+
y: +d[yField] // 确保y是数值类型
|
| 104 |
+
})).sort((a, b) => b.y - a.y);
|
| 105 |
+
|
| 106 |
+
// ---------- 5. 创建SVG容器 ----------
|
| 107 |
+
|
| 108 |
+
const svg = d3.select(containerSelector)
|
| 109 |
+
.append("svg")
|
| 110 |
+
.attr("width", "100%")
|
| 111 |
+
.attr("height", height)
|
| 112 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 113 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 114 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 115 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
// ---------- 6. 创建视觉效果 ----------
|
| 120 |
+
|
| 121 |
+
const defs = svg.append("defs");
|
| 122 |
+
|
| 123 |
+
// 获取颜色函数
|
| 124 |
+
const getColor = () => colorResolver.other("primary").value;
|
| 125 |
+
|
| 126 |
+
// 如果需要创建阴影滤镜
|
| 127 |
+
if (variables.has_shadow) {
|
| 128 |
+
const filter = defs.append("filter")
|
| 129 |
+
.attr("id", "shadow")
|
| 130 |
+
.attr("filterUnits", "userSpaceOnUse")
|
| 131 |
+
.attr("width", "200%")
|
| 132 |
+
.attr("height", "200%");
|
| 133 |
+
|
| 134 |
+
filter.append("feGaussianBlur")
|
| 135 |
+
.attr("in", "SourceAlpha")
|
| 136 |
+
.attr("stdDeviation", 3);
|
| 137 |
+
|
| 138 |
+
filter.append("feOffset")
|
| 139 |
+
.attr("dx", 2)
|
| 140 |
+
.attr("dy", 2)
|
| 141 |
+
.attr("result", "offsetblur");
|
| 142 |
+
|
| 143 |
+
const feMerge = filter.append("feMerge");
|
| 144 |
+
feMerge.append("feMergeNode");
|
| 145 |
+
feMerge.append("feMergeNode").attr("in", "SourceGraphic");
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// 如果需要创建渐变
|
| 149 |
+
if (variables.has_gradient) {
|
| 150 |
+
sortedData.forEach((item, idx) => {
|
| 151 |
+
// 添加安全的ID处理
|
| 152 |
+
const safeCategory = typeof item.x === 'string' ?
|
| 153 |
+
item.x.toString().replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() :
|
| 154 |
+
`category-${idx}`;
|
| 155 |
+
|
| 156 |
+
const baseColor = getColor();
|
| 157 |
+
const gradientId = `gradient-${safeCategory}`;
|
| 158 |
+
|
| 159 |
+
const gradient = defs.append("linearGradient")
|
| 160 |
+
.attr("id", gradientId)
|
| 161 |
+
.attr("x1", "0%")
|
| 162 |
+
.attr("y1", "100%")
|
| 163 |
+
.attr("x2", "0%")
|
| 164 |
+
.attr("y2", "0%");
|
| 165 |
+
|
| 166 |
+
gradient.append("stop")
|
| 167 |
+
.attr("offset", "0%")
|
| 168 |
+
.attr("stop-color", colorResolver.variant(baseColor, { mode: "darker", amount: 0.1 }));
|
| 169 |
+
|
| 170 |
+
gradient.append("stop")
|
| 171 |
+
.attr("offset", "100%")
|
| 172 |
+
.attr("stop-color", colorResolver.variant(baseColor, { mode: "brighter", amount: 0.1 }));
|
| 173 |
+
});
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
// ---------- 7. 创建图表 ----------
|
| 177 |
+
|
| 178 |
+
// 创建图表组
|
| 179 |
+
const chart = svg.append("g")
|
| 180 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 181 |
+
|
| 182 |
+
// 创建比例尺
|
| 183 |
+
const xScale = d3.scaleBand()
|
| 184 |
+
.domain(sortedData.map(d => d.x))
|
| 185 |
+
.range([0, innerWidth])
|
| 186 |
+
.padding(variables.has_spacing ? 0.4 : 0.2);
|
| 187 |
+
|
| 188 |
+
const yMax = d3.max(sortedData, d => d.y);
|
| 189 |
+
const yScale = d3.scaleLinear()
|
| 190 |
+
.domain([0, yMax > 0 ? yMax * 1.1 : 1]) // 处理y都为0的情况,并添加10%的填充
|
| 191 |
+
.range([innerHeight, 0]);
|
| 192 |
+
|
| 193 |
+
// 添加数值格式化函数
|
| 194 |
+
|
| 195 |
+
// *******************************************************************
|
| 196 |
+
// ** 开始修改:预计算标签字体大小和最大行数 **
|
| 197 |
+
// *******************************************************************
|
| 198 |
+
const defaultLabelFontSize = parseFloat(typography.label.font_size || 12);
|
| 199 |
+
const minLabelFontSize = 10; // 最小字体大小
|
| 200 |
+
const currentBarWidth = xScale.bandwidth(); // 使用实际的bar宽度
|
| 201 |
+
let finalLabelFontSize = defaultLabelFontSize;
|
| 202 |
+
let maxLinesNeeded = 1; // 记录所有标签所需的最大行数
|
| 203 |
+
const labelFontFamily = typography.label.font_family || "Arial";
|
| 204 |
+
const labelFontWeight = typography.label.font_weight || "normal";
|
| 205 |
+
const lineHeightFactor = 1.2; // 行高倍数
|
| 206 |
+
|
| 207 |
+
// 文本宽度测量辅助函数 (使用 canvas 提高性能)
|
| 208 |
+
const canvas = document.createElement('canvas');
|
| 209 |
+
const ctx = canvas.getContext('2d');
|
| 210 |
+
function measureTextWidth(text, fontFamily, fontSize, fontWeight) {
|
| 211 |
+
ctx.font = `${fontWeight || 'normal'} ${fontSize}px ${fontFamily || 'Arial'}`;
|
| 212 |
+
return chartUtils.text.contextWidth(ctx, text);
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
// 第一遍计算:确定统一的字体大小
|
| 216 |
+
if (currentBarWidth > 0) { // 确保 barWidth 有效
|
| 217 |
+
let maxRatio = 1;
|
| 218 |
+
sortedData.forEach(d => {
|
| 219 |
+
const labelText = String(d.x); // 确保是字符串
|
| 220 |
+
const textWidth = measureTextWidth(labelText, labelFontFamily, defaultLabelFontSize, labelFontWeight);
|
| 221 |
+
const ratio = textWidth / currentBarWidth;
|
| 222 |
+
if (ratio > maxRatio) {
|
| 223 |
+
maxRatio = ratio;
|
| 224 |
+
}
|
| 225 |
+
});
|
| 226 |
+
|
| 227 |
+
if (maxRatio > 1) {
|
| 228 |
+
finalLabelFontSize = Math.max(minLabelFontSize, Math.floor(defaultLabelFontSize / maxRatio));
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
// 第二遍计算:确定是否需要换行以及最大行数 (使用最终字体大小)
|
| 232 |
+
sortedData.forEach(d => {
|
| 233 |
+
const labelText = String(d.x);
|
| 234 |
+
const textWidth = measureTextWidth(labelText, labelFontFamily, finalLabelFontSize, labelFontWeight);
|
| 235 |
+
|
| 236 |
+
if (textWidth > currentBarWidth) {
|
| 237 |
+
// 模拟换行计算行数
|
| 238 |
+
const words = labelText.split(/\s+/); // 按空格分词
|
| 239 |
+
let currentLine = '';
|
| 240 |
+
let lines = 1;
|
| 241 |
+
let simulationSuccess = false; // 标记模拟是否成功
|
| 242 |
+
|
| 243 |
+
if (words.length > 1) { // 优先按单词换行
|
| 244 |
+
for (let i = 0; i < words.length; i++) {
|
| 245 |
+
const testLine = currentLine ? currentLine + " " + words[i] : words[i];
|
| 246 |
+
const testWidth = measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight);
|
| 247 |
+
if (testWidth > currentBarWidth && currentLine !== '') {
|
| 248 |
+
lines++;
|
| 249 |
+
currentLine = words[i];
|
| 250 |
+
// 检查单个单词是否也超长
|
| 251 |
+
if (measureTextWidth(currentLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > currentBarWidth) {
|
| 252 |
+
// 单个单词超长,需要转字符换行
|
| 253 |
+
simulationSuccess = false;
|
| 254 |
+
break; // 退出单词循环
|
| 255 |
+
}
|
| 256 |
+
} else {
|
| 257 |
+
currentLine = testLine;
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
if(currentLine !== '') simulationSuccess = true; // 如果有剩余单词则标记成功
|
| 261 |
+
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
// 如果单词换行不成功或只有一个单词,尝试字符换行
|
| 265 |
+
if(words.length <= 1 || !simulationSuccess) {
|
| 266 |
+
lines = 1; // 重置行数
|
| 267 |
+
const chars = labelText.split('');
|
| 268 |
+
currentLine = '';
|
| 269 |
+
for (let i = 0; i < chars.length; i++) {
|
| 270 |
+
const testLine = currentLine + chars[i];
|
| 271 |
+
if (measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > currentBarWidth && currentLine !== '') {
|
| 272 |
+
lines++;
|
| 273 |
+
currentLine = chars[i];
|
| 274 |
+
} else {
|
| 275 |
+
currentLine += chars[i];
|
| 276 |
+
}
|
| 277 |
+
}
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
if (lines > maxLinesNeeded) {
|
| 282 |
+
maxLinesNeeded = lines;
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
});
|
| 286 |
+
} else {
|
| 287 |
+
// 如果barWidth无效,则使用默认字体大小且不换行
|
| 288 |
+
finalLabelFontSize = defaultLabelFontSize;
|
| 289 |
+
maxLinesNeeded = 1;
|
| 290 |
+
}
|
| 291 |
+
// *******************************************************************
|
| 292 |
+
// ** 结束修改:预计算标签字体大小和最大行数 **
|
| 293 |
+
// *******************************************************************
|
| 294 |
+
|
| 295 |
+
// *******************************************************************
|
| 296 |
+
// ** 开始修改:计算图标的统一Y位置 **
|
| 297 |
+
// *******************************************************************
|
| 298 |
+
const labelStartY = innerHeight + 10; // 标签开始的 Y 位置 (靠近柱子底部)
|
| 299 |
+
const lineHeight = finalLabelFontSize * lineHeightFactor; // 行高
|
| 300 |
+
const labelBottomApprox = labelStartY + (maxLinesNeeded - 1) * lineHeight + finalLabelFontSize * 0.71; // 估算最下方标签基线位置
|
| 301 |
+
const iconRadius = 15; // 图标半径
|
| 302 |
+
const iconYPosition = labelBottomApprox + iconRadius + 5; // 在标签下方留出 5px 间距放置图标中心
|
| 303 |
+
const iconBottomY = iconYPosition + iconRadius; // 图标底部的Y坐标
|
| 304 |
+
const barExtensionBuffer = 5; // 柱子比图标底部多延伸的距离
|
| 305 |
+
const barBottomY = iconBottomY + barExtensionBuffer; // 柱子需要延伸到的最终Y坐标
|
| 306 |
+
// *******************************************************************
|
| 307 |
+
// ** 结束修改:计算图标的统一Y位置 **
|
| 308 |
+
// *******************************************************************
|
| 309 |
+
|
| 310 |
+
// 添加柱子 (修改高度)
|
| 311 |
+
chart.selectAll(".bar")
|
| 312 |
+
.data(sortedData)
|
| 313 |
+
.enter()
|
| 314 |
+
.append("rect")
|
| 315 |
+
.attr("class", "bar")
|
| 316 |
+
.attr("x", d => xScale(d.x))
|
| 317 |
+
.attr("y", d => yScale(d.y))
|
| 318 |
+
.attr("width", xScale.bandwidth())
|
| 319 |
+
// 修改: 使柱子高度只取决于数据值和yScale
|
| 320 |
+
.attr("height", d => Math.max(0, innerHeight - yScale(d.y)))
|
| 321 |
+
.attr("fill", d => {
|
| 322 |
+
if (variables.has_gradient) {
|
| 323 |
+
const safeCategory = typeof d.x === 'string' ?
|
| 324 |
+
d.x.toString().replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() :
|
| 325 |
+
`category-${sortedData.findIndex(item => item.x === d.x)}`; // 修正: 使用 findIndex 确保唯一性
|
| 326 |
+
return `url(#gradient-${safeCategory})`;
|
| 327 |
+
}
|
| 328 |
+
return getColor();
|
| 329 |
+
})
|
| 330 |
+
.attr("rx", variables.has_rounded_corners ? 4 : 0)
|
| 331 |
+
.attr("ry", variables.has_rounded_corners ? 4 : 0)
|
| 332 |
+
.style("stroke", variables.has_stroke ? "#333" : "none")
|
| 333 |
+
.style("stroke-width", variables.has_stroke ? 1 : 0)
|
| 334 |
+
.style("filter", variables.has_shadow ? "url(#shadow)" : "none")
|
| 335 |
+
.on("mouseover", function() {
|
| 336 |
+
d3.select(this).attr("opacity", 0.8);
|
| 337 |
+
})
|
| 338 |
+
.on("mouseout", function() {
|
| 339 |
+
d3.select(this).attr("opacity", 1);
|
| 340 |
+
});
|
| 341 |
+
|
| 342 |
+
// 在柱子上方添加数值标签
|
| 343 |
+
const defaultAnnotationFontSize = parseFloat(typography.annotation.font_size || 12);
|
| 344 |
+
const minAnnotationFontSize = 6; // 数值标签最小字体
|
| 345 |
+
const annotationFontFamily = typography.annotation.font_family || "Arial";
|
| 346 |
+
const annotationFontWeight = typography.annotation.font_weight || "bold";
|
| 347 |
+
|
| 348 |
+
chart.selectAll(".value-label")
|
| 349 |
+
.data(sortedData)
|
| 350 |
+
.enter()
|
| 351 |
+
.append("text")
|
| 352 |
+
.attr("class", "value-label")
|
| 353 |
+
.attr("x", d => xScale(d.x) + xScale.bandwidth() / 2)
|
| 354 |
+
.attr("y", d => yScale(d.y) - 5)
|
| 355 |
+
.attr("text-anchor", "middle")
|
| 356 |
+
.style("font-family", annotationFontFamily)
|
| 357 |
+
.style("font-weight", annotationFontWeight)
|
| 358 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 359 |
+
.each(function(d) {
|
| 360 |
+
const valueText = chartUtils.format.autoText(d.y) + (yUnit || "");
|
| 361 |
+
const maxWidth = xScale.bandwidth() * 1.1; // 最大允许宽度为柱宽的1.1倍
|
| 362 |
+
let finalValueFontSize = defaultAnnotationFontSize;
|
| 363 |
+
|
| 364 |
+
if (maxWidth > 0) { // 仅当宽度有效时计算
|
| 365 |
+
const textWidth = measureTextWidth(valueText, annotationFontFamily, defaultAnnotationFontSize, annotationFontWeight);
|
| 366 |
+
if (textWidth > maxWidth) {
|
| 367 |
+
finalValueFontSize = Math.max(minAnnotationFontSize, Math.floor(defaultAnnotationFontSize * (maxWidth / textWidth)));
|
| 368 |
+
}
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
d3.select(this)
|
| 372 |
+
.style("font-size", `${finalValueFontSize}px`)
|
| 373 |
+
.text(valueText);
|
| 374 |
+
});
|
| 375 |
+
|
| 376 |
+
// 在维度标签上方添加水平线
|
| 377 |
+
chart.append("line")
|
| 378 |
+
.attr("x1", 0)
|
| 379 |
+
.attr("y1", innerHeight + 20) // 直接位于维度标签上方的位置
|
| 380 |
+
.attr("x2", innerWidth)
|
| 381 |
+
.attr("y2", innerHeight + 20)
|
| 382 |
+
.attr("stroke", "#e0e0e0")
|
| 383 |
+
.attr("stroke-width", 1);
|
| 384 |
+
|
| 385 |
+
// *******************************************************************
|
| 386 |
+
// ** 开始修改:添加维度标签 (统一字体大小,自动换行) **
|
| 387 |
+
// *******************************************************************
|
| 388 |
+
chart.selectAll(".dimension-label")
|
| 389 |
+
.data(sortedData)
|
| 390 |
+
.enter()
|
| 391 |
+
.append("text")
|
| 392 |
+
.attr("class", "dimension-label")
|
| 393 |
+
.attr("text-anchor", "middle")
|
| 394 |
+
.style("font-family", labelFontFamily)
|
| 395 |
+
.style("font-size", `${finalLabelFontSize}px`) // 应用预计算的字体大小
|
| 396 |
+
.style("font-weight", labelFontWeight)
|
| 397 |
+
.style("fill", "#ffffff") // 使用默认文本颜色
|
| 398 |
+
.each(function(d) {
|
| 399 |
+
const labelText = String(d.x);
|
| 400 |
+
const textWidth = measureTextWidth(labelText, labelFontFamily, finalLabelFontSize, labelFontWeight);
|
| 401 |
+
const textElement = d3.select(this);
|
| 402 |
+
const xPos = xScale(d.x) + xScale.bandwidth() / 2;
|
| 403 |
+
const availableWidth = xScale.bandwidth();
|
| 404 |
+
|
| 405 |
+
if (textWidth > availableWidth && availableWidth > 0) {
|
| 406 |
+
// 需要换行 (重复之前的换行逻辑来渲染)
|
| 407 |
+
const words = labelText.split(/\s+/);
|
| 408 |
+
let lines = [];
|
| 409 |
+
let currentLine = '';
|
| 410 |
+
let simulationSuccess = false;
|
| 411 |
+
|
| 412 |
+
if(words.length > 1){
|
| 413 |
+
for (let i = 0; i < words.length; i++) {
|
| 414 |
+
const testLine = currentLine ? currentLine + " " + words[i] : words[i];
|
| 415 |
+
const testWidth = measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight);
|
| 416 |
+
if (testWidth > availableWidth && currentLine !== '') {
|
| 417 |
+
lines.push(currentLine);
|
| 418 |
+
currentLine = words[i];
|
| 419 |
+
if (measureTextWidth(currentLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > availableWidth) {
|
| 420 |
+
simulationSuccess = false; break;
|
| 421 |
+
}
|
| 422 |
+
} else { currentLine = testLine; }
|
| 423 |
+
}
|
| 424 |
+
if(currentLine !== '') { lines.push(currentLine); simulationSuccess = true; }
|
| 425 |
+
}
|
| 426 |
+
if(words.length <= 1 || !simulationSuccess) {
|
| 427 |
+
lines = [];
|
| 428 |
+
const chars = labelText.split('');
|
| 429 |
+
currentLine = '';
|
| 430 |
+
for (let i = 0; i < chars.length; i++) {
|
| 431 |
+
const testLine = currentLine + chars[i];
|
| 432 |
+
if (measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > availableWidth && currentLine !== '') {
|
| 433 |
+
lines.push(currentLine); currentLine = chars[i];
|
| 434 |
+
} else { currentLine += chars[i]; }
|
| 435 |
+
}
|
| 436 |
+
lines.push(currentLine);
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
lines.forEach((line, i) => {
|
| 440 |
+
textElement.append("tspan")
|
| 441 |
+
.attr("x", xPos)
|
| 442 |
+
.attr("y", labelStartY + i * lineHeight)
|
| 443 |
+
.attr("dy", "0.71em")
|
| 444 |
+
.text(line);
|
| 445 |
+
});
|
| 446 |
+
} else {
|
| 447 |
+
// 不需要换行
|
| 448 |
+
textElement.append("tspan")
|
| 449 |
+
.attr("x", xPos)
|
| 450 |
+
.attr("y", labelStartY)
|
| 451 |
+
.attr("dy", "0.71em")
|
| 452 |
+
.text(labelText);
|
| 453 |
+
}
|
| 454 |
+
});
|
| 455 |
+
// *******************************************************************
|
| 456 |
+
// ** 结束修改:添加维度标签 **
|
| 457 |
+
// *******************************************************************
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
// *******************************************************************
|
| 461 |
+
// ** 开始修改:添加图标 (移除圆形) - 动态调整 Y 位置 **
|
| 462 |
+
// *******************************************************************
|
| 463 |
+
chart.selectAll(".icon-group") // 修改类名以反映不再是圆形
|
| 464 |
+
.data(sortedData)
|
| 465 |
+
.enter()
|
| 466 |
+
.append("g")
|
| 467 |
+
.attr("class", "icon-group") // 修改类名
|
| 468 |
+
.attr("transform", d => `translate(${xScale(d.x) + xScale.bandwidth() / 2}, ${iconYPosition})`) // 应用动态Y位置
|
| 469 |
+
.each(function(d) {
|
| 470 |
+
|
| 471 |
+
if (images && images.field && images.field[d.x]) {
|
| 472 |
+
d3.select(this)
|
| 473 |
+
.append("image")
|
| 474 |
+
.attr("xlink:href", images.field[d.x])
|
| 475 |
+
.attr("x", -12)
|
| 476 |
+
.attr("y", -12)
|
| 477 |
+
.attr("width", 24)
|
| 478 |
+
.attr("preserveAspectRatio","xMidYMid meet")
|
| 479 |
+
.attr("height", 24);
|
| 480 |
+
}
|
| 481 |
+
});
|
| 482 |
+
// *******************************************************************
|
| 483 |
+
// ** 结束修改:添加图标 **
|
| 484 |
+
// *******************************************************************
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
// 返回SVG节点
|
| 488 |
+
return svg.node();
|
| 489 |
+
}
|
modules/chart_engine/template/d3-js/bar_chart/vertical_bar_chart_08.js
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Vertical Bar Chart",
|
| 5 |
+
"chart_name": "vertical_bar_chart_08",
|
| 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": ["primary"],
|
| 14 |
+
"supported_effects": ["shadow", "radius_corner", "gradient", "stroke", "spacing"],
|
| 15 |
+
"min_height": 400,
|
| 16 |
+
"min_width": 400,
|
| 17 |
+
"background": "no",
|
| 18 |
+
"icon_mark": "overlay",
|
| 19 |
+
"icon_label": "side",
|
| 20 |
+
"has_x_axis": "no",
|
| 21 |
+
"has_y_axis": "no"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
// 使用D3.js创建简单的垂直条形图
|
| 27 |
+
function makeChart(containerSelector, data) {
|
| 28 |
+
const colorResolver = chartUtils.color.resolver(data);
|
| 29 |
+
// ---------- 1. 数据准备 ----------
|
| 30 |
+
const jsonData = data; // 完整的JSON数据对象
|
| 31 |
+
const chartData = jsonData.data.data || []; // 图表数据
|
| 32 |
+
const variables = jsonData.variables || {}; // 图表配置
|
| 33 |
+
const typography = jsonData.typography || { // 字体设置
|
| 34 |
+
title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
|
| 35 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 36 |
+
description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 37 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 38 |
+
};
|
| 39 |
+
const colors = jsonData.colors || {
|
| 40 |
+
text_color: "#333333",
|
| 41 |
+
other: { primary: "#73D2C7", secondary: "#4682B4" }
|
| 42 |
+
};
|
| 43 |
+
const images = jsonData.images || { field: {}, other: {} }; // 图片(标志等)
|
| 44 |
+
const dataColumns = chartUtils.schema.columns(jsonData); // 数据列
|
| 45 |
+
|
| 46 |
+
// 设置视觉效果变量
|
| 47 |
+
variables.has_rounded_corners = variables.has_rounded_corners || false;
|
| 48 |
+
variables.has_shadow = variables.has_shadow || false;
|
| 49 |
+
variables.has_gradient = variables.has_gradient || false;
|
| 50 |
+
variables.has_stroke = variables.has_stroke || false;
|
| 51 |
+
variables.has_spacing = variables.has_spacing || false;
|
| 52 |
+
|
| 53 |
+
// 清空容器
|
| 54 |
+
d3.select(containerSelector).html("");
|
| 55 |
+
|
| 56 |
+
// ---------- 2. 尺寸和布局设置 ----------
|
| 57 |
+
|
| 58 |
+
// 设置图表尺寸和边距
|
| 59 |
+
const width = variables.width || 800;
|
| 60 |
+
const height = variables.height || 600;
|
| 61 |
+
|
| 62 |
+
// 边距: 上, 右, 下, 左
|
| 63 |
+
const margin = {
|
| 64 |
+
top: 60, // 柱子上方数值标签的空间
|
| 65 |
+
right: 30,
|
| 66 |
+
bottom: 150, // x轴和图标的空间
|
| 67 |
+
left: 60
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
// 计算实际图表区域尺寸
|
| 71 |
+
const innerWidth = width - margin.left - margin.right;
|
| 72 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 73 |
+
|
| 74 |
+
// ---------- 3. 提取字段名称和单位 ----------
|
| 75 |
+
|
| 76 |
+
let xField, yField;
|
| 77 |
+
let xUnit = "", yUnit = "";
|
| 78 |
+
|
| 79 |
+
// 安全提取字段名称
|
| 80 |
+
const xChannel = chartUtils.schema.channel(jsonData, "x");
|
| 81 |
+
const yChannel = chartUtils.schema.channel(jsonData, "y");
|
| 82 |
+
|
| 83 |
+
xField = xChannel.key;
|
| 84 |
+
yField = yChannel.key;
|
| 85 |
+
|
| 86 |
+
// 获取字段单位(如果存在)
|
| 87 |
+
if (xChannel.unit) {
|
| 88 |
+
xUnit = xChannel.unit;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
if (yChannel.unit) {
|
| 92 |
+
yUnit = yChannel.unit;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// ---------- 4. 数据处理 ----------
|
| 96 |
+
|
| 97 |
+
// 获取唯一的x轴值
|
| 98 |
+
const xValues = [...new Set(chartData.map(d => d[xField]))];
|
| 99 |
+
|
| 100 |
+
// 创建按y值降序排序的数据
|
| 101 |
+
const sortedData = chartData.map(d => ({
|
| 102 |
+
x: d[xField],
|
| 103 |
+
y: +d[yField] // 确保y是数值类型
|
| 104 |
+
})).sort((a, b) => b.y - a.y);
|
| 105 |
+
|
| 106 |
+
// ---------- 5. 创建SVG容器 ----------
|
| 107 |
+
|
| 108 |
+
const svg = d3.select(containerSelector)
|
| 109 |
+
.append("svg")
|
| 110 |
+
.attr("width", "100%")
|
| 111 |
+
.attr("height", height)
|
| 112 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 113 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 114 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 115 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
// ---------- 6. 创建视觉效果 ----------
|
| 120 |
+
|
| 121 |
+
const defs = svg.append("defs");
|
| 122 |
+
|
| 123 |
+
// *** BEGIN REMOVAL: Remove stripe pattern generation ***
|
| 124 |
+
// const patternDensity = 6;
|
| 125 |
+
// const patternStrokeWidth = 1.5;
|
| 126 |
+
// sortedData.forEach((item, idx) => {
|
| 127 |
+
// const dimension = item.x;
|
| 128 |
+
// let barColor = colorResolver.other("primary").value;
|
| 129 |
+
// if (colorResolver.field(dimension, 0, { fallbackKey: "primary" }).value) {
|
| 130 |
+
// barColor = colorResolver.field(dimension, 0, { fallbackKey: "primary" }).value;
|
| 131 |
+
// }
|
| 132 |
+
// const safeCategory = typeof dimension === 'string' ?
|
| 133 |
+
// dimension.toString().replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() :
|
| 134 |
+
// `category-${idx}`;
|
| 135 |
+
// const patternId = `pattern-stripe-${safeCategory}`;
|
| 136 |
+
// const pattern = defs.append("pattern")
|
| 137 |
+
// .attr("id", patternId)
|
| 138 |
+
// .attr("patternUnits", "userSpaceOnUse")
|
| 139 |
+
// .attr("width", patternDensity)
|
| 140 |
+
// .attr("height", patternDensity)
|
| 141 |
+
// .attr("patternTransform", "rotate(45)");
|
| 142 |
+
// pattern.append("rect")
|
| 143 |
+
// .attr("width", patternDensity)
|
| 144 |
+
// .attr("height", patternDensity)
|
| 145 |
+
// .attr("fill", barColor)
|
| 146 |
+
// .attr("opacity", 0.8);
|
| 147 |
+
// pattern.append("line")
|
| 148 |
+
// .attr("x1", 0)
|
| 149 |
+
// .attr("y1", 0)
|
| 150 |
+
// .attr("x2", 0)
|
| 151 |
+
// .attr("y2", patternDensity)
|
| 152 |
+
// .attr("stroke", "white")
|
| 153 |
+
// .attr("stroke-width", patternStrokeWidth)
|
| 154 |
+
// .attr("opacity", 0.6);
|
| 155 |
+
// });
|
| 156 |
+
// *** END REMOVAL ***
|
| 157 |
+
|
| 158 |
+
// *** BEGIN ADDITION: Define vertical gradient per category ***
|
| 159 |
+
sortedData.forEach((item, idx) => {
|
| 160 |
+
const dimension = item.x;
|
| 161 |
+
let barColor = colorResolver.other("primary").value; // Default color
|
| 162 |
+
if (colorResolver.field(dimension, 0, { fallbackKey: "primary" }).value) {
|
| 163 |
+
barColor = colorResolver.field(dimension, 0, { fallbackKey: "primary" }).value; // Use specific color if available
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
// Create safe ID for the gradient
|
| 167 |
+
const safeCategory = typeof dimension === 'string' ?
|
| 168 |
+
dimension.toString().replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() :
|
| 169 |
+
`category-${idx}`;
|
| 170 |
+
const gradientId = `gradient-${safeCategory}`;
|
| 171 |
+
|
| 172 |
+
const gradient = defs.append("linearGradient")
|
| 173 |
+
.attr("id", gradientId)
|
| 174 |
+
.attr("x1", "0%")
|
| 175 |
+
.attr("y1", "100%") // Bottom
|
| 176 |
+
.attr("x2", "0%")
|
| 177 |
+
.attr("y2", "0%"); // Top
|
| 178 |
+
|
| 179 |
+
// Darker color at the bottom
|
| 180 |
+
gradient.append("stop")
|
| 181 |
+
.attr("offset", "0%")
|
| 182 |
+
.attr("stop-color", colorResolver.variant(barColor, { mode: "darker", amount: 0.5 })); // Adjust factor as needed
|
| 183 |
+
|
| 184 |
+
// Lighter color at the top
|
| 185 |
+
gradient.append("stop")
|
| 186 |
+
.attr("offset", "100%")
|
| 187 |
+
.attr("stop-color", colorResolver.variant(barColor, { mode: "brighter", amount: 0.5 })); // Adjust factor as needed
|
| 188 |
+
});
|
| 189 |
+
// *** END ADDITION ***
|
| 190 |
+
|
| 191 |
+
// 如果需要创建阴影滤镜
|
| 192 |
+
// if (variables.has_shadow) {
|
| 193 |
+
// const filter = defs.append("filter")
|
| 194 |
+
// .attr("id", "shadow")
|
| 195 |
+
// .attr("filterUnits", "userSpaceOnUse")
|
| 196 |
+
// .attr("width", "200%")
|
| 197 |
+
// .attr("height", "200%");
|
| 198 |
+
|
| 199 |
+
// filter.append("feGaussianBlur")
|
| 200 |
+
// .attr("in", "SourceAlpha")
|
| 201 |
+
// .attr("stdDeviation", 3);
|
| 202 |
+
|
| 203 |
+
// filter.append("feOffset")
|
| 204 |
+
// .attr("dx", 2)
|
| 205 |
+
// .attr("dy", 2)
|
| 206 |
+
// .attr("result", "offsetblur");
|
| 207 |
+
|
| 208 |
+
// const feMerge = filter.append("feMerge");
|
| 209 |
+
// feMerge.append("feMergeNode");
|
| 210 |
+
// feMerge.append("feMergeNode").attr("in", "SourceGraphic");
|
| 211 |
+
// }
|
| 212 |
+
|
| 213 |
+
// ---------- 7. 创建图表 ----------
|
| 214 |
+
|
| 215 |
+
// 创建图表组
|
| 216 |
+
const chart = svg.append("g")
|
| 217 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 218 |
+
|
| 219 |
+
// 创建比例尺
|
| 220 |
+
const xScale = d3.scaleBand()
|
| 221 |
+
.domain(sortedData.map(d => d.x))
|
| 222 |
+
.range([0, innerWidth])
|
| 223 |
+
.padding(variables.has_spacing ? 0.4 : 0.2);
|
| 224 |
+
|
| 225 |
+
const yMax = d3.max(sortedData, d => d.y);
|
| 226 |
+
const yScale = d3.scaleLinear()
|
| 227 |
+
.domain([0, yMax > 0 ? yMax * 1.1 : 1]) // 处理y都为0的情况,并添加10%的填充
|
| 228 |
+
.range([innerHeight, 0]);
|
| 229 |
+
|
| 230 |
+
// *******************************************************************
|
| 231 |
+
// ** 开始修改:预计算标签字体大小和最大行数 **
|
| 232 |
+
// *******************************************************************
|
| 233 |
+
const defaultLabelFontSize = parseFloat(typography.label.font_size || 12);
|
| 234 |
+
const minLabelFontSize = 10; // 最小字体大小
|
| 235 |
+
const currentBarWidth = xScale.bandwidth(); // 使用实际的bar宽度
|
| 236 |
+
let finalLabelFontSize = defaultLabelFontSize;
|
| 237 |
+
let maxLinesNeeded = 1; // 记录所有标签所需的最大行数
|
| 238 |
+
const labelFontFamily = typography.label.font_family || "Arial";
|
| 239 |
+
const labelFontWeight = typography.label.font_weight || "normal";
|
| 240 |
+
const lineHeightFactor = 1.2; // 行高倍数
|
| 241 |
+
|
| 242 |
+
// 文本宽度测量辅助函数 (使用 canvas 提高性能)
|
| 243 |
+
const canvas = document.createElement('canvas');
|
| 244 |
+
const ctx = canvas.getContext('2d');
|
| 245 |
+
function measureTextWidth(text, fontFamily, fontSize, fontWeight) {
|
| 246 |
+
ctx.font = `${fontWeight || 'normal'} ${fontSize}px ${fontFamily || 'Arial'}`;
|
| 247 |
+
return chartUtils.text.contextWidth(ctx, text);
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
// 第一遍计算:确定统一的字体大小
|
| 251 |
+
if (currentBarWidth > 0) { // 确保 barWidth 有效
|
| 252 |
+
let maxRatio = 1;
|
| 253 |
+
sortedData.forEach(d => {
|
| 254 |
+
const labelText = String(d.x); // 确保是字符串
|
| 255 |
+
const textWidth = measureTextWidth(labelText, labelFontFamily, defaultLabelFontSize, labelFontWeight);
|
| 256 |
+
const ratio = textWidth / currentBarWidth;
|
| 257 |
+
if (ratio > maxRatio) {
|
| 258 |
+
maxRatio = ratio;
|
| 259 |
+
}
|
| 260 |
+
});
|
| 261 |
+
|
| 262 |
+
if (maxRatio > 1) {
|
| 263 |
+
finalLabelFontSize = Math.max(minLabelFontSize, Math.floor(defaultLabelFontSize / maxRatio));
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
// 第二遍计算:确定是否需要换行以及最大行数 (使用最终字体大小)
|
| 267 |
+
sortedData.forEach(d => {
|
| 268 |
+
const labelText = String(d.x);
|
| 269 |
+
const textWidth = measureTextWidth(labelText, labelFontFamily, finalLabelFontSize, labelFontWeight);
|
| 270 |
+
|
| 271 |
+
if (textWidth > currentBarWidth) {
|
| 272 |
+
// 模拟换行计算行数
|
| 273 |
+
const words = labelText.split(/\s+/); // 按空格分词
|
| 274 |
+
let currentLine = '';
|
| 275 |
+
let lines = 1;
|
| 276 |
+
let simulationSuccess = false; // 标记模拟是否成功
|
| 277 |
+
|
| 278 |
+
if (words.length > 1) { // 优先按单词换行
|
| 279 |
+
for (let i = 0; i < words.length; i++) {
|
| 280 |
+
const testLine = currentLine ? currentLine + " " + words[i] : words[i];
|
| 281 |
+
const testWidth = measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight);
|
| 282 |
+
if (testWidth > currentBarWidth && currentLine !== '') {
|
| 283 |
+
lines++;
|
| 284 |
+
currentLine = words[i];
|
| 285 |
+
// 检查单个单词是否也超长
|
| 286 |
+
if (measureTextWidth(currentLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > currentBarWidth) {
|
| 287 |
+
// 单个单词超长,需要转字符换行
|
| 288 |
+
simulationSuccess = false;
|
| 289 |
+
break; // 退出单词循环
|
| 290 |
+
}
|
| 291 |
+
} else {
|
| 292 |
+
currentLine = testLine;
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
if(currentLine !== '') simulationSuccess = true; // 如果有剩余单词则标记成功
|
| 296 |
+
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
// 如果单词换行不成功或只有一个单词,尝试字符换行
|
| 300 |
+
if(words.length <= 1 || !simulationSuccess) {
|
| 301 |
+
lines = 1; // 重置行数
|
| 302 |
+
const chars = labelText.split('');
|
| 303 |
+
currentLine = '';
|
| 304 |
+
for (let i = 0; i < chars.length; i++) {
|
| 305 |
+
const testLine = currentLine + chars[i];
|
| 306 |
+
if (measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > currentBarWidth && currentLine !== '') {
|
| 307 |
+
lines++;
|
| 308 |
+
currentLine = chars[i];
|
| 309 |
+
} else {
|
| 310 |
+
currentLine += chars[i];
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
if (lines > maxLinesNeeded) {
|
| 317 |
+
maxLinesNeeded = lines;
|
| 318 |
+
}
|
| 319 |
+
}
|
| 320 |
+
});
|
| 321 |
+
} else {
|
| 322 |
+
// 如果barWidth无效,则使用默认字体大小且不换行
|
| 323 |
+
finalLabelFontSize = defaultLabelFontSize;
|
| 324 |
+
maxLinesNeeded = 1;
|
| 325 |
+
}
|
| 326 |
+
// *******************************************************************
|
| 327 |
+
// ** 结束修改:预计算标签字体大小和最大行数 **
|
| 328 |
+
// *******************************************************************
|
| 329 |
+
|
| 330 |
+
// *******************************************************************
|
| 331 |
+
// ** 开始修改:计算图标的统一Y位置 **
|
| 332 |
+
// *******************************************************************
|
| 333 |
+
const labelStartY = innerHeight + 10; // 标签开始的 Y 位置 (靠近柱子底部)
|
| 334 |
+
const lineHeight = finalLabelFontSize * lineHeightFactor; // 行高
|
| 335 |
+
const labelBottomApprox = labelStartY + (maxLinesNeeded - 1) * lineHeight + finalLabelFontSize * 0.71; // 估算最下方标签基线位置
|
| 336 |
+
const iconRadius = 15; // 图标半径
|
| 337 |
+
const iconYPosition = labelBottomApprox + iconRadius + 5; // 在标签下方留出 5px 间距放置图标中心
|
| 338 |
+
const iconBottomY = iconYPosition + iconRadius; // 图标底部的Y坐标
|
| 339 |
+
const barExtensionBuffer = 5; // 柱子比图标底部多延伸的距离
|
| 340 |
+
const barBottomY = iconBottomY + barExtensionBuffer; // 柱子需要延伸到的最终Y坐标
|
| 341 |
+
// *******************************************************************
|
| 342 |
+
// ** 结束修改:计算图标的统一Y位置 **
|
| 343 |
+
// *******************************************************************
|
| 344 |
+
|
| 345 |
+
// 添加柱子 (修改高度)
|
| 346 |
+
chart.selectAll(".bar")
|
| 347 |
+
.data(sortedData)
|
| 348 |
+
.enter()
|
| 349 |
+
.append("rect")
|
| 350 |
+
.attr("class", "bar")
|
| 351 |
+
.attr("x", d => xScale(d.x))
|
| 352 |
+
.attr("y", d => yScale(d.y))
|
| 353 |
+
.attr("width", xScale.bandwidth())
|
| 354 |
+
// 修改: 使柱子高度只取决于数据值和yScale
|
| 355 |
+
.attr("height", d => Math.max(0, innerHeight - yScale(d.y)))
|
| 356 |
+
// *** BEGIN MODIFICATION: Apply specific gradient fill ***
|
| 357 |
+
.attr("fill", d => {
|
| 358 |
+
const dimension = d.x;
|
| 359 |
+
const safeCategory = typeof dimension === 'string' ?
|
| 360 |
+
dimension.toString().replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() :
|
| 361 |
+
`category-${sortedData.findIndex(item => item.x === dimension)}`;
|
| 362 |
+
const gradientId = `gradient-${safeCategory}`;
|
| 363 |
+
return `url(#${gradientId})`;
|
| 364 |
+
})
|
| 365 |
+
// *** END MODIFICATION ***
|
| 366 |
+
// *** BEGIN MODIFICATION: Force rounded corners ***
|
| 367 |
+
.attr("rx", 4) // Force rounded corners
|
| 368 |
+
.attr("ry", 4) // Force rounded corners
|
| 369 |
+
// *** END MODIFICATION ***
|
| 370 |
+
.style("stroke", variables.has_stroke ? "#333" : "none")
|
| 371 |
+
.style("stroke-width", variables.has_stroke ? 1 : 0)
|
| 372 |
+
.style("filter", variables.has_shadow ? "url(#shadow)" : "none")
|
| 373 |
+
.on("mouseover", function() {
|
| 374 |
+
d3.select(this).attr("opacity", 0.8);
|
| 375 |
+
})
|
| 376 |
+
.on("mouseout", function() {
|
| 377 |
+
d3.select(this).attr("opacity", 1);
|
| 378 |
+
});
|
| 379 |
+
|
| 380 |
+
// 在柱子上方添加数值标签
|
| 381 |
+
const defaultAnnotationFontSize = parseFloat(typography.annotation.font_size || 12);
|
| 382 |
+
const minAnnotationFontSize = 6; // 数值标签最小字体
|
| 383 |
+
const annotationFontFamily = typography.annotation.font_family || "Arial";
|
| 384 |
+
const annotationFontWeight = typography.annotation.font_weight || "bold";
|
| 385 |
+
|
| 386 |
+
// 添加数值格式化函数
|
| 387 |
+
|
| 388 |
+
chart.selectAll(".value-label")
|
| 389 |
+
.data(sortedData)
|
| 390 |
+
.enter()
|
| 391 |
+
.append("text")
|
| 392 |
+
.attr("class", "value-label")
|
| 393 |
+
.attr("x", d => xScale(d.x) + xScale.bandwidth() / 2)
|
| 394 |
+
.attr("y", d => yScale(d.y) - 5)
|
| 395 |
+
.attr("text-anchor", "middle")
|
| 396 |
+
.style("font-family", annotationFontFamily)
|
| 397 |
+
.style("font-weight", annotationFontWeight)
|
| 398 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 399 |
+
.each(function(d) {
|
| 400 |
+
const valueText = `${chartUtils.format.autoText(d.y)}${yUnit}`;
|
| 401 |
+
const maxWidth = xScale.bandwidth() * 1.1; // 最大允许宽度为柱宽的1.1倍
|
| 402 |
+
let finalValueFontSize = defaultAnnotationFontSize;
|
| 403 |
+
|
| 404 |
+
if (maxWidth > 0) { // 仅当宽度有效时计算
|
| 405 |
+
const textWidth = measureTextWidth(valueText, annotationFontFamily, defaultAnnotationFontSize, annotationFontWeight);
|
| 406 |
+
if (textWidth > maxWidth) {
|
| 407 |
+
finalValueFontSize = Math.max(minAnnotationFontSize, Math.floor(defaultAnnotationFontSize * (maxWidth / textWidth)));
|
| 408 |
+
}
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
d3.select(this)
|
| 412 |
+
.style("font-size", `${finalValueFontSize}px`)
|
| 413 |
+
.text(valueText);
|
| 414 |
+
});
|
| 415 |
+
|
| 416 |
+
// 在维度标签上方添加水平线
|
| 417 |
+
chart.append("line")
|
| 418 |
+
.attr("x1", 0)
|
| 419 |
+
.attr("y1", innerHeight + 20) // 直接位于维度标签上方的位置
|
| 420 |
+
.attr("x2", innerWidth)
|
| 421 |
+
.attr("y2", innerHeight + 20)
|
| 422 |
+
.attr("stroke", "#e0e0e0")
|
| 423 |
+
.attr("stroke-width", 1);
|
| 424 |
+
|
| 425 |
+
// *******************************************************************
|
| 426 |
+
// ** 开始修改:添加维度标签 (统一字体大小,自动换行) **
|
| 427 |
+
// *******************************************************************
|
| 428 |
+
chart.selectAll(".dimension-label")
|
| 429 |
+
.data(sortedData)
|
| 430 |
+
.enter()
|
| 431 |
+
.append("text")
|
| 432 |
+
.attr("class", "dimension-label")
|
| 433 |
+
.attr("text-anchor", "middle")
|
| 434 |
+
.style("font-family", labelFontFamily)
|
| 435 |
+
.style("font-size", `${finalLabelFontSize}px`) // 应用预计算的字体大小
|
| 436 |
+
.style("font-weight", labelFontWeight)
|
| 437 |
+
.style("fill", "#ffffff") // 使用默认文本颜色
|
| 438 |
+
.each(function(d) {
|
| 439 |
+
const labelText = String(d.x);
|
| 440 |
+
const textWidth = measureTextWidth(labelText, labelFontFamily, finalLabelFontSize, labelFontWeight);
|
| 441 |
+
const textElement = d3.select(this);
|
| 442 |
+
const xPos = xScale(d.x) + xScale.bandwidth() / 2;
|
| 443 |
+
const availableWidth = xScale.bandwidth();
|
| 444 |
+
|
| 445 |
+
if (textWidth > availableWidth && availableWidth > 0) {
|
| 446 |
+
// 需要换行 (重复之前的换行逻辑来渲染)
|
| 447 |
+
const words = labelText.split(/\s+/);
|
| 448 |
+
let lines = [];
|
| 449 |
+
let currentLine = '';
|
| 450 |
+
let simulationSuccess = false;
|
| 451 |
+
|
| 452 |
+
if(words.length > 1){
|
| 453 |
+
for (let i = 0; i < words.length; i++) {
|
| 454 |
+
const testLine = currentLine ? currentLine + " " + words[i] : words[i];
|
| 455 |
+
const testWidth = measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight);
|
| 456 |
+
if (testWidth > availableWidth && currentLine !== '') {
|
| 457 |
+
lines.push(currentLine);
|
| 458 |
+
currentLine = words[i];
|
| 459 |
+
if (measureTextWidth(currentLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > availableWidth) {
|
| 460 |
+
simulationSuccess = false; break;
|
| 461 |
+
}
|
| 462 |
+
} else { currentLine = testLine; }
|
| 463 |
+
}
|
| 464 |
+
if(currentLine !== '') { lines.push(currentLine); simulationSuccess = true; }
|
| 465 |
+
}
|
| 466 |
+
if(words.length <= 1 || !simulationSuccess) {
|
| 467 |
+
lines = [];
|
| 468 |
+
const chars = labelText.split('');
|
| 469 |
+
currentLine = '';
|
| 470 |
+
for (let i = 0; i < chars.length; i++) {
|
| 471 |
+
const testLine = currentLine + chars[i];
|
| 472 |
+
if (measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > availableWidth && currentLine !== '') {
|
| 473 |
+
lines.push(currentLine); currentLine = chars[i];
|
| 474 |
+
} else { currentLine += chars[i]; }
|
| 475 |
+
}
|
| 476 |
+
lines.push(currentLine);
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
lines.forEach((line, i) => {
|
| 480 |
+
textElement.append("tspan")
|
| 481 |
+
.attr("x", xPos)
|
| 482 |
+
.attr("y", labelStartY + i * lineHeight)
|
| 483 |
+
.attr("dy", "0.71em")
|
| 484 |
+
.text(line);
|
| 485 |
+
});
|
| 486 |
+
} else {
|
| 487 |
+
// 不需要换行
|
| 488 |
+
textElement.append("tspan")
|
| 489 |
+
.attr("x", xPos)
|
| 490 |
+
.attr("y", labelStartY)
|
| 491 |
+
.attr("dy", "0.71em")
|
| 492 |
+
.text(labelText);
|
| 493 |
+
}
|
| 494 |
+
});
|
| 495 |
+
// *******************************************************************
|
| 496 |
+
// ** 结束修改:添加维度标签 **
|
| 497 |
+
// *******************************************************************
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
// *******************************************************************
|
| 501 |
+
// ** 开始修改:添加图标 (移除圆形) - 动态调整 Y 位置 **
|
| 502 |
+
// *******************************************************************
|
| 503 |
+
chart.selectAll(".icon-group") // 修改类名以反映不再是圆形
|
| 504 |
+
.data(sortedData)
|
| 505 |
+
.enter()
|
| 506 |
+
.append("g")
|
| 507 |
+
.attr("class", "icon-group") // 修改类名
|
| 508 |
+
.attr("transform", d => `translate(${xScale(d.x) + xScale.bandwidth() / 2}, ${iconYPosition})`) // 应用动态Y位置
|
| 509 |
+
.each(function(d) {
|
| 510 |
+
|
| 511 |
+
if (images && images.field && images.field[d.x]) {
|
| 512 |
+
d3.select(this)
|
| 513 |
+
.append("image")
|
| 514 |
+
.attr("xlink:href", images.field[d.x])
|
| 515 |
+
.attr("x", -12)
|
| 516 |
+
.attr("y", -12)
|
| 517 |
+
.attr("width", 24)
|
| 518 |
+
.attr("preserveAspectRatio","xMidYMid meet")
|
| 519 |
+
.attr("height", 24);
|
| 520 |
+
}
|
| 521 |
+
});
|
| 522 |
+
// *******************************************************************
|
| 523 |
+
// ** 结束修改:添加图标 **
|
| 524 |
+
// *******************************************************************
|
| 525 |
+
|
| 526 |
+
|
| 527 |
+
// 返回SVG节点
|
| 528 |
+
return svg.node();
|
| 529 |
+
}
|
modules/chart_engine/template/d3-js/bar_chart/vertical_bar_chart_12.js
ADDED
|
@@ -0,0 +1,856 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Vertical Bar Chart",
|
| 5 |
+
"chart_name": "vertical_bar_chart_12",
|
| 6 |
+
"is_composite": true,
|
| 7 |
+
"required_fields": [["x", "y"], ["x", "y2"]],
|
| 8 |
+
"required_fields_type": [
|
| 9 |
+
[["categorical"], ["numerical"]],
|
| 10 |
+
[["categorical"], ["numerical"]]
|
| 11 |
+
],
|
| 12 |
+
"required_fields_range": [
|
| 13 |
+
[[2, 30], ["-inf", "inf"]],
|
| 14 |
+
[[2, 30], [0, "inf"]]
|
| 15 |
+
],
|
| 16 |
+
"required_fields_icons": ["x"],
|
| 17 |
+
"required_other_icons": [],
|
| 18 |
+
"required_fields_colors": ["x"],
|
| 19 |
+
"required_other_colors": ["primary","secondary"],
|
| 20 |
+
"supported_effects": ["radius_corner", "spacing"],
|
| 21 |
+
"min_height": 400,
|
| 22 |
+
"min_width": 400,
|
| 23 |
+
"background": "no",
|
| 24 |
+
"icon_mark": "none",
|
| 25 |
+
"icon_label": "side",
|
| 26 |
+
"has_x_axis": "no",
|
| 27 |
+
"has_y_axis": "no"
|
| 28 |
+
}
|
| 29 |
+
REQUIREMENTS_END
|
| 30 |
+
*/
|
| 31 |
+
|
| 32 |
+
function makeChart(containerSelector, dataJSON) {
|
| 33 |
+
const jsonData = dataJSON || {};
|
| 34 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 35 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 36 |
+
const chartUtilsFormatSample = chartUtils.format.autoText;
|
| 37 |
+
const chartUtilsTextSample = chartUtils.text.estimate;
|
| 38 |
+
const chartUtilsStandard = {
|
| 39 |
+
schema: chartUtils.schema,
|
| 40 |
+
format: chartUtils.format,
|
| 41 |
+
text: chartUtils.text,
|
| 42 |
+
color: chartUtils.color,
|
| 43 |
+
legendLayout: chartUtils.legend.layout,
|
| 44 |
+
legendDraw: chartUtils.legend.draw,
|
| 45 |
+
random: chartUtils.random.generator(jsonData, "dev-wz-style")
|
| 46 |
+
};
|
| 47 |
+
const standardChannels = chartUtils.schema.channels(jsonData, {
|
| 48 |
+
x: { fallbackIndex: 0 },
|
| 49 |
+
y: { fallbackIndex: 1 },
|
| 50 |
+
y2: { fallbackIndex: 2 },
|
| 51 |
+
y3: { fallbackIndex: 3 },
|
| 52 |
+
size: { fallbackIndex: 2 },
|
| 53 |
+
group: { fallbackIndex: 2 },
|
| 54 |
+
group2: { fallbackIndex: 3 },
|
| 55 |
+
group3: { fallbackIndex: 4 }
|
| 56 |
+
});
|
| 57 |
+
const rawDataBlock = jsonData.data || {};
|
| 58 |
+
const dataBlock = Array.isArray(rawDataBlock)
|
| 59 |
+
? { data: rawDataBlock, columns: jsonData.columns || [] }
|
| 60 |
+
: rawDataBlock;
|
| 61 |
+
const sourceRows = dataBlock.data || [];
|
| 62 |
+
const columns = dataBlock.columns || jsonData.columns || [];
|
| 63 |
+
const variables = jsonData.variables || {};
|
| 64 |
+
const typography = jsonData.typography || {};
|
| 65 |
+
const sourceColors = jsonData.colors || {};
|
| 66 |
+
|
| 67 |
+
d3.select(containerSelector).html("");
|
| 68 |
+
|
| 69 |
+
const xColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
|
| 70 |
+
const yColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
|
| 71 |
+
const y2Column = Array.from(columns).find(c => c["role"] === "y2")
|
| 72 |
+
|| Array.from(columns).find(c => c !== xColumn && c !== yColumn && c.data_type === "numerical")
|
| 73 |
+
|| columns[2] || {};
|
| 74 |
+
const xField = xColumn.name;
|
| 75 |
+
const yField = yColumn.name;
|
| 76 |
+
const y2Field = y2Column.name;
|
| 77 |
+
|
| 78 |
+
const width = Math.max(580, Number(variables.width) || 640);
|
| 79 |
+
const height = Math.max(860, Number(variables.height) || 900);
|
| 80 |
+
const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
|
| 81 |
+
const titleFamily = typography.title?.font_family || fontFamily;
|
| 82 |
+
const annotationFamily = typography.annotation?.font_family || fontFamily;
|
| 83 |
+
const darkText = sourceColors.text_color || "#08255a";
|
| 84 |
+
const mutedText = "#53657a";
|
| 85 |
+
const navy = "#08245c";
|
| 86 |
+
const blue = sourceColors.other?.secondary || "#0b82c8";
|
| 87 |
+
const red = sourceColors.other?.primary || "#b9152d";
|
| 88 |
+
const palette = sourceColors.available_colors?.length
|
| 89 |
+
? sourceColors.available_colors
|
| 90 |
+
: [red, "#159f9b", "#12a84f", "#5cb33e", "#7350a6", "#f3b719", "#1f77b4", "#f97316"];
|
| 91 |
+
const labelSize = Math.max(8.5, Math.min(11.5, parseFloat(typography.label?.font_size) || 10));
|
| 92 |
+
const valueSize = Math.max(8.5, Math.min(12, parseFloat(typography.annotation?.font_size) || 10));
|
| 93 |
+
const yUnit = yColumn.unit === "none" ? "" : (yColumn.unit || "");
|
| 94 |
+
const y2Unit = y2Column.unit === "none" ? "" : (y2Column.unit || "");
|
| 95 |
+
|
| 96 |
+
const svg = d3.select(containerSelector)
|
| 97 |
+
.append("svg")
|
| 98 |
+
.attr("width", "100%")
|
| 99 |
+
.attr("height", height)
|
| 100 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 101 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 102 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 103 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
|
| 104 |
+
.attr("class", "vertical-bar-chart-12 vertical-bar-chart-12-reference-layout");
|
| 105 |
+
|
| 106 |
+
const defs = svg.append("defs");
|
| 107 |
+
const pageGradient = defs.append("linearGradient")
|
| 108 |
+
.attr("id", "vb12-page-gradient")
|
| 109 |
+
.attr("x1", "0%")
|
| 110 |
+
.attr("x2", "100%")
|
| 111 |
+
.attr("y1", "0%")
|
| 112 |
+
.attr("y2", "100%");
|
| 113 |
+
pageGradient.append("stop").attr("offset", "0%").attr("stop-color", "#ffffff");
|
| 114 |
+
pageGradient.append("stop").attr("offset", "62%").attr("stop-color", "#fbfdff");
|
| 115 |
+
pageGradient.append("stop").attr("offset", "100%").attr("stop-color", "#e5f5ff");
|
| 116 |
+
|
| 117 |
+
const barGradient = defs.append("linearGradient")
|
| 118 |
+
.attr("id", "vb12-bar-highlight")
|
| 119 |
+
.attr("x1", "0%")
|
| 120 |
+
.attr("x2", "100%")
|
| 121 |
+
.attr("y1", "0%")
|
| 122 |
+
.attr("y2", "100%");
|
| 123 |
+
barGradient.append("stop").attr("offset", "0%").attr("stop-color", "rgba(255,255,255,0.26)");
|
| 124 |
+
barGradient.append("stop").attr("offset", "100%").attr("stop-color", "rgba(0,0,0,0.10)");
|
| 125 |
+
|
| 126 |
+
const cardShadow = defs.append("filter")
|
| 127 |
+
.attr("id", "vb12-card-shadow")
|
| 128 |
+
.attr("x", "-20%")
|
| 129 |
+
.attr("y", "-20%")
|
| 130 |
+
.attr("width", "140%")
|
| 131 |
+
.attr("height", "150%");
|
| 132 |
+
cardShadow.append("feDropShadow")
|
| 133 |
+
.attr("dx", 0)
|
| 134 |
+
.attr("dy", 8)
|
| 135 |
+
.attr("stdDeviation", 10)
|
| 136 |
+
.attr("flood-color", "#7bb9df")
|
| 137 |
+
.attr("flood-opacity", 0.18);
|
| 138 |
+
|
| 139 |
+
svg.append("rect")
|
| 140 |
+
.attr("class", "chart-canvas reference-page-background")
|
| 141 |
+
.attr("x", 0)
|
| 142 |
+
.attr("y", 0)
|
| 143 |
+
.attr("width", width)
|
| 144 |
+
.attr("height", height)
|
| 145 |
+
.attr("fill", "url(#vb12-page-gradient)");
|
| 146 |
+
|
| 147 |
+
svg.append("path")
|
| 148 |
+
.attr("class", "top-background-wave")
|
| 149 |
+
.attr("d", `M${width * 0.48},0 C${width * 0.62},${height * 0.04} ${width * 0.72},${height * 0.02} ${width},${height * 0.09} L${width},0 Z`)
|
| 150 |
+
.attr("fill", "#dff0fb");
|
| 151 |
+
|
| 152 |
+
svg.append("path")
|
| 153 |
+
.attr("class", "bottom-wave wave-back")
|
| 154 |
+
.attr("d", `M0 ${height - 62} C${width * 0.18} ${height - 90},${width * 0.36} ${height - 42},${width * 0.52} ${height - 68} S${width * 0.82} ${height - 96},${width} ${height - 56} L${width} ${height} L0 ${height} Z`)
|
| 155 |
+
.attr("fill", "#dceffd");
|
| 156 |
+
svg.append("path")
|
| 157 |
+
.attr("class", "bottom-wave wave-front")
|
| 158 |
+
.attr("d", `M0 ${height - 36} C${width * 0.18} ${height - 58},${width * 0.40} ${height - 12},${width * 0.60} ${height - 38} S${width * 0.83} ${height - 68},${width} ${height - 28} L${width} ${height} L0 ${height} Z`)
|
| 159 |
+
.attr("fill", "#55ace5");
|
| 160 |
+
svg.append("path")
|
| 161 |
+
.attr("class", "bottom-wave wave-deep")
|
| 162 |
+
.attr("d", `M0 ${height - 18} C${width * 0.24} ${height - 40},${width * 0.44} ${height + 8},${width * 0.70} ${height - 22} C${width * 0.84} ${height - 38},${width * 0.92} ${height - 22},${width} ${height - 30} L${width} ${height} L0 ${height} Z`)
|
| 163 |
+
.attr("fill", "#1594d2");
|
| 164 |
+
|
| 165 |
+
function message(text) {
|
| 166 |
+
svg.append("text")
|
| 167 |
+
.attr("x", width / 2)
|
| 168 |
+
.attr("y", height / 2)
|
| 169 |
+
.attr("text-anchor", "middle")
|
| 170 |
+
.attr("fill", darkText)
|
| 171 |
+
.style("font-family", fontFamily)
|
| 172 |
+
.style("font-size", "16px")
|
| 173 |
+
.text(text);
|
| 174 |
+
return svg.node();
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
if (!xField || !yField || !y2Field) return message("Missing vertical bar fields");
|
| 178 |
+
|
| 179 |
+
function readableLabel(value) {
|
| 180 |
+
return String(value ?? "")
|
| 181 |
+
.replace(/_/g, " ")
|
| 182 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 183 |
+
.replace(/\s+/g, " ")
|
| 184 |
+
.trim();
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
function parseTemporalRank(value) {
|
| 188 |
+
const text = String(value ?? "").trim();
|
| 189 |
+
const lower = text.toLowerCase();
|
| 190 |
+
const months = {
|
| 191 |
+
jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3, apr: 4, april: 4,
|
| 192 |
+
may: 5, jun: 6, june: 6, jul: 7, july: 7, aug: 8, august: 8,
|
| 193 |
+
sep: 9, sept: 9, september: 9, oct: 10, october: 10, nov: 11, november: 11, dec: 12, december: 12
|
| 194 |
+
};
|
| 195 |
+
const seasons = { winter: 1, spring: 2, summer: 3, fall: 4, autumn: 4 };
|
| 196 |
+
if (months[lower] != null) return months[lower];
|
| 197 |
+
let match = text.match(/^(-?\d{4})$/);
|
| 198 |
+
if (match) return Number(match[1]) * 10000;
|
| 199 |
+
match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
|
| 200 |
+
if (match) return Number(match[1]) * 10000 + Number(match[2]) * 100 + Number(match[3] || 1);
|
| 201 |
+
match = text.match(/^Q([1-4])\s+(-?\d{4})$/i) || text.match(/^(-?\d{4})\s+Q([1-4])$/i);
|
| 202 |
+
if (match && text.toUpperCase().startsWith("Q")) return Number(match[2]) * 10 + Number(match[1]);
|
| 203 |
+
if (match) return Number(match[1]) * 10 + Number(match[2]);
|
| 204 |
+
match = text.match(/^(winter|spring|summer|fall|autumn)\s+(-?\d{4})$/i);
|
| 205 |
+
if (match) return Number(match[2]) * 10 + seasons[match[1].toLowerCase()];
|
| 206 |
+
match = text.match(/^(-?\d{4})\s+(winter|spring|summer|fall|autumn)$/i);
|
| 207 |
+
if (match) return Number(match[1]) * 10 + seasons[match[2].toLowerCase()];
|
| 208 |
+
return NaN;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
function parseOrdinalRank(value) {
|
| 212 |
+
const text = String(value ?? "").trim().toLowerCase();
|
| 213 |
+
const named = {
|
| 214 |
+
primary: 1, elementary: 1, secondary: 2, college: 3, university: 3,
|
| 215 |
+
low: 1, lower: 1, medium: 2, moderate: 2, high: 3, higher: 3,
|
| 216 |
+
children: 1, adolescents: 2, teens: 2, adults: 3, seniors: 4
|
| 217 |
+
};
|
| 218 |
+
if (named[text] != null) return named[text];
|
| 219 |
+
let match = text.match(/(?:less than|under)\s*[$]?\s*([\d,]+)/);
|
| 220 |
+
if (match) return Number(match[1].replace(/,/g, "")) - 0.5;
|
| 221 |
+
match = text.match(/(?:more than|over)\s*[$]?\s*([\d,]+)/);
|
| 222 |
+
if (match) return Number(match[1].replace(/,/g, "")) + 0.5;
|
| 223 |
+
match = text.match(/^[$]?\s*([\d,]+(?:\.\d+)?)\s*(?:-|to)/);
|
| 224 |
+
if (match) return Number(match[1].replace(/,/g, ""));
|
| 225 |
+
match = text.match(/^(-?\d+(?:\.\d+)?)/);
|
| 226 |
+
if (match) return Number(match[1]);
|
| 227 |
+
return NaN;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
function compactNumber(value) {
|
| 231 |
+
const numeric = Number(value);
|
| 232 |
+
const abs = Math.abs(numeric);
|
| 233 |
+
if (abs >= 1000000000) return `${d3.format(".3~g")(numeric / 1000000000)}B`;
|
| 234 |
+
if (abs >= 1000000) return `${d3.format(".3~g")(numeric / 1000000)}M`;
|
| 235 |
+
if (abs >= 1000) return `${d3.format(".3~g")(numeric / 1000)}K`;
|
| 236 |
+
return d3.format(",.4~g")(numeric);
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
function formatLocalValue(value, unit = "") {
|
| 240 |
+
const numeric = Number(value);
|
| 241 |
+
const formatted = compactNumber(numeric);
|
| 242 |
+
if (!unit) return formatted;
|
| 243 |
+
if (unit === "%") return `${formatted}%`;
|
| 244 |
+
if (unit.startsWith("$")) {
|
| 245 |
+
const sign = numeric < 0 ? "-" : "";
|
| 246 |
+
return `${sign}$${compactNumber(Math.abs(numeric))}${unit.slice(1)}`;
|
| 247 |
+
}
|
| 248 |
+
if (unit.length <= 3 && /^[^A-Za-z0-9]+$/.test(unit)) return `${unit}${formatted}`;
|
| 249 |
+
return `${formatted} ${unit}`;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
function fitText(value, maxChars) {
|
| 253 |
+
const text = readableLabel(value);
|
| 254 |
+
if (text.length <= maxChars) return text;
|
| 255 |
+
return text.slice(0, Math.max(1, maxChars)).trim();
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
function wrapLines(value, maxChars, maxLines = 2) {
|
| 259 |
+
const words = readableLabel(value).split(/\s+/).filter(Boolean);
|
| 260 |
+
if (!words.length) return [""];
|
| 261 |
+
const lines = [];
|
| 262 |
+
let current = "";
|
| 263 |
+
words.forEach(word => {
|
| 264 |
+
const shortWord = word.length > maxChars ? word.slice(0, maxChars) : word;
|
| 265 |
+
const next = current ? `${current} ${shortWord}` : shortWord;
|
| 266 |
+
if (next.length <= maxChars) {
|
| 267 |
+
current = next;
|
| 268 |
+
} else {
|
| 269 |
+
if (current) lines.push(current);
|
| 270 |
+
current = shortWord;
|
| 271 |
+
}
|
| 272 |
+
});
|
| 273 |
+
if (current) lines.push(current);
|
| 274 |
+
if (lines.length <= maxLines) return lines;
|
| 275 |
+
return lines.slice(0, maxLines).map(line => fitText(line, maxChars));
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
const grouped = new Map();
|
| 279 |
+
sourceRows.forEach((row, index) => {
|
| 280 |
+
const category = readableLabel(row[xField]);
|
| 281 |
+
const yValue = Number(row[yField]);
|
| 282 |
+
const y2Value = Number(row[y2Field]);
|
| 283 |
+
if (!category || !Number.isFinite(yValue) || !Number.isFinite(y2Value)) return;
|
| 284 |
+
if (!grouped.has(category)) {
|
| 285 |
+
grouped.set(category, {
|
| 286 |
+
category,
|
| 287 |
+
rawCategory: row[xField],
|
| 288 |
+
firstIndex: index,
|
| 289 |
+
y: 0,
|
| 290 |
+
y2: 0
|
| 291 |
+
});
|
| 292 |
+
}
|
| 293 |
+
const entry = grouped.get(category);
|
| 294 |
+
entry.y += yValue;
|
| 295 |
+
entry.y2 += Math.max(0, y2Value);
|
| 296 |
+
});
|
| 297 |
+
|
| 298 |
+
const rows = Array.from(grouped.values()).map((row, index) => ({
|
| 299 |
+
...row,
|
| 300 |
+
originalIndex: index,
|
| 301 |
+
temporalRank: parseTemporalRank(row.rawCategory),
|
| 302 |
+
ordinalRank: parseOrdinalRank(row.rawCategory)
|
| 303 |
+
}));
|
| 304 |
+
if (!rows.length) return message("No valid data for vertical bars");
|
| 305 |
+
|
| 306 |
+
const allTemporal = xColumn.data_type === "temporal" || rows.every(d => Number.isFinite(d.temporalRank));
|
| 307 |
+
const allOrdinal = !allTemporal && rows.every(d => Number.isFinite(d.ordinalRank));
|
| 308 |
+
const sortMode = allTemporal ? "chronological" : (allOrdinal ? "ordered category" : "largest primary value");
|
| 309 |
+
rows.sort((a, b) => {
|
| 310 |
+
if (allTemporal) return d3.ascending(a.temporalRank, b.temporalRank) || d3.ascending(a.firstIndex, b.firstIndex);
|
| 311 |
+
if (allOrdinal) return d3.ascending(a.ordinalRank, b.ordinalRank) || d3.ascending(a.firstIndex, b.firstIndex);
|
| 312 |
+
return d3.descending(a.y, b.y) || d3.ascending(a.category, b.category);
|
| 313 |
+
});
|
| 314 |
+
|
| 315 |
+
const yLabel = readableLabel(yColumn.label || yField);
|
| 316 |
+
const y2Label = readableLabel(y2Column.label || y2Field);
|
| 317 |
+
const xLabel = readableLabel(xColumn.label || xField);
|
| 318 |
+
const titleLines = wrapLines(`${yLabel} and ${y2Label}`, 16, 5);
|
| 319 |
+
const byLine = fitText(`by ${xLabel}`, 24);
|
| 320 |
+
const subtitle = `${fitText(yLabel, 36)} compared with ${fitText(y2Label, 34)} across ${fitText(xLabel, 24).toLowerCase()} categories.`;
|
| 321 |
+
|
| 322 |
+
const headerX = 42;
|
| 323 |
+
const headerTop = 42;
|
| 324 |
+
const titleSize = Math.max(22, Math.min(25, width * 0.042));
|
| 325 |
+
const titleLeading = titleSize * 0.93;
|
| 326 |
+
const byLineY = titleLines.length * titleLeading + 8;
|
| 327 |
+
const accentY = byLineY + 31;
|
| 328 |
+
const subtitleY = accentY + 29;
|
| 329 |
+
const referenceHeader = svg.append("g")
|
| 330 |
+
.attr("class", "reference-header title-text-block")
|
| 331 |
+
.attr("transform", `translate(${headerX}, ${headerTop})`);
|
| 332 |
+
|
| 333 |
+
titleLines.forEach((line, index) => {
|
| 334 |
+
referenceHeader.append("text")
|
| 335 |
+
.attr("class", `reference-title-line reference-title-line-${index + 1}`)
|
| 336 |
+
.attr("x", 0)
|
| 337 |
+
.attr("y", index * titleLeading)
|
| 338 |
+
.attr("fill", navy)
|
| 339 |
+
.style("font-family", titleFamily)
|
| 340 |
+
.style("font-size", `${titleSize}px`)
|
| 341 |
+
.style("font-weight", "900")
|
| 342 |
+
.style("letter-spacing", "0")
|
| 343 |
+
.text(line);
|
| 344 |
+
});
|
| 345 |
+
|
| 346 |
+
referenceHeader.append("text")
|
| 347 |
+
.attr("class", "reference-byline")
|
| 348 |
+
.attr("x", 0)
|
| 349 |
+
.attr("y", byLineY)
|
| 350 |
+
.attr("fill", blue)
|
| 351 |
+
.style("font-family", titleFamily)
|
| 352 |
+
.style("font-size", `${Math.max(18, titleSize * 0.60)}px`)
|
| 353 |
+
.style("font-weight", "900")
|
| 354 |
+
.style("letter-spacing", "0")
|
| 355 |
+
.text(byLine);
|
| 356 |
+
|
| 357 |
+
referenceHeader.append("line")
|
| 358 |
+
.attr("class", "title-accent-rule")
|
| 359 |
+
.attr("x1", 0)
|
| 360 |
+
.attr("x2", 58)
|
| 361 |
+
.attr("y1", accentY)
|
| 362 |
+
.attr("y2", accentY)
|
| 363 |
+
.attr("stroke", "#0e9f9a")
|
| 364 |
+
.attr("stroke-width", 4)
|
| 365 |
+
.attr("stroke-linecap", "round");
|
| 366 |
+
|
| 367 |
+
wrapLines(subtitle, 42, 2).forEach((line, index) => {
|
| 368 |
+
referenceHeader.append("text")
|
| 369 |
+
.attr("class", "reference-subtitle-line")
|
| 370 |
+
.attr("x", 0)
|
| 371 |
+
.attr("y", subtitleY + index * 20)
|
| 372 |
+
.attr("fill", "#374151")
|
| 373 |
+
.style("font-family", annotationFamily)
|
| 374 |
+
.style("font-size", "15px")
|
| 375 |
+
.style("font-weight", "500")
|
| 376 |
+
.text(line);
|
| 377 |
+
});
|
| 378 |
+
|
| 379 |
+
const decor = svg.append("g")
|
| 380 |
+
.attr("class", "top-decorative-image-slot swimmer-wave-slot")
|
| 381 |
+
.attr("transform", `translate(${width * 0.48}, 34)`);
|
| 382 |
+
const decorW = width * 0.46;
|
| 383 |
+
decor.append("path")
|
| 384 |
+
.attr("class", "decorative-wave-ribbon")
|
| 385 |
+
.attr("d", `M0 110 C${decorW * 0.25} 88,${decorW * 0.36} 132,${decorW * 0.60} 92 C${decorW * 0.75} 68,${decorW * 0.90} 86,${decorW} 62 L${decorW} 118 C${decorW * 0.73} 139,${decorW * 0.42} 141,0 126 Z`)
|
| 386 |
+
.attr("fill", "#bfe6ff")
|
| 387 |
+
.attr("opacity", 0.86);
|
| 388 |
+
decor.append("path")
|
| 389 |
+
.attr("class", "decorative-wave-front")
|
| 390 |
+
.attr("d", `M${decorW * 0.06} 122 C${decorW * 0.25} 92,${decorW * 0.38} 124,${decorW * 0.55} 98 C${decorW * 0.76} 66,${decorW * 0.82} 94,${decorW * 0.98} 78`)
|
| 391 |
+
.attr("fill", "none")
|
| 392 |
+
.attr("stroke", "#178ed8")
|
| 393 |
+
.attr("stroke-width", 9)
|
| 394 |
+
.attr("stroke-linecap", "round")
|
| 395 |
+
.attr("opacity", 0.72);
|
| 396 |
+
decor.append("path")
|
| 397 |
+
.attr("class", "decorative-swimmer-arm")
|
| 398 |
+
.attr("d", `M${decorW * 0.28} 72 C${decorW * 0.41} 56,${decorW * 0.54} 42,${decorW * 0.64} 48 C${decorW * 0.70} 54,${decorW * 0.72} 68,${decorW * 0.76} 78`)
|
| 399 |
+
.attr("fill", "none")
|
| 400 |
+
.attr("stroke", "#f4a072")
|
| 401 |
+
.attr("stroke-width", 18)
|
| 402 |
+
.attr("stroke-linecap", "round");
|
| 403 |
+
decor.append("circle")
|
| 404 |
+
.attr("class", "decorative-swimmer-head")
|
| 405 |
+
.attr("cx", decorW * 0.84)
|
| 406 |
+
.attr("cy", 66)
|
| 407 |
+
.attr("r", 19)
|
| 408 |
+
.attr("fill", "#f5b18a");
|
| 409 |
+
decor.append("path")
|
| 410 |
+
.attr("class", "decorative-swimmer-cap")
|
| 411 |
+
.attr("d", `M${decorW * 0.80} 56 Q${decorW * 0.84} 37 ${decorW * 0.90} 55 L${decorW * 0.89} 73 Q${decorW * 0.84} 62 ${decorW * 0.80} 56 Z`)
|
| 412 |
+
.attr("fill", "#e91d36");
|
| 413 |
+
decor.append("circle")
|
| 414 |
+
.attr("class", "decorative-swimmer-goggle")
|
| 415 |
+
.attr("cx", decorW * 0.855)
|
| 416 |
+
.attr("cy", 64)
|
| 417 |
+
.attr("r", 5)
|
| 418 |
+
.attr("fill", navy);
|
| 419 |
+
|
| 420 |
+
const legendY = Math.max(214, headerTop + subtitleY + 64);
|
| 421 |
+
const legend = svg.append("g")
|
| 422 |
+
.attr("class", "legend-card reference-legend")
|
| 423 |
+
.attr("transform", `translate(${headerX}, ${legendY})`);
|
| 424 |
+
legend.append("rect")
|
| 425 |
+
.attr("x", 0)
|
| 426 |
+
.attr("y", 0)
|
| 427 |
+
.attr("width", Math.min(width - 84, 308))
|
| 428 |
+
.attr("height", 46)
|
| 429 |
+
.attr("rx", 10)
|
| 430 |
+
.attr("fill", "rgba(255,255,255,0.92)")
|
| 431 |
+
.attr("stroke", "rgba(148,163,184,0.36)");
|
| 432 |
+
|
| 433 |
+
const legendItems = [
|
| 434 |
+
{ label: yLabel, color: red, shape: "rect", className: "legend-primary" },
|
| 435 |
+
{ label: y2Label, color: navy, shape: "circle", className: "legend-secondary" }
|
| 436 |
+
];
|
| 437 |
+
legendItems.forEach((item, index) => {
|
| 438 |
+
const itemX = index ? Math.min(176, width * 0.30) : 24;
|
| 439 |
+
const itemG = legend.append("g")
|
| 440 |
+
.attr("class", `legend-item ${item.className}`)
|
| 441 |
+
.attr("transform", `translate(${itemX}, 23)`);
|
| 442 |
+
if (item.shape === "rect") {
|
| 443 |
+
itemG.append("rect")
|
| 444 |
+
.attr("x", -8)
|
| 445 |
+
.attr("y", -8)
|
| 446 |
+
.attr("width", 16)
|
| 447 |
+
.attr("height", 16)
|
| 448 |
+
.attr("rx", 1.5)
|
| 449 |
+
.attr("fill", item.color);
|
| 450 |
+
} else {
|
| 451 |
+
itemG.append("circle")
|
| 452 |
+
.attr("cx", 0)
|
| 453 |
+
.attr("cy", 0)
|
| 454 |
+
.attr("r", 8)
|
| 455 |
+
.attr("fill", item.color);
|
| 456 |
+
}
|
| 457 |
+
itemG.append("text")
|
| 458 |
+
.attr("x", 18)
|
| 459 |
+
.attr("y", 0)
|
| 460 |
+
.attr("dominant-baseline", "middle")
|
| 461 |
+
.attr("fill", darkText)
|
| 462 |
+
.style("font-family", annotationFamily)
|
| 463 |
+
.style("font-size", "13px")
|
| 464 |
+
.style("font-weight", "700")
|
| 465 |
+
.text(fitText(item.label + (index === 0 && yUnit ? ` (${yUnit})` : index === 1 && y2Unit ? ` (${y2Unit})` : ""), 19));
|
| 466 |
+
});
|
| 467 |
+
|
| 468 |
+
const chartX = Math.max(58, width * 0.10);
|
| 469 |
+
const chartY = legendY + 84;
|
| 470 |
+
const chartW = width - chartX * 2;
|
| 471 |
+
const footerH = 72;
|
| 472 |
+
const footerY = height - footerH - 45;
|
| 473 |
+
const chartH = Math.max(300, footerY - chartY - 34);
|
| 474 |
+
const plotTop = chartY + 142;
|
| 475 |
+
const plotBottom = chartY + chartH - 74;
|
| 476 |
+
const circleY = chartY + chartH - 34;
|
| 477 |
+
const xScale = d3.scaleBand()
|
| 478 |
+
.domain(rows.map(d => d.category))
|
| 479 |
+
.range([0, chartW])
|
| 480 |
+
.padding(rows.length > 8 ? 0.13 : 0.17);
|
| 481 |
+
|
| 482 |
+
const minY = d3.min(rows, d => d.y) || 0;
|
| 483 |
+
const maxY = d3.max(rows, d => d.y) || 0;
|
| 484 |
+
const span = Math.max(1, maxY - minY);
|
| 485 |
+
const yDomain = [
|
| 486 |
+
Math.min(0, minY - span * 0.12),
|
| 487 |
+
Math.max(0, maxY + span * 0.12)
|
| 488 |
+
];
|
| 489 |
+
const yScale = d3.scaleLinear()
|
| 490 |
+
.domain(yDomain)
|
| 491 |
+
.nice(5)
|
| 492 |
+
.range([plotBottom, plotTop]);
|
| 493 |
+
const zeroY = yScale(0);
|
| 494 |
+
const maxY2 = d3.max(rows, d => d.y2) || 1;
|
| 495 |
+
const circleScale = d3.scaleSqrt()
|
| 496 |
+
.domain([0, maxY2])
|
| 497 |
+
.range([Math.max(5, Math.min(8, xScale.bandwidth() * 0.18)), Math.max(13, Math.min(22, xScale.bandwidth() * 0.32))]);
|
| 498 |
+
const colorFor = (row, index) => sourceColors.field?.[row.rawCategory]
|
| 499 |
+
|| sourceColors.field?.[row.category]
|
| 500 |
+
|| palette[index % palette.length]
|
| 501 |
+
|| red;
|
| 502 |
+
const softFillFor = (color, index) => {
|
| 503 |
+
const fallback = ["#f8e9ed", "#e7f6f6", "#eaf8ef", "#edf7e9", "#f0ecfa", "#fff4cf", "#e8f1ff", "#fff0df"];
|
| 504 |
+
return d3.color(color)?.copy({ opacity: 0.10 })?.formatRgb() || fallback[index % fallback.length];
|
| 505 |
+
};
|
| 506 |
+
|
| 507 |
+
const chart = svg.append("g")
|
| 508 |
+
.attr("class", "reference-chart-block vertical-bar-plot")
|
| 509 |
+
.attr("transform", `translate(${chartX}, 0)`);
|
| 510 |
+
|
| 511 |
+
chart.append("rect")
|
| 512 |
+
.attr("class", "plot-background reference-plot-panel")
|
| 513 |
+
.attr("x", -10)
|
| 514 |
+
.attr("y", chartY - 8)
|
| 515 |
+
.attr("width", chartW + 20)
|
| 516 |
+
.attr("height", chartH + 24)
|
| 517 |
+
.attr("rx", 10)
|
| 518 |
+
.attr("fill", "rgba(255,255,255,0.68)")
|
| 519 |
+
.attr("stroke", "rgba(210,225,238,0.62)");
|
| 520 |
+
|
| 521 |
+
let yTicks = yScale.ticks(5);
|
| 522 |
+
if (!yTicks.some(tick => Math.abs(tick) < 1e-9)) yTicks.push(0);
|
| 523 |
+
yTicks = Array.from(new Set(yTicks.map(tick => Number(tick.toPrecision(12))))).sort((a, b) => a - b);
|
| 524 |
+
|
| 525 |
+
yTicks.forEach(tick => {
|
| 526 |
+
const y = yScale(tick);
|
| 527 |
+
chart.append("line")
|
| 528 |
+
.attr("class", "gridline y-axis-gridline")
|
| 529 |
+
.attr("x1", 0)
|
| 530 |
+
.attr("x2", chartW)
|
| 531 |
+
.attr("y1", y)
|
| 532 |
+
.attr("y2", y)
|
| 533 |
+
.attr("stroke", Math.abs(tick) < 1e-9 ? "rgba(148,163,184,0.58)" : "rgba(148,163,184,0.26)")
|
| 534 |
+
.attr("stroke-width", Math.abs(tick) < 1e-9 ? 1.2 : 0.8)
|
| 535 |
+
.attr("stroke-dasharray", Math.abs(tick) < 1e-9 ? "4 3" : "none");
|
| 536 |
+
chart.append("text")
|
| 537 |
+
.attr("class", "y-axis-tick primary-axis-tick")
|
| 538 |
+
.attr("x", -12)
|
| 539 |
+
.attr("y", y)
|
| 540 |
+
.attr("text-anchor", "end")
|
| 541 |
+
.attr("dominant-baseline", "middle")
|
| 542 |
+
.attr("fill", "#3f4755")
|
| 543 |
+
.style("font-family", annotationFamily)
|
| 544 |
+
.style("font-size", `${Math.max(8, valueSize - 0.5)}px`)
|
| 545 |
+
.style("font-weight", "650")
|
| 546 |
+
.text(formatLocalValue(tick, yUnit));
|
| 547 |
+
});
|
| 548 |
+
|
| 549 |
+
const y2Scale = d3.scaleLinear().domain([0, maxY2]).nice(4).range([plotBottom, plotTop]);
|
| 550 |
+
y2Scale.ticks(4).forEach(tick => {
|
| 551 |
+
chart.append("text")
|
| 552 |
+
.attr("class", "secondary-axis-tick")
|
| 553 |
+
.attr("x", chartW + 12)
|
| 554 |
+
.attr("y", y2Scale(tick))
|
| 555 |
+
.attr("dominant-baseline", "middle")
|
| 556 |
+
.attr("fill", "#075ca5")
|
| 557 |
+
.style("font-family", annotationFamily)
|
| 558 |
+
.style("font-size", `${Math.max(8, valueSize - 0.5)}px`)
|
| 559 |
+
.style("font-weight", "650")
|
| 560 |
+
.text(formatLocalValue(tick, y2Unit));
|
| 561 |
+
});
|
| 562 |
+
|
| 563 |
+
chart.append("text")
|
| 564 |
+
.attr("class", "axis-title y-axis-title")
|
| 565 |
+
.attr("x", -(plotTop + plotBottom) / 2)
|
| 566 |
+
.attr("y", -76)
|
| 567 |
+
.attr("transform", "rotate(-90)")
|
| 568 |
+
.attr("text-anchor", "middle")
|
| 569 |
+
.attr("fill", red)
|
| 570 |
+
.style("font-family", annotationFamily)
|
| 571 |
+
.style("font-size", `${Math.max(10, valueSize)}px`)
|
| 572 |
+
.style("font-weight", "800")
|
| 573 |
+
.text(fitText(yLabel + (yUnit ? ` (${yUnit})` : ""), 34));
|
| 574 |
+
|
| 575 |
+
chart.append("text")
|
| 576 |
+
.attr("class", "axis-title secondary-axis-title")
|
| 577 |
+
.attr("x", (plotTop + plotBottom) / 2)
|
| 578 |
+
.attr("y", -(chartW + 64))
|
| 579 |
+
.attr("transform", "rotate(90)")
|
| 580 |
+
.attr("text-anchor", "middle")
|
| 581 |
+
.attr("fill", "#075ca5")
|
| 582 |
+
.style("font-family", annotationFamily)
|
| 583 |
+
.style("font-size", `${Math.max(10, valueSize)}px`)
|
| 584 |
+
.style("font-weight", "800")
|
| 585 |
+
.text(fitText(y2Label + (y2Unit ? ` (${y2Unit})` : ""), 34));
|
| 586 |
+
|
| 587 |
+
rows.forEach((row, index) => {
|
| 588 |
+
const x = xScale(row.category);
|
| 589 |
+
const bandWidth = xScale.bandwidth();
|
| 590 |
+
const centerX = x + bandWidth / 2;
|
| 591 |
+
const color = colorFor(row, index);
|
| 592 |
+
const panelWidth = Math.max(32, bandWidth * 0.94);
|
| 593 |
+
const panelX = centerX - panelWidth / 2;
|
| 594 |
+
const panelY = chartY + 12;
|
| 595 |
+
const panelH = chartH - 2;
|
| 596 |
+
const iconR = Math.max(14, Math.min(24, bandWidth * 0.27));
|
| 597 |
+
const barWidth = Math.max(13, Math.min(40, bandWidth * 0.46));
|
| 598 |
+
const yValue = row.y;
|
| 599 |
+
const yTop = yScale(Math.max(0, yValue));
|
| 600 |
+
const yBottom = yScale(Math.min(0, yValue));
|
| 601 |
+
const barHeight = Math.max(1, Math.abs(yBottom - yTop));
|
| 602 |
+
const barY = yValue >= 0 ? yTop : zeroY;
|
| 603 |
+
const labelY = yValue >= 0 ? Math.max(plotTop - 8, barY - 13) : Math.min(plotBottom + 17, barY + barHeight + 13);
|
| 604 |
+
const radius = circleScale(row.y2);
|
| 605 |
+
|
| 606 |
+
chart.append("rect")
|
| 607 |
+
.attr("class", "category-column-panel reference-column-card")
|
| 608 |
+
.attr("data-source-index", row.firstIndex)
|
| 609 |
+
.attr("data-category", row.category)
|
| 610 |
+
.attr("data-y", row.y)
|
| 611 |
+
.attr("data-y2", row.y2)
|
| 612 |
+
.attr("x", panelX)
|
| 613 |
+
.attr("y", panelY)
|
| 614 |
+
.attr("width", panelWidth)
|
| 615 |
+
.attr("height", panelH)
|
| 616 |
+
.attr("rx", 13)
|
| 617 |
+
.attr("fill", softFillFor(color, index))
|
| 618 |
+
.attr("stroke", "rgba(255,255,255,0.88)")
|
| 619 |
+
.attr("filter", rows.length <= 7 ? "url(#vb12-card-shadow)" : null);
|
| 620 |
+
|
| 621 |
+
const icon = chart.append("g")
|
| 622 |
+
.attr("class", "category-icon-slot")
|
| 623 |
+
.attr("data-source-index", row.firstIndex)
|
| 624 |
+
.attr("data-category", row.category)
|
| 625 |
+
.attr("transform", `translate(${centerX}, ${chartY + 50})`);
|
| 626 |
+
icon.append("circle")
|
| 627 |
+
.attr("r", iconR)
|
| 628 |
+
.attr("fill", "rgba(255,255,255,0.86)")
|
| 629 |
+
.attr("stroke", "rgba(226,232,240,0.72)");
|
| 630 |
+
icon.append("path")
|
| 631 |
+
.attr("d", index % 3 === 0
|
| 632 |
+
? `M${-iconR * 0.55},${iconR * 0.16} C${-iconR * 0.18},${-iconR * 0.36} ${iconR * 0.18},${iconR * 0.48} ${iconR * 0.55},${-iconR * 0.14}`
|
| 633 |
+
: index % 3 === 1
|
| 634 |
+
? `M${-iconR * 0.52},${iconR * 0.24} L${-iconR * 0.10},${-iconR * 0.38} L${iconR * 0.50},${iconR * 0.16}`
|
| 635 |
+
: `M${-iconR * 0.48},0 L${iconR * 0.48},0 M0,${-iconR * 0.48} L0,${iconR * 0.48}`)
|
| 636 |
+
.attr("fill", "none")
|
| 637 |
+
.attr("stroke", color)
|
| 638 |
+
.attr("stroke-width", Math.max(2.2, iconR * 0.18))
|
| 639 |
+
.attr("stroke-linecap", "round")
|
| 640 |
+
.attr("stroke-linejoin", "round");
|
| 641 |
+
|
| 642 |
+
const categoryText = chart.append("text")
|
| 643 |
+
.attr("class", "x-axis-tick category-label")
|
| 644 |
+
.attr("data-source-index", row.firstIndex)
|
| 645 |
+
.attr("data-category", row.category)
|
| 646 |
+
.attr("x", centerX)
|
| 647 |
+
.attr("y", chartY + 94)
|
| 648 |
+
.attr("text-anchor", "middle")
|
| 649 |
+
.attr("fill", navy)
|
| 650 |
+
.style("font-family", fontFamily)
|
| 651 |
+
.style("font-size", `${Math.max(8, Math.min(labelSize + 0.5, bandWidth * 0.17))}px`)
|
| 652 |
+
.style("font-weight", "900");
|
| 653 |
+
const categoryChars = Math.max(5, Math.floor(panelWidth / (labelSize * 0.48)));
|
| 654 |
+
wrapLines(row.category.toUpperCase(), categoryChars, 2).forEach((line, lineIndex) => {
|
| 655 |
+
categoryText.append("tspan")
|
| 656 |
+
.attr("x", centerX)
|
| 657 |
+
.attr("dy", lineIndex === 0 ? 0 : labelSize + 1)
|
| 658 |
+
.text(line);
|
| 659 |
+
});
|
| 660 |
+
|
| 661 |
+
chart.append("rect")
|
| 662 |
+
.attr("class", "bar-track")
|
| 663 |
+
.attr("data-source-index", row.firstIndex)
|
| 664 |
+
.attr("data-category", row.category)
|
| 665 |
+
.attr("x", centerX - barWidth / 2)
|
| 666 |
+
.attr("y", plotTop)
|
| 667 |
+
.attr("width", barWidth)
|
| 668 |
+
.attr("height", plotBottom - plotTop)
|
| 669 |
+
.attr("rx", Math.min(12, barWidth / 2))
|
| 670 |
+
.attr("fill", "rgba(255,255,255,0.62)");
|
| 671 |
+
|
| 672 |
+
chart.append("rect")
|
| 673 |
+
.attr("class", "vertical-bar bar-mark data-point mark")
|
| 674 |
+
.attr("data-source-index", row.firstIndex)
|
| 675 |
+
.attr("data-category", row.category)
|
| 676 |
+
.attr("data-y", row.y)
|
| 677 |
+
.attr("data-y2", row.y2)
|
| 678 |
+
.attr("x", centerX - barWidth / 2)
|
| 679 |
+
.attr("y", barY)
|
| 680 |
+
.attr("width", barWidth)
|
| 681 |
+
.attr("height", barHeight)
|
| 682 |
+
.attr("rx", variables.has_rounded_corners === false ? 4 : Math.min(12, barWidth / 2))
|
| 683 |
+
.attr("fill", color);
|
| 684 |
+
|
| 685 |
+
chart.append("rect")
|
| 686 |
+
.attr("class", "bar-highlight")
|
| 687 |
+
.attr("x", centerX - barWidth / 2)
|
| 688 |
+
.attr("y", barY)
|
| 689 |
+
.attr("width", barWidth)
|
| 690 |
+
.attr("height", barHeight)
|
| 691 |
+
.attr("rx", variables.has_rounded_corners === false ? 4 : Math.min(12, barWidth / 2))
|
| 692 |
+
.attr("fill", "url(#vb12-bar-highlight)")
|
| 693 |
+
.attr("opacity", 0.45);
|
| 694 |
+
|
| 695 |
+
chart.append("text")
|
| 696 |
+
.attr("class", "bar-value-label value-label")
|
| 697 |
+
.attr("data-source-index", row.firstIndex)
|
| 698 |
+
.attr("data-category", row.category)
|
| 699 |
+
.attr("data-y", row.y)
|
| 700 |
+
.attr("x", centerX)
|
| 701 |
+
.attr("y", labelY)
|
| 702 |
+
.attr("text-anchor", "middle")
|
| 703 |
+
.attr("fill", color)
|
| 704 |
+
.style("font-family", annotationFamily)
|
| 705 |
+
.style("font-size", `${Math.max(11, valueSize + 2)}px`)
|
| 706 |
+
.style("font-weight", "900")
|
| 707 |
+
.text(formatLocalValue(row.y, yUnit));
|
| 708 |
+
|
| 709 |
+
chart.append("line")
|
| 710 |
+
.attr("class", "secondary-marker-guide")
|
| 711 |
+
.attr("data-source-index", row.firstIndex)
|
| 712 |
+
.attr("data-category", row.category)
|
| 713 |
+
.attr("x1", centerX)
|
| 714 |
+
.attr("x2", centerX)
|
| 715 |
+
.attr("y1", zeroY)
|
| 716 |
+
.attr("y2", circleY)
|
| 717 |
+
.attr("stroke", color)
|
| 718 |
+
.attr("stroke-width", 1.2)
|
| 719 |
+
.attr("stroke-dasharray", "3 3")
|
| 720 |
+
.attr("opacity", 0.62);
|
| 721 |
+
|
| 722 |
+
chart.append("circle")
|
| 723 |
+
.attr("class", "secondary-circle circle-mark")
|
| 724 |
+
.attr("data-source-index", row.firstIndex)
|
| 725 |
+
.attr("data-category", row.category)
|
| 726 |
+
.attr("data-y2", row.y2)
|
| 727 |
+
.attr("cx", centerX)
|
| 728 |
+
.attr("cy", circleY)
|
| 729 |
+
.attr("r", radius)
|
| 730 |
+
.attr("fill", color)
|
| 731 |
+
.attr("stroke", "rgba(255,255,255,0.95)")
|
| 732 |
+
.attr("stroke-width", 2.2);
|
| 733 |
+
|
| 734 |
+
chart.append("text")
|
| 735 |
+
.attr("class", "secondary-value-label")
|
| 736 |
+
.attr("data-source-index", row.firstIndex)
|
| 737 |
+
.attr("data-category", row.category)
|
| 738 |
+
.attr("data-y2", row.y2)
|
| 739 |
+
.attr("x", centerX)
|
| 740 |
+
.attr("y", circleY + Math.max(3, valueSize * 0.36))
|
| 741 |
+
.attr("text-anchor", "middle")
|
| 742 |
+
.attr("fill", "#ffffff")
|
| 743 |
+
.style("font-family", annotationFamily)
|
| 744 |
+
.style("font-size", `${Math.max(7.5, Math.min(valueSize, radius * 0.72))}px`)
|
| 745 |
+
.style("font-weight", "900")
|
| 746 |
+
.text(formatLocalValue(row.y2, ""));
|
| 747 |
+
|
| 748 |
+
if (y2Unit) {
|
| 749 |
+
chart.append("text")
|
| 750 |
+
.attr("class", "secondary-unit-label")
|
| 751 |
+
.attr("data-source-index", row.firstIndex)
|
| 752 |
+
.attr("data-category", row.category)
|
| 753 |
+
.attr("x", centerX)
|
| 754 |
+
.attr("y", circleY + radius + 17)
|
| 755 |
+
.attr("text-anchor", "middle")
|
| 756 |
+
.attr("fill", "#334155")
|
| 757 |
+
.style("font-family", annotationFamily)
|
| 758 |
+
.style("font-size", `${Math.max(7.5, valueSize - 1)}px`)
|
| 759 |
+
.text(fitText(y2Unit, 8));
|
| 760 |
+
}
|
| 761 |
+
});
|
| 762 |
+
|
| 763 |
+
const footer = svg.append("g")
|
| 764 |
+
.attr("class", "bottom-insight-card footer-source-card")
|
| 765 |
+
.attr("transform", `translate(${Math.max(34, width * 0.07)}, ${footerY})`);
|
| 766 |
+
const footerW = width - Math.max(34, width * 0.07) * 2;
|
| 767 |
+
footer.append("rect")
|
| 768 |
+
.attr("class", "footer-card-background")
|
| 769 |
+
.attr("x", 0)
|
| 770 |
+
.attr("y", 0)
|
| 771 |
+
.attr("width", footerW)
|
| 772 |
+
.attr("height", footerH)
|
| 773 |
+
.attr("rx", 11)
|
| 774 |
+
.attr("fill", "rgba(255,255,255,0.86)")
|
| 775 |
+
.attr("stroke", "#bed7ee")
|
| 776 |
+
.attr("stroke-width", 1);
|
| 777 |
+
|
| 778 |
+
const footerIcon = footer.append("g")
|
| 779 |
+
.attr("class", "footer-icon-slot")
|
| 780 |
+
.attr("transform", "translate(58, 36)");
|
| 781 |
+
footerIcon.append("path")
|
| 782 |
+
.attr("d", "M-24 18 L-24 3 L-15 3 L-15 18 M-9 18 L-9 -8 L0 -8 L0 18 M6 18 L6 -22 L18 -22 L18 18")
|
| 783 |
+
.attr("fill", "none")
|
| 784 |
+
.attr("stroke", blue)
|
| 785 |
+
.attr("stroke-width", 5)
|
| 786 |
+
.attr("stroke-linejoin", "round");
|
| 787 |
+
footerIcon.append("path")
|
| 788 |
+
.attr("d", "M-26 -4 L-9 -16 L0 -8 L21 -31")
|
| 789 |
+
.attr("fill", "none")
|
| 790 |
+
.attr("stroke", "#0f8fd0")
|
| 791 |
+
.attr("stroke-width", 5)
|
| 792 |
+
.attr("stroke-linecap", "round")
|
| 793 |
+
.attr("stroke-linejoin", "round");
|
| 794 |
+
footerIcon.append("path")
|
| 795 |
+
.attr("d", "M21 -31 L20 -15 M21 -31 L6 -28")
|
| 796 |
+
.attr("fill", "none")
|
| 797 |
+
.attr("stroke", "#0f8fd0")
|
| 798 |
+
.attr("stroke-width", 5)
|
| 799 |
+
.attr("stroke-linecap", "round");
|
| 800 |
+
|
| 801 |
+
footer.append("line")
|
| 802 |
+
.attr("class", "footer-divider")
|
| 803 |
+
.attr("x1", 102)
|
| 804 |
+
.attr("x2", 102)
|
| 805 |
+
.attr("y1", 14)
|
| 806 |
+
.attr("y2", footerH - 14)
|
| 807 |
+
.attr("stroke", "#bfd3e8")
|
| 808 |
+
.attr("stroke-width", 1.4);
|
| 809 |
+
|
| 810 |
+
const highY = rows.reduce((best, row) => row.y > best.y ? row : best, rows[0]);
|
| 811 |
+
const highY2 = rows.reduce((best, row) => row.y2 > best.y2 ? row : best, rows[0]);
|
| 812 |
+
const insightText = `${fitText(highY.category, 20)} leads ${fitText(yLabel, 24)}; ${fitText(highY2.category, 20)} leads ${fitText(y2Label, 24)}.`;
|
| 813 |
+
const insightLines = wrapLines(insightText, Math.max(34, Math.floor((footerW - 190) / 7)), 2);
|
| 814 |
+
insightLines.forEach((line, index) => {
|
| 815 |
+
footer.append("text")
|
| 816 |
+
.attr("class", "footer-insight-title")
|
| 817 |
+
.attr("x", 122)
|
| 818 |
+
.attr("y", 25 + index * 15)
|
| 819 |
+
.attr("fill", navy)
|
| 820 |
+
.style("font-family", annotationFamily)
|
| 821 |
+
.style("font-size", "13.5px")
|
| 822 |
+
.style("font-weight", "900")
|
| 823 |
+
.text(line);
|
| 824 |
+
});
|
| 825 |
+
footer.append("text")
|
| 826 |
+
.attr("class", "source-row source-text")
|
| 827 |
+
.attr("x", 122)
|
| 828 |
+
.attr("y", insightLines.length > 1 ? 58 : 51)
|
| 829 |
+
.attr("fill", mutedText)
|
| 830 |
+
.style("font-family", annotationFamily)
|
| 831 |
+
.style("font-size", "10.5px")
|
| 832 |
+
.text(fitText(`Source: ${fitText(xLabel, 20)} order is ${sortMode}; ${fitText(y2Label, 22)} uses circle size.`, Math.max(38, Math.floor((footerW - 245) / 5.6))));
|
| 833 |
+
|
| 834 |
+
const footerDecor = footer.append("g")
|
| 835 |
+
.attr("class", "footer-decorative-image-slot wave-icon-slot")
|
| 836 |
+
.attr("transform", `translate(${footerW - 72}, 39)`);
|
| 837 |
+
footerDecor.append("path")
|
| 838 |
+
.attr("d", "M-36 6 C-22 -13,-8 19,8 -1 S28 -12,38 4")
|
| 839 |
+
.attr("fill", "none")
|
| 840 |
+
.attr("stroke", "#93c5e8")
|
| 841 |
+
.attr("stroke-width", 5)
|
| 842 |
+
.attr("stroke-linecap", "round");
|
| 843 |
+
footerDecor.append("path")
|
| 844 |
+
.attr("d", "M-32 18 C-16 1,-2 29,16 10 S30 3,40 15")
|
| 845 |
+
.attr("fill", "none")
|
| 846 |
+
.attr("stroke", "#57aee2")
|
| 847 |
+
.attr("stroke-width", 4)
|
| 848 |
+
.attr("stroke-linecap", "round");
|
| 849 |
+
footerDecor.append("circle")
|
| 850 |
+
.attr("cx", 35)
|
| 851 |
+
.attr("cy", -17)
|
| 852 |
+
.attr("r", 10)
|
| 853 |
+
.attr("fill", "#9bcdf0");
|
| 854 |
+
|
| 855 |
+
return svg.node();
|
| 856 |
+
}
|
modules/chart_engine/template/d3-js/bar_chart/vertical_bar_chart_13.js
ADDED
|
@@ -0,0 +1,652 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Vertical Bar Chart",
|
| 5 |
+
"chart_name": "vertical_bar_chart_13",
|
| 6 |
+
"is_composite": true,
|
| 7 |
+
"required_fields": ["x", "y", "y2"],
|
| 8 |
+
"required_fields_type": [["categorical", "temporal"], ["numerical"], ["numerical"]],
|
| 9 |
+
"required_fields_range": [[2, 18], ["-inf", "inf"], [0, "inf"]],
|
| 10 |
+
"required_fields_icons": [],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": [],
|
| 13 |
+
"required_other_colors": ["primary", "secondary"],
|
| 14 |
+
"supported_effects": ["radius_corner", "spacing"],
|
| 15 |
+
"min_height": 400,
|
| 16 |
+
"min_width": 400,
|
| 17 |
+
"background": "no",
|
| 18 |
+
"icon_mark": "none",
|
| 19 |
+
"icon_label": "side",
|
| 20 |
+
"has_x_axis": "yes",
|
| 21 |
+
"has_y_axis": "yes"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
const jsonData = data;
|
| 28 |
+
const chartData = jsonData.data?.data || [];
|
| 29 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 30 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 31 |
+
const chartUtilsFormatSample = chartUtils.format.autoText;
|
| 32 |
+
const chartUtilsStandard = {
|
| 33 |
+
schema: chartUtils.schema,
|
| 34 |
+
format: chartUtils.format,
|
| 35 |
+
text: chartUtils.text,
|
| 36 |
+
color: chartUtils.color,
|
| 37 |
+
legendLayout: chartUtils.legend.layout,
|
| 38 |
+
legendDraw: chartUtils.legend.draw,
|
| 39 |
+
random: chartUtils.random.generator(jsonData, "dev-wz-style")
|
| 40 |
+
};
|
| 41 |
+
const standardChannels = chartUtils.schema.channels(jsonData, {
|
| 42 |
+
x: { fallbackIndex: 0 },
|
| 43 |
+
y: { fallbackIndex: 1 },
|
| 44 |
+
y2: { fallbackIndex: 2 },
|
| 45 |
+
y3: { fallbackIndex: 3 },
|
| 46 |
+
size: { fallbackIndex: 2 },
|
| 47 |
+
group: { fallbackIndex: 2 },
|
| 48 |
+
group2: { fallbackIndex: 3 },
|
| 49 |
+
group3: { fallbackIndex: 4 }
|
| 50 |
+
});
|
| 51 |
+
const variables = jsonData.variables || {};
|
| 52 |
+
const typography = jsonData.typography || {
|
| 53 |
+
label: { font_family: "Arial", font_size: "14px", font_weight: 500 },
|
| 54 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: 400 }
|
| 55 |
+
};
|
| 56 |
+
const sourceColors = jsonData.colors || {
|
| 57 |
+
text_color: "#1E2A3A",
|
| 58 |
+
other: { primary: "#2468C9", secondary: "#F35F72" }
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
const dimensionColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
|
| 62 |
+
const valueColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
|
| 63 |
+
const valueColumn2 = chartUtils.schema.channel(jsonData, "y2", { fallbackIndex: 2 }).raw || {};
|
| 64 |
+
const dimensionField = dimensionColumn.name;
|
| 65 |
+
const valueField = valueColumn.name;
|
| 66 |
+
const valueField2 = valueColumn2.name;
|
| 67 |
+
|
| 68 |
+
d3.select(containerSelector).html("");
|
| 69 |
+
|
| 70 |
+
const baseWidth = Number(variables.width) || 600;
|
| 71 |
+
const width = Math.max(860, baseWidth);
|
| 72 |
+
const height = Math.max(780, Number(variables.height) || 600);
|
| 73 |
+
const svg = d3.select(containerSelector)
|
| 74 |
+
.append("svg")
|
| 75 |
+
.attr("width", "100%")
|
| 76 |
+
.attr("height", height)
|
| 77 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 78 |
+
.attr("preserveAspectRatio", "xMidYMid meet")
|
| 79 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 80 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 81 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 82 |
+
|
| 83 |
+
const labelFont = typography.label || {};
|
| 84 |
+
const annotationFont = typography.annotation || {};
|
| 85 |
+
const labelFontFamily = labelFont.font_family || "Arial";
|
| 86 |
+
const annotationFontFamily = annotationFont.font_family || labelFontFamily;
|
| 87 |
+
const labelFontWeight = labelFont.font_weight || 500;
|
| 88 |
+
const annotationFontWeight = annotationFont.font_weight || 500;
|
| 89 |
+
const baseLabelSize = parseFloat(labelFont.font_size) || 14;
|
| 90 |
+
const baseAnnotationSize = parseFloat(annotationFont.font_size) || 12;
|
| 91 |
+
const navy = "#061A4C";
|
| 92 |
+
const muted = "#3C465A";
|
| 93 |
+
const barColor = "#2468C9";
|
| 94 |
+
const secondaryColor = "#F35F72";
|
| 95 |
+
const softRed = "#FFF1F3";
|
| 96 |
+
const axisColor = "#2D3545";
|
| 97 |
+
const gridColor = "#DDE3EA";
|
| 98 |
+
|
| 99 |
+
const canvas = document.createElement("canvas");
|
| 100 |
+
const context = canvas.getContext("2d");
|
| 101 |
+
const measureLabelText = (text, fontSize, weight = labelFontWeight, family = labelFontFamily) => {
|
| 102 |
+
context.font = `${weight} ${fontSize}px ${family}`;
|
| 103 |
+
return chartUtils.text.contextWidth(context, String(text ?? ""));
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
const unitText = (unit) => {
|
| 107 |
+
const raw = String(unit ?? "").trim();
|
| 108 |
+
return raw === "none" ? "" : raw;
|
| 109 |
+
};
|
| 110 |
+
const yUnit = unitText(valueColumn.unit);
|
| 111 |
+
const y2Unit = unitText(valueColumn2.unit);
|
| 112 |
+
const readableFieldName = value => String(value || "")
|
| 113 |
+
.replace(/_/g, " ")
|
| 114 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 115 |
+
.replace(/\s+/g, " ")
|
| 116 |
+
.trim();
|
| 117 |
+
|
| 118 |
+
const formatLocalValue = value => {
|
| 119 |
+
const numeric = Number(value);
|
| 120 |
+
if (!Number.isFinite(numeric)) return String(value ?? "");
|
| 121 |
+
const absValue = Math.abs(numeric);
|
| 122 |
+
if (absValue >= 1000000000) return d3.format("~g")(numeric / 1000000000) + "B";
|
| 123 |
+
if (absValue >= 1000000) return d3.format("~g")(numeric / 1000000) + "M";
|
| 124 |
+
if (absValue >= 1000) return d3.format("~g")(numeric / 1000) + "K";
|
| 125 |
+
return d3.format(",.4~g")(numeric);
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
+
const formatValueWithUnit = (value, unit, signed = false) => {
|
| 129 |
+
const numeric = Number(value);
|
| 130 |
+
const formatted = formatLocalValue(numeric);
|
| 131 |
+
const sign = signed && numeric > 0 ? "+" : "";
|
| 132 |
+
if (!unit) return `${sign}${formatted}`;
|
| 133 |
+
if (unit === "$") return `${sign}$${formatted}`;
|
| 134 |
+
if (unit === "%") return `${sign}${formatted}%`;
|
| 135 |
+
return `${sign}${formatted} ${unit}`;
|
| 136 |
+
};
|
| 137 |
+
|
| 138 |
+
const formatCompactValue = (value, unit, signed = false) => {
|
| 139 |
+
const numeric = Number(value);
|
| 140 |
+
const formatted = formatLocalValue(numeric);
|
| 141 |
+
const sign = signed && numeric > 0 ? "+" : "";
|
| 142 |
+
if (unit === "$") return `${sign}$${formatted}`;
|
| 143 |
+
if (unit === "%") return `${sign}${formatted}%`;
|
| 144 |
+
return `${sign}${formatted}`;
|
| 145 |
+
};
|
| 146 |
+
|
| 147 |
+
const legendLabel = (fieldName, unit) => {
|
| 148 |
+
const label = readableFieldName(fieldName);
|
| 149 |
+
if (!unit || unit === "%" || unit === "$") return label;
|
| 150 |
+
return `${label} (${unit})`;
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
const labelTokens = text => String(text ?? "")
|
| 154 |
+
.replace(/[()[\]{}]/g, " ")
|
| 155 |
+
.replace(/[,:;/\\|_-]+/g, " ")
|
| 156 |
+
.split(/\s+/)
|
| 157 |
+
.map(token => token.trim())
|
| 158 |
+
.filter(Boolean);
|
| 159 |
+
|
| 160 |
+
const temporalKey = value => {
|
| 161 |
+
if (value instanceof Date) return value.getTime();
|
| 162 |
+
if (typeof value === "number") return value;
|
| 163 |
+
const str = String(value ?? "").trim();
|
| 164 |
+
if (/^-?\d+(\.\d+)?$/.test(str)) return +str;
|
| 165 |
+
const parsed = Date.parse(str);
|
| 166 |
+
if (!Number.isNaN(parsed)) return parsed;
|
| 167 |
+
const yearMatch = str.match(/^-?\d{1,6}/);
|
| 168 |
+
return yearMatch ? +yearMatch[0] : null;
|
| 169 |
+
};
|
| 170 |
+
|
| 171 |
+
const compactLabelBase = (value, isTemporal) => {
|
| 172 |
+
const str = String(value ?? "").trim();
|
| 173 |
+
if (isTemporal) return str;
|
| 174 |
+
const parentheticalWithSuffix = str.match(/\(([^()]{2,})\)\s*[-–—]\s*(.{2,})$/);
|
| 175 |
+
if (parentheticalWithSuffix) return `${parentheticalWithSuffix[1].trim()} ${parentheticalWithSuffix[2].trim()}`;
|
| 176 |
+
const parenthetical = str.match(/\(([^()]{2,})\)\s*$/);
|
| 177 |
+
if (parenthetical) return parenthetical[1].trim();
|
| 178 |
+
const tokens = labelTokens(str);
|
| 179 |
+
if (tokens.length >= 4) return `${tokens[0]} ${tokens[tokens.length - 1]}`;
|
| 180 |
+
return str;
|
| 181 |
+
};
|
| 182 |
+
|
| 183 |
+
const suffixLabel = (value, tokenCount) => {
|
| 184 |
+
const tokens = labelTokens(value);
|
| 185 |
+
if (!tokens.length) return String(value ?? "");
|
| 186 |
+
return tokens.slice(Math.max(0, tokens.length - tokenCount)).join(" ");
|
| 187 |
+
};
|
| 188 |
+
|
| 189 |
+
const buildDisplayLabels = (values, isTemporal) => {
|
| 190 |
+
const labels = values.map(value => compactLabelBase(value, isTemporal));
|
| 191 |
+
const counts = new Map();
|
| 192 |
+
labels.forEach(label => counts.set(label.toLowerCase(), (counts.get(label.toLowerCase()) || 0) + 1));
|
| 193 |
+
return labels.map((label, index) => {
|
| 194 |
+
if ((counts.get(label.toLowerCase()) || 0) === 1) return label;
|
| 195 |
+
for (let tokenCount = 1; tokenCount <= 4; tokenCount++) {
|
| 196 |
+
const candidate = suffixLabel(values[index], tokenCount);
|
| 197 |
+
const duplicate = values.some((other, otherIndex) =>
|
| 198 |
+
otherIndex !== index && suffixLabel(other, tokenCount).toLowerCase() === candidate.toLowerCase()
|
| 199 |
+
);
|
| 200 |
+
if (!duplicate) return candidate;
|
| 201 |
+
}
|
| 202 |
+
return String(values[index]);
|
| 203 |
+
});
|
| 204 |
+
};
|
| 205 |
+
|
| 206 |
+
const splitLabelLines = (text, maxWidth, fontSize, maxLines = 2, weight = labelFontWeight) => {
|
| 207 |
+
const words = labelTokens(text);
|
| 208 |
+
const raw = String(text ?? "");
|
| 209 |
+
if (words.length <= 1) return [raw];
|
| 210 |
+
const lines = [];
|
| 211 |
+
let current = "";
|
| 212 |
+
for (const word of words) {
|
| 213 |
+
const next = current ? `${current} ${word}` : word;
|
| 214 |
+
if (measureLabelText(next, fontSize, weight, labelFontFamily) <= maxWidth || !current) {
|
| 215 |
+
current = next;
|
| 216 |
+
} else {
|
| 217 |
+
lines.push(current);
|
| 218 |
+
current = word;
|
| 219 |
+
}
|
| 220 |
+
if (lines.length === maxLines - 1) break;
|
| 221 |
+
}
|
| 222 |
+
if (current && lines.length < maxLines) lines.push(current);
|
| 223 |
+
return lines.slice(0, maxLines);
|
| 224 |
+
};
|
| 225 |
+
|
| 226 |
+
const normalizedData = chartData
|
| 227 |
+
.map((d, index) => ({
|
| 228 |
+
...d,
|
| 229 |
+
__index: index,
|
| 230 |
+
__dimension: d[dimensionField],
|
| 231 |
+
__y: +d[valueField],
|
| 232 |
+
__y2: Math.max(0, +d[valueField2] || 0),
|
| 233 |
+
__temporalKey: temporalKey(d[dimensionField])
|
| 234 |
+
}))
|
| 235 |
+
.filter(d => d.__dimension !== undefined && Number.isFinite(d.__y) && Number.isFinite(d.__y2));
|
| 236 |
+
|
| 237 |
+
const validTemporalKeys = normalizedData.filter(d => d.__temporalKey !== null).length;
|
| 238 |
+
const isTemporal = dimensionColumn.data_type === "temporal" || (
|
| 239 |
+
normalizedData.length > 0 && validTemporalKeys === normalizedData.length
|
| 240 |
+
);
|
| 241 |
+
const sortedData = [...normalizedData].sort((a, b) => {
|
| 242 |
+
if (isTemporal) return d3.ascending(a.__temporalKey, b.__temporalKey) || d3.ascending(a.__index, b.__index);
|
| 243 |
+
return d3.descending(a.__y, b.__y) || d3.ascending(a.__index, b.__index);
|
| 244 |
+
});
|
| 245 |
+
|
| 246 |
+
if (!sortedData.length) return svg.node();
|
| 247 |
+
|
| 248 |
+
const displayLabels = buildDisplayLabels(sortedData.map(d => d.__dimension), isTemporal);
|
| 249 |
+
sortedData.forEach((d, index) => {
|
| 250 |
+
d.__displayDimension = displayLabels[index] || d.__dimension;
|
| 251 |
+
});
|
| 252 |
+
|
| 253 |
+
const count = sortedData.length;
|
| 254 |
+
const denseCategories = count > 12;
|
| 255 |
+
const crowdedCategories = count > 9;
|
| 256 |
+
const longYAxisUnit = Boolean(yUnit && yUnit !== "%" && yUnit !== "$" && yUnit.length > 3);
|
| 257 |
+
const margin = { top: 66, right: 36, bottom: 226, left: longYAxisUnit ? 108 : 72 };
|
| 258 |
+
const innerWidth = width - margin.left - margin.right;
|
| 259 |
+
const plotTop = margin.top + 54;
|
| 260 |
+
const plotBottom = height - margin.bottom;
|
| 261 |
+
const plotHeight = plotBottom - plotTop;
|
| 262 |
+
const labelY = plotBottom + 32;
|
| 263 |
+
const secondaryY = plotBottom + 86;
|
| 264 |
+
const takeawayY = height - 118;
|
| 265 |
+
const sourceY = height - 20;
|
| 266 |
+
|
| 267 |
+
const xScale = d3.scaleBand()
|
| 268 |
+
.domain(sortedData.map((d, i) => `${i}-${d.__dimension}`))
|
| 269 |
+
.range([margin.left, margin.left + innerWidth])
|
| 270 |
+
.padding(count > 12 ? 0.18 : count > 8 ? 0.22 : 0.28);
|
| 271 |
+
|
| 272 |
+
const minY = Math.min(0, d3.min(sortedData, d => d.__y) || 0);
|
| 273 |
+
const maxY = Math.max(0, d3.max(sortedData, d => d.__y) || 0);
|
| 274 |
+
const yExtent = maxY - minY || 1;
|
| 275 |
+
const yScale = d3.scaleLinear()
|
| 276 |
+
.domain([minY - yExtent * 0.06, maxY + yExtent * 0.08])
|
| 277 |
+
.range([plotBottom, plotTop])
|
| 278 |
+
.nice(5);
|
| 279 |
+
const zeroY = yScale(0);
|
| 280 |
+
|
| 281 |
+
const maxY2 = d3.max(sortedData, d => d.__y2) || 1;
|
| 282 |
+
const badgeW = denseCategories
|
| 283 |
+
? Math.max(38, Math.min(76, xScale.bandwidth()))
|
| 284 |
+
: Math.max(42, Math.min(92, xScale.bandwidth() * 1.20));
|
| 285 |
+
const badgeH = denseCategories ? 44 : 46;
|
| 286 |
+
const maxMarkerSide = Math.max(8, Math.min(19, badgeH * 0.35, badgeW * 0.28));
|
| 287 |
+
const markerSideScale = d3.scaleSqrt()
|
| 288 |
+
.domain([0, maxY2])
|
| 289 |
+
.range([5, maxMarkerSide]);
|
| 290 |
+
|
| 291 |
+
const defs = svg.append("defs");
|
| 292 |
+
const barGradient = defs.append("linearGradient")
|
| 293 |
+
.attr("id", "vertical13BarGradient")
|
| 294 |
+
.attr("x1", "0%")
|
| 295 |
+
.attr("x2", "0%")
|
| 296 |
+
.attr("y1", "100%")
|
| 297 |
+
.attr("y2", "0%");
|
| 298 |
+
barGradient.append("stop").attr("offset", "0%").attr("stop-color", "#67C4F4");
|
| 299 |
+
barGradient.append("stop").attr("offset", "100%").attr("stop-color", "#1561C9");
|
| 300 |
+
const barShadow = defs.append("filter")
|
| 301 |
+
.attr("id", "vertical13BarShadow")
|
| 302 |
+
.attr("x", "-20%")
|
| 303 |
+
.attr("y", "-18%")
|
| 304 |
+
.attr("width", "140%")
|
| 305 |
+
.attr("height", "140%");
|
| 306 |
+
barShadow.append("feDropShadow")
|
| 307 |
+
.attr("dx", 0)
|
| 308 |
+
.attr("dy", 4)
|
| 309 |
+
.attr("stdDeviation", 3)
|
| 310 |
+
.attr("flood-color", "#1A5FA9")
|
| 311 |
+
.attr("flood-opacity", 0.18);
|
| 312 |
+
const iconGradient = defs.append("linearGradient")
|
| 313 |
+
.attr("id", "vertical13IconGradient")
|
| 314 |
+
.attr("x1", "0%")
|
| 315 |
+
.attr("x2", "100%")
|
| 316 |
+
.attr("y1", "100%")
|
| 317 |
+
.attr("y2", "0%");
|
| 318 |
+
iconGradient.append("stop").attr("offset", "0%").attr("stop-color", "#0C57BB");
|
| 319 |
+
iconGradient.append("stop").attr("offset", "100%").attr("stop-color", "#4FA7F4");
|
| 320 |
+
|
| 321 |
+
svg.append("rect")
|
| 322 |
+
.attr("class", "chart-canvas reference-chart-canvas")
|
| 323 |
+
.attr("x", 0)
|
| 324 |
+
.attr("y", 0)
|
| 325 |
+
.attr("width", width)
|
| 326 |
+
.attr("height", height)
|
| 327 |
+
.attr("fill", "transparent");
|
| 328 |
+
|
| 329 |
+
const legend = svg.append("g").attr("class", "reference-legend");
|
| 330 |
+
const legendItems = [
|
| 331 |
+
{ label: legendLabel(valueField, yUnit), color: barColor, className: "reference-primary-legend-dot" },
|
| 332 |
+
{ label: legendLabel(valueField2, y2Unit), color: secondaryColor, className: "reference-secondary-legend-dot" }
|
| 333 |
+
];
|
| 334 |
+
const legendFont = Math.max(11, Math.min(13, baseAnnotationSize));
|
| 335 |
+
const legendWidths = legendItems.map(item => 20 + measureLabelText(item.label, legendFont, 500, annotationFontFamily));
|
| 336 |
+
const legendGap = denseCategories ? 42 : 52;
|
| 337 |
+
const totalLegendWidth = legendWidths.reduce((a, b) => a + b, 0) + legendGap;
|
| 338 |
+
let legendX = margin.left + (innerWidth - totalLegendWidth) / 2;
|
| 339 |
+
legendItems.forEach((item, index) => {
|
| 340 |
+
const x = legendX + legendWidths.slice(0, index).reduce((a, b) => a + b, 0) + legendGap * index;
|
| 341 |
+
const group = legend.append("g")
|
| 342 |
+
.attr("class", "reference-legend-item")
|
| 343 |
+
.attr("transform", `translate(${x}, ${margin.top})`);
|
| 344 |
+
group.append("circle")
|
| 345 |
+
.attr("class", item.className)
|
| 346 |
+
.attr("cx", 0)
|
| 347 |
+
.attr("cy", 0)
|
| 348 |
+
.attr("r", 8)
|
| 349 |
+
.attr("fill", item.color);
|
| 350 |
+
group.append("text")
|
| 351 |
+
.attr("x", 18)
|
| 352 |
+
.attr("y", 1)
|
| 353 |
+
.attr("dominant-baseline", "middle")
|
| 354 |
+
.style("font-family", annotationFontFamily)
|
| 355 |
+
.style("font-size", `${legendFont}px`)
|
| 356 |
+
.style("font-weight", 500)
|
| 357 |
+
.style("fill", muted)
|
| 358 |
+
.text(item.label);
|
| 359 |
+
});
|
| 360 |
+
|
| 361 |
+
const chartG = svg.append("g").attr("class", "reference-chart-body");
|
| 362 |
+
chartG.append("line")
|
| 363 |
+
.attr("class", "reference-y-axis")
|
| 364 |
+
.attr("x1", margin.left)
|
| 365 |
+
.attr("x2", margin.left)
|
| 366 |
+
.attr("y1", plotTop)
|
| 367 |
+
.attr("y2", plotBottom)
|
| 368 |
+
.attr("stroke", "#AEB8C4")
|
| 369 |
+
.attr("stroke-width", 1.2);
|
| 370 |
+
chartG.append("line")
|
| 371 |
+
.attr("class", "reference-x-axis")
|
| 372 |
+
.attr("x1", margin.left)
|
| 373 |
+
.attr("x2", margin.left + innerWidth)
|
| 374 |
+
.attr("y1", zeroY)
|
| 375 |
+
.attr("y2", zeroY)
|
| 376 |
+
.attr("stroke", "#7B8592")
|
| 377 |
+
.attr("stroke-width", 1.3);
|
| 378 |
+
|
| 379 |
+
const tickValues = yScale.ticks(5);
|
| 380 |
+
tickValues.forEach(tick => {
|
| 381 |
+
const y = yScale(tick);
|
| 382 |
+
chartG.append("line")
|
| 383 |
+
.attr("class", "reference-grid-line")
|
| 384 |
+
.attr("x1", margin.left)
|
| 385 |
+
.attr("x2", margin.left + innerWidth)
|
| 386 |
+
.attr("y1", y)
|
| 387 |
+
.attr("y2", y)
|
| 388 |
+
.attr("stroke", gridColor)
|
| 389 |
+
.attr("stroke-width", tick === 0 ? 0 : 1)
|
| 390 |
+
.attr("stroke-dasharray", "4 5")
|
| 391 |
+
.attr("opacity", tick === 0 ? 0 : 0.78);
|
| 392 |
+
chartG.append("text")
|
| 393 |
+
.attr("class", "axis-tick")
|
| 394 |
+
.attr("x", margin.left - 12)
|
| 395 |
+
.attr("y", y)
|
| 396 |
+
.attr("text-anchor", "end")
|
| 397 |
+
.attr("dominant-baseline", "middle")
|
| 398 |
+
.style("font-family", annotationFontFamily)
|
| 399 |
+
.style("font-size", `${longYAxisUnit ? Math.max(10, Math.min(12, baseAnnotationSize)) : Math.max(11, baseAnnotationSize)}px`)
|
| 400 |
+
.style("font-weight", 500)
|
| 401 |
+
.style("fill", muted)
|
| 402 |
+
.text(formatValueWithUnit(tick, yUnit));
|
| 403 |
+
});
|
| 404 |
+
|
| 405 |
+
chartG.append("text")
|
| 406 |
+
.attr("class", "reference-y-axis-title")
|
| 407 |
+
.attr("x", -(plotTop + plotHeight / 2))
|
| 408 |
+
.attr("y", longYAxisUnit ? 18 : 24)
|
| 409 |
+
.attr("transform", "rotate(-90)")
|
| 410 |
+
.attr("text-anchor", "middle")
|
| 411 |
+
.style("font-family", labelFontFamily)
|
| 412 |
+
.style("font-size", `${longYAxisUnit ? Math.max(12, baseLabelSize - 1) : Math.max(14, baseLabelSize)}px`)
|
| 413 |
+
.style("font-weight", 500)
|
| 414 |
+
.style("fill", muted)
|
| 415 |
+
.text(readableFieldName(valueField));
|
| 416 |
+
|
| 417 |
+
const labelMaxWidth = Math.max(28, xScale.bandwidth() * (denseCategories ? 0.86 : crowdedCategories ? 1.05 : 1.35));
|
| 418 |
+
const xLabelFont = denseCategories
|
| 419 |
+
? Math.max(6.3, Math.min(8.4, xScale.bandwidth() / 5.8))
|
| 420 |
+
: crowdedCategories
|
| 421 |
+
? Math.max(7.6, Math.min(11, xScale.bandwidth() / 5))
|
| 422 |
+
: Math.max(7.5, Math.min(14, xScale.bandwidth() / 3.8));
|
| 423 |
+
const valueFont = denseCategories
|
| 424 |
+
? Math.max(8, Math.min(13, xScale.bandwidth() / 3.3))
|
| 425 |
+
: Math.max(8, Math.min(20, xScale.bandwidth() / 2.6));
|
| 426 |
+
const badgeFont = denseCategories
|
| 427 |
+
? Math.max(6.2, Math.min(10, badgeW / 5.6))
|
| 428 |
+
: Math.max(7, Math.min(14, badgeW / 4.6));
|
| 429 |
+
const barWidth = Math.max(9, Math.min(78, xScale.bandwidth() * 0.72));
|
| 430 |
+
const compactPrimaryLabels = denseCategories || longYAxisUnit;
|
| 431 |
+
const compactSecondaryLabels = denseCategories || Boolean(y2Unit && y2Unit !== "%" && y2Unit !== "$");
|
| 432 |
+
|
| 433 |
+
sortedData.forEach((d, index) => {
|
| 434 |
+
const key = `${index}-${d.__dimension}`;
|
| 435 |
+
const centerX = xScale(key) + xScale.bandwidth() / 2;
|
| 436 |
+
const yValue = d.__y;
|
| 437 |
+
const yBar = yScale(yValue);
|
| 438 |
+
const barY = Math.min(yBar, zeroY);
|
| 439 |
+
const barH = Math.max(0, Math.abs(zeroY - yBar));
|
| 440 |
+
const row = chartG.append("g")
|
| 441 |
+
.attr("class", "reference-data-item")
|
| 442 |
+
.attr("data-source-index", d.__index)
|
| 443 |
+
.attr("data-category", d.__dimension)
|
| 444 |
+
.attr("data-y", d.__y)
|
| 445 |
+
.attr("data-y2", d.__y2);
|
| 446 |
+
|
| 447 |
+
row.append("rect")
|
| 448 |
+
.attr("class", "bar mark reference-primary-bar")
|
| 449 |
+
.attr("data-source-index", d.__index)
|
| 450 |
+
.attr("data-category", d.__dimension)
|
| 451 |
+
.attr("data-value", d.__y)
|
| 452 |
+
.attr("x", centerX - barWidth / 2)
|
| 453 |
+
.attr("y", barY)
|
| 454 |
+
.attr("width", barWidth)
|
| 455 |
+
.attr("height", barH)
|
| 456 |
+
.attr("rx", variables.radius_corner === false ? 0 : Math.min(13, barWidth / 2))
|
| 457 |
+
.attr("ry", variables.radius_corner === false ? 0 : Math.min(13, barWidth / 2))
|
| 458 |
+
.attr("fill", yValue >= 0 ? "url(#vertical13BarGradient)" : secondaryColor)
|
| 459 |
+
.style("filter", "url(#vertical13BarShadow)");
|
| 460 |
+
|
| 461 |
+
const barLabel = compactPrimaryLabels
|
| 462 |
+
? formatCompactValue(yValue, yUnit, true)
|
| 463 |
+
: formatValueWithUnit(yValue, yUnit, true);
|
| 464 |
+
const labelInside = barH > valueFont * 2.2
|
| 465 |
+
&& measureLabelText(barLabel, valueFont, 800, annotationFontFamily) <= Math.max(barWidth * 1.35, xScale.bandwidth() * 0.78);
|
| 466 |
+
row.append("text")
|
| 467 |
+
.attr("class", "bar-value value reference-primary-value")
|
| 468 |
+
.attr("data-source-index", d.__index)
|
| 469 |
+
.attr("data-category", d.__dimension)
|
| 470 |
+
.attr("data-role", "primary")
|
| 471 |
+
.attr("data-value", d.__y)
|
| 472 |
+
.attr("x", centerX)
|
| 473 |
+
.attr("y", labelInside ? (yValue >= 0 ? barY + valueFont * 1.22 : barY + barH - valueFont * 0.45) : (yValue >= 0 ? barY - 6 : barY + barH + valueFont + 4))
|
| 474 |
+
.attr("text-anchor", "middle")
|
| 475 |
+
.style("font-family", annotationFontFamily)
|
| 476 |
+
.style("font-size", `${valueFont}px`)
|
| 477 |
+
.style("font-weight", 800)
|
| 478 |
+
.style("fill", labelInside ? "#FFFFFF" : navy)
|
| 479 |
+
.text(barLabel);
|
| 480 |
+
|
| 481 |
+
const labelText = row.append("text")
|
| 482 |
+
.attr("class", "label reference-category-label")
|
| 483 |
+
.attr("data-source-index", d.__index)
|
| 484 |
+
.attr("data-category", d.__dimension)
|
| 485 |
+
.attr("x", centerX)
|
| 486 |
+
.attr("y", labelY)
|
| 487 |
+
.attr("text-anchor", "middle")
|
| 488 |
+
.style("font-family", labelFontFamily)
|
| 489 |
+
.style("font-size", `${xLabelFont}px`)
|
| 490 |
+
.style("font-weight", 600)
|
| 491 |
+
.style("fill", navy);
|
| 492 |
+
splitLabelLines(d.__displayDimension, labelMaxWidth, xLabelFont, 2, 600).forEach((line, lineIndex) => {
|
| 493 |
+
labelText.append("tspan")
|
| 494 |
+
.attr("x", centerX)
|
| 495 |
+
.attr("dy", lineIndex === 0 ? 0 : `${xLabelFont * 1.12}px`)
|
| 496 |
+
.text(line);
|
| 497 |
+
});
|
| 498 |
+
|
| 499 |
+
const markerSide = markerSideScale(d.__y2);
|
| 500 |
+
const badgeX = centerX - badgeW / 2;
|
| 501 |
+
const badgeY = secondaryY - badgeH / 2;
|
| 502 |
+
row.append("rect")
|
| 503 |
+
.attr("class", "secondary-badge reference-secondary-badge")
|
| 504 |
+
.attr("x", badgeX)
|
| 505 |
+
.attr("y", badgeY)
|
| 506 |
+
.attr("width", badgeW)
|
| 507 |
+
.attr("height", badgeH)
|
| 508 |
+
.attr("rx", 9)
|
| 509 |
+
.attr("ry", 9)
|
| 510 |
+
.attr("fill", secondaryColor)
|
| 511 |
+
.attr("fill-opacity", 0.95);
|
| 512 |
+
|
| 513 |
+
const iconRadius = denseCategories ? 6 : 7;
|
| 514 |
+
const iconCx = badgeX + Math.max(denseCategories ? 10 : 12, badgeW * (denseCategories ? 0.27 : 0.25));
|
| 515 |
+
const iconCy = secondaryY;
|
| 516 |
+
row.append("path")
|
| 517 |
+
.attr("class", "reference-secondary-icon")
|
| 518 |
+
.attr("d", `M${iconCx - iconRadius},${iconCy + 4} A${iconRadius + 1},${iconRadius + 1} 0 0 1 ${iconCx + iconRadius},${iconCy + 4}`)
|
| 519 |
+
.attr("fill", "none")
|
| 520 |
+
.attr("stroke", "#FFFFFF")
|
| 521 |
+
.attr("stroke-width", 2)
|
| 522 |
+
.attr("stroke-linecap", "round");
|
| 523 |
+
row.append("line")
|
| 524 |
+
.attr("class", "reference-secondary-icon")
|
| 525 |
+
.attr("x1", iconCx)
|
| 526 |
+
.attr("x2", iconCx + (denseCategories ? 4 : 5))
|
| 527 |
+
.attr("y1", iconCy + 3)
|
| 528 |
+
.attr("y2", iconCy - 4)
|
| 529 |
+
.attr("stroke", "#FFFFFF")
|
| 530 |
+
.attr("stroke-width", 2)
|
| 531 |
+
.attr("stroke-linecap", "round");
|
| 532 |
+
row.append("rect")
|
| 533 |
+
.attr("class", "secondary-mark")
|
| 534 |
+
.attr("data-source-index", d.__index)
|
| 535 |
+
.attr("data-category", d.__dimension)
|
| 536 |
+
.attr("data-value", d.__y2)
|
| 537 |
+
.attr("data-side", markerSide)
|
| 538 |
+
.attr("x", iconCx - markerSide / 2)
|
| 539 |
+
.attr("y", iconCy + 8 - markerSide / 2)
|
| 540 |
+
.attr("width", markerSide)
|
| 541 |
+
.attr("height", markerSide)
|
| 542 |
+
.attr("rx", 2)
|
| 543 |
+
.attr("ry", 2)
|
| 544 |
+
.attr("fill", softRed)
|
| 545 |
+
.attr("fill-opacity", 0.74);
|
| 546 |
+
|
| 547 |
+
row.append("text")
|
| 548 |
+
.attr("class", "secondary-value value reference-secondary-value")
|
| 549 |
+
.attr("data-source-index", d.__index)
|
| 550 |
+
.attr("data-category", d.__dimension)
|
| 551 |
+
.attr("data-role", "secondary")
|
| 552 |
+
.attr("data-value", d.__y2)
|
| 553 |
+
.attr("x", badgeX + badgeW * (denseCategories ? 0.67 : 0.62))
|
| 554 |
+
.attr("y", secondaryY + 1)
|
| 555 |
+
.attr("text-anchor", "middle")
|
| 556 |
+
.attr("dominant-baseline", "middle")
|
| 557 |
+
.style("font-family", annotationFontFamily)
|
| 558 |
+
.style("font-size", `${badgeFont}px`)
|
| 559 |
+
.style("font-weight", 800)
|
| 560 |
+
.style("fill", "#FFFFFF")
|
| 561 |
+
.text(compactSecondaryLabels
|
| 562 |
+
? formatCompactValue(d.__y2, y2Unit)
|
| 563 |
+
: formatValueWithUnit(d.__y2, y2Unit));
|
| 564 |
+
});
|
| 565 |
+
|
| 566 |
+
const takeaway = svg.append("g")
|
| 567 |
+
.attr("class", "reference-takeaway-card")
|
| 568 |
+
.attr("transform", `translate(42, ${takeawayY})`);
|
| 569 |
+
const takeawayW = width - 84;
|
| 570 |
+
const takeawayH = 88;
|
| 571 |
+
takeaway.append("rect")
|
| 572 |
+
.attr("x", 0)
|
| 573 |
+
.attr("y", 0)
|
| 574 |
+
.attr("width", takeawayW)
|
| 575 |
+
.attr("height", takeawayH)
|
| 576 |
+
.attr("rx", 13)
|
| 577 |
+
.attr("ry", 13)
|
| 578 |
+
.attr("fill", "#F4FAFF")
|
| 579 |
+
.attr("stroke", "#C9DEEF")
|
| 580 |
+
.attr("stroke-width", 1.1);
|
| 581 |
+
|
| 582 |
+
const icon = takeaway.append("g")
|
| 583 |
+
.attr("class", "reference-footer-icon-slot")
|
| 584 |
+
.attr("transform", "translate(76, 44)");
|
| 585 |
+
icon.append("circle")
|
| 586 |
+
.attr("r", 37)
|
| 587 |
+
.attr("fill", "url(#vertical13IconGradient)")
|
| 588 |
+
.attr("stroke", "#0A57B7")
|
| 589 |
+
.attr("stroke-width", 1.1);
|
| 590 |
+
icon.append("path")
|
| 591 |
+
.attr("d", "M-22,14 L-8,0 L5,10 L24,-13")
|
| 592 |
+
.attr("fill", "none")
|
| 593 |
+
.attr("stroke", "#FFFFFF")
|
| 594 |
+
.attr("stroke-width", 5)
|
| 595 |
+
.attr("stroke-linecap", "round")
|
| 596 |
+
.attr("stroke-linejoin", "round");
|
| 597 |
+
icon.append("path")
|
| 598 |
+
.attr("d", "M12,-14 L25,-14 L25,-1")
|
| 599 |
+
.attr("fill", "none")
|
| 600 |
+
.attr("stroke", "#FFFFFF")
|
| 601 |
+
.attr("stroke-width", 5)
|
| 602 |
+
.attr("stroke-linecap", "round")
|
| 603 |
+
.attr("stroke-linejoin", "round");
|
| 604 |
+
|
| 605 |
+
const takeawayTitle = "KEY TAKEAWAY";
|
| 606 |
+
let insight = jsonData.metadata?.main_insight
|
| 607 |
+
|| jsonData.titles?.sub_title
|
| 608 |
+
|| `${readableFieldName(valueField)} and ${readableFieldName(valueField2)} are shown together.`;
|
| 609 |
+
const insightMax = takeawayW - 170;
|
| 610 |
+
const insightFont = 15;
|
| 611 |
+
if (measureLabelText(insight, insightFont, 500, annotationFontFamily) > insightMax * 1.8) {
|
| 612 |
+
insight = `${readableFieldName(valueField)} bars and ${readableFieldName(valueField2)} badges use source data.`;
|
| 613 |
+
}
|
| 614 |
+
const insightLines = splitLabelLines(insight, insightMax, insightFont, 2, 500);
|
| 615 |
+
takeaway.append("text")
|
| 616 |
+
.attr("class", "reference-takeaway-title")
|
| 617 |
+
.attr("x", 150)
|
| 618 |
+
.attr("y", 31)
|
| 619 |
+
.style("font-family", labelFontFamily)
|
| 620 |
+
.style("font-size", "18px")
|
| 621 |
+
.style("font-weight", 800)
|
| 622 |
+
.style("fill", "#0A4EA4")
|
| 623 |
+
.text(takeawayTitle);
|
| 624 |
+
const insightText = takeaway.append("text")
|
| 625 |
+
.attr("class", "reference-takeaway-text")
|
| 626 |
+
.attr("x", 150)
|
| 627 |
+
.attr("y", 56)
|
| 628 |
+
.style("font-family", annotationFontFamily)
|
| 629 |
+
.style("font-size", `${insightFont}px`)
|
| 630 |
+
.style("font-weight", 500)
|
| 631 |
+
.style("fill", navy);
|
| 632 |
+
insightLines.forEach((line, lineIndex) => {
|
| 633 |
+
insightText.append("tspan")
|
| 634 |
+
.attr("x", 150)
|
| 635 |
+
.attr("dy", lineIndex === 0 ? 0 : `${insightFont * 1.25}px`)
|
| 636 |
+
.text(line);
|
| 637 |
+
});
|
| 638 |
+
|
| 639 |
+
svg.append("text")
|
| 640 |
+
.attr("class", "reference-source-note")
|
| 641 |
+
.attr("x", width / 2)
|
| 642 |
+
.attr("y", sourceY)
|
| 643 |
+
.attr("text-anchor", "middle")
|
| 644 |
+
.style("font-family", annotationFontFamily)
|
| 645 |
+
.style("font-size", "11px")
|
| 646 |
+
.style("font-style", "italic")
|
| 647 |
+
.style("font-weight", 400)
|
| 648 |
+
.style("fill", "#68717E")
|
| 649 |
+
.text(`Source: ${readableFieldName(valueField)} and ${readableFieldName(valueField2)} source data`);
|
| 650 |
+
|
| 651 |
+
return svg.node();
|
| 652 |
+
}
|
modules/chart_engine/template/d3-js/bar_chart/vertical_group_bar_chart_11.js
ADDED
|
@@ -0,0 +1,597 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Vertical Group Bar Chart",
|
| 5 |
+
"chart_name": "vertical_group_bar_chart_11",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y", "group"],
|
| 8 |
+
"required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
|
| 9 |
+
"required_fields_range": [[2, 20], [0, "inf"], [2, 2]],
|
| 10 |
+
"required_fields_icons": ["x"],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": ["group"],
|
| 13 |
+
"required_other_colors": [],
|
| 14 |
+
"supported_effects": [],
|
| 15 |
+
"min_height": 400,
|
| 16 |
+
"min_width": 400,
|
| 17 |
+
"background": "no",
|
| 18 |
+
"icon_mark": "none",
|
| 19 |
+
"icon_label": "none",
|
| 20 |
+
"has_x_axis": "no",
|
| 21 |
+
"has_y_axis": "no"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
// 垂直分组条形图实现 - 带有图标和数值标签
|
| 27 |
+
function makeChart(containerSelector, data) {
|
| 28 |
+
const colorResolver = chartUtils.color.resolver(data);
|
| 29 |
+
// ---------- 1. 数据准备 ----------
|
| 30 |
+
// 提取数据和配置
|
| 31 |
+
const jsonData = data;
|
| 32 |
+
const chartData = jsonData.data.data || [];
|
| 33 |
+
const variables = jsonData.variables || {};
|
| 34 |
+
const typography = jsonData.typography || {
|
| 35 |
+
title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
|
| 36 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 37 |
+
description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 38 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 39 |
+
};
|
| 40 |
+
const colors = jsonData.colors || {
|
| 41 |
+
text_color: "#333333",
|
| 42 |
+
field: {},
|
| 43 |
+
other: {
|
| 44 |
+
primary: "#4682B4" // 默认主色调
|
| 45 |
+
}
|
| 46 |
+
};
|
| 47 |
+
const images = jsonData.images || { field: {}, other: {} };
|
| 48 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 49 |
+
|
| 50 |
+
// 如果不存在,添加副标题字段
|
| 51 |
+
typography.subtitle = typography.subtitle || typography.description;
|
| 52 |
+
|
| 53 |
+
// 设置视觉效果变量
|
| 54 |
+
variables.has_rounded_corners = variables.has_rounded_corners || false;
|
| 55 |
+
variables.has_shadow = variables.has_shadow || false;
|
| 56 |
+
variables.has_gradient = variables.has_gradient || false;
|
| 57 |
+
variables.has_stroke = variables.has_stroke || false;
|
| 58 |
+
variables.has_spacing = variables.has_spacing || false;
|
| 59 |
+
|
| 60 |
+
// 清除容器
|
| 61 |
+
d3.select(containerSelector).html("");
|
| 62 |
+
|
| 63 |
+
// ---------- 2. 尺寸和布局设置 ----------
|
| 64 |
+
// 设置图表尺寸和边距
|
| 65 |
+
const width = variables.width || 800;
|
| 66 |
+
const height = variables.height || 500;
|
| 67 |
+
|
| 68 |
+
// 边距:上,右,下,左
|
| 69 |
+
const margin = {
|
| 70 |
+
top: 100, // 标题和标签的空间
|
| 71 |
+
right: 30, // 右侧标签的空间
|
| 72 |
+
bottom: 80, // x轴和标签的空间
|
| 73 |
+
left: 30 // y轴和标签的空间
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
// 计算实际绘图区域大小
|
| 77 |
+
const innerWidth = width - margin.left - margin.right;
|
| 78 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 79 |
+
|
| 80 |
+
// ---------- 3. 提取字段名称和单位 ----------
|
| 81 |
+
let xField, yField, groupField;
|
| 82 |
+
let xUnit = "", yUnit = "";
|
| 83 |
+
|
| 84 |
+
// 安全提取字段名称
|
| 85 |
+
const xChannel = chartUtils.schema.channel(jsonData, "x");
|
| 86 |
+
const yChannel = chartUtils.schema.channel(jsonData, "y");
|
| 87 |
+
const groupChannel = chartUtils.schema.channel(jsonData, "group");
|
| 88 |
+
|
| 89 |
+
xField = xChannel.key;
|
| 90 |
+
yField = yChannel.key;
|
| 91 |
+
groupField = groupChannel.key;
|
| 92 |
+
|
| 93 |
+
// 获取字段单位
|
| 94 |
+
xUnit = xChannel.unit;
|
| 95 |
+
yUnit = yChannel.unit;
|
| 96 |
+
|
| 97 |
+
// ---------- 4. 数据处理 ----------
|
| 98 |
+
// 使用提供的数据
|
| 99 |
+
let useData = chartData;
|
| 100 |
+
|
| 101 |
+
// 获取x轴和分组的唯一值
|
| 102 |
+
const xValues = [...new Set(useData.map(d => d[xField]))];
|
| 103 |
+
let groupValues = [...new Set(useData.map(d => d[groupField]))];
|
| 104 |
+
|
| 105 |
+
// 如果组的数量不符合要求,给出警告
|
| 106 |
+
if (groupValues.length !== 2) {
|
| 107 |
+
console.warn("此图表需要恰好2个组字段");
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
// 第一个组是左侧柱子,第二个组是右侧柱子
|
| 111 |
+
const leftBarGroup = groupValues[0];
|
| 112 |
+
const rightBarGroup = groupValues[1];
|
| 113 |
+
|
| 114 |
+
// ---------- 5. 创建SVG容器 ----------
|
| 115 |
+
const svg = d3.select(containerSelector)
|
| 116 |
+
.append("svg")
|
| 117 |
+
.attr("width", "100%")
|
| 118 |
+
.attr("height", height)
|
| 119 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 120 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 121 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 122 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 123 |
+
|
| 124 |
+
// ---------- 6. 创建视觉效果 ----------
|
| 125 |
+
const defs = svg.append("defs");
|
| 126 |
+
|
| 127 |
+
// 如果需要,创建阴影滤镜
|
| 128 |
+
if (variables.has_shadow) {
|
| 129 |
+
const filter = defs.append("filter")
|
| 130 |
+
.attr("id", "shadow")
|
| 131 |
+
.attr("filterUnits", "userSpaceOnUse")
|
| 132 |
+
.attr("width", "200%")
|
| 133 |
+
.attr("height", "200%");
|
| 134 |
+
|
| 135 |
+
filter.append("feGaussianBlur")
|
| 136 |
+
.attr("in", "SourceAlpha")
|
| 137 |
+
.attr("stdDeviation", 3);
|
| 138 |
+
|
| 139 |
+
filter.append("feOffset")
|
| 140 |
+
.attr("dx", 2)
|
| 141 |
+
.attr("dy", 2)
|
| 142 |
+
.attr("result", "offsetblur");
|
| 143 |
+
|
| 144 |
+
const feMerge = filter.append("feMerge");
|
| 145 |
+
feMerge.append("feMergeNode");
|
| 146 |
+
feMerge.append("feMergeNode").attr("in", "SourceGraphic");
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// *** BEGIN ADDITION: Define vertical gradients for groups ***
|
| 150 |
+
const leftGroupColor = colorResolver.field(leftBarGroup, 0, { fallback: "#4269d0" }).value; // Default left color
|
| 151 |
+
const rightGroupColor = colorResolver.field(rightBarGroup, 0, { fallback: "#ff725c" }).value; // Default right color
|
| 152 |
+
|
| 153 |
+
// Gradient for the left group
|
| 154 |
+
const gradientLeft = defs.append("linearGradient")
|
| 155 |
+
.attr("id", "gradient-left")
|
| 156 |
+
.attr("x1", "0%")
|
| 157 |
+
.attr("y1", "0%") // Top
|
| 158 |
+
.attr("x2", "0%")
|
| 159 |
+
.attr("y2", "100%"); // Bottom
|
| 160 |
+
|
| 161 |
+
gradientLeft.append("stop")
|
| 162 |
+
.attr("offset", "0%")
|
| 163 |
+
.attr("stop-color", colorResolver.variant(leftGroupColor, { mode: "darker", amount: 0.3 }));
|
| 164 |
+
|
| 165 |
+
gradientLeft.append("stop")
|
| 166 |
+
.attr("offset", "50%")
|
| 167 |
+
.attr("stop-color", colorResolver.variant(leftGroupColor, { mode: "brighter", amount: 2.5 }));
|
| 168 |
+
|
| 169 |
+
gradientLeft.append("stop")
|
| 170 |
+
.attr("offset", "100%")
|
| 171 |
+
.attr("stop-color", colorResolver.variant(leftGroupColor, { mode: "darker", amount: 0.3 }));
|
| 172 |
+
|
| 173 |
+
// Gradient for the right group
|
| 174 |
+
const gradientRight = defs.append("linearGradient")
|
| 175 |
+
.attr("id", "gradient-right")
|
| 176 |
+
.attr("x1", "0%")
|
| 177 |
+
.attr("y1", "0%") // Top
|
| 178 |
+
.attr("x2", "0%")
|
| 179 |
+
.attr("y2", "100%"); // Bottom
|
| 180 |
+
|
| 181 |
+
gradientRight.append("stop")
|
| 182 |
+
.attr("offset", "0%")
|
| 183 |
+
.attr("stop-color", colorResolver.variant(rightGroupColor, { mode: "darker", amount: 0.3 }));
|
| 184 |
+
|
| 185 |
+
gradientRight.append("stop")
|
| 186 |
+
.attr("offset", "50%")
|
| 187 |
+
.attr("stop-color", colorResolver.variant(rightGroupColor, { mode: "brighter", amount: 2.5 }));
|
| 188 |
+
|
| 189 |
+
gradientRight.append("stop")
|
| 190 |
+
.attr("offset", "100%")
|
| 191 |
+
.attr("stop-color", colorResolver.variant(rightGroupColor, { mode: "darker", amount: 0.3 }));
|
| 192 |
+
// *** END ADDITION ***
|
| 193 |
+
|
| 194 |
+
// ---------- 7. 创建图表区域 ----------
|
| 195 |
+
const chart = svg.append("g")
|
| 196 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 197 |
+
|
| 198 |
+
// ---------- 8. 创建比例尺 ----------
|
| 199 |
+
// X比例尺(分类)用于主分类
|
| 200 |
+
const xScale = d3.scaleBand()
|
| 201 |
+
.domain(xValues)
|
| 202 |
+
.range([0, innerWidth])
|
| 203 |
+
.padding(0.2);
|
| 204 |
+
|
| 205 |
+
// 分组比例尺,用于每个类别内的细分
|
| 206 |
+
const groupScale = d3.scaleBand()
|
| 207 |
+
.domain([0, 1]) // 只有两个柱子,左侧和右侧
|
| 208 |
+
.range([0, xScale.bandwidth()])
|
| 209 |
+
.padding(0.2); // 同一维度柱子之间的间隙,增加间隔
|
| 210 |
+
|
| 211 |
+
// Y比例尺(数值)- 直接使用最大值映射到可用高度
|
| 212 |
+
const dataMax = d3.max(useData, d => +d[yField]) || 100;
|
| 213 |
+
const yScale = d3.scaleLinear()
|
| 214 |
+
.domain([0, dataMax])
|
| 215 |
+
.range([innerHeight, 0]);
|
| 216 |
+
|
| 217 |
+
// ---------- 9. 测量文本并计算字体大小 ----------
|
| 218 |
+
|
| 219 |
+
// 添加数值格式化函数
|
| 220 |
+
// 数值标签的可用宽度 = bar宽度 + 间距
|
| 221 |
+
const barWidth = groupScale.bandwidth();
|
| 222 |
+
const valueSpacing = xScale.bandwidth() * 0.1;
|
| 223 |
+
const valueLabelAvailableWidth = barWidth; // 每个值标签只能占据其柱子的宽度
|
| 224 |
+
|
| 225 |
+
// 维度标签的可用宽度 = 两个bar宽度 + bar间距
|
| 226 |
+
const dimensionLabelAvailableWidth = xScale.bandwidth();
|
| 227 |
+
|
| 228 |
+
// 计算最长的数值标签和维度标签
|
| 229 |
+
let maxValueLabelWidth = 0;
|
| 230 |
+
let maxDimensionLabelWidth = 0;
|
| 231 |
+
// 计算所有数值标签的最大宽度
|
| 232 |
+
useData.forEach(d => {
|
| 233 |
+
const textWidth = chartUtils.text.measure(svg, chartUtils.format.scaledText(+d[yField], { unit: yUnit }), {
|
| 234 |
+
fontFamily: typography.label.font_family,
|
| 235 |
+
fontSize: typography.label.font_size,
|
| 236 |
+
fontWeight: typography.label.font_weight
|
| 237 |
+
}).width;
|
| 238 |
+
maxValueLabelWidth = Math.max(maxValueLabelWidth, textWidth);
|
| 239 |
+
});
|
| 240 |
+
|
| 241 |
+
// 计算所有维度标签的最大宽度
|
| 242 |
+
xValues.forEach(xValue => {
|
| 243 |
+
const textWidth = chartUtils.text.measure(svg, xValue, {
|
| 244 |
+
fontFamily: typography.label.font_family,
|
| 245 |
+
fontSize: typography.label.font_size,
|
| 246 |
+
fontWeight: typography.label.font_weight
|
| 247 |
+
}).width;
|
| 248 |
+
maxDimensionLabelWidth = Math.max(maxDimensionLabelWidth, textWidth);
|
| 249 |
+
});
|
| 250 |
+
|
| 251 |
+
// 计算需要的字体缩放比例
|
| 252 |
+
const valueFontScale = Math.min(1, valueLabelAvailableWidth / maxValueLabelWidth);
|
| 253 |
+
const dimensionFontScale = Math.min(1, dimensionLabelAvailableWidth / maxDimensionLabelWidth);
|
| 254 |
+
|
| 255 |
+
// 计算实际使用的字体大小
|
| 256 |
+
const valueFontSize = Math.max(8, parseInt(typography.label.font_size) * valueFontScale); // 最小字体8px
|
| 257 |
+
const dimensionFontSize = Math.max(8, parseInt(typography.label.font_size) * dimensionFontScale);
|
| 258 |
+
|
| 259 |
+
// 计算动态文本大小的函数
|
| 260 |
+
const calculateFontSize = (text, maxWidth, baseSize = 12) => {
|
| 261 |
+
// 估算每个字符的平均��度 (假设为baseSize的60%)
|
| 262 |
+
const avgCharWidth = baseSize * 0.6;
|
| 263 |
+
// 计算文本的估计宽度
|
| 264 |
+
const textWidth = text.length * avgCharWidth;
|
| 265 |
+
// 如果文本宽度小于最大宽度,返回基础大小
|
| 266 |
+
if (textWidth < maxWidth) {
|
| 267 |
+
return baseSize;
|
| 268 |
+
}
|
| 269 |
+
// 否则,按比例缩小字体大小
|
| 270 |
+
return Math.max(10, Math.floor(baseSize * (maxWidth / textWidth)));
|
| 271 |
+
};
|
| 272 |
+
|
| 273 |
+
// ---------- 10. 创建坐标轴 (原来的第9步) ----------
|
| 274 |
+
// 创建x轴组,用于添加刻度标签
|
| 275 |
+
const xAxisGroup = chart.append("g")
|
| 276 |
+
.attr("class", "x-axis")
|
| 277 |
+
.attr("transform", `translate(0, ${innerHeight})`);
|
| 278 |
+
|
| 279 |
+
// 第一步:找出最长的标签并计算统一的字体大小
|
| 280 |
+
let maxLabelLength = 0;
|
| 281 |
+
const allLabels = xValues.map(d => d.toString());
|
| 282 |
+
|
| 283 |
+
// 找出最长的标签
|
| 284 |
+
const longestLabel = allLabels.reduce((a, b) => a.length > b.length ? a : b, "");
|
| 285 |
+
|
| 286 |
+
// 使用最长标签计算合适的统一字体大小
|
| 287 |
+
const labelMaxWidth = xScale.bandwidth()*1.3;
|
| 288 |
+
const uniformFontSize = calculateFontSize(longestLabel, labelMaxWidth, parseInt(typography.label.font_size));
|
| 289 |
+
|
| 290 |
+
// 绘制x轴标签
|
| 291 |
+
xAxisGroup.selectAll(".x-label")
|
| 292 |
+
.data(xValues)
|
| 293 |
+
.enter()
|
| 294 |
+
.append("text")
|
| 295 |
+
.attr("class", "x-label")
|
| 296 |
+
.attr("x", d => xScale(d) + xScale.bandwidth() / 2)
|
| 297 |
+
.attr("y", 20)
|
| 298 |
+
.attr("text-anchor", "middle")
|
| 299 |
+
.style("font-family", typography.label.font_family)
|
| 300 |
+
.style("font-size", `${uniformFontSize}px`) // 应用统一的字体大小
|
| 301 |
+
.style("font-weight", typography.label.font_weight)
|
| 302 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 303 |
+
.text(d => d)
|
| 304 |
+
.each(function(d) {
|
| 305 |
+
const text = d3.select(this);
|
| 306 |
+
const labelText = d.toString();
|
| 307 |
+
const labelWidth = chartUtils.text.measure(null, labelText, {
|
| 308 |
+
fontFamily: typography.label.font_family,
|
| 309 |
+
fontSize: uniformFontSize,
|
| 310 |
+
fontWeight: typography.label.font_weight
|
| 311 |
+
}).width;
|
| 312 |
+
|
| 313 |
+
// 检查使用统一字体大小后,文本是否仍然超过可用宽度
|
| 314 |
+
if (labelWidth > labelMaxWidth) {
|
| 315 |
+
// 如果仍然太长,应用文本换行
|
| 316 |
+
wrapText(text, labelText, labelMaxWidth, 1.1);
|
| 317 |
+
}
|
| 318 |
+
});
|
| 319 |
+
|
| 320 |
+
// 文本换行助手函数
|
| 321 |
+
function wrapText(text, str, width, lineHeight) {
|
| 322 |
+
const words = str.split(/\s+/).reverse();
|
| 323 |
+
let word;
|
| 324 |
+
let line = [];
|
| 325 |
+
let lineNumber = 0;
|
| 326 |
+
const y = text.attr("y");
|
| 327 |
+
const dy = parseFloat(text.attr("dy") || 0);
|
| 328 |
+
|
| 329 |
+
// 先清空文本
|
| 330 |
+
text.text(null);
|
| 331 |
+
|
| 332 |
+
// 处理文本
|
| 333 |
+
let tspans = [];
|
| 334 |
+
|
| 335 |
+
// 如果没有空格可分割,按字符分割
|
| 336 |
+
if (words.length <= 1) {
|
| 337 |
+
const chars = str.split('');
|
| 338 |
+
let currentLine = '';
|
| 339 |
+
|
| 340 |
+
for (let i = 0; i < chars.length; i++) {
|
| 341 |
+
currentLine += chars[i];
|
| 342 |
+
|
| 343 |
+
// 测量候选文本宽度
|
| 344 |
+
const isOverflow = chartUtils.text.measure(null, currentLine, {
|
| 345 |
+
fontFamily: text.style("font-family"),
|
| 346 |
+
fontSize: parseFloat(text.style("font-size")),
|
| 347 |
+
fontWeight: text.style("font-weight")
|
| 348 |
+
}).width > width;
|
| 349 |
+
|
| 350 |
+
if (isOverflow && currentLine.length > 1) {
|
| 351 |
+
// 当前行过长,回退一个字符并换行
|
| 352 |
+
currentLine = currentLine.slice(0, -1);
|
| 353 |
+
|
| 354 |
+
// 添加到tspans数组
|
| 355 |
+
tspans.push(currentLine);
|
| 356 |
+
|
| 357 |
+
// 重新开始下一行
|
| 358 |
+
currentLine = chars[i];
|
| 359 |
+
lineNumber++;
|
| 360 |
+
}
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
// 添加最后一行
|
| 364 |
+
if (currentLine.length > 0) {
|
| 365 |
+
tspans.push(currentLine);
|
| 366 |
+
}
|
| 367 |
+
} else {
|
| 368 |
+
// 处理有空格的文本
|
| 369 |
+
let currentLine = [];
|
| 370 |
+
|
| 371 |
+
while (word = words.pop()) {
|
| 372 |
+
currentLine.push(word);
|
| 373 |
+
|
| 374 |
+
// 测量候选文本宽度
|
| 375 |
+
const isOverflow = chartUtils.text.measure(null, currentLine.join(" "), {
|
| 376 |
+
fontFamily: text.style("font-family"),
|
| 377 |
+
fontSize: parseFloat(text.style("font-size")),
|
| 378 |
+
fontWeight: text.style("font-weight")
|
| 379 |
+
}).width > width;
|
| 380 |
+
|
| 381 |
+
if (isOverflow && currentLine.length > 1) {
|
| 382 |
+
// 回退一个词
|
| 383 |
+
currentLine.pop();
|
| 384 |
+
|
| 385 |
+
// 添加到tspans数组
|
| 386 |
+
tspans.push(currentLine.join(" "));
|
| 387 |
+
|
| 388 |
+
// 重新开始下一行
|
| 389 |
+
currentLine = [word];
|
| 390 |
+
lineNumber++;
|
| 391 |
+
}
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
// 添加最后一行
|
| 395 |
+
if (currentLine.length > 0) {
|
| 396 |
+
tspans.push(currentLine.join(" "));
|
| 397 |
+
}
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
// 计算总行数
|
| 401 |
+
const totalLines = tspans.length;
|
| 402 |
+
|
| 403 |
+
// 计算垂直居中的起始位置
|
| 404 |
+
// 对于单行文本,y位置保持不变
|
| 405 |
+
// 对于多行文本,需要向上偏移以保持垂直居中
|
| 406 |
+
let startY = y;
|
| 407 |
+
if (totalLines > 1) {
|
| 408 |
+
// 向上偏移半行距离 * (总行数-1)
|
| 409 |
+
startY = parseFloat(y) - (lineHeight * (totalLines - 1) / 2);
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
// 创建所有行的tspan元素
|
| 413 |
+
tspans.forEach((lineText, i) => {
|
| 414 |
+
text.append("tspan")
|
| 415 |
+
.attr("x", text.attr("x"))
|
| 416 |
+
.attr("y", startY)
|
| 417 |
+
.attr("dy", `${i * lineHeight}em`)
|
| 418 |
+
.text(lineText);
|
| 419 |
+
});
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
// ---------- 11. 绘制图例 (原来的第10步) ----------
|
| 423 |
+
// 创建临时文本元素计算文本宽度
|
| 424 |
+
const legendItems = legendData.map(item => ({
|
| 425 |
+
label: item.key,
|
| 426 |
+
color: item.color,
|
| 427 |
+
}));
|
| 428 |
+
chartUtils.legend.centered(svg, legendItems, {}, width / 2, 30, {
|
| 429 |
+
markerShape: "rect",
|
| 430 |
+
markerSize: 15,
|
| 431 |
+
labelGap: 5,
|
| 432 |
+
itemGap: legendSpacing,
|
| 433 |
+
fontSize: 12,
|
| 434 |
+
fontFamily: typography.label.font_family,
|
| 435 |
+
fontWeight: typography.label.font_weight,
|
| 436 |
+
textColor: colorResolver.text({ fallback: "#333333" }).value,
|
| 437 |
+
markerRadius: variables.has_rounded_corners ? 2 : 0,
|
| 438 |
+
});
|
| 439 |
+
|
| 440 |
+
// 绘制条形图和标签
|
| 441 |
+
xValues.forEach(xValue => {
|
| 442 |
+
// 获取当前x类别的数据
|
| 443 |
+
const xData = useData.filter(d => d[xField] === xValue);
|
| 444 |
+
|
| 445 |
+
// 获取左侧柱子的数据
|
| 446 |
+
const leftBarData = xData.find(d => d[groupField] === leftBarGroup);
|
| 447 |
+
|
| 448 |
+
// 获取右侧柱子的数据
|
| 449 |
+
const rightBarData = xData.find(d => d[groupField] === rightBarGroup);
|
| 450 |
+
|
| 451 |
+
// 计算左侧柱子的位置和高度
|
| 452 |
+
const leftBarX = xScale(xValue);
|
| 453 |
+
let leftBarY, leftBarHeight, leftValue;
|
| 454 |
+
|
| 455 |
+
if (leftBarData) {
|
| 456 |
+
leftValue = leftBarData[yField];
|
| 457 |
+
leftBarHeight = innerHeight - yScale(leftValue);
|
| 458 |
+
leftBarY = yScale(leftValue);
|
| 459 |
+
|
| 460 |
+
// 绘制左侧柱子
|
| 461 |
+
chart.append("rect")
|
| 462 |
+
.attr("class", "bar left-bar")
|
| 463 |
+
.attr("x", leftBarX)
|
| 464 |
+
.attr("y", leftBarY)
|
| 465 |
+
.attr("width", barWidth)
|
| 466 |
+
.attr("height", leftBarHeight)
|
| 467 |
+
.attr("fill", "url(#gradient-left)")
|
| 468 |
+
.attr("rx", barWidth/2)
|
| 469 |
+
.attr("ry", barWidth/2)
|
| 470 |
+
.attr("stroke", variables.has_stroke ? "#555" : "none")
|
| 471 |
+
.attr("stroke-width", variables.has_stroke ? 1 : 0)
|
| 472 |
+
.style("filter", variables.has_shadow ? "url(#shadow)" : "none");
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
// 绘制左侧柱子顶部的标签
|
| 477 |
+
// 首先计算标签文本宽度并调整字体大小
|
| 478 |
+
const leftValueText = chartUtils.format.scaledText(leftValue, { unit: yUnit });
|
| 479 |
+
let leftLabelFontSize = valueFontSize; // 默认使用之前计算的字体大小
|
| 480 |
+
|
| 481 |
+
let leftTextWidth = chartUtils.text.measure(chart, leftValueText, {
|
| 482 |
+
fontFamily: typography.label.font_family,
|
| 483 |
+
fontSize: `${leftLabelFontSize}px`,
|
| 484 |
+
fontWeight: "bold"
|
| 485 |
+
}).width;
|
| 486 |
+
// 最大允许宽度为柱子宽度的1.1倍
|
| 487 |
+
const maxLabelWidth = barWidth * 1.1;
|
| 488 |
+
|
| 489 |
+
// 如果文本宽度超过允许值,动态缩小字体
|
| 490 |
+
if (leftTextWidth > maxLabelWidth) {
|
| 491 |
+
// 按比例计算新字体大小
|
| 492 |
+
leftLabelFontSize = Math.max(4, leftLabelFontSize * (maxLabelWidth / leftTextWidth));
|
| 493 |
+
leftTextWidth = chartUtils.text.measure(chart, leftValueText, {
|
| 494 |
+
fontFamily: typography.label.font_family,
|
| 495 |
+
fontSize: `${leftLabelFontSize}px`,
|
| 496 |
+
fontWeight: "bold"
|
| 497 |
+
}).width;
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
// 使用调整后的字体大小绘制标签
|
| 501 |
+
chart.append("text")
|
| 502 |
+
.attr("class", "bar-label")
|
| 503 |
+
.attr("x", leftBarX + barWidth / 2)
|
| 504 |
+
.attr("y", leftBarY - 5)
|
| 505 |
+
.attr("text-anchor", "middle")
|
| 506 |
+
.style("font-family", typography.label.font_family)
|
| 507 |
+
.style("font-size", `${leftLabelFontSize}px`) // 使用动态调整的字体大小
|
| 508 |
+
.style("font-weight", "bold")
|
| 509 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 510 |
+
.text(leftValueText);
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
// 计算右侧柱子的位置和高度
|
| 514 |
+
// 使用groupScale正确计算右侧柱子的位置,确保两个柱子之间有间距
|
| 515 |
+
const rightBarX = leftBarX + barWidth + xScale.bandwidth() * 0.1; // 添加额外间距为柱子宽度的10%
|
| 516 |
+
let rightBarY, rightBarHeight, rightValue;
|
| 517 |
+
|
| 518 |
+
if (rightBarData) {
|
| 519 |
+
rightValue = rightBarData[yField];
|
| 520 |
+
rightBarHeight = innerHeight - yScale(rightValue);
|
| 521 |
+
rightBarY = yScale(rightValue);
|
| 522 |
+
|
| 523 |
+
// 绘制右侧柱子
|
| 524 |
+
chart.append("rect")
|
| 525 |
+
.attr("class", "bar right-bar")
|
| 526 |
+
.attr("x", rightBarX)
|
| 527 |
+
.attr("y", rightBarY)
|
| 528 |
+
.attr("width", barWidth)
|
| 529 |
+
.attr("height", rightBarHeight)
|
| 530 |
+
.attr("fill", "url(#gradient-right)")
|
| 531 |
+
.attr("rx", variables.has_rounded_corners ? 4 : 0)
|
| 532 |
+
.attr("ry", variables.has_rounded_corners ? 4 : 0)
|
| 533 |
+
.attr("stroke", variables.has_stroke ? "#555" : "none")
|
| 534 |
+
.attr("stroke-width", variables.has_stroke ? 1 : 0)
|
| 535 |
+
.style("filter", variables.has_shadow ? "url(#shadow)" : "none");
|
| 536 |
+
|
| 537 |
+
// 绘制右侧柱子顶部的标签
|
| 538 |
+
// 首先计算标签文本宽度并调整字体大小
|
| 539 |
+
const rightValueText = chartUtils.format.scaledText(rightValue, { unit: yUnit });
|
| 540 |
+
let rightLabelFontSize = valueFontSize; // 默认使用之前计算的字体大小
|
| 541 |
+
|
| 542 |
+
let rightTextWidth = chartUtils.text.measure(chart, rightValueText, {
|
| 543 |
+
fontFamily: typography.label.font_family,
|
| 544 |
+
fontSize: `${rightLabelFontSize}px`,
|
| 545 |
+
fontWeight: "bold"
|
| 546 |
+
}).width;
|
| 547 |
+
// 最大允许宽度为柱子宽度的1.1倍
|
| 548 |
+
const rightMaxLabelWidth = barWidth * 1.1;
|
| 549 |
+
|
| 550 |
+
// 如果文本宽度超过允许值,动态缩小字体
|
| 551 |
+
if (rightTextWidth > rightMaxLabelWidth) {
|
| 552 |
+
// 按比例计算新字体大小
|
| 553 |
+
rightLabelFontSize = Math.max(4, rightLabelFontSize * (rightMaxLabelWidth / rightTextWidth));
|
| 554 |
+
rightTextWidth = chartUtils.text.measure(chart, rightValueText, {
|
| 555 |
+
fontFamily: typography.label.font_family,
|
| 556 |
+
fontSize: `${rightLabelFontSize}px`,
|
| 557 |
+
fontWeight: "bold"
|
| 558 |
+
}).width;
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
// 使用调整后的字体大小绘制标签
|
| 562 |
+
chart.append("text")
|
| 563 |
+
.attr("class", "bar-label")
|
| 564 |
+
.attr("x", rightBarX + barWidth / 2)
|
| 565 |
+
.attr("y", rightBarY - 5)
|
| 566 |
+
.attr("text-anchor", "middle")
|
| 567 |
+
.style("font-family", typography.label.font_family)
|
| 568 |
+
.style("font-size", `${rightLabelFontSize}px`) // 使用动态调整的字体大小
|
| 569 |
+
.style("font-weight", "bold")
|
| 570 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 571 |
+
.text(rightValueText);
|
| 572 |
+
}
|
| 573 |
+
});
|
| 574 |
+
|
| 575 |
+
// *** BEGIN ADDITION: Draw icons below X-axis labels ***
|
| 576 |
+
const iconSize = 30; // Define icon size
|
| 577 |
+
const iconY = innerHeight + 45; // Y position below labels (innerHeight + label space + padding + half icon size)
|
| 578 |
+
|
| 579 |
+
xValues.forEach(xValue => {
|
| 580 |
+
if (images.field && images.field[xValue]) {
|
| 581 |
+
const iconX = xScale(xValue) + xScale.bandwidth() / 2; // Center X under the label
|
| 582 |
+
|
| 583 |
+
chart.append("image")
|
| 584 |
+
.attr("class", "category-icon-below") // New class name
|
| 585 |
+
.attr("x", iconX - iconSize / 2)
|
| 586 |
+
.attr("y", iconY - iconSize / 2)
|
| 587 |
+
.attr("width", iconSize)
|
| 588 |
+
.attr("height", iconSize)
|
| 589 |
+
.attr("preserveAspectRatio", "xMidYMid meet")
|
| 590 |
+
.attr("xlink:href", images.field[xValue]);
|
| 591 |
+
}
|
| 592 |
+
});
|
| 593 |
+
// *** END ADDITION ***
|
| 594 |
+
|
| 595 |
+
// 返回SVG节点
|
| 596 |
+
return svg.node();
|
| 597 |
+
}
|
modules/chart_engine/template/d3-js/bar_chart/vertical_group_bar_chart_12.js
ADDED
|
@@ -0,0 +1,857 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Vertical Group Bar Chart",
|
| 5 |
+
"chart_name": "vertical_group_bar_chart_12",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y", "group"],
|
| 8 |
+
"required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
|
| 9 |
+
"required_fields_range": [[2, 20], [0, "inf"], [2, 2]],
|
| 10 |
+
"required_fields_icons": ["x"],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": ["group"],
|
| 13 |
+
"required_other_colors": [],
|
| 14 |
+
"supported_effects": ["shadow", "radius_corner", "gradient", "stroke", "spacing"],
|
| 15 |
+
"min_height": 400,
|
| 16 |
+
"min_width": 400,
|
| 17 |
+
"background": "no",
|
| 18 |
+
"icon_mark": "none",
|
| 19 |
+
"icon_label": "none",
|
| 20 |
+
"has_x_axis": "no",
|
| 21 |
+
"has_y_axis": "no"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
// 垂直分组条形图实现 - 带有图标和数值标签
|
| 27 |
+
function makeChart(containerSelector, data) {
|
| 28 |
+
const colorResolver = chartUtils.color.resolver(data);
|
| 29 |
+
// ---------- 1. 数据准备 ----------
|
| 30 |
+
// 提取数据和配置
|
| 31 |
+
const jsonData = data;
|
| 32 |
+
const chartData = jsonData.data.data || [];
|
| 33 |
+
const variables = jsonData.variables || {};
|
| 34 |
+
const typography = jsonData.typography || {
|
| 35 |
+
title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
|
| 36 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 37 |
+
description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 38 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 39 |
+
};
|
| 40 |
+
const colors = jsonData.colors || {
|
| 41 |
+
text_color: "#333333",
|
| 42 |
+
field: {},
|
| 43 |
+
other: {
|
| 44 |
+
primary: "#4682B4" // 默认主色调
|
| 45 |
+
}
|
| 46 |
+
};
|
| 47 |
+
const images = jsonData.images || { field: {}, other: {} };
|
| 48 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 49 |
+
|
| 50 |
+
// 如果不存在,添加副标题字段
|
| 51 |
+
typography.subtitle = typography.subtitle || typography.description;
|
| 52 |
+
|
| 53 |
+
// 设置视觉效果变量
|
| 54 |
+
variables.has_rounded_corners = variables.has_rounded_corners || false;
|
| 55 |
+
variables.has_shadow = variables.has_shadow || false;
|
| 56 |
+
variables.has_gradient = variables.has_gradient || false;
|
| 57 |
+
variables.has_stroke = variables.has_stroke || false;
|
| 58 |
+
variables.has_spacing = variables.has_spacing || false;
|
| 59 |
+
|
| 60 |
+
const textColor = colorResolver.text({ fallback: colors.text_color || "#33413a" }).value;
|
| 61 |
+
const backgroundColor = colors.background_color || "#fbf6ea";
|
| 62 |
+
const titleGreen = colors.other?.secondary || "#0f4a2b";
|
| 63 |
+
const defaultColors = ["#b94728", "#d9a722"];
|
| 64 |
+
|
| 65 |
+
const groupColorFor = (group, index) => {
|
| 66 |
+
const direct = colors.field && colors.field[group];
|
| 67 |
+
if (direct) return direct;
|
| 68 |
+
const available = colors.available_colors || [];
|
| 69 |
+
return available[index % available.length] || defaultColors[index % defaultColors.length];
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
| 73 |
+
const d3Color = (value, fallback) => d3.color(value) || d3.color(fallback);
|
| 74 |
+
const relativeLuminance = (value, fallback = "#ffffff") => {
|
| 75 |
+
const c = d3Color(value, fallback);
|
| 76 |
+
if (!c) return 1;
|
| 77 |
+
const channel = (v) => {
|
| 78 |
+
const s = v / 255;
|
| 79 |
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
| 80 |
+
};
|
| 81 |
+
return 0.2126 * channel(c.r) + 0.7152 * channel(c.g) + 0.0722 * channel(c.b);
|
| 82 |
+
};
|
| 83 |
+
const lighten = (value, amount, fallback) => {
|
| 84 |
+
const c = d3Color(value, fallback);
|
| 85 |
+
return c ? c.brighter(amount).formatHex() : fallback;
|
| 86 |
+
};
|
| 87 |
+
const darken = (value, amount, fallback) => {
|
| 88 |
+
const c = d3Color(value, fallback);
|
| 89 |
+
return c ? c.darker(amount).formatHex() : fallback;
|
| 90 |
+
};
|
| 91 |
+
const slug = (value) => String(value).replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-|-$/g, "") || "group";
|
| 92 |
+
|
| 93 |
+
// 清除容器
|
| 94 |
+
d3.select(containerSelector).html("");
|
| 95 |
+
|
| 96 |
+
// ---------- 2. 尺寸和布局设置 ----------
|
| 97 |
+
// 设置图表尺寸和边距
|
| 98 |
+
const width = variables.width || 800;
|
| 99 |
+
const height = variables.height || 500;
|
| 100 |
+
|
| 101 |
+
// 边距:上,右,下,左
|
| 102 |
+
const margin = {
|
| 103 |
+
top: 112,
|
| 104 |
+
right: 18,
|
| 105 |
+
bottom: 88,
|
| 106 |
+
left: 62
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
// 计算实际绘图区域大小
|
| 110 |
+
const innerWidth = width - margin.left - margin.right;
|
| 111 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 112 |
+
|
| 113 |
+
// ---------- 3. 提取字段名称和单位 ----------
|
| 114 |
+
let xField, yField, groupField;
|
| 115 |
+
let xUnit = "", yUnit = "";
|
| 116 |
+
|
| 117 |
+
// 安全提取字段名称
|
| 118 |
+
const xChannel = chartUtils.schema.channel(jsonData, "x");
|
| 119 |
+
const yChannel = chartUtils.schema.channel(jsonData, "y");
|
| 120 |
+
const groupChannel = chartUtils.schema.channel(jsonData, "group");
|
| 121 |
+
|
| 122 |
+
xField = xChannel.key;
|
| 123 |
+
yField = yChannel.key;
|
| 124 |
+
groupField = groupChannel.key;
|
| 125 |
+
|
| 126 |
+
// 获取字段单位
|
| 127 |
+
xUnit = xChannel.unit;
|
| 128 |
+
yUnit = yChannel.unit;
|
| 129 |
+
|
| 130 |
+
// ---------- 4. 数据处理 ----------
|
| 131 |
+
// 使用提供的数据
|
| 132 |
+
let useData = chartData;
|
| 133 |
+
|
| 134 |
+
// 获取x轴和分组的唯一值;time-like fields render chronologically by default.
|
| 135 |
+
const rawXValues = [...new Set(useData.map(d => d[xField]))];
|
| 136 |
+
const timeLikeName = /(year|date|month|time|season|semester|quarter)/i.test(xField || "");
|
| 137 |
+
const monthIndex = {
|
| 138 |
+
jan: 0, january: 0, feb: 1, february: 1, mar: 2, march: 2, apr: 3, april: 3,
|
| 139 |
+
may: 4, jun: 5, june: 5, jul: 6, july: 6, aug: 7, august: 7, sep: 8, sept: 8,
|
| 140 |
+
september: 8, oct: 9, october: 9, nov: 10, november: 10, dec: 11, december: 11
|
| 141 |
+
};
|
| 142 |
+
const timeKey = (value) => {
|
| 143 |
+
const s = String(value).trim();
|
| 144 |
+
if (/^\d{4}$/.test(s)) return +s;
|
| 145 |
+
const lower = s.toLowerCase();
|
| 146 |
+
if (monthIndex[lower] !== undefined) return monthIndex[lower];
|
| 147 |
+
const parsed = Date.parse(s);
|
| 148 |
+
return Number.isNaN(parsed) ? null : parsed;
|
| 149 |
+
};
|
| 150 |
+
const xValues = timeLikeName && rawXValues.every(d => timeKey(d) !== null)
|
| 151 |
+
? rawXValues.slice().sort((a, b) => timeKey(a) - timeKey(b))
|
| 152 |
+
: rawXValues;
|
| 153 |
+
let groupValues = [...new Set(useData.map(d => d[groupField]))];
|
| 154 |
+
|
| 155 |
+
// 如果组的数量不符合要求,给出警告
|
| 156 |
+
if (groupValues.length !== 2) {
|
| 157 |
+
console.warn("此图表需要恰好2个组字段");
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// 第一个组是左侧柱子,第二个组是右侧柱子
|
| 161 |
+
const leftBarGroup = groupValues[0];
|
| 162 |
+
const rightBarGroup = groupValues[1];
|
| 163 |
+
const groups = [leftBarGroup, rightBarGroup];
|
| 164 |
+
const groupColors = groups.map((group, index) => groupColorFor(group, index));
|
| 165 |
+
const legendTextColor = relativeLuminance(backgroundColor) < 0.35 ? "#f8f4e8" : textColor;
|
| 166 |
+
const legendData = groups.map((group, index) => ({
|
| 167 |
+
key: group,
|
| 168 |
+
color: groupColors[index],
|
| 169 |
+
unit: yUnit
|
| 170 |
+
}));
|
| 171 |
+
const legendSpacing = 24;
|
| 172 |
+
|
| 173 |
+
// ---------- 5. 创建SVG容器 ----------
|
| 174 |
+
const svg = d3.select(containerSelector)
|
| 175 |
+
.append("svg")
|
| 176 |
+
.attr("width", "100%")
|
| 177 |
+
.attr("height", height)
|
| 178 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 179 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 180 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 181 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 182 |
+
|
| 183 |
+
// ---------- 6. 创建视觉效果 ----------
|
| 184 |
+
const defs = svg.append("defs");
|
| 185 |
+
|
| 186 |
+
defs.append("linearGradient")
|
| 187 |
+
.attr("id", "vg12-background")
|
| 188 |
+
.attr("x1", "0%")
|
| 189 |
+
.attr("y1", "0%")
|
| 190 |
+
.attr("x2", "0%")
|
| 191 |
+
.attr("y2", "100%")
|
| 192 |
+
.selectAll("stop")
|
| 193 |
+
.data([
|
| 194 |
+
{ offset: "0%", color: lighten(backgroundColor, 0.55, "#fffaf0") },
|
| 195 |
+
{ offset: "100%", color: backgroundColor }
|
| 196 |
+
])
|
| 197 |
+
.enter()
|
| 198 |
+
.append("stop")
|
| 199 |
+
.attr("offset", d => d.offset)
|
| 200 |
+
.attr("stop-color", d => d.color);
|
| 201 |
+
|
| 202 |
+
svg.append("rect")
|
| 203 |
+
.attr("class", "chart-background")
|
| 204 |
+
.attr("x", 0)
|
| 205 |
+
.attr("y", 0)
|
| 206 |
+
.attr("width", width)
|
| 207 |
+
.attr("height", height)
|
| 208 |
+
.attr("fill", "none");
|
| 209 |
+
|
| 210 |
+
// 如果需要,创建阴影滤镜
|
| 211 |
+
if (variables.has_shadow) {
|
| 212 |
+
const filter = defs.append("filter")
|
| 213 |
+
.attr("id", "shadow")
|
| 214 |
+
.attr("filterUnits", "userSpaceOnUse")
|
| 215 |
+
.attr("width", "200%")
|
| 216 |
+
.attr("height", "200%");
|
| 217 |
+
|
| 218 |
+
filter.append("feGaussianBlur")
|
| 219 |
+
.attr("in", "SourceAlpha")
|
| 220 |
+
.attr("stdDeviation", 3);
|
| 221 |
+
|
| 222 |
+
filter.append("feOffset")
|
| 223 |
+
.attr("dx", 2)
|
| 224 |
+
.attr("dy", 2)
|
| 225 |
+
.attr("result", "offsetblur");
|
| 226 |
+
|
| 227 |
+
const feMerge = filter.append("feMerge");
|
| 228 |
+
feMerge.append("feMergeNode");
|
| 229 |
+
feMerge.append("feMergeNode").attr("in", "SourceGraphic");
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
groups.forEach((group, i) => {
|
| 233 |
+
const groupColor = groupColors[i];
|
| 234 |
+
const gradient = defs.append("linearGradient")
|
| 235 |
+
.attr("id", `bar-gradient-${i}`)
|
| 236 |
+
.attr("x1", "0%")
|
| 237 |
+
.attr("y1", "0%")
|
| 238 |
+
.attr("x2", "0%")
|
| 239 |
+
.attr("y2", "100%");
|
| 240 |
+
|
| 241 |
+
gradient.append("stop")
|
| 242 |
+
.attr("offset", "0%")
|
| 243 |
+
.attr("stop-color", lighten(groupColor, 0.45, defaultColors[i]))
|
| 244 |
+
.attr("stop-opacity", 1);
|
| 245 |
+
gradient.append("stop")
|
| 246 |
+
.attr("offset", "100%")
|
| 247 |
+
.attr("stop-color", darken(groupColor, 0.2, defaultColors[i]))
|
| 248 |
+
.attr("stop-opacity", 1);
|
| 249 |
+
});
|
| 250 |
+
|
| 251 |
+
const panel = {
|
| 252 |
+
x: 10,
|
| 253 |
+
y: 74,
|
| 254 |
+
width: width - 20,
|
| 255 |
+
height: height - 88,
|
| 256 |
+
radius: 18
|
| 257 |
+
};
|
| 258 |
+
|
| 259 |
+
svg.append("rect")
|
| 260 |
+
.attr("class", "chart-panel")
|
| 261 |
+
.attr("x", panel.x)
|
| 262 |
+
.attr("y", panel.y)
|
| 263 |
+
.attr("width", panel.width)
|
| 264 |
+
.attr("height", panel.height)
|
| 265 |
+
.attr("rx", panel.radius)
|
| 266 |
+
.attr("ry", panel.radius)
|
| 267 |
+
.attr("fill", "#fffdf6")
|
| 268 |
+
.attr("stroke", "#e2d7bf")
|
| 269 |
+
.attr("stroke-width", 1.5)
|
| 270 |
+
.style("filter", variables.has_shadow ? "url(#shadow)" : "drop-shadow(0 2px 4px rgba(86, 68, 38, 0.14))");
|
| 271 |
+
|
| 272 |
+
// ---------- 7. 创建图表区域 ----------
|
| 273 |
+
const chart = svg.append("g")
|
| 274 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 275 |
+
|
| 276 |
+
// ---------- 8. 创建比例尺 ----------
|
| 277 |
+
// X比例尺(分类)用于主分类
|
| 278 |
+
const xScale = d3.scaleBand()
|
| 279 |
+
.domain(xValues)
|
| 280 |
+
.range([0, innerWidth])
|
| 281 |
+
.padding(0.10);
|
| 282 |
+
|
| 283 |
+
// 分组比例尺,用于每个类别内的细分
|
| 284 |
+
const groupScale = d3.scaleBand()
|
| 285 |
+
.domain([0, 1]) // 只有两个柱子,左侧和右侧
|
| 286 |
+
.range([0, xScale.bandwidth()])
|
| 287 |
+
.padding(0.2); // 同一维度柱子之间的间隙,增加间隔
|
| 288 |
+
|
| 289 |
+
// Y比例尺(数值)- 直接使用最大值映射到可用高度
|
| 290 |
+
const dataMax = d3.max(useData, d => +d[yField]) || 100;
|
| 291 |
+
const yScale = d3.scaleLinear()
|
| 292 |
+
.domain([0, dataMax * 1.12])
|
| 293 |
+
.nice(5)
|
| 294 |
+
.range([innerHeight, 0]);
|
| 295 |
+
|
| 296 |
+
// ---------- 9. 文本宽度计算和字体大小调整 (移动到这里,在绘制坐标轴之前) ----------
|
| 297 |
+
// 添加数值格式化函数
|
| 298 |
+
|
| 299 |
+
// 创建格式化函数
|
| 300 |
+
const formattedValue = (value) => {
|
| 301 |
+
const numeric = +value || 0;
|
| 302 |
+
if (Math.abs(numeric) < 10 && Math.abs(numeric - Math.round(numeric)) > 1e-6) {
|
| 303 |
+
return d3.format(".2f")(numeric).replace(/\.?0+$/, "");
|
| 304 |
+
}
|
| 305 |
+
return d3.format(",.0f")(numeric);
|
| 306 |
+
};
|
| 307 |
+
|
| 308 |
+
// 数值标签的可用宽度 = bar宽度 + 间距
|
| 309 |
+
const barWidth = Math.min(30, Math.max(18, xScale.bandwidth() * 0.40));
|
| 310 |
+
const pairGap = Math.max(4, xScale.bandwidth() * 0.07);
|
| 311 |
+
const valueSpacing = xScale.bandwidth() * 0.1;
|
| 312 |
+
const valueLabelAvailableWidth = Math.max(barWidth * 2.2, xScale.bandwidth() * 0.62);
|
| 313 |
+
|
| 314 |
+
// 维度标签的可用宽度 = 两个bar宽度 + bar间距
|
| 315 |
+
const dimensionLabelAvailableWidth = xScale.bandwidth();
|
| 316 |
+
|
| 317 |
+
// 计算最长的数值标签和维度标签
|
| 318 |
+
let maxValueLabelWidth = 0;
|
| 319 |
+
let maxDimensionLabelWidth = 0;
|
| 320 |
+
|
| 321 |
+
// 计算所有数值标签的最大宽度
|
| 322 |
+
useData.forEach(d => {
|
| 323 |
+
const textWidth = chartUtils.text.measure(svg, formattedValue(+d[yField]), {
|
| 324 |
+
fontFamily: typography.label.font_family,
|
| 325 |
+
fontSize: typography.label.font_size,
|
| 326 |
+
fontWeight: "bold"
|
| 327 |
+
}).width;
|
| 328 |
+
maxValueLabelWidth = Math.max(maxValueLabelWidth, textWidth);
|
| 329 |
+
});
|
| 330 |
+
|
| 331 |
+
// 计算所有维度标签的最大宽度
|
| 332 |
+
xValues.forEach(xValue => {
|
| 333 |
+
const textWidth = chartUtils.text.measure(svg, xValue, {
|
| 334 |
+
fontFamily: typography.label.font_family,
|
| 335 |
+
fontSize: typography.label.font_size,
|
| 336 |
+
fontWeight: "bold"
|
| 337 |
+
}).width;
|
| 338 |
+
maxDimensionLabelWidth = Math.max(maxDimensionLabelWidth, textWidth);
|
| 339 |
+
});
|
| 340 |
+
|
| 341 |
+
// 计算需要的字体缩放比例
|
| 342 |
+
const valueFontScale = Math.min(1, valueLabelAvailableWidth / maxValueLabelWidth);
|
| 343 |
+
const dimensionFontScale = Math.min(1, dimensionLabelAvailableWidth / maxDimensionLabelWidth);
|
| 344 |
+
|
| 345 |
+
// 计算实际使用的字体大小
|
| 346 |
+
const valueFontSize = Math.max(10, Math.min(12, parseInt(typography.label.font_size) * valueFontScale));
|
| 347 |
+
const dimensionFontSize = Math.max(8, parseInt(typography.label.font_size) * dimensionFontScale);
|
| 348 |
+
|
| 349 |
+
// 计算动态文本大小的函数
|
| 350 |
+
const calculateFontSize = (text, maxWidth, baseSize = 12) => {
|
| 351 |
+
// 估算每个字符的平均宽度 (假设为baseSize的60%)
|
| 352 |
+
const avgCharWidth = baseSize * 0.6;
|
| 353 |
+
// 计算文本的估计宽度
|
| 354 |
+
const textWidth = text.length * avgCharWidth;
|
| 355 |
+
// 如果文本宽度小于最大宽度,返回基础大小
|
| 356 |
+
if (textWidth < maxWidth) {
|
| 357 |
+
return baseSize;
|
| 358 |
+
}
|
| 359 |
+
// 否则,按比例缩小字体大小
|
| 360 |
+
return Math.max(10, Math.floor(baseSize * (maxWidth / textWidth)));
|
| 361 |
+
};
|
| 362 |
+
|
| 363 |
+
const roundedTopBarPath = (x, y, w, h, r) => {
|
| 364 |
+
const radius = clamp(Math.min(r, w / 2, h), 0, 4);
|
| 365 |
+
if (h <= 0) return "";
|
| 366 |
+
return [
|
| 367 |
+
`M${x},${y + h}`,
|
| 368 |
+
`L${x},${y + radius}`,
|
| 369 |
+
`Q${x},${y} ${x + radius},${y}`,
|
| 370 |
+
`L${x + w - radius},${y}`,
|
| 371 |
+
`Q${x + w},${y} ${x + w},${y + radius}`,
|
| 372 |
+
`L${x + w},${y + h}`,
|
| 373 |
+
"Z"
|
| 374 |
+
].join(" ");
|
| 375 |
+
};
|
| 376 |
+
|
| 377 |
+
const yTicks = yScale.ticks(5);
|
| 378 |
+
chart.append("g")
|
| 379 |
+
.attr("class", "y-grid")
|
| 380 |
+
.selectAll("line")
|
| 381 |
+
.data(yTicks)
|
| 382 |
+
.enter()
|
| 383 |
+
.append("line")
|
| 384 |
+
.attr("x1", 0)
|
| 385 |
+
.attr("x2", innerWidth)
|
| 386 |
+
.attr("y1", d => yScale(d))
|
| 387 |
+
.attr("y2", d => yScale(d))
|
| 388 |
+
.attr("stroke", "#dfd3bd")
|
| 389 |
+
.attr("stroke-width", 1)
|
| 390 |
+
.attr("stroke-dasharray", "3 4");
|
| 391 |
+
|
| 392 |
+
chart.append("line")
|
| 393 |
+
.attr("class", "y-axis-line")
|
| 394 |
+
.attr("x1", 0)
|
| 395 |
+
.attr("x2", 0)
|
| 396 |
+
.attr("y1", 0)
|
| 397 |
+
.attr("y2", innerHeight)
|
| 398 |
+
.attr("stroke", "#3e4846")
|
| 399 |
+
.attr("stroke-width", 1.2);
|
| 400 |
+
|
| 401 |
+
chart.append("line")
|
| 402 |
+
.attr("class", "baseline")
|
| 403 |
+
.attr("x1", 0)
|
| 404 |
+
.attr("x2", innerWidth)
|
| 405 |
+
.attr("y1", innerHeight)
|
| 406 |
+
.attr("y2", innerHeight)
|
| 407 |
+
.attr("stroke", "#3e4846")
|
| 408 |
+
.attr("stroke-width", 1.2);
|
| 409 |
+
|
| 410 |
+
chart.append("g")
|
| 411 |
+
.attr("class", "y-tick-labels")
|
| 412 |
+
.selectAll("text")
|
| 413 |
+
.data(yTicks)
|
| 414 |
+
.enter()
|
| 415 |
+
.append("text")
|
| 416 |
+
.attr("x", -10)
|
| 417 |
+
.attr("y", d => yScale(d) + 4)
|
| 418 |
+
.attr("text-anchor", "end")
|
| 419 |
+
.style("font-family", typography.label.font_family)
|
| 420 |
+
.style("font-size", "12px")
|
| 421 |
+
.style("font-weight", 500)
|
| 422 |
+
.style("fill", textColor)
|
| 423 |
+
.text(d => chartUtils.format.autoText(d));
|
| 424 |
+
|
| 425 |
+
chart.append("text")
|
| 426 |
+
.attr("class", "y-axis-title")
|
| 427 |
+
.attr("transform", `translate(${-52}, ${innerHeight / 2}) rotate(-90)`)
|
| 428 |
+
.attr("text-anchor", "middle")
|
| 429 |
+
.style("font-family", typography.label.font_family)
|
| 430 |
+
.style("font-size", "14px")
|
| 431 |
+
.style("font-weight", 600)
|
| 432 |
+
.style("fill", textColor)
|
| 433 |
+
.text(`${yField}${yUnit ? ` (${yUnit})` : ""}`);
|
| 434 |
+
|
| 435 |
+
// ---------- 10. 创建坐标轴 (原来的第9步) ----------
|
| 436 |
+
// 创建x轴组,用于添加刻度标签
|
| 437 |
+
const xAxisGroup = chart.append("g")
|
| 438 |
+
.attr("class", "x-axis")
|
| 439 |
+
.attr("transform", `translate(0, ${innerHeight + 50})`);
|
| 440 |
+
|
| 441 |
+
// 第一步:找出最长的标签并计算统一的字体大小
|
| 442 |
+
let maxLabelLength = 0;
|
| 443 |
+
const allLabels = xValues.map(d => d.toString());
|
| 444 |
+
|
| 445 |
+
// 找出最长的标签
|
| 446 |
+
const longestLabel = allLabels.reduce((a, b) => a.length > b.length ? a : b, "");
|
| 447 |
+
|
| 448 |
+
// 使用最长标签计算合适的统一字体大小
|
| 449 |
+
const labelMaxWidth = xScale.bandwidth()*1.3;
|
| 450 |
+
const uniformFontSize = calculateFontSize(longestLabel, labelMaxWidth, parseInt(typography.label.font_size));
|
| 451 |
+
|
| 452 |
+
// 绘制x轴标签
|
| 453 |
+
xAxisGroup.selectAll(".x-label")
|
| 454 |
+
.data(xValues)
|
| 455 |
+
.enter()
|
| 456 |
+
.append("text")
|
| 457 |
+
.attr("class", "x-label")
|
| 458 |
+
.attr("x", d => xScale(d) + xScale.bandwidth() / 2)
|
| 459 |
+
.attr("y", 12)
|
| 460 |
+
.attr("text-anchor", "middle")
|
| 461 |
+
.style("font-family", typography.label.font_family)
|
| 462 |
+
.style("font-size", `${uniformFontSize}px`) // 应用统一的字体大小
|
| 463 |
+
.style("font-weight", typography.label.font_weight)
|
| 464 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 465 |
+
.text(d => d)
|
| 466 |
+
.each(function(d) {
|
| 467 |
+
const text = d3.select(this);
|
| 468 |
+
const labelText = d.toString();
|
| 469 |
+
const labelWidth = chartUtils.text.measure(null, labelText, {
|
| 470 |
+
fontFamily: typography.label.font_family,
|
| 471 |
+
fontSize: uniformFontSize,
|
| 472 |
+
fontWeight: typography.label.font_weight
|
| 473 |
+
}).width;
|
| 474 |
+
|
| 475 |
+
// 检查使用统一字体大小后,文本是否仍然超过可用宽度
|
| 476 |
+
if (labelWidth > labelMaxWidth) {
|
| 477 |
+
// 如果仍然太长,应用文本换行
|
| 478 |
+
wrapText(text, labelText, labelMaxWidth, 1.1);
|
| 479 |
+
}
|
| 480 |
+
});
|
| 481 |
+
|
| 482 |
+
// 文本换行助手函数
|
| 483 |
+
function wrapText(text, str, width, lineHeight) {
|
| 484 |
+
const words = str.split(/\s+/).reverse();
|
| 485 |
+
let word;
|
| 486 |
+
let line = [];
|
| 487 |
+
let lineNumber = 0;
|
| 488 |
+
const y = text.attr("y");
|
| 489 |
+
const dy = parseFloat(text.attr("dy") || 0);
|
| 490 |
+
|
| 491 |
+
// 先清空文本
|
| 492 |
+
text.text(null);
|
| 493 |
+
|
| 494 |
+
// 处理文本
|
| 495 |
+
let tspans = [];
|
| 496 |
+
|
| 497 |
+
// 如果没有空格可分割,按字符分割
|
| 498 |
+
if (words.length <= 1) {
|
| 499 |
+
const chars = str.split('');
|
| 500 |
+
let currentLine = '';
|
| 501 |
+
|
| 502 |
+
for (let i = 0; i < chars.length; i++) {
|
| 503 |
+
currentLine += chars[i];
|
| 504 |
+
|
| 505 |
+
// 测量候选文本宽度
|
| 506 |
+
const isOverflow = chartUtils.text.measure(null, currentLine, {
|
| 507 |
+
fontFamily: text.style("font-family"),
|
| 508 |
+
fontSize: parseFloat(text.style("font-size")),
|
| 509 |
+
fontWeight: text.style("font-weight")
|
| 510 |
+
}).width > width;
|
| 511 |
+
|
| 512 |
+
if (isOverflow && currentLine.length > 1) {
|
| 513 |
+
// 当前行过长,回退一个字符并换行
|
| 514 |
+
currentLine = currentLine.slice(0, -1);
|
| 515 |
+
|
| 516 |
+
// 添加到tspans数组
|
| 517 |
+
tspans.push(currentLine);
|
| 518 |
+
|
| 519 |
+
// 重新开始下一行
|
| 520 |
+
currentLine = chars[i];
|
| 521 |
+
lineNumber++;
|
| 522 |
+
}
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
// 添加最后一行
|
| 526 |
+
if (currentLine.length > 0) {
|
| 527 |
+
tspans.push(currentLine);
|
| 528 |
+
}
|
| 529 |
+
} else {
|
| 530 |
+
// 处理有空格的文本
|
| 531 |
+
let currentLine = [];
|
| 532 |
+
|
| 533 |
+
while (word = words.pop()) {
|
| 534 |
+
currentLine.push(word);
|
| 535 |
+
|
| 536 |
+
// 测量候选文本宽度
|
| 537 |
+
const isOverflow = chartUtils.text.measure(null, currentLine.join(" "), {
|
| 538 |
+
fontFamily: text.style("font-family"),
|
| 539 |
+
fontSize: parseFloat(text.style("font-size")),
|
| 540 |
+
fontWeight: text.style("font-weight")
|
| 541 |
+
}).width > width;
|
| 542 |
+
|
| 543 |
+
if (isOverflow && currentLine.length > 1) {
|
| 544 |
+
// 回退一个词
|
| 545 |
+
currentLine.pop();
|
| 546 |
+
|
| 547 |
+
// 添加到tspans数组
|
| 548 |
+
tspans.push(currentLine.join(" "));
|
| 549 |
+
|
| 550 |
+
// 重新开始下一行
|
| 551 |
+
currentLine = [word];
|
| 552 |
+
lineNumber++;
|
| 553 |
+
}
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
// 添加最后一行
|
| 557 |
+
if (currentLine.length > 0) {
|
| 558 |
+
tspans.push(currentLine.join(" "));
|
| 559 |
+
}
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
// 计算总行数
|
| 563 |
+
const totalLines = tspans.length;
|
| 564 |
+
|
| 565 |
+
// 计算垂直居中的起始位置
|
| 566 |
+
// 对于单行文本,y位置保持不变
|
| 567 |
+
// 对于多行文本,需要向上偏移以保持垂直居中
|
| 568 |
+
let startY = y;
|
| 569 |
+
if (totalLines > 1) {
|
| 570 |
+
// 向上偏移半行距离 * (总行数-1)
|
| 571 |
+
startY = parseFloat(y) - (lineHeight * (totalLines - 1) / 2);
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
// 创建所有行的tspan元素
|
| 575 |
+
tspans.forEach((lineText, i) => {
|
| 576 |
+
text.append("tspan")
|
| 577 |
+
.attr("x", text.attr("x"))
|
| 578 |
+
.attr("y", startY)
|
| 579 |
+
.attr("dy", `${i * lineHeight}em`)
|
| 580 |
+
.text(lineText);
|
| 581 |
+
});
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
// ---------- 11. 绘制图例 (selected-reference standalone pill) ----------
|
| 585 |
+
const legendPill = {
|
| 586 |
+
width: Math.min(width - 120, Math.max(300, groups.join("").length * 9 + 215)),
|
| 587 |
+
height: 50,
|
| 588 |
+
y: 18
|
| 589 |
+
};
|
| 590 |
+
legendPill.x = (width - legendPill.width) / 2;
|
| 591 |
+
const legend = svg.append("g")
|
| 592 |
+
.attr("class", "legend reference-legend-pill")
|
| 593 |
+
.attr("data-reference-relationship", "independent-centered-between-subtitle-and-chart-card")
|
| 594 |
+
.attr("transform", `translate(${legendPill.x}, ${legendPill.y})`);
|
| 595 |
+
|
| 596 |
+
legend.append("rect")
|
| 597 |
+
.attr("class", "legend-pill-bg")
|
| 598 |
+
.attr("x", 0)
|
| 599 |
+
.attr("y", 0)
|
| 600 |
+
.attr("width", legendPill.width)
|
| 601 |
+
.attr("height", legendPill.height)
|
| 602 |
+
.attr("rx", 10)
|
| 603 |
+
.attr("ry", 10)
|
| 604 |
+
.attr("fill", "#fffdf7")
|
| 605 |
+
.attr("stroke", "#e2d7bf")
|
| 606 |
+
.attr("stroke-width", 1.2)
|
| 607 |
+
.style("filter", variables.has_shadow ? "url(#shadow)" : "drop-shadow(0 1.5px 3px rgba(86, 68, 38, 0.14))");
|
| 608 |
+
|
| 609 |
+
const drawLegendMarker = (g, cx, cy, color, index) => {
|
| 610 |
+
const marker = g.append("g")
|
| 611 |
+
.attr("class", "reserved-asset-slot legend-marker-slot")
|
| 612 |
+
.attr("data-asset-slot", "legend-marker")
|
| 613 |
+
.attr("data-asset-anchor", groups[index])
|
| 614 |
+
.attr("data-asset-bbox", `${cx - 13},${cy - 16},26,32`)
|
| 615 |
+
.attr("data-collision-avoid", "legend-label,legend-pill")
|
| 616 |
+
.attr("data-no-embedded-raster", "true")
|
| 617 |
+
.attr("data-no-copied-reference-art", "true");
|
| 618 |
+
marker.append("path")
|
| 619 |
+
.attr("d", `M${cx},${cy - 16} C${cx + 11},${cy - 2} ${cx + 12},${cy + 12} ${cx},${cy + 15} C${cx - 12},${cy + 12} ${cx - 11},${cy - 2} ${cx},${cy - 16} Z`)
|
| 620 |
+
.attr("fill", color)
|
| 621 |
+
.attr("stroke", darken(color, 0.35, color))
|
| 622 |
+
.attr("stroke-width", 0.8);
|
| 623 |
+
marker.append("path")
|
| 624 |
+
.attr("d", `M${cx + 5},${cy + 2} C${cx + 5},${cy + 8} ${cx + 1},${cy + 11} ${cx - 5},${cy + 10}`)
|
| 625 |
+
.attr("fill", "none")
|
| 626 |
+
.attr("stroke", "#ffffff")
|
| 627 |
+
.attr("stroke-width", 2)
|
| 628 |
+
.attr("stroke-linecap", "round")
|
| 629 |
+
.attr("opacity", 0.9);
|
| 630 |
+
};
|
| 631 |
+
|
| 632 |
+
const legendItemWidth = legendPill.width / 2;
|
| 633 |
+
legendData.forEach((itemData, index) => {
|
| 634 |
+
const itemX = index * legendItemWidth + 26;
|
| 635 |
+
const itemGroup = legend.append("g")
|
| 636 |
+
.attr("class", "legend-item")
|
| 637 |
+
.attr("transform", `translate(${itemX}, 0)`);
|
| 638 |
+
drawLegendMarker(itemGroup, 0, 25, itemData.color, index);
|
| 639 |
+
itemGroup.append("text")
|
| 640 |
+
.attr("x", 22)
|
| 641 |
+
.attr("y", 22)
|
| 642 |
+
.attr("dominant-baseline", "middle")
|
| 643 |
+
.attr("fill", textColor)
|
| 644 |
+
.style("font-family", typography.label.font_family)
|
| 645 |
+
.style("font-size", "13px")
|
| 646 |
+
.style("font-weight", 700)
|
| 647 |
+
.text(itemData.key);
|
| 648 |
+
itemGroup.append("text")
|
| 649 |
+
.attr("x", 22)
|
| 650 |
+
.attr("y", 38)
|
| 651 |
+
.attr("dominant-baseline", "middle")
|
| 652 |
+
.attr("fill", textColor)
|
| 653 |
+
.style("font-family", typography.label.font_family)
|
| 654 |
+
.style("font-size", "10px")
|
| 655 |
+
.style("font-weight", 500)
|
| 656 |
+
.text(yUnit ? `(${yUnit})` : "");
|
| 657 |
+
});
|
| 658 |
+
|
| 659 |
+
const categoryTotals = xValues.map(xValue => {
|
| 660 |
+
const rows = useData.filter(d => d[xField] === xValue);
|
| 661 |
+
return {
|
| 662 |
+
xValue,
|
| 663 |
+
total: d3.sum(rows, d => +d[yField] || 0),
|
| 664 |
+
firstValue: +(rows.find(d => d[groupField] === leftBarGroup)?.[yField] || 0),
|
| 665 |
+
complete: groups.every(group => rows.some(d => d[groupField] === group))
|
| 666 |
+
};
|
| 667 |
+
});
|
| 668 |
+
const highlightDatum = categoryTotals
|
| 669 |
+
.filter(d => d.complete)
|
| 670 |
+
.sort((a, b) => d3.descending(a.firstValue || a.total, b.firstValue || b.total))[0];
|
| 671 |
+
if (highlightDatum && xScale.bandwidth() > 35) {
|
| 672 |
+
const hx = xScale(highlightDatum.xValue) + xScale.bandwidth() * 0.07;
|
| 673 |
+
const hw = xScale.bandwidth() * 0.86;
|
| 674 |
+
chart.insert("rect", ".y-grid")
|
| 675 |
+
.attr("class", "max-category-highlight")
|
| 676 |
+
.attr("x", hx)
|
| 677 |
+
.attr("y", -10)
|
| 678 |
+
.attr("width", hw)
|
| 679 |
+
.attr("height", innerHeight + 12)
|
| 680 |
+
.attr("rx", 10)
|
| 681 |
+
.attr("ry", 10)
|
| 682 |
+
.attr("fill", "#dcebd3")
|
| 683 |
+
.attr("stroke", "#9fbe8e")
|
| 684 |
+
.attr("stroke-width", 1)
|
| 685 |
+
.attr("opacity", 0.45);
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
// 绘制条形图和标签
|
| 689 |
+
xValues.forEach(xValue => {
|
| 690 |
+
// 获取当前x类别的数据
|
| 691 |
+
const xData = useData.filter(d => d[xField] === xValue);
|
| 692 |
+
|
| 693 |
+
// 获取左侧柱子的数据
|
| 694 |
+
const leftBarData = xData.find(d => d[groupField] === leftBarGroup);
|
| 695 |
+
|
| 696 |
+
// 获取右侧柱子的数据
|
| 697 |
+
const rightBarData = xData.find(d => d[groupField] === rightBarGroup);
|
| 698 |
+
|
| 699 |
+
// 计算左侧柱子的位置和高度
|
| 700 |
+
const clusterCenter = xScale(xValue) + xScale.bandwidth() / 2;
|
| 701 |
+
const leftBarX = clusterCenter - barWidth - pairGap / 2;
|
| 702 |
+
let leftBarY, leftBarHeight, leftValue;
|
| 703 |
+
|
| 704 |
+
// --- Variables to track label/bar positions for icon placement ---
|
| 705 |
+
let minBarTopY = innerHeight;
|
| 706 |
+
let minExternalLabelTopY = innerHeight;
|
| 707 |
+
let leftLabelIsOutside = false; // Track label position
|
| 708 |
+
let rightLabelIsOutside = false;
|
| 709 |
+
// -------------------------------------------------------------
|
| 710 |
+
|
| 711 |
+
if (leftBarData) {
|
| 712 |
+
leftValue = leftBarData[yField];
|
| 713 |
+
leftBarHeight = innerHeight - yScale(leftValue);
|
| 714 |
+
leftBarY = yScale(leftValue);
|
| 715 |
+
minBarTopY = Math.min(minBarTopY, leftBarY);
|
| 716 |
+
|
| 717 |
+
// 绘制左侧柱子
|
| 718 |
+
chart.append("path")
|
| 719 |
+
.attr("class", "bar left-bar")
|
| 720 |
+
.attr("data-group", leftBarGroup)
|
| 721 |
+
.attr("data-x", xValue)
|
| 722 |
+
.attr("data-y", leftValue)
|
| 723 |
+
.attr("d", roundedTopBarPath(leftBarX, leftBarY, barWidth, leftBarHeight, Math.min(3, barWidth * 0.16)))
|
| 724 |
+
.attr("fill", "url(#bar-gradient-0)")
|
| 725 |
+
.attr("stroke", variables.has_stroke ? "#555" : "none")
|
| 726 |
+
.attr("stroke-width", variables.has_stroke ? 1 : 0)
|
| 727 |
+
.style("filter", variables.has_shadow ? "url(#shadow)" : "none");
|
| 728 |
+
|
| 729 |
+
// 绘制左侧柱子顶部的标签
|
| 730 |
+
const leftValueText = formattedValue(leftValue);
|
| 731 |
+
let leftLabelFontSize = valueFontSize; // 默认使用之前计算的字体大小
|
| 732 |
+
let leftTextWidth = chartUtils.text.measure(chart, leftValueText, {
|
| 733 |
+
fontFamily: typography.label.font_family,
|
| 734 |
+
fontSize: `${leftLabelFontSize}px`,
|
| 735 |
+
fontWeight: "bold"
|
| 736 |
+
}).width;
|
| 737 |
+
const maxLabelWidth = Math.max(barWidth * 2.2, xScale.bandwidth() * 0.62);
|
| 738 |
+
if (leftTextWidth > maxLabelWidth) {
|
| 739 |
+
leftLabelFontSize = Math.max(8, leftLabelFontSize * (maxLabelWidth / leftTextWidth));
|
| 740 |
+
}
|
| 741 |
+
// *** 修改: 判断标签是否能放入 Bar 内 ***
|
| 742 |
+
const labelHeightRequired = leftLabelFontSize + 10; // 字体高度 + 上下 padding
|
| 743 |
+
let leftLabelY, leftLabelColor, leftDominantBaseline = "auto"; // Default baseline
|
| 744 |
+
|
| 745 |
+
leftLabelY = Math.max(14, leftBarY - 7);
|
| 746 |
+
leftLabelColor = groupColors[0];
|
| 747 |
+
leftDominantBaseline = "auto";
|
| 748 |
+
minExternalLabelTopY = Math.min(minExternalLabelTopY, leftLabelY - leftLabelFontSize);
|
| 749 |
+
leftLabelIsOutside = true;
|
| 750 |
+
|
| 751 |
+
chart.append("text")
|
| 752 |
+
.attr("class", "bar-label left-label")
|
| 753 |
+
.attr("x", leftBarX + barWidth / 2)
|
| 754 |
+
.attr("y", leftLabelY) // 使用计算的 Y
|
| 755 |
+
.attr("dominant-baseline", leftDominantBaseline) // 设置 baseline
|
| 756 |
+
.attr("text-anchor", "middle")
|
| 757 |
+
.style("font-family", typography.label.font_family)
|
| 758 |
+
.style("font-size", `${leftLabelFontSize}px`)
|
| 759 |
+
.style("font-weight", "bold")
|
| 760 |
+
.style("fill", leftLabelColor) // 使用计算的颜色
|
| 761 |
+
.text(leftValueText);
|
| 762 |
+
} else {
|
| 763 |
+
leftValue = 0;
|
| 764 |
+
leftBarHeight = 0;
|
| 765 |
+
leftBarY = innerHeight;
|
| 766 |
+
}
|
| 767 |
+
|
| 768 |
+
const rightBarX = leftBarX + barWidth + pairGap;
|
| 769 |
+
let rightBarY, rightBarHeight, rightValue;
|
| 770 |
+
|
| 771 |
+
if (rightBarData) {
|
| 772 |
+
rightValue = rightBarData[yField];
|
| 773 |
+
rightBarHeight = innerHeight - yScale(rightValue);
|
| 774 |
+
rightBarY = yScale(rightValue);
|
| 775 |
+
minBarTopY = Math.min(minBarTopY, rightBarY);
|
| 776 |
+
|
| 777 |
+
// 绘制右侧柱子
|
| 778 |
+
chart.append("path")
|
| 779 |
+
.attr("class", "bar right-bar")
|
| 780 |
+
.attr("data-group", rightBarGroup)
|
| 781 |
+
.attr("data-x", xValue)
|
| 782 |
+
.attr("data-y", rightValue)
|
| 783 |
+
.attr("d", roundedTopBarPath(rightBarX, rightBarY, barWidth, rightBarHeight, Math.min(3, barWidth * 0.16)))
|
| 784 |
+
.attr("fill", "url(#bar-gradient-1)")
|
| 785 |
+
.attr("stroke", variables.has_stroke ? "#555" : "none")
|
| 786 |
+
.attr("stroke-width", variables.has_stroke ? 1 : 0)
|
| 787 |
+
.style("filter", variables.has_shadow ? "url(#shadow)" : "none");
|
| 788 |
+
|
| 789 |
+
// 绘制右侧柱子顶部的标签
|
| 790 |
+
const rightValueText = formattedValue(rightValue);
|
| 791 |
+
let rightLabelFontSize = valueFontSize; // 默认使用之前计算的字体大小
|
| 792 |
+
let rightTextWidth = chartUtils.text.measure(chart, rightValueText, {
|
| 793 |
+
fontFamily: typography.label.font_family,
|
| 794 |
+
fontSize: `${rightLabelFontSize}px`,
|
| 795 |
+
fontWeight: "bold"
|
| 796 |
+
}).width;
|
| 797 |
+
const rightMaxLabelWidth = Math.max(barWidth * 2.2, xScale.bandwidth() * 0.62);
|
| 798 |
+
if (rightTextWidth > rightMaxLabelWidth) {
|
| 799 |
+
rightLabelFontSize = Math.max(8, rightLabelFontSize * (rightMaxLabelWidth / rightTextWidth));
|
| 800 |
+
}
|
| 801 |
+
// *** 修改: 判断标签是否能放入 Bar 内 ***
|
| 802 |
+
const rightLabelHeightRequired = rightLabelFontSize + 10; // 字体高度 + 上下 padding
|
| 803 |
+
let rightLabelY, rightLabelColor, rightDominantBaseline = "auto";
|
| 804 |
+
|
| 805 |
+
rightLabelY = Math.max(14, rightBarY - 7);
|
| 806 |
+
rightLabelColor = groupColors[1];
|
| 807 |
+
rightDominantBaseline = "auto";
|
| 808 |
+
minExternalLabelTopY = Math.min(minExternalLabelTopY, rightLabelY - rightLabelFontSize);
|
| 809 |
+
rightLabelIsOutside = true;
|
| 810 |
+
|
| 811 |
+
chart.append("text")
|
| 812 |
+
.attr("class", "bar-label right-label")
|
| 813 |
+
.attr("x", rightBarX + barWidth / 2)
|
| 814 |
+
.attr("y", rightLabelY) // 使用计算的 Y
|
| 815 |
+
.attr("dominant-baseline", rightDominantBaseline) // 设置 baseline
|
| 816 |
+
.attr("text-anchor", "middle")
|
| 817 |
+
.style("font-family", typography.label.font_family)
|
| 818 |
+
.style("font-size", `${rightLabelFontSize}px`)
|
| 819 |
+
.style("font-weight", "bold")
|
| 820 |
+
.style("fill", rightLabelColor) // 使用计算的颜色
|
| 821 |
+
.text(rightValueText);
|
| 822 |
+
} else {
|
| 823 |
+
rightValue = 0;
|
| 824 |
+
rightBarHeight = 0;
|
| 825 |
+
rightBarY = innerHeight;
|
| 826 |
+
}
|
| 827 |
+
|
| 828 |
+
const iconSize = clamp(xScale.bandwidth() * 0.64, 25, 38);
|
| 829 |
+
const iconX = xScale(xValue) + xScale.bandwidth() / 2;
|
| 830 |
+
const iconY = innerHeight + 28;
|
| 831 |
+
const slot = chart.append("g")
|
| 832 |
+
.attr("class", "reserved-asset-slot category-icon-slot")
|
| 833 |
+
.attr("data-asset-slot", "category-icon")
|
| 834 |
+
.attr("data-asset-anchor", String(xValue))
|
| 835 |
+
.attr("data-asset-bbox", `${iconX - iconSize / 2},${iconY - iconSize / 2},${iconSize},${iconSize}`)
|
| 836 |
+
.attr("data-collision-avoid", "bars,value-labels,x-labels,panel-border")
|
| 837 |
+
.attr("data-no-embedded-raster", "true")
|
| 838 |
+
.attr("data-no-copied-reference-art", "true");
|
| 839 |
+
slot.append("circle")
|
| 840 |
+
.attr("cx", iconX)
|
| 841 |
+
.attr("cy", iconY)
|
| 842 |
+
.attr("r", iconSize / 2)
|
| 843 |
+
.attr("fill", lighten(backgroundColor, 0.4, "#fff6d9"))
|
| 844 |
+
.attr("stroke", "#cdbb91")
|
| 845 |
+
.attr("stroke-width", 1.2);
|
| 846 |
+
slot.append("circle")
|
| 847 |
+
.attr("cx", iconX)
|
| 848 |
+
.attr("cy", iconY)
|
| 849 |
+
.attr("r", Math.max(2.4, iconSize / 10))
|
| 850 |
+
.attr("fill", titleGreen)
|
| 851 |
+
.attr("opacity", 0.45);
|
| 852 |
+
|
| 853 |
+
}); // --- END xValues.forEach ---
|
| 854 |
+
|
| 855 |
+
// 返回SVG节点
|
| 856 |
+
return svg.node();
|
| 857 |
+
}
|
modules/chart_engine/template/d3-js/bar_chart/vertical_group_bar_chart_13.js
ADDED
|
@@ -0,0 +1,1198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Vertical Group Bar Chart",
|
| 5 |
+
"chart_name": "vertical_group_bar_chart_13",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y", "group"],
|
| 8 |
+
"required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
|
| 9 |
+
"required_fields_range": [[2, 20], [0, "inf"], [2, 2]],
|
| 10 |
+
"required_fields_icons": [],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": ["group"],
|
| 13 |
+
"required_other_colors": ["primary"],
|
| 14 |
+
"supported_effects": [],
|
| 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 |
+
// 垂直分组条形图实现 - 手绘风格
|
| 28 |
+
function makeChart(containerSelector, data) {
|
| 29 |
+
const colorResolver = chartUtils.color.resolver(data);
|
| 30 |
+
// ---------- 1. 数据准备 ----------
|
| 31 |
+
const jsonData = data;
|
| 32 |
+
const chartData = jsonData.data.data || [];
|
| 33 |
+
const variables = jsonData.variables || {};
|
| 34 |
+
const typography = jsonData.typography || {
|
| 35 |
+
title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
|
| 36 |
+
// Use a sketch-like font for labels
|
| 37 |
+
label: { font_family: "'Comic Sans MS', cursive", font_size: "12px", font_weight: "normal" },
|
| 38 |
+
description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 39 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 40 |
+
};
|
| 41 |
+
const colors = jsonData.colors || {
|
| 42 |
+
text_color: "#333333",
|
| 43 |
+
field: {},
|
| 44 |
+
other: {
|
| 45 |
+
primary: "#4682B4" // Default primary color (less relevant now)
|
| 46 |
+
}
|
| 47 |
+
};
|
| 48 |
+
const images = jsonData.images || { field: {}, other: {} };
|
| 49 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 50 |
+
|
| 51 |
+
typography.subtitle = typography.subtitle || typography.description;
|
| 52 |
+
|
| 53 |
+
// Sketch style doesn't usually use rounded corners or gradients
|
| 54 |
+
variables.has_rounded_corners = false; // Force disable rounded corners for sketch style
|
| 55 |
+
variables.has_gradient = false; // Force disable gradient
|
| 56 |
+
variables.has_shadow = variables.has_shadow || false;
|
| 57 |
+
variables.has_stroke = variables.has_stroke || false;
|
| 58 |
+
variables.has_spacing = variables.has_spacing || false;
|
| 59 |
+
|
| 60 |
+
d3.select(containerSelector).html("");
|
| 61 |
+
|
| 62 |
+
// ---------- 2. 尺寸和布局设置 ----------
|
| 63 |
+
const width = variables.width || 800;
|
| 64 |
+
const height = variables.height || 500;
|
| 65 |
+
const margin = {
|
| 66 |
+
top: 100, right: 30, bottom: 100, // Increased bottom margin for sketch labels + icons
|
| 67 |
+
left: 30
|
| 68 |
+
};
|
| 69 |
+
const innerWidth = width - margin.left - margin.right;
|
| 70 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 71 |
+
|
| 72 |
+
// ---------- 3. 提取字段名称和单位 ----------
|
| 73 |
+
let xField, yField, groupField;
|
| 74 |
+
let xUnit = "", yUnit = "";
|
| 75 |
+
const xChannel = chartUtils.schema.channel(jsonData, "x");
|
| 76 |
+
const yChannel = chartUtils.schema.channel(jsonData, "y");
|
| 77 |
+
const groupChannel = chartUtils.schema.channel(jsonData, "group");
|
| 78 |
+
xField = xChannel.key;
|
| 79 |
+
yField = yChannel.key;
|
| 80 |
+
groupField = groupChannel.key;
|
| 81 |
+
xUnit = xChannel.unit;
|
| 82 |
+
yUnit = yChannel.unit;
|
| 83 |
+
|
| 84 |
+
// ---------- 4. 数据处理 ----------
|
| 85 |
+
let useData = chartData;
|
| 86 |
+
const xValues = [...new Set(useData.map(d => d[xField]))];
|
| 87 |
+
let groupValues = [...new Set(useData.map(d => d[groupField]))];
|
| 88 |
+
if (groupValues.length !== 2) {
|
| 89 |
+
console.warn("此图表需要恰好2个组字段");
|
| 90 |
+
// Handle error or default behavior if groups != 2
|
| 91 |
+
if (groupValues.length < 2) return; // Stop if not enough groups
|
| 92 |
+
groupValues = groupValues.slice(0, 2); // Take first two if more than 2
|
| 93 |
+
}
|
| 94 |
+
const leftBarGroup = groupValues[0];
|
| 95 |
+
const rightBarGroup = groupValues[1];
|
| 96 |
+
|
| 97 |
+
// 获取分组颜色,若未在colors.field中定义,则使用默认颜色
|
| 98 |
+
const leftGroupColor = colorResolver.field(leftBarGroup, 0, { fallback: "#4269d0" }).value;
|
| 99 |
+
const rightGroupColor = colorResolver.field(rightBarGroup, 0, { fallback: "#ff725c" }).value;
|
| 100 |
+
|
| 101 |
+
return renderReferenceAligned();
|
| 102 |
+
|
| 103 |
+
function renderReferenceAligned() {
|
| 104 |
+
d3.select(containerSelector).html("");
|
| 105 |
+
|
| 106 |
+
const width = variables.width || 800;
|
| 107 |
+
const height = variables.height || 500;
|
| 108 |
+
const textColor = colorResolver.text({ fallback: colors.text_color || "#26352c" }).value;
|
| 109 |
+
const bgColor = "#fbf8ea";
|
| 110 |
+
const darkGreen = "#0f4b27";
|
| 111 |
+
const redAccent = "#d83a20";
|
| 112 |
+
const orange = "#f4b52a";
|
| 113 |
+
const orangeDark = "#d95a06";
|
| 114 |
+
const green = "#4d9a46";
|
| 115 |
+
const greenDark = "#0d6a3f";
|
| 116 |
+
|
| 117 |
+
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
| 118 |
+
const d3Color = (value, fallback) => d3.color(value) || d3.color(fallback);
|
| 119 |
+
const lighten = (value, amount, fallback) => {
|
| 120 |
+
const c = d3Color(value, fallback);
|
| 121 |
+
return c ? c.brighter(amount).formatHex() : fallback;
|
| 122 |
+
};
|
| 123 |
+
const darken = (value, amount, fallback) => {
|
| 124 |
+
const c = d3Color(value, fallback);
|
| 125 |
+
return c ? c.darker(amount).formatHex() : fallback;
|
| 126 |
+
};
|
| 127 |
+
const slug = (value) => String(value).replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-|-$/g, "") || "group";
|
| 128 |
+
const fontFamily = typography.label?.font_family || "Arial, sans-serif";
|
| 129 |
+
const titleFamily = typography.title?.font_family || "Arial, sans-serif";
|
| 130 |
+
|
| 131 |
+
const measure = (text, size, weight = 500, family = fontFamily) =>
|
| 132 |
+
chartUtils.text.measure(null, String(text), {
|
| 133 |
+
fontFamily: family,
|
| 134 |
+
fontSize: size,
|
| 135 |
+
fontWeight: weight
|
| 136 |
+
}).width;
|
| 137 |
+
const fitFont = (text, maxWidth, base, min, weight = 700, family = titleFamily) => {
|
| 138 |
+
const w = measure(text, base, weight, family);
|
| 139 |
+
return Math.max(min, Math.min(base, base * (maxWidth / Math.max(w, 1))));
|
| 140 |
+
};
|
| 141 |
+
const ellipsizeText = (text, maxWidth, size, weight = 800, family = fontFamily) => {
|
| 142 |
+
const value = String(text || "");
|
| 143 |
+
if (measure(value, size, weight, family) <= maxWidth) return value;
|
| 144 |
+
let trimmed = value;
|
| 145 |
+
while (trimmed.length > 1 && measure(`${trimmed}...`, size, weight, family) > maxWidth) {
|
| 146 |
+
trimmed = trimmed.slice(0, -1).trimEnd();
|
| 147 |
+
}
|
| 148 |
+
return trimmed.length > 1 ? `${trimmed}...` : value.slice(0, 1);
|
| 149 |
+
};
|
| 150 |
+
const wrapLines = (text, maxWidth, size, weight = 500, family = fontFamily, maxLines = 3) => {
|
| 151 |
+
const words = String(text || "").split(/\s+/).filter(Boolean);
|
| 152 |
+
if (!words.length) return [];
|
| 153 |
+
const lines = [];
|
| 154 |
+
let line = "";
|
| 155 |
+
words.forEach(word => {
|
| 156 |
+
const next = line ? `${line} ${word}` : word;
|
| 157 |
+
if (measure(next, size, weight, family) <= maxWidth || !line) {
|
| 158 |
+
line = next;
|
| 159 |
+
} else {
|
| 160 |
+
lines.push(line);
|
| 161 |
+
line = word;
|
| 162 |
+
}
|
| 163 |
+
});
|
| 164 |
+
if (line) lines.push(line);
|
| 165 |
+
if (lines.length <= maxLines) return lines;
|
| 166 |
+
const kept = lines.slice(0, maxLines);
|
| 167 |
+
while (measure(`${kept[maxLines - 1]}...`, size, weight, family) > maxWidth && kept[maxLines - 1].length > 4) {
|
| 168 |
+
kept[maxLines - 1] = kept[maxLines - 1].slice(0, -1);
|
| 169 |
+
}
|
| 170 |
+
kept[maxLines - 1] = `${kept[maxLines - 1]}...`;
|
| 171 |
+
return kept;
|
| 172 |
+
};
|
| 173 |
+
const addTextLines = (parent, lines, x, y, opts = {}) => {
|
| 174 |
+
const size = opts.size || 12;
|
| 175 |
+
const lineHeight = opts.lineHeight || size * 1.18;
|
| 176 |
+
const g = parent.append("g").attr("class", opts.class || "wrapped-text");
|
| 177 |
+
lines.forEach((line, index) => {
|
| 178 |
+
g.append("text")
|
| 179 |
+
.attr("x", x)
|
| 180 |
+
.attr("y", y + index * lineHeight)
|
| 181 |
+
.attr("text-anchor", opts.anchor || "start")
|
| 182 |
+
.attr("dominant-baseline", opts.baseline || "hanging")
|
| 183 |
+
.style("font-family", opts.family || fontFamily)
|
| 184 |
+
.style("font-size", `${size}px`)
|
| 185 |
+
.style("font-weight", opts.weight || 500)
|
| 186 |
+
.style("fill", opts.fill || textColor)
|
| 187 |
+
.text(line);
|
| 188 |
+
});
|
| 189 |
+
return g;
|
| 190 |
+
};
|
| 191 |
+
const formatValue = (value) => {
|
| 192 |
+
const numeric = +value || 0;
|
| 193 |
+
if (Math.abs(numeric) < 1) return d3.format(".3f")(numeric).replace(/\.?0+$/, "");
|
| 194 |
+
if (Math.abs(numeric) < 10) return d3.format(".2f")(numeric).replace(/\.?0+$/, "");
|
| 195 |
+
return d3.format(",.0f")(numeric);
|
| 196 |
+
};
|
| 197 |
+
const prettifyMetricLabel = (value) => String(value || "")
|
| 198 |
+
.replace(/[_-]+/g, " ")
|
| 199 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 200 |
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
| 201 |
+
.replace(/([a-zA-Z])(\d)/g, "$1 $2")
|
| 202 |
+
.replace(/(\d)([a-zA-Z])/g, "$1 $2")
|
| 203 |
+
.replace(/\s+/g, " ")
|
| 204 |
+
.trim();
|
| 205 |
+
const smallValueAxis = (maxValue) => {
|
| 206 |
+
if (maxValue <= 0.12) return { max: Math.ceil(maxValue / 0.02) * 0.02, step: 0.02 };
|
| 207 |
+
if (maxValue <= 0.4) return { max: Math.ceil(maxValue / 0.05) * 0.05, step: 0.05 };
|
| 208 |
+
if (maxValue <= 1) return { max: Math.ceil(maxValue / 0.1) * 0.1, step: 0.1 };
|
| 209 |
+
return null;
|
| 210 |
+
};
|
| 211 |
+
const roundedTopBarPath = (x, y, w, h, r) => {
|
| 212 |
+
const radius = clamp(Math.min(r, w / 2, h), 0, 3);
|
| 213 |
+
if (h <= 0) return "";
|
| 214 |
+
return [
|
| 215 |
+
`M${x},${y + h}`,
|
| 216 |
+
`L${x},${y + radius}`,
|
| 217 |
+
`Q${x},${y} ${x + radius},${y}`,
|
| 218 |
+
`L${x + w - radius},${y}`,
|
| 219 |
+
`Q${x + w},${y} ${x + w},${y + radius}`,
|
| 220 |
+
`L${x + w},${y + h}`,
|
| 221 |
+
"Z"
|
| 222 |
+
].join(" ");
|
| 223 |
+
};
|
| 224 |
+
|
| 225 |
+
const rawXValues = [...new Set(useData.map(d => d[xField]))];
|
| 226 |
+
const timeLikeName = /(year|date|month|time|season|semester|quarter)/i.test(xField || "");
|
| 227 |
+
const monthIndex = {
|
| 228 |
+
jan: 0, january: 0, feb: 1, february: 1, mar: 2, march: 2, apr: 3, april: 3,
|
| 229 |
+
may: 4, jun: 5, june: 5, jul: 6, july: 6, aug: 7, august: 7, sep: 8, sept: 8,
|
| 230 |
+
september: 8, oct: 9, october: 9, nov: 10, november: 10, dec: 11, december: 11
|
| 231 |
+
};
|
| 232 |
+
const timeKey = (value) => {
|
| 233 |
+
const s = String(value).trim();
|
| 234 |
+
if (/^\d{4}$/.test(s)) return +s;
|
| 235 |
+
const lower = s.toLowerCase();
|
| 236 |
+
if (monthIndex[lower] !== undefined) return monthIndex[lower];
|
| 237 |
+
const parsed = Date.parse(s);
|
| 238 |
+
return Number.isNaN(parsed) ? null : parsed;
|
| 239 |
+
};
|
| 240 |
+
const xValues = timeLikeName && rawXValues.every(d => timeKey(d) !== null)
|
| 241 |
+
? rawXValues.slice().sort((a, b) => timeKey(a) - timeKey(b))
|
| 242 |
+
: rawXValues;
|
| 243 |
+
const groups = groupValues.slice(0, 2);
|
| 244 |
+
const averages = groups.map(group => ({
|
| 245 |
+
group,
|
| 246 |
+
average: d3.mean(useData.filter(d => d[groupField] === group), d => +d[yField] || 0) || 0
|
| 247 |
+
}));
|
| 248 |
+
const lowGroup = (averages[0].average <= averages[1].average ? averages[0] : averages[1]).group;
|
| 249 |
+
const highGroup = (lowGroup === groups[0] ? groups[1] : groups[0]);
|
| 250 |
+
const legendGroups = [lowGroup, highGroup];
|
| 251 |
+
const barGroups = [highGroup, lowGroup];
|
| 252 |
+
const colorFor = (group) => {
|
| 253 |
+
const name = String(group).toLowerCase();
|
| 254 |
+
if (name.includes("organic") || group === lowGroup) return group === lowGroup ? green : colorResolver.field(group, 0, { fallback: green }).value;
|
| 255 |
+
if (name.includes("conventional") || group === highGroup) return group === highGroup ? orange : colorResolver.field(group, 1, { fallback: orange }).value;
|
| 256 |
+
return colorResolver.field(group, groups.indexOf(group), { fallback: groups.indexOf(group) === 0 ? green : orange }).value;
|
| 257 |
+
};
|
| 258 |
+
const groupColor = new Map(legendGroups.map(group => [group, colorFor(group)]));
|
| 259 |
+
groupColor.set(highGroup, colorFor(highGroup));
|
| 260 |
+
groupColor.set(lowGroup, colorFor(lowGroup));
|
| 261 |
+
|
| 262 |
+
const svg = d3.select(containerSelector)
|
| 263 |
+
.append("svg")
|
| 264 |
+
.attr("width", "100%")
|
| 265 |
+
.attr("height", height)
|
| 266 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 267 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 268 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 269 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 270 |
+
|
| 271 |
+
const defs = svg.append("defs");
|
| 272 |
+
defs.append("linearGradient")
|
| 273 |
+
.attr("id", "vg13-background")
|
| 274 |
+
.attr("x1", "0%")
|
| 275 |
+
.attr("y1", "0%")
|
| 276 |
+
.attr("x2", "100%")
|
| 277 |
+
.attr("y2", "100%")
|
| 278 |
+
.selectAll("stop")
|
| 279 |
+
.data([
|
| 280 |
+
{ offset: "0%", color: "#fffdf4" },
|
| 281 |
+
{ offset: "68%", color: bgColor },
|
| 282 |
+
{ offset: "100%", color: "#edf6d7" }
|
| 283 |
+
])
|
| 284 |
+
.enter()
|
| 285 |
+
.append("stop")
|
| 286 |
+
.attr("offset", d => d.offset)
|
| 287 |
+
.attr("stop-color", d => d.color);
|
| 288 |
+
svg.append("rect")
|
| 289 |
+
.attr("class", "chart-background")
|
| 290 |
+
.attr("x", 0)
|
| 291 |
+
.attr("y", 0)
|
| 292 |
+
.attr("width", width)
|
| 293 |
+
.attr("height", height)
|
| 294 |
+
.attr("fill", "url(#vg13-background)");
|
| 295 |
+
|
| 296 |
+
legendGroups.forEach((group, index) => {
|
| 297 |
+
const color = groupColor.get(group) || (index ? orange : green);
|
| 298 |
+
const pattern = defs.append("pattern")
|
| 299 |
+
.attr("id", `vg13-hatch-${slug(group)}`)
|
| 300 |
+
.attr("width", 7)
|
| 301 |
+
.attr("height", 7)
|
| 302 |
+
.attr("patternUnits", "userSpaceOnUse")
|
| 303 |
+
.attr("patternTransform", "rotate(45)");
|
| 304 |
+
pattern.append("rect").attr("width", 7).attr("height", 7).attr("fill", color);
|
| 305 |
+
pattern.append("path")
|
| 306 |
+
.attr("d", "M -1,2 L 8,2 M -1,5 L 8,5")
|
| 307 |
+
.attr("stroke", "#fff7cf")
|
| 308 |
+
.attr("stroke-width", 1.1)
|
| 309 |
+
.attr("opacity", 0.9);
|
| 310 |
+
});
|
| 311 |
+
|
| 312 |
+
const rangeText = (() => {
|
| 313 |
+
const parsed = xValues.map(timeKey).filter(d => d !== null);
|
| 314 |
+
if (parsed.length === xValues.length && xValues.length > 1 && /^\d{4}$/.test(String(xValues[0]))) {
|
| 315 |
+
return ` from ${xValues[0]} to ${xValues[xValues.length - 1]}`;
|
| 316 |
+
}
|
| 317 |
+
return "";
|
| 318 |
+
})();
|
| 319 |
+
const primaryPesticide = /pesticide\s*residue/i.test(prettifyMetricLabel(yField))
|
| 320 |
+
&& legendGroups.some(group => /organic/i.test(String(group)))
|
| 321 |
+
&& legendGroups.some(group => /conventional/i.test(String(group)));
|
| 322 |
+
const titleSpec = primaryPesticide
|
| 323 |
+
? {
|
| 324 |
+
line1: "Conventional Strawberries",
|
| 325 |
+
line2: "Have Higher Pesticide Residue",
|
| 326 |
+
subtitle: "Pesticide residue for conventional and organic strawberries from 2010 to 2023 (measured in ppm)."
|
| 327 |
+
}
|
| 328 |
+
: {
|
| 329 |
+
line1: `${prettifyMetricLabel(yField)} Comparison:`,
|
| 330 |
+
line2: `${String(highGroup)} vs. ${String(lowGroup)}`,
|
| 331 |
+
subtitle: `${prettifyMetricLabel(yField)} for ${String(highGroup)} and ${String(lowGroup)}${rangeText}${yUnit ? `, measured in ${yUnit}` : ""}.`
|
| 332 |
+
};
|
| 333 |
+
const headerLeft = width * 0.05;
|
| 334 |
+
const titleMaxW = width * 0.72;
|
| 335 |
+
const titleLine1Size = fitFont(titleSpec.line1, titleMaxW, clamp(height * 0.048, 42, 62), 24, 900, titleFamily);
|
| 336 |
+
const titleLine2Size = fitFont(titleSpec.line2, titleMaxW, clamp(height * 0.045, 38, 58), 22, 900, titleFamily);
|
| 337 |
+
svg.append("text")
|
| 338 |
+
.attr("class", "header-title-line header-title-primary")
|
| 339 |
+
.attr("x", headerLeft)
|
| 340 |
+
.attr("y", height * 0.035)
|
| 341 |
+
.attr("dominant-baseline", "hanging")
|
| 342 |
+
.style("font-family", titleFamily)
|
| 343 |
+
.style("font-size", `${titleLine1Size}px`)
|
| 344 |
+
.style("font-weight", 900)
|
| 345 |
+
.style("fill", darkGreen)
|
| 346 |
+
.text(titleSpec.line1);
|
| 347 |
+
svg.append("text")
|
| 348 |
+
.attr("class", "header-title-line header-title-accent")
|
| 349 |
+
.attr("x", headerLeft)
|
| 350 |
+
.attr("y", height * 0.095)
|
| 351 |
+
.attr("dominant-baseline", "hanging")
|
| 352 |
+
.style("font-family", titleFamily)
|
| 353 |
+
.style("font-size", `${titleLine2Size}px`)
|
| 354 |
+
.style("font-weight", 900)
|
| 355 |
+
.style("fill", redAccent)
|
| 356 |
+
.text(titleSpec.line2);
|
| 357 |
+
const dividerY = height * 0.137;
|
| 358 |
+
svg.append("line")
|
| 359 |
+
.attr("class", "header-divider")
|
| 360 |
+
.attr("x1", headerLeft)
|
| 361 |
+
.attr("x2", width * 0.32)
|
| 362 |
+
.attr("y1", dividerY)
|
| 363 |
+
.attr("y2", dividerY)
|
| 364 |
+
.attr("stroke", "#24733f")
|
| 365 |
+
.attr("stroke-width", 2);
|
| 366 |
+
svg.append("line")
|
| 367 |
+
.attr("class", "header-divider")
|
| 368 |
+
.attr("x1", width * 0.395)
|
| 369 |
+
.attr("x2", width * 0.61)
|
| 370 |
+
.attr("y1", dividerY)
|
| 371 |
+
.attr("y2", dividerY)
|
| 372 |
+
.attr("stroke", "#24733f")
|
| 373 |
+
.attr("stroke-width", 2);
|
| 374 |
+
const ornament = svg.append("g")
|
| 375 |
+
.attr("class", "reserved-asset-slot title-ornament-slot")
|
| 376 |
+
.attr("data-asset-slot", "title-ornament")
|
| 377 |
+
.attr("data-asset-bbox", `${width * 0.35},${dividerY - 15},${width * 0.04},30`)
|
| 378 |
+
.attr("data-asset-anchor", "header-divider-center")
|
| 379 |
+
.attr("data-collision-avoid", "title,subtitle,hero-produce-art")
|
| 380 |
+
.attr("data-no-embedded-raster", "true")
|
| 381 |
+
.attr("data-no-copied-reference-art", "true");
|
| 382 |
+
ornament.append("ellipse")
|
| 383 |
+
.attr("cx", width * 0.365)
|
| 384 |
+
.attr("cy", dividerY)
|
| 385 |
+
.attr("rx", 11)
|
| 386 |
+
.attr("ry", 7)
|
| 387 |
+
.attr("fill", "#8fbd78");
|
| 388 |
+
addTextLines(svg, wrapLines(titleSpec.subtitle, width * 0.58, clamp(height * 0.018, 16, 24), 500, fontFamily, 2), headerLeft, height * 0.160, {
|
| 389 |
+
class: "header-subtitle",
|
| 390 |
+
size: clamp(height * 0.018, 16, 24),
|
| 391 |
+
lineHeight: clamp(height * 0.028, 24, 35),
|
| 392 |
+
fill: textColor,
|
| 393 |
+
weight: 500
|
| 394 |
+
});
|
| 395 |
+
const heroX = width * 0.765;
|
| 396 |
+
const heroY = height * 0.020;
|
| 397 |
+
const heroW = width * 0.185;
|
| 398 |
+
const heroH = height * 0.190;
|
| 399 |
+
const hero = svg.append("g")
|
| 400 |
+
.attr("class", "reserved-asset-slot hero-produce-slot")
|
| 401 |
+
.attr("data-asset-slot", "hero-produce-art")
|
| 402 |
+
.attr("data-asset-bbox", `${heroX},${heroY},${heroW},${heroH}`)
|
| 403 |
+
.attr("data-asset-anchor", "header-top-right")
|
| 404 |
+
.attr("data-collision-avoid", "title,subtitle,comparison-card")
|
| 405 |
+
.attr("data-no-embedded-raster", "true")
|
| 406 |
+
.attr("data-no-copied-reference-art", "true");
|
| 407 |
+
hero.append("circle")
|
| 408 |
+
.attr("cx", heroX + heroW * 0.58)
|
| 409 |
+
.attr("cy", heroY + heroH * 0.44)
|
| 410 |
+
.attr("r", heroW * 0.38)
|
| 411 |
+
.attr("fill", "#eef6ce")
|
| 412 |
+
.attr("stroke", "#a8c287")
|
| 413 |
+
.attr("stroke-width", 2)
|
| 414 |
+
.attr("stroke-dasharray", "7 6")
|
| 415 |
+
.attr("opacity", 0.75);
|
| 416 |
+
hero.append("circle")
|
| 417 |
+
.attr("cx", heroX + heroW * 0.34)
|
| 418 |
+
.attr("cy", heroY + heroH * 0.56)
|
| 419 |
+
.attr("r", heroW * 0.15)
|
| 420 |
+
.attr("fill", "#d7edbb")
|
| 421 |
+
.attr("stroke", "#78a86d")
|
| 422 |
+
.attr("stroke-width", 1.6)
|
| 423 |
+
.attr("opacity", 0.72);
|
| 424 |
+
|
| 425 |
+
const card = { x: width * 0.05, y: height * 0.226, w: width * 0.90, h: height * 0.114 };
|
| 426 |
+
svg.append("rect")
|
| 427 |
+
.attr("class", "comparison-card")
|
| 428 |
+
.attr("x", card.x)
|
| 429 |
+
.attr("y", card.y)
|
| 430 |
+
.attr("width", card.w)
|
| 431 |
+
.attr("height", card.h)
|
| 432 |
+
.attr("rx", 14)
|
| 433 |
+
.attr("ry", 14)
|
| 434 |
+
.attr("fill", "#fffdf2")
|
| 435 |
+
.attr("stroke", "#dfdfbf")
|
| 436 |
+
.attr("stroke-width", 1)
|
| 437 |
+
.style("filter", "drop-shadow(0 2px 5px rgba(80,90,50,0.12))");
|
| 438 |
+
const hazard = svg.append("g")
|
| 439 |
+
.attr("class", "reserved-asset-slot hazard-product-slot")
|
| 440 |
+
.attr("data-asset-slot", "hazard-product-art")
|
| 441 |
+
.attr("data-asset-bbox", `${card.x + 14},${card.y + 12},${card.w * 0.19},${card.h - 24}`)
|
| 442 |
+
.attr("data-asset-anchor", "comparison-card-left")
|
| 443 |
+
.attr("data-collision-avoid", "comparison-card-text,legend")
|
| 444 |
+
.attr("data-no-embedded-raster", "true")
|
| 445 |
+
.attr("data-no-copied-reference-art", "true");
|
| 446 |
+
hazard.append("rect")
|
| 447 |
+
.attr("x", card.x + 44)
|
| 448 |
+
.attr("y", card.y + 20)
|
| 449 |
+
.attr("width", card.w * 0.08)
|
| 450 |
+
.attr("height", card.h - 38)
|
| 451 |
+
.attr("rx", 6)
|
| 452 |
+
.attr("fill", "#89c77e")
|
| 453 |
+
.attr("stroke", greenDark)
|
| 454 |
+
.attr("opacity", 0.65);
|
| 455 |
+
[0, 1, 2].forEach(i => {
|
| 456 |
+
const cx = card.x + 25;
|
| 457 |
+
const cy = card.y + 23 + i * 20;
|
| 458 |
+
svg.append("path")
|
| 459 |
+
.attr("class", "comparison-warning-mark")
|
| 460 |
+
.attr("d", `M${cx - 5},${cy - 5} L${cx + 5},${cy + 5} M${cx + 5},${cy - 5} L${cx - 5},${cy + 5}`)
|
| 461 |
+
.attr("stroke", redAccent)
|
| 462 |
+
.attr("stroke-width", 3)
|
| 463 |
+
.attr("stroke-linecap", "round");
|
| 464 |
+
});
|
| 465 |
+
const infoMidX = card.x + card.w * 0.33;
|
| 466 |
+
const infoRightX = card.x + card.w * 0.62;
|
| 467 |
+
svg.append("line")
|
| 468 |
+
.attr("x1", card.x + card.w * 0.58)
|
| 469 |
+
.attr("x2", card.x + card.w * 0.58)
|
| 470 |
+
.attr("y1", card.y + 18)
|
| 471 |
+
.attr("y2", card.y + card.h - 18)
|
| 472 |
+
.attr("stroke", "#a8c09b")
|
| 473 |
+
.attr("stroke-width", 2)
|
| 474 |
+
.attr("stroke-dasharray", "2 5");
|
| 475 |
+
const avgText = (group) => `${formatValue(averages.find(d => d.group === group)?.average || 0)}${yUnit ? ` ${yUnit}` : ""} avg`;
|
| 476 |
+
const drawGroupSummary = (x, group, isHigh) => {
|
| 477 |
+
const color = groupColor.get(group) || (isHigh ? orange : green);
|
| 478 |
+
svg.append("circle")
|
| 479 |
+
.attr("class", "comparison-card-swatch")
|
| 480 |
+
.attr("cx", x)
|
| 481 |
+
.attr("cy", card.y + card.h * 0.36)
|
| 482 |
+
.attr("r", 18)
|
| 483 |
+
.attr("fill", isHigh ? `url(#vg13-hatch-${slug(group)})` : "#f3fff1")
|
| 484 |
+
.attr("stroke", color)
|
| 485 |
+
.attr("stroke-width", 2);
|
| 486 |
+
svg.append("text")
|
| 487 |
+
.attr("x", x + 32)
|
| 488 |
+
.attr("y", card.y + card.h * 0.26)
|
| 489 |
+
.attr("dominant-baseline", "hanging")
|
| 490 |
+
.style("font-family", titleFamily)
|
| 491 |
+
.style("font-size", `${clamp(height * 0.025, 12, 16)}px`)
|
| 492 |
+
.style("font-weight", 800)
|
| 493 |
+
.style("fill", isHigh ? orangeDark : greenDark)
|
| 494 |
+
.text(String(group));
|
| 495 |
+
addTextLines(svg, wrapLines(isHigh ? `Higher residue (${avgText(group)})` : `Lower residue (${avgText(group)})`, card.w * 0.20, 12, 500, fontFamily, 2), x + 32, card.y + card.h * 0.52, {
|
| 496 |
+
class: "comparison-card-description",
|
| 497 |
+
size: clamp(height * 0.018, 9, 12),
|
| 498 |
+
lineHeight: clamp(height * 0.023, 11, 14),
|
| 499 |
+
fill: textColor,
|
| 500 |
+
weight: 500
|
| 501 |
+
});
|
| 502 |
+
};
|
| 503 |
+
drawGroupSummary(infoMidX, lowGroup, false);
|
| 504 |
+
drawGroupSummary(infoRightX, highGroup, true);
|
| 505 |
+
|
| 506 |
+
const panel = { x: width * 0.031, y: height * 0.353, w: width * 0.938, h: height * 0.473 };
|
| 507 |
+
svg.append("rect")
|
| 508 |
+
.attr("class", "chart-panel")
|
| 509 |
+
.attr("x", panel.x)
|
| 510 |
+
.attr("y", panel.y)
|
| 511 |
+
.attr("width", panel.w)
|
| 512 |
+
.attr("height", panel.h)
|
| 513 |
+
.attr("rx", 15)
|
| 514 |
+
.attr("ry", 15)
|
| 515 |
+
.attr("fill", "#fffef5")
|
| 516 |
+
.attr("stroke", "#d8dfb4")
|
| 517 |
+
.attr("stroke-width", 1.1)
|
| 518 |
+
.style("filter", "drop-shadow(0 2px 5px rgba(80,90,50,0.10))");
|
| 519 |
+
svg.append("text")
|
| 520 |
+
.attr("class", "y-axis-panel-title")
|
| 521 |
+
.attr("x", panel.x + 12)
|
| 522 |
+
.attr("y", panel.y + 16)
|
| 523 |
+
.attr("dominant-baseline", "hanging")
|
| 524 |
+
.style("font-family", fontFamily)
|
| 525 |
+
.style("font-size", `${clamp(height * 0.020, 10, 13)}px`)
|
| 526 |
+
.style("font-weight", 800)
|
| 527 |
+
.style("fill", textColor)
|
| 528 |
+
.text(`${prettifyMetricLabel(yField)}${yUnit ? ` (${yUnit})` : ""}`);
|
| 529 |
+
|
| 530 |
+
const legend = svg.append("g")
|
| 531 |
+
.attr("class", "legend chart-owned-legend")
|
| 532 |
+
.attr("data-reference-relationship", "independent-centered-above-plot")
|
| 533 |
+
.attr("transform", `translate(${panel.x + panel.w * 0.39},${panel.y + 26})`);
|
| 534 |
+
legendGroups.forEach((group, index) => {
|
| 535 |
+
const lx = index * panel.w * 0.19;
|
| 536 |
+
legend.append("rect")
|
| 537 |
+
.attr("x", lx)
|
| 538 |
+
.attr("y", -7)
|
| 539 |
+
.attr("width", 17)
|
| 540 |
+
.attr("height", 17)
|
| 541 |
+
.attr("rx", 3)
|
| 542 |
+
.attr("fill", `url(#vg13-hatch-${slug(group)})`)
|
| 543 |
+
.attr("stroke", groupColor.get(group))
|
| 544 |
+
.attr("stroke-width", 1.3);
|
| 545 |
+
legend.append("text")
|
| 546 |
+
.attr("x", lx + 27)
|
| 547 |
+
.attr("y", 2)
|
| 548 |
+
.attr("dominant-baseline", "middle")
|
| 549 |
+
.style("font-family", fontFamily)
|
| 550 |
+
.style("font-size", `${clamp(height * 0.020, 10, 13)}px`)
|
| 551 |
+
.style("font-weight", 800)
|
| 552 |
+
.style("fill", group === highGroup ? orangeDark : greenDark)
|
| 553 |
+
.text(String(group));
|
| 554 |
+
});
|
| 555 |
+
|
| 556 |
+
const plot = {
|
| 557 |
+
x: panel.x + panel.w * 0.065,
|
| 558 |
+
y: panel.y + panel.h * 0.11,
|
| 559 |
+
w: panel.w * 0.89,
|
| 560 |
+
h: panel.h * 0.815
|
| 561 |
+
};
|
| 562 |
+
const xScale2 = d3.scaleBand()
|
| 563 |
+
.domain(xValues)
|
| 564 |
+
.range([plot.x, plot.x + plot.w])
|
| 565 |
+
.padding(0.10);
|
| 566 |
+
const dataMax = d3.max(useData, d => +d[yField]) || 100;
|
| 567 |
+
const smallAxis = smallValueAxis(dataMax);
|
| 568 |
+
const yMax = smallAxis ? smallAxis.max : d3.scaleLinear().domain([0, dataMax * 1.14]).nice(5).domain()[1];
|
| 569 |
+
const yScale2 = d3.scaleLinear()
|
| 570 |
+
.domain([0, yMax])
|
| 571 |
+
.range([plot.y + plot.h, plot.y]);
|
| 572 |
+
const yTicks = smallAxis
|
| 573 |
+
? d3.range(0, yMax + smallAxis.step / 2, smallAxis.step)
|
| 574 |
+
: yScale2.ticks(5);
|
| 575 |
+
yTicks.forEach(tick => {
|
| 576 |
+
const y = yScale2(tick);
|
| 577 |
+
svg.append("line")
|
| 578 |
+
.attr("class", "gridline")
|
| 579 |
+
.attr("x1", plot.x)
|
| 580 |
+
.attr("x2", plot.x + plot.w)
|
| 581 |
+
.attr("y1", y)
|
| 582 |
+
.attr("y2", y)
|
| 583 |
+
.attr("stroke", "#d9d9bf")
|
| 584 |
+
.attr("stroke-dasharray", "3 4")
|
| 585 |
+
.attr("stroke-width", 0.8);
|
| 586 |
+
svg.append("text")
|
| 587 |
+
.attr("class", "y-tick")
|
| 588 |
+
.attr("x", plot.x - 9)
|
| 589 |
+
.attr("y", y + 4)
|
| 590 |
+
.attr("text-anchor", "end")
|
| 591 |
+
.style("font-family", fontFamily)
|
| 592 |
+
.style("font-size", `${clamp(height * 0.017, 8, 11)}px`)
|
| 593 |
+
.style("fill", textColor)
|
| 594 |
+
.text(formatValue(tick));
|
| 595 |
+
});
|
| 596 |
+
svg.append("line")
|
| 597 |
+
.attr("class", "y-axis-line")
|
| 598 |
+
.attr("x1", plot.x)
|
| 599 |
+
.attr("x2", plot.x)
|
| 600 |
+
.attr("y1", plot.y)
|
| 601 |
+
.attr("y2", plot.y + plot.h)
|
| 602 |
+
.attr("stroke", "#2f7141")
|
| 603 |
+
.attr("stroke-width", 1.1);
|
| 604 |
+
svg.append("line")
|
| 605 |
+
.attr("class", "baseline")
|
| 606 |
+
.attr("x1", plot.x)
|
| 607 |
+
.attr("x2", plot.x + plot.w)
|
| 608 |
+
.attr("y1", plot.y + plot.h)
|
| 609 |
+
.attr("y2", plot.y + plot.h)
|
| 610 |
+
.attr("stroke", "#2f7141")
|
| 611 |
+
.attr("stroke-width", 1.1);
|
| 612 |
+
|
| 613 |
+
const band = xScale2.bandwidth();
|
| 614 |
+
const barW = clamp(band * 0.26, 4, 14);
|
| 615 |
+
const gap = clamp(band * 0.08, 2, 5);
|
| 616 |
+
const labelSize = clamp(height * 0.018, 8, 12);
|
| 617 |
+
xValues.forEach(xValue => {
|
| 618 |
+
const rows = useData.filter(d => d[xField] === xValue);
|
| 619 |
+
const center = xScale2(xValue) + band / 2;
|
| 620 |
+
const highRow = rows.find(d => d[groupField] === highGroup);
|
| 621 |
+
const lowRow = rows.find(d => d[groupField] === lowGroup);
|
| 622 |
+
const positions = [
|
| 623 |
+
{ group: highGroup, row: highRow, x: center - barW - gap / 2, color: groupColor.get(highGroup), labelColor: orangeDark },
|
| 624 |
+
{ group: lowGroup, row: lowRow, x: center + gap / 2, color: groupColor.get(lowGroup), labelColor: greenDark }
|
| 625 |
+
];
|
| 626 |
+
positions.forEach(item => {
|
| 627 |
+
if (!item.row) return;
|
| 628 |
+
const value = +item.row[yField] || 0;
|
| 629 |
+
const y = yScale2(value);
|
| 630 |
+
const h = plot.y + plot.h - y;
|
| 631 |
+
svg.append("path")
|
| 632 |
+
.attr("class", `bar grouped-bar ${item.group === highGroup ? "high-bar" : "low-bar"}`)
|
| 633 |
+
.attr("data-group", item.group)
|
| 634 |
+
.attr("data-x", xValue)
|
| 635 |
+
.attr("data-y", value)
|
| 636 |
+
.attr("data-mark-endcap", "flat-baseline-slightly-rounded-top")
|
| 637 |
+
.attr("data-baseline-edge", "flat-square")
|
| 638 |
+
.attr("data-value-edge", "horizontal-flat-with-minor-top-corner-radius")
|
| 639 |
+
.attr("data-stacked-join", "not-applicable-grouped-bars-separated")
|
| 640 |
+
.attr("d", roundedTopBarPath(item.x, y, barW, Math.max(0, h), Math.min(2, barW * 0.14)))
|
| 641 |
+
.attr("fill", `url(#vg13-hatch-${slug(item.group)})`)
|
| 642 |
+
.attr("stroke", darken(item.color, 0.25, item.color))
|
| 643 |
+
.attr("stroke-width", 0.45);
|
| 644 |
+
svg.append("text")
|
| 645 |
+
.attr("class", "bar-value-label")
|
| 646 |
+
.attr("x", item.x + barW / 2)
|
| 647 |
+
.attr("y", Math.max(plot.y + 10, y - 5))
|
| 648 |
+
.attr("text-anchor", "middle")
|
| 649 |
+
.style("font-family", fontFamily)
|
| 650 |
+
.style("font-size", `${labelSize}px`)
|
| 651 |
+
.style("font-weight", 800)
|
| 652 |
+
.style("fill", item.labelColor)
|
| 653 |
+
.text(formatValue(value));
|
| 654 |
+
});
|
| 655 |
+
const xText = String(xValue);
|
| 656 |
+
const compactTimeLabel = /^\d{4}(-\d{2})?$/.test(xText) || timeKey(xText) !== null;
|
| 657 |
+
const tileMaxW = compactTimeLabel ? 44 : Math.min(78, band * 0.94);
|
| 658 |
+
const tileW = clamp(band * 0.92, 22, tileMaxW);
|
| 659 |
+
const tileH = clamp(height * 0.044, 18, 26);
|
| 660 |
+
const tileX = center - tileW / 2;
|
| 661 |
+
const tileY = panel.y + panel.h - tileH - 7;
|
| 662 |
+
svg.append("rect")
|
| 663 |
+
.attr("class", "x-label-tile")
|
| 664 |
+
.attr("data-reference-relationship", "centered-on-band-contained-label")
|
| 665 |
+
.attr("data-full-label", xText)
|
| 666 |
+
.attr("x", tileX)
|
| 667 |
+
.attr("y", tileY)
|
| 668 |
+
.attr("width", tileW)
|
| 669 |
+
.attr("height", tileH)
|
| 670 |
+
.attr("rx", 3)
|
| 671 |
+
.attr("ry", 3)
|
| 672 |
+
.attr("fill", greenDark)
|
| 673 |
+
.attr("stroke", "#0a4a2f")
|
| 674 |
+
.attr("stroke-width", 0.7);
|
| 675 |
+
const xSize = fitFont(xText, tileW - 6, clamp(height * 0.020, 9, 13), 6, 800, fontFamily);
|
| 676 |
+
const shortLabel = xText.length <= 8;
|
| 677 |
+
const fittedXText = (compactTimeLabel || shortLabel) ? xText : ellipsizeText(xText, tileW - 7, xSize, 800, fontFamily);
|
| 678 |
+
const xLabel = svg.append("text")
|
| 679 |
+
.attr("class", "x-label")
|
| 680 |
+
.attr("data-full-label", xText)
|
| 681 |
+
.attr("x", center)
|
| 682 |
+
.attr("y", tileY + tileH / 2 + 1)
|
| 683 |
+
.attr("text-anchor", "middle")
|
| 684 |
+
.attr("dominant-baseline", "middle")
|
| 685 |
+
.style("font-family", fontFamily)
|
| 686 |
+
.style("font-size", `${xSize}px`)
|
| 687 |
+
.style("font-weight", 800)
|
| 688 |
+
.style("fill", "#ffffff")
|
| 689 |
+
.text(fittedXText);
|
| 690 |
+
xLabel.append("title").text(xText);
|
| 691 |
+
});
|
| 692 |
+
|
| 693 |
+
return svg.node();
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
|
| 697 |
+
// ---------- 5. 创建SVG容器 ----------
|
| 698 |
+
const svg = d3.select(containerSelector)
|
| 699 |
+
.append("svg")
|
| 700 |
+
.attr("width", "100%")
|
| 701 |
+
.attr("height", height)
|
| 702 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 703 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 704 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 705 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 706 |
+
|
| 707 |
+
// ---------- 6. 创建视觉效果 (Patterns for Sketch Style) ----------
|
| 708 |
+
const defs = svg.append("defs");
|
| 709 |
+
|
| 710 |
+
// --- Sketch Pattern for Left Bars ---
|
| 711 |
+
const patternLeft = defs.append("pattern")
|
| 712 |
+
.attr("id", "pattern-sketch-left")
|
| 713 |
+
.attr("width", 8) // Pattern tile size
|
| 714 |
+
.attr("height", 8)
|
| 715 |
+
.attr("patternUnits", "userSpaceOnUse")
|
| 716 |
+
.attr("patternTransform", "rotate(45)"); // Rotate lines
|
| 717 |
+
|
| 718 |
+
// Background color for the pattern tile
|
| 719 |
+
patternLeft.append("rect")
|
| 720 |
+
.attr("width", 8)
|
| 721 |
+
.attr("height", 8)
|
| 722 |
+
.attr("fill", leftGroupColor);
|
| 723 |
+
|
| 724 |
+
// Sketch lines (white in example image)
|
| 725 |
+
patternLeft.append("path")
|
| 726 |
+
.attr("d", "M -1,2 l 10,0 M -1,5 l 10,0 M -1,8 l 10,0") // Horizontal lines, rotation makes them diagonal
|
| 727 |
+
.attr("stroke", "#FFFFFF") // White lines like example
|
| 728 |
+
.attr("stroke-width", 0.8); // Thinner lines
|
| 729 |
+
|
| 730 |
+
// --- Sketch Pattern for Right Bars ---
|
| 731 |
+
const patternRight = defs.append("pattern")
|
| 732 |
+
.attr("id", "pattern-sketch-right")
|
| 733 |
+
.attr("width", 8)
|
| 734 |
+
.attr("height", 8)
|
| 735 |
+
.attr("patternUnits", "userSpaceOnUse")
|
| 736 |
+
.attr("patternTransform", "rotate(45)");
|
| 737 |
+
|
| 738 |
+
patternRight.append("rect")
|
| 739 |
+
.attr("width", 8)
|
| 740 |
+
.attr("height", 8)
|
| 741 |
+
.attr("fill", rightGroupColor);
|
| 742 |
+
|
| 743 |
+
patternRight.append("path")
|
| 744 |
+
.attr("d", "M -1,2 l 10,0 M -1,5 l 10,0 M -1,8 l 10,0")
|
| 745 |
+
.attr("stroke", "#FFFFFF")
|
| 746 |
+
.attr("stroke-width", 0.8);
|
| 747 |
+
|
| 748 |
+
// *** 添加: 新的 Sketch Pattern for Label Backgrounds ***
|
| 749 |
+
const patternLabelSketch = defs.append("pattern")
|
| 750 |
+
.attr("id", "pattern-label-sketch") // 新 ID
|
| 751 |
+
.attr("width", 8) // 可以与 bar pattern 保持一致或调整
|
| 752 |
+
.attr("height", 8)
|
| 753 |
+
.attr("patternUnits", "userSpaceOnUse")
|
| 754 |
+
.attr("patternTransform", "rotate(45)"); // 保持斜线
|
| 755 |
+
|
| 756 |
+
// 添加白色背景矩形
|
| 757 |
+
patternLabelSketch.append("rect")
|
| 758 |
+
.attr("width", 8)
|
| 759 |
+
.attr("height", 8)
|
| 760 |
+
.attr("fill", "#FFFFFF"); // 白色背景
|
| 761 |
+
|
| 762 |
+
// 添加灰色斜线
|
| 763 |
+
patternLabelSketch.append("path")
|
| 764 |
+
.attr("d", "M -1,2 l 10,0 M -1,5 l 10,0 M -1,8 l 10,0") // 与 bar pattern 线条一致
|
| 765 |
+
.attr("stroke", "#CCCCCC") // *** 修改: 灰色线条 ***
|
| 766 |
+
.attr("stroke-width", 0.6); // 可以调整线条粗细
|
| 767 |
+
|
| 768 |
+
// Shadow Filter (if enabled)
|
| 769 |
+
if (variables.has_shadow) {
|
| 770 |
+
// ... (shadow filter code remains the same as original)
|
| 771 |
+
const filter = defs.append("filter")
|
| 772 |
+
.attr("id", "shadow")
|
| 773 |
+
.attr("filterUnits", "userSpaceOnUse")
|
| 774 |
+
.attr("width", "200%")
|
| 775 |
+
.attr("height", "200%");
|
| 776 |
+
|
| 777 |
+
filter.append("feGaussianBlur")
|
| 778 |
+
.attr("in", "SourceAlpha")
|
| 779 |
+
.attr("stdDeviation", 3);
|
| 780 |
+
|
| 781 |
+
filter.append("feOffset")
|
| 782 |
+
.attr("dx", 2)
|
| 783 |
+
.attr("dy", 2)
|
| 784 |
+
.attr("result", "offsetblur");
|
| 785 |
+
|
| 786 |
+
const feMerge = filter.append("feMerge");
|
| 787 |
+
feMerge.append("feMergeNode");
|
| 788 |
+
feMerge.append("feMergeNode").attr("in", "SourceGraphic");
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
// ---------- 7. 创建图表区域 ----------
|
| 792 |
+
const chart = svg.append("g")
|
| 793 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 794 |
+
|
| 795 |
+
// ---------- 8. 创建比例尺 ----------
|
| 796 |
+
const xScale = d3.scaleBand()
|
| 797 |
+
.domain(xValues)
|
| 798 |
+
.range([0, innerWidth])
|
| 799 |
+
.padding(0.2);
|
| 800 |
+
|
| 801 |
+
const groupScale = d3.scaleBand()
|
| 802 |
+
.domain([0, 1]) // Left (0) and Right (1) bars within a group
|
| 803 |
+
.range([0, xScale.bandwidth()])
|
| 804 |
+
.padding(variables.has_spacing ? 0.2 : 0.1); // Adjust spacing within group
|
| 805 |
+
|
| 806 |
+
const dataMax = d3.max(useData, d => +d[yField]) || 100;
|
| 807 |
+
const yScale = d3.scaleLinear()
|
| 808 |
+
.domain([0, dataMax * 1.1]) // Add some top padding for labels
|
| 809 |
+
.range([innerHeight, 0]);
|
| 810 |
+
|
| 811 |
+
const barWidth = groupScale.bandwidth();
|
| 812 |
+
|
| 813 |
+
// ---------- 9. 文本格式化 ----------
|
| 814 |
+
// 添加数值格式化函数
|
| 815 |
+
|
| 816 |
+
// 完整格式化函数(包含单位)
|
| 817 |
+
const formatValueWithUnit = (value) => `${chartUtils.format.autoText(value)}${yUnit}`;
|
| 818 |
+
|
| 819 |
+
// 计算动态文本大小的函数
|
| 820 |
+
const calculateFontSize = (text, maxWidth, baseSize = 12) => {
|
| 821 |
+
// 估算每个字符的平均宽度 (假设为baseSize的60%)
|
| 822 |
+
const avgCharWidth = baseSize * 0.6;
|
| 823 |
+
// 计算文本的估计宽度
|
| 824 |
+
const textWidth = text.length * avgCharWidth;
|
| 825 |
+
// 如果文本宽度小于最大宽度,返回基础大小
|
| 826 |
+
if (textWidth < maxWidth) {
|
| 827 |
+
return baseSize;
|
| 828 |
+
}
|
| 829 |
+
// 否则,按比例缩小字体大小
|
| 830 |
+
return Math.max(10, Math.floor(baseSize * (maxWidth / textWidth)));
|
| 831 |
+
};
|
| 832 |
+
function wrapText(text, str, width, lineHeight) {
|
| 833 |
+
const words = str.split(/\s+/).reverse();
|
| 834 |
+
let word;
|
| 835 |
+
let line = [];
|
| 836 |
+
let lineNumber = 0;
|
| 837 |
+
const y = text.attr("y");
|
| 838 |
+
const dy = parseFloat(text.attr("dy") || 0);
|
| 839 |
+
|
| 840 |
+
// 先清空文本
|
| 841 |
+
text.text(null);
|
| 842 |
+
|
| 843 |
+
// 处理文本
|
| 844 |
+
let tspans = [];
|
| 845 |
+
|
| 846 |
+
// 如果没有空格可分割,按字符分割
|
| 847 |
+
if (words.length <= 1) {
|
| 848 |
+
const chars = str.split('');
|
| 849 |
+
let currentLine = '';
|
| 850 |
+
|
| 851 |
+
for (let i = 0; i < chars.length; i++) {
|
| 852 |
+
currentLine += chars[i];
|
| 853 |
+
|
| 854 |
+
// 测量候选文本宽度
|
| 855 |
+
const isOverflow = chartUtils.text.measure(null, currentLine, {
|
| 856 |
+
fontFamily: text.style("font-family"),
|
| 857 |
+
fontSize: parseFloat(text.style("font-size")),
|
| 858 |
+
fontWeight: text.style("font-weight")
|
| 859 |
+
}).width > width;
|
| 860 |
+
|
| 861 |
+
if (isOverflow && currentLine.length > 1) {
|
| 862 |
+
// 当前行过长,回退一个字符并换行
|
| 863 |
+
currentLine = currentLine.slice(0, -1);
|
| 864 |
+
|
| 865 |
+
// 添加到tspans数组
|
| 866 |
+
tspans.push(currentLine);
|
| 867 |
+
|
| 868 |
+
// 重新开始下一行
|
| 869 |
+
currentLine = chars[i];
|
| 870 |
+
lineNumber++;
|
| 871 |
+
}
|
| 872 |
+
}
|
| 873 |
+
|
| 874 |
+
// 添加最后一行
|
| 875 |
+
if (currentLine.length > 0) {
|
| 876 |
+
tspans.push(currentLine);
|
| 877 |
+
}
|
| 878 |
+
} else {
|
| 879 |
+
// 处理有空格的文本
|
| 880 |
+
let currentLine = [];
|
| 881 |
+
|
| 882 |
+
while (word = words.pop()) {
|
| 883 |
+
currentLine.push(word);
|
| 884 |
+
|
| 885 |
+
// 测量候选文本宽度
|
| 886 |
+
const isOverflow = chartUtils.text.measure(null, currentLine.join(" "), {
|
| 887 |
+
fontFamily: text.style("font-family"),
|
| 888 |
+
fontSize: parseFloat(text.style("font-size")),
|
| 889 |
+
fontWeight: text.style("font-weight")
|
| 890 |
+
}).width > width;
|
| 891 |
+
|
| 892 |
+
if (isOverflow && currentLine.length > 1) {
|
| 893 |
+
// 回退一个词
|
| 894 |
+
currentLine.pop();
|
| 895 |
+
|
| 896 |
+
// 添加到tspans数组
|
| 897 |
+
tspans.push(currentLine.join(" "));
|
| 898 |
+
|
| 899 |
+
// 重新开始下一行
|
| 900 |
+
currentLine = [word];
|
| 901 |
+
lineNumber++;
|
| 902 |
+
}
|
| 903 |
+
}
|
| 904 |
+
|
| 905 |
+
// 添加最后一行
|
| 906 |
+
if (currentLine.length > 0) {
|
| 907 |
+
tspans.push(currentLine.join(" "));
|
| 908 |
+
}
|
| 909 |
+
}
|
| 910 |
+
|
| 911 |
+
// 计算总行数
|
| 912 |
+
const totalLines = tspans.length;
|
| 913 |
+
|
| 914 |
+
// 计算垂直居中的起始位置
|
| 915 |
+
// 对于单行文本,y位置保持不变
|
| 916 |
+
// 对于多行文本,需要向上偏移以保持垂直居中
|
| 917 |
+
let startY = y;
|
| 918 |
+
if (totalLines > 1) {
|
| 919 |
+
// 向上偏移半行距离 * (总行数-1)
|
| 920 |
+
startY = parseFloat(y) - (lineHeight * (totalLines - 1) / 2);
|
| 921 |
+
}
|
| 922 |
+
|
| 923 |
+
// 创建所有行的tspan元素
|
| 924 |
+
tspans.forEach((lineText, i) => {
|
| 925 |
+
text.append("tspan")
|
| 926 |
+
.attr("x", text.attr("x"))
|
| 927 |
+
.attr("y", startY)
|
| 928 |
+
.attr("dy", `${i * lineHeight}em`)
|
| 929 |
+
.text(lineText);
|
| 930 |
+
});
|
| 931 |
+
}
|
| 932 |
+
|
| 933 |
+
// ---------- 10. 创建X坐标轴标签 (新逻辑) ----------
|
| 934 |
+
const xAxisGroup = chart.append("g")
|
| 935 |
+
.attr("class", "x-axis")
|
| 936 |
+
.attr("transform", `translate(0, ${innerHeight})`);
|
| 937 |
+
|
| 938 |
+
// 计算标签最大宽度和统一字体大小 (同 chart_12)
|
| 939 |
+
const labelMaxWidth = xScale.bandwidth() * 1.1; // 允许稍微宽一点以便背景有空间
|
| 940 |
+
const longestLabel = xValues.reduce((a, b) => a.toString().length > b.toString().length ? a.toString() : b.toString(), "");
|
| 941 |
+
const baseFontSize = parseInt(typography.label.font_size);
|
| 942 |
+
const uniformFontSize = calculateFontSize(longestLabel, labelMaxWidth * 0.9, baseFontSize); // 计算字体时用稍小的宽度
|
| 943 |
+
|
| 944 |
+
// 定义手绘背景的内边距
|
| 945 |
+
const sketchPadding = 5;
|
| 946 |
+
|
| 947 |
+
// 绘制X轴标签组
|
| 948 |
+
xAxisGroup.selectAll(".x-label-group")
|
| 949 |
+
.data(xValues)
|
| 950 |
+
.enter()
|
| 951 |
+
.append("g")
|
| 952 |
+
.attr("class", "x-label-group")
|
| 953 |
+
.attr("transform", d => `translate(${xScale(d) + xScale.bandwidth() / 2}, 25)`) // Group 定位不变
|
| 954 |
+
.each(function(d) {
|
| 955 |
+
const group = d3.select(this);
|
| 956 |
+
const textContent = d.toString();
|
| 957 |
+
|
| 958 |
+
// 1. 创建文本元素
|
| 959 |
+
const text = group.append("text")
|
| 960 |
+
.attr("class", "x-label")
|
| 961 |
+
.attr("x", 0) // 相对于 group 居中
|
| 962 |
+
.attr("y", 0) // 相对于 group 垂直基线 (会被 wrapText 调整)
|
| 963 |
+
.attr("text-anchor", "middle")
|
| 964 |
+
.style("font-family", "'Comic Sans MS', cursive")
|
| 965 |
+
.style("font-size", `${uniformFontSize}px`) // 应用统一字体
|
| 966 |
+
.style("font-weight", typography.label.font_weight)
|
| 967 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 968 |
+
.text(textContent);
|
| 969 |
+
|
| 970 |
+
// 2. 检查宽度并应用换行 (同 chart_12)
|
| 971 |
+
// 注意: wrapText 需要在 chart_13 中可用
|
| 972 |
+
const textWidth = chartUtils.text.measure(null, textContent, {
|
| 973 |
+
fontFamily: "'Comic Sans MS', cursive",
|
| 974 |
+
fontSize: uniformFontSize,
|
| 975 |
+
fontWeight: typography.label.font_weight
|
| 976 |
+
}).width;
|
| 977 |
+
if (textWidth > labelMaxWidth) {
|
| 978 |
+
wrapText(text, textContent, labelMaxWidth, 1.1); // 1.1 是行高倍数
|
| 979 |
+
}
|
| 980 |
+
|
| 981 |
+
// 3. 根据最终文本行计算背景边界框
|
| 982 |
+
const renderedLines = text.selectAll("tspan").nodes().map(node => d3.select(node).text());
|
| 983 |
+
const labelLines = renderedLines.length ? renderedLines : [text.text()];
|
| 984 |
+
const measureOptions = {
|
| 985 |
+
fontFamily: "'Comic Sans MS', cursive",
|
| 986 |
+
fontSize: uniformFontSize,
|
| 987 |
+
fontWeight: typography.label.font_weight
|
| 988 |
+
};
|
| 989 |
+
const bboxWidth = Math.max(...labelLines.map(line => chartUtils.text.measure(null, line, measureOptions).width), 0);
|
| 990 |
+
const bboxHeight = labelLines.length > 1
|
| 991 |
+
? uniformFontSize + (labelLines.length - 1) * uniformFontSize * 1.1
|
| 992 |
+
: chartUtils.text.measure(null, labelLines[0], measureOptions).height;
|
| 993 |
+
const bbox = {
|
| 994 |
+
width: bboxWidth,
|
| 995 |
+
height: bboxHeight,
|
| 996 |
+
x: -bboxWidth / 2,
|
| 997 |
+
y: labelLines.length > 1 ? -bboxHeight / 2 : -bboxHeight * 0.8
|
| 998 |
+
};
|
| 999 |
+
|
| 1000 |
+
// 4. 计算手绘背景尺寸和位置
|
| 1001 |
+
const bgWidth = bbox.width + 2 * sketchPadding;
|
| 1002 |
+
const bgHeight = bbox.height + 1.5 * sketchPadding;
|
| 1003 |
+
// bgX/bgY 相对于 group 的 (0,0) 点计算
|
| 1004 |
+
const bgX = bbox.x - sketchPadding;
|
| 1005 |
+
const bgY = bbox.y - sketchPadding * 0.75; // 调整 Y 使背景居中
|
| 1006 |
+
|
| 1007 |
+
// 5. 插入手绘背景矩形 (在文本之前)
|
| 1008 |
+
group.insert("rect", "text")
|
| 1009 |
+
.attr("class", "label-background")
|
| 1010 |
+
.attr("x", bgX)
|
| 1011 |
+
.attr("y", bgY)
|
| 1012 |
+
.attr("width", bgWidth)
|
| 1013 |
+
.attr("height", bgHeight)
|
| 1014 |
+
.attr("fill", "url(#pattern-label-sketch)")
|
| 1015 |
+
.attr("stroke", "#AAAAAA")
|
| 1016 |
+
.attr("stroke-width", 0.5);
|
| 1017 |
+
});
|
| 1018 |
+
|
| 1019 |
+
// ---------- 11. 绘制图例 ----------
|
| 1020 |
+
const legendItems = legendData.map(item => ({
|
| 1021 |
+
label: item.key,
|
| 1022 |
+
color: item.pattern || item.color,
|
| 1023 |
+
}));
|
| 1024 |
+
chartUtils.legend.centered(svg, legendItems, {}, width / 2, 30, {
|
| 1025 |
+
markerShape: "rect",
|
| 1026 |
+
markerSize: 15,
|
| 1027 |
+
labelGap: 5,
|
| 1028 |
+
itemGap: 20,
|
| 1029 |
+
fontSize: 12,
|
| 1030 |
+
fontFamily: typography.label.font_family || "'Comic Sans MS', cursive",
|
| 1031 |
+
textColor: colorResolver.text({ fallback: "#333333" }).value,
|
| 1032 |
+
});
|
| 1033 |
+
|
| 1034 |
+
// ---------- 12. 绘制条形图和数值标签 (带手绘背景) ----------
|
| 1035 |
+
xValues.forEach(xValue => {
|
| 1036 |
+
const xData = useData.filter(d => d[xField] === xValue);
|
| 1037 |
+
const leftBarData = xData.find(d => d[groupField] === leftBarGroup);
|
| 1038 |
+
const rightBarData = xData.find(d => d[groupField] === rightBarGroup);
|
| 1039 |
+
|
| 1040 |
+
const baseFontSizeValue = parseInt(typography.label.font_size); // 数值标签基础大小
|
| 1041 |
+
const valueLabelMaxWidth = barWidth * 1.1; // 数值标签最大宽度
|
| 1042 |
+
|
| 1043 |
+
// --- Left Bar ---
|
| 1044 |
+
if (leftBarData) {
|
| 1045 |
+
const leftValue = +leftBarData[yField];
|
| 1046 |
+
if (isNaN(leftValue)) return; // Skip if value is not a number
|
| 1047 |
+
|
| 1048 |
+
const leftBarHeight = innerHeight - yScale(leftValue);
|
| 1049 |
+
const leftBarY = yScale(leftValue);
|
| 1050 |
+
const leftBarX = xScale(xValue) + groupScale(0); // Use groupScale for position
|
| 1051 |
+
|
| 1052 |
+
// Draw Bar
|
| 1053 |
+
chart.append("rect")
|
| 1054 |
+
.attr("class", "bar left-bar")
|
| 1055 |
+
.attr("x", leftBarX)
|
| 1056 |
+
.attr("y", leftBarY)
|
| 1057 |
+
.attr("width", barWidth)
|
| 1058 |
+
.attr("height", Math.max(0, leftBarHeight)) // Ensure height is not negative
|
| 1059 |
+
.attr("fill", "url(#pattern-sketch-left)") // Use sketch pattern
|
| 1060 |
+
.attr("stroke", variables.has_stroke ? "#555" : "none")
|
| 1061 |
+
.attr("stroke-width", variables.has_stroke ? 1 : 0)
|
| 1062 |
+
.style("filter", variables.has_shadow ? "url(#shadow)" : "none");
|
| 1063 |
+
|
| 1064 |
+
// --- 修改: 绘制带背景的数值标签 ---
|
| 1065 |
+
if (leftBarHeight > 5) {
|
| 1066 |
+
const labelX = leftBarX + barWidth / 2;
|
| 1067 |
+
const labelY = leftBarY - 8;
|
| 1068 |
+
const textContent = formatValueWithUnit(leftValue);
|
| 1069 |
+
|
| 1070 |
+
// 使用临时 group 来绘制标签和背景
|
| 1071 |
+
const labelGroup = chart.append("g")
|
| 1072 |
+
.attr("class", "value-label-group left-value-label");
|
| 1073 |
+
|
| 1074 |
+
// 复用字体大小调整逻辑 (可以封装成函数)
|
| 1075 |
+
let currentFontSize = baseFontSizeValue;
|
| 1076 |
+
let bbox = chartUtils.text.measure(labelGroup, textContent, {
|
| 1077 |
+
fontFamily: "'Comic Sans MS', cursive",
|
| 1078 |
+
fontSize: baseFontSizeValue,
|
| 1079 |
+
fontWeight: "bold"
|
| 1080 |
+
});
|
| 1081 |
+
if (bbox.width > valueLabelMaxWidth - 2 * sketchPadding) {
|
| 1082 |
+
currentFontSize = Math.max(8, baseFontSizeValue * ((valueLabelMaxWidth - 2 * sketchPadding) / bbox.width));
|
| 1083 |
+
}
|
| 1084 |
+
|
| 1085 |
+
// 获取最终尺寸
|
| 1086 |
+
bbox = chartUtils.text.measure(labelGroup, textContent, {
|
| 1087 |
+
fontFamily: "'Comic Sans MS', cursive",
|
| 1088 |
+
fontSize: currentFontSize,
|
| 1089 |
+
fontWeight: "bold"
|
| 1090 |
+
});
|
| 1091 |
+
|
| 1092 |
+
const bgWidth = bbox.width + 2 * sketchPadding;
|
| 1093 |
+
const bgHeight = bbox.height + 1.5 * sketchPadding;
|
| 1094 |
+
const bgX = labelX - bgWidth / 2;
|
| 1095 |
+
const bgY = labelY - bgHeight + sketchPadding / 2;
|
| 1096 |
+
|
| 1097 |
+
labelGroup.append("rect")
|
| 1098 |
+
.attr("x", bgX)
|
| 1099 |
+
.attr("y", bgY)
|
| 1100 |
+
.attr("width", bgWidth)
|
| 1101 |
+
.attr("height", bgHeight)
|
| 1102 |
+
.attr("fill", "url(#pattern-label-sketch)")
|
| 1103 |
+
.attr("stroke", "#AAAAAA")
|
| 1104 |
+
.attr("stroke-width", 0.5);
|
| 1105 |
+
|
| 1106 |
+
labelGroup.append("text")
|
| 1107 |
+
.attr("class", "bar-label")
|
| 1108 |
+
.attr("x", labelX)
|
| 1109 |
+
.attr("y", labelY)
|
| 1110 |
+
.attr("text-anchor", "middle")
|
| 1111 |
+
.style("font-family", "'Comic Sans MS', cursive")
|
| 1112 |
+
.style("font-size", `${currentFontSize}px`)
|
| 1113 |
+
.style("font-weight", "bold")
|
| 1114 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 1115 |
+
.attr("dominant-baseline", "alphabetic")
|
| 1116 |
+
.text(textContent);
|
| 1117 |
+
}
|
| 1118 |
+
}
|
| 1119 |
+
|
| 1120 |
+
// --- Right Bar --- (类似地修改数值标签)
|
| 1121 |
+
if (rightBarData) {
|
| 1122 |
+
const rightValue = +rightBarData[yField];
|
| 1123 |
+
if (isNaN(rightValue)) return; // Skip if value is not a number
|
| 1124 |
+
|
| 1125 |
+
const rightBarHeight = innerHeight - yScale(rightValue);
|
| 1126 |
+
const rightBarY = yScale(rightValue);
|
| 1127 |
+
const rightBarX = xScale(xValue) + groupScale(1); // Use groupScale for position
|
| 1128 |
+
|
| 1129 |
+
// Draw Bar
|
| 1130 |
+
chart.append("rect")
|
| 1131 |
+
.attr("class", "bar right-bar")
|
| 1132 |
+
.attr("x", rightBarX)
|
| 1133 |
+
.attr("y", rightBarY)
|
| 1134 |
+
.attr("width", barWidth)
|
| 1135 |
+
.attr("height", Math.max(0, rightBarHeight)) // Ensure height is not negative
|
| 1136 |
+
.attr("fill", "url(#pattern-sketch-right)") // Use sketch pattern
|
| 1137 |
+
.attr("stroke", variables.has_stroke ? "#555" : "none")
|
| 1138 |
+
.attr("stroke-width", variables.has_stroke ? 1 : 0)
|
| 1139 |
+
.style("filter", variables.has_shadow ? "url(#shadow)" : "none");
|
| 1140 |
+
|
| 1141 |
+
// --- 修改: 绘制带背景的数值标签 ---
|
| 1142 |
+
if (rightBarHeight > 5) {
|
| 1143 |
+
const labelX = rightBarX + barWidth / 2;
|
| 1144 |
+
const labelY = rightBarY - 8;
|
| 1145 |
+
const textContent = formatValueWithUnit(rightValue);
|
| 1146 |
+
|
| 1147 |
+
const labelGroup = chart.append("g")
|
| 1148 |
+
.attr("class", "value-label-group right-value-label");
|
| 1149 |
+
|
| 1150 |
+
// 复用字体大小调整逻辑
|
| 1151 |
+
let currentFontSize = baseFontSizeValue;
|
| 1152 |
+
let bbox = chartUtils.text.measure(labelGroup, textContent, {
|
| 1153 |
+
fontFamily: "'Comic Sans MS', cursive",
|
| 1154 |
+
fontSize: baseFontSizeValue,
|
| 1155 |
+
fontWeight: "bold"
|
| 1156 |
+
});
|
| 1157 |
+
if (bbox.width > valueLabelMaxWidth - 2 * sketchPadding) {
|
| 1158 |
+
currentFontSize = Math.max(8, baseFontSizeValue * ((valueLabelMaxWidth - 2 * sketchPadding) / bbox.width));
|
| 1159 |
+
}
|
| 1160 |
+
|
| 1161 |
+
bbox = chartUtils.text.measure(labelGroup, textContent, {
|
| 1162 |
+
fontFamily: "'Comic Sans MS', cursive",
|
| 1163 |
+
fontSize: currentFontSize,
|
| 1164 |
+
fontWeight: "bold"
|
| 1165 |
+
});
|
| 1166 |
+
|
| 1167 |
+
const bgWidth = bbox.width + 2 * sketchPadding;
|
| 1168 |
+
const bgHeight = bbox.height + 1.5 * sketchPadding;
|
| 1169 |
+
const bgX = labelX - bgWidth / 2;
|
| 1170 |
+
const bgY = labelY - bgHeight + sketchPadding / 2;
|
| 1171 |
+
|
| 1172 |
+
labelGroup.append("rect")
|
| 1173 |
+
.attr("x", bgX)
|
| 1174 |
+
.attr("y", bgY)
|
| 1175 |
+
.attr("width", bgWidth)
|
| 1176 |
+
.attr("height", bgHeight)
|
| 1177 |
+
.attr("fill", "url(#pattern-label-sketch)")
|
| 1178 |
+
.attr("stroke", "#AAAAAA")
|
| 1179 |
+
.attr("stroke-width", 0.5);
|
| 1180 |
+
|
| 1181 |
+
labelGroup.append("text")
|
| 1182 |
+
.attr("class", "bar-label")
|
| 1183 |
+
.attr("x", labelX)
|
| 1184 |
+
.attr("y", labelY)
|
| 1185 |
+
.attr("text-anchor", "middle")
|
| 1186 |
+
.style("font-family", "'Comic Sans MS', cursive")
|
| 1187 |
+
.style("font-size", `${currentFontSize}px`)
|
| 1188 |
+
.style("font-weight", "bold")
|
| 1189 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 1190 |
+
.attr("dominant-baseline", "alphabetic")
|
| 1191 |
+
.text(textContent);
|
| 1192 |
+
}
|
| 1193 |
+
}
|
| 1194 |
+
});
|
| 1195 |
+
|
| 1196 |
+
// ---------- 14. 返回SVG节点 ----------
|
| 1197 |
+
return svg.node();
|
| 1198 |
+
}
|
modules/chart_engine/template/d3-js/bar_chart/vertical_pictorial_percentage_bar_chart_01.js
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Pictorial Percentage Bar Chart",
|
| 5 |
+
"chart_name": "vertical_pictorial_percentage_bar_chart_01",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y"],
|
| 8 |
+
"required_fields_type": [["categorical"], ["numerical"]],
|
| 9 |
+
"required_fields_range": [[2, 10], [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
|
| 27 |
+
function makeChart(containerSelector, data) {
|
| 28 |
+
const colorResolver = chartUtils.color.resolver(data);
|
| 29 |
+
// ---------- 1. 数据准备 ----------
|
| 30 |
+
// 提取数据和配置
|
| 31 |
+
const jsonData = data; // 完整的JSON数据对象
|
| 32 |
+
const chartData = jsonData.data.data || []; // 图表数据
|
| 33 |
+
const variables = jsonData.variables || {}; // 图表配置,如果不存在则使用空对象
|
| 34 |
+
const typography = jsonData.typography || { // 字体设置,如果不存在则使用默认值
|
| 35 |
+
title: { font_family: "Arial", font_size: "28px", font_weight: 700 },
|
| 36 |
+
label: { font_family: "Arial", font_size: "16px", font_weight: 500 },
|
| 37 |
+
description: { font_family: "Arial", font_size: "16px", font_weight: 500 },
|
| 38 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: 400 }
|
| 39 |
+
};
|
| 40 |
+
const colors = jsonData.colors || {
|
| 41 |
+
text_color: "#000000",
|
| 42 |
+
other: { primary: "#4682B4", secondary: "#FF7F50" }
|
| 43 |
+
};
|
| 44 |
+
const images = jsonData.images || { field: {}, other: {} }; // 图像(国旗等)
|
| 45 |
+
const dataColumns = chartUtils.schema.columns(jsonData); // 使用data_columns
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
// 清空容器
|
| 49 |
+
d3.select(containerSelector).html("");
|
| 50 |
+
|
| 51 |
+
// ---------- 2. 提取字段名称 ----------
|
| 52 |
+
let xField, yField;
|
| 53 |
+
|
| 54 |
+
// 安全地提取字段名称
|
| 55 |
+
const xChannel = chartUtils.schema.channel(jsonData, "x");
|
| 56 |
+
const yChannel = chartUtils.schema.channel(jsonData, "y");
|
| 57 |
+
|
| 58 |
+
xField = xChannel.key;
|
| 59 |
+
yField = yChannel.key;
|
| 60 |
+
|
| 61 |
+
// 获取字段单位(如果存在)
|
| 62 |
+
let dimensionUnit = "";
|
| 63 |
+
let valueUnit = ""; // 默认为百分比
|
| 64 |
+
|
| 65 |
+
if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
|
| 66 |
+
dimensionUnit = chartUtils.schema.channel(jsonData, "x").unit;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
|
| 70 |
+
valueUnit = chartUtils.schema.channel(jsonData, "y").unit;
|
| 71 |
+
}
|
| 72 |
+
// 添加数值格式化函数
|
| 73 |
+
// ---------- 3. 数据处理 ----------
|
| 74 |
+
// 按照y值降序排序数据
|
| 75 |
+
const sortedData = [...chartData].sort((a, b) => b[yField] - a[yField]);
|
| 76 |
+
|
| 77 |
+
// ---------- 4. 尺寸和布局设置 ----------
|
| 78 |
+
const width = variables.width || 800;
|
| 79 |
+
const height = variables.height || 600;
|
| 80 |
+
|
| 81 |
+
// 边距: 上, 右, 下, 左
|
| 82 |
+
const margin = {
|
| 83 |
+
top: 120, // 减少顶部空间
|
| 84 |
+
right: 30,
|
| 85 |
+
bottom: 80, // 国旗和标签的空间
|
| 86 |
+
left: 30
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
// 计算实际绘图区域尺寸
|
| 90 |
+
const innerWidth = width - margin.left - margin.right;
|
| 91 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 92 |
+
|
| 93 |
+
// ---------- 5. 创建SVG容器 ----------
|
| 94 |
+
const svg = d3.select(containerSelector)
|
| 95 |
+
.append("svg")
|
| 96 |
+
.attr("width", width)
|
| 97 |
+
.attr("height", height)
|
| 98 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 99 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 100 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 101 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
// 创建主图表组
|
| 105 |
+
const chart = svg.append("g")
|
| 106 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 107 |
+
|
| 108 |
+
// ---------- 6. 创建比例尺 ----------
|
| 109 |
+
// X轴比例尺(瓶子之间的间隔)
|
| 110 |
+
const xScale = d3.scaleBand()
|
| 111 |
+
.domain(sortedData.map(d => d[xField]))
|
| 112 |
+
.range([0, innerWidth])
|
| 113 |
+
.padding(0.2);
|
| 114 |
+
|
| 115 |
+
// Y轴比例尺(瓶子的高度)
|
| 116 |
+
const yMax = d3.max(sortedData, d => +d[yField]);
|
| 117 |
+
const yScale = d3.scaleLinear()
|
| 118 |
+
.domain([0, yMax * 1.1]) // 添加10%的空间
|
| 119 |
+
.range([innerHeight, 0]);
|
| 120 |
+
|
| 121 |
+
// ---------- 7. 创建瓶子和蒙版 ----------
|
| 122 |
+
// 定义瓶子的宽度和高度 (不修改这部分)
|
| 123 |
+
const bottleWidth = xScale.bandwidth();
|
| 124 |
+
const bottleHeight = Math.max(bottleWidth * 3, innerHeight * 0.5);
|
| 125 |
+
|
| 126 |
+
// 获取瓶子的主要颜色
|
| 127 |
+
const bottleColor = colorResolver.other("primary", { fallback: "#4CAF50" }).value;
|
| 128 |
+
|
| 129 |
+
// 定义国旗和文本的空间
|
| 130 |
+
const flagTextHeight = 50;
|
| 131 |
+
|
| 132 |
+
// 计算瓶子底部应在的位置 - 让瓶子底部接近国旗
|
| 133 |
+
const bottleBottomY = innerHeight - 10; // 瓶子底部位置,紧贴国旗上方
|
| 134 |
+
const bottleTopY = bottleBottomY - bottleHeight; // 瓶子顶部位置
|
| 135 |
+
|
| 136 |
+
// 创建一个数组来存储所有的值标签元素引用
|
| 137 |
+
const valueLabels = [];
|
| 138 |
+
|
| 139 |
+
const bottleImageSVG = `<svg viewBox="0 0 200 500" xmlns="http://www.w3.org/2000/svg">
|
| 140 |
+
<path d="M60,280 L60,450 Q100,480 140,450 L140,280 Z" fill="#8BC34A"/>
|
| 141 |
+
|
| 142 |
+
<path d="M60,230 L60,280 L140,280 L140,230 Z" fill="#FDD835"/>
|
| 143 |
+
<circle cx="100" cy="255" r="25" fill="#FDD835"/>
|
| 144 |
+
|
| 145 |
+
<path d="M75,50 Q100,40 125,50 L125,70 Q100,80 75,70 Z" fill="#555555"/>
|
| 146 |
+
<path d="M80,70 L60,230 L140,230 L120,70 Z" fill="#555555"/>
|
| 147 |
+
|
| 148 |
+
<path d="M80,70 Q100,60 120,70 L120,70 Q100,80 80,70 Z" fill="#555555"/>
|
| 149 |
+
</svg>`;
|
| 150 |
+
|
| 151 |
+
// 辅助函数:将字符串转换为 base64
|
| 152 |
+
function svgToBase64(svg) {
|
| 153 |
+
// 移除所有换行符和多余的空格
|
| 154 |
+
const cleanSvg = svg.replace(/\s+/g, ' ').trim();
|
| 155 |
+
// 使用 encodeURIComponent 处理 Unicode 字符
|
| 156 |
+
const encoded = encodeURIComponent(cleanSvg);
|
| 157 |
+
return 'data:image/svg+xml;base64,' + btoa(unescape(encoded));
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// 将 SVG 转换为 base64
|
| 161 |
+
const bottleImageBase64 = svgToBase64(bottleImageSVG);
|
| 162 |
+
|
| 163 |
+
// 从SVG的viewBox中获取实际比例
|
| 164 |
+
const svgViewBox = bottleImageSVG.match(/viewBox="([^"]+)"/)[1].split(' ').map(Number);
|
| 165 |
+
const svgWidth = svgViewBox[2];
|
| 166 |
+
const svgHeight = svgViewBox[3];
|
| 167 |
+
const svgAspectRatio = svgHeight / svgWidth;
|
| 168 |
+
|
| 169 |
+
// 计算基础间距(在 makeChart 函数开始处添加)
|
| 170 |
+
const baseBottleWidth = xScale.bandwidth();
|
| 171 |
+
const baseBottleHeight = baseBottleWidth * svgAspectRatio;
|
| 172 |
+
const spacingAfterBottle = baseBottleWidth * 0.1; // 瓶子和图标之间的间距
|
| 173 |
+
const spacingBetweenIconLabel = baseBottleWidth * 0.15; // 图标和标签之间的间距
|
| 174 |
+
|
| 175 |
+
// 开始添加瓶子图像和蒙版
|
| 176 |
+
chart.selectAll(".bottle-group")
|
| 177 |
+
.data(sortedData)
|
| 178 |
+
.enter()
|
| 179 |
+
.append("g")
|
| 180 |
+
.attr("class", "bottle-group")
|
| 181 |
+
.attr("transform", d => `translate(${xScale(d[xField])}, ${bottleTopY})`)
|
| 182 |
+
.each(function(d, i) {
|
| 183 |
+
const group = d3.select(this);
|
| 184 |
+
const bottleWidth = xScale.bandwidth();
|
| 185 |
+
// 根据SVG的实际比例计算瓶子高度
|
| 186 |
+
const bottleHeight = bottleWidth * svgAspectRatio;
|
| 187 |
+
// 判断是否为最大值
|
| 188 |
+
const isMaxValue = +d[yField] === yMax;
|
| 189 |
+
const bottlePercentage = isMaxValue ? 1 : Math.max(0, (+d[yField] / yMax));
|
| 190 |
+
|
| 191 |
+
// 定义裁剪路径
|
| 192 |
+
const clipId = `clip-${i}`;
|
| 193 |
+
group.append("clipPath")
|
| 194 |
+
.attr("id", clipId)
|
| 195 |
+
.append("rect")
|
| 196 |
+
.attr("x", 0)
|
| 197 |
+
.attr("y", bottleHeight * (1 - bottlePercentage))
|
| 198 |
+
.attr("width", bottleWidth)
|
| 199 |
+
.attr("height", bottleHeight * bottlePercentage);
|
| 200 |
+
|
| 201 |
+
// 添加半透明的背景瓶子
|
| 202 |
+
group.append("image")
|
| 203 |
+
.attr("xlink:href", bottleImageBase64)
|
| 204 |
+
.attr("width", bottleWidth)
|
| 205 |
+
.attr("height", bottleHeight)
|
| 206 |
+
.attr("preserveAspectRatio", "xMidYMid meet")
|
| 207 |
+
.style("opacity", 0.15);
|
| 208 |
+
|
| 209 |
+
// 添加裁剪后的前景瓶子
|
| 210 |
+
group.append("image")
|
| 211 |
+
.attr("xlink:href", bottleImageBase64)
|
| 212 |
+
.attr("width", bottleWidth)
|
| 213 |
+
.attr("height", bottleHeight)
|
| 214 |
+
.attr("preserveAspectRatio", "xMidYMid meet")
|
| 215 |
+
.attr("clip-path", `url(#${clipId})`);
|
| 216 |
+
|
| 217 |
+
// 计算标签方块的大小和位置
|
| 218 |
+
const squareSize = bottleWidth * 0.6;
|
| 219 |
+
const squareX = (bottleWidth - squareSize) / 2;
|
| 220 |
+
const squareY = bottleHeight * (2/3) - (squareSize / 2);
|
| 221 |
+
|
| 222 |
+
// 添加正方形背景
|
| 223 |
+
group.append("rect")
|
| 224 |
+
.attr("class", "value-bg")
|
| 225 |
+
.attr("x", squareX)
|
| 226 |
+
.attr("y", squareY)
|
| 227 |
+
.attr("width", squareSize)
|
| 228 |
+
.attr("height", squareSize)
|
| 229 |
+
.attr("fill", "#f5f5f5")
|
| 230 |
+
.attr("opacity", 0.8)
|
| 231 |
+
.attr("rx", 5)
|
| 232 |
+
.attr("ry", 5);
|
| 233 |
+
|
| 234 |
+
// 格式化值
|
| 235 |
+
|
| 236 |
+
const formattedValue = `${chartUtils.format.autoText(+d[yField])}${valueUnit}`;
|
| 237 |
+
// 我们将在下面计算实际字体大小,先使用基础大小创建文本元素
|
| 238 |
+
const baseValueFontSize = 16;
|
| 239 |
+
|
| 240 |
+
// 添加数值文本但先不设置字体大小
|
| 241 |
+
const valueText = group.append("text")
|
| 242 |
+
.attr("class", "value-label")
|
| 243 |
+
.attr("x", bottleWidth / 2)
|
| 244 |
+
.attr("y", squareY + (squareSize * 0.6)) // 先设置一个位置,稍后会调整
|
| 245 |
+
.attr("text-anchor", "middle")
|
| 246 |
+
.style("font-family", typography.label.font_family)
|
| 247 |
+
.style("font-weight", "bold")
|
| 248 |
+
.style("fill", "#333")
|
| 249 |
+
.style("font-size", `${baseValueFontSize}px`) // 先设置基础字体大小
|
| 250 |
+
.text(formattedValue);
|
| 251 |
+
|
| 252 |
+
// 将值标签添加到数组中,以便后续统一调整
|
| 253 |
+
valueLabels.push({
|
| 254 |
+
element: valueText,
|
| 255 |
+
value: formattedValue,
|
| 256 |
+
squareSize: squareSize
|
| 257 |
+
});
|
| 258 |
+
});
|
| 259 |
+
|
| 260 |
+
// ---------- 8. 添加国旗和国家名称 (修改后) ----------
|
| 261 |
+
const baseLabelFontSize = 14; // 基础字体大小
|
| 262 |
+
// 文本换行辅助函数 (添加 alignment 参数)
|
| 263 |
+
function wrapText(text, str, width, lineHeight = 1.1, alignment = 'middle') {
|
| 264 |
+
const words = str.split(/\s+/).reverse(); // 按空格分割单词
|
| 265 |
+
let word;
|
| 266 |
+
let line = [];
|
| 267 |
+
let lineNumber = 0;
|
| 268 |
+
const initialY = parseFloat(text.attr("y")); // 获取原始y坐标
|
| 269 |
+
const initialX = parseFloat(text.attr("x")); // 获取原始x坐标
|
| 270 |
+
const actualFontSize = parseFloat(text.style("font-size")); // 获取实际应用的字体大小
|
| 271 |
+
|
| 272 |
+
text.text(null); // 清空现有文本
|
| 273 |
+
|
| 274 |
+
let tspans = []; // 存储最终要渲染的行
|
| 275 |
+
|
| 276 |
+
// 优先按单词换行
|
| 277 |
+
if (words.length > 1) {
|
| 278 |
+
let currentLine = [];
|
| 279 |
+
while (word = words.pop()) {
|
| 280 |
+
currentLine.push(word);
|
| 281 |
+
const isOverflow = chartUtils.text.measure(null, currentLine.join(" "), {
|
| 282 |
+
fontFamily: text.style("font-family"),
|
| 283 |
+
fontSize: parseFloat(text.style("font-size")),
|
| 284 |
+
fontWeight: text.style("font-weight")
|
| 285 |
+
}).width > width;
|
| 286 |
+
|
| 287 |
+
if (isOverflow && currentLine.length > 1) {
|
| 288 |
+
currentLine.pop(); // 回退一个词
|
| 289 |
+
tspans.push(currentLine.join(" ")); // 添加完成的行
|
| 290 |
+
currentLine = [word]; // 新行以当前词开始
|
| 291 |
+
lineNumber++;
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
// 添加最后一行
|
| 295 |
+
if (currentLine.length > 0) {
|
| 296 |
+
tspans.push(currentLine.join(" "));
|
| 297 |
+
}
|
| 298 |
+
} else { // 如果没有空格或只有一个词,则按字符换行
|
| 299 |
+
const chars = str.split('');
|
| 300 |
+
let currentLine = '';
|
| 301 |
+
for (let i = 0; i < chars.length; i++) {
|
| 302 |
+
const nextLine = currentLine + chars[i];
|
| 303 |
+
const isOverflow = chartUtils.text.measure(null, nextLine, {
|
| 304 |
+
fontFamily: text.style("font-family"),
|
| 305 |
+
fontSize: parseFloat(text.style("font-size")),
|
| 306 |
+
fontWeight: text.style("font-weight")
|
| 307 |
+
}).width > width;
|
| 308 |
+
|
| 309 |
+
if (isOverflow && currentLine.length > 0) { // 如果加了新字符就超长了,并且当前行不为空
|
| 310 |
+
tspans.push(currentLine); // 添加当前行
|
| 311 |
+
currentLine = chars[i]; // 新行从这个字符开始
|
| 312 |
+
lineNumber++;
|
| 313 |
+
} else {
|
| 314 |
+
currentLine = nextLine; // 没超长就继续加字符
|
| 315 |
+
}
|
| 316 |
+
}
|
| 317 |
+
// 添加最后一行
|
| 318 |
+
if (currentLine.length > 0) {
|
| 319 |
+
tspans.push(currentLine);
|
| 320 |
+
}
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
// 计算总行数
|
| 324 |
+
const totalLines = tspans.length;
|
| 325 |
+
let startDy = 0;
|
| 326 |
+
|
| 327 |
+
// 根据对齐方式计算起始偏移
|
| 328 |
+
if (alignment === 'middle') {
|
| 329 |
+
// 垂直居中:向上移动半行*(总行数-1)
|
| 330 |
+
startDy = -( (totalLines - 1) * lineHeight / 2);
|
| 331 |
+
} else if (alignment === 'bottom') {
|
| 332 |
+
// 底部对齐:计算总高度,向上移动 总高度 - 单行高度(近似)
|
| 333 |
+
// 注意:em单位是相对于字体大小的,这里用 lineHeight * actualFontSize 近似计算像素高度
|
| 334 |
+
const totalHeightEm = totalLines * lineHeight;
|
| 335 |
+
startDy = -(totalHeightEm - lineHeight); // 将底部对齐到原始y
|
| 336 |
+
}
|
| 337 |
+
// 如果是 'top' 对齐,startDy 保持为 0,即第一行基线在原始y位置
|
| 338 |
+
// 其他对齐方式(如 'top')可以保持 startDy 为 0
|
| 339 |
+
|
| 340 |
+
// 创建所有行的tspan元素
|
| 341 |
+
tspans.forEach((lineText, i) => {
|
| 342 |
+
text.append("tspan")
|
| 343 |
+
.attr("x", initialX) // x坐标与父<text>相同
|
| 344 |
+
.attr("dy", (i === 0 ? startDy : lineHeight) + "em") // 第一行应用起始偏移,后续行应用行高
|
| 345 |
+
.text(lineText);
|
| 346 |
+
});
|
| 347 |
+
}
|
| 348 |
+
// 计算所有维度标签在基础字体大小下的宽度
|
| 349 |
+
const dimensionWidths = sortedData.map(d => chartUtils.text.measure(svg, d[xField], {
|
| 350 |
+
fontFamily: typography.label.font_family,
|
| 351 |
+
fontSize: baseLabelFontSize
|
| 352 |
+
}).width);
|
| 353 |
+
|
| 354 |
+
const maxAvailableWidth = xScale.bandwidth() * 1.2;
|
| 355 |
+
|
| 356 |
+
// 计算是否需要缩小字体以及缩放比例
|
| 357 |
+
const maxDimensionWidth = Math.max(...dimensionWidths);
|
| 358 |
+
const labelScaleFactor = maxDimensionWidth > maxAvailableWidth ? maxAvailableWidth / maxDimensionWidth : 1;
|
| 359 |
+
|
| 360 |
+
// 确定最终的统一字体大小(增大1.5倍)
|
| 361 |
+
const finalLabelFontSize = baseLabelFontSize * labelScaleFactor * 1.5;
|
| 362 |
+
|
| 363 |
+
// 开始添加国旗和标签组
|
| 364 |
+
chart.selectAll(".flag-label-group")
|
| 365 |
+
.data(sortedData)
|
| 366 |
+
.enter()
|
| 367 |
+
.append("g")
|
| 368 |
+
.attr("class", "flag-label-group")
|
| 369 |
+
// 根据实际瓶子高度调整位置
|
| 370 |
+
.attr("transform", d => `translate(${xScale(d[xField]) + baseBottleWidth / 2}, ${bottleTopY + baseBottleHeight + spacingAfterBottle})`)
|
| 371 |
+
.each(function(d, i) {
|
| 372 |
+
const group = d3.select(this);
|
| 373 |
+
|
| 374 |
+
// 计算图标尺寸
|
| 375 |
+
const iconWidth = baseBottleWidth;
|
| 376 |
+
const iconHeight = iconWidth * 0.75;
|
| 377 |
+
|
| 378 |
+
// 添加国旗(如果数据中提供了图片字段和对应的值)
|
| 379 |
+
if (images.field && images.field[d[xField]]) {
|
| 380 |
+
group.append("image")
|
| 381 |
+
.attr("xlink:href", images.field[d[xField]])
|
| 382 |
+
.attr("x", -iconWidth / 2)
|
| 383 |
+
.attr("y", 0)
|
| 384 |
+
.attr("width", iconWidth)
|
| 385 |
+
.attr("height", iconHeight)
|
| 386 |
+
.attr("preserveAspectRatio", "xMidYMid meet");
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
// 添加国家名称文本
|
| 390 |
+
const label = group.append("text")
|
| 391 |
+
.attr("class", "country-label")
|
| 392 |
+
.attr("x", 0)
|
| 393 |
+
.attr("y", iconHeight + spacingBetweenIconLabel)
|
| 394 |
+
.attr("text-anchor", "middle")
|
| 395 |
+
.style("font-family", typography.label.font_family)
|
| 396 |
+
.style("font-size", `${finalLabelFontSize}px`)
|
| 397 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 398 |
+
.text(d[xField]);
|
| 399 |
+
|
| 400 |
+
// 如果需要换行,重新应用换行
|
| 401 |
+
const labelMaxWidth = baseBottleWidth * 1.05;
|
| 402 |
+
if (chartUtils.text.measure(chart, d[xField], {
|
| 403 |
+
fontFamily: typography.label.font_family,
|
| 404 |
+
fontSize: finalLabelFontSize
|
| 405 |
+
}).width > labelMaxWidth) {
|
| 406 |
+
wrapText(label, d[xField].toString(), labelMaxWidth, 1.1, 'top');
|
| 407 |
+
}
|
| 408 |
+
});
|
| 409 |
+
|
| 410 |
+
// ---------- 9. 统一调整所有值标签的字体大小 ----------
|
| 411 |
+
// 创建临时文本元素来测量值标签文本宽度
|
| 412 |
+
|
| 413 |
+
const baseValueFontSize = 16;
|
| 414 |
+
// 计算所有值标签在基础字体大小下的实际宽度
|
| 415 |
+
const valueTextWidths = valueLabels.map(item => {
|
| 416 |
+
return chartUtils.text.measure(svg, item.value, {
|
| 417 |
+
fontFamily: typography.label.font_family,
|
| 418 |
+
fontSize: `${baseValueFontSize}px`,
|
| 419 |
+
fontWeight: "bold"
|
| 420 |
+
}).width;
|
| 421 |
+
});
|
| 422 |
+
|
| 423 |
+
// 计算每个标签所需的缩放比例(方形宽度 / 文本宽度)
|
| 424 |
+
const valueScaleFactors = valueLabels.map((item, i) => {
|
| 425 |
+
// 使用60%的方形宽度作为最大可用宽度
|
| 426 |
+
const maxWidth = item.squareSize * 1; // 留一些边距
|
| 427 |
+
return maxWidth / valueTextWidths[i];
|
| 428 |
+
});
|
| 429 |
+
|
| 430 |
+
// 取最小的缩放比例,确保所有值标签使用相同的字体大小
|
| 431 |
+
const minValueScaleFactor = Math.min(...valueScaleFactors, 1); // 不要放大,只缩小
|
| 432 |
+
|
| 433 |
+
// 计算最终的统一字体大小
|
| 434 |
+
const finalValueFontSize = Math.min(baseValueFontSize * minValueScaleFactor, baseValueFontSize);
|
| 435 |
+
|
| 436 |
+
// 应用统一的字体大小到所有值标签
|
| 437 |
+
valueLabels.forEach(item => {
|
| 438 |
+
item.element
|
| 439 |
+
.style("font-size", `${finalValueFontSize}px`);
|
| 440 |
+
});
|
| 441 |
+
|
| 442 |
+
// 返回SVG节点
|
| 443 |
+
return svg.node();
|
| 444 |
+
}
|
modules/chart_engine/template/d3-js/bar_chart/vertical_pictorial_percentage_bar_chart_02.js
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Pictorial Percentage Bar Chart",
|
| 5 |
+
"chart_name": "vertical_pictorial_percentage_bar_chart_02",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y"],
|
| 8 |
+
"required_fields_type": [["categorical"], ["numerical"]],
|
| 9 |
+
"required_fields_range": [[2, 10], [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": "dark",
|
| 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
|
| 27 |
+
function makeChart(containerSelector, data) {
|
| 28 |
+
const colorResolver = chartUtils.color.resolver(data);
|
| 29 |
+
// ---------- 1. 数据准备 ----------
|
| 30 |
+
// 提取数据和配置
|
| 31 |
+
const jsonData = data; // 完整的JSON数据对象
|
| 32 |
+
const chartData = jsonData.data.data || []; // 图表数据
|
| 33 |
+
const variables = jsonData.variables || {}; // 图表配置,如果不存在则使用空对象
|
| 34 |
+
const typography = jsonData.typography || { // 字体设置,如果不存在则使用默认值
|
| 35 |
+
title: { font_family: "Arial", font_size: "28px", font_weight: 700 },
|
| 36 |
+
label: { font_family: "Arial", font_size: "16px", font_weight: 500 },
|
| 37 |
+
description: { font_family: "Arial", font_size: "16px", font_weight: 500 },
|
| 38 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: 400 }
|
| 39 |
+
};
|
| 40 |
+
const colors = jsonData.colors_dark || {
|
| 41 |
+
text_color: "#000000",
|
| 42 |
+
other: { primary: "#4682B4", secondary: "#FF7F50" }
|
| 43 |
+
};
|
| 44 |
+
const images = jsonData.images || { field: {}, other: {} }; // 图像(国旗等)
|
| 45 |
+
const dataColumns = chartUtils.schema.columns(jsonData); // 使用data_columns
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
// 清空容器
|
| 49 |
+
d3.select(containerSelector).html("");
|
| 50 |
+
|
| 51 |
+
// ---------- 2. 提取字段名称 ----------
|
| 52 |
+
let xField, yField;
|
| 53 |
+
|
| 54 |
+
// 安全地提取字段名称
|
| 55 |
+
const xChannel = chartUtils.schema.channel(jsonData, "x");
|
| 56 |
+
const yChannel = chartUtils.schema.channel(jsonData, "y");
|
| 57 |
+
|
| 58 |
+
xField = xChannel.key;
|
| 59 |
+
yField = yChannel.key;
|
| 60 |
+
|
| 61 |
+
// 获取字段单位(如果存在)
|
| 62 |
+
let dimensionUnit = "";
|
| 63 |
+
let valueUnit = ""; // 默认为百分比
|
| 64 |
+
|
| 65 |
+
if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
|
| 66 |
+
dimensionUnit = chartUtils.schema.channel(jsonData, "x").unit;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
|
| 70 |
+
valueUnit = chartUtils.schema.channel(jsonData, "y").unit;
|
| 71 |
+
}
|
| 72 |
+
// 添加数值格式化函数
|
| 73 |
+
// ---------- 3. 数据处理 ----------
|
| 74 |
+
// 按照y值降序排序数据
|
| 75 |
+
const sortedData = [...chartData].sort((a, b) => b[yField] - a[yField]);
|
| 76 |
+
|
| 77 |
+
// ---------- 4. 尺寸和布局设置 ----------
|
| 78 |
+
const width = variables.width || 800;
|
| 79 |
+
const height = variables.height || 600;
|
| 80 |
+
|
| 81 |
+
// 边距: 上, 右, 下, 左
|
| 82 |
+
const margin = {
|
| 83 |
+
top: 120, // 减少顶部空间
|
| 84 |
+
right: 30,
|
| 85 |
+
bottom: 80, // 国旗和标签的空间
|
| 86 |
+
left: 30
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
// 计算实际绘图区域尺寸
|
| 90 |
+
const innerWidth = width - margin.left - margin.right;
|
| 91 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 92 |
+
|
| 93 |
+
// ---------- 5. 创建SVG容器 ----------
|
| 94 |
+
const svg = d3.select(containerSelector)
|
| 95 |
+
.append("svg")
|
| 96 |
+
.attr("width", width)
|
| 97 |
+
.attr("height", height)
|
| 98 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 99 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 100 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 101 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
// 创建主图表组
|
| 105 |
+
const chart = svg.append("g")
|
| 106 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 107 |
+
|
| 108 |
+
// ---------- 6. 创建比例尺 ----------
|
| 109 |
+
// X轴比例尺(瓶子之间的间隔)
|
| 110 |
+
const xScale = d3.scaleBand()
|
| 111 |
+
.domain(sortedData.map(d => d[xField]))
|
| 112 |
+
.range([0, innerWidth])
|
| 113 |
+
.padding(0.2);
|
| 114 |
+
|
| 115 |
+
// Y轴比例尺(瓶子的高度)
|
| 116 |
+
const yMax = d3.max(sortedData, d => +d[yField]);
|
| 117 |
+
const yScale = d3.scaleLinear()
|
| 118 |
+
.domain([0, yMax * 1.1]) // 添加10%的空间
|
| 119 |
+
.range([innerHeight, 0]);
|
| 120 |
+
|
| 121 |
+
// ---------- 7. 创建瓶子和蒙版 ----------
|
| 122 |
+
// 定义瓶子的宽度和高度 (不修改这部分)
|
| 123 |
+
const bottleWidth = xScale.bandwidth();
|
| 124 |
+
const bottleHeight = Math.max(bottleWidth * 3, innerHeight * 0.5);
|
| 125 |
+
|
| 126 |
+
// 获取瓶子的主要颜色
|
| 127 |
+
const bottleColor = colorResolver.other("primary", { fallback: "#4CAF50" }).value;
|
| 128 |
+
|
| 129 |
+
// 定义国旗和文本的空间
|
| 130 |
+
const flagTextHeight = 50;
|
| 131 |
+
|
| 132 |
+
// 计算瓶子底部应在的位置 - 让瓶子底部接近国旗
|
| 133 |
+
const bottleBottomY = innerHeight - 10; // 瓶子底部位置,紧贴国旗上方
|
| 134 |
+
const bottleTopY = bottleBottomY - bottleHeight; // 瓶子顶部位置
|
| 135 |
+
|
| 136 |
+
// 创建一个数组来存储所有的值标签元素引用
|
| 137 |
+
const valueLabels = [];
|
| 138 |
+
const bottleImageSVG = `<svg viewBox="0 0 200 500" xmlns="http://www.w3.org/2000/svg">
|
| 139 |
+
<!-- 瓶身主体 -->
|
| 140 |
+
<path d="M60,280 L60,450 Q100,480 140,450 L140,280 Z" fill="#2196F3" filter="url(#glow)"/>
|
| 141 |
+
|
| 142 |
+
<!-- 瓶颈过渡部分 -->
|
| 143 |
+
<path d="M60,230 L60,280 L140,280 L140,230 Z" fill="#64B5F6"/>
|
| 144 |
+
<circle cx="100" cy="255" r="25" fill="#64B5F6"/>
|
| 145 |
+
|
| 146 |
+
<!-- 瓶口 -->
|
| 147 |
+
<path d="M75,50 Q100,40 125,50 L125,70 Q100,80 75,70 Z" fill="#90CAF9"/>
|
| 148 |
+
<path d="M80,70 L60,230 L140,230 L120,70 Z" fill="#90CAF9"/>
|
| 149 |
+
<path d="M80,70 Q100,60 120,70 L120,70 Q100,80 80,70 Z" fill="#90CAF9"/>
|
| 150 |
+
|
| 151 |
+
<!-- 添加光泽效果 -->
|
| 152 |
+
<defs>
|
| 153 |
+
<linearGradient id="shine" x1="0%" y1="0%" x2="100%" y2="0%">
|
| 154 |
+
<stop offset="0%" style="stop-color:rgba(255,255,255,0.1)"/>
|
| 155 |
+
<stop offset="50%" style="stop-color:rgba(255,255,255,0.3)"/>
|
| 156 |
+
<stop offset="100%" style="stop-color:rgba(255,255,255,0.1)"/>
|
| 157 |
+
</linearGradient>
|
| 158 |
+
<filter id="glow">
|
| 159 |
+
<feGaussianBlur stdDeviation="2" result="blur"/>
|
| 160 |
+
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
|
| 161 |
+
</filter>
|
| 162 |
+
</defs>
|
| 163 |
+
|
| 164 |
+
<!-- 添加光泽效果 -->
|
| 165 |
+
<path d="M70,280 L70,440 Q100,460 130,440 L130,280 Z" fill="url(#shine)" opacity="0.5"/>
|
| 166 |
+
</svg>`;
|
| 167 |
+
|
| 168 |
+
// 辅助函数:将字符串转换为 base64
|
| 169 |
+
function svgToBase64(svg) {
|
| 170 |
+
// 移除所有换行符和多余的空格
|
| 171 |
+
const cleanSvg = svg.replace(/\s+/g, ' ').trim();
|
| 172 |
+
// 使用 encodeURIComponent 处理 Unicode 字符
|
| 173 |
+
const encoded = encodeURIComponent(cleanSvg);
|
| 174 |
+
return 'data:image/svg+xml;base64,' + btoa(unescape(encoded));
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
// 将 SVG 转换为 base64
|
| 178 |
+
const bottleImageBase64 = svgToBase64(bottleImageSVG);
|
| 179 |
+
|
| 180 |
+
// 从SVG的viewBox中获取实际比例
|
| 181 |
+
const svgViewBox = bottleImageSVG.match(/viewBox="([^"]+)"/)[1].split(' ').map(Number);
|
| 182 |
+
const svgWidth = svgViewBox[2];
|
| 183 |
+
const svgHeight = svgViewBox[3];
|
| 184 |
+
const svgAspectRatio = svgHeight / svgWidth;
|
| 185 |
+
|
| 186 |
+
// 计算基础间距(在 makeChart 函数开始处添加)
|
| 187 |
+
const baseBottleWidth = xScale.bandwidth();
|
| 188 |
+
const baseBottleHeight = baseBottleWidth * svgAspectRatio;
|
| 189 |
+
const spacingAfterBottle = baseBottleWidth * 0.1; // 瓶子和图标之间的间距
|
| 190 |
+
const spacingBetweenIconLabel = baseBottleWidth * 0.15; // 图标和标签之间的间距
|
| 191 |
+
|
| 192 |
+
// 开始添加瓶子图像和蒙版
|
| 193 |
+
chart.selectAll(".bottle-group")
|
| 194 |
+
.data(sortedData)
|
| 195 |
+
.enter()
|
| 196 |
+
.append("g")
|
| 197 |
+
.attr("class", "bottle-group")
|
| 198 |
+
.attr("transform", d => `translate(${xScale(d[xField])}, ${bottleTopY})`)
|
| 199 |
+
.each(function(d, i) {
|
| 200 |
+
const group = d3.select(this);
|
| 201 |
+
const bottleWidth = xScale.bandwidth();
|
| 202 |
+
// 根据SVG的实际比例计算瓶子高度
|
| 203 |
+
const bottleHeight = bottleWidth * svgAspectRatio;
|
| 204 |
+
// 判断是否为最大值
|
| 205 |
+
const isMaxValue = +d[yField] === yMax;
|
| 206 |
+
const bottlePercentage = isMaxValue ? 1 : Math.max(0, (+d[yField] / yMax));
|
| 207 |
+
|
| 208 |
+
// 定义裁剪路径
|
| 209 |
+
const clipId = `clip-${i}`;
|
| 210 |
+
group.append("clipPath")
|
| 211 |
+
.attr("id", clipId)
|
| 212 |
+
.append("rect")
|
| 213 |
+
.attr("x", 0)
|
| 214 |
+
.attr("y", bottleHeight * (1 - bottlePercentage))
|
| 215 |
+
.attr("width", bottleWidth)
|
| 216 |
+
.attr("height", bottleHeight * bottlePercentage);
|
| 217 |
+
|
| 218 |
+
// 添加半透明的背景瓶子
|
| 219 |
+
group.append("image")
|
| 220 |
+
.attr("xlink:href", bottleImageBase64)
|
| 221 |
+
.attr("width", bottleWidth)
|
| 222 |
+
.attr("height", bottleHeight)
|
| 223 |
+
.attr("preserveAspectRatio", "xMidYMid meet")
|
| 224 |
+
.style("opacity", 0.15);
|
| 225 |
+
|
| 226 |
+
// 添加裁剪后的前景瓶子
|
| 227 |
+
group.append("image")
|
| 228 |
+
.attr("xlink:href", bottleImageBase64)
|
| 229 |
+
.attr("width", bottleWidth)
|
| 230 |
+
.attr("height", bottleHeight)
|
| 231 |
+
.attr("preserveAspectRatio", "xMidYMid meet")
|
| 232 |
+
.attr("clip-path", `url(#${clipId})`);
|
| 233 |
+
|
| 234 |
+
// 计算标签方块的大小和位置
|
| 235 |
+
const squareSize = bottleWidth * 0.6;
|
| 236 |
+
const squareX = (bottleWidth - squareSize) / 2;
|
| 237 |
+
const squareY = bottleHeight * (2/3) - (squareSize / 2);
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
// const formattedValue = `${chartUtils.format.autoText(+d[yField])}${valueUnit}`;
|
| 241 |
+
// // 我们将在下面计算实际字体大小,先使用基础大小创建文本元素
|
| 242 |
+
// const baseValueFontSize = 16;
|
| 243 |
+
|
| 244 |
+
// // 添加数值文本但先不设置字体大小
|
| 245 |
+
// const valueText = group.append("text")
|
| 246 |
+
// .attr("class", "value-label")
|
| 247 |
+
// .attr("x", bottleWidth / 2)
|
| 248 |
+
// .attr("y", squareY + (squareSize * 0.6)) // 先设置一个位置,稍后会调整
|
| 249 |
+
// .attr("text-anchor", "middle")
|
| 250 |
+
// .style("font-family", typography.label.font_family)
|
| 251 |
+
// .style("font-weight", "bold")
|
| 252 |
+
// .style("fill", "#fff")
|
| 253 |
+
// .style("font-size", `${baseValueFontSize}px`) // 先设置基础字体大小
|
| 254 |
+
// .text(formattedValue);
|
| 255 |
+
|
| 256 |
+
// // 将值标签添加到数组中,以便后续统一调整
|
| 257 |
+
// valueLabels.push({
|
| 258 |
+
// element: valueText,
|
| 259 |
+
// value: formattedValue,
|
| 260 |
+
// squareSize: squareSize
|
| 261 |
+
// });
|
| 262 |
+
});
|
| 263 |
+
|
| 264 |
+
// ---------- 8. 添加国旗和国家名称 (修改后) ----------
|
| 265 |
+
const baseLabelFontSize = 14; // 基础字体大小
|
| 266 |
+
// 文本换行辅助函数 (添加 alignment 参数)
|
| 267 |
+
function wrapText(text, str, width, lineHeight = 1.1, alignment = 'middle') {
|
| 268 |
+
const words = str.split(/\s+/).reverse(); // 按空格分割单词
|
| 269 |
+
let word;
|
| 270 |
+
let line = [];
|
| 271 |
+
let lineNumber = 0;
|
| 272 |
+
const initialY = parseFloat(text.attr("y")); // 获取原始y坐标
|
| 273 |
+
const initialX = parseFloat(text.attr("x")); // 获取原始x坐标
|
| 274 |
+
const actualFontSize = parseFloat(text.style("font-size")); // 获取实际应用的字体大小
|
| 275 |
+
|
| 276 |
+
text.text(null); // 清空现有文本
|
| 277 |
+
|
| 278 |
+
let tspans = []; // 存储最终要渲染的行
|
| 279 |
+
|
| 280 |
+
// 优先按单词换行
|
| 281 |
+
if (words.length > 1) {
|
| 282 |
+
let currentLine = [];
|
| 283 |
+
while (word = words.pop()) {
|
| 284 |
+
currentLine.push(word);
|
| 285 |
+
const isOverflow = chartUtils.text.measure(null, currentLine.join(" "), {
|
| 286 |
+
fontFamily: text.style("font-family"),
|
| 287 |
+
fontSize: parseFloat(text.style("font-size")),
|
| 288 |
+
fontWeight: text.style("font-weight")
|
| 289 |
+
}).width > width;
|
| 290 |
+
|
| 291 |
+
if (isOverflow && currentLine.length > 1) {
|
| 292 |
+
currentLine.pop(); // 回退一个词
|
| 293 |
+
tspans.push(currentLine.join(" ")); // 添加完成的行
|
| 294 |
+
currentLine = [word]; // 新行以当前词开始
|
| 295 |
+
lineNumber++;
|
| 296 |
+
}
|
| 297 |
+
}
|
| 298 |
+
// 添加最后一行
|
| 299 |
+
if (currentLine.length > 0) {
|
| 300 |
+
tspans.push(currentLine.join(" "));
|
| 301 |
+
}
|
| 302 |
+
} else { // 如果没有空格或只有一个词,则按字符换行
|
| 303 |
+
const chars = str.split('');
|
| 304 |
+
let currentLine = '';
|
| 305 |
+
for (let i = 0; i < chars.length; i++) {
|
| 306 |
+
const nextLine = currentLine + chars[i];
|
| 307 |
+
const isOverflow = chartUtils.text.measure(null, nextLine, {
|
| 308 |
+
fontFamily: text.style("font-family"),
|
| 309 |
+
fontSize: parseFloat(text.style("font-size")),
|
| 310 |
+
fontWeight: text.style("font-weight")
|
| 311 |
+
}).width > width;
|
| 312 |
+
|
| 313 |
+
if (isOverflow && currentLine.length > 0) { // 如果加了新字符就超长了,并且当前行不为空
|
| 314 |
+
tspans.push(currentLine); // 添加当前行
|
| 315 |
+
currentLine = chars[i]; // 新行从这个字符开始
|
| 316 |
+
lineNumber++;
|
| 317 |
+
} else {
|
| 318 |
+
currentLine = nextLine; // 没超长就继续加字符
|
| 319 |
+
}
|
| 320 |
+
}
|
| 321 |
+
// 添加最后一行
|
| 322 |
+
if (currentLine.length > 0) {
|
| 323 |
+
tspans.push(currentLine);
|
| 324 |
+
}
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
// 计算总行数
|
| 328 |
+
const totalLines = tspans.length;
|
| 329 |
+
let startDy = 0;
|
| 330 |
+
|
| 331 |
+
// 根据对齐方式计算起始偏移
|
| 332 |
+
if (alignment === 'middle') {
|
| 333 |
+
// 垂直居中:向上移动半行*(总行数-1)
|
| 334 |
+
startDy = -( (totalLines - 1) * lineHeight / 2);
|
| 335 |
+
} else if (alignment === 'bottom') {
|
| 336 |
+
// 底部对齐:计算总高度,向上移动 总高度 - 单行高度(近似)
|
| 337 |
+
// 注意:em单位是相对于字体大小的,这里用 lineHeight * actualFontSize 近似计算像素高度
|
| 338 |
+
const totalHeightEm = totalLines * lineHeight;
|
| 339 |
+
startDy = -(totalHeightEm - lineHeight); // 将底部对齐到原始y
|
| 340 |
+
}
|
| 341 |
+
// 如果是 'top' 对齐,startDy 保持为 0,即第一行基线在原始y位置
|
| 342 |
+
// 其他对齐方式(如 'top')可以保持 startDy 为 0
|
| 343 |
+
|
| 344 |
+
// 创建所有行的tspan元素
|
| 345 |
+
tspans.forEach((lineText, i) => {
|
| 346 |
+
text.append("tspan")
|
| 347 |
+
.attr("x", initialX) // x坐标与父<text>相同
|
| 348 |
+
.attr("dy", (i === 0 ? startDy : lineHeight) + "em") // 第一行应用起始偏移,后续行应用行高
|
| 349 |
+
.text(lineText);
|
| 350 |
+
});
|
| 351 |
+
}
|
| 352 |
+
// 计算所有维度标签在基础字体大小下的宽度
|
| 353 |
+
const dimensionWidths = sortedData.map(d => chartUtils.text.measure(svg, d[xField], {
|
| 354 |
+
fontFamily: typography.label.font_family,
|
| 355 |
+
fontSize: baseLabelFontSize
|
| 356 |
+
}).width);
|
| 357 |
+
|
| 358 |
+
const maxAvailableWidth = xScale.bandwidth() * 1.2;
|
| 359 |
+
|
| 360 |
+
// 计算是否需要缩小字体以及缩放比例
|
| 361 |
+
const maxDimensionWidth = Math.max(...dimensionWidths);
|
| 362 |
+
const labelScaleFactor = maxDimensionWidth > maxAvailableWidth ? maxAvailableWidth / maxDimensionWidth : 1;
|
| 363 |
+
|
| 364 |
+
// 确定最终的统一字体大小(增大1.5倍)
|
| 365 |
+
const finalLabelFontSize = baseLabelFontSize * labelScaleFactor * 1.5;
|
| 366 |
+
|
| 367 |
+
// 开始添加国旗和标签组
|
| 368 |
+
chart.selectAll(".flag-label-group")
|
| 369 |
+
.data(sortedData)
|
| 370 |
+
.enter()
|
| 371 |
+
.append("g")
|
| 372 |
+
.attr("class", "flag-label-group")
|
| 373 |
+
// 根据实际瓶子高度调整位置
|
| 374 |
+
.attr("transform", d => `translate(${xScale(d[xField]) + baseBottleWidth / 2}, ${bottleTopY + baseBottleHeight + spacingAfterBottle})`)
|
| 375 |
+
.each(function(d, i) {
|
| 376 |
+
const group = d3.select(this);
|
| 377 |
+
|
| 378 |
+
// 计算图标尺寸
|
| 379 |
+
const iconWidth = baseBottleWidth;
|
| 380 |
+
const iconHeight = iconWidth * 0.75;
|
| 381 |
+
|
| 382 |
+
// 添加国旗(如果数据中提供了图片字段和对应的值)
|
| 383 |
+
if (images.field && images.field[d[xField]]) {
|
| 384 |
+
group.append("image")
|
| 385 |
+
.attr("xlink:href", images.field[d[xField]])
|
| 386 |
+
.attr("x", -iconWidth / 2)
|
| 387 |
+
.attr("y", 0)
|
| 388 |
+
.attr("width", iconWidth)
|
| 389 |
+
.attr("height", iconHeight)
|
| 390 |
+
.attr("preserveAspectRatio", "xMidYMid meet");
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
// 添加国家名称文本
|
| 394 |
+
const label = group.append("text")
|
| 395 |
+
.attr("class", "country-label")
|
| 396 |
+
.attr("x", 0)
|
| 397 |
+
.attr("y", iconHeight + spacingBetweenIconLabel)
|
| 398 |
+
.attr("text-anchor", "middle")
|
| 399 |
+
.style("font-family", typography.label.font_family)
|
| 400 |
+
.style("font-size", `${finalLabelFontSize}px`)
|
| 401 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 402 |
+
.text(d[xField]);
|
| 403 |
+
|
| 404 |
+
// 如果需要换行,重新应用换行
|
| 405 |
+
const labelMaxWidth = baseBottleWidth * 1.05;
|
| 406 |
+
if (chartUtils.text.measure(chart, d[xField], {
|
| 407 |
+
fontFamily: typography.label.font_family,
|
| 408 |
+
fontSize: finalLabelFontSize
|
| 409 |
+
}).width > labelMaxWidth) {
|
| 410 |
+
wrapText(label, d[xField].toString(), labelMaxWidth, 1.1, 'top');
|
| 411 |
+
}
|
| 412 |
+
});
|
| 413 |
+
|
| 414 |
+
// ---------- 9. 统一调整所有值标签的字体大小 ----------
|
| 415 |
+
// 创建临时文本元素来测量值标签文本宽度
|
| 416 |
+
|
| 417 |
+
const baseValueFontSize = 16;
|
| 418 |
+
// 计算所有值标签在基础字体大小下的实际宽度
|
| 419 |
+
const valueTextWidths = valueLabels.map(item => {
|
| 420 |
+
return chartUtils.text.measure(svg, item.value, {
|
| 421 |
+
fontFamily: typography.label.font_family,
|
| 422 |
+
fontSize: `${baseValueFontSize}px`,
|
| 423 |
+
fontWeight: "bold"
|
| 424 |
+
}).width;
|
| 425 |
+
});
|
| 426 |
+
|
| 427 |
+
// 计算每个标签所需的缩放比例(方形宽度 / 文本宽度)
|
| 428 |
+
const valueScaleFactors = valueLabels.map((item, i) => {
|
| 429 |
+
// 使用60%的方形宽度作为最大可用宽度
|
| 430 |
+
const maxWidth = item.squareSize * 1; // 留一些边距
|
| 431 |
+
return maxWidth / valueTextWidths[i];
|
| 432 |
+
});
|
| 433 |
+
|
| 434 |
+
// 取最小的缩放比例,确保所有值标签使用相同的字体大小
|
| 435 |
+
const minValueScaleFactor = Math.min(...valueScaleFactors, 1); // 不要放大,只缩小
|
| 436 |
+
|
| 437 |
+
// 计算最终的统一字体大小
|
| 438 |
+
const finalValueFontSize = Math.min(baseValueFontSize * minValueScaleFactor, baseValueFontSize);
|
| 439 |
+
|
| 440 |
+
// 应用统一的字体大小到所有值标签
|
| 441 |
+
valueLabels.forEach(item => {
|
| 442 |
+
item.element
|
| 443 |
+
.style("font-size", `${finalValueFontSize}px`);
|
| 444 |
+
});
|
| 445 |
+
|
| 446 |
+
// 返回SVG节点
|
| 447 |
+
return svg.node();
|
| 448 |
+
}
|