Ray1ee01 commited on
Commit
73256d7
·
verified ·
1 Parent(s): 6bc14e7

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/type41_radial_layered_spline_ area_chart/radial_layered_spline_area_chart_grid_01.js ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Radial Layered Spline Area Chart",
5
+ "chart_name": "radial_layered_spline_area_chart_grid_01",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[3, 7], [0, "inf"], [1, 6]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["group"],
12
+ "required_other_colors": [],
13
+ "supported_effects": [],
14
+ "min_height": 400,
15
+ "min_width": 400,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "no",
20
+ "has_y_axis": "no"
21
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+ function makeChart(containerSelector, data) {
26
+ // 提取数据
27
+ const jsonData = data;
28
+ const chartData = jsonData.data.data;
29
+ const variables = jsonData.variables;
30
+ const typography = jsonData.typography;
31
+ const colors = jsonData.colors || {};
32
+ const colorResolver = chartUtils.color.resolver(jsonData);
33
+ const dataColumns = chartUtils.schema.columns(jsonData);
34
+ const images = jsonData.images || {};
35
+
36
+ // 清空容器
37
+ d3.select(containerSelector).html("");
38
+
39
+ // 获取字段名
40
+ const categoryField = chartUtils.schema.columnField(dataColumns, 0);
41
+ const valueField = chartUtils.schema.columnField(dataColumns, 1);
42
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
43
+
44
+ // 内联layoutLegend函数
45
+ const layoutLegend = (g, groups, colors, options = {}) => {
46
+ const defaults = {
47
+ maxWidth: 500, x: 0, y: 0, itemHeight: 20, itemSpacing: 20, rowSpacing: 10,
48
+ symbolSize: 10, textColor: "#333", fontSize: 12, fontWeight: "normal", align: "left", shape: "circle"
49
+ };
50
+ const opts = {...defaults, ...options};
51
+
52
+ const itemWidths = groups.map(group => opts.symbolSize * 2 + chartUtils.text.measure(g, group, {
53
+ fontSize: opts.fontSize,
54
+ fontWeight: opts.fontWeight
55
+ }).width + 5);
56
+
57
+ const rows = [];
58
+ let currentRow = [], currentRowWidth = 0;
59
+ itemWidths.forEach((width, i) => {
60
+ if (currentRow.length === 0 || currentRowWidth + width + opts.itemSpacing <= opts.maxWidth) {
61
+ currentRow.push(i);
62
+ currentRowWidth += width + (currentRow.length > 1 ? opts.itemSpacing : 0);
63
+ } else {
64
+ rows.push(currentRow);
65
+ currentRow = [i];
66
+ currentRowWidth = width;
67
+ }
68
+ });
69
+ if (currentRow.length > 0) rows.push(currentRow);
70
+
71
+ const totalHeight = rows.length * opts.itemHeight + (rows.length - 1) * opts.rowSpacing;
72
+ const maxRowWidth = Math.max(...rows.map(row => row.reduce((sum, i, idx) => sum + itemWidths[i] + (idx > 0 ? opts.itemSpacing : 0), 0)));
73
+
74
+ rows.forEach((row, rowIndex) => {
75
+ const rowWidth = row.reduce((sum, i, idx) => sum + itemWidths[i] + (idx > 0 ? opts.itemSpacing : 0), 0);
76
+ let rowStartX = opts.align === "center" ? opts.x + (opts.maxWidth - rowWidth) / 2 : opts.align === "right" ? opts.x + opts.maxWidth - rowWidth : opts.x;
77
+
78
+ let currentX = rowStartX;
79
+ row.forEach(i => {
80
+ const group = groups[i];
81
+ const color = colorResolver.field(group, i, { palette: "category10" }).value;
82
+ const legendGroup = g.append("g").attr("transform", `translate(${currentX}, ${opts.y + rowIndex * (opts.itemHeight + opts.rowSpacing)})`);
83
+
84
+ legendGroup.append("circle").attr("class", "mark").attr("cx", opts.symbolSize / 2).attr("cy", opts.itemHeight / 2).attr("r", opts.symbolSize / 2).attr("fill", color);
85
+ legendGroup.append("text").attr("class", "label").attr("x", opts.symbolSize * 1.5).attr("y", opts.itemHeight / 2).attr("dominant-baseline", "middle").attr("fill", opts.textColor).style("font-size", `${opts.fontSize}px`).style("font-weight", opts.fontWeight).text(group);
86
+
87
+ currentX += itemWidths[i] + opts.itemSpacing;
88
+ });
89
+ });
90
+
91
+ return { width: maxRowWidth, height: totalHeight };
92
+ };
93
+
94
+ // 设置尺寸和边距
95
+ const width = variables.width;
96
+ const height = variables.height;
97
+ const margin = { top: 50, right: 50, bottom: 50, left: 50 };
98
+
99
+ // 创建SVG
100
+ const svg = d3.select(containerSelector)
101
+ .append("svg")
102
+ .attr("width", "100%")
103
+ .attr("height", height)
104
+ .attr("viewBox", `0 0 ${width} ${height}`)
105
+ .attr("style", "max-width: 100%; height: auto;")
106
+ .attr("xmlns", "http://www.w3.org/2000/svg")
107
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
108
+
109
+ // 创建图表区域
110
+ const chartWidth = width - margin.left - margin.right;
111
+ const chartHeight = height - margin.top - margin.bottom;
112
+ const radius = Math.min(chartWidth, chartHeight) / 2;
113
+
114
+ const g = svg.append("g")
115
+ .attr("transform", `translate(${width/2}, ${height/2})`);
116
+
117
+ // 获取唯一类别和分组
118
+ const categories = [...new Set(chartData.map(d => d[categoryField]))];
119
+ const groupAvgs = [...new Set(chartData.map(d => d[groupField]))]
120
+ .map(group => ({
121
+ group,
122
+ avg: d3.mean(chartData.filter(d => d[groupField] === group), d => +d[valueField])
123
+ }))
124
+ .sort((a, b) => b.avg - a.avg);
125
+
126
+ const groups = groupAvgs.map(d => d.group);
127
+
128
+ // 创建颜色比例尺
129
+ const colorScale = d => colorResolver.field(d, groups.indexOf(d), { palette: "tableau10" }).value;
130
+
131
+ // 创建角度比例尺
132
+ const angleScale = d3.scalePoint()
133
+ .domain(categories)
134
+ .range([0, 2 * Math.PI - (2 * Math.PI / categories.length)]);
135
+
136
+ // 创建半径比例尺
137
+ const allValues = chartData.map(d => +d[valueField]);
138
+ const minValue = Math.min(0, d3.min(allValues));
139
+ const maxValue = d3.max(allValues);
140
+
141
+ const radiusScale = d3.scaleLinear()
142
+ .domain([minValue, maxValue])
143
+ .range([0, radius])
144
+ .nice();
145
+
146
+ // 绘制背景圆环
147
+ const ticks = radiusScale.ticks(5);
148
+
149
+ // 绘制同心圆
150
+ g.selectAll(".circle-axis")
151
+ .data(ticks)
152
+ .enter()
153
+ .append("circle")
154
+ .attr("class", "gridline")
155
+ .attr("cx", 0)
156
+ .attr("cy", 0)
157
+ .attr("r", d => radiusScale(d))
158
+ .attr("fill", "none")
159
+ .attr("stroke", "#bbb")
160
+ .attr("stroke-width", 1)
161
+ .attr("stroke-dasharray", "4,4");
162
+
163
+ // 绘制径向轴线
164
+ g.selectAll(".axis-line")
165
+ .data(categories)
166
+ .enter()
167
+ .append("line")
168
+ .attr("class", "axis")
169
+ .attr("x1", 0)
170
+ .attr("y1", 0)
171
+ .attr("x2", d => radius * Math.cos(angleScale(d) - Math.PI/2))
172
+ .attr("y2", d => radius * Math.sin(angleScale(d) - Math.PI/2))
173
+ .attr("stroke", "#bbb")
174
+ .attr("stroke-width", 1);
175
+
176
+ // 添加类别标签
177
+ g.selectAll(".category-label")
178
+ .data(categories)
179
+ .enter()
180
+ .append("text")
181
+ .attr("class", "label")
182
+ .attr("x", d => (radius + 20) * Math.cos(angleScale(d) - Math.PI/2))
183
+ .attr("y", d => (radius + 20) * Math.sin(angleScale(d) - Math.PI/2))
184
+ .attr("text-anchor", d => {
185
+ const angle = angleScale(d);
186
+ return angle > Math.PI ? "end" : "start";
187
+ })
188
+ .attr("dominant-baseline", "middle")
189
+ .attr("fill", "#333")
190
+ .attr("font-size", "16px")
191
+ .attr("font-weight", "bold")
192
+ .text(d => chartUtils.format.category(d).text);
193
+
194
+ // 添加刻度值标签
195
+ g.selectAll(".tick-label")
196
+ .data(ticks.filter(d => d > 0))
197
+ .enter()
198
+ .append("text")
199
+ .attr("class", "value")
200
+ .attr("x", 5)
201
+ .attr("y", d => -radiusScale(d) + 5)
202
+ .attr("text-anchor", "start")
203
+ .attr("font-size", "14px")
204
+ .attr("fill", "#666")
205
+ .text(d => chartUtils.format.number(d).text);
206
+
207
+ // 创建径向面积生成器 - 关键:所有面积都从中心点开始(innerRadius=0)
208
+ const areaRadial = d3.areaRadial()
209
+ .angle(d => angleScale(d[categoryField]))
210
+ .innerRadius(0) // 关键:所有系列共享零基线(中心点)
211
+ .outerRadius(d => radiusScale(+d[valueField]))
212
+ .curve(d3.curveCatmullRomClosed.alpha(0.5)); // 改回CatmullRom以确保经过数据点
213
+
214
+ // 绘制每个组的径向分层面积 - 按平均值降序绘制,确保小的不被大的完全遮挡
215
+ [...groups].reverse().forEach(group => {
216
+ const groupData = chartData.filter(d => d[groupField] === group);
217
+
218
+ // 为每个类别找到对应的数据点,如果没有则跳过该类别
219
+ const sortedData = categories
220
+ .map(cat => groupData.find(d => d[categoryField] === cat))
221
+ .filter(d => d !== undefined); // 只保留实际存在的数据点
222
+
223
+ // 只有当有足够数据点时才绘制面积(至少3个点)
224
+ if (sortedData.length >= 3) {
225
+ // 绘制径向面积
226
+ g.append("path")
227
+ .datum(sortedData)
228
+ .attr("class", "mark")
229
+ .attr("d", areaRadial)
230
+ .attr("fill", colorScale(group))
231
+ .attr("fill-opacity", 0.3) // 半透明,便于看见被遮挡的层
232
+ .attr("stroke", colorScale(group))
233
+ .attr("stroke-width", 2)
234
+ .attr("stroke-opacity", 0.8);
235
+ }
236
+
237
+ // 绘制数据点 - 始终绘制所有实际数据点
238
+ sortedData.forEach(point => {
239
+ const angle = angleScale(point[categoryField]) - Math.PI/2;
240
+ const distance = radiusScale(+point[valueField]);
241
+
242
+ g.append("circle")
243
+ .attr("class", "mark")
244
+ .attr("cx", distance * Math.cos(angle))
245
+ .attr("cy", distance * Math.sin(angle))
246
+ .attr("r", 3)
247
+ .attr("fill", colorScale(group))
248
+ .attr("stroke", "#fff")
249
+ .attr("stroke-width", 1);
250
+ });
251
+ });
252
+
253
+ // 添加图例
254
+ const legendGroup = svg.append("g");
255
+ const legendSize = chartUtils.legend.draw(legendGroup, groups, colors, {
256
+ x: 0, y: 0, fontSize: 14, fontWeight: "bold", align: "center",
257
+ maxWidth: chartWidth, shape: "circle", textColor: "#333"
258
+ });
259
+
260
+ // 居中legend
261
+ legendGroup.attr("transform", `translate(${(width - legendSize.width) / 2}, ${height - margin.bottom + 10})`);
262
+
263
+ return svg.node();
264
+ }
modules/chart_engine/template/d3-js/type41_radial_layered_spline_ area_chart/radial_layered_spline_area_plain_chart_01.js ADDED
@@ -0,0 +1,729 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Radial Layered Spline Area Chart",
5
+ "chart_name": "radial_layered_spline_area_plain_chart_01",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[3, 7], [0, "inf"], [1, 6]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["group"],
12
+ "required_other_colors": [],
13
+ "supported_effects": [],
14
+ "min_height": 400,
15
+ "min_width": 400,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "no",
20
+ "has_y_axis": "no"
21
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+ function makeChart(containerSelector, data) {
26
+ const jsonData = data || {};
27
+ const sourceData = jsonData.data?.data || [];
28
+ const dataColumns = chartUtils.schema.columns(jsonData);
29
+ const colorResolver = chartUtils.color.resolver(jsonData);
30
+ const chartUtilsFormatSample = chartUtils.format.autoText;
31
+ const chartUtilsTextSample = chartUtils.text.estimate;
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
+ const sourceColors = jsonData.colors || {};
54
+
55
+ d3.select(containerSelector).html("");
56
+
57
+ const roleColumn = role => Array.from(dataColumns).find(col => col.role === role);
58
+ const categoryColumn = roleColumn("x") || dataColumns[0] || {};
59
+ const valueColumn = roleColumn("y") || dataColumns[1] || {};
60
+ const groupColumn = roleColumn("group") || dataColumns[2] || {};
61
+ const categoryField = categoryColumn.name;
62
+ const valueField = valueColumn.name;
63
+ const groupField = groupColumn.name;
64
+ const valueUnit = (valueColumn.unit || "").trim();
65
+
66
+ const width = Math.max(720, Number(variables.width) || 720);
67
+ const height = Math.max(Math.round(width * 1.5), Number(variables.height) || 0, 1080);
68
+ const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
69
+ const annotationFamily = typography.annotation?.font_family || fontFamily;
70
+ const labelWeight = typography.label?.font_weight || "600";
71
+ const annotationWeight = typography.annotation?.font_weight || "600";
72
+ const baseLabelSize = parseFloat(typography.label?.font_size) || 12;
73
+ const baseValueSize = parseFloat(typography.annotation?.font_size) || 11;
74
+ const textColor = "#102766";
75
+ const mutedText = "#5f6f8a";
76
+ const gridColor = "rgba(78, 107, 157, 0.24)";
77
+
78
+ const svg = d3.select(containerSelector)
79
+ .append("svg")
80
+ .attr("width", "100%")
81
+ .attr("height", height)
82
+ .attr("viewBox", `0 0 ${width} ${height}`)
83
+ .attr("style", "max-width: 100%; height: auto;")
84
+ .attr("xmlns", "http://www.w3.org/2000/svg")
85
+ .attr("class", "radial-layered-spline-area-root");
86
+
87
+ const defs = svg.append("defs");
88
+ const bgGradient = defs.append("linearGradient")
89
+ .attr("id", "radial-layered-bg-gradient")
90
+ .attr("x1", "0%")
91
+ .attr("y1", "0%")
92
+ .attr("x2", "100%")
93
+ .attr("y2", "100%");
94
+ bgGradient.append("stop").attr("offset", "0%").attr("stop-color", "#fbfdff");
95
+ bgGradient.append("stop").attr("offset", "54%").attr("stop-color", "#f1f7ff");
96
+ bgGradient.append("stop").attr("offset", "100%").attr("stop-color", "#f9fbff");
97
+ const softShadow = defs.append("filter")
98
+ .attr("id", "radial-layered-soft-shadow")
99
+ .attr("x", "-25%")
100
+ .attr("y", "-25%")
101
+ .attr("width", "150%")
102
+ .attr("height", "150%");
103
+ softShadow.append("feDropShadow")
104
+ .attr("dx", 0)
105
+ .attr("dy", 8)
106
+ .attr("stdDeviation", 10)
107
+ .attr("flood-color", "#164a86")
108
+ .attr("flood-opacity", 0.14);
109
+ defs.append("style")
110
+ .text(".text[data-type='title']{display:none;}");
111
+
112
+ svg.append("rect")
113
+ .attr("class", "poster-background")
114
+ .attr("x", -180)
115
+ .attr("y", -700)
116
+ .attr("width", width + 420)
117
+ .attr("height", height + 900)
118
+ .attr("fill", "url(#radial-layered-bg-gradient)");
119
+ svg.append("circle")
120
+ .attr("class", "header-background-wash")
121
+ .attr("cx", width * 0.82)
122
+ .attr("cy", -500)
123
+ .attr("r", width * 0.32)
124
+ .attr("fill", "rgba(199, 226, 250, 0.36)");
125
+
126
+ if (!categoryField || !valueField || !groupField) {
127
+ svg.append("text")
128
+ .attr("x", width / 2)
129
+ .attr("y", height / 2)
130
+ .attr("text-anchor", "middle")
131
+ .attr("fill", textColor)
132
+ .style("font-family", fontFamily)
133
+ .style("font-size", "16px")
134
+ .text("Missing radial layered spline area fields");
135
+ return svg.node();
136
+ }
137
+
138
+ function cleanLabel(value) {
139
+ return String(value ?? "")
140
+ .replace(/_/g, " ")
141
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
142
+ .replace(/\s+/g, " ")
143
+ .trim();
144
+ }
145
+
146
+ function compactNumber(value) {
147
+ const numeric = Number(value);
148
+ if (!Number.isFinite(numeric)) return String(value ?? "");
149
+ const abs = Math.abs(numeric);
150
+ if (abs >= 1000000000) return `${d3.format(".3~g")(numeric / 1000000000)}B`;
151
+ if (abs >= 1000000) return `${d3.format(".3~g")(numeric / 1000000)}M`;
152
+ if (abs >= 1000) return `${d3.format(".3~g")(numeric / 1000)}K`;
153
+ return d3.format(",.4~g")(numeric);
154
+ }
155
+
156
+ function formatLocalValue(value, includeUnit = true) {
157
+ const formatted = compactNumber(value);
158
+ if (!includeUnit || !valueUnit || valueUnit === "none") return formatted;
159
+ if (valueUnit === "%") return `${formatted}%`;
160
+ if (valueUnit === "$") return `$${formatted}`;
161
+ return `${formatted} ${valueUnit}`;
162
+ }
163
+
164
+ function parseTemporalRank(value) {
165
+ const text = String(value ?? "").trim();
166
+ const lower = text.toLowerCase();
167
+ const monthOrder = {
168
+ jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3, apr: 4, april: 4,
169
+ may: 5, jun: 6, june: 6, jul: 7, july: 7, aug: 8, august: 8,
170
+ sep: 9, sept: 9, september: 9, oct: 10, october: 10, nov: 11, november: 11, dec: 12, december: 12
171
+ };
172
+ if (monthOrder[lower] != null) return monthOrder[lower];
173
+ let match = text.match(/^(-?\d{4})$/);
174
+ if (match) return Number(match[1]) * 10000;
175
+ match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
176
+ if (match) return Number(match[1]) * 10000 + Number(match[2]) * 100 + Number(match[3] || 1);
177
+ match = text.match(/^q([1-4])\s*(-?\d{4})$/i) || text.match(/^(-?\d{4})\s*q([1-4])$/i);
178
+ if (match && match[1].length === 1) return Number(match[2]) * 10 + Number(match[1]);
179
+ if (match) return Number(match[1]) * 10 + Number(match[2]);
180
+ return NaN;
181
+ }
182
+
183
+ function parseOrdinalRank(value) {
184
+ const text = String(value ?? "").trim().toLowerCase();
185
+ let match = text.match(/^(\d+(?:\.\d+)?)\s*(?:-|to)\s*\d+(?:\.\d+)?/);
186
+ if (match) return Number(match[1]);
187
+ match = text.match(/^(\d+(?:\.\d+)?)\s*\+$/);
188
+ if (match) return Number(match[1]);
189
+ match = text.match(/^(-?\d+(?:\.\d+)?)/);
190
+ if (match) return Number(match[1]);
191
+
192
+ const educationRanks = [
193
+ [/primary|elementary/, 1],
194
+ [/secondary|middle|high school/, 2],
195
+ [/undergraduate|bachelor|college/, 3],
196
+ [/graduate|master/, 4],
197
+ [/doctor|phd|postgraduate/, 5]
198
+ ];
199
+ for (const [pattern, rank] of educationRanks) {
200
+ if (pattern.test(text)) return rank;
201
+ }
202
+ return NaN;
203
+ }
204
+
205
+ const rows = sourceData
206
+ .map((d, index) => ({
207
+ index,
208
+ category: cleanLabel(d[categoryField]),
209
+ rawCategory: d[categoryField],
210
+ group: cleanLabel(d[groupField]),
211
+ rawGroup: d[groupField],
212
+ value: Number(d[valueField])
213
+ }))
214
+ .filter(d => d.category && d.group && Number.isFinite(d.value) && d.value >= 0);
215
+
216
+ const categoryLabels = Array.from(new Set(rows.map(d => d.category)));
217
+ const groupLabels = Array.from(new Set(rows.map(d => d.group)));
218
+ if (categoryLabels.length < 3 || groupLabels.length < 1) {
219
+ svg.append("text")
220
+ .attr("x", width / 2)
221
+ .attr("y", height / 2)
222
+ .attr("text-anchor", "middle")
223
+ .attr("fill", textColor)
224
+ .style("font-family", fontFamily)
225
+ .style("font-size", "16px")
226
+ .text("Not enough data for radial layered spline area");
227
+ return svg.node();
228
+ }
229
+
230
+ const categoryStats = categoryLabels.map(category => {
231
+ const categoryRows = rows.filter(d => d.category === category);
232
+ const rawCategory = categoryRows[0]?.rawCategory;
233
+ return {
234
+ category,
235
+ rawCategory,
236
+ meanValue: d3.mean(categoryRows, d => d.value) || 0,
237
+ temporalRank: parseTemporalRank(rawCategory),
238
+ ordinalRank: parseOrdinalRank(rawCategory)
239
+ };
240
+ });
241
+ const allTemporal = categoryStats.every(d => Number.isFinite(d.temporalRank));
242
+ const allOrdinal = !allTemporal && categoryStats.every(d => Number.isFinite(d.ordinalRank));
243
+ const sortMode = allTemporal ? "chronological" : (allOrdinal ? "ordered category" : "average value");
244
+ const orderedCategories = [...categoryStats].sort((a, b) => {
245
+ if (allTemporal) return d3.ascending(a.temporalRank, b.temporalRank);
246
+ if (allOrdinal) return d3.ascending(a.ordinalRank, b.ordinalRank);
247
+ return d3.descending(a.meanValue, b.meanValue) || d3.ascending(a.category, b.category);
248
+ }).map((d, index) => ({ ...d, rank: index + 1 }));
249
+
250
+ const groupStats = groupLabels.map(group => {
251
+ const groupRows = rows.filter(d => d.group === group);
252
+ return {
253
+ group,
254
+ meanValue: d3.mean(groupRows, d => d.value) || 0,
255
+ maxValue: d3.max(groupRows, d => d.value) || 0
256
+ };
257
+ }).sort((a, b) => d3.descending(a.meanValue, b.meanValue) || d3.ascending(a.group, b.group));
258
+ const groups = groupStats.map(d => d.group);
259
+
260
+ const measureGroup = svg.append("g").attr("visibility", "hidden");
261
+ function measureLabelText(text, fontSize, family = fontFamily, weight = labelWeight) {
262
+ const node = measureGroup.append("text")
263
+ .style("font-family", family)
264
+ .style("font-size", `${fontSize}px`)
265
+ .style("font-weight", weight)
266
+ .text(String(text ?? ""))
267
+ .node();
268
+ const measured = node ? node.textContent.length * 7 : 0;
269
+ measureGroup.selectAll("text").remove();
270
+ return Math.max(measured || 0, String(text ?? "").length * fontSize * 0.52);
271
+ }
272
+
273
+ function truncateText(text, maxWidth, fontSize, family = fontFamily, weight = labelWeight) {
274
+ const full = String(text ?? "");
275
+ if (measureLabelText(full, fontSize, family, weight) <= maxWidth) return full;
276
+ let lo = 1;
277
+ let hi = full.length;
278
+ let best = full.slice(0, 1);
279
+ while (lo <= hi) {
280
+ const mid = Math.floor((lo + hi) / 2);
281
+ const candidate = `${full.slice(0, mid).trim()}...`;
282
+ if (measureLabelText(candidate, fontSize, family, weight) <= maxWidth) {
283
+ best = candidate;
284
+ lo = mid + 1;
285
+ } else {
286
+ hi = mid - 1;
287
+ }
288
+ }
289
+ return best;
290
+ }
291
+
292
+ function wrapTextLines(text, maxWidth, fontSize, family = fontFamily, weight = labelWeight, maxLines = 3) {
293
+ const words = String(text ?? "").replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
294
+ const lines = [];
295
+ let current = "";
296
+ words.forEach(word => {
297
+ const candidate = current ? `${current} ${word}` : word;
298
+ if (measureLabelText(candidate, fontSize, family, weight) <= maxWidth || !current) {
299
+ current = candidate;
300
+ } else {
301
+ lines.push(current);
302
+ current = word;
303
+ }
304
+ });
305
+ if (current) lines.push(current);
306
+ if (lines.length > maxLines) {
307
+ const kept = lines.slice(0, maxLines);
308
+ kept[maxLines - 1] = truncateText(lines.slice(maxLines - 1).join(" "), maxWidth, fontSize, family, weight);
309
+ return kept;
310
+ }
311
+ return lines;
312
+ }
313
+
314
+ function rgba(color, opacity) {
315
+ const c = d3.rgb(color);
316
+ return `rgba(${c.r}, ${c.g}, ${c.b}, ${opacity})`;
317
+ }
318
+
319
+ const mainTitle = cleanLabel(jsonData.titles?.main_title || jsonData.metadata?.title || variables.title || "Layered Spline Area");
320
+ const titleSplit = mainTitle.match(/^(.+?)\s+in\s+(.+)$/i);
321
+ const heroTitle = titleSplit ? titleSplit[1] : mainTitle;
322
+ const kickerTitle = titleSplit ? `IN ${titleSplit[2].toUpperCase()}` : cleanLabel(valueField).toUpperCase();
323
+ const subtitle = cleanLabel(jsonData.titles?.sub_title || jsonData.metadata?.description || jsonData.description || "");
324
+ const headerX = -width * 0.06;
325
+ const headerTop = -620;
326
+ const headerMaxWidth = width * 0.43;
327
+ const longHeroTitle = measureLabelText(heroTitle, width * 0.052, fontFamily, "800") > headerMaxWidth * 2.1;
328
+ const heroFontSize = longHeroTitle ? Math.max(28, Math.min(34, width * 0.046)) : Math.max(36, Math.min(54, width * 0.072));
329
+ const heroLineHeight = heroFontSize * (longHeroTitle ? 1.03 : 1.06);
330
+ const heroLines = wrapTextLines(heroTitle, headerMaxWidth, heroFontSize, fontFamily, "800", longHeroTitle ? 4 : 2);
331
+ heroLines.forEach((lineText, index) => {
332
+ svg.append("text")
333
+ .attr("class", "chart-title hero-title")
334
+ .attr("x", headerX)
335
+ .attr("y", headerTop + index * heroLineHeight)
336
+ .attr("fill", "#102766")
337
+ .style("font-family", fontFamily)
338
+ .style("font-size", `${heroFontSize}px`)
339
+ .style("font-weight", "800")
340
+ .style("letter-spacing", "0")
341
+ .text(lineText);
342
+ });
343
+ const kickerY = headerTop + heroLines.length * heroLineHeight + 22;
344
+ svg.append("text")
345
+ .attr("class", "chart-subtitle-kicker")
346
+ .attr("x", headerX)
347
+ .attr("y", kickerY)
348
+ .attr("fill", "#109184")
349
+ .style("font-family", fontFamily)
350
+ .style("font-size", `${Math.max(15, width * 0.024)}px`)
351
+ .style("font-weight", "800")
352
+ .style("letter-spacing", "0")
353
+ .text(truncateText(kickerTitle, headerMaxWidth + 54, Math.max(15, width * 0.024), fontFamily, "800"));
354
+ svg.append("line")
355
+ .attr("class", "header-accent-rule")
356
+ .attr("x1", headerX)
357
+ .attr("x2", headerX + 54)
358
+ .attr("y1", kickerY + 26)
359
+ .attr("y2", kickerY + 26)
360
+ .attr("stroke", "#12a494")
361
+ .attr("stroke-width", 4)
362
+ .attr("stroke-linecap", "round");
363
+ wrapTextLines(subtitle, headerMaxWidth, Math.max(16, width * 0.026), fontFamily, "500", 3)
364
+ .forEach((lineText, index) => {
365
+ svg.append("text")
366
+ .attr("class", "chart-subtitle-body")
367
+ .attr("x", headerX)
368
+ .attr("y", kickerY + 58 + index * 28)
369
+ .attr("fill", "#20325a")
370
+ .style("font-family", fontFamily)
371
+ .style("font-size", `${Math.max(16, width * 0.026)}px`)
372
+ .style("font-weight", "500")
373
+ .text(lineText);
374
+ });
375
+
376
+ const slotCx = width * 0.72;
377
+ const slotCy = -510;
378
+ const slotR = Math.min(width * 0.16, height * 0.108);
379
+ const headerSlot = svg.append("g")
380
+ .attr("class", "reserved-asset-slot header-topic-image-slot")
381
+ .attr("data-asset-slot", "header_topic_image")
382
+ .attr("data-asset-source-policy", "image2_asset")
383
+ .attr("data-anchor", "top-right-header-safe-area")
384
+ .attr("data-collision-rule", "keep 24px from title/subtitle and 28px from chart labels")
385
+ .attr("transform", `translate(${slotCx}, ${slotCy})`);
386
+ headerSlot.append("circle")
387
+ .attr("class", "header-topic-image-slot-mask")
388
+ .attr("r", slotR)
389
+ .attr("fill", "#c9f4f6")
390
+ .attr("stroke", "#ffffff")
391
+ .attr("stroke-width", 12)
392
+ .attr("filter", "url(#radial-layered-soft-shadow)");
393
+ headerSlot.append("circle")
394
+ .attr("class", "header-topic-image-slot-glow")
395
+ .attr("r", slotR * 0.86)
396
+ .attr("fill", "rgba(255, 255, 255, 0.22)");
397
+ headerSlot.append("circle")
398
+ .attr("class", "header-topic-image-slot-inner")
399
+ .attr("r", slotR * 0.66)
400
+ .attr("fill", "none")
401
+ .attr("stroke", "rgba(16, 39, 102, 0.18)")
402
+ .attr("stroke-width", 2)
403
+ .attr("stroke-dasharray", "6,9");
404
+ headerSlot.append("text")
405
+ .attr("class", "header-topic-image-slot-label")
406
+ .attr("text-anchor", "middle")
407
+ .attr("dominant-baseline", "central")
408
+ .attr("fill", "rgba(16, 39, 102, 0.30)")
409
+ .style("font-family", fontFamily)
410
+ .style("font-size", `${Math.max(11, width * 0.017)}px`)
411
+ .style("font-weight", "700")
412
+ .text("IMAGE");
413
+
414
+ const centerX = width * 0.5;
415
+ const centerY = height * 0.152;
416
+ const radius = Math.max(220, Math.min(width * 0.412, height * 0.275));
417
+ const labelRadius = radius + 30;
418
+ const maxValue = Math.max(1, d3.max(rows, d => d.value) || 1);
419
+ const radiusScale = d3.scaleLinear()
420
+ .domain([0, maxValue * 1.04])
421
+ .range([0, radius]);
422
+ const domainMax = radiusScale.domain()[1];
423
+ const ticks = d3.range(1, 5).map(step => domainMax * step / 4);
424
+ const angleForIndex = index => (index / orderedCategories.length) * Math.PI * 2 - Math.PI / 2;
425
+
426
+ const referencePalette = ["#2364d8", "#8665dc", "#18a8a3", "#f59e0b", "#ef476f", "#2a9d8f"];
427
+ const colorScale = d3.scaleOrdinal()
428
+ .domain(groups)
429
+ .range(groups.map((group, index) => referencePalette[index % referencePalette.length]));
430
+
431
+ const valueByGroupCategory = new Map();
432
+ rows.forEach(d => {
433
+ valueByGroupCategory.set(`${d.group}|||${d.category}`, d.value);
434
+ });
435
+
436
+ const seriesData = groupStats.map(groupInfo => {
437
+ const values = orderedCategories.map((categoryInfo, index) => {
438
+ const value = valueByGroupCategory.get(`${groupInfo.group}|||${categoryInfo.category}`) ?? 0;
439
+ const angle = angleForIndex(index);
440
+ const distance = radiusScale(value);
441
+ return {
442
+ group: groupInfo.group,
443
+ category: categoryInfo.category,
444
+ rank: categoryInfo.rank,
445
+ value,
446
+ angle,
447
+ x: distance * Math.cos(angle),
448
+ y: distance * Math.sin(angle)
449
+ };
450
+ });
451
+ return {
452
+ group: groupInfo.group,
453
+ meanValue: groupInfo.meanValue,
454
+ values
455
+ };
456
+ });
457
+
458
+ const plot = svg.append("g")
459
+ .attr("class", "radial-layered-spline-area-plot")
460
+ .attr("transform", `translate(${centerX}, ${centerY})`);
461
+
462
+ const ringLine = d3.line()
463
+ .x(d => d.x)
464
+ .y(d => d.y)
465
+ .curve(d3.curveLinearClosed);
466
+
467
+ ticks.forEach(tick => {
468
+ const tickRadius = radiusScale(tick);
469
+ const ringPoints = orderedCategories.map((categoryInfo, index) => {
470
+ const angle = angleForIndex(index);
471
+ return {
472
+ x: tickRadius * Math.cos(angle),
473
+ y: tickRadius * Math.sin(angle)
474
+ };
475
+ });
476
+ plot.append("path")
477
+ .attr("class", "radial-grid-ring gridline")
478
+ .attr("d", ringLine(ringPoints))
479
+ .attr("fill", "none")
480
+ .attr("stroke", gridColor)
481
+ .attr("stroke-width", 1)
482
+ .attr("stroke-dasharray", "3,4");
483
+
484
+ });
485
+
486
+ orderedCategories.forEach((categoryInfo, index) => {
487
+ const angle = angleForIndex(index);
488
+ const axisX = radius * Math.cos(angle);
489
+ const axisY = radius * Math.sin(angle);
490
+ const cos = Math.cos(angle);
491
+ const sin = Math.sin(angle);
492
+ const iconR = Math.max(18, Math.min(26, width * 0.034));
493
+ let labelX = labelRadius * cos;
494
+ let labelY = labelRadius * sin;
495
+ if (sin < -0.78) {
496
+ labelY = -radius - iconR * 3.25;
497
+ }
498
+
499
+ plot.append("line")
500
+ .attr("class", "category-axis-line")
501
+ .attr("x1", 0)
502
+ .attr("y1", 0)
503
+ .attr("x2", axisX)
504
+ .attr("y2", axisY)
505
+ .attr("stroke", rgba(textColor, 0.12))
506
+ .attr("stroke-width", 1);
507
+
508
+ plot.append("circle")
509
+ .attr("class", "category-index-dot")
510
+ .attr("cx", axisX)
511
+ .attr("cy", axisY)
512
+ .attr("r", 4.5)
513
+ .attr("fill", "#ffffff")
514
+ .attr("stroke", "#2364d8")
515
+ .attr("stroke-width", 2);
516
+
517
+ plot.append("text")
518
+ .attr("class", "category-label")
519
+ .attr("x", labelX)
520
+ .attr("y", labelY)
521
+ .attr("text-anchor", Math.abs(cos) < 0.25 ? "middle" : (cos > 0 ? "start" : "end"))
522
+ .attr("dominant-baseline", "central")
523
+ .attr("fill", textColor)
524
+ .style("font-family", fontFamily)
525
+ .style("font-size", `${Math.max(16, Math.min(22, baseLabelSize * 1.3))}px`)
526
+ .style("font-weight", "800")
527
+ .text(truncateText(categoryInfo.category, width * 0.16, Math.max(16, Math.min(22, baseLabelSize * 1.3)), fontFamily, "800"));
528
+
529
+ let iconX = labelX;
530
+ let iconY = labelY;
531
+ if (sin < -0.78) {
532
+ iconY = -radius - iconR - 8;
533
+ } else if (sin > 0.58) {
534
+ iconY = Math.min(labelY + iconR * 1.65, radius + 86 - iconR - 18);
535
+ iconX = labelX + (cos >= 0 ? iconR * 1.1 : -iconR * 1.1);
536
+ } else {
537
+ iconY = labelY + iconR * 1.35;
538
+ iconX = labelX + (cos >= 0 ? iconR * 1.85 : -iconR * 1.85);
539
+ }
540
+ const edgePad = iconR + 8;
541
+ iconX = Math.max(-centerX + edgePad, Math.min(centerX - edgePad, iconX));
542
+ iconY = Math.max(-radius - iconR - 24, Math.min(height - centerY - 250, iconY));
543
+ const iconSlot = plot.append("g")
544
+ .attr("class", "reserved-asset-slot category-icon-slot")
545
+ .attr("data-asset-slot", `category_icon_${categoryInfo.rank}`)
546
+ .attr("data-category", categoryInfo.category)
547
+ .attr("data-asset-source-policy", "image2_asset")
548
+ .attr("data-anchor", "category-axis-label-floating-offset")
549
+ .attr("data-collision-rule", "avoid category label, radial mark, legend card, and canvas edge")
550
+ .attr("transform", `translate(${iconX}, ${iconY})`);
551
+ iconSlot.append("circle")
552
+ .attr("class", "category-icon-slot-mask")
553
+ .attr("r", iconR)
554
+ .attr("fill", "#ffffff")
555
+ .attr("stroke", rgba(colorScale(groups[index % groups.length] || groups[0]), 0.55))
556
+ .attr("stroke-width", 1.6)
557
+ .attr("filter", "url(#radial-layered-soft-shadow)");
558
+ iconSlot.append("circle")
559
+ .attr("class", "category-icon-slot-placeholder")
560
+ .attr("r", iconR * 0.32)
561
+ .attr("fill", rgba(colorScale(groups[index % groups.length] || groups[0]), 0.18))
562
+ .attr("stroke", rgba(textColor, 0.14))
563
+ .attr("stroke-width", 1);
564
+ });
565
+
566
+ const line = d3.line()
567
+ .x(d => d.x)
568
+ .y(d => d.y)
569
+ .curve(d3.curveCatmullRomClosed.alpha(0.5));
570
+
571
+ [...seriesData].reverse().forEach(series => {
572
+ const color = colorScale(series.group);
573
+ plot.append("path")
574
+ .datum(series.values)
575
+ .attr("class", "mark radial-layered-spline-area")
576
+ .attr("data-tag", "mark")
577
+ .attr("data-group", series.group)
578
+ .attr("d", line)
579
+ .attr("fill", color)
580
+ .attr("fill-opacity", 0.18)
581
+ .attr("stroke", color)
582
+ .attr("stroke-width", 3)
583
+ .attr("stroke-opacity", 0.9)
584
+ .attr("stroke-linejoin", "round");
585
+ });
586
+
587
+ const pointRows = seriesData.flatMap(series => series.values.map(d => ({ ...d, color: colorScale(series.group) })));
588
+ plot.selectAll(".radial-area-point")
589
+ .data(pointRows)
590
+ .enter()
591
+ .append("circle")
592
+ .attr("class", "mark data-point radial-area-point")
593
+ .attr("data-tag", "mark")
594
+ .attr("data-group", d => d.group)
595
+ .attr("data-category", d => d.category)
596
+ .attr("cx", d => d.x)
597
+ .attr("cy", d => d.y)
598
+ .attr("r", 5.2)
599
+ .attr("fill", d => d.color)
600
+ .attr("stroke", "#ffffff")
601
+ .attr("stroke-width", 2.4);
602
+
603
+ plot.append("circle")
604
+ .attr("class", "radial-origin")
605
+ .attr("r", 2.2)
606
+ .attr("fill", rgba(textColor, 0.45))
607
+ .attr("opacity", 0.65);
608
+
609
+ const legendColumns = Math.max(1, Math.min(groups.length, groups.length <= 3 ? groups.length : 3));
610
+ const legendRows = Math.max(1, Math.ceil(groups.length / legendColumns));
611
+ const legendPadX = 28;
612
+ const legendPadTop = 28;
613
+ const legendPadBottom = 24;
614
+ const legendRowGap = 74;
615
+ const legendWidth = Math.min(width * 0.88, Math.max(width * 0.72, legendColumns * 176 + legendPadX * 2));
616
+ const legendColumnWidth = (legendWidth - legendPadX * 2) / legendColumns;
617
+ const legendItemTextWidth = Math.max(82, legendColumnWidth - 46);
618
+ const legendHeight = legendPadTop + legendPadBottom + legendRows * 56 + (legendRows - 1) * 18;
619
+ const legendX = (width - legendWidth) / 2;
620
+ const legendY = centerY + radius + 86;
621
+ const legend = svg.append("g")
622
+ .attr("class", "group-legend-card radial-layered-spline-key")
623
+ .attr("transform", `translate(${legendX}, ${legendY})`);
624
+
625
+ legend.append("rect")
626
+ .attr("class", "legend-card-background")
627
+ .attr("x", 0)
628
+ .attr("y", 0)
629
+ .attr("width", legendWidth)
630
+ .attr("height", legendHeight)
631
+ .attr("rx", 18)
632
+ .attr("fill", "#ffffff")
633
+ .attr("stroke", "rgba(118, 152, 190, 0.22)")
634
+ .attr("filter", "url(#radial-layered-soft-shadow)");
635
+
636
+ function legendDescription(group) {
637
+ const lower = String(group).toLowerCase();
638
+ if (/pro|positive|support|yes|agree/.test(lower)) return "Users expressing pro-climate views.";
639
+ if (/con|negative|oppose|deny|skeptic/.test(lower)) return "Users expressing skeptical views.";
640
+ if (/neutral|mixed|other/.test(lower)) return "Users with neutral or mixed views.";
641
+ const stat = groupStats.find(d => d.group === group);
642
+ if (stat && Number.isFinite(stat.meanValue)) {
643
+ return `Mean ${formatLocalValue(stat.meanValue)} across categories.`;
644
+ }
645
+ return "Data-bound series in this radial layer.";
646
+ }
647
+
648
+ const groupLegend = legend.selectAll(".group-legend-row")
649
+ .data(groups)
650
+ .enter()
651
+ .append("g")
652
+ .attr("class", "group-legend-row")
653
+ .attr("transform", (d, i) => {
654
+ const col = i % legendColumns;
655
+ const row = Math.floor(i / legendColumns);
656
+ return `translate(${legendPadX + col * legendColumnWidth}, ${legendPadTop + row * legendRowGap})`;
657
+ });
658
+
659
+ legend.selectAll(".legend-divider")
660
+ .data(d3.range(1, legendColumns))
661
+ .enter()
662
+ .append("line")
663
+ .attr("class", "legend-divider")
664
+ .attr("x1", d => legendPadX - 14 + d * legendColumnWidth)
665
+ .attr("x2", d => legendPadX - 14 + d * legendColumnWidth)
666
+ .attr("y1", 20)
667
+ .attr("y2", legendHeight - 20)
668
+ .attr("stroke", "rgba(16, 39, 102, 0.12)")
669
+ .attr("stroke-width", 1);
670
+
671
+ legend.selectAll(".legend-row-divider")
672
+ .data(d3.range(1, legendRows))
673
+ .enter()
674
+ .append("line")
675
+ .attr("class", "legend-row-divider")
676
+ .attr("x1", legendPadX)
677
+ .attr("x2", legendWidth - legendPadX)
678
+ .attr("y1", d => legendPadTop + d * legendRowGap - 19)
679
+ .attr("y2", d => legendPadTop + d * legendRowGap - 19)
680
+ .attr("stroke", "rgba(16, 39, 102, 0.09)")
681
+ .attr("stroke-width", 1);
682
+
683
+ groupLegend.append("circle")
684
+ .attr("class", "group-swatch")
685
+ .attr("cx", 0)
686
+ .attr("cy", 0)
687
+ .attr("r", 8)
688
+ .attr("fill", d => colorScale(d))
689
+ .attr("fill-opacity", 0.95);
690
+
691
+ groupLegend.append("text")
692
+ .attr("class", "group-label")
693
+ .attr("x", 20)
694
+ .attr("y", 0)
695
+ .attr("dominant-baseline", "middle")
696
+ .attr("fill", textColor)
697
+ .style("font-family", fontFamily)
698
+ .style("font-size", `${Math.max(16, baseValueSize * 1.45)}px`)
699
+ .style("font-weight", "800")
700
+ .text(d => truncateText(d, legendItemTextWidth, Math.max(16, baseValueSize * 1.45), fontFamily, "800"));
701
+
702
+ groupLegend.each(function(d) {
703
+ const row = d3.select(this);
704
+ wrapTextLines(legendDescription(d), legendItemTextWidth, Math.max(11, baseValueSize * 1.02), fontFamily, "500", 2)
705
+ .forEach((lineText, index) => {
706
+ row.append("text")
707
+ .attr("class", "group-legend-description")
708
+ .attr("x", 20)
709
+ .attr("y", 28 + index * 18)
710
+ .attr("fill", "#536274")
711
+ .style("font-family", fontFamily)
712
+ .style("font-size", `${Math.max(11, baseValueSize * 1.02)}px`)
713
+ .style("font-weight", "500")
714
+ .text(lineText);
715
+ });
716
+ });
717
+
718
+ svg.append("rect")
719
+ .attr("class", "layout-extent-anchor")
720
+ .attr("x", width / 2)
721
+ .attr("y", legendY + legendHeight + 24)
722
+ .attr("width", 1)
723
+ .attr("height", 1)
724
+ .attr("fill", "transparent")
725
+ .attr("opacity", 0);
726
+
727
+ measureGroup.remove();
728
+ return svg.node();
729
+ }