Ray1ee01's picture
Upload folder using huggingface_hub
e134901 verified
Raw
History Blame Contribute Delete
12.8 kB
/*
REQUIREMENTS_BEGIN
{
"chart_type": "Scatterplot",
"chart_name": "scatterplot_02",
"required_fields": ["x", "y", "y2"],
"required_fields_type": [["categorical"], ["numerical"], ["numerical"]],
"required_fields_range": [[8, 150], ["-inf", "inf"], ["-inf", "inf"]],
"required_fields_icons": ["x"],
"required_other_icons": ["primary"],
"required_fields_colors": [],
"required_other_colors": ["primary"],
"supported_effects": ["shadow", "radius_corner"],
"min_height": 750,
"min_width": 750,
"background": "no",
"icon_mark": "none",
"icon_label": "side",
"has_x_axis": "no",
"has_y_axis": "no"
}
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);
// 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);
// Set dimensions and margins
const width = variables.width;
const height = variables.height;
// 创建临时axis获取标签
const yExtent = d3.extent(chartData, d => +d[y2Field]);
const tempYScale = d3.scaleLinear()
.domain([yExtent[0] - (yExtent[1] - yExtent[0]) * 0.1, yExtent[1] + (yExtent[1] - yExtent[0]) * 0.1])
.range([height, 0]);
// 计算最长标签的宽度
const tickValues = tempYScale.ticks ? tempYScale.ticks() : tempYScale.domain();
const tickFormat = tempYScale.tickFormat ? tempYScale.tickFormat() : d => d;
const maxLabelWidth = d3.max(tickValues, tick => chartUtils.text.measure(null, tickFormat(tick), {
fontFamily: "sans-serif",
fontSize: 10,
fontWeight: "normal"
}).width) || 0;
// 根据最长标签计算左边距,加上一些额外空间和y轴标题空间
const leftMargin = Math.max(50, maxLabelWidth + 20 + 25); // 标签宽度 + tick线 + 标题空间
const margin = { top: 25, right: 25, bottom: 50, left: leftMargin };
// 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})`);
// Create scales
const xExtent = d3.extent(chartData, d => +d[yField]);
// 使用线性比例尺,确保支持负值
const xScale = d3.scaleLinear()
.domain([xExtent[0] - (xExtent[1] - xExtent[0]) * 0.1, xExtent[1] + (xExtent[1] - xExtent[0]) * 0.1])
.range([0, chartWidth]);
const yScale = d3.scaleLinear()
.domain([yExtent[0] - (yExtent[1] - yExtent[0]) * 0.1, yExtent[1] + (yExtent[1] - yExtent[0]) * 0.1])
.range([chartHeight, 0]);
// Create axes with grid lines
const xAxis = d3.axisBottom(xScale)
.tickSize(-chartHeight)
.tickPadding(10)
.tickFormat(d => chartUtils.format.number(d).text);
const yAxis = d3.axisLeft(yScale)
.tickSize(-chartWidth)
.tickPadding(10)
.tickFormat(d => chartUtils.format.number(d).text);
// Add X axis
const xAxisGroup = g.append("g")
.attr("class", "axis x-axis")
.attr("transform", `translate(0, ${chartHeight})`)
.call(xAxis);
// Add Y axis
const yAxisGroup = g.append("g")
.attr("class", "axis y-axis")
.call(yAxis);
// 修改网格线样式 - 必须先选择tick线条
g.selectAll(".tick line")
.style("stroke", "#ddd")
.style("stroke-width", 0.5)
.style("opacity", 0.5);
// 移除轴线域路径
g.selectAll(".domain").remove();
// 设置轴刻度文本样式
g.selectAll(".tick text")
.style("color", colorResolver.text({ fallback: "#333333" }).value)
.style("font-size", "10px");
// 添加参考线 - 零轴 (放在网格线之后,保证在上层)
g.append("line")
.attr("x1", 0)
.attr("y1", yScale(0))
.attr("x2", chartWidth)
.attr("y2", yScale(0))
.style("stroke", "#000")
.style("stroke-width", 1)
.style("opacity", 0.5);
g.append("line")
.attr("x1", xScale(0))
.attr("y1", 0)
.attr("x2", xScale(0))
.attr("y2", chartHeight)
.style("stroke", "#000")
.style("stroke-width", 1)
.style("opacity", 0.5);
// Add axis titles
g.append("text")
.attr("class", "axis-title")
.attr("x", chartWidth / 2)
.attr("y", chartHeight + margin.bottom - 10)
.attr("text-anchor", "middle")
.attr("font-size", 13)
.text("Difference in yards per attempt");
// 修改Y轴标题的位置,根据左边距自适应
g.append("text")
.attr("class", "axis-title")
.attr("transform", "rotate(-90)")
.attr("x", -chartHeight / 2)
.attr("y", -margin.left + Math.min(30, leftMargin / 3)) // 根据边距自适应调整
.attr("text-anchor", "middle")
.attr("font-size", 13)
.text("Difference in points above replacement");
// 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);
// Add data points
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", "white")
.attr("stroke", "white")
.attr("stroke-width", 4);
// Add icon images
points.append("image")
.attr("xlink:href", d => images.field[d[xField]])
.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]);
return svg.node();
}