Ray1ee01 commited on
Commit
7e5cee5
·
verified ·
1 Parent(s): f9b6fdc

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
+ }