/* REQUIREMENTS_BEGIN { "chart_type": "Scatterplot", "chart_name": "scatterplot_01", "required_fields": ["x", "y", "y2"], "required_fields_type": [["categorical"], ["numerical"], ["numerical"]], "required_fields_range": [[8, 32], [0, "inf"], [0, "inf"]], "required_fields_icons": [], "required_other_icons": [], "required_fields_colors": [], "required_other_colors": ["primary"], "supported_effects": [], "min_height": 750, "min_width": 750, "background": "light", "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 xField = chartUtils.schema.column(dataColumns, 0).key; const yField = chartUtils.schema.column(dataColumns, 1).key; const y2Field = chartUtils.schema.column(dataColumns, 2).key; const yUnit = chartUtils.schema.column(dataColumns, 1).unit || ""; const y2Unit = chartUtils.schema.column(dataColumns, 2).unit || ""; const width = variables.width; const height = variables.height; const textColor = sourceColors.text_color || "#333333"; const primary = (sourceColors.other && sourceColors.other.primary) || "#5278a8"; const secondary = (sourceColors.other && sourceColors.other.secondary) || "#d3b45f"; const colorPool = [ primary, secondary, ...((sourceColors.available_colors || []).filter(Boolean)) ].filter((value, index, arr) => value && arr.indexOf(value) === index); const fallbackColors = ["#5278a8", "#d06d58", "#6e9f67", "#8c6bb1", "#c49a3d"]; const palette = colorPool.length ? colorPool : fallbackColors; const labelFontFamily = (typography.label && typography.label.font_family) || "Arial"; const labelFontWeight = (typography.label && typography.label.font_weight) || 500; const annotationFontFamily = (typography.annotation && typography.annotation.font_family) || labelFontFamily; const chartData = rawData .map((d, i) => ({ ...d, __sourceIndex: i, __label: String(d[xField]), __x: Number(d[yField]), __y: Number(d[y2Field]) })) .filter(d => Number.isFinite(d.__x) && Number.isFinite(d.__y)) .sort((a, b) => d3.ascending(a.__x, b.__x) || d3.descending(a.__y, b.__y) || d3.ascending(a.__label, b.__label)) .map((d, i) => ({ ...d, __id: i + 1 })); 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"); const margin = { top: Math.max(30, height * 0.06), right: Math.max(34, width * 0.06), bottom: Math.max(60, height * 0.11), left: Math.max(70, width * 0.12) }; const chartWidth = Math.max(260, width - margin.left - margin.right); const chartHeight = Math.max(260, height - margin.top - margin.bottom); const chartX = margin.left; const chartY = margin.top; function humanizeField(name) { return String(name) .replace(/_/g, " ") .replace(/([a-z])([A-Z])/g, "$1 $2") .replace(/\s+/g, " ") .trim(); } function formatNumber(value) { const abs = Math.abs(value); if (abs >= 1000000000) return d3.format(".3s")(value).replace("G", "B"); if (abs >= 1000000) return d3.format(".3s")(value); if (abs >= 1000) return d3.format(".3s")(value); if (abs >= 100) return d3.format(",.0f")(value); if (abs >= 10) return d3.format(",.1f")(value).replace(/\.0$/, ""); return d3.format(",.2f")(value).replace(/\.?0+$/, ""); } function formatLocalValue(value, unit) { const numberText = formatNumber(value); if (!unit) return numberText; const prefixMatch = String(unit).match(/^([$€£¥])(.+)?$/); if (prefixMatch) { return `${prefixMatch[1]}${numberText}${prefixMatch[2] || ""}`; } if (unit === "%") return `${numberText}%`; return `${numberText}${unit}`; } function axisTitle(column, unit) { const title = humanizeField(column.name || ""); if (!unit || title.includes(unit)) return title; return `${title} (${unit})`; } function 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; let max = extent[1] + pad; if (extent[0] >= 0 && min < 0) min = 0; return [min, max]; } function compactOneLabel(text, maxChars) { const clean = String(text).replace(/\s+/g, " ").trim(); if (clean.length <= maxChars) return clean; const words = clean.split(" ").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; } const head = Math.max(4, Math.floor((maxChars - 1) * 0.55)); const tail = Math.max(4, maxChars - head - 1); return `${clean.slice(0, head)}/${clean.slice(clean.length - tail)}`; } function compactLabels(rows, maxChars) { const used = new Set(); rows.forEach((row) => { let label = compactOneLabel(row.__label, maxChars); if (used.has(label)) { const suffix = ` ${row.__id}`; label = `${compactOneLabel(row.__label, Math.max(4, maxChars - suffix.length))}${suffix}`; } while (used.has(label)) { label = `${compactOneLabel(row.__label, Math.max(4, maxChars - 3))} ${row.__id}`; } used.add(label); row.__displayLabel = label; }); } compactLabels(chartData, chartData.length > 18 ? 16 : 20); const xScale = d3.scaleLinear() .domain(paddedDomain(chartData.map(d => d.__x))) .nice() .range([0, chartWidth]); const yScale = d3.scaleLinear() .domain(paddedDomain(chartData.map(d => d.__y))) .nice() .range([chartHeight, 0]); const xTickCount = Math.max(3, Math.min(6, Math.floor(chartWidth / 75))); const yTickCount = Math.max(3, Math.min(6, Math.floor(chartHeight / 85))); const chart = svg.append("g") .attr("class", "scatterplot-plot-area") .attr("transform", `translate(${chartX},${chartY})`); chart.append("rect") .attr("class", "plot-background") .attr("x", -10) .attr("y", -10) .attr("width", chartWidth + 20) .attr("height", chartHeight + 20) .attr("rx", 10) .attr("fill", "#fbfff7") .attr("stroke", primary) .attr("stroke-width", 1.2) .attr("opacity", 0.92); chart.append("rect") .attr("class", "plot-grid-background") .attr("width", chartWidth) .attr("height", chartHeight) .attr("fill", "#ffffff") .attr("opacity", 0.28); const xAxis = d3.axisBottom(xScale) .ticks(xTickCount) .tickFormat(d => formatLocalValue(d, yUnit)) .tickSize(-chartHeight) .tickPadding(8); const yAxis = d3.axisLeft(yScale) .ticks(yTickCount) .tickFormat(d => formatLocalValue(d, y2Unit)) .tickSize(-chartWidth) .tickPadding(8); const xAxisGroup = chart.append("g") .attr("class", "axis x-axis") .attr("transform", `translate(0,${chartHeight})`) .call(xAxis); const yAxisGroup = chart.append("g") .attr("class", "axis y-axis") .call(yAxis); chart.selectAll(".domain") .attr("stroke", primary) .attr("stroke-width", 1.8) .attr("opacity", 0.95); chart.selectAll(".tick line") .attr("class", "gridline") .attr("stroke", primary) .attr("stroke-width", 0.65) .attr("stroke-dasharray", "4 4") .attr("opacity", 0.18); chart.selectAll(".tick text") .attr("class", "axis-tick") .attr("fill", textColor) .style("font-family", annotationFontFamily) .style("font-size", "11px") .style("font-weight", 500); const arrowSize = 8; chart.append("path") .attr("class", "axis-arrow x-axis-arrow") .attr("d", `M${chartWidth},${chartHeight} l${-arrowSize},${-arrowSize / 2} l0,${arrowSize} Z`) .attr("fill", primary) .attr("opacity", 0.95); chart.append("path") .attr("class", "axis-arrow y-axis-arrow") .attr("d", `M0,0 l${-arrowSize / 2},${arrowSize} l${arrowSize},0 Z`) .attr("fill", primary) .attr("opacity", 0.95); if (xScale.domain()[0] < 0 && xScale.domain()[1] > 0) { chart.append("line") .attr("class", "zero-line") .attr("x1", xScale(0)) .attr("x2", xScale(0)) .attr("y1", 0) .attr("y2", chartHeight) .attr("stroke", textColor) .attr("stroke-width", 1.1) .attr("opacity", 0.45); } if (yScale.domain()[0] < 0 && yScale.domain()[1] > 0) { chart.append("line") .attr("class", "zero-line") .attr("x1", 0) .attr("x2", chartWidth) .attr("y1", yScale(0)) .attr("y2", yScale(0)) .attr("stroke", textColor) .attr("stroke-width", 1.1) .attr("opacity", 0.45); } chart.append("text") .attr("class", "axis-title x-axis-title") .attr("x", chartWidth / 2) .attr("y", chartHeight + 48) .attr("text-anchor", "middle") .attr("fill", textColor) .style("font-family", labelFontFamily) .style("font-size", "13px") .style("font-weight", 700) .text(axisTitle(dataColumns[1], yUnit)); chart.append("text") .attr("class", "axis-title y-axis-title") .attr("transform", "rotate(-90)") .attr("x", -chartHeight / 2) .attr("y", -48) .attr("text-anchor", "middle") .attr("fill", textColor) .style("font-family", labelFontFamily) .style("font-size", "13px") .style("font-weight", 700) .text(axisTitle(dataColumns[2], y2Unit)); function rectsOverlap(a, b, pad = 2) { return !(a.x + a.w + pad < b.x || b.x + b.w + pad < a.x || a.y + a.h + pad < b.y || b.y + b.h + pad < a.y); } function estimateLabelWidth(text, fontSize) { return Math.max(24, String(text).length * fontSize * 0.56); } const pointRadius = Math.max(7.5, Math.min(12, Math.min(chartWidth, chartHeight) / 32)); const pointPositions = chartData.map(d => ({ row: d, x: xScale(d.__x), y: yScale(d.__y) })); const pointAvoidRects = pointPositions.map(p => ({ x: p.x - pointRadius - 2, y: p.y - pointRadius - 2, w: pointRadius * 2 + 4, h: pointRadius * 2 + 4 })); function computePointLabels() { const fontSize = chartData.length > 18 ? 8.5 : 9.5; const placed = []; const gap = pointRadius + 5; const sorted = pointPositions.slice().sort((a, b) => a.y - b.y || a.x - b.x); sorted.forEach((p) => { const labelText = compactOneLabel(p.row.__label, chartData.length > 18 ? 15 : 18); const labelWidth = estimateLabelWidth(labelText, fontSize); const candidates = [ { side: "right", dx: gap, dy: 3, anchor: "start" }, { side: "left", dx: -gap, dy: 3, anchor: "end" }, { side: "upper-right", dx: gap, dy: -10, anchor: "start" }, { side: "lower-right", dx: gap, dy: 14, anchor: "start" }, { side: "upper-left", dx: -gap, dy: -10, anchor: "end" }, { side: "lower-left", dx: -gap, dy: 14, anchor: "end" } ]; let chosen = null; for (const candidate of candidates) { const x = p.x + candidate.dx; const y = p.y + candidate.dy - fontSize * 0.85; const rect = { x: candidate.anchor === "end" ? x - labelWidth : x, y, w: labelWidth, h: fontSize + 3 }; const inside = rect.x >= 2 && rect.y >= 2 && rect.x + rect.w <= chartWidth - 2 && rect.y + rect.h <= chartHeight - 2; const collides = placed.some(existing => rectsOverlap(rect, existing, 2)) || pointAvoidRects.some(existing => rectsOverlap(rect, existing, 0)); if (inside && !collides) { chosen = { ...candidate, text: labelText, fontSize, rect }; break; } } if (!chosen) { const anchor = p.x > chartWidth * 0.72 ? "end" : "start"; const dx = anchor === "end" ? -gap : gap; chosen = { side: anchor === "end" ? "left-fallback" : "right-fallback", dx, dy: 3, anchor, text: compactOneLabel(p.row.__label, 12), fontSize: Math.max(7.5, fontSize - 1), rect: { x: anchor === "end" ? p.x + dx - estimateLabelWidth(compactOneLabel(p.row.__label, 12), Math.max(7.5, fontSize - 1)) : p.x + dx, y: p.y + 3 - fontSize * 0.85, w: estimateLabelWidth(compactOneLabel(p.row.__label, 12), Math.max(7.5, fontSize - 1)), h: fontSize + 3 } }; } placed.push(chosen.rect); p.row.__labelPlacement = chosen; }); } computePointLabels(); const points = chart.selectAll(".data-point") .data(chartData) .enter() .append("g") .attr("class", "data-point") .attr("transform", d => `translate(${xScale(d.__x)},${yScale(d.__y)})`); points.append("circle") .attr("class", "point-mark mark") .attr("r", pointRadius) .attr("fill", d => palette[(d.__id - 1) % palette.length]) .attr("fill-opacity", 0.86) .attr("stroke", "#ffffff") .attr("stroke-width", 2) .attr("filter", "drop-shadow(0 2px 2px rgba(36, 65, 38, 0.24))"); points.append("circle") .attr("class", "point-marker-interior asset-slot") .attr("data-asset-slot", "point-marker-interior") .attr("data-no-embedded-raster", "true") .attr("data-no-copied-reference-art", "true") .attr("data-slot-anchor", "point-center") .attr("data-collision-rule", "clipped-within-marker-avoid-labels-axes") .attr("r", pointRadius * 0.58) .attr("fill", "#ffffff") .attr("fill-opacity", 0.24); 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.5px" : "7.5px") .style("font-weight", 700) .text(d => d.__id); const labelLayer = chart.append("g") .attr("class", "direct-point-label-layer"); labelLayer.selectAll(".direct-point-label") .data(chartData) .enter() .append("text") .attr("class", "direct-point-label category-label label") .attr("x", d => xScale(d.__x) + d.__labelPlacement.dx) .attr("y", d => yScale(d.__y) + d.__labelPlacement.dy) .attr("text-anchor", d => d.__labelPlacement.anchor) .attr("dominant-baseline", "central") .attr("fill", textColor) .style("font-family", labelFontFamily) .style("font-size", d => `${d.__labelPlacement.fontSize}px`) .style("font-weight", 650) .text(d => d.__labelPlacement.text); const tooltip = d3.select("body").append("div") .attr("class", "tooltip") .style("opacity", 0); points .on("mouseover", function(event, d) { d3.select(this).select("circle") .attr("stroke", textColor) .attr("stroke-width", 2); tooltip.transition() .duration(160) .style("opacity", 0.9); tooltip.html(`${d.__label}
${axisTitle(dataColumns[1], yUnit)}: ${formatLocalValue(d.__x, yUnit)}
${axisTitle(dataColumns[2], y2Unit)}: ${formatLocalValue(d.__y, y2Unit)}`) .style("left", (event.pageX + 10) + "px") .style("top", (event.pageY - 20) + "px"); }) .on("mouseout", function() { d3.select(this).select("circle") .attr("stroke", "#ffffff") .attr("stroke-width", 1.5); tooltip.transition() .duration(300) .style("opacity", 0); }); const xs = chartData.map(d => d.__x); const ys = chartData.map(d => d.__y); const meanX = d3.mean(xs); const meanY = d3.mean(ys); const covariance = d3.mean(chartData.map(d => (d.__x - meanX) * (d.__y - meanY))) || 0; const varianceX = d3.mean(xs.map(v => Math.pow(v - meanX, 2))) || 0; const varianceY = d3.mean(ys.map(v => Math.pow(v - meanY, 2))) || 0; const correlation = varianceX && varianceY ? covariance / Math.sqrt(varianceX * varianceY) : 0; const positiveTrend = correlation >= 0; const trendLayer = chart.insert("g", ".data-point") .attr("class", "trend-line-layer"); if (varianceX > 0) { const slope = covariance / varianceX; const intercept = meanY - slope * meanX; const xDomain = xScale.domain(); const trendLine = [ { x: xDomain[0], y: slope * xDomain[0] + intercept }, { x: xDomain[1], y: slope * xDomain[1] + intercept } ]; trendLayer.append("line") .attr("class", "correlation-trend-line") .attr("x1", xScale(trendLine[0].x)) .attr("y1", yScale(trendLine[0].y)) .attr("x2", xScale(trendLine[1].x)) .attr("y2", yScale(trendLine[1].y)) .attr("stroke", positiveTrend ? primary : secondary) .attr("stroke-width", 1.4) .attr("stroke-dasharray", "5 5") .attr("opacity", 0.56); } function drawCallout(parent, cfg) { const g = parent.append("g") .attr("class", `scatter-callout ${cfg.className || ""}`) .attr("transform", `translate(${cfg.x},${cfg.y})`); g.append("rect") .attr("class", "callout-card") .attr("width", cfg.w) .attr("height", cfg.h) .attr("rx", 6) .attr("fill", cfg.fill) .attr("stroke", cfg.stroke) .attr("stroke-width", 1) .attr("opacity", 0.94); g.append("rect") .attr("class", "callout-icon-slot asset-slot") .attr("data-asset-slot", "callout-icon") .attr("data-no-embedded-raster", "true") .attr("data-no-copied-reference-art", "true") .attr("data-slot-anchor", cfg.className || "callout") .attr("data-collision-rule", "inside-callout-padding") .attr("x", 7) .attr("y", cfg.h / 2 - 8) .attr("width", 16) .attr("height", 16) .attr("rx", 4) .attr("fill", cfg.stroke) .attr("opacity", 0.18); g.append("text") .attr("class", "callout-title label") .attr("x", 30) .attr("y", 18) .attr("fill", cfg.stroke) .style("font-family", labelFontFamily) .style("font-size", "8.5px") .style("font-weight", 800) .text(cfg.title); g.append("text") .attr("class", "callout-subtitle label") .attr("x", 30) .attr("y", 32) .attr("fill", textColor) .style("font-family", annotationFontFamily) .style("font-size", "8.5px") .style("font-weight", 500) .text(cfg.subtitle); return g; } const annotationAvoid = pointAvoidRects.concat(chartData.map(d => d.__labelPlacement.rect)); const calloutLayer = chart.append("g").attr("class", "scatterplot-callout-layer"); const calloutW = Math.min(132, chartWidth * 0.28); const calloutH = 40; const cornerCandidates = [ { className: "high-y-low-x", x: 12, y: 12, w: calloutW, h: calloutH, title: `HIGH ${humanizeField(y2Field).toUpperCase()}`, subtitle: `Lower ${humanizeField(yField)}`, fill: "#f7fff2", stroke: primary }, { className: "high-y-high-x", x: chartWidth - calloutW - 12, y: 12, w: calloutW, h: calloutH, title: `HIGH ${humanizeField(y2Field).toUpperCase()}`, subtitle: `High ${humanizeField(yField)}`, fill: "#f7fff2", stroke: primary }, { className: "low-y-low-x", x: 12, y: chartHeight - calloutH - 12, w: calloutW, h: calloutH, title: `LOW ${humanizeField(y2Field).toUpperCase()}`, subtitle: `Lower ${humanizeField(yField)}`, fill: "#fff9ef", stroke: secondary }, { className: "low-y-high-x", x: chartWidth - calloutW - 12, y: chartHeight - calloutH - 12, w: calloutW, h: calloutH, title: `LOW ${humanizeField(y2Field).toUpperCase()}`, subtitle: `High ${humanizeField(yField)}`, fill: "#fff9ef", stroke: secondary } ]; cornerCandidates.forEach((cfg) => { const rect = { x: cfg.x, y: cfg.y, w: cfg.w, h: cfg.h }; const collides = annotationAvoid.some(existing => rectsOverlap(rect, existing, 4)); if (!collides) drawCallout(calloutLayer, cfg); }); const insightText = `${positiveTrend ? "Higher" : "Lower"} ${humanizeField(yField)} tends to align with ${positiveTrend ? "higher" : "lower"} ${humanizeField(y2Field)}.`; const insightW = Math.min(190, chartWidth * 0.34); const insightH = 56; const insightCandidates = [ { x: chartWidth - insightW - 18, y: chartHeight - insightH - 54 }, { x: chartWidth - insightW - 18, y: Math.max(18, chartHeight * 0.52) }, { x: 18, y: Math.max(18, chartHeight * 0.48) } ]; const insightSpot = insightCandidates.find(c => !annotationAvoid.some(existing => rectsOverlap({ x: c.x, y: c.y, w: insightW, h: insightH }, existing, 5))); if (insightSpot) { const g = calloutLayer.append("g") .attr("class", "scatter-insight-card") .attr("transform", `translate(${insightSpot.x},${insightSpot.y})`); g.append("rect") .attr("width", insightW) .attr("height", insightH) .attr("rx", 7) .attr("fill", "#f4fbef") .attr("stroke", primary) .attr("stroke-width", 1) .attr("opacity", 0.94); g.append("circle") .attr("class", "callout-icon-slot asset-slot") .attr("data-asset-slot", "callout-icon") .attr("data-no-embedded-raster", "true") .attr("data-no-copied-reference-art", "true") .attr("data-slot-anchor", "insight-card") .attr("data-collision-rule", "inside-callout-padding") .attr("cx", 21) .attr("cy", 20) .attr("r", 10) .attr("fill", primary) .attr("opacity", 0.16); g.append("text") .attr("class", "insight-title label") .attr("x", 39) .attr("y", 19) .attr("fill", primary) .style("font-family", labelFontFamily) .style("font-size", "9px") .style("font-weight", 800) .text("KEY TAKEAWAY"); const words = insightText.split(" "); const lines = []; let line = ""; words.forEach((word) => { const test = line ? `${line} ${word}` : word; if (test.length > 31 && line) { lines.push(line); line = word; } else { line = test; } }); if (line) lines.push(line); g.selectAll(".insight-line") .data(lines.slice(0, 3)) .enter() .append("text") .attr("class", "insight-line label") .attr("x", 39) .attr("y", (d, i) => 34 + i * 11) .attr("fill", textColor) .style("font-family", annotationFontFamily) .style("font-size", "8.5px") .text(d => d); } return svg.node(); }