/* 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(); }