Ray1ee01's picture
Upload folder using huggingface_hub
e134901 verified
Raw
History Blame Contribute Delete
24.8 kB
/*
REQUIREMENTS_BEGIN
{
"chart_type": "Scatterplot",
"chart_name": "scatterplot_03",
"required_fields": ["x", "y", "y2"],
"required_fields_type": [["categorical"], ["numerical"], ["numerical"]],
"required_fields_range": [[8, 24], [0, "inf"], [0, "inf"]],
"required_fields_icons": [],
"required_other_icons": [],
"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": "none",
"has_x_axis": "yes",
"has_y_axis": "yes"
}
REQUIREMENTS_END
*/
function makeChart(containerSelector, data) {
const jsonData = data || {};
const rawData = jsonData.data?.data || [];
const variables = jsonData.variables || {};
const typography = jsonData.typography || {};
const dataColumns = chartUtils.schema.columns(jsonData);
const colorResolver = chartUtils.color.resolver(jsonData);
const chartUtilsFormatSample = chartUtils.format.autoText;
const chartUtilsTextSample = chartUtils.text.estimate;
const chartUtilsStandard = {
schema: chartUtils.schema,
format: chartUtils.format,
text: chartUtils.text,
color: chartUtils.color,
legendLayout: chartUtils.legend.layout,
legendDraw: chartUtils.legend.draw,
random: chartUtils.random.generator(jsonData, "dev-wz-style")
};
const standardChannels = chartUtils.schema.channels(jsonData, {
x: { fallbackIndex: 0 },
y: { fallbackIndex: 1 },
y2: { fallbackIndex: 2 },
y3: { fallbackIndex: 3 },
size: { fallbackIndex: 2 },
group: { fallbackIndex: 2 },
group2: { fallbackIndex: 3 },
group3: { fallbackIndex: 4 }
});
const sourceColors = jsonData.colors || {};
d3.select(containerSelector).html("");
const categoryColumn = dataColumns[0] || {};
const xColumn = dataColumns[1] || {};
const yColumn = dataColumns[2] || {};
const categoryField = categoryColumn.name;
const xField = xColumn.name;
const yField = yColumn.name;
const xUnit = xColumn.unit || "";
const yUnit = yColumn.unit || "";
const width = Math.max(520, Number(variables.width) || 600);
const height = Math.max(520, Number(variables.height) || 600);
const textColor = sourceColors.text_color || "#12325A";
const primary = sourceColors.other?.primary || sourceColors.primary || "#1F75C9";
const secondary = sourceColors.other?.secondary || "#25A978";
const mutedText = "#66758A";
const gridColor = "#BED3E2";
const panelBg = "#F2FAFF";
const cardBg = "#FFFFFF";
const axisColor = "#14345A";
const labelFontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
const labelFontWeight = typography.label?.font_weight || 600;
const annotationFontFamily = typography.annotation?.font_family || labelFontFamily;
const titleFontFamily = typography.title?.font_family || labelFontFamily;
const colorPool = [
primary,
secondary,
...((sourceColors.available_colors || []).filter(Boolean))
].filter((value, index, arr) => value && arr.indexOf(value) === index);
const fallbackColors = ["#1F75C9", "#25A978", "#F2B632", "#D65A70", "#7756B5", "#607D8B"];
const palette = colorPool.length ? colorPool : fallbackColors;
const svg = d3.select(containerSelector)
.append("svg")
.attr("width", "100%")
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("style", "max-width: 100%; height: auto;")
.attr("xmlns", "http://www.w3.org/2000/svg")
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
.attr("class", "scatterplot-root reference-layout");
if (!categoryField || !xField || !yField) {
svg.append("text")
.attr("x", width / 2)
.attr("y", height / 2)
.attr("text-anchor", "middle")
.attr("fill", textColor)
.style("font-family", labelFontFamily)
.style("font-size", "16px")
.text("Missing scatterplot fields");
return svg.node();
}
const humanizeField = name => String(name || "")
.replace(/_/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/\s+/g, " ")
.trim();
const compactNumber = value => {
const abs = Math.abs(value);
if (abs >= 1000000000) return `${d3.format(".2~f")(value / 1000000000)}B`;
if (abs >= 1000000) return `${d3.format(".2~f")(value / 1000000)}M`;
if (abs >= 1000) return `${d3.format(".2~f")(value / 1000)}K`;
if (abs >= 100) return d3.format(",.0f")(value);
if (abs >= 10) return d3.format(",.1f")(value).replace(/\.0$/, "");
if (abs >= 1) return d3.format(",.2f")(value).replace(/\.?0+$/, "");
return d3.format(",.3f")(value).replace(/\.?0+$/, "");
};
const formatLocalValue = (value, unit) => {
const numberText = compactNumber(value);
const prefixMatch = String(unit || "").match(/^([$€£¥])(.+)?$/);
if (!unit) return numberText;
if (prefixMatch) return `${prefixMatch[1]}${numberText}${prefixMatch[2] || ""}`;
if (unit === "%") return `${numberText}%`;
return `${numberText}${unit}`;
};
const axisTitle = (column, unit) => {
const title = humanizeField(column.label || column.name || "");
if (!unit || title.includes(unit)) return title;
return `${title} (${unit})`;
};
const shortLabel = (value, maxChars = 14) => {
const clean = humanizeField(value);
if (clean.length <= maxChars) return clean;
const words = clean.split(/[\s/,-]+/).filter(Boolean);
if (words.length > 1) {
const firstLast = `${words[0]} ${words[words.length - 1]}`;
if (firstLast.length <= maxChars) return firstLast;
const initialLast = `${words[0][0]}. ${words[words.length - 1]}`;
if (initialLast.length <= maxChars) return initialLast;
}
return `${clean.slice(0, Math.max(1, maxChars - 1)).trim()}.`;
};
const paddedDomain = values => {
const extent = d3.extent(values);
if (!Number.isFinite(extent[0]) || !Number.isFinite(extent[1])) return [0, 1];
if (extent[0] === extent[1]) {
const pad = Math.max(Math.abs(extent[0]) * 0.1, 1);
return [extent[0] - pad, extent[1] + pad];
}
const pad = (extent[1] - extent[0]) * 0.08;
let min = extent[0] - pad;
const max = extent[1] + pad;
if (extent[0] >= 0 && min < 0) min = 0;
return [min, max];
};
const chartData = rawData
.map((d, index) => ({
...d,
__sourceIndex: index,
__label: String(d[categoryField]),
__x: Number(d[xField]),
__y: Number(d[yField])
}))
.filter(d => Number.isFinite(d.__x) && Number.isFinite(d.__y))
.sort((a, b) => d3.ascending(a.__x, b.__x) || d3.ascending(a.__y, b.__y) || d3.ascending(a.__label, b.__label))
.map((d, i) => ({ ...d, __id: i + 1 }));
if (!chartData.length) {
svg.append("text")
.attr("x", width / 2)
.attr("y", height / 2)
.attr("text-anchor", "middle")
.attr("fill", textColor)
.style("font-family", labelFontFamily)
.style("font-size", "16px")
.text("Not enough data");
return svg.node();
}
const mean = values => d3.mean(values) || 0;
const xMean = mean(chartData.map(d => d.__x));
const yMean = mean(chartData.map(d => d.__y));
const covariance = d3.sum(chartData, d => (d.__x - xMean) * (d.__y - yMean));
const xVariance = d3.sum(chartData, d => Math.pow(d.__x - xMean, 2));
const yVariance = d3.sum(chartData, d => Math.pow(d.__y - yMean, 2));
const slope = xVariance === 0 ? 0 : covariance / xVariance;
const intercept = yMean - slope * xMean;
const correlation = xVariance === 0 || yVariance === 0 ? 0 : covariance / Math.sqrt(xVariance * yVariance);
const corrLabel = Math.abs(correlation) >= 0.65 ? "Strong" : Math.abs(correlation) >= 0.35 ? "Moderate" : "Weak";
const corrDirection = correlation >= 0 ? "positive" : "negative";
const defs = svg.append("defs");
const bgGradient = defs.append("linearGradient")
.attr("id", "scatter-ref-bg")
.attr("x1", "0%")
.attr("x2", "0%")
.attr("y1", "0%")
.attr("y2", "100%");
bgGradient.append("stop").attr("offset", "0%").attr("stop-color", "#F7FCFF");
bgGradient.append("stop").attr("offset", "100%").attr("stop-color", panelBg);
const root = svg.append("g")
.attr("class", "scatterplot-reference-panel")
.attr("data-tag", "chart");
root.append("rect")
.attr("class", "chart-style-panel")
.attr("x", 2)
.attr("y", 2)
.attr("width", width - 4)
.attr("height", height - 4)
.attr("rx", 16)
.attr("fill", "url(#scatter-ref-bg)");
const addIconSlot = (group, cx, cy, r, className, fill, glyph) => {
const icon = group.append("g").attr("class", className);
icon.append("circle")
.attr("cx", cx)
.attr("cy", cy)
.attr("r", r)
.attr("fill", fill)
.attr("opacity", 0.95);
icon.append("text")
.attr("x", cx)
.attr("y", cy + 1)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("fill", "#FFFFFF")
.style("font-family", labelFontFamily)
.style("font-size", `${Math.max(11, r * 0.76)}px`)
.style("font-weight", "900")
.text(glyph);
return icon;
};
const chipY = 22;
const chipH = 56;
const chipGap = 16;
const chipW = Math.min(240, (width - 64 - chipGap) / 2);
const chipX = Math.max(22, (width - chipW * 2 - chipGap) / 2);
const addMetricChip = (x, color, glyph, title, subtitle, className) => {
const chip = root.append("g")
.attr("class", `metric-chip ${className}`)
.attr("transform", `translate(${x}, ${chipY})`);
chip.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", chipW)
.attr("height", chipH)
.attr("rx", 13)
.attr("fill", cardBg)
.attr("stroke", "#D7E6F0")
.attr("stroke-width", 1);
addIconSlot(chip, 32, chipH / 2, 18, "metric-icon-slot decorative-icon-slot", color, glyph);
chip.append("text")
.attr("class", "metric-title")
.attr("x", 62)
.attr("y", 23)
.attr("fill", color)
.style("font-family", titleFontFamily)
.style("font-size", "12px")
.style("font-weight", "900")
.text(shortLabel(title, Math.max(12, Math.floor((chipW - 74) / 6.3))));
chip.append("text")
.attr("class", "metric-subtitle")
.attr("x", 62)
.attr("y", 40)
.attr("fill", mutedText)
.style("font-family", annotationFontFamily)
.style("font-size", "8.5px")
.style("font-weight", "650")
.text(shortLabel(subtitle, Math.max(16, Math.floor((chipW - 74) / 5.4))));
};
addMetricChip(chipX, primary, "X", axisTitle(xColumn, xUnit), "Horizontal axis", "x-metric-chip");
addMetricChip(chipX + chipW + chipGap, secondary, "Y", axisTitle(yColumn, yUnit), "Vertical axis", "y-metric-chip");
const footerH = Math.max(48, Math.min(64, height * 0.105));
const footerY = height - footerH - 12;
const plotX = Math.max(58, width * 0.11);
const plotY = chipY + chipH + 46;
const plotW = width - plotX - Math.max(24, width * 0.055);
const plotH = Math.max(260, footerY - plotY - 58);
const xScale = d3.scaleLinear()
.domain(paddedDomain(chartData.map(d => d.__x)))
.nice()
.range([0, plotW]);
const yScale = d3.scaleLinear()
.domain(paddedDomain(chartData.map(d => d.__y)))
.nice()
.range([plotH, 0]);
const xTickCount = Math.max(4, Math.min(7, Math.floor(plotW / 80)));
const yTickCount = Math.max(4, Math.min(7, Math.floor(plotH / 70)));
const plot = root.append("g")
.attr("class", "scatterplot-plot-area")
.attr("transform", `translate(${plotX},${plotY})`);
plot.append("rect")
.attr("class", "plot-background")
.attr("x", -10)
.attr("y", -10)
.attr("width", plotW + 20)
.attr("height", plotH + 20)
.attr("rx", 10)
.attr("fill", "#FFFFFF")
.attr("opacity", 0.48);
const xAxis = d3.axisBottom(xScale)
.ticks(xTickCount)
.tickFormat(d => formatLocalValue(d, xUnit))
.tickSize(-plotH)
.tickPadding(8);
const yAxis = d3.axisLeft(yScale)
.ticks(yTickCount)
.tickFormat(d => formatLocalValue(d, yUnit))
.tickSize(-plotW)
.tickPadding(8);
plot.append("g")
.attr("class", "axis x-axis")
.attr("transform", `translate(0,${plotH})`)
.call(xAxis);
plot.append("g")
.attr("class", "axis y-axis")
.call(yAxis);
plot.selectAll(".domain")
.attr("stroke", axisColor)
.attr("stroke-width", 1.4)
.attr("opacity", 0.82);
plot.selectAll(".tick line")
.attr("class", "gridline")
.attr("stroke", gridColor)
.attr("stroke-width", 0.8)
.attr("opacity", 0.72)
.attr("stroke-dasharray", "3 3");
plot.selectAll(".tick text")
.attr("class", "axis-tick")
.attr("fill", axisColor)
.style("font-family", annotationFontFamily)
.style("font-size", "9px")
.style("font-weight", "650");
const xDomain = xScale.domain();
const trendPoints = [
[xDomain[0], slope * xDomain[0] + intercept],
[xDomain[1], slope * xDomain[1] + intercept]
].map(([x, y]) => [xScale(x), yScale(Math.max(yScale.domain()[0], Math.min(yScale.domain()[1], y)))]);
plot.append("line")
.attr("class", "trend-line")
.attr("x1", trendPoints[0][0])
.attr("y1", trendPoints[0][1])
.attr("x2", trendPoints[1][0])
.attr("y2", trendPoints[1][1])
.attr("stroke", correlation >= 0 ? primary : "#C61B6A")
.attr("stroke-width", 1.8)
.attr("stroke-dasharray", "5 5")
.attr("opacity", 0.78);
plot.append("text")
.attr("class", "axis-title y-axis-title")
.attr("x", -24)
.attr("y", -22)
.attr("text-anchor", "start")
.attr("fill", secondary)
.style("font-family", titleFontFamily)
.style("font-size", "12px")
.style("font-weight", "900")
.text(axisTitle(yColumn, yUnit));
plot.append("text")
.attr("class", "axis-title x-axis-title")
.attr("x", plotW)
.attr("y", plotH + 42)
.attr("text-anchor", "end")
.attr("fill", primary)
.style("font-family", titleFontFamily)
.style("font-size", "12px")
.style("font-weight", "900")
.text(axisTitle(xColumn, xUnit));
const pointRadius = Math.max(6.6, Math.min(9.6, Math.min(plotW, plotH) / 40));
const labelMaxChars = chartData.length > 18 ? 9 : 12;
const labelFontSize = chartData.length > 18 ? 7.4 : 8.4;
const labelRows = [];
const occupiedLabelBounds = [];
const overlapArea = (a, b) => {
const xOverlap = Math.min(a.x2, b.x2) - Math.max(a.x1, b.x1);
const yOverlap = Math.min(a.y2, b.y2) - Math.max(a.y1, b.y1);
return xOverlap > 0 && yOverlap > 0 ? xOverlap * yOverlap : 0;
};
const labelWidth = text => Math.max(18, Math.min(90, String(text).length * labelFontSize * 0.64));
const makeBounds = (x, y, anchor, text) => {
const w = labelWidth(text);
const h = labelFontSize * 1.18;
const padX = 2.4;
const padY = 1.5;
let x1 = x;
let x2 = x + w;
if (anchor === "end") {
x1 = x - w;
x2 = x;
} else if (anchor === "middle") {
x1 = x - w / 2;
x2 = x + w / 2;
}
return { x1: x1 - padX, y1: y - h - padY, x2: x2 + padX, y2: y + 2 + padY };
};
const outsidePenalty = b => (
Math.max(0, 3 - b.x1) +
Math.max(0, b.x2 - (plotW - 3)) +
Math.max(0, 2 - b.y1) +
Math.max(0, b.y2 - (plotH - 4))
);
const pointOverlapPenalty = bounds => d3.sum(chartData, d => {
const px = xScale(d.__x);
const py = yScale(d.__y);
const inside = px >= bounds.x1 && px <= bounds.x2 && py >= bounds.y1 && py <= bounds.y2;
return inside ? 55 : 0;
});
const candidateLabelPositions = d => {
const px = xScale(d.__x);
const py = yScale(d.__y);
const preferLeft = px > plotW * 0.68;
const preferDown = py < plotH * 0.22;
const sideOrder = preferLeft ? [-1, 1] : [1, -1];
const verticalOrder = preferDown ? [1, -1, 0] : [-1, 1, 0];
const candidates = [];
sideOrder.forEach((side, sideIndex) => {
verticalOrder.forEach((vertical, verticalIndex) => {
const dx = side * (pointRadius + (vertical === 0 ? 8 : 6));
const dy = vertical === 0 ? 3 : vertical * (pointRadius + 6);
candidates.push({
x: px + dx,
y: py + dy,
anchor: side === 1 ? "start" : "end",
priority: sideIndex * 18 + verticalIndex * 7
});
});
});
[0, -1, 1].forEach((vertical, index) => {
const dy = vertical === 0
? (preferDown ? pointRadius + 12 : -pointRadius - 8)
: vertical * (pointRadius + 16);
candidates.push({
x: px,
y: py + dy,
anchor: "middle",
priority: 42 + index * 8
});
});
return candidates;
};
[...chartData]
.sort((a, b) => d3.ascending(yScale(a.__y), yScale(b.__y)) || d3.descending(xScale(a.__x), xScale(b.__x)))
.forEach(d => {
const text = shortLabel(d.__label, labelMaxChars);
const px = xScale(d.__x);
const py = yScale(d.__y);
const best = candidateLabelPositions(d)
.map(candidate => {
const bounds = makeBounds(candidate.x, candidate.y, candidate.anchor, text);
const overlaps = occupiedLabelBounds
.map(existing => overlapArea(bounds, existing))
.filter(area => area > 0);
const labelOverlap = d3.sum(overlaps);
const distance = Math.hypot(candidate.x - px, candidate.y - py);
const score = candidate.priority +
outsidePenalty(bounds) * 7 +
labelOverlap * 6 +
overlaps.length * 120 +
pointOverlapPenalty(bounds) +
distance * 0.6;
return { ...candidate, bounds, score };
})
.sort((a, b) => d3.ascending(a.score, b.score))[0];
occupiedLabelBounds.push(best.bounds);
labelRows.push({ datum: d, text, x: best.x, y: best.y, anchor: best.anchor });
});
const points = plot.selectAll(".data-point")
.data(chartData)
.enter()
.append("g")
.attr("class", "data-point")
.attr("data-label", d => d.__label)
.attr("data-x", d => d.__x)
.attr("data-y", d => d.__y)
.attr("data-source-index", d => d.__sourceIndex)
.attr("data-rank", d => d.__id)
.attr("transform", d => `translate(${xScale(d.__x)},${yScale(d.__y)})`);
points.append("circle")
.attr("class", "point-mark mark")
.attr("data-tag", "mark")
.attr("data-label", d => d.__label)
.attr("data-x", d => d.__x)
.attr("data-y", d => d.__y)
.attr("data-source-index", d => d.__sourceIndex)
.attr("data-rank", d => d.__id)
.attr("r", pointRadius)
.attr("fill", d => palette[(d.__id - 1) % palette.length])
.attr("fill-opacity", 0.96)
.attr("stroke", "#FFFFFF")
.attr("stroke-width", 1.8);
points.append("text")
.attr("class", "point-id label")
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("fill", "#FFFFFF")
.style("font-family", annotationFontFamily)
.style("font-size", pointRadius < 8.5 ? "6.2px" : "7px")
.style("font-weight", "900")
.text(d => d.__id);
plot.append("g")
.attr("class", "point-label-layer")
.selectAll(".category-label")
.data(labelRows)
.enter()
.append("text")
.attr("class", "category-label label")
.attr("data-label", d => d.datum.__label)
.attr("data-x", d => d.datum.__x)
.attr("data-y", d => d.datum.__y)
.attr("data-source-index", d => d.datum.__sourceIndex)
.attr("data-rank", d => d.datum.__id)
.attr("x", d => d.x)
.attr("y", d => d.y)
.attr("text-anchor", d => d.anchor)
.attr("fill", axisColor)
.attr("stroke", "#FFFFFF")
.attr("stroke-width", 3)
.attr("paint-order", "stroke")
.style("font-family", labelFontFamily)
.style("font-size", `${labelFontSize}px`)
.style("font-weight", "800")
.text(d => d.text);
const footer = root.append("g")
.attr("class", "footer-note-card")
.attr("transform", `translate(${Math.max(20, width * 0.045)}, ${footerY})`);
const footerW = width - Math.max(20, width * 0.045) * 2;
footer.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", footerW)
.attr("height", footerH)
.attr("rx", 12)
.attr("fill", "#FFFFFF")
.attr("opacity", 0.78);
addIconSlot(footer, 24, footerH / 2, 14, "footer-icon-slot decorative-icon-slot", primary, "i");
const corrPillW = Math.min(142, Math.max(108, footerW * 0.24));
const corrPillH = 34;
const corrPillX = footerW - corrPillW - 10;
const corrPillY = Math.max(7, footerH / 2 - corrPillH / 2);
const corr = footer.append("g")
.attr("class", "correlation-card footer-correlation-pill")
.attr("transform", `translate(${corrPillX}, ${corrPillY})`);
corr.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", corrPillW)
.attr("height", corrPillH)
.attr("rx", 10)
.attr("fill", "#F8FCFF")
.attr("stroke", correlation >= 0 ? primary : "#C61B6A")
.attr("stroke-width", 1);
addIconSlot(corr, 19, corrPillH / 2, 12, "correlation-icon-slot decorative-icon-slot", correlation >= 0 ? primary : "#C61B6A", "r");
corr.append("text")
.attr("class", "correlation-label")
.attr("x", 38)
.attr("y", 14)
.attr("fill", textColor)
.style("font-family", labelFontFamily)
.style("font-size", "7.7px")
.style("font-weight", "800")
.text(`${corrLabel} ${corrDirection}`);
corr.append("text")
.attr("class", "correlation-value")
.attr("x", 38)
.attr("y", 27)
.attr("fill", correlation >= 0 ? primary : "#C61B6A")
.style("font-family", annotationFontFamily)
.style("font-size", "8px")
.style("font-weight", "900")
.text(`r = ${d3.format(".2f")(correlation)}`);
const footerTextLimit = Math.max(18, Math.floor((corrPillX - 56) / 5.2));
footer.append("text")
.attr("class", "source-note")
.attr("x", 48)
.attr("y", footerH / 2 - 8)
.attr("fill", textColor)
.style("font-family", annotationFontFamily)
.style("font-size", "8.5px")
.style("font-weight", "800")
.text(shortLabel(`Source: ${humanizeField(categoryField)} comparison`, footerTextLimit));
footer.append("text")
.attr("class", "scale-note")
.attr("x", 48)
.attr("y", footerH / 2 + 10)
.attr("fill", mutedText)
.style("font-family", annotationFontFamily)
.style("font-size", "8.5px")
.text(shortLabel(`True ${humanizeField(xField)} and ${humanizeField(yField)} values preserved.`, footerTextLimit));
return svg.node();
}