Ray1ee01 commited on
Commit
11d633f
·
verified ·
1 Parent(s): e61deb8

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/type9_radial_grouped_bar_chart/radial_grouped_bar_plain_chart_01.js ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Radial Grouped Bar Chart",
5
+ "chart_name": "radial_grouped_bar_plain_chart_01",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[3, 6], [0, 100], [2, 4]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": ["primary", "secondary", "background"],
13
+ "supported_effects": ["gradient", "opacity"],
14
+ "min_height": 600,
15
+ "min_width": 800,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "yes"
21
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+ function makeChart(containerSelector, data) {
26
+ const jsonData = data;
27
+ const chartData = jsonData.data.data;
28
+ const variables = jsonData.variables;
29
+ const colors = jsonData.colors || {};
30
+ const colorResolver = chartUtils.color.resolver(jsonData);
31
+ const dataColumns = chartUtils.schema.columns(jsonData);
32
+
33
+ // 数值单位规范
34
+ // 添加数值格式化函数
35
+
36
+ d3.select(containerSelector).html("");
37
+
38
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
39
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
40
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
41
+
42
+ // 获取单位信息
43
+ let valueUnit = "";
44
+ const valueChannel = chartUtils.schema.channel(jsonData, "y");
45
+ if (valueChannel.unit) {
46
+ valueUnit = valueChannel.unit;
47
+ }
48
+
49
+ // 获取所有唯一的分组
50
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
51
+
52
+ // 创建颜色比例尺
53
+ const colorScale = colorResolver.scale(groups, { palette: "category10" });
54
+
55
+ // 获取唯一的x值
56
+ const uniqueXValues = [...new Set(chartData.map(d => d[xField]))];
57
+
58
+ // 按xField和groupField排序
59
+ chartData.sort((a, b) => {
60
+ // 首先按照xValue排序
61
+ if (a[xField] !== b[xField]) {
62
+ return String(a[xField]).localeCompare(String(b[xField]));
63
+ }
64
+ // xValue相同时按照group排序
65
+ return String(a[groupField]).localeCompare(String(b[groupField]));
66
+ });
67
+
68
+ // 尺寸
69
+ const width = variables.width;
70
+ const height = variables.height;
71
+ const margin = { top: 40, right: 40, bottom: 40, left: 40 };
72
+ const chartWidth = width - margin.left - margin.right;
73
+ const chartHeight = height - margin.top - margin.bottom;
74
+
75
+ const svg = d3.select(containerSelector)
76
+ .append("svg")
77
+ .attr("width", "100%")
78
+ .attr("height", height)
79
+ .attr("viewBox", `0 0 ${width} ${height}`)
80
+ .attr("style", "max-width: 100%; height: auto;")
81
+ .attr("xmlns", "http://www.w3.org/2000/svg")
82
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
83
+ const centerX = width / 2;
84
+ const centerY = height / 2;
85
+ const maxRadius = Math.min(chartWidth, chartHeight) / 2;
86
+
87
+ const nBars = chartData.length;
88
+ const minRadius = maxRadius * 0.2;
89
+ const maxBarRadius = maxRadius * 0.95;
90
+ const barWidth = (maxBarRadius - minRadius) / nBars * 0.7;
91
+ const barGap = (maxBarRadius - minRadius) / nBars * 0.3;
92
+
93
+ // 角度比例尺(最大270°)
94
+ const maxValue = d3.max(chartData, d => +d[yField]);
95
+ const angleScale = d3.scaleLinear()
96
+ .domain([0, maxValue])
97
+ .range([0, 1.5 * Math.PI]); // 270°
98
+
99
+ const g = svg.append("g")
100
+ .attr("transform", `translate(${centerX}, ${centerY})`);
101
+
102
+ // 辅助线
103
+ const numTicks = 5;
104
+ const ticks = d3.range(0, maxValue + 1, maxValue / numTicks);
105
+ ticks.forEach(tick => {
106
+ g.append("path")
107
+ .attr("d", d3.arc()
108
+ .innerRadius(minRadius)
109
+ .outerRadius(maxBarRadius + barWidth * 0.5)
110
+ .startAngle(angleScale(tick))
111
+ .endAngle(angleScale(tick))
112
+ )
113
+ .attr("stroke", "#e0e0e0")
114
+ .attr("stroke-width", 1)
115
+ .attr("fill", "none");
116
+ g.append("text")
117
+ .attr("x", Math.cos(angleScale(tick) - Math.PI / 2) * (maxBarRadius + barWidth * 0.7))
118
+ .attr("y", Math.sin(angleScale(tick) - Math.PI / 2) * (maxBarRadius + barWidth * 0.7))
119
+ .attr("text-anchor", "middle")
120
+ .attr("dominant-baseline", "middle")
121
+ .attr("fill", "#888")
122
+ .style("font-size", "12px")
123
+ .text(chartUtils.format.integer(tick, { unit: valueUnit }).text);
124
+ });
125
+
126
+ const labelPadding = 20;
127
+ // 条形
128
+ chartData.forEach((d, i) => {
129
+ const innerR = minRadius + i * (barWidth + barGap);
130
+ const outerR = innerR + barWidth;
131
+ const endAngle = angleScale(d[yField]);
132
+
133
+ g.append("path")
134
+ .attr("d", d3.arc()
135
+ .innerRadius(innerR)
136
+ .outerRadius(outerR)
137
+ .startAngle(0)
138
+ .endAngle(endAngle)
139
+ )
140
+ .attr("fill", colorScale(d[groupField]))
141
+ .attr("opacity", 0.85);
142
+
143
+ // 只给同一个x值的第一个条形添加标签
144
+ const isFirstBarWithThisX = chartData.findIndex(item => item[xField] === d[xField]) === i;
145
+ if (isFirstBarWithThisX) {
146
+ g.append("text")
147
+ .attr("x", Math.cos(-Math.PI / 2) * (innerR + barWidth / 2) - labelPadding)
148
+ .attr("y", Math.sin(-Math.PI / 2) * (innerR + barWidth / 2))
149
+ .attr("text-anchor", "end")
150
+ .attr("dominant-baseline", "middle")
151
+ .attr("fill", "#222b44")
152
+ .style("font-size", "12px")
153
+ .style("font-weight", "bold")
154
+ .text(d[xField]);
155
+ }
156
+ });
157
+ chartUtils.legend.draw(svg, groups, {
158
+ colorScale,
159
+ colorResolver,
160
+ colors,
161
+ }, {
162
+ x: width - 150,
163
+ y: 20,
164
+ direction: "vertical",
165
+ markerShape: "rect",
166
+ markerSize: 15,
167
+ labelGap: 5,
168
+ itemGap: 5,
169
+ rowGap: 5,
170
+ fontSize: 10,
171
+ textColor: colorResolver.text({ fallback: "#333" }).value,
172
+ itemHeight: 20,
173
+ });
174
+ return svg.node();
175
+ }
modules/chart_engine/template/d3-js/type9_radial_grouped_bar_chart/radial_grouped_bar_plain_chart_02.js ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Radial Grouped Bar Chart",
5
+ "chart_name": "radial_grouped_bar_plain_chart_02",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[3, 6], [0, 100], [2, 4]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": ["primary", "secondary", "background"],
13
+ "supported_effects": ["gradient", "opacity"],
14
+ "min_height": 600,
15
+ "min_width": 800,
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) { //Group Radial Bar Chart plain chart#2 圆角
26
+ const jsonData = data;
27
+ const chartData = jsonData.data.data;
28
+ const variables = jsonData.variables;
29
+ const colors = jsonData.colors || {};
30
+ const colorResolver = chartUtils.color.resolver(jsonData);
31
+ const dataColumns = chartUtils.schema.columns(jsonData);
32
+
33
+ // 数值单位规范
34
+ // 添加数值格式化函数
35
+
36
+ // 生成圆角弧形路径的函数
37
+ const createRoundedArcPath = (innerRadius, outerRadius, startAngle, endAngle, cornerRadius) => {
38
+ const startAngleRad = startAngle - Math.PI / 2; // 调整起始角度,使0度在顶部
39
+ const endAngleRad = endAngle - Math.PI / 2;
40
+
41
+ // 计算各个关键点的坐标
42
+ const innerStartX = innerRadius * Math.cos(startAngleRad);
43
+ const innerStartY = innerRadius * Math.sin(startAngleRad);
44
+ const innerEndX = innerRadius * Math.cos(endAngleRad);
45
+ const innerEndY = innerRadius * Math.sin(endAngleRad);
46
+ const outerStartX = outerRadius * Math.cos(startAngleRad);
47
+ const outerStartY = outerRadius * Math.sin(startAngleRad);
48
+ const outerEndX = outerRadius * Math.cos(endAngleRad);
49
+ const outerEndY = outerRadius * Math.sin(endAngleRad);
50
+
51
+ // 计算圆角的控制点
52
+ const thickness = outerRadius - innerRadius;
53
+ const adjustedCornerRadius = Math.min(cornerRadius, thickness / 2,
54
+ Math.abs(endAngle - startAngle) * innerRadius / 2);
55
+
56
+ // 计算圆角在各个角的偏移
57
+ const startCornerOffset = adjustedCornerRadius / innerRadius;
58
+ const endCornerOffset = adjustedCornerRadius / innerRadius;
59
+
60
+ // 内弧起始圆角点
61
+ const innerStartCornerAngle = startAngleRad + startCornerOffset;
62
+ const innerStartCornerX = innerRadius * Math.cos(innerStartCornerAngle);
63
+ const innerStartCornerY = innerRadius * Math.sin(innerStartCornerAngle);
64
+
65
+ // 内弧结束圆角点
66
+ const innerEndCornerAngle = endAngleRad - endCornerOffset;
67
+ const innerEndCornerX = innerRadius * Math.cos(innerEndCornerAngle);
68
+ const innerEndCornerY = innerRadius * Math.sin(innerEndCornerAngle);
69
+
70
+ // 外弧起始圆角点
71
+ const outerStartCornerAngle = startAngleRad + adjustedCornerRadius / outerRadius;
72
+ const outerStartCornerX = outerRadius * Math.cos(outerStartCornerAngle);
73
+ const outerStartCornerY = outerRadius * Math.sin(outerStartCornerAngle);
74
+
75
+ // 外弧结束圆角点
76
+ const outerEndCornerAngle = endAngleRad - adjustedCornerRadius / outerRadius;
77
+ const outerEndCornerX = outerRadius * Math.cos(outerEndCornerAngle);
78
+ const outerEndCornerY = outerRadius * Math.sin(outerEndCornerAngle);
79
+
80
+ const largeArcFlag = endAngle - startAngle > Math.PI ? 1 : 0;
81
+
82
+ return `
83
+ M ${innerStartCornerX} ${innerStartCornerY}
84
+ A ${adjustedCornerRadius} ${adjustedCornerRadius} 0 0 1 ${outerStartCornerX} ${outerStartCornerY}
85
+ A ${outerRadius} ${outerRadius} 0 ${largeArcFlag} 1 ${outerEndCornerX} ${outerEndCornerY}
86
+ A ${adjustedCornerRadius} ${adjustedCornerRadius} 0 0 1 ${innerEndCornerX} ${innerEndCornerY}
87
+ A ${innerRadius} ${innerRadius} 0 ${largeArcFlag} 0 ${innerStartCornerX} ${innerStartCornerY}
88
+ Z
89
+ `;
90
+ };
91
+
92
+ d3.select(containerSelector).html("");
93
+
94
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
95
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
96
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
97
+
98
+ // 获取单位信息
99
+ let valueUnit = "";
100
+ const valueChannel = chartUtils.schema.channel(jsonData, "y");
101
+ if (valueChannel.unit) {
102
+ valueUnit = valueChannel.unit;
103
+ }
104
+
105
+ // 获取所有唯一的分组
106
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
107
+
108
+ // 创建颜色比例尺
109
+ const colorScale = colorResolver.scale(groups, { palette: "category10" });
110
+
111
+ // 获取唯一的x值
112
+ const uniqueXValues = [...new Set(chartData.map(d => d[xField]))];
113
+
114
+ // 按xField和groupField排序
115
+ chartData.sort((a, b) => {
116
+ // 首先按照xValue排序
117
+ if (a[xField] !== b[xField]) {
118
+ return String(a[xField]).localeCompare(String(b[xField]));
119
+ }
120
+ // xValue相同时按照group排序
121
+ return String(a[groupField]).localeCompare(String(b[groupField]));
122
+ });
123
+
124
+ // 尺寸
125
+ const width = variables.width;
126
+ const height = variables.height;
127
+ const margin = { top: 40, right: 40, bottom: 40, left: 40 };
128
+ const chartWidth = width - margin.left - margin.right;
129
+ const chartHeight = height - margin.top - margin.bottom;
130
+
131
+ const svg = d3.select(containerSelector)
132
+ .append("svg")
133
+ .attr("width", "100%")
134
+ .attr("height", height)
135
+ .attr("viewBox", `0 0 ${width} ${height}`)
136
+ .attr("style", "max-width: 100%; height: auto;")
137
+ .attr("xmlns", "http://www.w3.org/2000/svg")
138
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
139
+ const centerX = width / 2;
140
+ const centerY = height / 2;
141
+ const maxRadius = Math.min(chartWidth, chartHeight) / 2;
142
+
143
+ const nBars = chartData.length;
144
+ const minRadius = maxRadius * 0.2;
145
+ const maxBarRadius = maxRadius * 0.95;
146
+ const barWidth = (maxBarRadius - minRadius) / nBars * 0.7;
147
+ const barGap = (maxBarRadius - minRadius) / nBars * 0.3;
148
+
149
+ // 角度比例尺(最大270°)
150
+ const maxValue = d3.max(chartData, d => +d[yField]);
151
+ const angleScale = d3.scaleLinear()
152
+ .domain([0, maxValue])
153
+ .range([0, 1.5 * Math.PI]); // 270°
154
+
155
+ const g = svg.append("g")
156
+ .attr("transform", `translate(${centerX}, ${centerY})`);
157
+
158
+ // 辅助线
159
+ const numTicks = 5;
160
+ const ticks = d3.range(0, maxValue + 1, maxValue / numTicks);
161
+ ticks.forEach(tick => {
162
+ g.append("path")
163
+ .attr("d", d3.arc()
164
+ .innerRadius(minRadius)
165
+ .outerRadius(maxBarRadius + barWidth * 0.5)
166
+ .startAngle(angleScale(tick))
167
+ .endAngle(angleScale(tick))
168
+ )
169
+ .attr("stroke", "#e0e0e0")
170
+ .attr("stroke-width", 1)
171
+ .attr("fill", "none");
172
+ g.append("text")
173
+ .attr("x", Math.cos(angleScale(tick) - Math.PI / 2) * (maxBarRadius + barWidth * 0.7))
174
+ .attr("y", Math.sin(angleScale(tick) - Math.PI / 2) * (maxBarRadius + barWidth * 0.7))
175
+ .attr("text-anchor", "middle")
176
+ .attr("dominant-baseline", "middle")
177
+ .attr("fill", "#888")
178
+ .style("font-size", "12px")
179
+ .text(chartUtils.format.integer(tick, { unit: valueUnit }).text);
180
+ });
181
+
182
+ const labelPadding = 20;
183
+ // 条形
184
+ chartData.forEach((d, i) => {
185
+ const innerR = minRadius + i * (barWidth + barGap);
186
+ const outerR = innerR + barWidth;
187
+ const endAngle = angleScale(d[yField]);
188
+ const cornerRadius = barWidth / 2; // 圆角半径为宽度的一半
189
+
190
+ // 使用圆角弧形路径
191
+ g.append("path")
192
+ .attr("d", createRoundedArcPath(innerR, outerR, 0, endAngle, cornerRadius))
193
+ .attr("fill", colorScale(d[groupField]))
194
+ .attr("opacity", 0.85);
195
+
196
+ // 只给同一个x值的第一个条形添加标签
197
+ const isFirstBarWithThisX = chartData.findIndex(item => item[xField] === d[xField]) === i;
198
+ if (isFirstBarWithThisX) {
199
+ g.append("text")
200
+ .attr("x", Math.cos(-Math.PI / 2) * (innerR + barWidth / 2) - labelPadding)
201
+ .attr("y", Math.sin(-Math.PI / 2) * (innerR + barWidth / 2))
202
+ .attr("text-anchor", "end")
203
+ .attr("dominant-baseline", "middle")
204
+ .attr("fill", "#222b44")
205
+ .style("font-size", "12px")
206
+ .style("font-weight", "bold")
207
+ .text(d[xField]);
208
+ }
209
+
210
+ // 数值标签(沿柱子末端弧线)
211
+ const valueText = chartUtils.format.autoText(+d[yField], { unit: valueUnit });
212
+ const valueRadius = innerR + barWidth / 2;
213
+ const valueAngle = endAngle;
214
+ const valueTextPathId = `valueTextPath-${i}`;
215
+
216
+ // 根据数值大小动态计算文字路径长度
217
+ const valueTextLen = chartUtils.text.measure(null, valueText, { fontSize: 10 }).width;
218
+ const minAngle = 0.08; // 减小最小角度
219
+ const valueShiftAngle = Math.max(valueTextLen / valueRadius, minAngle);
220
+
221
+ // 计算文字路径的起始和结束角度
222
+ const pathStartAngle = Math.max(0, valueAngle - valueShiftAngle);
223
+ const pathEndAngle = Math.min(1.7 * Math.PI, valueAngle + valueShiftAngle);
224
+
225
+ // 只有当弧长足够显示文字时才添加标签
226
+ if (endAngle > 0.2) { // 只有当角度足够大时才显示数值标签
227
+ g.append("path")
228
+ .attr("id", valueTextPathId)
229
+ .attr("d", d3.arc()({
230
+ innerRadius: valueRadius,
231
+ outerRadius: valueRadius,
232
+ startAngle: pathStartAngle,
233
+ endAngle: pathEndAngle
234
+ }))
235
+ .style("fill", "none")
236
+ .style("stroke", "none");
237
+
238
+ g.append("text")
239
+ .attr("font-size", "10px")
240
+ .attr("fill", "#333")
241
+ .attr("font-weight", "normal")
242
+ .append("textPath")
243
+ .attr("xlink:href", `#${valueTextPathId}`)
244
+ .attr("startOffset", "30%")
245
+ .attr("text-anchor", "start")
246
+ .attr("dominant-baseline", "start")
247
+ .text(valueText);
248
+ }
249
+ });
250
+ chartUtils.legend.draw(svg, groups, {
251
+ colorScale,
252
+ colorResolver,
253
+ colors,
254
+ }, {
255
+ x: width - 150,
256
+ y: 20,
257
+ direction: "vertical",
258
+ markerShape: "rect",
259
+ markerSize: 15,
260
+ labelGap: 5,
261
+ itemGap: 5,
262
+ rowGap: 5,
263
+ fontSize: 10,
264
+ textColor: colorResolver.text({ fallback: "#333" }).value,
265
+ itemHeight: 20,
266
+ });
267
+ return svg.node();
268
+ }
modules/chart_engine/template/d3-js/type9_radial_grouped_bar_chart/radial_grouped_bar_plain_chart_03.js ADDED
@@ -0,0 +1,535 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Radial Grouped Bar Chart",
5
+ "chart_name": "radial_grouped_bar_plain_chart_03",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[3, 20], [0, 100], [2,2]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": ["primary", "secondary", "background"],
13
+ "supported_effects": ["gradient", "opacity"],
14
+ "min_height": 600,
15
+ "min_width": 800,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "yes"
21
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+ 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 xColumn = roleColumn("x") || dataColumns[0] || {};
59
+ const yColumn = roleColumn("y") || dataColumns[1] || {};
60
+ const groupColumn = roleColumn("group") || dataColumns[2] || {};
61
+ const xField = xColumn.name;
62
+ const yField = yColumn.name;
63
+ const groupField = groupColumn.name;
64
+ const yUnit = (yColumn.unit || "").trim();
65
+
66
+ const width = Math.max(820, Number(variables.width) || 820);
67
+ const height = Math.max(620, Number(variables.height) || 620);
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 = sourceColors.text_color || "#1f2937";
75
+ const mutedText = "#64748b";
76
+ const primaryColor = sourceColors.other?.primary || sourceColors.primary || "#2563eb";
77
+ const secondaryColor = sourceColors.other?.secondary || "#f97316";
78
+ const gridColor = "rgba(100, 116, 139, 0.24)";
79
+
80
+ const svg = d3.select(containerSelector)
81
+ .append("svg")
82
+ .attr("width", "100%")
83
+ .attr("height", height)
84
+ .attr("viewBox", `0 0 ${width} ${height}`)
85
+ .attr("style", "max-width: 100%; height: auto;")
86
+ .attr("xmlns", "http://www.w3.org/2000/svg")
87
+ .attr("class", "radial-grouped-bar-chart-root");
88
+
89
+ if (!xField || !yField || !groupField) {
90
+ svg.append("text")
91
+ .attr("x", width / 2)
92
+ .attr("y", height / 2)
93
+ .attr("text-anchor", "middle")
94
+ .attr("fill", textColor)
95
+ .style("font-family", fontFamily)
96
+ .style("font-size", "16px")
97
+ .text("Missing radial grouped bar fields");
98
+ return svg.node();
99
+ }
100
+
101
+ function cleanLabel(value) {
102
+ return String(value ?? "")
103
+ .replace(/_/g, " ")
104
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
105
+ .replace(/\s+/g, " ")
106
+ .trim();
107
+ }
108
+
109
+ function compactNumber(value) {
110
+ const numeric = Number(value);
111
+ if (!Number.isFinite(numeric)) return String(value ?? "");
112
+ const abs = Math.abs(numeric);
113
+ if (abs >= 1000000000) return `${d3.format(".3~g")(numeric / 1000000000)}B`;
114
+ if (abs >= 1000000) return `${d3.format(".3~g")(numeric / 1000000)}M`;
115
+ if (abs >= 1000) return `${d3.format(".3~g")(numeric / 1000)}K`;
116
+ return d3.format(",.4~g")(numeric);
117
+ }
118
+
119
+ function formatLocalValue(value, includeUnit = true) {
120
+ const formatted = compactNumber(value);
121
+ if (!includeUnit || !yUnit || yUnit === "none") return formatted;
122
+ if (yUnit === "%") return `${formatted}%`;
123
+ if (yUnit === "$") return `$${formatted}`;
124
+ return `${formatted} ${yUnit}`;
125
+ }
126
+
127
+ function parseTemporalRank(value) {
128
+ const text = String(value ?? "").trim();
129
+ const lower = text.toLowerCase();
130
+ const monthOrder = {
131
+ jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3, apr: 4, april: 4,
132
+ may: 5, jun: 6, june: 6, jul: 7, july: 7, aug: 8, august: 8,
133
+ sep: 9, sept: 9, september: 9, oct: 10, october: 10, nov: 11, november: 11, dec: 12, december: 12
134
+ };
135
+ if (monthOrder[lower] != null) return monthOrder[lower];
136
+ let match = text.match(/^(-?\d{4})$/);
137
+ if (match) return Number(match[1]) * 10000;
138
+ match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
139
+ if (match) return Number(match[1]) * 10000 + Number(match[2]) * 100 + Number(match[3] || 1);
140
+ match = text.match(/^q([1-4])\s*(-?\d{4})$/i) || text.match(/^(-?\d{4})\s*q([1-4])$/i);
141
+ if (match && match[1].length === 1) return Number(match[2]) * 10 + Number(match[1]);
142
+ if (match) return Number(match[1]) * 10 + Number(match[2]);
143
+ return NaN;
144
+ }
145
+
146
+ function parseOrdinalRank(value) {
147
+ const text = String(value ?? "").trim().toLowerCase();
148
+ let match = text.match(/^(\d+(?:\.\d+)?)\s*(?:-|to)\s*\d+(?:\.\d+)?/);
149
+ if (match) return Number(match[1]);
150
+ match = text.match(/^(\d+(?:\.\d+)?)\s*\+$/);
151
+ if (match) return Number(match[1]);
152
+ match = text.match(/^(-?\d+(?:\.\d+)?)/);
153
+ if (match) return Number(match[1]);
154
+ return NaN;
155
+ }
156
+
157
+ const rows = sourceData
158
+ .map((d, index) => ({
159
+ index,
160
+ category: cleanLabel(d[xField]),
161
+ rawCategory: d[xField],
162
+ group: cleanLabel(d[groupField]),
163
+ rawGroup: d[groupField],
164
+ value: Number(d[yField])
165
+ }))
166
+ .filter(d => d.category && d.group && Number.isFinite(d.value) && d.value >= 0);
167
+
168
+ const categories = Array.from(new Set(rows.map(d => d.category)));
169
+ const groups = Array.from(new Set(rows.map(d => d.group))).slice(0, 2);
170
+ if (categories.length < 3 || groups.length < 2) {
171
+ svg.append("text")
172
+ .attr("x", width / 2)
173
+ .attr("y", height / 2)
174
+ .attr("text-anchor", "middle")
175
+ .attr("fill", textColor)
176
+ .style("font-family", fontFamily)
177
+ .style("font-size", "16px")
178
+ .text("Not enough grouped radial data");
179
+ return svg.node();
180
+ }
181
+
182
+ const categoryStats = categories.map(category => {
183
+ const categoryRows = rows.filter(d => d.category === category);
184
+ const rawCategory = categoryRows[0]?.rawCategory;
185
+ const meanValue = d3.mean(categoryRows, d => d.value) || 0;
186
+ return {
187
+ category,
188
+ rawCategory,
189
+ meanValue,
190
+ temporalRank: parseTemporalRank(rawCategory),
191
+ ordinalRank: parseOrdinalRank(rawCategory)
192
+ };
193
+ });
194
+ const allTemporal = categoryStats.every(d => Number.isFinite(d.temporalRank));
195
+ const allOrdinal = !allTemporal && categoryStats.every(d => Number.isFinite(d.ordinalRank));
196
+ const sortMode = allTemporal ? "chronological" : (allOrdinal ? "ordered category" : "average value");
197
+ const orderedCategories = [...categoryStats].sort((a, b) => {
198
+ if (allTemporal) return d3.ascending(a.temporalRank, b.temporalRank);
199
+ if (allOrdinal) return d3.ascending(a.ordinalRank, b.ordinalRank);
200
+ return d3.descending(a.meanValue, b.meanValue) || d3.ascending(a.category, b.category);
201
+ }).map((d, index) => ({ ...d, rank: index + 1 }));
202
+
203
+ const valueByCategoryGroup = new Map();
204
+ rows.forEach(d => {
205
+ valueByCategoryGroup.set(`${d.category}|||${d.group}`, d.value);
206
+ });
207
+ const displayRows = orderedCategories.flatMap(categoryInfo => groups.map((group, groupIndex) => ({
208
+ category: categoryInfo.category,
209
+ rawCategory: categoryInfo.rawCategory,
210
+ rank: categoryInfo.rank,
211
+ group,
212
+ groupIndex,
213
+ value: valueByCategoryGroup.get(`${categoryInfo.category}|||${group}`) ?? 0
214
+ })));
215
+
216
+ const measureGroup = svg.append("g").attr("visibility", "hidden");
217
+ function measureLabelText(text, fontSize, family = fontFamily, weight = labelWeight) {
218
+ const node = measureGroup.append("text")
219
+ .style("font-family", family)
220
+ .style("font-size", `${fontSize}px`)
221
+ .style("font-weight", weight)
222
+ .text(String(text ?? ""))
223
+ .node();
224
+ const measured = node ? node.textContent.length * 7 : 0;
225
+ measureGroup.selectAll("text").remove();
226
+ return Math.max(measured || 0, String(text ?? "").length * fontSize * 0.52);
227
+ }
228
+
229
+ function truncateText(text, maxWidth, fontSize, family = fontFamily, weight = labelWeight) {
230
+ const full = String(text ?? "");
231
+ if (measureLabelText(full, fontSize, family, weight) <= maxWidth) return full;
232
+ let lo = 1;
233
+ let hi = full.length;
234
+ let best = full.slice(0, 1);
235
+ while (lo <= hi) {
236
+ const mid = Math.floor((lo + hi) / 2);
237
+ const candidate = `${full.slice(0, mid).trim()}...`;
238
+ if (measureLabelText(candidate, fontSize, family, weight) <= maxWidth) {
239
+ best = candidate;
240
+ lo = mid + 1;
241
+ } else {
242
+ hi = mid - 1;
243
+ }
244
+ }
245
+ return best;
246
+ }
247
+
248
+ function rgba(color, opacity) {
249
+ const c = d3.rgb(color);
250
+ return `rgba(${c.r}, ${c.g}, ${c.b}, ${opacity})`;
251
+ }
252
+
253
+ const legendWidth = Math.max(286, Math.min(330, width * 0.38));
254
+ const plotLeft = 40;
255
+ const plotRight = width - legendWidth - 42;
256
+ const centerX = plotLeft + (plotRight - plotLeft) / 2;
257
+ const centerY = height * 0.5;
258
+ const outerRadius = Math.max(165, Math.min(plotRight - centerX - 24, centerX - plotLeft - 24, height / 2 - 64));
259
+ const innerRadius = Math.max(48, outerRadius * 0.28);
260
+ const categoryCount = orderedCategories.length;
261
+ const startAngle = -Math.PI * 0.92;
262
+ const endAngle = Math.PI * 1.08;
263
+ const fullAngle = endAngle - startAngle;
264
+ const categoryGap = Math.min(0.025, fullAngle / categoryCount * 0.14);
265
+ const groupGap = Math.min(0.012, fullAngle / categoryCount * 0.08);
266
+ const categoryBand = fullAngle / categoryCount;
267
+ const groupBand = (categoryBand - categoryGap * 2 - groupGap) / groups.length;
268
+
269
+ const maxValue = Math.max(1, d3.max(displayRows, d => d.value) || 1);
270
+ const radialScale = d3.scaleLinear()
271
+ .domain([0, maxValue])
272
+ .nice(4)
273
+ .range([0, outerRadius - innerRadius]);
274
+ const domainMax = radialScale.domain()[1];
275
+ const tickValues = radialScale.ticks(4).filter(tick => tick > 0);
276
+ const groupColors = d3.scaleOrdinal()
277
+ .domain(groups)
278
+ .range([primaryColor, secondaryColor]);
279
+
280
+ const plot = svg.append("g")
281
+ .attr("class", "radial-grouped-bar-plot")
282
+ .attr("transform", `translate(${centerX}, ${centerY})`);
283
+
284
+ tickValues.forEach(tick => {
285
+ plot.append("circle")
286
+ .attr("class", "radial-grid-ring gridline")
287
+ .attr("r", innerRadius + radialScale(tick))
288
+ .attr("fill", "none")
289
+ .attr("stroke", gridColor)
290
+ .attr("stroke-width", 1)
291
+ .attr("stroke-dasharray", "3,4");
292
+
293
+ plot.append("text")
294
+ .attr("class", "axis-tick radial-grid-label")
295
+ .attr("x", 6)
296
+ .attr("y", -(innerRadius + radialScale(tick)))
297
+ .attr("text-anchor", "start")
298
+ .attr("dominant-baseline", "middle")
299
+ .attr("fill", mutedText)
300
+ .style("font-family", annotationFamily)
301
+ .style("font-size", `${Math.max(8, baseValueSize * 0.86)}px`)
302
+ .style("font-weight", annotationWeight)
303
+ .text(formatLocalValue(tick, false));
304
+ });
305
+
306
+ orderedCategories.forEach((categoryInfo, index) => {
307
+ const middleAngle = startAngle + index * categoryBand + categoryBand / 2;
308
+ const outerX = (outerRadius + 18) * Math.cos(middleAngle - Math.PI / 2);
309
+ const outerY = (outerRadius + 18) * Math.sin(middleAngle - Math.PI / 2);
310
+ const axisX = outerRadius * Math.cos(middleAngle - Math.PI / 2);
311
+ const axisY = outerRadius * Math.sin(middleAngle - Math.PI / 2);
312
+
313
+ plot.append("line")
314
+ .attr("class", "category-axis-line")
315
+ .attr("x1", innerRadius * Math.cos(middleAngle - Math.PI / 2))
316
+ .attr("y1", innerRadius * Math.sin(middleAngle - Math.PI / 2))
317
+ .attr("x2", axisX)
318
+ .attr("y2", axisY)
319
+ .attr("stroke", rgba(textColor, 0.11))
320
+ .attr("stroke-width", 1);
321
+
322
+ plot.append("circle")
323
+ .attr("class", "category-index-dot")
324
+ .attr("cx", outerX)
325
+ .attr("cy", outerY)
326
+ .attr("r", 9)
327
+ .attr("fill", "#ffffff")
328
+ .attr("stroke", rgba(textColor, 0.22))
329
+ .attr("stroke-width", 1);
330
+
331
+ plot.append("text")
332
+ .attr("class", "category-index category-label")
333
+ .attr("x", outerX)
334
+ .attr("y", outerY)
335
+ .attr("text-anchor", "middle")
336
+ .attr("dominant-baseline", "central")
337
+ .attr("fill", textColor)
338
+ .style("font-family", fontFamily)
339
+ .style("font-size", `${Math.max(7, Math.min(10, baseLabelSize * 0.8))}px`)
340
+ .style("font-weight", "700")
341
+ .text(categoryInfo.rank);
342
+ });
343
+
344
+ const arc = d3.arc()
345
+ .innerRadius(innerRadius)
346
+ .outerRadius(d => innerRadius + Math.max(0, radialScale(d.value)))
347
+ .startAngle(d => startAngle + (d.rank - 1) * categoryBand + categoryGap + d.groupIndex * (groupBand + groupGap))
348
+ .endAngle(d => startAngle + (d.rank - 1) * categoryBand + categoryGap + d.groupIndex * (groupBand + groupGap) + groupBand)
349
+ .padAngle(0.002)
350
+ .cornerRadius(2.5);
351
+
352
+ plot.selectAll(".bar-row")
353
+ .data(displayRows)
354
+ .enter()
355
+ .append("path")
356
+ .attr("class", "mark radial-grouped-bar data-point")
357
+ .attr("data-tag", "mark")
358
+ .attr("data-category", d => d.category)
359
+ .attr("data-group", d => d.group)
360
+ .attr("d", d => arc(d))
361
+ .attr("fill", d => groupColors(d.group))
362
+ .attr("fill-opacity", d => d.value === 0 ? 0.18 : 0.86)
363
+ .attr("stroke", "#ffffff")
364
+ .attr("stroke-width", 0.8);
365
+
366
+ plot.append("circle")
367
+ .attr("class", "radial-baseline")
368
+ .attr("r", innerRadius)
369
+ .attr("fill", "#ffffff")
370
+ .attr("stroke", rgba(textColor, 0.16))
371
+ .attr("stroke-width", 1.2);
372
+
373
+ plot.append("text")
374
+ .attr("class", "axis-title")
375
+ .attr("x", 0)
376
+ .attr("y", -8)
377
+ .attr("text-anchor", "middle")
378
+ .attr("dominant-baseline", "central")
379
+ .attr("fill", textColor)
380
+ .style("font-family", fontFamily)
381
+ .style("font-size", `${Math.max(10, baseLabelSize * 0.95)}px`)
382
+ .style("font-weight", "700")
383
+ .text(cleanLabel(yField));
384
+
385
+ plot.append("text")
386
+ .attr("class", "axis-title axis-title-sub")
387
+ .attr("x", 0)
388
+ .attr("y", 12)
389
+ .attr("text-anchor", "middle")
390
+ .attr("dominant-baseline", "central")
391
+ .attr("fill", mutedText)
392
+ .style("font-family", annotationFamily)
393
+ .style("font-size", `${Math.max(8, baseValueSize * 0.9)}px`)
394
+ .style("font-weight", annotationWeight)
395
+ .text(`0 to ${formatLocalValue(domainMax)}`);
396
+
397
+ const legendX = width - legendWidth - 24;
398
+ const legendY = 64;
399
+ const rowGap = Math.max(17, Math.min(28, (height - legendY - 86) / categoryCount));
400
+ const keyFontSize = Math.max(8, Math.min(baseLabelSize, rowGap * 0.45));
401
+ const keyValueSize = Math.max(7.5, Math.min(baseValueSize, rowGap * 0.4));
402
+ const valueColumnWidth = Math.max(
403
+ 42,
404
+ ...displayRows.map(d => measureLabelText(formatLocalValue(d.value, false), keyValueSize, annotationFamily, annotationWeight))
405
+ );
406
+ const valueGap = 10;
407
+ const groupValueWidth = Math.max(valueColumnWidth, 44);
408
+ const value2X = legendWidth;
409
+ const value1X = value2X - groupValueWidth - valueGap;
410
+ const labelMaxWidth = Math.max(72, value1X - 36);
411
+ const key = svg.append("g")
412
+ .attr("class", "radial-grouped-bar-key")
413
+ .attr("transform", `translate(${legendX}, ${legendY})`);
414
+
415
+ key.append("text")
416
+ .attr("class", "key-title")
417
+ .attr("x", 0)
418
+ .attr("y", -30)
419
+ .attr("fill", textColor)
420
+ .style("font-family", fontFamily)
421
+ .style("font-size", `${Math.max(11, baseLabelSize)}px`)
422
+ .style("font-weight", "700")
423
+ .text("Category key");
424
+
425
+ const groupLegend = key.selectAll(".group-legend-row")
426
+ .data(groups)
427
+ .enter()
428
+ .append("g")
429
+ .attr("class", "group-legend-row")
430
+ .attr("transform", (d, i) => `translate(${i * Math.min(138, legendWidth / 2)}, -12)`);
431
+
432
+ groupLegend.append("rect")
433
+ .attr("class", "group-swatch")
434
+ .attr("x", 0)
435
+ .attr("y", -8)
436
+ .attr("width", 10)
437
+ .attr("height", 10)
438
+ .attr("rx", 2)
439
+ .attr("fill", d => groupColors(d));
440
+
441
+ groupLegend.append("text")
442
+ .attr("class", "group-label")
443
+ .attr("x", 15)
444
+ .attr("y", 0)
445
+ .attr("fill", mutedText)
446
+ .style("font-family", annotationFamily)
447
+ .style("font-size", `${Math.max(8, keyValueSize)}px`)
448
+ .style("font-weight", annotationWeight)
449
+ .text(d => truncateText(d, Math.min(112, legendWidth / 2 - 20), Math.max(8, keyValueSize), annotationFamily, annotationWeight));
450
+
451
+ key.append("line")
452
+ .attr("class", "key-rule")
453
+ .attr("x1", 0)
454
+ .attr("x2", legendWidth)
455
+ .attr("y1", 6)
456
+ .attr("y2", 6)
457
+ .attr("stroke", rgba(textColor, 0.16))
458
+ .attr("stroke-width", 1);
459
+
460
+ const legendRows = key.selectAll(".legend-row")
461
+ .data(orderedCategories)
462
+ .enter()
463
+ .append("g")
464
+ .attr("class", "legend-row")
465
+ .attr("transform", (d, i) => `translate(0, ${18 + i * rowGap})`);
466
+
467
+ legendRows.append("text")
468
+ .attr("class", "legend-index")
469
+ .attr("x", 0)
470
+ .attr("y", 0)
471
+ .attr("fill", textColor)
472
+ .style("font-family", fontFamily)
473
+ .style("font-size", `${keyFontSize}px`)
474
+ .style("font-weight", "700")
475
+ .text(d => d.rank);
476
+
477
+ legendRows.append("text")
478
+ .attr("class", "category-label")
479
+ .attr("x", 20)
480
+ .attr("y", 0)
481
+ .attr("fill", textColor)
482
+ .style("font-family", fontFamily)
483
+ .style("font-size", `${keyFontSize}px`)
484
+ .style("font-weight", labelWeight)
485
+ .text(d => truncateText(d.category, labelMaxWidth, keyFontSize, fontFamily, labelWeight));
486
+
487
+ groups.forEach((group, groupIndex) => {
488
+ legendRows.append("text")
489
+ .attr("class", "value-label")
490
+ .attr("x", groupIndex === 0 ? value1X : value2X)
491
+ .attr("y", 0)
492
+ .attr("text-anchor", "end")
493
+ .attr("fill", groupColors(group))
494
+ .style("font-family", annotationFamily)
495
+ .style("font-size", `${keyValueSize}px`)
496
+ .style("font-weight", annotationWeight)
497
+ .text(d => {
498
+ const value = valueByCategoryGroup.get(`${d.category}|||${group}`) ?? 0;
499
+ return formatLocalValue(value, false);
500
+ });
501
+ });
502
+
503
+ legendRows.append("line")
504
+ .attr("class", "legend-row-rule")
505
+ .attr("x1", 20)
506
+ .attr("x2", legendWidth)
507
+ .attr("y1", rowGap * 0.43)
508
+ .attr("y2", rowGap * 0.43)
509
+ .attr("stroke", rgba(textColor, 0.08))
510
+ .attr("stroke-width", 1);
511
+
512
+ svg.append("text")
513
+ .attr("class", "scale-note")
514
+ .attr("x", centerX)
515
+ .attr("y", height - 26)
516
+ .attr("text-anchor", "middle")
517
+ .attr("fill", mutedText)
518
+ .style("font-family", annotationFamily)
519
+ .style("font-size", `${Math.max(8.5, baseValueSize)}px`)
520
+ .style("font-weight", annotationWeight)
521
+ .text(`Bars radiate from the center; categories sorted by ${sortMode}.`);
522
+
523
+ svg.append("text")
524
+ .attr("class", "category-note")
525
+ .attr("x", legendX)
526
+ .attr("y", height - 26)
527
+ .attr("fill", mutedText)
528
+ .style("font-family", annotationFamily)
529
+ .style("font-size", `${Math.max(8, keyValueSize)}px`)
530
+ .style("font-weight", annotationWeight)
531
+ .text("Numbers around the ring map to the key.");
532
+
533
+ measureGroup.remove();
534
+ return svg.node();
535
+ }