Spaces:
Sleeping
Sleeping
| /* | |
| REQUIREMENTS_BEGIN | |
| { | |
| "chart_type": "Grouped Scatterplot", | |
| "chart_name": "grouped_scatterplot_05", | |
| "required_fields": ["x", "y", "y2", "group"], | |
| "required_fields_type": [["categorical"], ["numerical"], ["numerical"], ["categorical"]], | |
| "required_fields_range": [[8, 150], [0, "inf"], [0, "inf"], [2, 6]], | |
| "required_fields_icons": ["group"], | |
| "required_other_icons": [], | |
| "required_fields_colors": [], | |
| "required_other_colors": ["primary"], | |
| "hierarchy": ["group"], | |
| "supported_effects": ["shadow", "radius_corner"], | |
| "min_height": 750, | |
| "min_width": 750, | |
| "background": "no", | |
| "icon_mark": "replace", | |
| "icon_label": "none", | |
| "has_x_axis": "yes", | |
| "has_y_axis": "yes" | |
| } | |
| REQUIREMENTS_END | |
| */ | |
| function makeChart(containerSelector, data) { | |
| // Extract data | |
| const jsonData = data; | |
| const chartData = jsonData.data.data; | |
| const variables = jsonData.variables; | |
| const typography = jsonData.typography; | |
| const dataColumns = chartUtils.schema.columns(jsonData); | |
| const images = jsonData.images || {}; | |
| const colors = jsonData.colors; | |
| const colorResolver = chartUtils.color.resolver(jsonData); | |
| const effects = variables.effects || {}; | |
| // 强制启用阴影效果和网格线,确保它们始终显示 | |
| const hasShadow = true; // 将其设置为true,不再依赖effects.shadow | |
| const hasGridlines = true; | |
| // Clear container | |
| d3.select(containerSelector).html(""); | |
| // Get field names | |
| const xField = chartUtils.schema.columnField(dataColumns, 0); | |
| const yField = chartUtils.schema.columnField(dataColumns, 1); | |
| const y2Field = chartUtils.schema.columnField(dataColumns, 2); | |
| const groupField = chartUtils.schema.channel(jsonData, "group", { fallbackKey: "" }).key; | |
| // Set dimensions and margins | |
| const width = variables.width; | |
| const height = variables.height; | |
| const margin = { top: 50, right: 25, bottom: 50, left: 50 }; | |
| // 设置标记大小和圆角效果(移到前面定义) | |
| const squareSize = 24; // 正方形的尺寸 | |
| const cornerRadius = effects.radius_corner ? 3 : 0; // 圆角半径,根据效果设置 | |
| // Create SVG | |
| const svg = d3.select(containerSelector) | |
| .append("svg") | |
| .attr("width", width) | |
| .attr("height", height) | |
| .attr("xmlns", "http://www.w3.org/2000/svg") | |
| .attr("xmlns:xlink", "http://www.w3.org/1999/xlink"); | |
| // Create chart area | |
| const chartWidth = width - margin.left - margin.right; | |
| const chartHeight = height - margin.top - margin.bottom; | |
| const g = svg.append("g") | |
| .attr("transform", `translate(${margin.left}, ${margin.top})`); | |
| // 获取分组列表(只在一个地方定义,避免重复) | |
| const groups = [...new Set(chartData.map(d => d[groupField]))]; | |
| // 创建定义区域,用于存放图标和滤镜 | |
| const defs = svg.append("defs"); | |
| // 预先为每个分组定义图标,避免重复 | |
| groups.forEach((group, i) => { | |
| // 如果有图标,添加到defs中以便复用 | |
| if (images.field && images.field[group]) { | |
| defs.append("image") | |
| .attr("id", `icon-${i}`) | |
| .attr("xlink:href", images.field[group]) | |
| .attr("width", squareSize) | |
| .attr("height", squareSize) | |
| .attr("x", -squareSize/2) | |
| .attr("y", -squareSize/2); | |
| } | |
| }); | |
| // Create scales | |
| const xExtent = d3.extent(chartData, d => +d[yField]); | |
| const yExtent = d3.extent(chartData, d => +d[y2Field]); | |
| // 检查数据是否包含负值或0值 | |
| function hasNegativeOrZeroValues(data, field) { | |
| return data.some(d => d[field] <= 0); | |
| } | |
| // 判断数据分布是否不均匀 | |
| function isDistributionUneven(data, field) { | |
| const values = data.map(d => d[field]); | |
| const extent = d3.extent(values); | |
| const range = extent[1] - extent[0]; | |
| const median = d3.median(values); | |
| const q1 = d3.quantile(values.sort(d3.ascending), 0.25); | |
| const q3 = d3.quantile(values.sort(d3.ascending), 0.75); | |
| const iqr = q3 - q1; | |
| // 不均匀分布的判断标准 | |
| return range > iqr * 3 || Math.abs(median - (extent[0] + extent[1])/2) > range * 0.2; | |
| } | |
| // 为X轴创建合适的比例尺 | |
| const xHasNegativeOrZero = hasNegativeOrZeroValues(chartData, yField); | |
| const xIsUneven = isDistributionUneven(chartData, yField); | |
| const xScale = (!xHasNegativeOrZero && xIsUneven) | |
| ? d3.scaleLog() | |
| .domain([Math.max(xExtent[0] * 0.9, 0.1), xExtent[1] * 1.1]) | |
| .range([0, chartWidth]) | |
| : d3.scaleLinear() | |
| .domain([xExtent[0] - (xExtent[1] - xExtent[0]) * 0.1, xExtent[1] + (xExtent[1] - xExtent[0]) * 0.1]) | |
| .range([0, chartWidth]); | |
| // 为Y轴创建合适的比例尺 | |
| const yHasNegativeOrZero = hasNegativeOrZeroValues(chartData, y2Field); | |
| const yIsUneven = isDistributionUneven(chartData, y2Field); | |
| const yScale = (!yHasNegativeOrZero && yIsUneven) | |
| ? d3.scaleLog() | |
| .domain([Math.max(yExtent[0] * 0.9, 0.1), yExtent[1] * 1.1]) | |
| .range([chartHeight, 0]) | |
| : d3.scaleLinear() | |
| .domain([yExtent[0] - (yExtent[1] - yExtent[0]) * 0.1, yExtent[1] + (yExtent[1] - yExtent[0]) * 0.1]) | |
| .range([chartHeight, 0]); | |
| // 添加网格线 - 白色半透明网格线,直接控制网格线数量 | |
| if (hasGridlines) { | |
| // 定义固定的网格线数量,无论是线性还是对数刻度 | |
| const xGridCount = 8; // X轴固定8条网格线 | |
| const yGridCount = 6; // Y轴固定6条网格线 | |
| // 手动计算网格线位置,确保在对数刻度下也有固定数量 | |
| function generateGridPositions(scale, count, domain) { | |
| const min = domain[0]; | |
| const max = domain[1]; | |
| const isLog = scale.constructor.name.includes('Log'); | |
| if (isLog) { | |
| // 对数刻度下创建均匀分布的网格线 | |
| const logMin = Math.log10(Math.max(min, 0.1)); // 保护性处理,避免负数或0 | |
| const logMax = Math.log10(max); | |
| const step = (logMax - logMin) / (count - 1); | |
| return Array.from({length: count}, (_, i) => { | |
| return Math.pow(10, logMin + step * i); | |
| }); | |
| } else { | |
| // 线性刻度下创建均匀分布的网格线 | |
| const step = (max - min) / (count - 1); | |
| return Array.from({length: count}, (_, i) => min + step * i); | |
| } | |
| } | |
| // 生成网格线位置 | |
| const xGridPositions = generateGridPositions( | |
| xScale, | |
| xGridCount, | |
| [xExtent[0] - (xExtent[1] - xExtent[0]) * 0.1, xExtent[1] + (xExtent[1] - xExtent[0]) * 0.1] | |
| ); | |
| const yGridPositions = generateGridPositions( | |
| yScale, | |
| yGridCount, | |
| [yExtent[0] - (yExtent[1] - yExtent[0]) * 0.1, yExtent[1] + (yExtent[1] - yExtent[0]) * 0.1] | |
| ); | |
| // 添加垂直网格线,使用固定数量的位置 | |
| g.append("g") | |
| .attr("class", "grid x-grid") | |
| .attr("transform", `translate(0, ${chartHeight})`) | |
| .call(d3.axisBottom(xScale) | |
| .tickSize(-chartHeight) | |
| .tickFormat("") | |
| .tickValues(xGridPositions) // 使用固定数量的位置 | |
| ) | |
| .selectAll("line") | |
| .style("stroke", "white") | |
| .style("stroke-width", 1.0) | |
| .style("stroke-opacity", 0.5) | |
| .style("stroke-dasharray", "none"); | |
| // 添加水平网格线,使用固定数量的位置 | |
| g.append("g") | |
| .attr("class", "grid y-grid") | |
| .call(d3.axisLeft(yScale) | |
| .tickSize(-chartWidth) | |
| .tickFormat("") | |
| .tickValues(yGridPositions) // 使用固定数量的位置 | |
| ) | |
| .selectAll("line") | |
| .style("stroke", "white") | |
| .style("stroke-width", 1.0) | |
| .style("stroke-opacity", 0.5) | |
| .style("stroke-dasharray", "none"); | |
| // 隐藏网格线轴的路径 | |
| g.selectAll(".grid path") | |
| .style("stroke", "none"); | |
| // 确保网格线显示在数据点下方 | |
| g.selectAll(".grid").lower(); | |
| } | |
| // Create axes with自定义格式化 - 限制tick数量,但确保显示一定数量 | |
| const xAxis = d3.axisBottom(xScale) | |
| .tickSize(0) | |
| .tickPadding(10) | |
| .ticks((!xHasNegativeOrZero && xIsUneven) ? 5 : 6) // 对数刻度使用较少的刻度 | |
| .tickFormat(d => chartUtils.format.autoText(+(d))); | |
| const yAxis = d3.axisLeft(yScale) | |
| .tickSize(0) | |
| .tickPadding(10) | |
| .ticks((!yHasNegativeOrZero && yIsUneven) ? 5 : 6) // 对数刻度使用较少的刻度 | |
| .tickFormat(d => chartUtils.format.autoText(+(d))); | |
| // Add X axis | |
| const xAxisGroup = g.append("g") | |
| .attr("class", "axis x-axis") | |
| .attr("transform", `translate(0, ${chartHeight})`) | |
| .call(xAxis) | |
| xAxisGroup | |
| .selectAll("path") | |
| .style("stroke", colorResolver.text({ fallback: "#333333" }).value) | |
| .style("stroke-width", 1) | |
| .style("opacity", 0.5) | |
| xAxisGroup | |
| .selectAll("text") | |
| .style("color", colorResolver.text({ fallback: "#333333" }).value) | |
| // Add Y axis | |
| const yAxisGroup = g.append("g") | |
| .attr("class", "axis y-axis") | |
| .call(yAxis) | |
| .style("color", colorResolver.text({ fallback: "#333333" }).value) | |
| .style("fill", "black"); | |
| yAxisGroup | |
| .selectAll("path") | |
| .style("stroke", colorResolver.text({ fallback: "#333333" }).value) | |
| .style("stroke-width", 1) | |
| .style("opacity", 0.5) | |
| yAxisGroup | |
| .selectAll("text") | |
| .style("color", colorResolver.text({ fallback: "#333333" }).value) | |
| // 对于对数刻度,更智能地过滤刻度标签,确保至少显示关键点 | |
| if (!xHasNegativeOrZero && xIsUneven) { | |
| // 获取所有刻度值 | |
| const ticks = xAxisGroup.selectAll(".tick").data(); | |
| const visibleTicks = new Set(); | |
| // 确保显示范围端点 | |
| if (ticks.length > 0) { | |
| visibleTicks.add(ticks[0]); | |
| visibleTicks.add(ticks[ticks.length - 1]); | |
| } | |
| // 添加10的整数幂刻度点 | |
| ticks.forEach(d => { | |
| const log10 = Math.log10(d); | |
| if (Math.abs(log10 - Math.round(log10)) < 0.01) { | |
| visibleTicks.add(d); | |
| } | |
| }); | |
| // 如果可见刻度太少,添加一些中间值 | |
| if (visibleTicks.size < 3 && ticks.length > 3) { | |
| // 添加中间点 | |
| const middleIndex = Math.floor(ticks.length / 2); | |
| visibleTicks.add(ticks[middleIndex]); | |
| } | |
| // 隐藏不需要的刻度标签 | |
| xAxisGroup.selectAll(".tick text").each(function(d) { | |
| if (!visibleTicks.has(d)) { | |
| d3.select(this).style("display", "none"); | |
| } | |
| }); | |
| } | |
| if (!yHasNegativeOrZero && yIsUneven) { | |
| // 获取所有刻度值 | |
| const ticks = yAxisGroup.selectAll(".tick").data(); | |
| const visibleTicks = new Set(); | |
| // 确保显示范围端点 | |
| if (ticks.length > 0) { | |
| visibleTicks.add(ticks[0]); | |
| visibleTicks.add(ticks[ticks.length - 1]); | |
| } | |
| // 添加10的整数幂刻度点 | |
| ticks.forEach(d => { | |
| const log10 = Math.log10(d); | |
| if (Math.abs(log10 - Math.round(log10)) < 0.01) { | |
| visibleTicks.add(d); | |
| } | |
| }); | |
| // 如果可见刻度太少,添加一些中间值 | |
| if (visibleTicks.size < 3 && ticks.length > 3) { | |
| // 添加中间点 | |
| const middleIndex = Math.floor(ticks.length / 2); | |
| visibleTicks.add(ticks[middleIndex]); | |
| } | |
| // 隐藏不需要的刻度标签 | |
| yAxisGroup.selectAll(".tick text").each(function(d) { | |
| if (!visibleTicks.has(d)) { | |
| d3.select(this).style("display", "none"); | |
| } | |
| }); | |
| } | |
| // Add axis titles | |
| g.append("text") | |
| .attr("class", "axis-title") | |
| .attr("x", chartWidth) | |
| .attr("y", chartHeight + margin.bottom / 2 + 15) | |
| .attr("text-anchor", "end") | |
| .attr("font-size", 13) | |
| .text(yField); | |
| g.append("text") | |
| .attr("class", "axis-title") | |
| .attr("transform", "rotate(-90)") | |
| .attr("x", -margin.top) | |
| .attr("y", -margin.left / 2 - 10) | |
| .attr("text-anchor", "end") | |
| .attr("font-size", 13) | |
| .text(y2Field); | |
| // Create tooltip | |
| const tooltip = d3.select("body").append("div") | |
| .attr("class", "tooltip") | |
| .style("opacity", 0); | |
| // 计算标签边界的辅助函数(移到外层作用域) | |
| function calculateLabelBounds(pointX, pointY, position, labelWidth, labelHeight) { | |
| let labelX1, labelY1; | |
| if (position.priority === 1) { // right | |
| labelX1 = pointX + 20; | |
| labelY1 = pointY - labelHeight/2; | |
| } else if (position.priority === 2) { // top | |
| labelX1 = pointX - labelWidth / 2; | |
| labelY1 = pointY - 20 - labelHeight; | |
| } else if (position.priority === 3) { // left | |
| labelX1 = pointX - 20 - labelWidth; | |
| labelY1 = pointY - labelHeight/2; | |
| } else if (position.priority === 4) { // bottom | |
| labelX1 = pointX - labelWidth / 2; | |
| labelY1 = pointY + 20; | |
| } else if (position.priority === 5) { // top-right | |
| labelX1 = pointX + 15; | |
| labelY1 = pointY - 15 - labelHeight; | |
| } else if (position.priority === 6) { // top-left | |
| labelX1 = pointX - 15 - labelWidth; | |
| labelY1 = pointY - 15 - labelHeight; | |
| } else if (position.priority === 7) { // bottom-left | |
| labelX1 = pointX - 15 - labelWidth; | |
| labelY1 = pointY + 20; | |
| } else { // bottom-right | |
| labelX1 = pointX + 15; | |
| labelY1 = pointY + 20; | |
| } | |
| return { | |
| x1: labelX1, | |
| y1: labelY1, | |
| x2: labelX1 + labelWidth, | |
| y2: labelY1 + labelHeight | |
| }; | |
| } | |
| // Helper function to find optimal label position | |
| function findOptimalPosition(d, allPoints, currentPositions = {}) { | |
| const positions = [ | |
| { x: 20, y: 4, anchor: "start", priority: 1 }, // right | |
| { x: 0, y: -20, anchor: "middle", priority: 2 }, // top | |
| { x: -20, y: 4, anchor: "end", priority: 3 }, // left | |
| { x: 0, y: 28, anchor: "middle", priority: 4 }, // bottom | |
| { x: 20, y: -20, anchor: "start", priority: 5 }, // top-right | |
| { x: -20, y: -20, anchor: "end", priority: 6 }, // top-left | |
| { x: -20, y: 28, anchor: "end", priority: 7 }, // bottom-left | |
| { x: 20, y: 28, anchor: "start", priority: 8 } // bottom-right | |
| ]; | |
| const pointX = xScale(+d[yField]); | |
| const pointY = yScale(+d[y2Field]); | |
| // 如果已经有位置分配,直接返回 | |
| if (currentPositions[d[xField]]) { | |
| return currentPositions[d[xField]]; | |
| } | |
| const textBBox = chartUtils.text.measure(g, d[xField], { | |
| fontFamily: typography.label.font_family, | |
| fontSize: 10, | |
| }); | |
| const labelWidth = textBBox.width; | |
| const labelHeight = textBBox.height; | |
| // 贪心算法:按优先级顺序尝试每个位置,选择第一个没有重叠的位置 | |
| for (const pos of positions) { | |
| let hasOverlap = false; | |
| // 计算标签边界 | |
| let labelX1, labelY1, labelX2, labelY2; | |
| if (pos.priority === 1) { // right | |
| labelX1 = pointX + 20; | |
| labelY1 = pointY - labelHeight / 2; | |
| } else if (pos.priority === 2) { // top | |
| labelX1 = pointX - labelWidth / 2; | |
| labelY1 = pointY - 20 - labelHeight; | |
| } else if (pos.priority === 3) { // left | |
| labelX1 = pointX - 20 - labelWidth; | |
| labelY1 = pointY - labelHeight / 2; | |
| } else if (pos.priority === 4) { // bottom | |
| labelX1 = pointX - labelWidth / 2; | |
| labelY1 = pointY + 20; | |
| } else if (pos.priority === 5) { // top-right | |
| labelX1 = pointX + 15; | |
| labelY1 = pointY - 15 - labelHeight; | |
| } else if (pos.priority === 6) { // top-left | |
| labelX1 = pointX - 15 - labelWidth; | |
| labelY1 = pointY - 15 - labelHeight; | |
| } else if (pos.priority === 7) { // bottom-left | |
| labelX1 = pointX - 15 - labelWidth; | |
| labelY1 = pointY + 15; | |
| } else { // bottom-right | |
| labelX1 = pointX + 15; | |
| labelY1 = pointY + 15; | |
| } | |
| labelX2 = labelX1 + labelWidth; | |
| labelY2 = labelY1 + labelHeight; | |
| // 检查边界约束 | |
| if (labelX1 < 0 || labelX2 > chartWidth || labelY1 < 0 || labelY2 > chartHeight) { | |
| continue; | |
| } | |
| // 检查与其他点及其标签的重叠 | |
| for (const p of allPoints) { | |
| if (p === d) continue; | |
| const pX = xScale(p[yField]); | |
| const pY = yScale(p[y2Field]); | |
| // 检查与点的重叠 | |
| const pointRadius = circleRadius; | |
| const dx = labelX1 + labelWidth/2 - pX; | |
| const dy = labelY1 + labelHeight/2 - pY; | |
| const distance = Math.sqrt(dx * dx + dy * dy); | |
| if (distance < pointRadius + Math.sqrt(labelWidth * labelWidth + labelHeight * labelHeight) / 2) { | |
| hasOverlap = true; | |
| break; | |
| } | |
| // 检查与其他标签的重叠 | |
| const pPos = currentPositions[p[xField]]; | |
| if (pPos) { | |
| const otherBBox = chartUtils.text.measure(g, p[xField], { | |
| fontFamily: typography.label.font_family, | |
| fontSize: 10, | |
| }); | |
| let otherX1, otherY1; | |
| if (pPos.anchor === "start") { | |
| otherX1 = pX + pPos.x; | |
| otherY1 = pY + pPos.y - otherBBox.height/2; | |
| } else if (pPos.anchor === "middle") { | |
| otherX1 = pX + pPos.x - otherBBox.width/2; | |
| otherY1 = pY + pPos.y; | |
| } else { | |
| otherX1 = pX + pPos.x - otherBBox.width; | |
| otherY1 = pY + pPos.y - otherBBox.height/2; | |
| } | |
| if (labelX1 < otherX1 + otherBBox.width && labelX2 > otherX1 && | |
| labelY1 < otherY1 + otherBBox.height && labelY2 > otherY1) { | |
| hasOverlap = true; | |
| break; | |
| } | |
| } | |
| } | |
| if (!hasOverlap) { | |
| return { ...pos, canShow: true }; | |
| } | |
| } | |
| // 如果所有位置都有重叠,返回优先级最高的位置,但标记为不显示 | |
| return { ...positions[0], canShow: false }; | |
| } | |
| // Determine circle size based on number of data points | |
| const numPoints = chartData.length; | |
| const circleRadius = numPoints <= 15 ? 15 : Math.max(10, 15 - (numPoints - 15) / 20); | |
| // 添加数据点 | |
| const points = g.selectAll(".data-point") | |
| .data(chartData) | |
| .enter() | |
| .append("g") | |
| .attr("class", "data-point") | |
| .attr("transform", d => `translate(${xScale(+d[yField])}, ${yScale(+d[y2Field])})`); | |
| // Add white circular background | |
| points.append("circle") | |
| .attr("r", circleRadius) | |
| .attr("fill", d => colorResolver.field(d[groupField], 0, { fallbackKey: "primary" }).value) | |
| .attr("stroke", d => colorResolver.field(d[groupField], 0, { fallbackKey: "primary" }).value) | |
| .attr("stroke-width", 8); | |
| // Add icon images | |
| points.append("image") | |
| .attr("xlink:href", d => images.field[d[groupField]]) | |
| .attr("width", circleRadius * 2) | |
| .attr("height", circleRadius * 2) | |
| .attr("x", -circleRadius) | |
| .attr("y", -circleRadius); | |
| // Calculate optimal positions for all labels | |
| let labelPositions = {}; | |
| chartData.forEach(d => { | |
| labelPositions[d[xField]] = findOptimalPosition(d, chartData, labelPositions); | |
| }); | |
| // Add labels with optimized positions, only showing non-overlapping ones | |
| points.append("text") | |
| .attr("class", "data-label") | |
| .attr("x", d => labelPositions[d[xField]].x) | |
| .attr("y", d => labelPositions[d[xField]].y) | |
| .attr("text-anchor", d => labelPositions[d[xField]].anchor) | |
| .style("font-family", typography.label.font_family) | |
| .style("font-size", 10) | |
| .style("font-weight", typography.label.font_weight) | |
| .style("opacity", d => labelPositions[d[xField]].canShow ? 1 : 0) | |
| .text(d => d[xField]); | |
| // Add interactivity | |
| points | |
| .on("mouseover", function(event, d) { | |
| const markGroup = d3.select(this).select(".mark-group"); | |
| // 判断是使用图标还是圆形,分别处理放大效果 | |
| if (markGroup.select("use").size() > 0) { | |
| markGroup.select("use") | |
| .transition() | |
| .duration(200) | |
| .attr("transform", "scale(1.2)"); | |
| } else if (markGroup.select("circle").size() > 0) { | |
| markGroup.select("circle") | |
| .transition() | |
| .duration(200) | |
| .attr("r", squareSize * 0.6); | |
| } | |
| d3.select(this).select(".data-label") | |
| .style("font-weight", "bold"); | |
| // 格式化工具提示中的数值 | |
| const yValueFormatted = chartUtils.format.autoText(+(d[yField])); | |
| const y2ValueFormatted = chartUtils.format.autoText(+(d[y2Field])); | |
| tooltip.transition() | |
| .duration(200) | |
| .style("opacity", 0.9); | |
| tooltip.html(`<strong>${d[xField]}</strong><br/>${yField}: ${yValueFormatted}<br/>${y2Field}: ${y2ValueFormatted}`) | |
| .style("left", (event.pageX + 10) + "px") | |
| .style("top", (event.pageY - 20) + "px"); | |
| }) | |
| .on("mouseout", function() { | |
| const markGroup = d3.select(this).select(".mark-group"); | |
| // 判断是使用图标还是圆形,分别处理还原效果 | |
| if (markGroup.select("use").size() > 0) { | |
| markGroup.select("use") | |
| .transition() | |
| .duration(200) | |
| .attr("transform", "scale(1)"); | |
| } else if (markGroup.select("circle").size() > 0) { | |
| markGroup.select("circle") | |
| .transition() | |
| .duration(200) | |
| .attr("r", squareSize/2); | |
| } | |
| d3.select(this).select(".data-label") | |
| .style("font-weight", "normal"); | |
| tooltip.transition() | |
| .duration(500) | |
| .style("opacity", 0); | |
| }); | |
| // 图例配置参数 | |
| const legendFontSize = parseFloat(typography.label?.font_size || 12); | |
| const legendFontWeight = typography.label?.font_weight || "normal"; | |
| const legendFontFamily = typography.label?.font_family || "Arial"; | |
| const legendColor = colorResolver.text({ fallback: "#333333" }).value; | |
| const legendSquareSize = 20; // 图例方块尺寸 | |
| const legendItemPadding = 5; // 方块与文本间距 | |
| const legendColumnPadding = 20; // 图例项间距 | |
| // 创建图例组,标题放在左侧 | |
| if (groups.length > 0) { | |
| // 创建图例容器 | |
| const legendTitleText = groupField; | |
| const titleWidth = chartUtils.text.measure(svg, legendTitleText, { | |
| fontSize: legendFontSize + 1, | |
| fontFamily: legendFontFamily, | |
| fontWeight: "bold", | |
| }).width; | |
| const legendDraw = chartUtils.legend.draw(svg, groups, { | |
| color: group => colorScale(group), | |
| colorResolver, | |
| colors, | |
| }, { | |
| x: margin.left + titleWidth + 15, | |
| y: legendY, | |
| markerShape: "rect", | |
| markerSize: legendSquareSize, | |
| labelGap: legendItemPadding, | |
| itemGap: legendColumnPadding, | |
| fontSize: legendFontSize, | |
| fontFamily: legendFontFamily, | |
| fontWeight: legendFontWeight, | |
| textColor: legendColor, | |
| }); | |
| const legendBgWidth = titleWidth + 15 + legendDraw.width + 20; | |
| const legendBgHeight = legendSquareSize + 20; | |
| const legendBgX = margin.left - 10; | |
| const legendBgY = legendY - legendBgHeight / 2; | |
| const legendGroup = svg.insert("g", ".chart-legend").attr("class", "chart-legend"); | |
| legendGroup.append("rect") | |
| .attr("class", "legend-background") | |
| .attr("x", legendBgX) | |
| .attr("y", legendBgY) | |
| .attr("width", legendBgWidth) | |
| .attr("height", legendBgHeight) | |
| .attr("fill", "white") | |
| .attr("stroke", "#ddd") | |
| .attr("rx", 4) | |
| .attr("ry", 4); | |
| legendGroup.append("text") | |
| .attr("class", "legend-title") | |
| .attr("x", margin.left) | |
| .attr("y", legendY) | |
| .attr("dominant-baseline", "middle") | |
| .style("font-family", legendFontFamily) | |
| .style("font-size", `${legendFontSize + 1}px`) | |
| .style("font-weight", "bold") | |
| .style("fill", legendColor) | |
| .text(legendTitleText); | |
| } | |
| // 统一处理轴标签格式化 | |
| function applyFormattedAxisLabels(axisGroup) { | |
| axisGroup.selectAll(".tick text") | |
| .each(function(d) { | |
| const formattedValue = chartUtils.format.autoText(+(d)); | |
| d3.select(this).text(formattedValue); | |
| }); | |
| } | |
| // 修改轴标签格式 | |
| applyFormattedAxisLabels(xAxisGroup); | |
| applyFormattedAxisLabels(yAxisGroup); | |
| return svg.node(); | |
| } | |