Spaces:
Sleeping
Sleeping
| /* | |
| 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(`<strong>${d.__label}</strong><br/>${axisTitle(dataColumns[1], yUnit)}: ${formatLocalValue(d.__x, yUnit)}<br/>${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(); | |
| } | |