Ray1ee01 commited on
Commit
6bc14e7
·
verified ·
1 Parent(s): 73c8a35

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/type40_radial_layered_area_chart/radial_layered_area_chart_grid_01.js ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Radial Layered Area Chart",
5
+ "chart_name": "radial_layered_area_chart_grid_01",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[3, 7], [0, "inf"], [1, 6]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["group"],
12
+ "required_other_colors": [],
13
+ "supported_effects": [],
14
+ "min_height": 400,
15
+ "min_width": 400,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "no",
20
+ "has_y_axis": "no"
21
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+ function makeChart(containerSelector, data) {
26
+ // 提取数据
27
+ const jsonData = data;
28
+ const chartData = jsonData.data.data;
29
+ const variables = jsonData.variables;
30
+ const typography = jsonData.typography;
31
+ const colors = jsonData.colors || {};
32
+ const colorResolver = chartUtils.color.resolver(jsonData);
33
+ const dataColumns = chartUtils.schema.columns(jsonData);
34
+ const images = jsonData.images || {};
35
+
36
+ // 清空容器
37
+ d3.select(containerSelector).html("");
38
+
39
+ // 获取字段名
40
+ const categoryField = chartUtils.schema.columnField(dataColumns, 0);
41
+ const valueField = chartUtils.schema.columnField(dataColumns, 1);
42
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
43
+
44
+ // 内联layoutLegend函数
45
+ const layoutLegend = (g, groups, colors, options = {}) => {
46
+ const defaults = {
47
+ maxWidth: 500, x: 0, y: 0, itemHeight: 20, itemSpacing: 20, rowSpacing: 10,
48
+ symbolSize: 10, textColor: "#333", fontSize: 12, fontWeight: "normal", align: "left", shape: "circle"
49
+ };
50
+ const opts = {...defaults, ...options};
51
+
52
+ const itemWidths = groups.map(group => opts.symbolSize * 2 + chartUtils.text.measure(g, group, {
53
+ fontSize: opts.fontSize,
54
+ fontWeight: opts.fontWeight
55
+ }).width + 5);
56
+
57
+ const rows = [];
58
+ let currentRow = [], currentRowWidth = 0;
59
+ itemWidths.forEach((width, i) => {
60
+ if (currentRow.length === 0 || currentRowWidth + width + opts.itemSpacing <= opts.maxWidth) {
61
+ currentRow.push(i);
62
+ currentRowWidth += width + (currentRow.length > 1 ? opts.itemSpacing : 0);
63
+ } else {
64
+ rows.push(currentRow);
65
+ currentRow = [i];
66
+ currentRowWidth = width;
67
+ }
68
+ });
69
+ if (currentRow.length > 0) rows.push(currentRow);
70
+
71
+ const totalHeight = rows.length * opts.itemHeight + (rows.length - 1) * opts.rowSpacing;
72
+ const maxRowWidth = Math.max(...rows.map(row => row.reduce((sum, i, idx) => sum + itemWidths[i] + (idx > 0 ? opts.itemSpacing : 0), 0)));
73
+
74
+ rows.forEach((row, rowIndex) => {
75
+ const rowWidth = row.reduce((sum, i, idx) => sum + itemWidths[i] + (idx > 0 ? opts.itemSpacing : 0), 0);
76
+ let rowStartX = opts.align === "center" ? opts.x + (opts.maxWidth - rowWidth) / 2 : opts.align === "right" ? opts.x + opts.maxWidth - rowWidth : opts.x;
77
+
78
+ let currentX = rowStartX;
79
+ row.forEach(i => {
80
+ const group = groups[i];
81
+ const color = colorResolver.field(group, i, { palette: "category10" }).value;
82
+ const legendGroup = g.append("g").attr("transform", `translate(${currentX}, ${opts.y + rowIndex * (opts.itemHeight + opts.rowSpacing)})`);
83
+
84
+ legendGroup.append("circle").attr("class", "mark").attr("cx", opts.symbolSize / 2).attr("cy", opts.itemHeight / 2).attr("r", opts.symbolSize / 2).attr("fill", color);
85
+ legendGroup.append("text").attr("class", "label").attr("x", opts.symbolSize * 1.5).attr("y", opts.itemHeight / 2).attr("dominant-baseline", "middle").attr("fill", opts.textColor).style("font-size", `${opts.fontSize}px`).style("font-weight", opts.fontWeight).text(group);
86
+
87
+ currentX += itemWidths[i] + opts.itemSpacing;
88
+ });
89
+ });
90
+
91
+ return { width: maxRowWidth, height: totalHeight };
92
+ };
93
+
94
+ // 设置尺寸和边距
95
+ const width = variables.width;
96
+ const height = variables.height;
97
+ const margin = { top: 50, right: 50, bottom: 50, left: 50 };
98
+
99
+ // 创建SVG
100
+ const svg = d3.select(containerSelector)
101
+ .append("svg")
102
+ .attr("width", "100%")
103
+ .attr("height", height)
104
+ .attr("viewBox", `0 0 ${width} ${height}`)
105
+ .attr("style", "max-width: 100%; height: auto;")
106
+ .attr("xmlns", "http://www.w3.org/2000/svg")
107
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
108
+
109
+ // 创建图表区域
110
+ const chartWidth = width - margin.left - margin.right;
111
+ const chartHeight = height - margin.top - margin.bottom;
112
+ const radius = Math.min(chartWidth, chartHeight) / 2;
113
+
114
+ const g = svg.append("g")
115
+ .attr("transform", `translate(${width/2}, ${height/2})`);
116
+
117
+ // 获取唯一类别和分组
118
+ const categories = [...new Set(chartData.map(d => d[categoryField]))];
119
+ const groupAvgs = [...new Set(chartData.map(d => d[groupField]))]
120
+ .map(group => ({
121
+ group,
122
+ avg: d3.mean(chartData.filter(d => d[groupField] === group), d => +d[valueField])
123
+ }))
124
+ .sort((a, b) => b.avg - a.avg);
125
+
126
+ const groups = groupAvgs.map(d => d.group);
127
+
128
+ // 创建颜色比例尺
129
+ const colorScale = d => colorResolver.field(d, groups.indexOf(d), { palette: "tableau10" }).value;
130
+
131
+ // 创建角度比例尺
132
+ const angleScale = d3.scalePoint()
133
+ .domain(categories)
134
+ .range([0, 2 * Math.PI - (2 * Math.PI / categories.length)]);
135
+
136
+ // 创建半径比例尺
137
+ const allValues = chartData.map(d => +d[valueField]);
138
+ const minValue = Math.min(0, d3.min(allValues));
139
+ const maxValue = d3.max(allValues);
140
+
141
+ const radiusScale = d3.scaleLinear()
142
+ .domain([minValue, maxValue])
143
+ .range([0, radius])
144
+ .nice();
145
+
146
+ // 绘制背景圆环
147
+ const ticks = radiusScale.ticks(5);
148
+
149
+ // 绘制同心圆
150
+ g.selectAll(".circle-axis")
151
+ .data(ticks)
152
+ .enter()
153
+ .append("circle")
154
+ .attr("class", "gridline")
155
+ .attr("cx", 0)
156
+ .attr("cy", 0)
157
+ .attr("r", d => radiusScale(d))
158
+ .attr("fill", "none")
159
+ .attr("stroke", "#bbb")
160
+ .attr("stroke-width", 1)
161
+ .attr("stroke-dasharray", "4,4");
162
+
163
+ // 绘制径向轴线
164
+ g.selectAll(".axis-line")
165
+ .data(categories)
166
+ .enter()
167
+ .append("line")
168
+ .attr("class", "axis")
169
+ .attr("x1", 0)
170
+ .attr("y1", 0)
171
+ .attr("x2", d => radius * Math.cos(angleScale(d) - Math.PI/2))
172
+ .attr("y2", d => radius * Math.sin(angleScale(d) - Math.PI/2))
173
+ .attr("stroke", "#bbb")
174
+ .attr("stroke-width", 1);
175
+
176
+ // 添加类别标签
177
+ g.selectAll(".category-label")
178
+ .data(categories)
179
+ .enter()
180
+ .append("text")
181
+ .attr("class", "label")
182
+ .attr("x", d => (radius + 20) * Math.cos(angleScale(d) - Math.PI/2))
183
+ .attr("y", d => (radius + 20) * Math.sin(angleScale(d) - Math.PI/2))
184
+ .attr("text-anchor", d => {
185
+ const angle = angleScale(d);
186
+ return angle > Math.PI ? "end" : "start";
187
+ })
188
+ .attr("dominant-baseline", "middle")
189
+ .attr("fill", "#333")
190
+ .attr("font-size", "16px")
191
+ .attr("font-weight", "bold")
192
+ .text(d => chartUtils.format.category(d).text);
193
+
194
+ // 添加刻度值标签
195
+ g.selectAll(".tick-label")
196
+ .data(ticks.filter(d => d > 0))
197
+ .enter()
198
+ .append("text")
199
+ .attr("class", "value")
200
+ .attr("x", 5)
201
+ .attr("y", d => -radiusScale(d) + 5)
202
+ .attr("text-anchor", "start")
203
+ .attr("font-size", "14px")
204
+ .attr("fill", "#666")
205
+ .text(d => chartUtils.format.number(d).text);
206
+
207
+ // 创建径向面积生成器 - 关键:所有面积都从中心点开始(innerRadius=0)
208
+ const areaRadial = d3.areaRadial()
209
+ .angle(d => angleScale(d[categoryField]))
210
+ .innerRadius(0) // 关键:所有系列共享零基线(中心点)
211
+ .outerRadius(d => radiusScale(+d[valueField]))
212
+ .curve(d3.curveLinearClosed);
213
+
214
+ // 绘制每个组的径向分层面积 - 按平均值降序绘制,确保小的不被大的完全遮挡
215
+ [...groups].reverse().forEach(group => {
216
+ const groupData = chartData.filter(d => d[groupField] === group);
217
+
218
+ // 确保数据按角度排序,形成完整的径向面积
219
+ const sortedData = categories.map(cat => {
220
+ const point = groupData.find(d => d[categoryField] === cat);
221
+ return point || { [categoryField]: cat, [valueField]: 0, [groupField]: group };
222
+ });
223
+
224
+ // 绘制径向面积
225
+ g.append("path")
226
+ .datum(sortedData)
227
+ .attr("class", "mark")
228
+ .attr("d", areaRadial)
229
+ .attr("fill", colorScale(group))
230
+ .attr("fill-opacity", 0.3) // 半透明,便于看见被遮挡的层
231
+ .attr("stroke", colorScale(group))
232
+ .attr("stroke-width", 2)
233
+ .attr("stroke-opacity", 0.8);
234
+
235
+ // 绘制数据点
236
+ categories.forEach(cat => {
237
+ const point = groupData.find(item => item[categoryField] === cat);
238
+ if (point) {
239
+ const angle = angleScale(cat) - Math.PI/2;
240
+ const distance = radiusScale(+point[valueField]);
241
+
242
+ g.append("circle")
243
+ .attr("class", "mark")
244
+ .attr("cx", distance * Math.cos(angle))
245
+ .attr("cy", distance * Math.sin(angle))
246
+ .attr("r", 3)
247
+ .attr("fill", colorScale(group))
248
+ .attr("stroke", "#fff")
249
+ .attr("stroke-width", 1);
250
+ }
251
+ });
252
+ });
253
+
254
+ // 添加图例
255
+ const legendGroup = svg.append("g");
256
+ const legendSize = chartUtils.legend.draw(legendGroup, groups, colors, {
257
+ x: 0, y: 0, fontSize: 14, fontWeight: "bold", align: "center",
258
+ maxWidth: chartWidth, shape: "circle", textColor: "#333"
259
+ });
260
+
261
+ // 居中legend
262
+ legendGroup.attr("transform", `translate(${(width - legendSize.width) / 2}, ${height - margin.bottom + 10})`);
263
+
264
+ return svg.node();
265
+ }
modules/chart_engine/template/d3-js/type40_radial_layered_area_chart/radial_layered_area_plain_chart_01.js ADDED
@@ -0,0 +1,440 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Radial Layered Area Chart",
5
+ "chart_name": "radial_layered_area_plain_chart_01",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["categorical", "temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[3, 30], [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": 520,
15
+ "min_width": 520,
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 && 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
+ const fieldColors = sourceColors.field || {};
55
+
56
+ d3.select(containerSelector).html("");
57
+
58
+ const categoryColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
59
+ const valueColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
60
+ const groupColumn = chartUtils.schema.channel(jsonData, "group", { fallbackIndex: 2 }).raw || {};
61
+ const categoryField = categoryColumn.name;
62
+ const valueField = valueColumn.name;
63
+ const groupField = groupColumn.name;
64
+ const valueUnit = (valueColumn.unit || "").trim();
65
+
66
+ const width = Math.max(520, Number(variables.width) || 600);
67
+ const height = Math.max(520, Number(variables.height) || 600);
68
+ const textColor = sourceColors.text_color || "#263244";
69
+ const mutedText = "#64748b";
70
+ const gridColor = "rgba(100, 116, 139, 0.24)";
71
+ const axisColor = "rgba(38, 50, 68, 0.34)";
72
+ const fontFamily = (typography.label && typography.label.font_family) ||
73
+ (typography.title && typography.title.font_family) ||
74
+ "Arial, sans-serif";
75
+ const annotationFamily = (typography.annotation && typography.annotation.font_family) || fontFamily;
76
+ const labelWeight = (typography.label && typography.label.font_weight) || "700";
77
+ const valueWeight = (typography.annotation && typography.annotation.font_weight) || "600";
78
+ const baseLabelSize = parseFloat(typography.label && typography.label.font_size) || 11;
79
+ const baseValueSize = parseFloat(typography.annotation && typography.annotation.font_size) || 10;
80
+
81
+ const svg = d3.select(containerSelector)
82
+ .append("svg")
83
+ .attr("width", "100%")
84
+ .attr("height", height)
85
+ .attr("viewBox", `0 0 ${width} ${height}`)
86
+ .attr("style", "max-width: 100%; height: auto;")
87
+ .attr("xmlns", "http://www.w3.org/2000/svg")
88
+ .attr("class", "radial-layered-area-root");
89
+
90
+ function showMessage(message) {
91
+ svg.append("text")
92
+ .attr("x", width / 2)
93
+ .attr("y", height / 2)
94
+ .attr("text-anchor", "middle")
95
+ .attr("fill", textColor)
96
+ .style("font-family", fontFamily)
97
+ .style("font-size", "16px")
98
+ .text(message);
99
+ return svg.node();
100
+ }
101
+
102
+ if (!categoryField || !valueField || !groupField) {
103
+ return showMessage("Missing radial layered area fields");
104
+ }
105
+
106
+ function cleanLabel(value) {
107
+ return String(value ?? "")
108
+ .replace(/_/g, " ")
109
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
110
+ .replace(/\s+/g, " ")
111
+ .trim();
112
+ }
113
+
114
+ function readableName(value) {
115
+ return cleanLabel(value).replace(/\b\w/g, letter => letter.toUpperCase());
116
+ }
117
+
118
+ function compactNumber(value) {
119
+ const numeric = Number(value);
120
+ if (!Number.isFinite(numeric)) return String(value ?? "");
121
+ const abs = Math.abs(numeric);
122
+ if (abs >= 1000000000) return `${d3.format(".3~g")(numeric / 1000000000)}B`;
123
+ if (abs >= 1000000) return `${d3.format(".3~g")(numeric / 1000000)}M`;
124
+ if (abs >= 1000) return `${d3.format(".3~g")(numeric / 1000)}K`;
125
+ return d3.format(",.4~g")(numeric);
126
+ }
127
+
128
+ function formatLocalValue(value, includeUnit = true) {
129
+ const formatted = compactNumber(value);
130
+ if (!includeUnit || !valueUnit || valueUnit === "none") return formatted;
131
+ const currencyWithSuffix = valueUnit.match(/^([£$€¥])\s*([A-Za-z]+)$/);
132
+ if (valueUnit === "%") return `${formatted}%`;
133
+ if (currencyWithSuffix) return `${currencyWithSuffix[1]}${formatted}${currencyWithSuffix[2]}`;
134
+ if (/^[£$€¥]$/.test(valueUnit)) return `${valueUnit}${formatted}`;
135
+ return `${formatted} ${valueUnit}`;
136
+ }
137
+
138
+ function parseTemporalRank(value) {
139
+ const text = String(value ?? "").trim();
140
+ let match = text.match(/^(-?\d{4})$/);
141
+ if (match) return Number(match[1]) * 10000;
142
+ match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
143
+ if (match) return Number(match[1]) * 10000 + Number(match[2]) * 100 + Number(match[3] || 1);
144
+ match = text.match(/^q([1-4])\s*(-?\d{4})$/i) || text.match(/^(-?\d{4})\s*q([1-4])$/i);
145
+ if (match && match[1].length === 1) return Number(match[2]) * 10 + Number(match[1]);
146
+ if (match) return Number(match[1]) * 10 + Number(match[2]);
147
+ const parsed = new Date(text);
148
+ return Number.isNaN(+parsed) ? NaN : +parsed;
149
+ }
150
+
151
+ function parseOrdinalRank(value) {
152
+ const text = String(value ?? "").trim().toLowerCase();
153
+ let match = text.match(/^(\d+(?:\.\d+)?)\s*(?:-|to)\s*\d+(?:\.\d+)?/);
154
+ if (match) return Number(match[1]);
155
+ match = text.match(/^(-?\d+(?:\.\d+)?)(?:st|nd|rd|th)?\b/);
156
+ if (match) return Number(match[1]);
157
+ return NaN;
158
+ }
159
+
160
+ function shortenLabel(value, maxChars) {
161
+ const clean = cleanLabel(value);
162
+ if (clean.length <= maxChars) return clean;
163
+ const words = clean.split(" ").filter(Boolean);
164
+ if (words.length > 1) {
165
+ const firstLast = `${words[0]} ${words[words.length - 1]}`;
166
+ if (firstLast.length <= maxChars) return firstLast;
167
+ }
168
+ return `${clean.slice(0, Math.max(4, maxChars - 3))}...`;
169
+ }
170
+
171
+ const aggregated = new Map();
172
+ sourceData.forEach((row, index) => {
173
+ const category = cleanLabel(row[categoryField]);
174
+ const group = cleanLabel(row[groupField]);
175
+ const value = Number(row[valueField]);
176
+ if (!category || !group || !Number.isFinite(value) || value < 0) return;
177
+ const key = `${category}|||${group}`;
178
+ if (!aggregated.has(key)) {
179
+ aggregated.set(key, {
180
+ category,
181
+ rawCategory: row[categoryField],
182
+ group,
183
+ rawGroup: row[groupField],
184
+ value: 0,
185
+ firstIndex: index
186
+ });
187
+ }
188
+ aggregated.get(key).value += value;
189
+ });
190
+
191
+ const rows = Array.from(aggregated.values());
192
+ const categories = Array.from(new Set(rows.map(d => d.category)));
193
+ const groups = Array.from(new Set(rows.map(d => d.group)));
194
+ if (categories.length < 3 || groups.length < 1) {
195
+ return showMessage("Not enough data for radial layered area");
196
+ }
197
+
198
+ const valueByCategoryGroup = new Map(rows.map(d => [`${d.category}|||${d.group}`, d.value]));
199
+ const categoryStats = categories.map(category => {
200
+ const categoryRows = rows.filter(d => d.category === category);
201
+ const rawCategory = categoryRows[0]?.rawCategory;
202
+ return {
203
+ category,
204
+ rawCategory,
205
+ firstIndex: d3.min(categoryRows, d => d.firstIndex) ?? 0,
206
+ total: d3.sum(categoryRows, d => d.value),
207
+ temporalRank: parseTemporalRank(rawCategory),
208
+ ordinalRank: parseOrdinalRank(rawCategory)
209
+ };
210
+ });
211
+ const categoriesAreTemporal = categoryStats.every(d => Number.isFinite(d.temporalRank));
212
+ const categoriesAreOrdinal = !categoriesAreTemporal && categoryStats.every(d => Number.isFinite(d.ordinalRank));
213
+ const sortMode = categoriesAreTemporal ? "chronological" : (categoriesAreOrdinal ? "ordered category" : "ranked by total");
214
+ const orderedCategories = categoryStats.slice().sort((a, b) => {
215
+ if (categoriesAreTemporal) return d3.ascending(a.temporalRank, b.temporalRank) || d3.ascending(a.firstIndex, b.firstIndex);
216
+ if (categoriesAreOrdinal) return d3.ascending(a.ordinalRank, b.ordinalRank) || d3.ascending(a.firstIndex, b.firstIndex);
217
+ return d3.descending(a.total, b.total) || d3.ascending(a.category, b.category);
218
+ }).map(d => d.category);
219
+
220
+ const groupStats = groups.map(group => {
221
+ const groupRows = rows.filter(d => d.group === group);
222
+ return {
223
+ group,
224
+ rawGroup: groupRows[0]?.rawGroup,
225
+ firstIndex: d3.min(groupRows, d => d.firstIndex) ?? 0,
226
+ mean: d3.mean(groupRows, d => d.value) || 0,
227
+ max: d3.max(groupRows, d => d.value) || 0
228
+ };
229
+ });
230
+ const orderedGroups = groupStats.slice()
231
+ .sort((a, b) => d3.descending(a.mean, b.mean) || d3.ascending(a.firstIndex, b.firstIndex))
232
+ .map(d => d.group);
233
+ const rawGroupByClean = new Map(groupStats.map(d => [d.group, d.rawGroup]));
234
+
235
+ const maxValue = Math.max(1e-9, d3.max(rows, d => d.value) || 0);
236
+ const margin = { top: 32, right: 34, bottom: 68, left: 34 };
237
+ const legendHeight = Math.min(70, 18 * Math.ceil(orderedGroups.length / 3));
238
+ const centerX = width / 2;
239
+ const centerY = (height - margin.bottom + margin.top - legendHeight) / 2 + 12;
240
+ const labelReserve = orderedCategories.length > 8 ? 42 : 52;
241
+ const radius = Math.max(92, Math.min(width - margin.left - margin.right, height - margin.top - margin.bottom - legendHeight) / 2 - labelReserve);
242
+ const angleStep = (2 * Math.PI) / orderedCategories.length;
243
+ const angleScale = d3.scalePoint()
244
+ .domain(orderedCategories)
245
+ .range([0, 2 * Math.PI - angleStep])
246
+ .padding(0);
247
+ const radiusScale = d3.scaleLinear()
248
+ .domain([0, maxValue])
249
+ .nice(4)
250
+ .range([0, radius]);
251
+ const domainMax = radiusScale.domain()[1];
252
+ const ticks = radiusScale.ticks(4).filter(tick => tick > 0);
253
+
254
+ const palette = d3.schemeTableau10 || d3.schemeCategory10;
255
+ const colorScale = d3.scaleOrdinal()
256
+ .domain(orderedGroups)
257
+ .range(orderedGroups.map((group, index) => {
258
+ const rawGroup = rawGroupByClean.get(group);
259
+ return fieldColors[group] || fieldColors[rawGroup] || palette[index % palette.length];
260
+ }));
261
+
262
+ function pointFor(category, value) {
263
+ const angle = (angleScale(category) || 0) - Math.PI / 2;
264
+ const distance = radiusScale(value);
265
+ return {
266
+ angle,
267
+ distance,
268
+ x: distance * Math.cos(angle),
269
+ y: distance * Math.sin(angle)
270
+ };
271
+ }
272
+
273
+ const chart = svg.append("g")
274
+ .attr("class", "radial-layered-area-chart")
275
+ .attr("data-tag", "chart")
276
+ .attr("transform", `translate(${centerX}, ${centerY})`);
277
+
278
+ ticks.forEach(tick => {
279
+ chart.append("circle")
280
+ .attr("class", "radial-grid")
281
+ .attr("r", radiusScale(tick))
282
+ .attr("fill", "none")
283
+ .attr("stroke", gridColor)
284
+ .attr("stroke-width", 1)
285
+ .attr("stroke-dasharray", "3,4");
286
+ chart.append("text")
287
+ .attr("class", "ring-label axis-tick")
288
+ .attr("x", 4)
289
+ .attr("y", -radiusScale(tick) - 3)
290
+ .attr("fill", mutedText)
291
+ .style("font-family", annotationFamily)
292
+ .style("font-size", `${Math.max(7.2, baseValueSize * 0.72)}px`)
293
+ .style("font-weight", valueWeight)
294
+ .text(formatLocalValue(tick, false));
295
+ });
296
+
297
+ orderedCategories.forEach(category => {
298
+ const angle = (angleScale(category) || 0) - Math.PI / 2;
299
+ const x = radius * Math.cos(angle);
300
+ const y = radius * Math.sin(angle);
301
+ chart.append("line")
302
+ .attr("class", "radial-axis")
303
+ .attr("x1", 0)
304
+ .attr("y1", 0)
305
+ .attr("x2", x)
306
+ .attr("y2", y)
307
+ .attr("stroke", axisColor)
308
+ .attr("stroke-width", 1);
309
+
310
+ const labelDistance = radius + (orderedCategories.length > 8 ? 15 : 20);
311
+ const labelX = labelDistance * Math.cos(angle);
312
+ const labelY = labelDistance * Math.sin(angle);
313
+ const normalizedAngle = (angle + Math.PI * 2) % (Math.PI * 2);
314
+ const anchor = Math.abs(Math.cos(angle)) < 0.25 ? "middle" : (normalizedAngle > Math.PI / 2 && normalizedAngle < Math.PI * 1.5 ? "end" : "start");
315
+ chart.append("text")
316
+ .attr("class", "category-label")
317
+ .attr("x", labelX)
318
+ .attr("y", labelY)
319
+ .attr("text-anchor", anchor)
320
+ .attr("dominant-baseline", "middle")
321
+ .attr("fill", textColor)
322
+ .style("font-family", annotationFamily)
323
+ .style("font-size", `${Math.max(7.6, Math.min(baseLabelSize, orderedCategories.length > 8 ? 8.4 : 9.6))}px`)
324
+ .style("font-weight", labelWeight)
325
+ .text(shortenLabel(category, orderedCategories.length > 8 ? 9 : 13));
326
+ });
327
+
328
+ const areaRadial = d3.areaRadial()
329
+ .angle(d => angleScale(d.category))
330
+ .innerRadius(0)
331
+ .outerRadius(d => radiusScale(d.value))
332
+ .curve(d3.curveLinearClosed);
333
+
334
+ const lineRadial = d3.lineRadial()
335
+ .angle(d => angleScale(d.category))
336
+ .radius(d => radiusScale(d.value))
337
+ .curve(d3.curveLinearClosed);
338
+
339
+ orderedGroups.slice().reverse().forEach(group => {
340
+ const series = orderedCategories.map(category => ({
341
+ category,
342
+ group,
343
+ value: valueByCategoryGroup.get(`${category}|||${group}`) ?? 0
344
+ }));
345
+ const color = colorScale(group);
346
+ chart.append("path")
347
+ .datum(series)
348
+ .attr("class", "radial-layered-area mark")
349
+ .attr("data-tag", "mark")
350
+ .attr("data-group", group)
351
+ .attr("d", areaRadial)
352
+ .attr("fill", color)
353
+ .attr("fill-opacity", 0.24)
354
+ .attr("stroke", "none");
355
+ chart.append("path")
356
+ .datum(series)
357
+ .attr("class", "radial-layered-area-line")
358
+ .attr("data-group", group)
359
+ .attr("d", lineRadial)
360
+ .attr("fill", "none")
361
+ .attr("stroke", color)
362
+ .attr("stroke-width", 1.8)
363
+ .attr("stroke-linejoin", "round")
364
+ .attr("stroke-opacity", 0.9);
365
+
366
+ series.forEach(d => {
367
+ const point = pointFor(d.category, d.value);
368
+ chart.append("circle")
369
+ .attr("class", "radial-area-point data-point")
370
+ .attr("data-tag", "mark")
371
+ .attr("data-group", group)
372
+ .attr("data-category", d.category)
373
+ .attr("data-value", d.value)
374
+ .attr("cx", point.x)
375
+ .attr("cy", point.y)
376
+ .attr("r", 2.3)
377
+ .attr("fill", color)
378
+ .attr("stroke", "#ffffff")
379
+ .attr("stroke-width", 0.7);
380
+ });
381
+ });
382
+
383
+ const legend = svg.append("g")
384
+ .attr("class", "radial-layered-area-legend")
385
+ .attr("transform", `translate(${margin.left}, ${height - margin.bottom + 16})`);
386
+ const legendFont = Math.max(8.2, Math.min(baseValueSize, 10.2));
387
+ const maxLegendWidth = width - margin.left - margin.right;
388
+ let currentX = 0;
389
+ let currentY = 0;
390
+ orderedGroups.forEach((group, index) => {
391
+ const label = shortenLabel(group, 18);
392
+ const itemWidth = Math.min(160, 16 + label.length * legendFont * 0.55 + 18);
393
+ if (currentX > 0 && currentX + itemWidth > maxLegendWidth) {
394
+ currentX = 0;
395
+ currentY += 17;
396
+ }
397
+ const item = legend.append("g")
398
+ .attr("class", "legend-item")
399
+ .attr("transform", `translate(${currentX}, ${currentY})`);
400
+ item.append("circle")
401
+ .attr("class", "legend-swatch")
402
+ .attr("cx", 5)
403
+ .attr("cy", 5)
404
+ .attr("r", 5)
405
+ .attr("fill", colorScale(group));
406
+ item.append("text")
407
+ .attr("class", "legend-label")
408
+ .attr("x", 14)
409
+ .attr("y", 8)
410
+ .attr("fill", textColor)
411
+ .style("font-family", annotationFamily)
412
+ .style("font-size", `${legendFont}px`)
413
+ .style("font-weight", valueWeight)
414
+ .text(label);
415
+ currentX += itemWidth;
416
+ });
417
+
418
+ const valueName = readableName(valueColumn.label || valueColumn.name || valueField);
419
+ const categoryName = readableName(categoryColumn.label || categoryColumn.name || categoryField);
420
+ svg.append("text")
421
+ .attr("class", "scale-note")
422
+ .attr("x", 24)
423
+ .attr("y", height - 18)
424
+ .attr("fill", mutedText)
425
+ .style("font-family", annotationFamily)
426
+ .style("font-size", `${Math.max(8, baseValueSize * 0.82)}px`)
427
+ .text(`Radius shows ${valueName}${valueUnit && valueUnit !== "none" ? ` (${valueUnit})` : ""}; shared radial scale 0-${formatLocalValue(domainMax, true)}.`);
428
+
429
+ svg.append("text")
430
+ .attr("class", "category-note")
431
+ .attr("x", width - 24)
432
+ .attr("y", height - 18)
433
+ .attr("text-anchor", "end")
434
+ .attr("fill", mutedText)
435
+ .style("font-family", annotationFamily)
436
+ .style("font-size", `${Math.max(8, baseValueSize * 0.82)}px`)
437
+ .text(`${categoryName} order: ${sortMode}`);
438
+
439
+ return svg.node();
440
+ }