Ray1ee01 commited on
Commit
acfa6b6
·
verified ·
1 Parent(s): 4d7e0fd

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/type16_dot_chart/dot_plain_chart_01.js ADDED
@@ -0,0 +1,462 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Dot Chart",
5
+ "chart_name": "dot_plain_chart_01",
6
+ "chart_for": "comparison",
7
+ "is_composite": false,
8
+ "required_fields": ["x", "y", "group"],
9
+ "required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
10
+ "required_fields_range": [[2, 20], [0, "inf"], [2, 5]],
11
+ "required_fields_icons": ["x"],
12
+ "required_other_icons": [],
13
+ "required_fields_colors": ["group"],
14
+ "hierarchy": ["group"],
15
+ "required_other_colors": ["primary"],
16
+ "supported_effects": ["shadow", "radius_corner", "gradient", "stroke", "spacing"],
17
+ "min_height": 400,
18
+ "min_width": 400,
19
+ "background": "no",
20
+ "icon_mark": "none",
21
+ "icon_label": "side",
22
+ "has_x_axis": "yes",
23
+ "has_y_axis": "yes"
24
+ }
25
+ REQUIREMENTS_END
26
+ */
27
+
28
+ // 散点图实现 - 使用D3.js
29
+ function makeChart(containerSelector, data) {
30
+ // ---------- 1. 数据与配置 ----------
31
+ const jsonData = data;
32
+ const chartData = jsonData.data.data;
33
+ const variables = jsonData.variables || {};
34
+ const typography = jsonData.typography || {
35
+ label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
36
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
37
+ };
38
+ const colors = jsonData.colors || { text_color: "#333333" };
39
+ const colorResolver = chartUtils.color.resolver(jsonData);
40
+ const images = jsonData.images || { field: {}, other: {} };
41
+ const dataColumns = chartUtils.schema.columns(jsonData);
42
+
43
+ // 清空容器 & 创建文本测量上下文
44
+ d3.select(containerSelector).html("");
45
+ const canvas = document.createElement('canvas');
46
+ const context = canvas.getContext('2d');
47
+ function measureTextWidth(text, fontFamily, fontSize, fontWeight) {
48
+ context.font = `${fontWeight || 'normal'} ${fontSize || '12px'} ${fontFamily || 'Arial'}`;
49
+ return chartUtils.text.contextWidth(context, text);
50
+ }
51
+
52
+ // 添加文本自动换行辅助函数
53
+ function wrapText(text, maxWidth, fontFamily, fontSize, fontWeight) {
54
+ if (!text) return [];
55
+
56
+ const words = text.toString().split(/\s+/).filter(d => d.length > 0);
57
+ if (words.length === 0) return [];
58
+
59
+ const lines = [];
60
+ let currentLine = words[0];
61
+
62
+ for (let i = 1; i < words.length; i++) {
63
+ const word = words[i];
64
+ const width = measureTextWidth(currentLine + " " + word, fontFamily, fontSize, fontWeight);
65
+
66
+ if (width < maxWidth) {
67
+ currentLine += " " + word;
68
+ } else {
69
+ lines.push(currentLine);
70
+ currentLine = word;
71
+ }
72
+ }
73
+
74
+ lines.push(currentLine);
75
+ return lines;
76
+ }
77
+
78
+ // 如果只有一个单词但超长,进行单词拆分
79
+ function breakLongWord(word, maxWidth, fontFamily, fontSize, fontWeight) {
80
+ if (!word) return [];
81
+
82
+ const characters = word.split('');
83
+ const lines = [];
84
+ let currentLine = '';
85
+
86
+ for (let char of characters) {
87
+ const testLine = currentLine + char;
88
+ const width = measureTextWidth(testLine, fontFamily, fontSize, fontWeight);
89
+
90
+ if (width < maxWidth) {
91
+ currentLine = testLine;
92
+ } else {
93
+ if (currentLine) {
94
+ lines.push(currentLine);
95
+ currentLine = char;
96
+ } else {
97
+ // 如果单个字符就超宽,强制添加
98
+ lines.push(char);
99
+ currentLine = '';
100
+ }
101
+ }
102
+ }
103
+
104
+ if (currentLine) {
105
+ lines.push(currentLine);
106
+ }
107
+
108
+ return lines;
109
+ }
110
+
111
+ // ---------- 2. 尺寸与边距 ----------
112
+ const width = variables.width || 600;
113
+ const height = variables.height || 500;
114
+ // 初始边距,后续动态调整
115
+ let margin = { top: 75, right: 20, bottom: 70, left: 20 };
116
+
117
+ // ---------- 3. 字段提取 ----------
118
+ const dimensionField = chartUtils.schema.channel(jsonData, "x").key; // Y轴类别字段
119
+ const valueField = chartUtils.schema.channel(jsonData, "y").key; // X轴数值字段
120
+ const groupField = chartUtils.schema.channel(jsonData, "group").key; // 分组字段
121
+
122
+ // ---------- 4. 数据处理 ----------
123
+ const dimensions = [...new Set(chartData.map(d => d[dimensionField]))]; // Y轴唯一类别
124
+ const groups = [...new Set(chartData.map(d => d[groupField]))]; // 唯一分组
125
+
126
+ // ---------- 5. 动态计算边距与内部尺寸 ----------
127
+
128
+ // --- 5a: 计算X轴需求 ---
129
+ const maxValue = d3.max(chartData, d => +d[valueField]) || 0;
130
+ const tempXScale = d3.scaleLinear().domain([0, maxValue]).nice(); // 用于计算刻度的临时比例尺
131
+ const xAxisTicks = tempXScale.ticks(5);
132
+ const xAxisTickFormat = tempXScale.tickFormat(5);
133
+
134
+ // 计算X轴标签所需高度 -> 更新下边距
135
+ let maxXAxisLabelHeight = 0;
136
+ if (xAxisTicks.length > 0) {
137
+ const labelFontSize = typography.label.font_size || '12px';
138
+ maxXAxisLabelHeight = parseFloat(labelFontSize) * 1.2; // 基于字体大小估算
139
+ }
140
+ const xAxisPadding = 15;
141
+ margin.bottom = Math.max(margin.bottom, maxXAxisLabelHeight + xAxisPadding);
142
+
143
+ // 计算X轴标签最大宽度 -> 更新右边距
144
+ let maxXAxisLabelWidth = 0;
145
+ if (xAxisTicks.length > 0) {
146
+ const fontFamily = typography.label.font_family || 'Arial';
147
+ const fontSize = typography.label.font_size || '12px';
148
+ const fontWeight = typography.label.font_weight || 'normal';
149
+ xAxisTicks.forEach(tick => {
150
+ const formattedTickText = chartUtils.format.autoText(+(tick));
151
+ const textWidth = measureTextWidth(formattedTickText, fontFamily, fontSize, fontWeight);
152
+ maxXAxisLabelWidth = Math.max(maxXAxisLabelWidth, textWidth);
153
+ });
154
+ }
155
+ // 调整右边距防止标签溢出
156
+ margin.right = Math.max(margin.right, (maxXAxisLabelWidth / 2) + 10);
157
+
158
+
159
+ // --- 5b: 估算Y轴需求 -> 更新左边距 ---
160
+ const iconPadding = 5;
161
+ let maxYAxisLabelWidth = 0;
162
+
163
+ // 估算图标尺寸 (基于预估的条带宽度)
164
+ let preliminaryInnerHeight = height - margin.top - margin.bottom;
165
+ let preliminaryYScale = d3.scaleBand().domain(dimensions).range([0, preliminaryInnerHeight]).padding(0.1);
166
+ let estIconHeight = preliminaryYScale.bandwidth() > 0 ? preliminaryYScale.bandwidth() * 0.6 : 20;
167
+ let estIconWidth = estIconHeight * 1.33; // 假设4:3宽高比
168
+
169
+ // 计算Y轴标签+图标所需宽度
170
+ const maxYAxisLabelSpace = width * 0.25; // 最大允许Y轴标签宽度(占图表宽度的25%)
171
+ const minFontSize = 8; // 最小字体大小
172
+ const baseFontSize = parseFloat(typography.label.font_size || '12px');
173
+ const fontFamily = typography.label.font_family || 'Arial';
174
+ const fontWeight = typography.label.font_weight || 'normal';
175
+
176
+ const yLabelsInfo = {}; // 存储标签相关信息
177
+
178
+ dimensions.forEach(dim => {
179
+ // 移除图标相关代码
180
+ const iconSpace = 0; // 不再考虑图标空间
181
+ const labelWidth = measureTextWidth(dim, fontFamily, `${baseFontSize}px`, fontWeight);
182
+
183
+ // 如果标签超长需要适配
184
+ if (labelWidth > (maxYAxisLabelSpace - iconSpace)) {
185
+ // 计算需要的缩放因子
186
+ const scaleFactor = Math.max(0.7, (maxYAxisLabelSpace - iconSpace) / labelWidth);
187
+ const adjustedFontSize = Math.max(minFontSize, baseFontSize * scaleFactor);
188
+
189
+ // 检查是否需要换行
190
+ if (adjustedFontSize < baseFontSize * 0.8) {
191
+ // 计算单行最大宽度
192
+ const maxLineWidth = maxYAxisLabelSpace - iconSpace;
193
+
194
+ // 尝试按空格换行
195
+ let textLines = wrapText(dim, maxLineWidth, fontFamily, `${adjustedFontSize}px`, fontWeight);
196
+
197
+ // 如果是单个长词,尝试字符分割
198
+ if (textLines.length === 1 && measureTextWidth(textLines[0], fontFamily, `${adjustedFontSize}px`, fontWeight) > maxLineWidth) {
199
+ textLines = breakLongWord(dim, maxLineWidth, fontFamily, `${adjustedFontSize}px`, fontWeight);
200
+ }
201
+
202
+ yLabelsInfo[dim] = {
203
+ fontSize: adjustedFontSize,
204
+ lines: textLines,
205
+ needsWrap: true
206
+ };
207
+
208
+ // 计算包含所有行的宽度
209
+ let maxTextLineWidth = 0;
210
+ textLines.forEach(line => {
211
+ const lineWidth = measureTextWidth(line, fontFamily, `${adjustedFontSize}px`, fontWeight);
212
+ maxTextLineWidth = Math.max(maxTextLineWidth, lineWidth);
213
+ });
214
+
215
+ const requiredWidth = iconSpace + maxTextLineWidth;
216
+ maxYAxisLabelWidth = Math.max(maxYAxisLabelWidth, requiredWidth);
217
+ } else {
218
+ // 只需缩小字体,不需换行
219
+ yLabelsInfo[dim] = {
220
+ fontSize: adjustedFontSize,
221
+ lines: [dim],
222
+ needsWrap: false
223
+ };
224
+
225
+ const requiredWidth = iconSpace + measureTextWidth(dim, fontFamily, `${adjustedFontSize}px`, fontWeight);
226
+ maxYAxisLabelWidth = Math.max(maxYAxisLabelWidth, requiredWidth);
227
+ }
228
+ } else {
229
+ // 标签长度正常
230
+ yLabelsInfo[dim] = {
231
+ fontSize: baseFontSize,
232
+ lines: [dim],
233
+ needsWrap: false
234
+ };
235
+
236
+ const requiredWidth = iconSpace + labelWidth;
237
+ maxYAxisLabelWidth = Math.max(maxYAxisLabelWidth, requiredWidth);
238
+ }
239
+ });
240
+
241
+ const yAxisPadding = 20;
242
+ // 减少左边距30像素
243
+ margin.left = Math.max(margin.left, maxYAxisLabelWidth + yAxisPadding) - 30;
244
+
245
+ // --- 5c: 最终确定内部尺寸 ---
246
+ const innerWidth = width - margin.left - margin.right;
247
+ const innerHeight = height - margin.top - margin.bottom;
248
+
249
+ // ---------- 6. 比例尺 ----------
250
+ const yScale = d3.scaleBand()
251
+ .domain(dimensions)
252
+ .range([0, innerHeight])
253
+ .padding(0.1); // Y轴类别比例尺
254
+
255
+ const xScale = d3.scaleLinear()
256
+ .domain(tempXScale.domain()) // 复用优化后的域
257
+ .range([0, innerWidth]); // X轴数值比例尺
258
+
259
+ const colorScale = colorResolver.scale(groups, { palette: "category10" }); // 分组颜色比例尺
260
+
261
+ // 点的半径,基于Y轴条带高度,有上下限
262
+ const pointRadius = Math.max(3, Math.min(yScale.bandwidth() * 0.25, 10));
263
+
264
+ // ---------- 7. SVG 容器与主分组 ----------
265
+ const svg = d3.select(containerSelector)
266
+ .append("svg")
267
+ .attr("width", "100%")
268
+ .attr("height", height)
269
+ .attr("viewBox", `0 0 ${width} ${height}`) // 实现响应式缩放
270
+ .attr("style", `max-width: 100%; height: auto;`)
271
+ .attr("xmlns", "http://www.w3.org/2000/svg")
272
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink"); // 用于xlink:href
273
+
274
+ const g = svg.append("g")
275
+ .attr("transform", `translate(${margin.left}, ${margin.top})`); // 主绘图区
276
+
277
+ // ---------- 8. 绘制坐标轴与网格线 ----------
278
+
279
+ // X轴 (仅标签)
280
+ const xAxis = d3.axisBottom(xScale)
281
+ .ticks(5)
282
+ .tickFormat(d => chartUtils.format.autoText(+(d)))
283
+ .tickSizeOuter(0);
284
+
285
+ g.append("g")
286
+ .attr("class", "x-axis")
287
+ .attr("transform", `translate(0, ${innerHeight})`)
288
+ .call(xAxis)
289
+ .call(g => g.select(".domain").remove()) // 移除轴线
290
+ .call(g => g.selectAll(".tick line").remove()) // 移除刻度线
291
+ .call(g => g.selectAll(".tick text")
292
+ .style("font-family", typography.label.font_family)
293
+ .style("font-size", typography.label.font_size)
294
+ .style("fill", colorResolver.text({ fallback: "#333" }).value));
295
+
296
+ // 添加X轴标题
297
+ g.append("text")
298
+ .attr("class", "x-axis-title")
299
+ .attr("x", innerWidth / 2)
300
+ .attr("y", innerHeight + margin.bottom / 2 + 5)
301
+ .attr("text-anchor", "middle")
302
+ .style("font-family", typography.label.font_family)
303
+ .style("font-size", typography.label.font_size)
304
+ .style("font-weight", "bold")
305
+ .style("fill", colorResolver.text({ fallback: "#333" }).value)
306
+ .text(valueField); // 使用Y变量名称作为标题
307
+
308
+ // --- 绘制垂直网格线 (手动) ---
309
+ const gridPadding = 5; // 网格线上下延伸距离
310
+ const yTopGrid = dimensions.length > 0 ? yScale(dimensions[0]) + yScale.bandwidth() / 2 : 0; // 最上方水平网格线Y
311
+ const yBottomGrid = dimensions.length > 0 ? yScale(dimensions[dimensions.length - 1]) + yScale.bandwidth() / 2 : innerHeight; // 最下方水平网格线Y
312
+
313
+ const xTickValues = xScale.ticks(5); // 获取刻度值
314
+
315
+ g.append("g")
316
+ .attr("class", "grid vertical-grid")
317
+ .selectAll("line")
318
+ .data(xTickValues)
319
+ .enter()
320
+ .append("line")
321
+ .attr("x1", d => xScale(d))
322
+ .attr("x2", d => xScale(d))
323
+ .attr("y1", yTopGrid - gridPadding) // 起点Y
324
+ .attr("y2", yBottomGrid + gridPadding) // 终点Y
325
+ .attr("stroke", "#aaaaaa")
326
+ .attr("stroke-opacity", 0.7);
327
+
328
+
329
+ // 水平网格线 (每个Y类别条带中心)
330
+ g.append("g")
331
+ .attr("class", "grid horizontal-grid")
332
+ .selectAll("line")
333
+ .data(dimensions)
334
+ .enter()
335
+ .append("line")
336
+ .attr("x1", 0)
337
+ .attr("x2", innerWidth)
338
+ .attr("y1", d => yScale(d) + yScale.bandwidth() / 2) // Y坐标在条带中心
339
+ .attr("y2", d => yScale(d) + yScale.bandwidth() / 2)
340
+ .attr("stroke", "#aaaaaa")
341
+ .attr("stroke-opacity", 0.7);
342
+
343
+ // ---------- 9. 绘制Y轴标签与图标 ----------
344
+ const yAxisGroup = g.append("g").attr("class", "y-axis-labels");
345
+
346
+ dimensions.forEach(dim => {
347
+ const yPos = yScale(dim) + yScale.bandwidth() / 2; // 类别条带中心Y
348
+ const labelInfo = yLabelsInfo[dim];
349
+ const lineCount = labelInfo.lines.length;
350
+ const lineHeight = parseFloat(labelInfo.fontSize) * 1.2; // 行高
351
+ const totalTextHeight = lineHeight * lineCount;
352
+
353
+ // 标签X位置 (不考虑图标)
354
+ const labelX = -5;
355
+
356
+ // 处理多行文本或单行文本
357
+ if (labelInfo.needsWrap && lineCount > 1) {
358
+ const labelGroup = yAxisGroup.append("g")
359
+ .attr("class", "y-axis-label-group");
360
+
361
+ // 计算多行文本的起始Y位置(居中对齐)
362
+ const startY = yPos - (totalTextHeight / 2) + (lineHeight / 2);
363
+
364
+ labelInfo.lines.forEach((line, i) => {
365
+ labelGroup.append("text")
366
+ .attr("x", labelX)
367
+ .attr("y", startY + (i * lineHeight))
368
+ .attr("text-anchor", "end") // 右对齐
369
+ .style("font-family", typography.label.font_family)
370
+ .style("font-size", `${labelInfo.fontSize}px`)
371
+ .style("font-weight", typography.label.font_weight)
372
+ .style("fill", colorResolver.text({ fallback: "#333" }).value)
373
+ .text(line);
374
+ });
375
+ } else {
376
+ // 单行文本
377
+ yAxisGroup.append("text")
378
+ .attr("x", labelX)
379
+ .attr("y", yPos)
380
+ .attr("dy", "0.35em") // 垂直微调居中
381
+ .attr("text-anchor", "end") // 右对齐
382
+ .style("font-family", typography.label.font_family)
383
+ .style("font-size", `${labelInfo.fontSize}px`)
384
+ .style("font-weight", typography.label.font_weight)
385
+ .style("fill", colorResolver.text({ fallback: "#333" }).value)
386
+ .text(dim);
387
+ }
388
+ });
389
+
390
+ // ---------- 10. 绘制数据点 ----------
391
+ g.append("g")
392
+ .attr("class", "scatter-points")
393
+ .selectAll("circle")
394
+ .data(chartData)
395
+ .enter()
396
+ .append("circle")
397
+ .attr("cx", d => xScale(+d[valueField]))
398
+ .attr("cy", d => yScale(d[dimensionField]) + yScale.bandwidth() / 2) // Y坐标在条带中心
399
+ .attr("r", pointRadius)
400
+ .attr("fill", d => colorScale(d[groupField]));
401
+
402
+ // ---------- 11. 绘制图例 ----------
403
+ const initialLegendFontSize = parseFloat(typography.label?.font_size || 12);
404
+ const legendFontWeight = typography.label?.font_weight || "normal";
405
+ const legendFontFamily = typography.label?.font_family || "Arial";
406
+ const legendColor = colorResolver.text({ fallback: "#333333" }).value;
407
+ const legendItemPadding = 5; // 标记与文本间距
408
+ const legendColumnPadding = 20; // 图例项间距,增加了间距
409
+ const legendMinimumFontSize = 9; // 最小图例字体大小
410
+ const legendRowPadding = 15; // 图例行间距,增加以防止行间重叠
411
+
412
+ // 图例标题设置
413
+ const legendTitle = groupField; // 使用分组字段名作为图例标题
414
+ const legendTitleFontSize = initialLegendFontSize;
415
+ const legendTitleFontWeight = "bold"; // 标题加粗
416
+
417
+ if (groups.length > 0) {
418
+ const legendFontSize = parseFloat(typography.label?.font_size || 12);
419
+ const legendFontWeight = typography.label?.font_weight || "normal";
420
+ const legendFontFamily = typography.label?.font_family || "Arial";
421
+ const legendColor = colorResolver.text({ fallback: "#333333" }).value;
422
+ const baseWidth = (typeof chartWidth === "number" ? chartWidth : innerWidth);
423
+ const legendWidth = baseWidth * 0.9;
424
+ const legendCenterX = margin.left + baseWidth / 2;
425
+ const legendY = margin.top / 2;
426
+
427
+ if (typeof groupField === "string" && groupField.length > 0) {
428
+ svg.append("text")
429
+ .attr("class", "legend-title")
430
+ .attr("x", legendCenterX)
431
+ .attr("y", legendY - (legendFontSize + 6))
432
+ .attr("text-anchor", "middle")
433
+ .style("font-family", legendFontFamily)
434
+ .style("font-size", `${legendFontSize + 1}px`)
435
+ .style("font-weight", "bold")
436
+ .style("fill", legendColor)
437
+ .text(groupField);
438
+ }
439
+
440
+ chartUtils.legend.centered(svg, groups, {
441
+ colorScale,
442
+ colorResolver,
443
+ colors,
444
+ }, legendCenterX, legendY, {
445
+ markerShape: "circle",
446
+ markerSize: pointRadius * 2,
447
+ labelGap: 5,
448
+ itemGap: 20,
449
+ rowGap: 15,
450
+ maxWidth: legendWidth,
451
+ fitToWidth: true,
452
+ minFontSize: 9,
453
+ fontSize: legendFontSize,
454
+ fontFamily: legendFontFamily,
455
+ fontWeight: legendFontWeight,
456
+ textColor: legendColor,
457
+ });
458
+ }
459
+
460
+ // ---------- 12. 返回 SVG 节点 ----------
461
+ return svg.node();
462
+ }