Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files
modules/chart_engine/template/d3-js/type11_circular_stacked_bar_chart/circular_stacked_bar_chart_icons_01.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Circular Stacked Bar Chart",
|
| 5 |
+
"chart_name": "circular_stacked_bar_chart_icons_01",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y", "group"],
|
| 8 |
+
"required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
|
| 9 |
+
"required_fields_range": [[5, 20], [0, "inf"], [2, 10]],
|
| 10 |
+
"required_fields_icons": ["x", "group"],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": ["group"],
|
| 13 |
+
"required_other_colors": ["primary"],
|
| 14 |
+
"supported_effects": ["shadow", "radius_corner", "gradient", "stroke"],
|
| 15 |
+
"min_height": 500,
|
| 16 |
+
"min_width": 500,
|
| 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 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
const colorResolver = chartUtils.color.resolver(data);
|
| 28 |
+
// ---------- 1. 数据准备阶段 ----------
|
| 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: "12px", 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: { primary: "#084594" }
|
| 41 |
+
};
|
| 42 |
+
const images = jsonData.images || {};
|
| 43 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 44 |
+
|
| 45 |
+
// 数值单位规范
|
| 46 |
+
// 添加数值格式化函数
|
| 47 |
+
|
| 48 |
+
// 视觉效果默认值
|
| 49 |
+
variables.has_rounded_corners = variables.has_rounded_corners || false;
|
| 50 |
+
variables.has_shadow = variables.has_shadow || false;
|
| 51 |
+
variables.has_gradient = variables.has_gradient || false;
|
| 52 |
+
variables.has_stroke = variables.has_stroke || false;
|
| 53 |
+
|
| 54 |
+
// 清空容器
|
| 55 |
+
d3.select(containerSelector).html("");
|
| 56 |
+
|
| 57 |
+
// ---------- 2. 尺寸和布局设置 ----------
|
| 58 |
+
const width = variables.width || 800;
|
| 59 |
+
const height = variables.height || 800;
|
| 60 |
+
const size = Math.min(width, height);
|
| 61 |
+
|
| 62 |
+
const margin = {
|
| 63 |
+
top: 90,
|
| 64 |
+
right: 50,
|
| 65 |
+
bottom: 60,
|
| 66 |
+
left: 50
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
const innerWidth = size - margin.left - margin.right;
|
| 70 |
+
const innerHeight = size - margin.top - margin.bottom;
|
| 71 |
+
|
| 72 |
+
const centerX = margin.left + innerWidth / 2;
|
| 73 |
+
const centerY = margin.top + innerHeight / 2;
|
| 74 |
+
const radius = Math.min(innerWidth, innerHeight) / 2;
|
| 75 |
+
|
| 76 |
+
// ---------- 3. 提取字段名和单位 ----------
|
| 77 |
+
const dimensionField = chartUtils.schema.channel(jsonData, "x").key || "category";
|
| 78 |
+
const valueField = chartUtils.schema.channel(jsonData, "y").key || "value";
|
| 79 |
+
const groupField = chartUtils.schema.channel(jsonData, "group").key || "group";
|
| 80 |
+
|
| 81 |
+
// 单位
|
| 82 |
+
let valueUnit = "";
|
| 83 |
+
const valueChannel = chartUtils.schema.channel(jsonData, "y");
|
| 84 |
+
if (valueChannel.unit) {
|
| 85 |
+
valueUnit = valueChannel.unit === "B" ? " B" : valueChannel.unit;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// ---------- 4. 数据处理 ----------
|
| 89 |
+
// 获取所有唯一的组
|
| 90 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 91 |
+
|
| 92 |
+
// 按类别分组并计算堆叠值
|
| 93 |
+
const groupedData = d3.group(chartData, d => d[dimensionField]);
|
| 94 |
+
const stackedData = Array.from(groupedData, ([category, values]) => {
|
| 95 |
+
const stack = {};
|
| 96 |
+
let total = 0;
|
| 97 |
+
groups.forEach(group => {
|
| 98 |
+
const groupValue = values.find(v => v[groupField] === group)?.[valueField] || 0;
|
| 99 |
+
stack[group] = {
|
| 100 |
+
start: total,
|
| 101 |
+
end: total + groupValue,
|
| 102 |
+
value: groupValue
|
| 103 |
+
};
|
| 104 |
+
total += groupValue;
|
| 105 |
+
});
|
| 106 |
+
return {
|
| 107 |
+
category,
|
| 108 |
+
stacks: stack,
|
| 109 |
+
total
|
| 110 |
+
};
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
+
// 按总值降序排序
|
| 114 |
+
stackedData.sort((a, b) => b.total - a.total);
|
| 115 |
+
|
| 116 |
+
const totalItems = stackedData.length;
|
| 117 |
+
if (totalItems === 0) return;
|
| 118 |
+
|
| 119 |
+
const anglePerItem = (2 * Math.PI) / totalItems;
|
| 120 |
+
const maxValue = d3.max(stackedData, d => d.total);
|
| 121 |
+
|
| 122 |
+
// ---------- 5. 创建SVG和效果定义 ----------
|
| 123 |
+
const svg = d3.select(containerSelector)
|
| 124 |
+
.append("svg")
|
| 125 |
+
.attr("width", "100%")
|
| 126 |
+
.attr("height", size)
|
| 127 |
+
.attr("viewBox", `0 0 ${size} ${size}`)
|
| 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 |
+
const defs = svg.append("defs");
|
| 133 |
+
|
| 134 |
+
if (variables.has_shadow) {
|
| 135 |
+
const filter = defs.append("filter")
|
| 136 |
+
.attr("id", "shadow")
|
| 137 |
+
.attr("filterUnits", "userSpaceOnUse")
|
| 138 |
+
.attr("x", -innerWidth/2)
|
| 139 |
+
.attr("y", -innerHeight/2)
|
| 140 |
+
.attr("width", innerWidth*2)
|
| 141 |
+
.attr("height", innerHeight*2);
|
| 142 |
+
filter.append("feGaussianBlur").attr("in", "SourceAlpha").attr("stdDeviation", 4);
|
| 143 |
+
filter.append("feOffset").attr("dx", 3).attr("dy", 3).attr("result", "offsetblur");
|
| 144 |
+
filter.append("feFlood").attr("flood-color", "#000").attr("flood-opacity", 0.3);
|
| 145 |
+
filter.append("feComposite").attr("in2", "offsetblur").attr("operator", "in");
|
| 146 |
+
const feMerge = filter.append("feMerge");
|
| 147 |
+
feMerge.append("feMergeNode");
|
| 148 |
+
feMerge.append("feMergeNode").attr("in", "SourceGraphic");
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
// ---------- 6. 创建比例尺 ----------
|
| 152 |
+
const centralCircleRadius = radius * 0.25;
|
| 153 |
+
const radiusScale = d3.scaleLinear()
|
| 154 |
+
.domain([0, maxValue])
|
| 155 |
+
.range([centralCircleRadius + 20, radius * 0.9]);
|
| 156 |
+
|
| 157 |
+
// 为每个组创建颜色
|
| 158 |
+
const groupColors = {};
|
| 159 |
+
groups.forEach((group, i) => {
|
| 160 |
+
const baseColor = colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
|
| 161 |
+
groupColors[group] = colorResolver.field(group, 0, { fallbackKey: "primary" }).value
|
| 162 |
+
});
|
| 163 |
+
|
| 164 |
+
// ---------- 7. 创建中心圆和背景 ----------
|
| 165 |
+
svg.append("circle")
|
| 166 |
+
.attr("cx", centerX)
|
| 167 |
+
.attr("cy", centerY)
|
| 168 |
+
.attr("r", centralCircleRadius)
|
| 169 |
+
.attr("fill", "#ffffff")
|
| 170 |
+
.attr("stroke", "#aaaaaa")
|
| 171 |
+
.attr("stroke-width", 1.5);
|
| 172 |
+
|
| 173 |
+
// ---------- 8. 创建堆叠扇形 ----------
|
| 174 |
+
const sectorsGroup = svg.append("g")
|
| 175 |
+
.attr("transform", `translate(${centerX}, ${centerY})`);
|
| 176 |
+
|
| 177 |
+
const labelsGroup = svg.append("g")
|
| 178 |
+
.attr("transform", `translate(${centerX}, ${centerY})`);
|
| 179 |
+
|
| 180 |
+
stackedData.forEach((d, i) => {
|
| 181 |
+
const startAngle = i * anglePerItem;
|
| 182 |
+
const endAngle = startAngle + anglePerItem;
|
| 183 |
+
const midAngle = startAngle + anglePerItem / 2;
|
| 184 |
+
|
| 185 |
+
// 为每个组创建堆叠的扇形
|
| 186 |
+
groups.forEach(group => {
|
| 187 |
+
const stack = d.stacks[group];
|
| 188 |
+
if (stack.value > 0) {
|
| 189 |
+
const innerRadius = radiusScale(stack.start);
|
| 190 |
+
const outerRadius = radiusScale(stack.end);
|
| 191 |
+
|
| 192 |
+
const arcGenerator = d3.arc()
|
| 193 |
+
.innerRadius(innerRadius)
|
| 194 |
+
.outerRadius(outerRadius)
|
| 195 |
+
.startAngle(startAngle)
|
| 196 |
+
.endAngle(endAngle)
|
| 197 |
+
.padAngle(0.02);
|
| 198 |
+
|
| 199 |
+
sectorsGroup.append("path")
|
| 200 |
+
.attr("d", arcGenerator)
|
| 201 |
+
.attr("fill", groupColors[group])
|
| 202 |
+
.attr("stroke", variables.has_stroke ? colorResolver.variant(groupColors[group], { mode: "darker", amount: 0.5 }) : "none")
|
| 203 |
+
.attr("stroke-width", variables.has_stroke ? 1 : 0)
|
| 204 |
+
.attr("style", variables.has_shadow ? "filter: url(#shadow)" : null)
|
| 205 |
+
}
|
| 206 |
+
});
|
| 207 |
+
|
| 208 |
+
// 添加末端圆圈和标签
|
| 209 |
+
const outerRadius = radiusScale(d.total);
|
| 210 |
+
const endCircleX = Math.sin(midAngle) * outerRadius;
|
| 211 |
+
const endCircleY = -Math.cos(midAngle) * outerRadius;
|
| 212 |
+
const endCircleRadius = Math.max(12, Math.min(30, radius * 0.1));
|
| 213 |
+
|
| 214 |
+
sectorsGroup.append("circle")
|
| 215 |
+
.attr("cx", endCircleX)
|
| 216 |
+
.attr("cy", endCircleY)
|
| 217 |
+
.attr("r", endCircleRadius)
|
| 218 |
+
.attr("fill", "#ffffff")
|
| 219 |
+
.attr("stroke", "#aaaaaa")
|
| 220 |
+
.attr("stroke-width", 1);
|
| 221 |
+
|
| 222 |
+
// 添加总值标签
|
| 223 |
+
const valueText = `${chartUtils.format.autoText(d.total)}${valueUnit}`;
|
| 224 |
+
sectorsGroup.append("text")
|
| 225 |
+
.attr("x", endCircleX)
|
| 226 |
+
.attr("y", endCircleY)
|
| 227 |
+
.attr("text-anchor", "middle")
|
| 228 |
+
.attr("dominant-baseline", "middle")
|
| 229 |
+
.attr("font-family", typography.annotation.font_family)
|
| 230 |
+
.attr("font-size", `${endCircleRadius * 0.8}px`)
|
| 231 |
+
.attr("font-weight", "bold")
|
| 232 |
+
.attr("fill", "#333333")
|
| 233 |
+
.text(valueText);
|
| 234 |
+
|
| 235 |
+
// 添加类别标签
|
| 236 |
+
const iconSize = 30;
|
| 237 |
+
const labelPadding = 20;
|
| 238 |
+
const iconRadius = outerRadius + endCircleRadius + labelPadding;
|
| 239 |
+
const iconX = Math.sin(midAngle) * iconRadius;
|
| 240 |
+
const iconY = -Math.cos(midAngle) * iconRadius;
|
| 241 |
+
|
| 242 |
+
const iconUrl = images?.field?.[d.category];
|
| 243 |
+
if (iconUrl) {
|
| 244 |
+
labelsGroup.append("image")
|
| 245 |
+
.attr("xlink:href", iconUrl)
|
| 246 |
+
.attr("x", iconX - iconSize / 2)
|
| 247 |
+
.attr("y", iconY - iconSize / 2)
|
| 248 |
+
.attr("width", iconSize)
|
| 249 |
+
.attr("height", iconSize)
|
| 250 |
+
.attr("preserveAspectRatio", "xMidYMid meet");
|
| 251 |
+
}
|
| 252 |
+
});
|
| 253 |
+
|
| 254 |
+
chartUtils.legend.draw(svg, groups, {
|
| 255 |
+
color: group => groupColors[group],
|
| 256 |
+
colorResolver,
|
| 257 |
+
colors,
|
| 258 |
+
}, {
|
| 259 |
+
x: size - 150,
|
| 260 |
+
y: 50,
|
| 261 |
+
direction: "vertical",
|
| 262 |
+
markerShape: "rect",
|
| 263 |
+
markerSize: 15,
|
| 264 |
+
labelGap: 5,
|
| 265 |
+
rowGap: 10,
|
| 266 |
+
itemHeight: 25,
|
| 267 |
+
fontSize: typography.label.font_size,
|
| 268 |
+
fontFamily: typography.label.font_family,
|
| 269 |
+
fontWeight: typography.label.font_weight,
|
| 270 |
+
textColor: colorResolver.text({ fallback: "#333333" }).value,
|
| 271 |
+
});
|
| 272 |
+
|
| 273 |
+
return svg.node();
|
| 274 |
+
}
|
modules/chart_engine/template/d3-js/type11_circular_stacked_bar_chart/circular_stacked_bar_plain_chart_01.js
ADDED
|
@@ -0,0 +1,732 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "circular stacked bar chart",
|
| 5 |
+
"chart_name": "circular_stacked_bar_plain_chart_01",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y", "group"],
|
| 8 |
+
"required_fields_type": [["categorical", "temporal"], ["numerical"], ["categorical"]],
|
| 9 |
+
"required_fields_range": [[3, 16], [0, "inf"], [2, 8]],
|
| 10 |
+
"required_fields_icons": [],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": ["group"],
|
| 13 |
+
"required_other_colors": ["primary"],
|
| 14 |
+
"supported_effects": ["shadow", "radius_corner", "gradient", "stroke", "spacing"],
|
| 15 |
+
"min_height": 560,
|
| 16 |
+
"min_width": 720,
|
| 17 |
+
"background": "no",
|
| 18 |
+
"icon_mark": "none",
|
| 19 |
+
"icon_label": "none",
|
| 20 |
+
"has_x_axis": "yes",
|
| 21 |
+
"has_y_axis": "yes"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
const jsonData = data || {};
|
| 28 |
+
const chartData = (jsonData.data && 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 chartUtilsTextSample = chartUtils.text.estimate;
|
| 33 |
+
const chartUtilsStandard = {
|
| 34 |
+
schema: chartUtils.schema,
|
| 35 |
+
format: chartUtils.format,
|
| 36 |
+
text: chartUtils.text,
|
| 37 |
+
color: chartUtils.color,
|
| 38 |
+
legendLayout: chartUtils.legend.layout,
|
| 39 |
+
legendDraw: chartUtils.legend.draw,
|
| 40 |
+
random: chartUtils.random.generator(jsonData, "dev-wz-style")
|
| 41 |
+
};
|
| 42 |
+
const standardChannels = chartUtils.schema.channels(jsonData, {
|
| 43 |
+
x: { fallbackIndex: 0 },
|
| 44 |
+
y: { fallbackIndex: 1 },
|
| 45 |
+
y2: { fallbackIndex: 2 },
|
| 46 |
+
y3: { fallbackIndex: 3 },
|
| 47 |
+
size: { fallbackIndex: 2 },
|
| 48 |
+
group: { fallbackIndex: 2 },
|
| 49 |
+
group2: { fallbackIndex: 3 },
|
| 50 |
+
group3: { fallbackIndex: 4 }
|
| 51 |
+
});
|
| 52 |
+
const variables = jsonData.variables || {};
|
| 53 |
+
const typography = jsonData.typography || {
|
| 54 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 55 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 56 |
+
};
|
| 57 |
+
const sourceColors = jsonData.colors || {};
|
| 58 |
+
|
| 59 |
+
d3.select(containerSelector).html("");
|
| 60 |
+
|
| 61 |
+
const width = Number(variables.width) || 800;
|
| 62 |
+
const height = Number(variables.height) || 640;
|
| 63 |
+
const labelTypography = typography.label || {};
|
| 64 |
+
const valueTypography = typography.annotation || labelTypography;
|
| 65 |
+
const labelFamily = labelTypography.font_family || "Arial";
|
| 66 |
+
const valueFamily = valueTypography.font_family || "Arial";
|
| 67 |
+
const labelWeight = labelTypography.font_weight || "normal";
|
| 68 |
+
const baseLabelSize = parseFloat(labelTypography.font_size) || 12;
|
| 69 |
+
const baseValueSize = parseFloat(valueTypography.font_size) || 12;
|
| 70 |
+
|
| 71 |
+
const xColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
|
| 72 |
+
const yColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
|
| 73 |
+
const groupColumn = chartUtils.schema.channel(jsonData, "group", { fallbackIndex: 2 }).raw || {};
|
| 74 |
+
const xField = xColumn.name || chartUtils.schema.column(dataColumns, 0).raw?.name || "dimension";
|
| 75 |
+
const yField = yColumn.name || chartUtils.schema.column(dataColumns, 1).raw?.name || "value";
|
| 76 |
+
const groupField = groupColumn.name || chartUtils.schema.column(dataColumns, 2).raw?.name || "group";
|
| 77 |
+
const xUnit = xColumn.unit && xColumn.unit !== "none" ? xColumn.unit : "";
|
| 78 |
+
const yUnit = yColumn.unit && yColumn.unit !== "none" ? yColumn.unit : "";
|
| 79 |
+
|
| 80 |
+
const navy = "#071d4d";
|
| 81 |
+
const mutedText = "#54606f";
|
| 82 |
+
const greenText = "#2f7a43";
|
| 83 |
+
const canvasColor = "#fbfdff";
|
| 84 |
+
const badgeFill = "#eeeafb";
|
| 85 |
+
const referencePalette = [
|
| 86 |
+
"#1f63c5",
|
| 87 |
+
"#d866a9",
|
| 88 |
+
"#4baa68",
|
| 89 |
+
"#f3b23c",
|
| 90 |
+
"#4aa3df",
|
| 91 |
+
"#7b72cc",
|
| 92 |
+
"#ed7b45",
|
| 93 |
+
"#2d9b8b"
|
| 94 |
+
];
|
| 95 |
+
|
| 96 |
+
function rgba(color, opacity) {
|
| 97 |
+
const c = d3.rgb(color);
|
| 98 |
+
return `rgba(${c.r}, ${c.g}, ${c.b}, ${opacity})`;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function clamp(value, min, max) {
|
| 102 |
+
return Math.max(min, Math.min(max, value));
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
function parseTemporalSortValue(value) {
|
| 106 |
+
const text = String(value ?? "").trim();
|
| 107 |
+
const year = text.match(/^(-?\d{4})$/);
|
| 108 |
+
if (year) return Number(year[1]);
|
| 109 |
+
const date = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
|
| 110 |
+
if (date) {
|
| 111 |
+
return Number(date[1]) * 10000 + Number(date[2]) * 100 + Number(date[3] || 1);
|
| 112 |
+
}
|
| 113 |
+
const monthNames = {
|
| 114 |
+
jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
|
| 115 |
+
jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12
|
| 116 |
+
};
|
| 117 |
+
const month = monthNames[text.slice(0, 3).toLowerCase()];
|
| 118 |
+
return month || NaN;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
function formatNumber(value) {
|
| 122 |
+
const numericValue = Number(value);
|
| 123 |
+
if (!Number.isFinite(numericValue)) return String(value ?? "");
|
| 124 |
+
const absValue = Math.abs(numericValue);
|
| 125 |
+
if (absValue >= 1000000000) return `${d3.format(".3~g")(numericValue / 1000000000)}B`;
|
| 126 |
+
if (absValue >= 1000000) return `${d3.format(".3~g")(numericValue / 1000000)}M`;
|
| 127 |
+
if (absValue >= 1000) return `${d3.format(".3~g")(numericValue / 1000)}K`;
|
| 128 |
+
return d3.format(",.4~g")(numericValue);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
function formatWithUnit(value, unit) {
|
| 132 |
+
const formatted = formatNumber(value);
|
| 133 |
+
if (!unit) return formatted;
|
| 134 |
+
if (unit === "$") return `$${formatted}`;
|
| 135 |
+
if (unit === "%") return `${formatted}%`;
|
| 136 |
+
return `${formatted} ${unit}`;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
function formatDimension(value) {
|
| 140 |
+
return xUnit ? `${value} ${xUnit}` : String(value ?? "");
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
const rows = chartData.map((d, index) => ({
|
| 144 |
+
...d,
|
| 145 |
+
__index: index,
|
| 146 |
+
__x: d[xField],
|
| 147 |
+
__label: formatDimension(d[xField]),
|
| 148 |
+
__group: String(d[groupField] ?? ""),
|
| 149 |
+
__value: Number(d[yField])
|
| 150 |
+
})).filter(d => Number.isFinite(d.__value) && d.__group !== "");
|
| 151 |
+
|
| 152 |
+
const groups = [];
|
| 153 |
+
rows.forEach(d => {
|
| 154 |
+
if (!groups.includes(d.__group)) groups.push(d.__group);
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
const categoryMap = new Map();
|
| 158 |
+
rows.forEach(d => {
|
| 159 |
+
const key = String(d.__x);
|
| 160 |
+
if (!categoryMap.has(key)) {
|
| 161 |
+
categoryMap.set(key, {
|
| 162 |
+
key,
|
| 163 |
+
raw: d.__x,
|
| 164 |
+
label: d.__label,
|
| 165 |
+
firstIndex: d.__index,
|
| 166 |
+
values: new Map(),
|
| 167 |
+
total: 0
|
| 168 |
+
});
|
| 169 |
+
}
|
| 170 |
+
const category = categoryMap.get(key);
|
| 171 |
+
const value = Math.max(0, d.__value);
|
| 172 |
+
category.values.set(d.__group, (category.values.get(d.__group) || 0) + value);
|
| 173 |
+
category.total += value;
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
const categories = Array.from(categoryMap.values());
|
| 177 |
+
const isTemporal = xColumn.data_type === "temporal" || (
|
| 178 |
+
categories.length > 0 && categories.every(d => Number.isFinite(parseTemporalSortValue(d.raw)))
|
| 179 |
+
);
|
| 180 |
+
categories.sort((a, b) => {
|
| 181 |
+
if (isTemporal) {
|
| 182 |
+
const aTime = parseTemporalSortValue(a.raw);
|
| 183 |
+
const bTime = parseTemporalSortValue(b.raw);
|
| 184 |
+
if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) {
|
| 185 |
+
return aTime - bTime;
|
| 186 |
+
}
|
| 187 |
+
} else {
|
| 188 |
+
const byTotal = b.total - a.total;
|
| 189 |
+
if (Math.abs(byTotal) > 1e-9) return byTotal;
|
| 190 |
+
}
|
| 191 |
+
return a.firstIndex - b.firstIndex;
|
| 192 |
+
});
|
| 193 |
+
|
| 194 |
+
if (!categories.length || !groups.length) return;
|
| 195 |
+
|
| 196 |
+
const svg = d3.select(containerSelector)
|
| 197 |
+
.append("svg")
|
| 198 |
+
.attr("width", "100%")
|
| 199 |
+
.attr("height", height)
|
| 200 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 201 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 202 |
+
.attr("xmlns", "http://www.w3.org/2000/svg");
|
| 203 |
+
|
| 204 |
+
const defs = svg.append("defs");
|
| 205 |
+
const softShadow = defs.append("filter")
|
| 206 |
+
.attr("id", "circular_stacked_bar_plain_01_soft_shadow")
|
| 207 |
+
.attr("x", "-30%")
|
| 208 |
+
.attr("y", "-30%")
|
| 209 |
+
.attr("width", "160%")
|
| 210 |
+
.attr("height", "160%");
|
| 211 |
+
softShadow.append("feDropShadow")
|
| 212 |
+
.attr("dx", 0)
|
| 213 |
+
.attr("dy", 4)
|
| 214 |
+
.attr("stdDeviation", 4)
|
| 215 |
+
.attr("flood-color", "#18315c")
|
| 216 |
+
.attr("flood-opacity", 0.13);
|
| 217 |
+
|
| 218 |
+
const lightShadow = defs.append("filter")
|
| 219 |
+
.attr("id", "circular_stacked_bar_plain_01_light_shadow")
|
| 220 |
+
.attr("x", "-35%")
|
| 221 |
+
.attr("y", "-35%")
|
| 222 |
+
.attr("width", "170%")
|
| 223 |
+
.attr("height", "170%");
|
| 224 |
+
lightShadow.append("feDropShadow")
|
| 225 |
+
.attr("dx", 0)
|
| 226 |
+
.attr("dy", 2)
|
| 227 |
+
.attr("stdDeviation", 2.3)
|
| 228 |
+
.attr("flood-color", "#15315c")
|
| 229 |
+
.attr("flood-opacity", 0.12);
|
| 230 |
+
|
| 231 |
+
const bottomGradient = defs.append("linearGradient")
|
| 232 |
+
.attr("id", "circular_stacked_bar_plain_01_bottom_gradient")
|
| 233 |
+
.attr("x1", "0%")
|
| 234 |
+
.attr("x2", "100%")
|
| 235 |
+
.attr("y1", "0%")
|
| 236 |
+
.attr("y2", "0%");
|
| 237 |
+
bottomGradient.append("stop").attr("offset", "0%").attr("stop-color", "#eef6ff");
|
| 238 |
+
bottomGradient.append("stop").attr("offset", "100%").attr("stop-color", "#f7fbff");
|
| 239 |
+
|
| 240 |
+
const badgeGradient = defs.append("radialGradient")
|
| 241 |
+
.attr("id", "circular_stacked_bar_plain_01_badge_gradient")
|
| 242 |
+
.attr("cx", "42%")
|
| 243 |
+
.attr("cy", "36%")
|
| 244 |
+
.attr("r", "66%");
|
| 245 |
+
badgeGradient.append("stop").attr("offset", "0%").attr("stop-color", "#ffffff");
|
| 246 |
+
badgeGradient.append("stop").attr("offset", "100%").attr("stop-color", badgeFill);
|
| 247 |
+
|
| 248 |
+
const measure = svg.append("g").attr("visibility", "hidden");
|
| 249 |
+
function measureLabelText(text, fontSize, family, weight) {
|
| 250 |
+
const node = measure.append("text")
|
| 251 |
+
.style("font-family", family)
|
| 252 |
+
.style("font-size", `${fontSize}px`)
|
| 253 |
+
.style("font-weight", weight)
|
| 254 |
+
.text(text)
|
| 255 |
+
.node();
|
| 256 |
+
const measured = node ? node.textContent.length * 7 : String(text).length * fontSize * 0.55;
|
| 257 |
+
measure.selectAll("text").remove();
|
| 258 |
+
return measured;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
function breakLongWord(word, maxWidth, fontSize, family, weight) {
|
| 262 |
+
const lines = [];
|
| 263 |
+
let current = "";
|
| 264 |
+
for (const char of String(word)) {
|
| 265 |
+
const next = current + char;
|
| 266 |
+
if (current && measureLabelText(next, fontSize, family, weight) > maxWidth) {
|
| 267 |
+
lines.push(current);
|
| 268 |
+
current = char;
|
| 269 |
+
} else {
|
| 270 |
+
current = next;
|
| 271 |
+
}
|
| 272 |
+
}
|
| 273 |
+
if (current) lines.push(current);
|
| 274 |
+
return lines.length ? lines : [String(word)];
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
function wrapText(text, maxWidth, fontSize, family, weight) {
|
| 278 |
+
const words = String(text || "").split(/\s+/).filter(Boolean);
|
| 279 |
+
if (!words.length) return [String(text || "")];
|
| 280 |
+
const lines = [];
|
| 281 |
+
let current = "";
|
| 282 |
+
for (const word of words) {
|
| 283 |
+
const candidate = current ? `${current} ${word}` : word;
|
| 284 |
+
if (measureLabelText(candidate, fontSize, family, weight) <= maxWidth) {
|
| 285 |
+
current = candidate;
|
| 286 |
+
continue;
|
| 287 |
+
}
|
| 288 |
+
if (current) lines.push(current);
|
| 289 |
+
if (measureLabelText(word, fontSize, family, weight) <= maxWidth) {
|
| 290 |
+
current = word;
|
| 291 |
+
} else {
|
| 292 |
+
const broken = breakLongWord(word, maxWidth, fontSize, family, weight);
|
| 293 |
+
lines.push(...broken.slice(0, -1));
|
| 294 |
+
current = broken[broken.length - 1];
|
| 295 |
+
}
|
| 296 |
+
}
|
| 297 |
+
if (current) lines.push(current);
|
| 298 |
+
return lines.length ? lines : [String(text || "")];
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
function addWrappedText(parent, lines, options) {
|
| 302 |
+
const text = parent.append("text")
|
| 303 |
+
.attr("class", options.className || null)
|
| 304 |
+
.attr("x", options.x)
|
| 305 |
+
.attr("y", options.y)
|
| 306 |
+
.attr("text-anchor", options.anchor || "middle")
|
| 307 |
+
.style("font-family", options.family || labelFamily)
|
| 308 |
+
.style("font-size", `${options.fontSize}px`)
|
| 309 |
+
.style("font-weight", options.weight || labelWeight)
|
| 310 |
+
.style("fill", options.fill || navy);
|
| 311 |
+
if (options.attrs) {
|
| 312 |
+
Object.entries(options.attrs).forEach(([key, value]) => text.attr(key, value));
|
| 313 |
+
}
|
| 314 |
+
lines.slice(0, options.maxLines || lines.length).forEach((line, lineIndex) => {
|
| 315 |
+
text.append("tspan")
|
| 316 |
+
.attr("x", options.x)
|
| 317 |
+
.attr("dy", lineIndex === 0 ? 0 : options.fontSize * 1.12)
|
| 318 |
+
.text(line);
|
| 319 |
+
});
|
| 320 |
+
return text;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
function polarPoint(angle, radius, cx, cy) {
|
| 324 |
+
return {
|
| 325 |
+
x: cx + Math.sin(angle) * radius,
|
| 326 |
+
y: cy - Math.cos(angle) * radius
|
| 327 |
+
};
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
function drawBadgeIcon(parent, x, y, size, index) {
|
| 331 |
+
const icon = parent.append("g")
|
| 332 |
+
.attr("class", "category-icon-glyph")
|
| 333 |
+
.attr("transform", `translate(${x}, ${y})`)
|
| 334 |
+
.attr("stroke", navy)
|
| 335 |
+
.attr("stroke-width", Math.max(1.2, size * 0.075))
|
| 336 |
+
.attr("stroke-linecap", "round")
|
| 337 |
+
.attr("stroke-linejoin", "round")
|
| 338 |
+
.attr("fill", "none");
|
| 339 |
+
const s = size / 22;
|
| 340 |
+
const pattern = index % 4;
|
| 341 |
+
if (pattern === 0) {
|
| 342 |
+
icon.append("path").attr("d", `M${-8 * s},${6 * s} L${-8 * s},${-3 * s} L0,${-8 * s} L${8 * s},${-3 * s} L${8 * s},${6 * s}`);
|
| 343 |
+
icon.append("path").attr("d", `M${-4 * s},${6 * s} L${-4 * s},${-1 * s} L${4 * s},${-1 * s} L${4 * s},${6 * s}`);
|
| 344 |
+
icon.append("path").attr("d", `M${-11 * s},${6 * s} L${11 * s},${6 * s}`);
|
| 345 |
+
} else if (pattern === 1) {
|
| 346 |
+
icon.append("path").attr("d", `M${-9 * s},${-1 * s} L0,${-7 * s} L${9 * s},${-1 * s} L0,${5 * s} Z`);
|
| 347 |
+
icon.append("path").attr("d", `M${-5 * s},${4 * s} C${-2 * s},${8 * s} ${2 * s},${8 * s} ${5 * s},${4 * s}`);
|
| 348 |
+
icon.append("path").attr("d", `M${8 * s},${0} L${8 * s},${7 * s}`);
|
| 349 |
+
icon.append("circle").attr("cx", 8 * s).attr("cy", 9 * s).attr("r", 1.5 * s);
|
| 350 |
+
} else if (pattern === 2) {
|
| 351 |
+
icon.append("rect")
|
| 352 |
+
.attr("x", -7 * s)
|
| 353 |
+
.attr("y", -8 * s)
|
| 354 |
+
.attr("width", 14 * s)
|
| 355 |
+
.attr("height", 16 * s)
|
| 356 |
+
.attr("rx", 1.5 * s);
|
| 357 |
+
icon.append("path").attr("d", `M${-4 * s},${-3 * s} H${4 * s}`);
|
| 358 |
+
icon.append("path").attr("d", `M${-4 * s},${2 * s} H${4 * s}`);
|
| 359 |
+
icon.append("circle").attr("cx", 4.5 * s).attr("cy", 7 * s).attr("r", 2.5 * s);
|
| 360 |
+
} else {
|
| 361 |
+
icon.append("path").attr("d", `M${-8 * s},${7 * s} V${-2 * s} L0,${-8 * s} L${8 * s},${-2 * s} V${7 * s}`);
|
| 362 |
+
icon.append("path").attr("d", `M${-3 * s},${7 * s} V${1 * s} H${3 * s} V${7 * s}`);
|
| 363 |
+
icon.append("path").attr("d", `M${-10 * s},${7 * s} H${10 * s}`);
|
| 364 |
+
}
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
function drawCenterIcon(parent, cx, cy, radius) {
|
| 368 |
+
const s = radius / 72;
|
| 369 |
+
const icon = parent.append("g")
|
| 370 |
+
.attr("class", "center-decorative-icon")
|
| 371 |
+
.attr("transform", `translate(${cx}, ${cy})`)
|
| 372 |
+
.attr("stroke", navy)
|
| 373 |
+
.attr("stroke-width", Math.max(2.5, radius * 0.055))
|
| 374 |
+
.attr("stroke-linecap", "round")
|
| 375 |
+
.attr("stroke-linejoin", "round")
|
| 376 |
+
.attr("fill", "none");
|
| 377 |
+
icon.append("path")
|
| 378 |
+
.attr("d", `M${-35 * s},${-7 * s} L0,${-24 * s} L${35 * s},${-7 * s} L0,${10 * s} Z`);
|
| 379 |
+
icon.append("path")
|
| 380 |
+
.attr("d", `M${-20 * s},${4 * s} V${19 * s} C${-9 * s},${28 * s} ${9 * s},${28 * s} ${20 * s},${19 * s} V${4 * s}`);
|
| 381 |
+
icon.append("path").attr("d", `M${18 * s},${-1 * s} L${30 * s},${5 * s} V${24 * s}`);
|
| 382 |
+
icon.append("circle").attr("cx", 30 * s).attr("cy", 27 * s).attr("r", 2.8 * s);
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
function drawInsightIcon(parent, cx, cy, radius) {
|
| 386 |
+
const s = radius / 34;
|
| 387 |
+
const icon = parent.append("g")
|
| 388 |
+
.attr("class", "insight-icon")
|
| 389 |
+
.attr("transform", `translate(${cx}, ${cy})`);
|
| 390 |
+
icon.append("circle")
|
| 391 |
+
.attr("r", radius)
|
| 392 |
+
.attr("fill", referencePalette[0])
|
| 393 |
+
.attr("stroke", "#ffffff")
|
| 394 |
+
.attr("stroke-width", Math.max(3, radius * 0.16));
|
| 395 |
+
const lines = icon.append("g")
|
| 396 |
+
.attr("stroke", "#ffffff")
|
| 397 |
+
.attr("stroke-width", Math.max(2.2, radius * 0.09))
|
| 398 |
+
.attr("stroke-linecap", "round")
|
| 399 |
+
.attr("stroke-linejoin", "round")
|
| 400 |
+
.attr("fill", "none");
|
| 401 |
+
lines.append("path").attr("d", `M${-14 * s},${9 * s} L${-5 * s},${2 * s} L${3 * s},${5 * s} L${13 * s},${-9 * s}`);
|
| 402 |
+
lines.append("path").attr("d", `M${7 * s},${-9 * s} H${13 * s} V${-3 * s}`);
|
| 403 |
+
[-12, -4, 4].forEach((x, i) => {
|
| 404 |
+
lines.append("rect")
|
| 405 |
+
.attr("x", x * s)
|
| 406 |
+
.attr("y", (10 - i * 5) * s)
|
| 407 |
+
.attr("width", 5 * s)
|
| 408 |
+
.attr("height", (5 + i * 5) * s)
|
| 409 |
+
.attr("fill", "#ffffff")
|
| 410 |
+
.attr("stroke", "none");
|
| 411 |
+
});
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
svg.append("rect")
|
| 415 |
+
.attr("class", "chart-canvas")
|
| 416 |
+
.attr("x", 0)
|
| 417 |
+
.attr("y", 0)
|
| 418 |
+
.attr("width", width)
|
| 419 |
+
.attr("height", height)
|
| 420 |
+
.attr("fill", canvasColor);
|
| 421 |
+
|
| 422 |
+
const groupColorScale = d3.scaleOrdinal()
|
| 423 |
+
.domain(groups)
|
| 424 |
+
.range(groups.map((group, index) => {
|
| 425 |
+
if (index < referencePalette.length) return referencePalette[index];
|
| 426 |
+
const t = groups.length <= 1 ? 0.5 : index / (groups.length - 1);
|
| 427 |
+
return d3.interpolateRgb(referencePalette[0], referencePalette[2])(t);
|
| 428 |
+
}));
|
| 429 |
+
|
| 430 |
+
const maxTotal = Math.max(1, d3.max(categories, d => d.total) || 1);
|
| 431 |
+
const categoryCount = categories.length;
|
| 432 |
+
const bottomCardHeight = clamp(height * 0.145, 76, 96);
|
| 433 |
+
const bottomCardY = height - bottomCardHeight - clamp(height * 0.032, 16, 24);
|
| 434 |
+
const legendRows = groups.length > 4 ? 2 : 1;
|
| 435 |
+
const legendY = clamp(height * 0.065, 28, 44);
|
| 436 |
+
const legendRowGap = clamp(height * 0.034, 18, 23);
|
| 437 |
+
const legendBottom = legendY + legendRows * legendRowGap + 10;
|
| 438 |
+
const centerX = width / 2;
|
| 439 |
+
const centerY = clamp((legendBottom + bottomCardY) / 2 + height * 0.018, height * 0.36, height * 0.57);
|
| 440 |
+
const labelAllowance = categoryCount > 10 ? 84 : 112;
|
| 441 |
+
const verticalAllowance = categoryCount > 10 ? 78 : 102;
|
| 442 |
+
const outerRadius = Math.max(
|
| 443 |
+
96,
|
| 444 |
+
Math.min(
|
| 445 |
+
width * (categoryCount > 10 ? 0.22 : 0.255),
|
| 446 |
+
(bottomCardY - legendBottom - verticalAllowance) * 0.5,
|
| 447 |
+
(width - labelAllowance * 2) * 0.5
|
| 448 |
+
)
|
| 449 |
+
);
|
| 450 |
+
const innerRadius = Math.max(44, outerRadius * 0.42);
|
| 451 |
+
const labelOrbit = Math.min(width * 0.43, outerRadius + (categoryCount > 10 ? 68 : 112));
|
| 452 |
+
const iconRadius = categoryCount > 10 ? 13 : categoryCount > 8 ? 16 : 22;
|
| 453 |
+
const labelFontSize = categoryCount > 10
|
| 454 |
+
? Math.max(7.5, Math.min(9, baseLabelSize * 0.58))
|
| 455 |
+
: Math.max(9, Math.min(13, baseLabelSize * 0.78));
|
| 456 |
+
const valueFontSize = categoryCount > 10
|
| 457 |
+
? Math.max(8, Math.min(10, baseValueSize * 0.82))
|
| 458 |
+
: Math.max(10, Math.min(14, baseValueSize * 1.0));
|
| 459 |
+
|
| 460 |
+
const chart = svg.append("g")
|
| 461 |
+
.attr("class", "circular-stacked-bar-plot")
|
| 462 |
+
.attr("transform", `translate(${centerX}, ${centerY})`);
|
| 463 |
+
|
| 464 |
+
const radiusScale = d3.scaleLinear()
|
| 465 |
+
.domain([0, maxTotal])
|
| 466 |
+
.range([innerRadius, outerRadius]);
|
| 467 |
+
const angleScale = d3.scaleBand()
|
| 468 |
+
.domain(categories.map(d => d.key))
|
| 469 |
+
.range([0, Math.PI * 2])
|
| 470 |
+
.padding(categoryCount > 10 ? 0.035 : 0.08);
|
| 471 |
+
|
| 472 |
+
chart.append("circle")
|
| 473 |
+
.attr("class", "outer-wheel-halo")
|
| 474 |
+
.attr("r", outerRadius + 9)
|
| 475 |
+
.attr("fill", "#f8fbf4")
|
| 476 |
+
.attr("stroke", rgba(referencePalette[2], 0.22))
|
| 477 |
+
.attr("stroke-width", 1.1)
|
| 478 |
+
.style("filter", "url(#circular_stacked_bar_plain_01_light_shadow)");
|
| 479 |
+
|
| 480 |
+
const centerCircle = chart.append("circle")
|
| 481 |
+
.attr("class", "inner-ring")
|
| 482 |
+
.attr("r", innerRadius * 0.96)
|
| 483 |
+
.attr("fill", "#ffffff")
|
| 484 |
+
.attr("stroke", rgba(navy, 0.12))
|
| 485 |
+
.attr("stroke-width", 1.2)
|
| 486 |
+
.style("filter", "url(#circular_stacked_bar_plain_01_light_shadow)");
|
| 487 |
+
|
| 488 |
+
const segmentData = [];
|
| 489 |
+
categories.forEach(category => {
|
| 490 |
+
let start = 0;
|
| 491 |
+
groups.forEach(group => {
|
| 492 |
+
const value = category.values.get(group) || 0;
|
| 493 |
+
if (value <= 0) return;
|
| 494 |
+
const end = start + value;
|
| 495 |
+
segmentData.push({
|
| 496 |
+
category,
|
| 497 |
+
group,
|
| 498 |
+
value,
|
| 499 |
+
start,
|
| 500 |
+
end
|
| 501 |
+
});
|
| 502 |
+
start = end;
|
| 503 |
+
});
|
| 504 |
+
});
|
| 505 |
+
|
| 506 |
+
const arc = d3.arc()
|
| 507 |
+
.cornerRadius(variables.has_radius_corner === false ? 0 : 3)
|
| 508 |
+
.padAngle(categoryCount > 10 ? 0.004 : 0.008)
|
| 509 |
+
.padRadius(innerRadius);
|
| 510 |
+
|
| 511 |
+
chart.selectAll(".stack-segment")
|
| 512 |
+
.data(segmentData)
|
| 513 |
+
.enter()
|
| 514 |
+
.append("path")
|
| 515 |
+
.attr("class", "stack-segment mark")
|
| 516 |
+
.attr("data-category", d => d.category.label)
|
| 517 |
+
.attr("data-category-key", d => d.category.key)
|
| 518 |
+
.attr("data-group", d => d.group)
|
| 519 |
+
.attr("data-value", d => d.value)
|
| 520 |
+
.attr("data-start", d => d.start)
|
| 521 |
+
.attr("data-end", d => d.end)
|
| 522 |
+
.attr("data-total", d => d.category.total)
|
| 523 |
+
.attr("d", d => arc({
|
| 524 |
+
innerRadius: radiusScale(d.start),
|
| 525 |
+
outerRadius: radiusScale(d.end),
|
| 526 |
+
startAngle: angleScale(d.category.key),
|
| 527 |
+
endAngle: angleScale(d.category.key) + angleScale.bandwidth()
|
| 528 |
+
}))
|
| 529 |
+
.attr("fill", d => groupColorScale(d.group))
|
| 530 |
+
.attr("stroke", "#ffffff")
|
| 531 |
+
.attr("stroke-width", variables.has_stroke === false ? 1.6 : 2.4)
|
| 532 |
+
.style("filter", "url(#circular_stacked_bar_plain_01_light_shadow)");
|
| 533 |
+
|
| 534 |
+
drawCenterIcon(chart, 0, 0, innerRadius * 0.82);
|
| 535 |
+
centerCircle.raise();
|
| 536 |
+
chart.select(".center-decorative-icon").raise();
|
| 537 |
+
|
| 538 |
+
const labelLayer = svg.append("g").attr("class", "radial-label-layer");
|
| 539 |
+
const valueBubbleRadius = categoryCount > 10 ? 14 : 19;
|
| 540 |
+
categories.forEach((category, index) => {
|
| 541 |
+
const startAngle = angleScale(category.key);
|
| 542 |
+
const angle = startAngle + angleScale.bandwidth() / 2;
|
| 543 |
+
const outerPoint = polarPoint(angle, outerRadius + 10, centerX, centerY);
|
| 544 |
+
const bubblePoint = polarPoint(angle, outerRadius + valueBubbleRadius * 0.58, centerX, centerY);
|
| 545 |
+
const labelPointRaw = polarPoint(angle, labelOrbit, centerX, centerY);
|
| 546 |
+
const labelPoint = {
|
| 547 |
+
x: clamp(labelPointRaw.x, iconRadius + 42, width - iconRadius - 42),
|
| 548 |
+
y: clamp(
|
| 549 |
+
labelPointRaw.y,
|
| 550 |
+
legendBottom + iconRadius + 12,
|
| 551 |
+
bottomCardY - iconRadius - Math.max(36, labelFontSize * 3.1)
|
| 552 |
+
)
|
| 553 |
+
};
|
| 554 |
+
const elbowX = labelPoint.x + (labelPoint.x < centerX ? iconRadius + 13 : -iconRadius - 13);
|
| 555 |
+
const iconEdgeX = labelPoint.x + (labelPoint.x < centerX ? iconRadius : -iconRadius);
|
| 556 |
+
|
| 557 |
+
labelLayer.append("polyline")
|
| 558 |
+
.attr("class", "category-connector")
|
| 559 |
+
.attr("points", [
|
| 560 |
+
`${outerPoint.x},${outerPoint.y}`,
|
| 561 |
+
`${elbowX},${outerPoint.y}`,
|
| 562 |
+
`${iconEdgeX},${labelPoint.y}`
|
| 563 |
+
].join(" "))
|
| 564 |
+
.attr("fill", "none")
|
| 565 |
+
.attr("stroke", rgba(referencePalette[0], 0.58))
|
| 566 |
+
.attr("stroke-width", 1.2)
|
| 567 |
+
.attr("stroke-dasharray", "3 4")
|
| 568 |
+
.attr("stroke-linecap", "round");
|
| 569 |
+
|
| 570 |
+
labelLayer.append("circle")
|
| 571 |
+
.attr("class", "category-icon-slot")
|
| 572 |
+
.attr("data-category", category.label)
|
| 573 |
+
.attr("data-category-key", category.key)
|
| 574 |
+
.attr("cx", labelPoint.x)
|
| 575 |
+
.attr("cy", labelPoint.y)
|
| 576 |
+
.attr("r", iconRadius)
|
| 577 |
+
.attr("fill", "url(#circular_stacked_bar_plain_01_badge_gradient)");
|
| 578 |
+
drawBadgeIcon(labelLayer, labelPoint.x, labelPoint.y, iconRadius * 1.18, index);
|
| 579 |
+
|
| 580 |
+
const labelLines = wrapText(category.label, categoryCount > 10 ? 70 : 106, labelFontSize, labelFamily, "600");
|
| 581 |
+
addWrappedText(labelLayer, labelLines, {
|
| 582 |
+
className: "label",
|
| 583 |
+
x: labelPoint.x,
|
| 584 |
+
y: labelPoint.y + iconRadius + labelFontSize * 0.95,
|
| 585 |
+
anchor: "middle",
|
| 586 |
+
family: labelFamily,
|
| 587 |
+
fontSize: labelFontSize,
|
| 588 |
+
weight: "600",
|
| 589 |
+
fill: navy,
|
| 590 |
+
maxLines: 2,
|
| 591 |
+
attrs: {
|
| 592 |
+
"data-category": category.label,
|
| 593 |
+
"data-category-key": category.key
|
| 594 |
+
}
|
| 595 |
+
});
|
| 596 |
+
|
| 597 |
+
labelLayer.append("circle")
|
| 598 |
+
.attr("class", "value-bubble")
|
| 599 |
+
.attr("cx", bubblePoint.x)
|
| 600 |
+
.attr("cy", bubblePoint.y)
|
| 601 |
+
.attr("r", valueBubbleRadius)
|
| 602 |
+
.attr("fill", "#ffffff")
|
| 603 |
+
.attr("stroke", rgba(navy, 0.09))
|
| 604 |
+
.attr("stroke-width", 0.9)
|
| 605 |
+
.style("filter", "url(#circular_stacked_bar_plain_01_soft_shadow)");
|
| 606 |
+
|
| 607 |
+
labelLayer.append("text")
|
| 608 |
+
.attr("class", "value total-value")
|
| 609 |
+
.attr("data-category", category.label)
|
| 610 |
+
.attr("data-category-key", category.key)
|
| 611 |
+
.attr("data-total", category.total)
|
| 612 |
+
.attr("x", bubblePoint.x)
|
| 613 |
+
.attr("y", bubblePoint.y + valueFontSize * 0.34)
|
| 614 |
+
.attr("text-anchor", "middle")
|
| 615 |
+
.style("font-family", valueFamily)
|
| 616 |
+
.style("font-size", `${valueFontSize}px`)
|
| 617 |
+
.style("font-weight", "700")
|
| 618 |
+
.style("fill", greenText)
|
| 619 |
+
.text(formatWithUnit(category.total, yUnit));
|
| 620 |
+
});
|
| 621 |
+
|
| 622 |
+
const legend = svg.append("g").attr("class", "group-legend");
|
| 623 |
+
const legendFontSize = Math.max(10, Math.min(14, baseLabelSize * 0.9));
|
| 624 |
+
const legendItems = groups.map(group => {
|
| 625 |
+
const textWidth = measureLabelText(group, legendFontSize, labelFamily, "700");
|
| 626 |
+
return { group, width: Math.max(68, textWidth + 34) };
|
| 627 |
+
});
|
| 628 |
+
const rowsForLegend = [];
|
| 629 |
+
if (legendRows === 1) {
|
| 630 |
+
rowsForLegend.push(legendItems);
|
| 631 |
+
} else {
|
| 632 |
+
const firstRowCount = Math.ceil(legendItems.length / 2);
|
| 633 |
+
rowsForLegend.push(legendItems.slice(0, firstRowCount));
|
| 634 |
+
rowsForLegend.push(legendItems.slice(firstRowCount));
|
| 635 |
+
}
|
| 636 |
+
rowsForLegend.forEach((rowItems, rowIndex) => {
|
| 637 |
+
const rowWidth = rowItems.reduce((sum, item) => sum + item.width, 0) + Math.max(0, rowItems.length - 1) * 8;
|
| 638 |
+
let x = (width - rowWidth) / 2;
|
| 639 |
+
rowItems.forEach(item => {
|
| 640 |
+
const itemGroup = legend.append("g")
|
| 641 |
+
.attr("class", "group-legend-item")
|
| 642 |
+
.attr("transform", `translate(${x}, ${legendY + rowIndex * legendRowGap})`);
|
| 643 |
+
itemGroup.append("circle")
|
| 644 |
+
.attr("class", "group-chip")
|
| 645 |
+
.attr("data-group", item.group)
|
| 646 |
+
.attr("cx", 8)
|
| 647 |
+
.attr("cy", 0)
|
| 648 |
+
.attr("r", 7)
|
| 649 |
+
.attr("fill", groupColorScale(item.group));
|
| 650 |
+
itemGroup.append("text")
|
| 651 |
+
.attr("class", "group-label")
|
| 652 |
+
.attr("data-group", item.group)
|
| 653 |
+
.attr("x", 24)
|
| 654 |
+
.attr("y", legendFontSize * 0.34)
|
| 655 |
+
.attr("text-anchor", "start")
|
| 656 |
+
.style("font-family", labelFamily)
|
| 657 |
+
.style("font-size", `${legendFontSize}px`)
|
| 658 |
+
.style("font-weight", "700")
|
| 659 |
+
.style("fill", navy)
|
| 660 |
+
.text(item.group);
|
| 661 |
+
x += item.width + 8;
|
| 662 |
+
});
|
| 663 |
+
});
|
| 664 |
+
|
| 665 |
+
const insightCardX = width * 0.10;
|
| 666 |
+
const insightCardW = width * 0.80;
|
| 667 |
+
svg.append("rect")
|
| 668 |
+
.attr("class", "bottom-insight-card")
|
| 669 |
+
.attr("x", insightCardX)
|
| 670 |
+
.attr("y", bottomCardY)
|
| 671 |
+
.attr("width", insightCardW)
|
| 672 |
+
.attr("height", bottomCardHeight)
|
| 673 |
+
.attr("rx", 16)
|
| 674 |
+
.attr("ry", 16)
|
| 675 |
+
.attr("fill", "url(#circular_stacked_bar_plain_01_bottom_gradient)");
|
| 676 |
+
|
| 677 |
+
const insightIconRadius = Math.min(27, bottomCardHeight * 0.38);
|
| 678 |
+
const insightIconX = insightCardX + insightIconRadius + 19;
|
| 679 |
+
const insightIconY = bottomCardY + bottomCardHeight / 2;
|
| 680 |
+
drawInsightIcon(svg, insightIconX, insightIconY, insightIconRadius);
|
| 681 |
+
|
| 682 |
+
svg.append("line")
|
| 683 |
+
.attr("class", "bottom-insight-rule")
|
| 684 |
+
.attr("x1", insightIconX + insightIconRadius + 19)
|
| 685 |
+
.attr("x2", insightIconX + insightIconRadius + 19)
|
| 686 |
+
.attr("y1", bottomCardY + 16)
|
| 687 |
+
.attr("y2", bottomCardY + bottomCardHeight - 16)
|
| 688 |
+
.attr("stroke", rgba(referencePalette[0], 0.24))
|
| 689 |
+
.attr("stroke-width", 1.1);
|
| 690 |
+
|
| 691 |
+
const insightTextX = insightIconX + insightIconRadius + 36;
|
| 692 |
+
const insightWidth = insightCardX + insightCardW - insightTextX - 18;
|
| 693 |
+
const metadata = jsonData.metadata || {};
|
| 694 |
+
const insightText = metadata.main_insight
|
| 695 |
+
|| metadata.description
|
| 696 |
+
|| jsonData.description
|
| 697 |
+
|| `Stacked ${yField} totals compare ${groupField} by ${xField}.`;
|
| 698 |
+
const primaryInsight = String(insightText).replace(/\s+/g, " ").trim();
|
| 699 |
+
const insightTitleSize = Math.max(9.5, Math.min(12.5, baseLabelSize * 0.70));
|
| 700 |
+
const primaryLines = wrapText(primaryInsight, insightWidth, insightTitleSize, labelFamily, "700");
|
| 701 |
+
const insightTitleLineLimit = primaryLines.length > 2 ? 3 : 2;
|
| 702 |
+
addWrappedText(svg, primaryLines, {
|
| 703 |
+
className: "bottom-insight-title",
|
| 704 |
+
x: insightTextX,
|
| 705 |
+
y: bottomCardY + 23,
|
| 706 |
+
anchor: "start",
|
| 707 |
+
family: labelFamily,
|
| 708 |
+
fontSize: insightTitleSize,
|
| 709 |
+
weight: "700",
|
| 710 |
+
fill: navy,
|
| 711 |
+
maxLines: insightTitleLineLimit
|
| 712 |
+
});
|
| 713 |
+
|
| 714 |
+
const noteText = isTemporal
|
| 715 |
+
? "Categories follow chronological order; radial length preserves original values."
|
| 716 |
+
: "Categories are ranked by total unless totals match original order.";
|
| 717 |
+
const noteLines = wrapText(noteText, insightWidth, Math.max(8, baseValueSize * 0.72), valueFamily, "400");
|
| 718 |
+
addWrappedText(svg, noteLines, {
|
| 719 |
+
className: "bottom-insight-note",
|
| 720 |
+
x: insightTextX,
|
| 721 |
+
y: bottomCardY + bottomCardHeight - 18,
|
| 722 |
+
anchor: "start",
|
| 723 |
+
family: valueFamily,
|
| 724 |
+
fontSize: Math.max(8, baseValueSize * 0.72),
|
| 725 |
+
weight: "400",
|
| 726 |
+
fill: mutedText,
|
| 727 |
+
maxLines: 1
|
| 728 |
+
});
|
| 729 |
+
|
| 730 |
+
measure.remove();
|
| 731 |
+
return svg.node();
|
| 732 |
+
}
|