Ray1ee01 commited on
Commit
65b6fe8
·
verified ·
1 Parent(s): 3f342b2

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/radar/multiple_radar_chart_01.js ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Multiple Radar Chart",
5
+ "chart_name": "multiple_radar_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
+ // 提取数据
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
+ // 设置尺寸和边距
45
+ const width = variables.width;
46
+ const height = variables.height;
47
+ const margin = { top: 50, right: 50, bottom: 50, left: 50 };
48
+
49
+ // 创建SVG
50
+ const svg = d3.select(containerSelector)
51
+ .append("svg")
52
+ .attr("width", "100%")
53
+ .attr("height", height)
54
+ .attr("viewBox", `0 0 ${width} ${height}`)
55
+ .attr("style", "max-width: 100%; height: auto;")
56
+ .attr("xmlns", "http://www.w3.org/2000/svg")
57
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
58
+
59
+ // 创建图表区域
60
+ const chartWidth = width - margin.left - margin.right;
61
+ const chartHeight = height - margin.top - margin.bottom;
62
+ const radius = Math.min(chartWidth, chartHeight) / 2;
63
+
64
+ const g = svg.append("g")
65
+ .attr("transform", `translate(${width/2}, ${height/2})`);
66
+
67
+ // 获取唯一类别和分组
68
+ const categories = [...new Set(chartData.map(d => d[categoryField]))];
69
+
70
+ // 计算每个分组的平均值并按降序排序
71
+ const groupAvgs = [...new Set(chartData.map(d => d[groupField]))]
72
+ .map(group => ({
73
+ group,
74
+ avg: d3.mean(chartData.filter(d => d[groupField] === group), d => +d[valueField])
75
+ }))
76
+ .sort((a, b) => b.avg - a.avg);
77
+
78
+ const groups = groupAvgs.map(d => d.group);
79
+
80
+ // 创建颜色比例尺
81
+ const colorScale = d => colorResolver.field(d, groups.indexOf(d), { palette: "tableau10" }).value;
82
+
83
+ // 创建角度比例尺
84
+ const angleScale = d3.scalePoint()
85
+ .domain(categories)
86
+ .range([0, 2 * Math.PI - (2 * Math.PI / categories.length)]);
87
+
88
+ // 创建半径比例尺
89
+ const allValues = chartData.map(d => +d[valueField]);
90
+ const minValue = Math.min(0, d3.min(allValues));
91
+ const maxValue = d3.max(allValues);
92
+
93
+ const radiusScale = d3.scaleLinear()
94
+ .domain([minValue, maxValue])
95
+ .range([0, radius])
96
+ .nice();
97
+
98
+ // 绘制背景圆环
99
+ const ticks = radiusScale.ticks(5);
100
+
101
+ // 绘制同心圆
102
+ g.selectAll(".circle-axis")
103
+ .data(ticks)
104
+ .enter()
105
+ .append("circle")
106
+ .attr("class", "circle-axis")
107
+ .attr("cx", 0)
108
+ .attr("cy", 0)
109
+ .attr("r", d => radiusScale(d))
110
+ .attr("fill", "none")
111
+ .attr("stroke", "#bbb")
112
+ .attr("stroke-width", 1)
113
+ .attr("stroke-dasharray", "4,4");
114
+
115
+ // 绘制径向轴线
116
+ g.selectAll(".axis-line")
117
+ .data(categories)
118
+ .enter()
119
+ .append("line")
120
+ .attr("class", "axis-line")
121
+ .attr("x1", 0)
122
+ .attr("y1", 0)
123
+ .attr("x2", (d, i) => radius * Math.cos(angleScale(d) - Math.PI/2))
124
+ .attr("y2", (d, i) => radius * Math.sin(angleScale(d) - Math.PI/2))
125
+ .attr("stroke", "#bbb")
126
+ .attr("stroke-width", 1);
127
+
128
+ // 添加类别标签
129
+ g.selectAll(".category-label")
130
+ .data(categories)
131
+ .enter()
132
+ .append("text")
133
+ .attr("class", "category-label")
134
+ .attr("x", d => (radius + 20) * Math.cos(angleScale(d) - Math.PI/2))
135
+ .attr("y", d => (radius + 20) * Math.sin(angleScale(d) - Math.PI/2))
136
+ .attr("text-anchor", d => {
137
+ const angle = angleScale(d);
138
+ if (Math.abs(angle) < 0.1 || Math.abs(angle - Math.PI) < 0.1) {
139
+ return "middle";
140
+ }
141
+ return angle > Math.PI ? "end" : "start";
142
+ })
143
+ .attr("dominant-baseline", d => {
144
+ const angle = angleScale(d);
145
+ if (Math.abs(angle) < 0.1 || Math.abs(angle - Math.PI) < 0.1) {
146
+ return "middle";
147
+ }
148
+ return angle < Math.PI ? "hanging" : "auto";
149
+ })
150
+ .attr("fill", "#333")
151
+ .attr("font-size", "16px")
152
+ .attr("font-weight", "bold")
153
+ .text(d => chartUtils.format.category(d).text);
154
+
155
+ // 添加刻度值标签
156
+ g.selectAll(".tick-label")
157
+ .data(ticks)
158
+ .enter()
159
+ .append("text")
160
+ .attr("class", "tick-label")
161
+ .attr("x", 5)
162
+ .attr("y", d => -radiusScale(d))
163
+ .attr("text-anchor", "start")
164
+ .attr("font-size", "14px")
165
+ .attr("fill", "#666")
166
+ .text(d => chartUtils.format.number(d).text);
167
+
168
+ // 按组分组数据
169
+ const groupedData = d3.group(chartData, d => d[groupField]);
170
+
171
+ // 创建折线生成器
172
+ const lineGenerator = d => {
173
+ const points = categories.map(cat => {
174
+ const point = d.find(item => item[categoryField] === cat);
175
+ if (point) {
176
+ const angle = angleScale(cat) - Math.PI/2;
177
+ const distance = radiusScale(+point[valueField]);
178
+ return [
179
+ distance * Math.cos(angle),
180
+ distance * Math.sin(angle)
181
+ ];
182
+ }
183
+ return [0, 0]; // 如果没有数据,默认为中心点
184
+ });
185
+
186
+ return d3.line()(points) + "Z"; // 闭合路径
187
+ };
188
+
189
+ // 绘制每个组的雷达折线
190
+ groupedData.forEach((values, group) => {
191
+ // 绘制折线
192
+ g.append("path")
193
+ .datum(values)
194
+ .attr("class", `radar-line-${group}`)
195
+ .attr("d", lineGenerator)
196
+ .attr("fill", colorScale(group))
197
+ .attr("fill-opacity", 0.2)
198
+ .attr("stroke", colorScale(group))
199
+ .attr("stroke-width", 2)
200
+ .attr("stroke-linejoin", "miter"); // 使用尖角连接,强调折线效果
201
+
202
+ // 绘制数据点
203
+ categories.forEach(cat => {
204
+ const point = values.find(item => item[categoryField] === cat);
205
+ if (point) {
206
+ const angle = angleScale(cat) - Math.PI/2;
207
+ const distance = radiusScale(+point[valueField]);
208
+
209
+ g.append("circle")
210
+ .attr("class", `radar-point-${group}`)
211
+ .attr("cx", distance * Math.cos(angle))
212
+ .attr("cy", distance * Math.sin(angle))
213
+ .attr("r", 4)
214
+ .attr("fill", colorScale(group))
215
+ .attr("stroke", "#fff")
216
+ .attr("stroke-width", 1);
217
+ }
218
+ });
219
+ });
220
+
221
+ // 添加图例
222
+ const legendGroup = svg.append("g");
223
+
224
+ const legendSize = chartUtils.legend.draw(legendGroup, groups, colors, {
225
+ x: 0,
226
+ y: 0,
227
+ fontSize: 14,
228
+ fontWeight: "bold",
229
+ align: "left",
230
+ maxWidth: chartWidth,
231
+ shape: "circle",
232
+ });
233
+
234
+ // 居中legend
235
+ legendGroup.attr("transform", `translate(${(chartWidth - legendSize.width) / 2}, ${-20 - legendSize.height/2})`);
236
+
237
+ return svg.node();
238
+ }
modules/chart_engine/template/d3-js/radar/multiple_radar_chart_03.js ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Multiple Radar Chart",
5
+ "chart_name": "multiple_radar_chart_03",
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": "dark",
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_dark || {};
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
+ // 设置尺寸和边距
45
+ const width = variables.width;
46
+ const height = variables.height;
47
+ const margin = { top: 50, right: 50, bottom: 50, left: 50 };
48
+
49
+ // 创建SVG
50
+ const svg = d3.select(containerSelector)
51
+ .append("svg")
52
+ .attr("width", "100%")
53
+ .attr("height", height)
54
+ .attr("viewBox", `0 0 ${width} ${height}`)
55
+ .attr("style", "max-width: 100%; height: auto;")
56
+ .attr("xmlns", "http://www.w3.org/2000/svg")
57
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
58
+
59
+ // 创建图表区域
60
+ const chartWidth = width - margin.left - margin.right;
61
+ const chartHeight = height - margin.top - margin.bottom;
62
+ const radius = Math.min(chartWidth, chartHeight) / 2;
63
+
64
+ const g = svg.append("g")
65
+ .attr("transform", `translate(${width/2}, ${height/2})`);
66
+
67
+ // 获取唯一类别和分组
68
+ const categories = [...new Set(chartData.map(d => d[categoryField]))];
69
+
70
+ // 计算每个分组的平均值并按降序排序
71
+ const groupAvgs = [...new Set(chartData.map(d => d[groupField]))]
72
+ .map(group => ({
73
+ group,
74
+ avg: d3.mean(chartData.filter(d => d[groupField] === group), d => +d[valueField])
75
+ }))
76
+ .sort((a, b) => b.avg - a.avg);
77
+
78
+ const groups = groupAvgs.map(d => d.group);
79
+
80
+ // 创建颜色比例尺
81
+ const colorScale = d => colorResolver.field(d, groups.indexOf(d), { palette: "tableau10" }).value;
82
+
83
+ // 创建角度比例尺
84
+ const angleScale = d3.scalePoint()
85
+ .domain(categories)
86
+ .range([0, 2 * Math.PI - (2 * Math.PI / categories.length)]);
87
+
88
+ // 创建半径比例尺
89
+ const allValues = chartData.map(d => +d[valueField]);
90
+ const minValue = Math.min(0, d3.min(allValues));
91
+ const maxValue = d3.max(allValues);
92
+
93
+ const radiusScale = d3.scaleLinear()
94
+ .domain([minValue, maxValue])
95
+ .range([0, radius])
96
+ .nice();
97
+
98
+ // 绘制背景圆环
99
+ const ticks = radiusScale.ticks(5);
100
+
101
+ // 绘制同心圆
102
+ g.selectAll(".circle-axis")
103
+ .data(ticks)
104
+ .enter()
105
+ .append("circle")
106
+ .attr("class", "circle-axis")
107
+ .attr("cx", 0)
108
+ .attr("cy", 0)
109
+ .attr("r", d => radiusScale(d))
110
+ .attr("fill", "none")
111
+ .attr("stroke", "#bbb")
112
+ .attr("stroke-width", 1)
113
+ .attr("stroke-dasharray", "4,4");
114
+
115
+ // 绘制径向轴线
116
+ g.selectAll(".axis-line")
117
+ .data(categories)
118
+ .enter()
119
+ .append("line")
120
+ .attr("class", "axis-line")
121
+ .attr("x1", 0)
122
+ .attr("y1", 0)
123
+ .attr("x2", (d, i) => radius * Math.cos(angleScale(d) - Math.PI/2))
124
+ .attr("y2", (d, i) => radius * Math.sin(angleScale(d) - Math.PI/2))
125
+ .attr("stroke", "#bbb")
126
+ .attr("stroke-width", 1);
127
+
128
+ // 添加类别标签
129
+ g.selectAll(".category-label")
130
+ .data(categories)
131
+ .enter()
132
+ .append("text")
133
+ .attr("class", "category-label")
134
+ .attr("x", d => (radius + 20) * Math.cos(angleScale(d) - Math.PI/2))
135
+ .attr("y", d => (radius + 20) * Math.sin(angleScale(d) - Math.PI/2))
136
+ .attr("text-anchor", d => {
137
+ const angle = angleScale(d);
138
+ if (Math.abs(angle) < 0.1 || Math.abs(angle - Math.PI) < 0.1) {
139
+ return "middle";
140
+ }
141
+ return angle > Math.PI ? "end" : "start";
142
+ })
143
+ .attr("dominant-baseline", d => {
144
+ const angle = angleScale(d);
145
+ if (Math.abs(angle) < 0.1 || Math.abs(angle - Math.PI) < 0.1) {
146
+ return "middle";
147
+ }
148
+ return angle < Math.PI ? "hanging" : "auto";
149
+ })
150
+ .attr("fill", "#fff")
151
+ .attr("font-size", "16px")
152
+ .attr("font-weight", "bold")
153
+ .text(d => chartUtils.format.category(d).text);
154
+
155
+ // 添加刻度值标签
156
+ g.selectAll(".tick-label")
157
+ .data(ticks)
158
+ .enter()
159
+ .append("text")
160
+ .attr("class", "tick-label")
161
+ .attr("x", 5)
162
+ .attr("y", d => -radiusScale(d))
163
+ .attr("text-anchor", "start")
164
+ .attr("font-size", "14px")
165
+ .attr("fill", "#ddd")
166
+ .text(d => chartUtils.format.number(d).text);
167
+
168
+ // 按组分组数据
169
+ const groupedData = d3.group(chartData, d => d[groupField]);
170
+
171
+ // 创建折线生成器
172
+ const lineGenerator = d => {
173
+ const points = categories.map(cat => {
174
+ const point = d.find(item => item[categoryField] === cat);
175
+ if (point) {
176
+ const angle = angleScale(cat) - Math.PI/2;
177
+ const distance = radiusScale(+point[valueField]);
178
+ return [
179
+ distance * Math.cos(angle),
180
+ distance * Math.sin(angle)
181
+ ];
182
+ }
183
+ return [0, 0]; // 如果没有数据,默认为中心点
184
+ });
185
+
186
+ return d3.line()(points) + "Z"; // 闭合路径
187
+ };
188
+
189
+ // 绘制每个组的雷达折线
190
+ groupedData.forEach((values, group) => {
191
+ // 绘制折线
192
+ g.append("path")
193
+ .datum(values)
194
+ .attr("class", `radar-line-${group}`)
195
+ .attr("d", lineGenerator)
196
+ .attr("fill", colorScale(group))
197
+ .attr("fill-opacity", 0.2)
198
+ .attr("stroke", colorScale(group))
199
+ .attr("stroke-width", 2)
200
+ .attr("stroke-linejoin", "miter"); // 使用尖角连接,强调折线效果
201
+
202
+ // 绘制数据点
203
+ categories.forEach(cat => {
204
+ const point = values.find(item => item[categoryField] === cat);
205
+ if (point) {
206
+ const angle = angleScale(cat) - Math.PI/2;
207
+ const distance = radiusScale(+point[valueField]);
208
+
209
+ g.append("circle")
210
+ .attr("class", `radar-point-${group}`)
211
+ .attr("cx", distance * Math.cos(angle))
212
+ .attr("cy", distance * Math.sin(angle))
213
+ .attr("r", 4)
214
+ .attr("fill", colorScale(group))
215
+ .attr("stroke", "#fff")
216
+ .attr("stroke-width", 1);
217
+ }
218
+ });
219
+ });
220
+
221
+ // 添加图例
222
+ const legendGroup = svg.append("g");
223
+
224
+ const legendSize = chartUtils.legend.draw(legendGroup, groups, colors, {
225
+ x: 0,
226
+ y: 0,
227
+ fontSize: 14,
228
+ fontWeight: "bold",
229
+ align: "left",
230
+ maxWidth: chartWidth,
231
+ textColor: "#fff",
232
+ shape: "circle",
233
+ });
234
+
235
+ // 居中legend
236
+ legendGroup.attr("transform", `translate(${(chartWidth - legendSize.width) / 2}, ${-20 - legendSize.height/2})`);
237
+
238
+ return svg.node();
239
+ }
modules/chart_engine/template/d3-js/radar/multiple_radar_line_chart_03.js ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Multiple Radar Line Chart",
5
+ "chart_name": "multiple_radar_line_chart_03",
6
+ "is_composite": false,
7
+ "required_fields": ["group", "x", "y"],
8
+ "required_fields_type": [["categorical"], ["categorical"], ["numerical"]],
9
+ "required_fields_range": [[2, 7], [3, 12], [0, "inf"]],
10
+ "required_fields_icons": [],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": [],
13
+ "required_other_colors": ["primary"],
14
+ "supported_effects": ["shadow", "stroke"],
15
+ "min_height": 400,
16
+ "min_width": 400,
17
+ "background": "no",
18
+ "icon_mark": "none",
19
+ "icon_label": "none",
20
+ "has_x_axis": "no",
21
+ "has_y_axis": "no"
22
+ }
23
+ REQUIREMENTS_END
24
+ */
25
+
26
+ function makeChart(containerSelector, data) {
27
+ const jsonData = data || {};
28
+ const sourceData = jsonData.data?.data || [];
29
+ const variables = jsonData.variables || {};
30
+ const typography = jsonData.typography || {};
31
+ const sourceColors = jsonData.colors || jsonData.colors_dark || {};
32
+ const dataColumns = chartUtils.schema.columns(jsonData);
33
+ const colorResolver = chartUtils.color.resolver(jsonData);
34
+ const chartUtilsFormatSample = chartUtils.format.autoText;
35
+ const chartUtilsTextSample = chartUtils.text.estimate;
36
+ const chartUtilsStandard = {
37
+ schema: chartUtils.schema,
38
+ format: chartUtils.format,
39
+ text: chartUtils.text,
40
+ color: chartUtils.color,
41
+ legendLayout: chartUtils.legend.layout,
42
+ legendDraw: chartUtils.legend.draw,
43
+ random: chartUtils.random.generator(jsonData, "dev-wz-style")
44
+ };
45
+ const standardChannels = chartUtils.schema.channels(jsonData, {
46
+ x: { fallbackIndex: 0 },
47
+ y: { fallbackIndex: 1 },
48
+ y2: { fallbackIndex: 2 },
49
+ y3: { fallbackIndex: 3 },
50
+ size: { fallbackIndex: 2 },
51
+ group: { fallbackIndex: 2 },
52
+ group2: { fallbackIndex: 3 },
53
+ group3: { fallbackIndex: 4 }
54
+ });
55
+
56
+ d3.select(containerSelector).html("");
57
+
58
+ const roleColumn = role => Array.from(dataColumns).find(col => col.role === role);
59
+ const categoryColumn = roleColumn("x") || dataColumns[0];
60
+ const valueColumn = roleColumn("y") || dataColumns[1];
61
+ const groupColumn = roleColumn("group") || dataColumns[2];
62
+ const categoryField = categoryColumn?.name;
63
+ const valueField = valueColumn?.name;
64
+ const groupField = groupColumn?.name;
65
+ const categoryType = categoryColumn?.data_type || "categorical";
66
+ const valueUnit = (valueColumn?.unit || "").trim();
67
+
68
+ const width = variables.width || 600;
69
+ const height = variables.height || 600;
70
+ const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
71
+ const titleFont = typography.title?.font_family || fontFamily;
72
+ const textColor = sourceColors.text_color || "#1f2937";
73
+ const mutedText = "#64748b";
74
+ const panelBg = "#f5fbfd";
75
+ const cardBg = "#ffffff";
76
+ const panelStroke = "#d8ebef";
77
+ const gridColor = "rgba(92, 126, 142, 0.22)";
78
+ const primary = sourceColors.other?.primary || sourceColors.primary || "#2c7fb8";
79
+
80
+ const readableName = value => String(value || "")
81
+ .replace(/_/g, " ")
82
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
83
+ .replace(/\s+/g, " ")
84
+ .trim();
85
+
86
+ const shortLabel = (value, maxChars = 22) => {
87
+ const label = readableName(value);
88
+ if (label.length <= maxChars) return label;
89
+ const parts = label.split(/[\s/,-]+/).filter(Boolean);
90
+ if (parts.length > 1) {
91
+ const initials = parts.map(part => part[0]).join("").toUpperCase();
92
+ if (initials.length >= 2 && initials.length <= maxChars) return initials;
93
+ }
94
+ return `${label.slice(0, Math.max(1, maxChars - 3)).trim()}...`;
95
+ };
96
+
97
+ const blendWithWhite = (colorValue, amount = 0.86) => {
98
+ const color = d3.color(colorValue);
99
+ if (!color) return colorValue;
100
+ return d3.interpolateRgb(color.formatHex(), "#ffffff")(amount);
101
+ };
102
+
103
+ const compactNumber = value => {
104
+ const abs = Math.abs(value);
105
+ if (abs >= 1000000000) return `${d3.format(".2~f")(value / 1000000000)}B`;
106
+ if (abs >= 1000000) return `${d3.format(".2~f")(value / 1000000)}M`;
107
+ if (abs >= 1000) return `${d3.format(".2~f")(value / 1000)}K`;
108
+ if (abs >= 100) return d3.format(",.0f")(value);
109
+ if (abs >= 10) return d3.format(",.1f")(value).replace(/\.0$/, "");
110
+ if (abs >= 1) return d3.format(",.2f")(value).replace(/\.?0+$/, "");
111
+ return d3.format(",.3f")(value).replace(/\.?0+$/, "");
112
+ };
113
+
114
+ const formatLocalValue = value => {
115
+ const formatted = compactNumber(value);
116
+ const currencyWithSuffix = valueUnit.match(/^([£$€¥])\s*([A-Za-z]+)$/);
117
+ if (valueUnit === "%") return `${formatted}%`;
118
+ if (currencyWithSuffix) return `${currencyWithSuffix[1]}${formatted}${currencyWithSuffix[2]}`;
119
+ if (/^[£$€¥]$/.test(valueUnit)) return `${valueUnit}${formatted}`;
120
+ if (valueUnit) return `${formatted} ${valueUnit}`;
121
+ return formatted;
122
+ };
123
+
124
+ const monthOrder = {
125
+ jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3, apr: 4, april: 4,
126
+ may: 5, jun: 6, june: 6, jul: 7, july: 7, aug: 8, august: 8,
127
+ sep: 9, sept: 9, september: 9, oct: 10, october: 10, nov: 11, november: 11, dec: 12, december: 12
128
+ };
129
+
130
+ const categoryRank = value => {
131
+ const label = String(value || "").trim();
132
+ const lower = label.toLowerCase();
133
+ if (monthOrder[lower] != null) return monthOrder[lower];
134
+ const year = label.match(/^\d{4}$/);
135
+ if (year) return Number(label);
136
+ const yearMonth = label.match(/^(\d{4})[-/](\d{1,2})$/);
137
+ if (yearMonth) return Number(yearMonth[1]) * 100 + Number(yearMonth[2]);
138
+ return null;
139
+ };
140
+
141
+ const svg = d3.select(containerSelector)
142
+ .append("svg")
143
+ .attr("width", "100%")
144
+ .attr("height", height)
145
+ .attr("viewBox", `0 0 ${width} ${height}`)
146
+ .attr("style", "max-width: 100%; height: auto;")
147
+ .attr("xmlns", "http://www.w3.org/2000/svg")
148
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
149
+ .attr("class", "multiple-radar-line-chart-root");
150
+
151
+ if (!categoryField || !valueField || !groupField) {
152
+ svg.append("text")
153
+ .attr("x", width / 2)
154
+ .attr("y", height / 2)
155
+ .attr("text-anchor", "middle")
156
+ .attr("fill", textColor)
157
+ .style("font-family", fontFamily)
158
+ .style("font-size", "16px")
159
+ .text("Missing radar fields");
160
+ return svg.node();
161
+ }
162
+
163
+ const rows = sourceData
164
+ .map((d, index) => ({
165
+ sourceIndex: index,
166
+ group: String(d[groupField]),
167
+ category: String(d[categoryField]),
168
+ rawCategory: d[categoryField],
169
+ value: Number(d[valueField])
170
+ }))
171
+ .filter(d => d.group && d.category && Number.isFinite(d.value) && d.value >= 0);
172
+
173
+ if (rows.length === 0) {
174
+ svg.append("text")
175
+ .attr("x", width / 2)
176
+ .attr("y", height / 2)
177
+ .attr("text-anchor", "middle")
178
+ .attr("fill", textColor)
179
+ .style("font-family", fontFamily)
180
+ .style("font-size", "16px")
181
+ .text("Not enough data");
182
+ return svg.node();
183
+ }
184
+
185
+ const groups = Array.from(new Set(rows.map(d => d.group)));
186
+ const categoryValues = Array.from(new Set(rows.map(d => d.category)));
187
+ const rankedCategories = categoryValues.map(label => ({ label, rank: categoryRank(label) }));
188
+ const orderedCategories = rankedCategories.every(d => d.rank != null)
189
+ ? rankedCategories.sort((a, b) => d3.ascending(a.rank, b.rank)).map(d => d.label)
190
+ : categoryValues.sort((a, b) => d3.ascending(a, b));
191
+
192
+ const groupData = groups.map(group => {
193
+ const values = orderedCategories.map(category => {
194
+ const match = rows.find(d => d.group === group && d.category === category);
195
+ return {
196
+ category,
197
+ value: match ? match.value : 0,
198
+ sourceIndex: match ? match.sourceIndex : -1
199
+ };
200
+ });
201
+ return {
202
+ group,
203
+ values,
204
+ maxValue: d3.max(values, d => d.value) || 0,
205
+ meanValue: d3.mean(values, d => d.value) || 0,
206
+ sourceIndexes: values.map(d => d.sourceIndex).filter(index => index >= 0)
207
+ };
208
+ }).sort((a, b) => d3.descending(a.meanValue, b.meanValue) || d3.ascending(a.group, b.group));
209
+ groupData.forEach((series, index) => {
210
+ series.rank = index + 1;
211
+ });
212
+
213
+ const allValues = rows.map(d => d.value);
214
+ const maxValue = d3.max(allValues) || 1;
215
+ const radiusScaleDomainMax = d3.scaleLinear().domain([0, maxValue]).nice(5).domain()[1];
216
+
217
+ const chartCount = groupData.length;
218
+ const columns = chartCount <= 3 ? chartCount : Math.ceil(chartCount / 2);
219
+ const gridRows = Math.ceil(chartCount / columns);
220
+ const outerPad = Math.max(14, Math.min(22, width * 0.03));
221
+ const headerH = 44;
222
+ const footerH = 58;
223
+ const margin = { top: outerPad + headerH, right: outerPad + 12, bottom: outerPad + footerH, left: outerPad + 12 };
224
+ const gridWidth = width - margin.left - margin.right;
225
+ const gridHeight = height - margin.top - margin.bottom;
226
+ const cellWidth = gridWidth / columns;
227
+ const cellHeight = gridHeight / gridRows;
228
+ const cardGap = Math.max(10, Math.min(16, width * 0.022));
229
+ const radius = Math.max(44, Math.min(cellWidth - cardGap * 2, cellHeight - cardGap * 2 - 38) * 0.34);
230
+ const labelRadius = radius + (orderedCategories.length > 8 ? 14 : 12);
231
+ const categoryDense = orderedCategories.length > 8;
232
+ const palette = sourceColors.available_colors || d3.schemeTableau10 || d3.schemeCategory10;
233
+ const groupColors = d3.scaleOrdinal()
234
+ .domain(groupData.map(d => d.group))
235
+ .range(groupData.map((d, index) => sourceColors.field?.[d.group] || palette[index % palette.length] || primary));
236
+
237
+ const angleForIndex = index => (index / orderedCategories.length) * Math.PI * 2 - Math.PI / 2;
238
+ const radiusScale = d3.scaleLinear()
239
+ .domain([0, radiusScaleDomainMax])
240
+ .range([0, radius]);
241
+ const ticks = radiusScale.ticks(4).filter(tick => tick > 0);
242
+
243
+ const polygonPoints = values => orderedCategories.map((category, index) => {
244
+ const point = values.find(d => d.category === category);
245
+ const distance = radiusScale(point ? point.value : 0);
246
+ const angle = angleForIndex(index);
247
+ return [distance * Math.cos(angle), distance * Math.sin(angle)];
248
+ });
249
+
250
+ const line = d3.line().curve(d3.curveLinearClosed);
251
+
252
+ const panel = svg.append("g")
253
+ .attr("class", "chart-style-panel")
254
+ .attr("data-chart-layout", "light-radar-card-grid");
255
+
256
+ panel.append("rect")
257
+ .attr("x", outerPad / 2)
258
+ .attr("y", outerPad / 2)
259
+ .attr("width", width - outerPad)
260
+ .attr("height", height - outerPad)
261
+ .attr("rx", 18)
262
+ .attr("fill", panelBg)
263
+ .attr("stroke", panelStroke)
264
+ .attr("stroke-width", 1.1);
265
+
266
+ panel.append("circle")
267
+ .attr("class", "header-icon-dot")
268
+ .attr("cx", outerPad + 12)
269
+ .attr("cy", outerPad + 16)
270
+ .attr("r", 8)
271
+ .attr("fill", blendWithWhite(primary, 0.35));
272
+
273
+ panel.append("text")
274
+ .attr("class", "chart-kicker")
275
+ .attr("x", outerPad + 28)
276
+ .attr("y", outerPad + 20)
277
+ .attr("fill", mutedText)
278
+ .style("font-family", fontFamily)
279
+ .style("font-size", "10px")
280
+ .style("font-weight", "700")
281
+ .style("letter-spacing", "0px")
282
+ .text(`${chartCount} radar cards`);
283
+
284
+ groupData.forEach((series, index) => {
285
+ const row = Math.floor(index / columns);
286
+ const col = index % columns;
287
+ const rowCount = row === gridRows - 1 ? chartCount - row * columns : columns;
288
+ const rowOffset = (columns - rowCount) * cellWidth / 2;
289
+ const color = groupColors(series.group);
290
+ const cardX = margin.left + rowOffset + col * cellWidth + cardGap / 2;
291
+ const cardY = margin.top + row * cellHeight + cardGap / 2;
292
+ const cardW = cellWidth - cardGap;
293
+ const cardH = cellHeight - cardGap;
294
+ const cx = cardX + cardW / 2;
295
+ const cy = cardY + cardH / 2 + 7;
296
+
297
+ const card = svg.append("g")
298
+ .attr("class", "radar-card")
299
+ .attr("data-group", series.group)
300
+ .attr("data-source-indexes", series.sourceIndexes.join(","))
301
+ .attr("data-rank", series.rank);
302
+
303
+ card.append("rect")
304
+ .attr("x", cardX)
305
+ .attr("y", cardY)
306
+ .attr("width", cardW)
307
+ .attr("height", cardH)
308
+ .attr("rx", 14)
309
+ .attr("fill", cardBg)
310
+ .attr("stroke", blendWithWhite(color, 0.45))
311
+ .attr("stroke-width", 1.2);
312
+
313
+ card.append("rect")
314
+ .attr("x", cardX + 12)
315
+ .attr("y", cardY + 12)
316
+ .attr("width", 22)
317
+ .attr("height", 6)
318
+ .attr("rx", 3)
319
+ .attr("fill", color)
320
+ .attr("opacity", 0.78);
321
+
322
+ const chart = svg.append("g")
323
+ .attr("class", "radar-small-multiple")
324
+ .attr("data-group", series.group)
325
+ .attr("data-source-indexes", series.sourceIndexes.join(","))
326
+ .attr("data-rank", series.rank)
327
+ .attr("transform", `translate(${cx}, ${cy})`);
328
+
329
+ ticks.forEach(tick => {
330
+ chart.append("circle")
331
+ .attr("class", "radar-grid-ring")
332
+ .attr("r", radiusScale(tick))
333
+ .attr("fill", "none")
334
+ .attr("stroke", gridColor)
335
+ .attr("stroke-width", 1);
336
+ });
337
+
338
+ orderedCategories.forEach((category, categoryIndex) => {
339
+ const angle = angleForIndex(categoryIndex);
340
+ const outerX = radius * Math.cos(angle);
341
+ const outerY = radius * Math.sin(angle);
342
+
343
+ chart.append("line")
344
+ .attr("class", "radar-axis-line")
345
+ .attr("x1", 0)
346
+ .attr("y1", 0)
347
+ .attr("x2", outerX)
348
+ .attr("y2", outerY)
349
+ .attr("stroke", gridColor)
350
+ .attr("stroke-width", 1);
351
+
352
+ const showDenseCategory = categoryDense
353
+ ? categoryIndex % Math.ceil(orderedCategories.length / 6) === 0
354
+ : true;
355
+ if (showDenseCategory) {
356
+ const labelX = labelRadius * Math.cos(angle);
357
+ const labelY = labelRadius * Math.sin(angle);
358
+ const cos = Math.cos(angle);
359
+ const sin = Math.sin(angle);
360
+ chart.append("text")
361
+ .attr("class", "radar-category-label")
362
+ .attr("data-category", category)
363
+ .attr("x", labelX)
364
+ .attr("y", labelY)
365
+ .attr("text-anchor", Math.abs(cos) < 0.2 ? "middle" : (cos > 0 ? "start" : "end"))
366
+ .attr("dominant-baseline", Math.abs(sin) < 0.2 ? "middle" : (sin > 0 ? "hanging" : "auto"))
367
+ .attr("fill", mutedText)
368
+ .style("font-family", fontFamily)
369
+ .style("font-size", categoryDense ? "7.6px" : "8.8px")
370
+ .style("font-weight", "650")
371
+ .text(shortLabel(category, categoryDense ? 6 : 10));
372
+ }
373
+ });
374
+
375
+ chart.append("path")
376
+ .attr("class", "mark radar-line")
377
+ .attr("data-tag", "mark")
378
+ .attr("data-group", series.group)
379
+ .attr("data-source-indexes", series.sourceIndexes.join(","))
380
+ .attr("data-rank", series.rank)
381
+ .attr("d", line(polygonPoints(series.values)))
382
+ .attr("fill", color)
383
+ .attr("fill-opacity", 0.16)
384
+ .attr("stroke", color)
385
+ .attr("stroke-width", 2.2)
386
+ .attr("stroke-linejoin", "round");
387
+
388
+ series.values.forEach((point, pointIndex) => {
389
+ const angle = angleForIndex(pointIndex);
390
+ const distance = radiusScale(point.value);
391
+ chart.append("circle")
392
+ .attr("class", "radar-point")
393
+ .attr("data-tag", "mark")
394
+ .attr("data-group", series.group)
395
+ .attr("data-category", point.category)
396
+ .attr("data-value", point.value)
397
+ .attr("data-source-index", point.sourceIndex)
398
+ .attr("data-rank", series.rank)
399
+ .attr("cx", distance * Math.cos(angle))
400
+ .attr("cy", distance * Math.sin(angle))
401
+ .attr("r", 2.6)
402
+ .attr("fill", color)
403
+ .attr("stroke", "#ffffff")
404
+ .attr("stroke-width", 1.2);
405
+ });
406
+
407
+ chart.append("text")
408
+ .attr("class", "radar-group-label")
409
+ .attr("x", 0)
410
+ .attr("y", -radius - (categoryDense ? 28 : 24))
411
+ .attr("text-anchor", "middle")
412
+ .attr("fill", textColor)
413
+ .style("font-family", titleFont)
414
+ .style("font-size", "10.5px")
415
+ .style("font-weight", "800")
416
+ .text(shortLabel(series.group, Math.max(12, Math.floor(cardW / 8))));
417
+
418
+ chart.append("text")
419
+ .attr("class", "radar-group-value-label")
420
+ .attr("data-group", series.group)
421
+ .attr("data-value", series.maxValue)
422
+ .attr("x", 0)
423
+ .attr("y", radius + (categoryDense ? 40 : 30))
424
+ .attr("text-anchor", "middle")
425
+ .attr("fill", mutedText)
426
+ .style("font-family", fontFamily)
427
+ .style("font-size", "8.5px")
428
+ .style("font-weight", "700")
429
+ .text(`max ${formatLocalValue(series.maxValue)}`);
430
+ });
431
+
432
+ const legend = svg.append("g")
433
+ .attr("class", "radar-scale-key")
434
+ .attr("transform", `translate(${outerPad + 12}, ${height - outerPad - footerH + 13})`);
435
+
436
+ svg.insert("rect", ".radar-scale-key")
437
+ .attr("class", "radar-summary-strip")
438
+ .attr("x", outerPad + 8)
439
+ .attr("y", height - outerPad - footerH + 2)
440
+ .attr("width", width - outerPad * 2 - 16)
441
+ .attr("height", footerH - 12)
442
+ .attr("rx", 13)
443
+ .attr("fill", "#ffffff")
444
+ .attr("stroke", panelStroke)
445
+ .attr("stroke-width", 1);
446
+
447
+ legend.append("text")
448
+ .attr("class", "radar-scale-title")
449
+ .attr("x", 0)
450
+ .attr("y", 0)
451
+ .attr("fill", textColor)
452
+ .style("font-family", fontFamily)
453
+ .style("font-size", "10px")
454
+ .style("font-weight", "700")
455
+ .text(`${readableName(valueColumn?.label || valueColumn?.name || valueField)} scale: 0-${formatLocalValue(radiusScaleDomainMax)}`);
456
+
457
+ const categoryKey = svg.append("g")
458
+ .attr("class", "radar-category-key")
459
+ .attr("transform", `translate(${Math.max(outerPad + 12, width - outerPad - 260)}, ${height - outerPad - footerH + 13})`);
460
+
461
+ categoryKey.append("text")
462
+ .attr("class", "radar-category-key-label")
463
+ .attr("x", 0)
464
+ .attr("y", 0)
465
+ .attr("fill", mutedText)
466
+ .style("font-family", fontFamily)
467
+ .style("font-size", "10px")
468
+ .text(`${readableName(categoryColumn?.label || categoryColumn?.name || categoryField)} order: ${shortLabel(orderedCategories[0], 10)} to ${shortLabel(orderedCategories[orderedCategories.length - 1], 10)}`);
469
+
470
+ return svg.node();
471
+ }
modules/chart_engine/template/d3-js/radar/multiple_radar_spline_chart_01.js ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Multiple Radar Spline Chart",
5
+ "chart_name": "multiple_radar_spline_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
+ // 提取数据
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
+ // 设置尺寸和边距
45
+ const width = variables.width;
46
+ const height = variables.height;
47
+ const margin = { top: 50, right: 50, bottom: 50, left: 50 };
48
+
49
+ // 创建SVG
50
+ const svg = d3.select(containerSelector)
51
+ .append("svg")
52
+ .attr("width", "100%")
53
+ .attr("height", height)
54
+ .attr("viewBox", `0 0 ${width} ${height}`)
55
+ .attr("style", "max-width: 100%; height: auto;")
56
+ .attr("xmlns", "http://www.w3.org/2000/svg")
57
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
58
+
59
+ // 创建图表区域
60
+ const chartWidth = width - margin.left - margin.right;
61
+ const chartHeight = height - margin.top - margin.bottom;
62
+ const radius = Math.min(chartWidth, chartHeight) / 2;
63
+
64
+ const g = svg.append("g")
65
+ .attr("transform", `translate(${width/2}, ${height/2})`);
66
+
67
+ // 获取唯一类别和分组
68
+ const categories = [...new Set(chartData.map(d => d[categoryField]))];
69
+
70
+ // 计算每个分组的平均值并按降序排序
71
+ const groupAvgs = [...new Set(chartData.map(d => d[groupField]))]
72
+ .map(group => ({
73
+ group,
74
+ avg: d3.mean(chartData.filter(d => d[groupField] === group), d => +d[valueField])
75
+ }))
76
+ .sort((a, b) => b.avg - a.avg);
77
+
78
+ const groups = groupAvgs.map(d => d.group);
79
+
80
+ // 创建颜色比例尺
81
+ const colorScale = d => colorResolver.field(d, groups.indexOf(d), { palette: "tableau10" }).value;
82
+
83
+ // 创建角度比例尺
84
+ const angleScale = d3.scalePoint()
85
+ .domain(categories)
86
+ .range([0, 2 * Math.PI - (2 * Math.PI / categories.length)]);
87
+
88
+ // 创建半径比例尺
89
+ const allValues = chartData.map(d => +d[valueField]);
90
+ const minValue = Math.min(0, d3.min(allValues));
91
+ const maxValue = d3.max(allValues);
92
+
93
+ const radiusScale = d3.scaleLinear()
94
+ .domain([minValue, maxValue * 1.2])
95
+ .range([0, radius])
96
+ .nice();
97
+
98
+ // 绘制背景圆环
99
+ const ticks = radiusScale.ticks(5);
100
+
101
+ // 绘制同心圆
102
+ g.selectAll(".circle-axis")
103
+ .data(ticks)
104
+ .enter()
105
+ .append("circle")
106
+ .attr("class", "circle-axis")
107
+ .attr("cx", 0)
108
+ .attr("cy", 0)
109
+ .attr("r", d => radiusScale(d))
110
+ .attr("fill", "none")
111
+ .attr("stroke", "#bbb")
112
+ .attr("stroke-width", 1)
113
+ .attr("stroke-dasharray", "4,4");
114
+
115
+ // 绘制径向轴线
116
+ g.selectAll(".axis-line")
117
+ .data(categories)
118
+ .enter()
119
+ .append("line")
120
+ .attr("class", "axis-line")
121
+ .attr("x1", 0)
122
+ .attr("y1", 0)
123
+ .attr("x2", (d, i) => radius * Math.cos(angleScale(d) - Math.PI/2))
124
+ .attr("y2", (d, i) => radius * Math.sin(angleScale(d) - Math.PI/2))
125
+ .attr("stroke", "#bbb")
126
+ .attr("stroke-width", 1);
127
+
128
+ // 添加类别标签
129
+ g.selectAll(".category-label")
130
+ .data(categories)
131
+ .enter()
132
+ .append("text")
133
+ .attr("class", "category-label")
134
+ .attr("x", d => (radius + 20) * Math.cos(angleScale(d) - Math.PI/2))
135
+ .attr("y", d => (radius + 20) * Math.sin(angleScale(d) - Math.PI/2))
136
+ .attr("text-anchor", d => {
137
+ const angle = angleScale(d);
138
+ if (Math.abs(angle) < 0.1 || Math.abs(angle - Math.PI) < 0.1) {
139
+ return "middle";
140
+ }
141
+ return angle > Math.PI ? "end" : "start";
142
+ })
143
+ .attr("dominant-baseline", d => {
144
+ const angle = angleScale(d);
145
+ if (Math.abs(angle) < 0.1 || Math.abs(angle - Math.PI) < 0.1) {
146
+ return "middle";
147
+ }
148
+ return angle < Math.PI ? "hanging" : "auto";
149
+ })
150
+ .attr("fill", "#333")
151
+ .attr("font-size", "16px")
152
+ .attr("font-weight", "bold")
153
+ .text(d => chartUtils.format.category(d).text);
154
+
155
+ // 添加刻度值标签
156
+ g.selectAll(".tick-label")
157
+ .data(ticks)
158
+ .enter()
159
+ .append("text")
160
+ .attr("class", "tick-label")
161
+ .attr("x", 5)
162
+ .attr("y", d => -radiusScale(d))
163
+ .attr("text-anchor", "start")
164
+ .attr("font-size", "14px")
165
+ .attr("fill", "#666")
166
+ .text(d => chartUtils.format.number(d).text);
167
+
168
+ // 按组分组数据
169
+ const groupedData = d3.group(chartData, d => d[groupField]);
170
+
171
+ // 创建样条曲线生成器
172
+ const lineGenerator = d => {
173
+ const points = categories.map(cat => {
174
+ const point = d.find(item => item[categoryField] === cat);
175
+ if (point) {
176
+ const angle = angleScale(cat) - Math.PI/2;
177
+ const distance = radiusScale(+point[valueField]);
178
+ return [
179
+ distance * Math.cos(angle),
180
+ distance * Math.sin(angle)
181
+ ];
182
+ }
183
+ return [0, 0]; // 如果没有数据,默认为中心点
184
+ });
185
+
186
+ // 为了闭合曲线,我们需要复制第一个点到最后
187
+ const closedPoints = [...points];
188
+
189
+ // 使用基本的曲线插值器,但降低tension值使曲线更平滑
190
+ return d3.line()
191
+ .curve(d3.curveCatmullRomClosed.alpha(0.5))(closedPoints);
192
+ };
193
+
194
+ // 绘制每个组的雷达曲线
195
+ groupedData.forEach((values, group) => {
196
+ // 绘制曲线
197
+ g.append("path")
198
+ .datum(values)
199
+ .attr("class", `radar-line-${group}`)
200
+ .attr("d", lineGenerator)
201
+ .attr("fill", colorScale(group))
202
+ .attr("fill-opacity", 0)
203
+ .attr("stroke", colorScale(group))
204
+ .attr("stroke-width", 4);
205
+
206
+ // 绘制数据点
207
+ categories.forEach(cat => {
208
+ const point = values.find(item => item[categoryField] === cat);
209
+ if (point) {
210
+ const angle = angleScale(cat) - Math.PI/2;
211
+ const distance = radiusScale(+point[valueField]);
212
+
213
+ g.append("circle")
214
+ .attr("class", `radar-point-${group}`)
215
+ .attr("cx", distance * Math.cos(angle))
216
+ .attr("cy", distance * Math.sin(angle))
217
+ .attr("r", 6)
218
+ .attr("fill", colorScale(group))
219
+ .attr("stroke", "#fff")
220
+ .attr("stroke-width", 2);
221
+ }
222
+ });
223
+ });
224
+
225
+ // 添加图例
226
+ const legendGroup = svg.append("g");
227
+
228
+ const legendSize = chartUtils.legend.draw(legendGroup, groups, colors, {
229
+ x: 0,
230
+ y: 0,
231
+ fontSize: 14,
232
+ fontWeight: "bold",
233
+ align: "left",
234
+ maxWidth: chartWidth,
235
+ shape: "circle",
236
+ });
237
+
238
+ // 居中legend
239
+ legendGroup.attr("transform", `translate(${(chartWidth - legendSize.width) / 2}, ${-20 - legendSize.height/2})`);
240
+
241
+ return svg.node();
242
+ }
modules/chart_engine/template/d3-js/radar/multiple_radar_spline_chart_02.js ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Multiple Radar Spline Chart",
5
+ "chart_name": "multiple_radar_spline_chart_02",
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": "dark",
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_dark || {};
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
+ // 设置尺寸和边距
45
+ const width = variables.width;
46
+ const height = variables.height;
47
+ const margin = { top: 50, right: 50, bottom: 50, left: 50 };
48
+
49
+ // 创建SVG
50
+ const svg = d3.select(containerSelector)
51
+ .append("svg")
52
+ .attr("width", "100%")
53
+ .attr("height", height)
54
+ .attr("viewBox", `0 0 ${width} ${height}`)
55
+ .attr("style", "max-width: 100%; height: auto;")
56
+ .attr("xmlns", "http://www.w3.org/2000/svg")
57
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
58
+
59
+ // 创建图表区域
60
+ const chartWidth = width - margin.left - margin.right;
61
+ const chartHeight = height - margin.top - margin.bottom;
62
+ const radius = Math.min(chartWidth, chartHeight) / 2;
63
+
64
+ const g = svg.append("g")
65
+ .attr("transform", `translate(${width/2}, ${height/2})`);
66
+
67
+ // 获取唯一类别和分组
68
+ const categories = [...new Set(chartData.map(d => d[categoryField]))];
69
+
70
+ // 计算每个分组的平均值并按降序排序
71
+ const groupAvgs = [...new Set(chartData.map(d => d[groupField]))]
72
+ .map(group => ({
73
+ group,
74
+ avg: d3.mean(chartData.filter(d => d[groupField] === group), d => +d[valueField])
75
+ }))
76
+ .sort((a, b) => b.avg - a.avg);
77
+
78
+ const groups = groupAvgs.map(d => d.group);
79
+
80
+ // 创建颜色比例尺
81
+ const colorScale = d => colorResolver.field(d, groups.indexOf(d), { palette: "tableau10" }).value;
82
+
83
+ // 创建角度比例尺
84
+ const angleScale = d3.scalePoint()
85
+ .domain(categories)
86
+ .range([0, 2 * Math.PI - (2 * Math.PI / categories.length)]);
87
+
88
+ // 创建半径比例尺
89
+ const allValues = chartData.map(d => +d[valueField]);
90
+ const minValue = Math.min(0, d3.min(allValues));
91
+ const maxValue = d3.max(allValues);
92
+
93
+ const radiusScale = d3.scaleLinear()
94
+ .domain([minValue, maxValue * 1.2])
95
+ .range([0, radius])
96
+ .nice();
97
+
98
+ // 绘制背景圆环
99
+ const ticks = radiusScale.ticks(5);
100
+
101
+ // 绘制同心圆
102
+ g.selectAll(".circle-axis")
103
+ .data(ticks)
104
+ .enter()
105
+ .append("circle")
106
+ .attr("class", "circle-axis")
107
+ .attr("cx", 0)
108
+ .attr("cy", 0)
109
+ .attr("r", d => radiusScale(d))
110
+ .attr("fill", "none")
111
+ .attr("stroke", "#bbb")
112
+ .attr("stroke-width", 1)
113
+ .attr("stroke-dasharray", "4,4");
114
+
115
+ // 绘制径向轴线
116
+ g.selectAll(".axis-line")
117
+ .data(categories)
118
+ .enter()
119
+ .append("line")
120
+ .attr("class", "axis-line")
121
+ .attr("x1", 0)
122
+ .attr("y1", 0)
123
+ .attr("x2", (d, i) => radius * Math.cos(angleScale(d) - Math.PI/2))
124
+ .attr("y2", (d, i) => radius * Math.sin(angleScale(d) - Math.PI/2))
125
+ .attr("stroke", "#bbb")
126
+ .attr("stroke-width", 1);
127
+
128
+ // 添加类别标签
129
+ g.selectAll(".category-label")
130
+ .data(categories)
131
+ .enter()
132
+ .append("text")
133
+ .attr("class", "category-label")
134
+ .attr("x", d => (radius + 20) * Math.cos(angleScale(d) - Math.PI/2))
135
+ .attr("y", d => (radius + 20) * Math.sin(angleScale(d) - Math.PI/2))
136
+ .attr("text-anchor", d => {
137
+ const angle = angleScale(d);
138
+ if (Math.abs(angle) < 0.1 || Math.abs(angle - Math.PI) < 0.1) {
139
+ return "middle";
140
+ }
141
+ return angle > Math.PI ? "end" : "start";
142
+ })
143
+ .attr("dominant-baseline", d => {
144
+ const angle = angleScale(d);
145
+ if (Math.abs(angle) < 0.1 || Math.abs(angle - Math.PI) < 0.1) {
146
+ return "middle";
147
+ }
148
+ return angle < Math.PI ? "hanging" : "auto";
149
+ })
150
+ .attr("fill", "#fff")
151
+ .attr("font-size", "16px")
152
+ .attr("font-weight", "bold")
153
+ .text(d => chartUtils.format.category(d).text);
154
+
155
+ // 添加刻度值标签
156
+ g.selectAll(".tick-label")
157
+ .data(ticks)
158
+ .enter()
159
+ .append("text")
160
+ .attr("class", "tick-label")
161
+ .attr("x", 5)
162
+ .attr("y", d => -radiusScale(d))
163
+ .attr("text-anchor", "start")
164
+ .attr("font-size", "14px")
165
+ .attr("fill", "#ddd")
166
+ .text(d => chartUtils.format.number(d).text);
167
+
168
+ // 按组分组数据
169
+ const groupedData = d3.group(chartData, d => d[groupField]);
170
+
171
+ // 创建样条曲线生成器
172
+ const lineGenerator = d => {
173
+ const points = categories.map(cat => {
174
+ const point = d.find(item => item[categoryField] === cat);
175
+ if (point) {
176
+ const angle = angleScale(cat) - Math.PI/2;
177
+ const distance = radiusScale(+point[valueField]);
178
+ return [
179
+ distance * Math.cos(angle),
180
+ distance * Math.sin(angle)
181
+ ];
182
+ }
183
+ return [0, 0]; // 如果没有数据,默认为中心点
184
+ });
185
+
186
+ // 为了闭合曲线,我们需要复制第一个点到最后
187
+ const closedPoints = [...points];
188
+
189
+ // 使用基本的曲线插值器,但降低tension值使曲线更平滑
190
+ return d3.line()
191
+ .curve(d3.curveCatmullRomClosed.alpha(0.5))(closedPoints);
192
+ };
193
+
194
+ // 绘制每个组的雷达曲线
195
+ groupedData.forEach((values, group) => {
196
+ // 绘制曲线
197
+ g.append("path")
198
+ .datum(values)
199
+ .attr("class", `radar-line-${group}`)
200
+ .attr("d", lineGenerator)
201
+ .attr("fill", colorScale(group))
202
+ .attr("fill-opacity", 0)
203
+ .attr("stroke", colorScale(group))
204
+ .attr("stroke-width", 4);
205
+
206
+ // 绘制数据点
207
+ categories.forEach(cat => {
208
+ const point = values.find(item => item[categoryField] === cat);
209
+ if (point) {
210
+ const angle = angleScale(cat) - Math.PI/2;
211
+ const distance = radiusScale(+point[valueField]);
212
+
213
+ g.append("circle")
214
+ .attr("class", `radar-point-${group}`)
215
+ .attr("cx", distance * Math.cos(angle))
216
+ .attr("cy", distance * Math.sin(angle))
217
+ .attr("r", 6)
218
+ .attr("fill", colorScale(group))
219
+ .attr("stroke", "#fff")
220
+ .attr("stroke-width", 2);
221
+ }
222
+ });
223
+ });
224
+
225
+ // 添加图例
226
+ const legendGroup = svg.append("g");
227
+
228
+ const legendSize = chartUtils.legend.draw(legendGroup, groups, colors, {
229
+ x: 0,
230
+ y: 0,
231
+ fontSize: 14,
232
+ fontWeight: "bold",
233
+ align: "left",
234
+ maxWidth: chartWidth,
235
+ textColor: "#fff",
236
+ shape: "circle",
237
+ });
238
+
239
+ // 居中legend
240
+ legendGroup.attr("transform", `translate(${(chartWidth - legendSize.width) / 2}, ${-20 - legendSize.height/2})`);
241
+
242
+ return svg.node();
243
+ }
modules/chart_engine/template/d3-js/radar/multiple_radar_spline_chart_03.js ADDED
@@ -0,0 +1,639 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Multiple Radar Spline Chart",
5
+ "chart_name": "multiple_radar_spline_chart_03",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["categorical", "temporal"], ["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_light || 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 = Number(variables.width) || 600;
67
+ const height = Number(variables.height) || 600;
68
+ const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
69
+ const titleFamily = typography.title?.font_family || fontFamily;
70
+ const textColor = sourceColors.text_color || "#1f2937";
71
+ const mutedText = "#64748b";
72
+ const panelBg = "#f5fbfd";
73
+ const panelStroke = "#d8ebef";
74
+ const plotCardFill = "#ffffff";
75
+ const gridColor = "rgba(92, 126, 142, 0.2)";
76
+ const axisColor = "rgba(71, 85, 105, 0.28)";
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("xmlns:xlink", "http://www.w3.org/1999/xlink")
86
+ .attr("class", "multiple-radar-spline-chart-root");
87
+
88
+ if (!categoryField || !valueField || !groupField) {
89
+ svg.append("text")
90
+ .attr("class", "chart-message")
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 radar fields");
98
+ return svg.node();
99
+ }
100
+
101
+ function readableLabel(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 || !valueUnit || valueUnit === "none") return formatted;
122
+ if (valueUnit === "%") return `${formatted}%`;
123
+ const currencyWithSuffix = valueUnit.match(/^([£$€¥])\s*([A-Za-z]+)$/);
124
+ if (currencyWithSuffix) return `${currencyWithSuffix[1]}${formatted}${currencyWithSuffix[2]}`;
125
+ if (/^[£$€¥]$/.test(valueUnit)) return `${valueUnit}${formatted}`;
126
+ return `${formatted} ${valueUnit}`;
127
+ }
128
+
129
+ function parseTemporalRank(value) {
130
+ const text = String(value ?? "").trim();
131
+ const lower = text.toLowerCase();
132
+ const monthOrder = {
133
+ jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3, apr: 4, april: 4,
134
+ may: 5, jun: 6, june: 6, jul: 7, july: 7, aug: 8, august: 8,
135
+ sep: 9, sept: 9, september: 9, oct: 10, october: 10, nov: 11, november: 11, dec: 12, december: 12
136
+ };
137
+ if (monthOrder[lower] != null) return monthOrder[lower];
138
+ let match = text.match(/^(-?\d{4})$/);
139
+ if (match) return Number(match[1]) * 10000;
140
+ match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
141
+ if (match) return Number(match[1]) * 10000 + Number(match[2]) * 100 + Number(match[3] || 1);
142
+ match = text.match(/^q([1-4])\s*(-?\d{4})$/i) || text.match(/^(-?\d{4})\s*q([1-4])$/i);
143
+ if (match && match[1].length === 1) return Number(match[2]) * 10 + Number(match[1]);
144
+ if (match) return Number(match[1]) * 10 + Number(match[2]);
145
+ return NaN;
146
+ }
147
+
148
+ function parseOrdinalRank(value) {
149
+ const text = String(value ?? "").trim().toLowerCase();
150
+ let match = text.match(/^(\d+(?:\.\d+)?)\s*(?:-|to|–|—)\s*\d+(?:\.\d+)?/);
151
+ if (match) return Number(match[1]);
152
+ match = text.match(/^(\d+(?:\.\d+)?)\s*\+$/);
153
+ if (match) return Number(match[1]);
154
+ match = text.match(/^(\d+)(?:st|nd|rd|th)\b/);
155
+ if (match) return Number(match[1]);
156
+ match = text.match(/^(-?\d+(?:\.\d+)?)/);
157
+ if (match) return Number(match[1]);
158
+
159
+ const sizeRanks = [
160
+ [/very\s+small|micro|tiny/, 1],
161
+ [/town|village/, 1.5],
162
+ [/small\s+city|small/, 2],
163
+ [/mid[-\s]?sized|medium/, 3],
164
+ [/regional\s+hub/, 4],
165
+ [/very\s+large/, 6],
166
+ [/large\b/, 5],
167
+ [/metro|metropolis|mega/, 7]
168
+ ];
169
+ for (const [pattern, rank] of sizeRanks) {
170
+ if (pattern.test(text)) return rank;
171
+ }
172
+ return NaN;
173
+ }
174
+
175
+ function splitLabel(label, maxChars = 17, maxLines = 2) {
176
+ const text = readableLabel(label);
177
+ if (text.length <= maxChars) return [text];
178
+ const parts = text
179
+ .replace(/\//g, "/ ")
180
+ .replace(/,/g, ", ")
181
+ .split(/\s+/)
182
+ .filter(Boolean);
183
+ const lines = [];
184
+ let current = "";
185
+ parts.forEach(part => {
186
+ if (!current) {
187
+ current = part;
188
+ } else if (`${current} ${part}`.length <= maxChars) {
189
+ current = `${current} ${part}`;
190
+ } else {
191
+ lines.push(current);
192
+ current = part;
193
+ }
194
+ });
195
+ if (current) lines.push(current);
196
+
197
+ const expanded = [];
198
+ lines.forEach(line => {
199
+ if (line.length <= maxChars) {
200
+ expanded.push(line);
201
+ } else {
202
+ for (let i = 0; i < line.length; i += maxChars) {
203
+ expanded.push(line.slice(i, i + maxChars));
204
+ }
205
+ }
206
+ });
207
+ if (expanded.length <= maxLines) return expanded;
208
+ const kept = expanded.slice(0, maxLines);
209
+ kept[maxLines - 1] = expanded.slice(maxLines - 1).join(" ");
210
+ return kept;
211
+ }
212
+
213
+ function shortLabel(value, maxChars = 18) {
214
+ const label = readableLabel(value);
215
+ if (label.length <= maxChars) return label;
216
+ const parts = label.split(/[\s/,-]+/).filter(Boolean);
217
+ if (parts.length > 1) {
218
+ const initials = parts.map(part => part[0]).join("").toUpperCase();
219
+ if (initials.length >= 2 && initials.length <= maxChars) return initials;
220
+ }
221
+ return `${label.slice(0, Math.max(1, maxChars - 3)).trim()}...`;
222
+ }
223
+
224
+ function blendWithWhite(colorValue, amount = 0.86) {
225
+ const color = d3.color(colorValue);
226
+ if (!color) return colorValue;
227
+ return d3.interpolateRgb(color.formatHex(), "#ffffff")(amount);
228
+ }
229
+
230
+ function appendWrappedText(selection, lines, options = {}) {
231
+ const {
232
+ x = 0,
233
+ y = 0,
234
+ anchor = "middle",
235
+ lineHeight = 12,
236
+ fill = textColor,
237
+ fontSize = 11,
238
+ fontWeight = "600",
239
+ family = fontFamily
240
+ } = options;
241
+ const text = selection.append("text")
242
+ .attr("x", x)
243
+ .attr("y", y)
244
+ .attr("text-anchor", anchor)
245
+ .attr("fill", fill)
246
+ .style("font-family", family)
247
+ .style("font-size", `${fontSize}px`)
248
+ .style("font-weight", fontWeight);
249
+ const yOffset = -((lines.length - 1) * lineHeight) / 2;
250
+ lines.forEach((line, index) => {
251
+ text.append("tspan")
252
+ .attr("x", x)
253
+ .attr("dy", index === 0 ? yOffset : lineHeight)
254
+ .text(line);
255
+ });
256
+ return text;
257
+ }
258
+
259
+ function safeId(value) {
260
+ return String(value ?? "")
261
+ .replace(/[^a-zA-Z0-9_-]+/g, "-")
262
+ .replace(/^-+|-+$/g, "")
263
+ .slice(0, 40) || "series";
264
+ }
265
+
266
+ const rows = sourceData
267
+ .map((d, index) => ({
268
+ index,
269
+ group: readableLabel(d[groupField]),
270
+ category: readableLabel(d[categoryField]),
271
+ rawCategory: d[categoryField],
272
+ value: Number(d[valueField])
273
+ }))
274
+ .filter(d => d.group && d.category && Number.isFinite(d.value) && d.value >= 0);
275
+
276
+ const categoryValues = Array.from(new Set(rows.map(d => d.category)));
277
+ const groupValues = Array.from(new Set(rows.map(d => d.group)));
278
+
279
+ if (categoryValues.length < 3 || groupValues.length < 1 || rows.length < 3) {
280
+ svg.append("text")
281
+ .attr("class", "chart-message")
282
+ .attr("x", width / 2)
283
+ .attr("y", height / 2)
284
+ .attr("text-anchor", "middle")
285
+ .attr("fill", textColor)
286
+ .style("font-family", fontFamily)
287
+ .style("font-size", "16px")
288
+ .text("Not enough data for radar spline");
289
+ return svg.node();
290
+ }
291
+
292
+ const categoryStats = categoryValues.map(category => {
293
+ const categoryRows = rows.filter(d => d.category === category);
294
+ return {
295
+ category,
296
+ temporalRank: parseTemporalRank(categoryRows[0]?.rawCategory ?? category),
297
+ ordinalRank: parseOrdinalRank(categoryRows[0]?.rawCategory ?? category),
298
+ meanValue: d3.mean(categoryRows, d => d.value) || 0
299
+ };
300
+ });
301
+ const allTemporal = categoryStats.every(d => Number.isFinite(d.temporalRank));
302
+ const allOrdinal = !allTemporal && categoryStats.every(d => Number.isFinite(d.ordinalRank));
303
+ const sortMode = allTemporal ? "chronological" : (allOrdinal ? "ordered category" : "mean value");
304
+ const orderedCategories = [...categoryStats]
305
+ .sort((a, b) => {
306
+ if (allTemporal) return d3.ascending(a.temporalRank, b.temporalRank);
307
+ if (allOrdinal) return d3.ascending(a.ordinalRank, b.ordinalRank);
308
+ return d3.descending(a.meanValue, b.meanValue) || d3.ascending(a.category, b.category);
309
+ })
310
+ .map(d => d.category);
311
+
312
+ const aggregated = new Map();
313
+ rows.forEach(d => {
314
+ const key = `${d.group}\u0000${d.category}`;
315
+ const current = aggregated.get(key) || { group: d.group, category: d.category, values: [], sourceIndexes: [] };
316
+ current.values.push(d.value);
317
+ current.sourceIndexes.push(d.index);
318
+ aggregated.set(key, current);
319
+ });
320
+
321
+ const groups = groupValues.map(group => {
322
+ const values = orderedCategories.map(category => {
323
+ const entry = aggregated.get(`${group}\u0000${category}`);
324
+ return {
325
+ category,
326
+ value: entry ? d3.mean(entry.values) : 0,
327
+ sourceIndexes: entry ? entry.sourceIndexes : []
328
+ };
329
+ });
330
+ return {
331
+ group,
332
+ values,
333
+ meanValue: d3.mean(values, d => d.value) || 0,
334
+ maxValue: d3.max(values, d => d.value) || 0,
335
+ sourceIndexes: values.flatMap(d => d.sourceIndexes)
336
+ };
337
+ }).sort((a, b) => d3.descending(a.meanValue, b.meanValue) || d3.ascending(a.group, b.group));
338
+ groups.forEach((series, index) => {
339
+ series.rank = index + 1;
340
+ });
341
+
342
+ const groupColor = group => {
343
+ if (sourceColors.field && sourceColors.field[group]) return sourceColors.field[group];
344
+ const originalGroup = rows.find(d => d.group === group)?.group;
345
+ if (originalGroup && sourceColors.field && sourceColors.field[originalGroup]) return sourceColors.field[originalGroup];
346
+ const palette = sourceColors.available_colors || d3.schemeTableau10 || d3.schemeCategory10;
347
+ return palette[groups.findIndex(d => d.group === group) % palette.length];
348
+ };
349
+
350
+ const maxValue = Math.max(1, d3.max(rows, d => d.value) || 1);
351
+ const radiusScale = d3.scaleLinear()
352
+ .domain([0, maxValue])
353
+ .nice(5);
354
+ const domainMax = radiusScale.domain()[1];
355
+
356
+ const outerPad = Math.max(14, Math.min(22, width * 0.03));
357
+ const footerH = Math.max(74, groups.length > 3 ? 92 : 76);
358
+ const margin = { top: outerPad + 52, right: 82, bottom: outerPad + footerH + 18, left: 82 };
359
+ const centerX = width / 2;
360
+ const centerY = margin.top + (height - margin.top - margin.bottom) * 0.5;
361
+ const radius = Math.max(
362
+ 88,
363
+ Math.min(
364
+ (width - margin.left - margin.right) / 2,
365
+ centerY - margin.top - 12,
366
+ height - margin.bottom - centerY - 14
367
+ )
368
+ );
369
+ radiusScale.range([0, radius]);
370
+
371
+ const labelRadius = radius + 26;
372
+ const tickValues = radiusScale.ticks(5).filter(tick => tick > 0);
373
+ const angleForIndex = index => (index / orderedCategories.length) * Math.PI * 2 - Math.PI / 2;
374
+ const pointFor = (categoryIndex, value) => {
375
+ const angle = angleForIndex(categoryIndex);
376
+ const distance = radiusScale(value);
377
+ return [distance * Math.cos(angle), distance * Math.sin(angle)];
378
+ };
379
+ const splineLine = d3.line()
380
+ .curve(d3.curveCatmullRomClosed.alpha(0.55));
381
+
382
+ const defs = svg.append("defs");
383
+ const panel = svg.append("g")
384
+ .attr("class", "chart-style-panel")
385
+ .attr("data-chart-layout", "light-single-radar-reference");
386
+
387
+ panel.append("rect")
388
+ .attr("x", outerPad / 2)
389
+ .attr("y", outerPad / 2)
390
+ .attr("width", width - outerPad)
391
+ .attr("height", height - outerPad)
392
+ .attr("rx", 18)
393
+ .attr("fill", panelBg)
394
+ .attr("stroke", panelStroke)
395
+ .attr("stroke-width", 1.1);
396
+
397
+ panel.append("circle")
398
+ .attr("class", "header-icon-dot")
399
+ .attr("cx", outerPad + 12)
400
+ .attr("cy", outerPad + 16)
401
+ .attr("r", 8)
402
+ .attr("fill", blendWithWhite(groupColor(groups[0].group), 0.35));
403
+
404
+ panel.append("text")
405
+ .attr("class", "chart-kicker")
406
+ .attr("x", outerPad + 28)
407
+ .attr("y", outerPad + 20)
408
+ .attr("fill", mutedText)
409
+ .style("font-family", fontFamily)
410
+ .style("font-size", "10px")
411
+ .style("font-weight", "700")
412
+ .style("letter-spacing", "0px")
413
+ .text(`${groups.length} spline series`);
414
+
415
+ svg.append("rect")
416
+ .attr("class", "radar-plot-card")
417
+ .attr("x", outerPad + 16)
418
+ .attr("y", margin.top - 32)
419
+ .attr("width", width - (outerPad + 16) * 2)
420
+ .attr("height", height - margin.top - margin.bottom + 72)
421
+ .attr("rx", 18)
422
+ .attr("fill", plotCardFill)
423
+ .attr("stroke", panelStroke)
424
+ .attr("stroke-width", 1);
425
+
426
+ const plot = svg.append("g")
427
+ .attr("class", "radar-spline-plot")
428
+ .attr("transform", `translate(${centerX}, ${centerY})`);
429
+
430
+ plot.append("circle")
431
+ .attr("class", "radar-plot-background")
432
+ .attr("r", radius + 8)
433
+ .attr("fill", "rgba(240, 249, 252, 0.76)")
434
+ .attr("stroke", "rgba(148, 163, 184, 0.16)")
435
+ .attr("stroke-width", 1);
436
+
437
+ tickValues.forEach(tick => {
438
+ plot.append("circle")
439
+ .attr("class", "radar-grid-ring gridline")
440
+ .attr("r", radiusScale(tick))
441
+ .attr("fill", "none")
442
+ .attr("stroke", gridColor)
443
+ .attr("stroke-width", 1);
444
+
445
+ plot.append("text")
446
+ .attr("class", "radar-tick-label axis-tick")
447
+ .attr("x", 7)
448
+ .attr("y", -radiusScale(tick) + 3)
449
+ .attr("fill", mutedText)
450
+ .style("font-family", fontFamily)
451
+ .style("font-size", "9px")
452
+ .style("font-weight", "500")
453
+ .text(formatLocalValue(tick, false));
454
+ });
455
+
456
+ orderedCategories.forEach((category, index) => {
457
+ const angle = angleForIndex(index);
458
+ const cos = Math.cos(angle);
459
+ const sin = Math.sin(angle);
460
+ const axisEnd = pointFor(index, domainMax);
461
+
462
+ plot.append("line")
463
+ .attr("class", "radar-axis-line")
464
+ .attr("x1", 0)
465
+ .attr("y1", 0)
466
+ .attr("x2", axisEnd[0])
467
+ .attr("y2", axisEnd[1])
468
+ .attr("stroke", axisColor)
469
+ .attr("stroke-width", 1);
470
+
471
+ const labelX = labelRadius * cos;
472
+ const labelY = labelRadius * sin;
473
+ const anchor = Math.abs(cos) < 0.18 ? "middle" : (cos > 0 ? "start" : "end");
474
+ appendWrappedText(plot, splitLabel(category, orderedCategories.length > 6 ? 14 : 17, 2), {
475
+ x: labelX,
476
+ y: labelY,
477
+ anchor,
478
+ lineHeight: orderedCategories.length > 6 ? 10 : 11,
479
+ fill: textColor,
480
+ fontSize: orderedCategories.length > 6 ? 9.5 : 10.5,
481
+ fontWeight: "650",
482
+ family: fontFamily
483
+ }).attr("class", "radar-category-label category-label");
484
+ });
485
+
486
+ groups.forEach((series, seriesIndex) => {
487
+ const color = groupColor(series.group);
488
+ const points = series.values.map((point, pointIndex) => pointFor(pointIndex, point.value));
489
+ const gradientId = `radar-spline-fill-${seriesIndex}-${safeId(series.group)}`;
490
+ const gradient = defs.append("radialGradient")
491
+ .attr("id", gradientId)
492
+ .attr("cx", "50%")
493
+ .attr("cy", "50%")
494
+ .attr("r", "58%");
495
+ gradient.append("stop")
496
+ .attr("offset", "0%")
497
+ .attr("stop-color", color)
498
+ .attr("stop-opacity", 0.16);
499
+ gradient.append("stop")
500
+ .attr("offset", "100%")
501
+ .attr("stop-color", color)
502
+ .attr("stop-opacity", 0.04);
503
+
504
+ const seriesGroup = plot.append("g")
505
+ .attr("class", "radar-series")
506
+ .attr("data-group", series.group)
507
+ .attr("data-source-indexes", series.sourceIndexes.join(","))
508
+ .attr("data-rank", series.rank);
509
+
510
+ seriesGroup.append("path")
511
+ .attr("class", "radar-area")
512
+ .attr("data-group", series.group)
513
+ .attr("data-source-indexes", series.sourceIndexes.join(","))
514
+ .attr("data-rank", series.rank)
515
+ .attr("d", splineLine(points))
516
+ .attr("fill", `url(#${gradientId})`)
517
+ .attr("stroke", "none")
518
+ .attr("pointer-events", "none");
519
+
520
+ seriesGroup.append("path")
521
+ .attr("class", "mark radar-spline-line")
522
+ .attr("data-tag", "mark")
523
+ .attr("data-group", series.group)
524
+ .attr("data-source-indexes", series.sourceIndexes.join(","))
525
+ .attr("data-rank", series.rank)
526
+ .attr("d", splineLine(points))
527
+ .attr("fill", "none")
528
+ .attr("stroke", color)
529
+ .attr("stroke-width", 2.4)
530
+ .attr("stroke-linecap", "round")
531
+ .attr("stroke-linejoin", "round")
532
+ .attr("opacity", seriesIndex < 4 ? 0.96 : 0.86);
533
+
534
+ series.values.forEach((point, pointIndex) => {
535
+ const [x, y] = pointFor(pointIndex, point.value);
536
+ seriesGroup.append("circle")
537
+ .attr("class", "radar-point data-point")
538
+ .attr("data-tag", "mark")
539
+ .attr("data-category", point.category)
540
+ .attr("data-group", series.group)
541
+ .attr("data-value", point.value)
542
+ .attr("data-source-indexes", point.sourceIndexes.join(","))
543
+ .attr("data-rank", series.rank)
544
+ .attr("cx", x)
545
+ .attr("cy", y)
546
+ .attr("r", 3.5)
547
+ .attr("fill", color)
548
+ .attr("stroke", "#ffffff")
549
+ .attr("stroke-width", 1.2);
550
+ });
551
+ });
552
+
553
+ svg.append("text")
554
+ .attr("class", "scale-note axis-title")
555
+ .attr("x", outerPad + 16)
556
+ .attr("y", outerPad + 40)
557
+ .attr("fill", mutedText)
558
+ .style("font-family", fontFamily)
559
+ .style("font-size", "11px")
560
+ .style("font-weight", "600")
561
+ .text(`${readableLabel(valueColumn.label || valueField)} scale: 0-${formatLocalValue(domainMax)}`);
562
+
563
+ const legendColumns = groups.length <= 3 ? groups.length : 3;
564
+ const legendItemWidth = (width - 70) / Math.max(legendColumns, 1);
565
+ const footerY = height - outerPad - footerH + 8;
566
+ svg.append("rect")
567
+ .attr("class", "radar-summary-strip")
568
+ .attr("x", outerPad + 10)
569
+ .attr("y", footerY)
570
+ .attr("width", width - outerPad * 2 - 20)
571
+ .attr("height", footerH - 12)
572
+ .attr("rx", 14)
573
+ .attr("fill", "#ffffff")
574
+ .attr("stroke", panelStroke)
575
+ .attr("stroke-width", 1);
576
+
577
+ const legend = svg.append("g")
578
+ .attr("class", "radar-legend")
579
+ .attr("transform", `translate(${outerPad + 28}, ${footerY + 16})`);
580
+
581
+ groups.forEach((series, index) => {
582
+ const col = index % legendColumns;
583
+ const row = Math.floor(index / legendColumns);
584
+ const item = legend.append("g")
585
+ .attr("class", "legend-item")
586
+ .attr("data-group", series.group)
587
+ .attr("data-source-indexes", series.sourceIndexes.join(","))
588
+ .attr("data-rank", series.rank)
589
+ .attr("transform", `translate(${col * legendItemWidth}, ${row * 23})`);
590
+ const color = groupColor(series.group);
591
+
592
+ item.append("line")
593
+ .attr("x1", 0)
594
+ .attr("y1", 7)
595
+ .attr("x2", 20)
596
+ .attr("y2", 7)
597
+ .attr("stroke", color)
598
+ .attr("stroke-width", 3)
599
+ .attr("stroke-linecap", "round");
600
+ item.append("circle")
601
+ .attr("cx", 10)
602
+ .attr("cy", 7)
603
+ .attr("r", 3)
604
+ .attr("fill", color)
605
+ .attr("stroke", "#ffffff")
606
+ .attr("stroke-width", 1);
607
+ item.append("text")
608
+ .attr("class", "legend-label")
609
+ .attr("x", 28)
610
+ .attr("y", 10)
611
+ .attr("fill", textColor)
612
+ .style("font-family", titleFamily)
613
+ .style("font-size", "11px")
614
+ .style("font-weight", "700")
615
+ .text(shortLabel(series.group, 18));
616
+ item.append("text")
617
+ .attr("class", "legend-value")
618
+ .attr("data-group", series.group)
619
+ .attr("data-value", series.meanValue)
620
+ .attr("x", 28)
621
+ .attr("y", 22)
622
+ .attr("fill", mutedText)
623
+ .style("font-family", fontFamily)
624
+ .style("font-size", "8.5px")
625
+ .text(`avg ${formatLocalValue(series.meanValue)}`);
626
+ });
627
+
628
+ svg.append("text")
629
+ .attr("class", "category-note")
630
+ .attr("x", width / 2)
631
+ .attr("y", height - outerPad - 8)
632
+ .attr("text-anchor", "middle")
633
+ .attr("fill", mutedText)
634
+ .style("font-family", fontFamily)
635
+ .style("font-size", "10px")
636
+ .text(`${readableLabel(categoryColumn.label || categoryField)} order: ${sortMode}`);
637
+
638
+ return svg.node();
639
+ }
modules/chart_engine/template/d3-js/radar/radar_line_chart_01.js ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Radar Line Chart",
5
+ "chart_name": "radar_line_chart_01",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["categorical"], ["numerical"]],
8
+ "required_fields_range": [[3, 12], [0, "inf"]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": ["primary"],
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
+
43
+ // 设置尺寸和边距
44
+ const width = variables.width;
45
+ const height = variables.height;
46
+ const margin = { top: 50, right: 50, bottom: 50, left: 50 };
47
+
48
+ // 创建SVG
49
+ const svg = d3.select(containerSelector)
50
+ .append("svg")
51
+ .attr("width", "100%")
52
+ .attr("height", height)
53
+ .attr("viewBox", `0 0 ${width} ${height}`)
54
+ .attr("style", "max-width: 100%; height: auto;")
55
+ .attr("xmlns", "http://www.w3.org/2000/svg")
56
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
57
+
58
+ // 创建图表区域
59
+ const chartWidth = width - margin.left - margin.right;
60
+ const chartHeight = height - margin.top - margin.bottom;
61
+ const radius = Math.min(chartWidth, chartHeight) / 2;
62
+
63
+ const g = svg.append("g")
64
+ .attr("transform", `translate(${width/2}, ${height/2})`);
65
+
66
+ // 获取唯一类别
67
+ const categories = [...new Set(chartData.map(d => d[categoryField]))];
68
+
69
+ // 获取主色调
70
+ const mainColor = colorResolver.other("primary", { fallback: "#1f77b4" }).value;
71
+
72
+ // 创建角度比例尺
73
+ const angleScale = d3.scalePoint()
74
+ .domain(categories)
75
+ .range([0, 2 * Math.PI - (2 * Math.PI / categories.length)]);
76
+
77
+ // 创建半径比例尺
78
+ const allValues = chartData.map(d => +d[valueField]);
79
+ const minValue = Math.min(0, d3.min(allValues));
80
+ const maxValue = d3.max(allValues);
81
+
82
+ const radiusScale = d3.scaleLinear()
83
+ .domain([minValue, maxValue * 1.2])
84
+ .range([0, radius])
85
+ .nice();
86
+
87
+ // 绘制背景圆环
88
+ const ticks = radiusScale.ticks(5);
89
+
90
+ // 绘制同心圆
91
+ g.selectAll(".circle-axis")
92
+ .data(ticks)
93
+ .enter()
94
+ .append("circle")
95
+ .attr("class", "circle-axis")
96
+ .attr("cx", 0)
97
+ .attr("cy", 0)
98
+ .attr("r", d => radiusScale(d))
99
+ .attr("fill", "none")
100
+ .attr("stroke", "#bbb")
101
+ .attr("stroke-width", 1)
102
+ .attr("stroke-dasharray", "4,4");
103
+
104
+ // 绘制径向轴线
105
+ g.selectAll(".axis-line")
106
+ .data(categories)
107
+ .enter()
108
+ .append("line")
109
+ .attr("class", "axis-line")
110
+ .attr("x1", 0)
111
+ .attr("y1", 0)
112
+ .attr("x2", (d, i) => radius * Math.cos(angleScale(d) - Math.PI/2))
113
+ .attr("y2", (d, i) => radius * Math.sin(angleScale(d) - Math.PI/2))
114
+ .attr("stroke", "#bbb")
115
+ .attr("stroke-width", 1);
116
+
117
+ // 添加类别标签
118
+ g.selectAll(".category-label")
119
+ .data(categories)
120
+ .enter()
121
+ .append("text")
122
+ .attr("class", "category-label")
123
+ .attr("x", d => (radius + 20) * Math.cos(angleScale(d) - Math.PI/2))
124
+ .attr("y", d => (radius + 20) * Math.sin(angleScale(d) - Math.PI/2))
125
+ .attr("text-anchor", d => {
126
+ const angle = angleScale(d);
127
+ if (Math.abs(angle) < 0.1 || Math.abs(angle - Math.PI) < 0.1) {
128
+ return "middle";
129
+ }
130
+ return angle > Math.PI ? "end" : "start";
131
+ })
132
+ .attr("dominant-baseline", d => {
133
+ const angle = angleScale(d);
134
+ if (Math.abs(angle) < 0.1 || Math.abs(angle - Math.PI) < 0.1) {
135
+ return "middle";
136
+ }
137
+ return angle < Math.PI ? "hanging" : "auto";
138
+ })
139
+ .attr("fill", "#333")
140
+ .attr("font-size", "16px")
141
+ .attr("font-weight", "bold")
142
+ .text(d => d);
143
+
144
+ // 添加刻度值标签
145
+ g.selectAll(".tick-label")
146
+ .data(ticks)
147
+ .enter()
148
+ .append("text")
149
+ .attr("class", "tick-label")
150
+ .attr("x", 5)
151
+ .attr("y", d => -radiusScale(d))
152
+ .attr("text-anchor", "start")
153
+ .attr("font-size", "14px")
154
+ .attr("fill", "#666")
155
+ .text(d => d);
156
+
157
+ // 创建折线生成器
158
+ const lineGenerator = () => {
159
+ const points = categories.map(cat => {
160
+ const point = chartData.find(item => item[categoryField] === cat);
161
+ if (point) {
162
+ const angle = angleScale(cat) - Math.PI/2;
163
+ const distance = radiusScale(+point[valueField]);
164
+ return [
165
+ distance * Math.cos(angle),
166
+ distance * Math.sin(angle)
167
+ ];
168
+ }
169
+ return [0, 0]; // 如果没有数据,默认为中心点
170
+ });
171
+
172
+ // 使用折线连接点
173
+ return d3.line()(points) + "Z"; // 闭合路径
174
+ };
175
+
176
+ // 绘制雷达折线
177
+ g.append("path")
178
+ .attr("class", "radar-line")
179
+ .attr("d", lineGenerator())
180
+ .attr("fill", mainColor)
181
+ .attr("fill-opacity", 0.2)
182
+ .attr("stroke", mainColor)
183
+ .attr("stroke-width", 6)
184
+ .attr("stroke-linejoin", "miter"); // 使用尖角连接,强调折线效果
185
+
186
+ // 绘制数据点
187
+ categories.forEach((cat, index) => {
188
+ const point = chartData.find(item => item[categoryField] === cat);
189
+ if (point) {
190
+ const angle = angleScale(cat) - Math.PI/2;
191
+ const distance = radiusScale(+point[valueField]);
192
+
193
+ g.append("circle")
194
+ .attr("class", "radar-point")
195
+ .attr("cx", distance * Math.cos(angle))
196
+ .attr("cy", distance * Math.sin(angle))
197
+ .attr("r", 6)
198
+ .attr("fill", mainColor)
199
+ .attr("stroke", "#fff")
200
+ .attr("stroke-width", 3);
201
+
202
+ // 添加数值标签背景
203
+ const labelText = chartUtils.format.number(point[valueField]).text;
204
+ const textWidth = chartUtils.text.measure(null, labelText, { fontSize: 14 }).width;
205
+
206
+ const textX = index === 0 ? (distance + 30) * Math.cos(angle) - 20 : (distance + 30) * Math.cos(angle);
207
+ const textY = index === 0 ? (distance + 15) * Math.sin(angle) : (distance + 30) * Math.sin(angle);
208
+
209
+ g.append("rect")
210
+ .attr("class", "value-label-bg")
211
+ .attr("x", textX - textWidth/2 - 4)
212
+ .attr("y", textY - 8)
213
+ .attr("width", textWidth + 8)
214
+ .attr("height", 16)
215
+ .attr("fill", colorResolver.other("primary").value)
216
+ .attr("rx", 3);
217
+
218
+ // 添加数值标签
219
+ g.append("text")
220
+ .attr("class", "value-label")
221
+ .attr("x", textX)
222
+ .attr("y", textY)
223
+ .attr("text-anchor", "middle")
224
+ .attr("dominant-baseline", "middle")
225
+ .attr("font-size", "14px")
226
+ .attr("fill", "#fff")
227
+ .text(labelText);
228
+ }
229
+ });
230
+
231
+ return svg.node();
232
+ }
modules/chart_engine/template/d3-js/radar/radar_line_chart_03.js ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Radar Line Chart",
5
+ "chart_name": "radar_line_chart_03",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["categorical"], ["numerical"]],
8
+ "required_fields_range": [[3, 12], [0, "inf"]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": ["primary"],
13
+ "supported_effects": [],
14
+ "min_height": 400,
15
+ "min_width": 400,
16
+ "background": "dark",
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_dark || {};
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
+
43
+ // 设置尺寸和边距
44
+ const width = variables.width;
45
+ const height = variables.height;
46
+ const margin = { top: 50, right: 50, bottom: 50, left: 50 };
47
+
48
+ // 创建SVG
49
+ const svg = d3.select(containerSelector)
50
+ .append("svg")
51
+ .attr("width", "100%")
52
+ .attr("height", height)
53
+ .attr("viewBox", `0 0 ${width} ${height}`)
54
+ .attr("style", "max-width: 100%; height: auto;")
55
+ .attr("xmlns", "http://www.w3.org/2000/svg")
56
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
57
+
58
+ // 创建图表区域
59
+ const chartWidth = width - margin.left - margin.right;
60
+ const chartHeight = height - margin.top - margin.bottom;
61
+ const radius = Math.min(chartWidth, chartHeight) / 2;
62
+
63
+ const g = svg.append("g")
64
+ .attr("transform", `translate(${width/2}, ${height/2})`);
65
+
66
+ // 获取唯一类别
67
+ const categories = [...new Set(chartData.map(d => d[categoryField]))];
68
+
69
+ // 获取主色调
70
+ const mainColor = colorResolver.other("primary", { fallback: "#1f77b4" }).value;
71
+
72
+ // 创建角度比例尺
73
+ const angleScale = d3.scalePoint()
74
+ .domain(categories)
75
+ .range([0, 2 * Math.PI - (2 * Math.PI / categories.length)]);
76
+
77
+ // 创建半径比例尺
78
+ const allValues = chartData.map(d => +d[valueField]);
79
+ const minValue = Math.min(0, d3.min(allValues));
80
+ const maxValue = d3.max(allValues);
81
+
82
+ const radiusScale = d3.scaleLinear()
83
+ .domain([minValue, maxValue * 1.2])
84
+ .range([0, radius])
85
+ .nice();
86
+
87
+ // 绘制背景圆环
88
+ const ticks = radiusScale.ticks(5);
89
+
90
+ // 绘制同心圆
91
+ g.selectAll(".circle-axis")
92
+ .data(ticks)
93
+ .enter()
94
+ .append("circle")
95
+ .attr("class", "circle-axis")
96
+ .attr("cx", 0)
97
+ .attr("cy", 0)
98
+ .attr("r", d => radiusScale(d))
99
+ .attr("fill", "none")
100
+ .attr("stroke", "#bbb")
101
+ .attr("stroke-width", 1)
102
+ .attr("stroke-dasharray", "4,4");
103
+
104
+ // 绘制径向轴线
105
+ g.selectAll(".axis-line")
106
+ .data(categories)
107
+ .enter()
108
+ .append("line")
109
+ .attr("class", "axis-line")
110
+ .attr("x1", 0)
111
+ .attr("y1", 0)
112
+ .attr("x2", (d, i) => radius * Math.cos(angleScale(d) - Math.PI/2))
113
+ .attr("y2", (d, i) => radius * Math.sin(angleScale(d) - Math.PI/2))
114
+ .attr("stroke", "#bbb")
115
+ .attr("stroke-width", 1);
116
+
117
+ // 添加类别标签
118
+ g.selectAll(".category-label")
119
+ .data(categories)
120
+ .enter()
121
+ .append("text")
122
+ .attr("class", "category-label")
123
+ .attr("x", d => (radius + 20) * Math.cos(angleScale(d) - Math.PI/2))
124
+ .attr("y", d => (radius + 20) * Math.sin(angleScale(d) - Math.PI/2))
125
+ .attr("text-anchor", d => {
126
+ const angle = angleScale(d);
127
+ if (Math.abs(angle) < 0.1 || Math.abs(angle - Math.PI) < 0.1) {
128
+ return "middle";
129
+ }
130
+ return angle > Math.PI ? "end" : "start";
131
+ })
132
+ .attr("dominant-baseline", d => {
133
+ const angle = angleScale(d);
134
+ if (Math.abs(angle) < 0.1 || Math.abs(angle - Math.PI) < 0.1) {
135
+ return "middle";
136
+ }
137
+ return angle < Math.PI ? "hanging" : "auto";
138
+ })
139
+ .attr("fill", "#fff")
140
+ .attr("font-size", "16px")
141
+ .attr("font-weight", "bold")
142
+ .text(d => d);
143
+
144
+ // 添加刻度值标签
145
+ g.selectAll(".tick-label")
146
+ .data(ticks)
147
+ .enter()
148
+ .append("text")
149
+ .attr("class", "tick-label")
150
+ .attr("x", 5)
151
+ .attr("y", d => -radiusScale(d))
152
+ .attr("text-anchor", "start")
153
+ .attr("font-size", "14px")
154
+ .attr("fill", "#ddd")
155
+ .text(d => d);
156
+
157
+ // 创建折线生成器
158
+ const lineGenerator = () => {
159
+ const points = categories.map(cat => {
160
+ const point = chartData.find(item => item[categoryField] === cat);
161
+ if (point) {
162
+ const angle = angleScale(cat) - Math.PI/2;
163
+ const distance = radiusScale(+point[valueField]);
164
+ return [
165
+ distance * Math.cos(angle),
166
+ distance * Math.sin(angle)
167
+ ];
168
+ }
169
+ return [0, 0]; // 如果没有数据,默认为中心点
170
+ });
171
+
172
+ // 使用折线连接点
173
+ return d3.line()(points) + "Z"; // 闭合路径
174
+ };
175
+
176
+ // 绘制雷达折线
177
+ g.append("path")
178
+ .attr("class", "radar-line")
179
+ .attr("d", lineGenerator())
180
+ .attr("fill", mainColor)
181
+ .attr("fill-opacity", 0.2)
182
+ .attr("stroke", mainColor)
183
+ .attr("stroke-width", 6)
184
+ .attr("stroke-linejoin", "miter"); // 使用尖角连接,强调折线效果
185
+
186
+ // 绘制数据点
187
+ categories.forEach((cat, index) => {
188
+ const point = chartData.find(item => item[categoryField] === cat);
189
+ if (point) {
190
+ const angle = angleScale(cat) - Math.PI/2;
191
+ const distance = radiusScale(+point[valueField]);
192
+
193
+ g.append("circle")
194
+ .attr("class", "radar-point")
195
+ .attr("cx", distance * Math.cos(angle))
196
+ .attr("cy", distance * Math.sin(angle))
197
+ .attr("r", 6)
198
+ .attr("fill", mainColor)
199
+ .attr("stroke", "#fff")
200
+ .attr("stroke-width", 3);
201
+
202
+ // 添加数值标签背景
203
+ const labelText = chartUtils.format.number(point[valueField]).text;
204
+ const textWidth = chartUtils.text.measure(null, labelText, { fontSize: 14 }).width;
205
+
206
+ const textX = index === 0 ? (distance + 30) * Math.cos(angle) - 20 : (distance + 30) * Math.cos(angle);
207
+ const textY = index === 0 ? (distance + 15) * Math.sin(angle) : (distance + 30) * Math.sin(angle);
208
+
209
+ g.append("rect")
210
+ .attr("class", "value-label-bg")
211
+ .attr("x", textX - textWidth/2 - 4)
212
+ .attr("y", textY - 8)
213
+ .attr("width", textWidth + 8)
214
+ .attr("height", 16)
215
+ .attr("fill", colorResolver.other("primary").value)
216
+ .attr("rx", 3);
217
+
218
+ // 添加数值标签
219
+ g.append("text")
220
+ .attr("class", "value-label")
221
+ .attr("x", textX)
222
+ .attr("y", textY)
223
+ .attr("text-anchor", "middle")
224
+ .attr("dominant-baseline", "middle")
225
+ .attr("font-size", "14px")
226
+ .attr("fill", "#fff")
227
+ .text(labelText);
228
+ }
229
+ });
230
+
231
+ return svg.node();
232
+ }
modules/chart_engine/template/d3-js/radar/radar_spline_chart_01.js ADDED
@@ -0,0 +1,516 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Radar Spline Chart",
5
+ "chart_name": "radar_spline_chart_01",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["categorical"], ["numerical"]],
8
+ "required_fields_range": [[3, 12], [0, "inf"]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": ["primary"],
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 categoryField = categoryColumn.name;
61
+ const valueField = valueColumn.name;
62
+ const valueUnit = (valueColumn.unit || "").trim();
63
+
64
+ const width = Number(variables.width) || 600;
65
+ const height = Number(variables.height) || 600;
66
+ const primaryColor = sourceColors.other?.primary || sourceColors.primary || "#2563eb";
67
+ const textColor = sourceColors.text_color || "#1f2937";
68
+ const mutedText = "#64748b";
69
+ const gridColor = "rgba(100, 116, 139, 0.28)";
70
+ const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
71
+ const annotationFamily = typography.annotation?.font_family || fontFamily;
72
+ const labelWeight = typography.label?.font_weight || "600";
73
+ const annotationWeight = typography.annotation?.font_weight || "600";
74
+ const baseLabelSize = parseFloat(typography.label?.font_size) || 12;
75
+ const baseValueSize = parseFloat(typography.annotation?.font_size) || 11;
76
+
77
+ const svg = d3.select(containerSelector)
78
+ .append("svg")
79
+ .attr("width", "100%")
80
+ .attr("height", height)
81
+ .attr("viewBox", `0 0 ${width} ${height}`)
82
+ .attr("style", "max-width: 100%; height: auto;")
83
+ .attr("xmlns", "http://www.w3.org/2000/svg")
84
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
85
+ .attr("class", "radar-spline-chart-root");
86
+
87
+ if (!categoryField || !valueField) {
88
+ svg.append("text")
89
+ .attr("x", width / 2)
90
+ .attr("y", height / 2)
91
+ .attr("text-anchor", "middle")
92
+ .attr("fill", textColor)
93
+ .style("font-family", fontFamily)
94
+ .style("font-size", "16px")
95
+ .text("Missing radar fields");
96
+ return svg.node();
97
+ }
98
+
99
+ function readableLabel(value) {
100
+ return String(value ?? "")
101
+ .replace(/_/g, " ")
102
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
103
+ .replace(/\s+/g, " ")
104
+ .trim();
105
+ }
106
+
107
+ function compactNumber(value) {
108
+ const numeric = Number(value);
109
+ if (!Number.isFinite(numeric)) return String(value ?? "");
110
+ const abs = Math.abs(numeric);
111
+ if (abs >= 1000000000) return `${d3.format(".3~g")(numeric / 1000000000)}B`;
112
+ if (abs >= 1000000) return `${d3.format(".3~g")(numeric / 1000000)}M`;
113
+ if (abs >= 1000) return `${d3.format(".3~g")(numeric / 1000)}K`;
114
+ return d3.format(",.4~g")(numeric);
115
+ }
116
+
117
+ function formatLocalValue(value, includeUnit = true) {
118
+ const formatted = compactNumber(value);
119
+ if (!includeUnit || !valueUnit || valueUnit === "none") return formatted;
120
+ if (valueUnit === "%") return `${formatted}%`;
121
+ const currencyWithSuffix = valueUnit.match(/^([£$€¥])\s*([A-Za-z]+)$/);
122
+ if (currencyWithSuffix) return `${currencyWithSuffix[1]}${formatted}${currencyWithSuffix[2]}`;
123
+ if (/^[£$€¥]$/.test(valueUnit)) return `${valueUnit}${formatted}`;
124
+ return `${formatted} ${valueUnit}`;
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
+
155
+ const sizeRanks = [
156
+ [/very\s+small|micro|tiny/, 1],
157
+ [/town|village/, 1.5],
158
+ [/small\s+city|small/, 2],
159
+ [/mid[-\s]?sized|medium/, 3],
160
+ [/regional\s+hub/, 4],
161
+ [/very\s+large/, 6],
162
+ [/large\b/, 5],
163
+ [/metro|metropolis|mega/, 7]
164
+ ];
165
+ for (const [pattern, rank] of sizeRanks) {
166
+ if (pattern.test(text)) return rank;
167
+ }
168
+ return NaN;
169
+ }
170
+
171
+ const rows = sourceData
172
+ .map((d, index) => ({
173
+ raw: d,
174
+ index,
175
+ label: readableLabel(d[categoryField]),
176
+ rawLabel: d[categoryField],
177
+ value: Number(d[valueField])
178
+ }))
179
+ .filter(d => d.label && Number.isFinite(d.value) && d.value >= 0);
180
+
181
+ if (rows.length < 3) {
182
+ svg.append("text")
183
+ .attr("x", width / 2)
184
+ .attr("y", height / 2)
185
+ .attr("text-anchor", "middle")
186
+ .attr("fill", textColor)
187
+ .style("font-family", fontFamily)
188
+ .style("font-size", "16px")
189
+ .text("Not enough data for radar spline");
190
+ return svg.node();
191
+ }
192
+
193
+ const temporalRanks = rows.map(d => parseTemporalRank(d.rawLabel));
194
+ const ordinalRanks = rows.map(d => parseOrdinalRank(d.rawLabel));
195
+ const allTemporal = temporalRanks.every(Number.isFinite);
196
+ const allOrdinal = !allTemporal && ordinalRanks.every(Number.isFinite);
197
+ const sortMode = allTemporal ? "chronological" : (allOrdinal ? "ordered category" : "value ranked");
198
+ const sortedData = [...rows].sort((a, b) => {
199
+ if (allTemporal) return parseTemporalRank(a.rawLabel) - parseTemporalRank(b.rawLabel);
200
+ if (allOrdinal) return parseOrdinalRank(a.rawLabel) - parseOrdinalRank(b.rawLabel);
201
+ return d3.descending(a.value, b.value) || d3.ascending(a.label, b.label);
202
+ }).map((d, index) => ({ ...d, rank: index + 1 }));
203
+
204
+ const measureGroup = svg.append("g").attr("visibility", "hidden");
205
+ function measureLabelText(text, fontSize, family = fontFamily, weight = labelWeight) {
206
+ const node = measureGroup.append("text")
207
+ .style("font-family", family)
208
+ .style("font-size", `${fontSize}px`)
209
+ .style("font-weight", weight)
210
+ .text(String(text ?? ""))
211
+ .node();
212
+ const measured = node ? node.textContent.length * 7 : 0;
213
+ measureGroup.selectAll("text").remove();
214
+ return Math.max(measured || 0, String(text ?? "").length * fontSize * 0.52);
215
+ }
216
+
217
+ function truncateText(text, maxWidth, fontSize, family = fontFamily, weight = labelWeight) {
218
+ const full = String(text ?? "");
219
+ if (measureLabelText(full, fontSize, family, weight) <= maxWidth) return full;
220
+ if (full.length <= 3) return full;
221
+ let lo = 1;
222
+ let hi = full.length;
223
+ let best = full.slice(0, 1);
224
+ while (lo <= hi) {
225
+ const mid = Math.floor((lo + hi) / 2);
226
+ const candidate = `${full.slice(0, mid).trim()}...`;
227
+ if (measureLabelText(candidate, fontSize, family, weight) <= maxWidth) {
228
+ best = candidate;
229
+ lo = mid + 1;
230
+ } else {
231
+ hi = mid - 1;
232
+ }
233
+ }
234
+ return best;
235
+ }
236
+
237
+ function rgba(color, opacity) {
238
+ const c = d3.rgb(color);
239
+ return `rgba(${c.r}, ${c.g}, ${c.b}, ${opacity})`;
240
+ }
241
+
242
+ const n = sortedData.length;
243
+ const legendWidth = Math.max(176, Math.min(222, width * 0.36));
244
+ const legendX = width - legendWidth - 18;
245
+ const legendY = 62;
246
+ const legendBottom = height - 54;
247
+ const legendHeight = legendBottom - legendY;
248
+ const rowGap = Math.max(22, Math.min(34, legendHeight / Math.max(n, 1)));
249
+ const keyFontSize = Math.max(8.5, Math.min(baseLabelSize, rowGap * 0.42));
250
+ const keyValueSize = Math.max(8, Math.min(baseValueSize, rowGap * 0.38));
251
+ const valueColumnWidth = Math.max(
252
+ 48,
253
+ ...sortedData.map(d => measureLabelText(formatLocalValue(d.value, false), keyValueSize, annotationFamily, annotationWeight))
254
+ );
255
+ const labelMaxWidth = Math.max(78, legendWidth - valueColumnWidth - 38);
256
+ const plotRight = legendX - 24;
257
+ const centerX = Math.max(145, plotRight / 2);
258
+ const centerY = Math.max(170, Math.min(height - 150, height * 0.49));
259
+ const radius = Math.max(92, Math.min(centerX - 44, plotRight - centerX - 16, centerY - 48, height - centerY - 78));
260
+ const axisNumberRadius = radius + 19;
261
+ const pointRadius = Math.max(4.2, Math.min(6.5, radius / 28));
262
+
263
+ const maxValue = Math.max(1, d3.max(sortedData, d => d.value) || 1);
264
+ const radiusScale = d3.scaleLinear()
265
+ .domain([0, maxValue])
266
+ .nice(4)
267
+ .range([0, radius]);
268
+ const domainMax = radiusScale.domain()[1];
269
+ const ticks = radiusScale.ticks(4).filter(tick => tick > 0);
270
+ const angleForIndex = index => (index / n) * Math.PI * 2 - Math.PI / 2;
271
+
272
+ const plot = svg.append("g")
273
+ .attr("class", "radar-spline-plot")
274
+ .attr("transform", `translate(${centerX}, ${centerY})`);
275
+
276
+ ticks.forEach(tick => {
277
+ plot.append("circle")
278
+ .attr("class", "radar-grid-ring gridline")
279
+ .attr("r", radiusScale(tick))
280
+ .attr("fill", "none")
281
+ .attr("stroke", gridColor)
282
+ .attr("stroke-width", 1)
283
+ .attr("stroke-dasharray", "3,4");
284
+
285
+ plot.append("text")
286
+ .attr("class", "radar-grid-label axis-tick")
287
+ .attr("x", 7)
288
+ .attr("y", -radiusScale(tick))
289
+ .attr("text-anchor", "start")
290
+ .attr("dominant-baseline", "middle")
291
+ .attr("fill", mutedText)
292
+ .style("font-family", annotationFamily)
293
+ .style("font-size", `${Math.max(8, keyValueSize * 0.9)}px`)
294
+ .style("font-weight", annotationWeight)
295
+ .text(formatLocalValue(tick, false));
296
+ });
297
+
298
+ sortedData.forEach((d, index) => {
299
+ const angle = angleForIndex(index);
300
+ const axisX = radius * Math.cos(angle);
301
+ const axisY = radius * Math.sin(angle);
302
+ const labelX = axisNumberRadius * Math.cos(angle);
303
+ const labelY = axisNumberRadius * Math.sin(angle);
304
+
305
+ plot.append("line")
306
+ .attr("class", "radar-axis-line")
307
+ .attr("x1", 0)
308
+ .attr("y1", 0)
309
+ .attr("x2", axisX)
310
+ .attr("y2", axisY)
311
+ .attr("stroke", gridColor)
312
+ .attr("stroke-width", 1);
313
+
314
+ plot.append("circle")
315
+ .attr("class", "radar-axis-index-dot")
316
+ .attr("cx", labelX)
317
+ .attr("cy", labelY)
318
+ .attr("r", 10)
319
+ .attr("fill", "#ffffff")
320
+ .attr("stroke", rgba(primaryColor, 0.45))
321
+ .attr("stroke-width", 1.2);
322
+
323
+ plot.append("text")
324
+ .attr("class", "radar-axis-index category-label")
325
+ .attr("x", labelX)
326
+ .attr("y", labelY)
327
+ .attr("text-anchor", "middle")
328
+ .attr("dominant-baseline", "central")
329
+ .attr("fill", textColor)
330
+ .style("font-family", fontFamily)
331
+ .style("font-size", `${Math.max(8, keyFontSize * 0.88)}px`)
332
+ .style("font-weight", "700")
333
+ .text(d.rank);
334
+ });
335
+
336
+ const points = sortedData.map((d, index) => {
337
+ const angle = angleForIndex(index);
338
+ const distance = radiusScale(d.value);
339
+ return {
340
+ ...d,
341
+ angle,
342
+ x: distance * Math.cos(angle),
343
+ y: distance * Math.sin(angle)
344
+ };
345
+ });
346
+ const spline = d3.line()
347
+ .x(d => d.x)
348
+ .y(d => d.y)
349
+ .curve(d3.curveCatmullRomClosed.alpha(0.55));
350
+
351
+ plot.append("path")
352
+ .attr("class", "radar-spline-area")
353
+ .attr("d", spline(points))
354
+ .attr("fill", primaryColor)
355
+ .attr("fill-opacity", 0.12)
356
+ .attr("stroke", "none");
357
+
358
+ plot.append("path")
359
+ .attr("class", "mark radar-spline-line")
360
+ .attr("data-tag", "mark")
361
+ .attr("d", spline(points))
362
+ .attr("fill", "none")
363
+ .attr("stroke", primaryColor)
364
+ .attr("stroke-width", Math.max(3, Math.min(5, radius / 40)))
365
+ .attr("stroke-linecap", "round")
366
+ .attr("stroke-linejoin", "round");
367
+
368
+ const pointGroups = plot.selectAll(".radar-point-group")
369
+ .data(points)
370
+ .enter()
371
+ .append("g")
372
+ .attr("class", "radar-point-group data-point")
373
+ .attr("transform", d => `translate(${d.x}, ${d.y})`);
374
+
375
+ pointGroups.append("circle")
376
+ .attr("class", "mark radar-point")
377
+ .attr("data-tag", "mark")
378
+ .attr("r", pointRadius)
379
+ .attr("fill", primaryColor)
380
+ .attr("stroke", "#ffffff")
381
+ .attr("stroke-width", 2.4);
382
+
383
+ pointGroups.append("text")
384
+ .attr("class", "radar-point-label")
385
+ .attr("x", 0)
386
+ .attr("y", 0)
387
+ .attr("text-anchor", "middle")
388
+ .attr("dominant-baseline", "central")
389
+ .attr("fill", "#ffffff")
390
+ .style("font-family", fontFamily)
391
+ .style("font-size", `${Math.max(6.5, pointRadius * 1.35)}px`)
392
+ .style("font-weight", "700")
393
+ .text(d => d.rank);
394
+
395
+ plot.append("circle")
396
+ .attr("class", "radar-origin")
397
+ .attr("r", 2.4)
398
+ .attr("fill", mutedText)
399
+ .attr("opacity", 0.75);
400
+
401
+ svg.append("text")
402
+ .attr("class", "axis-title")
403
+ .attr("x", centerX)
404
+ .attr("y", Math.max(20, centerY - radius - 36))
405
+ .attr("text-anchor", "middle")
406
+ .attr("fill", textColor)
407
+ .style("font-family", fontFamily)
408
+ .style("font-size", `${Math.max(11, baseLabelSize)}px`)
409
+ .style("font-weight", "700")
410
+ .text(`${readableLabel(valueField)} radial scale`);
411
+
412
+ svg.append("text")
413
+ .attr("class", "scale-note")
414
+ .attr("x", centerX)
415
+ .attr("y", Math.min(height - 24, centerY + radius + 44))
416
+ .attr("text-anchor", "middle")
417
+ .attr("fill", mutedText)
418
+ .style("font-family", annotationFamily)
419
+ .style("font-size", `${Math.max(8.5, keyValueSize)}px`)
420
+ .style("font-weight", annotationWeight)
421
+ .text(`0 to ${formatLocalValue(domainMax)}; categories sorted by ${sortMode}`);
422
+
423
+ const key = svg.append("g")
424
+ .attr("class", "radar-spline-key")
425
+ .attr("transform", `translate(${legendX}, ${legendY})`);
426
+
427
+ key.append("text")
428
+ .attr("class", "key-title")
429
+ .attr("x", 0)
430
+ .attr("y", -18)
431
+ .attr("fill", textColor)
432
+ .style("font-family", fontFamily)
433
+ .style("font-size", `${Math.max(11, baseLabelSize)}px`)
434
+ .style("font-weight", "700")
435
+ .text("Axis key");
436
+
437
+ key.append("line")
438
+ .attr("class", "key-rule")
439
+ .attr("x1", 0)
440
+ .attr("x2", legendWidth)
441
+ .attr("y1", -7)
442
+ .attr("y2", -7)
443
+ .attr("stroke", rgba(textColor, 0.18))
444
+ .attr("stroke-width", 1);
445
+
446
+ const rowsGroup = key.selectAll(".legend-row")
447
+ .data(sortedData)
448
+ .enter()
449
+ .append("g")
450
+ .attr("class", "legend-row")
451
+ .attr("transform", (d, i) => `translate(0, ${i * rowGap})`);
452
+
453
+ rowsGroup.append("circle")
454
+ .attr("class", "legend-index-dot")
455
+ .attr("cx", 9)
456
+ .attr("cy", 0)
457
+ .attr("r", 8.5)
458
+ .attr("fill", "#ffffff")
459
+ .attr("stroke", rgba(primaryColor, 0.5))
460
+ .attr("stroke-width", 1.1);
461
+
462
+ rowsGroup.append("text")
463
+ .attr("class", "legend-index")
464
+ .attr("x", 9)
465
+ .attr("y", 0)
466
+ .attr("text-anchor", "middle")
467
+ .attr("dominant-baseline", "central")
468
+ .attr("fill", textColor)
469
+ .style("font-family", fontFamily)
470
+ .style("font-size", `${Math.max(8, keyFontSize * 0.82)}px`)
471
+ .style("font-weight", "700")
472
+ .text(d => d.rank);
473
+
474
+ rowsGroup.append("text")
475
+ .attr("class", "category-label")
476
+ .attr("x", 24)
477
+ .attr("y", -3)
478
+ .attr("fill", textColor)
479
+ .style("font-family", fontFamily)
480
+ .style("font-size", `${keyFontSize}px`)
481
+ .style("font-weight", labelWeight)
482
+ .text(d => truncateText(d.label, labelMaxWidth, keyFontSize, fontFamily, labelWeight));
483
+
484
+ rowsGroup.append("text")
485
+ .attr("class", "value-label")
486
+ .attr("x", legendWidth)
487
+ .attr("y", -3)
488
+ .attr("text-anchor", "end")
489
+ .attr("fill", mutedText)
490
+ .style("font-family", annotationFamily)
491
+ .style("font-size", `${keyValueSize}px`)
492
+ .style("font-weight", annotationWeight)
493
+ .text(d => formatLocalValue(d.value, false));
494
+
495
+ rowsGroup.append("line")
496
+ .attr("class", "legend-row-rule")
497
+ .attr("x1", 24)
498
+ .attr("x2", legendWidth)
499
+ .attr("y1", rowGap * 0.43)
500
+ .attr("y2", rowGap * 0.43)
501
+ .attr("stroke", rgba(textColor, 0.1))
502
+ .attr("stroke-width", 1);
503
+
504
+ svg.append("text")
505
+ .attr("class", "category-note")
506
+ .attr("x", legendX)
507
+ .attr("y", height - 22)
508
+ .attr("fill", mutedText)
509
+ .style("font-family", annotationFamily)
510
+ .style("font-size", `${Math.max(8, keyValueSize * 0.9)}px`)
511
+ .style("font-weight", annotationWeight)
512
+ .text("Numbers on the spline map to categories in the key.");
513
+
514
+ measureGroup.remove();
515
+ return svg.node();
516
+ }
modules/chart_engine/template/d3-js/radar/radar_spline_chart_03.js ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Radar Spline Chart",
5
+ "chart_name": "radar_spline_chart_03",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["categorical"], ["numerical"]],
8
+ "required_fields_range": [[3, 12], [0, "inf"]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": [],
13
+ "supported_effects": [],
14
+ "min_height": 440,
15
+ "min_width": 440,
16
+ "background": "no",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "no",
20
+ "has_y_axis": "yes"
21
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+ function makeChart(containerSelector, dataJSON) {
26
+ const jsonData = dataJSON || {};
27
+ const dataColumns = chartUtils.schema.columns(jsonData);
28
+ const colorResolver = chartUtils.color.resolver(jsonData);
29
+ const chartUtilsFormatSample = chartUtils.format.autoText;
30
+ const chartUtilsTextSample = chartUtils.text.estimate;
31
+ const chartUtilsStandard = {
32
+ schema: chartUtils.schema,
33
+ format: chartUtils.format,
34
+ text: chartUtils.text,
35
+ color: chartUtils.color,
36
+ legendLayout: chartUtils.legend.layout,
37
+ legendDraw: chartUtils.legend.draw,
38
+ random: chartUtils.random.generator(jsonData, "dev-wz-style")
39
+ };
40
+ const standardChannels = chartUtils.schema.channels(jsonData, {
41
+ x: { fallbackIndex: 0 },
42
+ y: { fallbackIndex: 1 },
43
+ y2: { fallbackIndex: 2 },
44
+ y3: { fallbackIndex: 3 },
45
+ size: { fallbackIndex: 2 },
46
+ group: { fallbackIndex: 2 },
47
+ group2: { fallbackIndex: 3 },
48
+ group3: { fallbackIndex: 4 }
49
+ });
50
+ const dataBlock = jsonData.data || {};
51
+ const sourceRows = dataBlock.data || [];
52
+ const columns = dataBlock.columns || [];
53
+ const variables = jsonData.variables || {};
54
+ const typography = jsonData.typography || {};
55
+ const sourceColors = jsonData.colors || {};
56
+
57
+ d3.select(containerSelector).html("");
58
+
59
+ const categoryColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
60
+ const valueColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
61
+ const categoryField = categoryColumn.name;
62
+ const valueField = valueColumn.name;
63
+ const valueUnit = valueColumn.unit === "none" ? "" : (valueColumn.unit || "");
64
+
65
+ const width = Math.max(440, Number(variables.width) || 560);
66
+ const height = Math.max(440, Number(variables.height) || 560);
67
+ const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
68
+ const annotationFamily = typography.annotation?.font_family || fontFamily;
69
+ const textColor = sourceColors.text_color || "#1f2937";
70
+ const mutedText = "#64748b";
71
+ const gridColor = "rgba(100, 116, 139, 0.25)";
72
+ const labelSize = Math.max(8.5, Math.min(11, parseFloat(typography.label?.font_size) || 10.5));
73
+ const valueSize = Math.max(8, Math.min(10, parseFloat(typography.annotation?.font_size) || 9.5));
74
+ const labelWeight = typography.label?.font_weight || "700";
75
+ const valueWeight = typography.annotation?.font_weight || "650";
76
+
77
+ const svg = d3.select(containerSelector)
78
+ .append("svg")
79
+ .attr("width", "100%")
80
+ .attr("height", height)
81
+ .attr("viewBox", `0 0 ${width} ${height}`)
82
+ .attr("style", "max-width: 100%; height: auto;")
83
+ .attr("xmlns", "http://www.w3.org/2000/svg")
84
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
85
+ .attr("class", "radar-spline-chart");
86
+
87
+ function message(text) {
88
+ svg.append("text")
89
+ .attr("x", width / 2)
90
+ .attr("y", height / 2)
91
+ .attr("text-anchor", "middle")
92
+ .attr("fill", textColor)
93
+ .style("font-family", fontFamily)
94
+ .style("font-size", "16px")
95
+ .text(text);
96
+ return svg.node();
97
+ }
98
+
99
+ if (!categoryField || !valueField) return message("Missing radar spline fields");
100
+
101
+ function readableLabel(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 parseTemporalRank(value) {
110
+ const text = String(value ?? "").trim();
111
+ const lower = text.toLowerCase();
112
+ const months = {
113
+ jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3, apr: 4, april: 4,
114
+ may: 5, jun: 6, june: 6, jul: 7, july: 7, aug: 8, august: 8,
115
+ sep: 9, sept: 9, september: 9, oct: 10, october: 10, nov: 11, november: 11, dec: 12, december: 12
116
+ };
117
+ const seasons = { winter: 1, spring: 2, summer: 3, fall: 4, autumn: 4 };
118
+ if (months[lower] != null) return months[lower];
119
+ let match = text.match(/^(-?\d{4})$/);
120
+ if (match) return Number(match[1]) * 10000;
121
+ match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
122
+ if (match) return Number(match[1]) * 10000 + Number(match[2]) * 100 + Number(match[3] || 1);
123
+ match = text.match(/^Q([1-4])\s+(-?\d{4})$/i) || text.match(/^(-?\d{4})\s+Q([1-4])$/i);
124
+ if (match && text.toUpperCase().startsWith("Q")) return Number(match[2]) * 10 + Number(match[1]);
125
+ if (match) return Number(match[1]) * 10 + Number(match[2]);
126
+ match = text.match(/^(winter|spring|summer|fall|autumn)\s+(-?\d{4})$/i);
127
+ if (match) return Number(match[2]) * 10 + seasons[match[1].toLowerCase()];
128
+ match = text.match(/^(-?\d{4})\s+(winter|spring|summer|fall|autumn)$/i);
129
+ if (match) return Number(match[1]) * 10 + seasons[match[2].toLowerCase()];
130
+ return NaN;
131
+ }
132
+
133
+ function parseOrdinalRank(value) {
134
+ const text = String(value ?? "").trim().toLowerCase();
135
+ const named = {
136
+ primary: 1, elementary: 1, secondary: 2, college: 3, university: 3,
137
+ low: 1, lower: 1, medium: 2, moderate: 2, high: 3, higher: 3,
138
+ children: 1, adolescents: 2, teens: 2, adults: 3, seniors: 4
139
+ };
140
+ if (named[text] != null) return named[text];
141
+ let match = text.match(/(?:less than|under)\s*[$]?\s*([\d,]+)/);
142
+ if (match) return Number(match[1].replace(/,/g, "")) - 0.5;
143
+ match = text.match(/(?:more than|over)\s*[$]?\s*([\d,]+)/);
144
+ if (match) return Number(match[1].replace(/,/g, "")) + 0.5;
145
+ match = text.match(/^[$]?\s*([\d,]+(?:\.\d+)?)\s*(?:-|to|–|—)/);
146
+ if (match) return Number(match[1].replace(/,/g, ""));
147
+ match = text.match(/^(-?\d+(?:\.\d+)?)/);
148
+ if (match) return Number(match[1]);
149
+ return NaN;
150
+ }
151
+
152
+ function compactNumber(value) {
153
+ const numeric = Number(value);
154
+ const abs = Math.abs(numeric);
155
+ if (abs >= 1000000000) return `${d3.format(".3~g")(numeric / 1000000000)}B`;
156
+ if (abs >= 1000000) return `${d3.format(".3~g")(numeric / 1000000)}M`;
157
+ if (abs >= 1000) return `${d3.format(".3~g")(numeric / 1000)}K`;
158
+ return d3.format(",.4~g")(numeric);
159
+ }
160
+
161
+ function formatLocalValue(value, includeUnit = true) {
162
+ const formatted = compactNumber(value);
163
+ if (!includeUnit || !valueUnit) return formatted;
164
+ if (valueUnit === "%") return `${formatted}%`;
165
+ if (valueUnit.length <= 3 && /^[^A-Za-z0-9]+$/.test(valueUnit)) return `${valueUnit}${formatted}`;
166
+ return `${formatted} ${valueUnit}`;
167
+ }
168
+
169
+ function wrapText(value, maxChars, maxLines = 2) {
170
+ const words = readableLabel(value).split(/\s+/).filter(Boolean);
171
+ if (!words.length) return [""];
172
+ const lines = [];
173
+ let current = "";
174
+ words.forEach(word => {
175
+ const next = current ? `${current} ${word}` : word;
176
+ if (next.length <= maxChars) {
177
+ current = next;
178
+ } else {
179
+ if (current) lines.push(current);
180
+ current = word;
181
+ }
182
+ });
183
+ if (current) lines.push(current);
184
+ if (lines.length <= maxLines) return lines;
185
+ const kept = lines.slice(0, maxLines);
186
+ kept[maxLines - 1] = `${kept[maxLines - 1].slice(0, Math.max(1, maxChars - 3)).trim()}...`;
187
+ return kept;
188
+ }
189
+
190
+ function polarPoint(angle, radius) {
191
+ return [Math.cos(angle - Math.PI / 2) * radius, Math.sin(angle - Math.PI / 2) * radius];
192
+ }
193
+
194
+ const aggregated = new Map();
195
+ sourceRows.forEach((row, index) => {
196
+ const category = readableLabel(row[categoryField]);
197
+ const value = Number(row[valueField]);
198
+ if (!category || !Number.isFinite(value) || value < 0) return;
199
+ if (!aggregated.has(category)) {
200
+ aggregated.set(category, {
201
+ category,
202
+ rawCategory: row[categoryField],
203
+ firstIndex: index,
204
+ value: 0,
205
+ count: 0
206
+ });
207
+ }
208
+ const entry = aggregated.get(category);
209
+ entry.value += value;
210
+ entry.count += 1;
211
+ });
212
+
213
+ const rows = Array.from(aggregated.values()).map(d => ({
214
+ ...d,
215
+ temporalRank: parseTemporalRank(d.rawCategory),
216
+ ordinalRank: parseOrdinalRank(d.rawCategory)
217
+ }));
218
+ if (rows.length < 3) return message("Not enough categories for radar spline");
219
+
220
+ const allTemporal = categoryColumn.data_type === "temporal" || rows.every(d => Number.isFinite(d.temporalRank));
221
+ const allOrdinal = !allTemporal && rows.every(d => Number.isFinite(d.ordinalRank));
222
+ const sortMode = allTemporal ? "chronological" : (allOrdinal ? "ordered category" : "largest to smallest");
223
+ rows.sort((a, b) => {
224
+ if (allTemporal) return d3.ascending(a.temporalRank, b.temporalRank) || d3.ascending(a.firstIndex, b.firstIndex);
225
+ if (allOrdinal) return d3.ascending(a.ordinalRank, b.ordinalRank) || d3.ascending(a.firstIndex, b.firstIndex);
226
+ return d3.descending(a.value, b.value) || d3.ascending(a.category, b.category);
227
+ });
228
+
229
+ const margin = { top: 54, right: 50, bottom: 52, left: 50 };
230
+ const radius = Math.max(110, Math.min(width - margin.left - margin.right, height - margin.top - margin.bottom) / 2 - 38);
231
+ const centerX = width / 2;
232
+ const centerY = height / 2 + 6;
233
+ const maxValue = d3.max(rows, d => d.value) || 1;
234
+ const radiusScale = d3.scaleLinear()
235
+ .domain([0, maxValue * 1.12])
236
+ .nice(4)
237
+ .range([0, radius]);
238
+ const domainMax = radiusScale.domain()[1];
239
+ const ticks = radiusScale.ticks(4).filter(tick => tick >= 0);
240
+ const mainColor = sourceColors.other?.primary || sourceColors.available_colors?.[0] || "#2563eb";
241
+ const fillColor = d3.color(mainColor)?.copy({ opacity: 0.14 }) || "rgba(37, 99, 235, 0.14)";
242
+
243
+ const plot = svg.append("g")
244
+ .attr("class", "radar-spline-plot")
245
+ .attr("transform", `translate(${centerX}, ${centerY})`);
246
+
247
+ ticks.forEach(tick => {
248
+ plot.append("circle")
249
+ .attr("class", "gridline radar-grid-ring y-axis-tick")
250
+ .attr("r", radiusScale(tick))
251
+ .attr("fill", "none")
252
+ .attr("stroke", gridColor)
253
+ .attr("stroke-width", tick === 0 ? 1.2 : 1);
254
+ if (tick > 0) {
255
+ plot.append("text")
256
+ .attr("class", "axis-tick-label y-axis-tick")
257
+ .attr("x", 6)
258
+ .attr("y", -radiusScale(tick))
259
+ .attr("dominant-baseline", "middle")
260
+ .attr("fill", mutedText)
261
+ .style("font-family", annotationFamily)
262
+ .style("font-size", `${valueSize}px`)
263
+ .style("font-weight", valueWeight)
264
+ .text(formatLocalValue(tick, false));
265
+ }
266
+ });
267
+
268
+ const angleStep = (2 * Math.PI) / rows.length;
269
+ rows.forEach((row, index) => {
270
+ row.angle = index * angleStep;
271
+ row.radius = radiusScale(row.value);
272
+ const [x2, y2] = polarPoint(row.angle, radius);
273
+ const [labelX, labelY] = polarPoint(row.angle, radius + 24);
274
+ plot.append("line")
275
+ .attr("class", "axis-line")
276
+ .attr("data-category", row.category)
277
+ .attr("x1", 0)
278
+ .attr("y1", 0)
279
+ .attr("x2", x2)
280
+ .attr("y2", y2)
281
+ .attr("stroke", gridColor)
282
+ .attr("stroke-width", 1);
283
+ const label = plot.append("text")
284
+ .attr("class", "category-label")
285
+ .attr("data-category", row.category)
286
+ .attr("x", labelX)
287
+ .attr("y", labelY)
288
+ .attr("text-anchor", Math.abs(labelX) < 8 ? "middle" : (labelX > 0 ? "start" : "end"))
289
+ .attr("dominant-baseline", Math.abs(labelY) < 8 ? "middle" : (labelY > 0 ? "hanging" : "auto"))
290
+ .attr("fill", textColor)
291
+ .style("font-family", fontFamily)
292
+ .style("font-size", `${labelSize}px`)
293
+ .style("font-weight", labelWeight);
294
+ wrapText(row.category, rows.length > 8 ? 9 : 12, 2).forEach((line, lineIndex) => {
295
+ label.append("tspan")
296
+ .attr("x", labelX)
297
+ .attr("dy", lineIndex === 0 ? 0 : labelSize + 1)
298
+ .text(line);
299
+ });
300
+ });
301
+
302
+ const lineRadial = d3.lineRadial()
303
+ .angle(d => d.angle)
304
+ .radius(d => d.radius)
305
+ .curve(d3.curveCatmullRomClosed.alpha(0.55));
306
+
307
+ plot.append("path")
308
+ .datum(rows)
309
+ .attr("class", "radar-area")
310
+ .attr("d", lineRadial)
311
+ .attr("fill", fillColor)
312
+ .attr("stroke", "none");
313
+
314
+ plot.append("path")
315
+ .datum(rows)
316
+ .attr("class", "radar-spline-line")
317
+ .attr("d", lineRadial)
318
+ .attr("fill", "none")
319
+ .attr("stroke", mainColor)
320
+ .attr("stroke-width", 2.4)
321
+ .attr("stroke-linejoin", "round")
322
+ .attr("stroke-linecap", "round");
323
+
324
+ rows.forEach((row, index) => {
325
+ const [x, y] = polarPoint(row.angle, row.radius);
326
+ const [valueX, valueY] = polarPoint(row.angle, row.radius + 14);
327
+ plot.append("circle")
328
+ .attr("class", "radar-point data-point")
329
+ .attr("data-tag", "mark")
330
+ .attr("data-category", row.category)
331
+ .attr("data-value", row.value)
332
+ .attr("cx", x)
333
+ .attr("cy", y)
334
+ .attr("r", 4.4)
335
+ .attr("fill", mainColor)
336
+ .attr("stroke", "#ffffff")
337
+ .attr("stroke-width", 1.2);
338
+ plot.append("text")
339
+ .attr("class", "value-label")
340
+ .attr("data-category", row.category)
341
+ .attr("x", valueX)
342
+ .attr("y", valueY)
343
+ .attr("text-anchor", Math.abs(valueX) < 8 ? "middle" : (valueX > 0 ? "start" : "end"))
344
+ .attr("dominant-baseline", "middle")
345
+ .attr("fill", mutedText)
346
+ .style("font-family", annotationFamily)
347
+ .style("font-size", `${valueSize}px`)
348
+ .style("font-weight", valueWeight)
349
+ .text(formatLocalValue(row.value, false));
350
+ });
351
+
352
+ svg.append("text")
353
+ .attr("class", "axis-title scale-note")
354
+ .attr("x", width / 2)
355
+ .attr("y", 22)
356
+ .attr("text-anchor", "middle")
357
+ .attr("fill", mutedText)
358
+ .style("font-family", annotationFamily)
359
+ .style("font-size", `${valueSize}px`)
360
+ .style("font-weight", valueWeight)
361
+ .text(`${readableLabel(valueColumn.label || valueField)} radial scale: 0-${formatLocalValue(domainMax, true)}`);
362
+
363
+ svg.append("text")
364
+ .attr("class", "category-note")
365
+ .attr("x", width / 2)
366
+ .attr("y", height - 16)
367
+ .attr("text-anchor", "middle")
368
+ .attr("fill", mutedText)
369
+ .style("font-family", annotationFamily)
370
+ .style("font-size", `${Math.max(8, valueSize - 1)}px`)
371
+ .text(`${readableLabel(categoryColumn.label || categoryField)} order: ${sortMode}; radial positions are ordered categories, not elapsed-distance intervals`);
372
+
373
+ return svg.node();
374
+ }