/* REQUIREMENTS_BEGIN { "chart_type": "Bubble Chart", "chart_name": "bubble_chart_01", "required_fields": ["x", "y", "y2"], "required_fields_type": [["categorical"], ["numerical"], ["numerical"]], "required_fields_range": [[8, 30], [0, "inf"], [0, "inf"]], "required_fields_icons": [], "required_other_icons": [], "required_fields_colors": [], "required_other_colors": ["primary"], "supported_effects": ["shadow", "radius_corner", "stroke"], "min_height": 900, "min_width": 650, "background": "light", "icon_mark": "none", "icon_label": "none", "has_title": "yes", "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 titles = jsonData.titles || {}; const metadata = jsonData.metadata || {}; const imageMap = { ...(jsonData.images?.other || {}), ...(jsonData.images?.field || {}) }; const dataColumns = chartUtils.schema.columns(jsonData); d3.select(containerSelector).html(""); const categoryColumn = chartUtils.schema.column(dataColumns, 0).raw || dataColumns[0] || {}; const xColumn = chartUtils.schema.column(dataColumns, 1).raw || dataColumns[1] || {}; const yColumn = chartUtils.schema.column(dataColumns, 2).raw || dataColumns[2] || {}; const categoryField = categoryColumn.name; const xField = xColumn.name; const yField = yColumn.name; const width = Math.max(1080, Number(variables.width) || 1080); const height = Math.max(1536, Number(variables.height) || Math.round(width * 1.42)); const navy = "#08264A"; const green = "#226B34"; const greenDark = "#145D41"; const blue = "#0E5593"; const muted = "#27405A"; const grid = "#C9D5D5"; const canvas = "#FFFDF8"; const panelStroke = "#B9D2B0"; const fontSerif = "Georgia, 'Times New Roman', serif"; const fontSans = "Inter, Arial, sans-serif"; const idSuffix = String(containerSelector || "bubble-chart-01").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 24); 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", "bubble-chart-01-reference-layout"); const defs = svg.append("defs"); const shadowId = `bubble-shadow-${idSuffix}`; const shadow = defs.append("filter").attr("id", shadowId).attr("x", "-35%").attr("y", "-35%").attr("width", "170%").attr("height", "170%"); shadow.append("feDropShadow").attr("dx", 0).attr("dy", 5).attr("stdDeviation", 5).attr("flood-color", "#193657").attr("flood-opacity", 0.18); const arrowId = `bubble-axis-arrow-${idSuffix}`; defs.append("marker").attr("id", arrowId).attr("markerWidth", 10).attr("markerHeight", 10).attr("refX", 8).attr("refY", 5).attr("orient", "auto") .append("path").attr("d", "M0,0 L10,5 L0,10 Z").attr("fill", navy); svg.append("rect").attr("width", width).attr("height", height).attr("fill", canvas); const addEmptyState = message => { svg.append("text") .attr("x", width / 2) .attr("y", height / 2) .attr("text-anchor", "middle") .attr("fill", navy) .style("font-family", fontSans) .style("font-size", "20px") .text(message); return svg.node(); }; if (!categoryField || !xField || !yField) return addEmptyState("Missing bubble chart fields"); const readableName = value => String(value ?? "") .replace(/_/g, " ") .replace(/([a-z])([A-Z])/g, "$1 $2") .replace(/\s+/g, " ") .trim(); const compactLabel = (value, maxChars = 18) => { const text = readableName(value); if (text.length <= maxChars) return text; const words = text.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; } return `${text.slice(0, Math.max(4, maxChars - 4))}...`; }; const wrapWords = (value, maxChars, maxLines = 3) => { const words = String(value ?? "").replace(/\s+/g, " ").trim().split(" ").filter(Boolean); const lines = []; let current = ""; words.forEach(word => { const next = current ? `${current} ${word}` : word; if (next.length <= maxChars || !current) current = next; else { lines.push(current); current = word; } }); if (current) lines.push(current); if (lines.length <= maxLines) return lines; return lines.slice(0, maxLines - 1).concat(compactLabel(lines.slice(maxLines - 1).join(" "), maxChars)); }; const formatTick = value => { const n = Number(value); if (!Number.isFinite(n)) return ""; if (n >= 1000) return `${d3.format("~g")(n / 1000)}k`; return d3.format("~g")(n); }; const formatUnitValue = (value, unit) => { const n = Number(value); if (!Number.isFinite(n)) return ""; const unitText = String(unit || "").trim(); const text = n >= 1000 ? d3.format(".3~s")(n).replace("G", "B") : d3.format(",.2~f")(n); if (unitText === "%") return `${text}%`; if (/^[£$€¥]/.test(unitText)) return `${unitText}${text}`; return unitText ? `${text} ${unitText}` : text; }; const axisTitle = (column, unitOverride) => { const base = readableName(column.label || column.name || ""); const unit = String(unitOverride ?? column.unit ?? "").trim(); if (!unit || unit === "none" || base.includes(unit)) return base; return `${base} (${unit})`; }; const rows = rawData.map((row, index) => ({ raw: row, index, label: readableName(row[categoryField]), x: Number(row[xField]), y: Number(row[yField]) })).filter(d => d.label && Number.isFinite(d.x) && Number.isFinite(d.y) && d.y > 0); if (!rows.length) return addEmptyState("No valid bubble values"); const xUnit = xColumn.unit === "none" ? "" : (xColumn.unit || ""); const yUnit = yColumn.unit === "none" ? "" : (yColumn.unit || ""); const xExtent = d3.extent(rows, d => d.x); const xLooksPercent = xUnit === "%" || (xExtent[0] >= 0 && xExtent[1] <= 100); const xDomain = xLooksPercent ? [Math.max(0, Math.floor(xExtent[0] / 10) * 10 - 5), Math.min(105, Math.ceil(xExtent[1] / 10) * 10 + 5)] : (() => { const pad = Math.max(1, (xExtent[1] - xExtent[0]) * 0.08); return [Math.max(0, xExtent[0] - pad), xExtent[1] + pad]; })(); const yExtent = d3.extent(rows, d => d.y); const tickCandidates = [1, 3, 10, 30, 100, 300, 1000, 3000, 10000, 20000, 30000, 100000, 300000, 1000000]; const yMin = tickCandidates.findLast ? (tickCandidates.findLast(t => t <= yExtent[0]) || tickCandidates[0]) : tickCandidates.filter(t => t <= yExtent[0]).pop() || tickCandidates[0]; const yMax = tickCandidates.find(t => t >= yExtent[1]) || Math.pow(10, Math.ceil(Math.log10(yExtent[1]))); const yTicks = tickCandidates.filter(t => t >= yMin && t <= yMax && (t < yMax || yMax <= yExtent[1] * 1.02)); const titleRaw = readableName(titles.main_title || metadata.title || `${axisTitle(xColumn, xUnit)} by ${axisTitle(yColumn, yUnit)}`); let titleMain = titleRaw; let titleAccent = ""; const renewableMatch = titleRaw.match(/^(.*?)(Renewable Energy Adoption)$/i); if (renewableMatch) { titleMain = renewableMatch[1].trim(); titleAccent = renewableMatch[2].trim(); } const subtitle = String(titles.sub_title || metadata.description || "").trim(); const titleG = svg.append("g") .attr("class", "header-title-art-slot") .attr("data-reserved-slot", "header_title_art_slot") .attr("data-slot-anchor", "top-left-header") .attr("data-slot-bbox", `${Math.round(width * 0.055)},${Math.round(height * 0.05)},${Math.round(width * 0.59)},${Math.round(height * 0.25)}`) .attr("data-collision-rule", "stay left of renewable illustration slot") .attr("transform", `translate(${width * 0.055},${height * 0.055})`); const mainTitleLines = /doesn['’]?t guarantee/i.test(titleMain) ? titleMain.replace(/\s+Doesn['’]?t Guarantee/i, "|Doesn't Guarantee").split("|").filter(Boolean) : wrapWords(titleMain, 20, 2); mainTitleLines.forEach((line, i) => { titleG.append("text") .attr("x", 0) .attr("y", i * 78) .attr("fill", navy) .style("font-family", fontSerif) .style("font-size", "68px") .style("font-weight", "900") .text(line); }); const accentY = mainTitleLines.length * 78 + 6; if (titleAccent) { titleG.append("text") .attr("x", 0) .attr("y", accentY) .attr("fill", green) .style("font-family", fontSerif) .style("font-size", "45px") .style("font-weight", "900") .text(titleAccent); } wrapWords(subtitle, 54, 2).forEach((line, i) => { titleG.append("text") .attr("x", 0) .attr("y", accentY + (titleAccent ? 58 : 12) + i * 34) .attr("fill", navy) .style("font-family", fontSans) .style("font-size", "27px") .style("font-weight", "500") .text(line); }); const dividerY = accentY + (titleAccent ? 150 : 92); titleG.append("line").attr("x1", 6).attr("x2", 245).attr("y1", dividerY).attr("y2", dividerY).attr("stroke", "#80A970").attr("stroke-width", 2); titleG.append("line").attr("x1", 315).attr("x2", 560).attr("y1", dividerY).attr("y2", dividerY).attr("stroke", "#80A970").attr("stroke-width", 2); titleG.append("ellipse").attr("cx", 280).attr("cy", dividerY).attr("rx", 9).attr("ry", 18).attr("fill", "none").attr("stroke", "#5B9C45").attr("stroke-width", 2).attr("transform", `rotate(28 280 ${dividerY})`); const illustration = { x: width * 0.665, y: height * 0.045, w: width * 0.30, h: height * 0.285 }; const illClipId = `header-renewable-clip-${idSuffix}`; defs.append("clipPath").attr("id", illClipId) .append("ellipse") .attr("cx", illustration.x + illustration.w / 2) .attr("cy", illustration.y + illustration.h / 2) .attr("rx", illustration.w / 2) .attr("ry", illustration.h / 2); const illG = svg.append("g") .attr("class", "header-renewable-illustration-slot") .attr("data-reserved-slot", "header_renewable_illustration_slot") .attr("data-slot-anchor", "top-right-header") .attr("data-slot-bbox", `${Math.round(illustration.x)},${Math.round(illustration.y)},${Math.round(illustration.w)},${Math.round(illustration.h)}`) .attr("data-collision-rule", "stay right of title and above side annotation cards"); illG.append("ellipse") .attr("cx", illustration.x + illustration.w / 2) .attr("cy", illustration.y + illustration.h / 2) .attr("rx", illustration.w / 2) .attr("ry", illustration.h / 2) .attr("fill", "#D8F0F2"); if (typeof imageMap.primary === "string" && imageMap.primary.startsWith("data:image")) { illG.append("image") .attr("href", imageMap.primary) .attr("xlink:href", imageMap.primary) .attr("x", illustration.x) .attr("y", illustration.y) .attr("width", illustration.w) .attr("height", illustration.h) .attr("preserveAspectRatio", "xMidYMid slice") .attr("clip-path", `url(#${illClipId})`); } else { illG.append("path").attr("d", `M${illustration.x + 45},${illustration.y + illustration.h - 45} C${illustration.x + 110},${illustration.y + illustration.h - 120} ${illustration.x + 230},${illustration.y + illustration.h - 120} ${illustration.x + illustration.w - 28},${illustration.y + illustration.h - 55} L${illustration.x + illustration.w - 28},${illustration.y + illustration.h} L${illustration.x + 45},${illustration.y + illustration.h} Z`).attr("fill", "#78A947").attr("opacity", 0.82).attr("clip-path", `url(#${illClipId})`); illG.append("circle").attr("cx", illustration.x + illustration.w * 0.65).attr("cy", illustration.y + illustration.h * 0.34).attr("r", 33).attr("fill", "#F4CD66").attr("clip-path", `url(#${illClipId})`); illG.append("rect").attr("x", illustration.x + illustration.w * 0.44).attr("y", illustration.y + illustration.h * 0.36).attr("width", 56).attr("height", 150).attr("rx", 8).attr("fill", "#4C6F7D").attr("clip-path", `url(#${illClipId})`); } const plot = { x: width * 0.09, y: height * 0.335, w: width * 0.84, h: height * 0.53 }; const xScale = d3.scaleLinear().domain(xDomain).range([plot.x, plot.x + plot.w]); const yScale = d3.scaleLog().domain([Math.max(0.1, yMin), yMax]).range([plot.y + plot.h - 38, plot.y]).clamp(true); const radius = d3.scaleSqrt().domain(d3.extent(rows, d => d.y)).range([21, 34]); yTicks.forEach(tick => { const y = yScale(tick); svg.append("line").attr("x1", plot.x).attr("x2", plot.x + plot.w * 0.72).attr("y1", y).attr("y2", y).attr("stroke", grid).attr("stroke-width", 1.2).attr("stroke-dasharray", "4 4"); svg.append("text") .attr("x", plot.x - 18) .attr("y", y + 7) .attr("text-anchor", "end") .attr("fill", navy) .style("font-family", fontSans) .style("font-size", "22px") .text(formatTick(tick)); }); svg.append("line").attr("x1", plot.x).attr("x2", plot.x).attr("y1", plot.y).attr("y2", plot.y + plot.h).attr("stroke", navy).attr("stroke-width", 2.2); svg.append("line").attr("x1", plot.x).attr("x2", plot.x + plot.w).attr("y1", plot.y + plot.h).attr("y2", plot.y + plot.h).attr("stroke", navy).attr("stroke-width", 2.2).attr("marker-end", `url(#${arrowId})`); const xTicks = xScale.ticks(10).filter(t => t >= xDomain[0] && t <= Math.min(100, xDomain[1])); xTicks.forEach(tick => { const x = xScale(tick); svg.append("text") .attr("x", x) .attr("y", plot.y + plot.h + 31) .attr("text-anchor", "middle") .attr("fill", navy) .style("font-family", fontSans) .style("font-size", "22px") .text(d3.format("~g")(tick)); }); svg.append("text") .attr("x", plot.x - 48) .attr("y", plot.y - 28) .attr("fill", navy) .style("font-family", fontSans) .style("font-size", "23px") .style("font-weight", "900") .text(axisTitle(yColumn, yUnit).toUpperCase()); svg.append("text") .attr("x", plot.x + plot.w / 2) .attr("y", plot.y + plot.h + 76) .attr("text-anchor", "middle") .attr("fill", navy) .style("font-family", fontSans) .style("font-size", "26px") .style("font-weight", "900") .text(axisTitle(xColumn, xUnit)); const colorScale = d3.scaleOrdinal().domain(rows.map(d => d.label)).range(["#1F78B4", "#E31A1C", "#33A02C", "#FB9A99", "#FF7F00", "#6A3D9A", "#B2DF8A", "#A6CEE3"]); const pointRows = rows.map((d, i) => { const px = xScale(d.x); const py = yScale(d.y); const r = radius(d.y); const plotRatio = (px - plot.x) / plot.w; const side = plotRatio > 0.72 ? "left" : (plotRatio > 0.24 && plotRatio < 0.62 ? (i % 2 ? "left" : "right") : "right"); return { ...d, px, py, r, side, labelY: py, slotId: `bubble_country_icon_slot_${i + 1}` }; }); const labelFontSize = pointRows.length > 12 ? 12 : 14; const labelHeight = labelFontSize + 6; const obstacles = pointRows.map(d => ({ x0: d.px - d.r - 3, x1: d.px + d.r + 3, y0: d.py - d.r - 3, y1: d.py + d.r + 3, self: d })); const overlaps = (a, b, pad = 2) => Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0) > -pad && Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0) > -pad; const labelBox = (x, y, anchor, text) => { const w = Math.min(165, Math.max(42, text.length * labelFontSize * 0.72)); const x0 = anchor === "end" ? x - w : (anchor === "middle" ? x - w / 2 : x); return { x0, x1: x0 + w, y0: y - labelHeight + 4, y1: y + 5 }; }; const baseCard = { x: width * 0.755, y: height * 0.35, w: width * 0.185, h: height * 0.175, gap: height * 0.02 }; const cardRectsFor = (x, w) => [0, 1].map(i => { const y = baseCard.y + i * (baseCard.h + baseCard.gap); return { x0: x, x1: x + w, y0: y, y1: y + baseCard.h }; }); const bubbleBox = d => ({ x0: d.px - d.r, x1: d.px + d.r, y0: d.py - d.r, y1: d.py + d.r }); const collidingPointsForCards = rects => pointRows.filter(d => rects.some(rect => overlaps(rect, bubbleBox(d), 14))); let card = { ...baseCard }; const baseCollisions = collidingPointsForCards(cardRectsFor(card.x, card.w)); if (baseCollisions.length) { card.w = width * 0.165; const shiftedX = d3.max(baseCollisions, d => d.px + d.r + 18) || card.x; card.x = Math.min(width - card.w - 16, Math.max(card.x, shiftedX)); } const annotationObstacles = cardRectsFor(card.x, card.w).map(rect => ({ ...rect, x0: rect.x0 - 6, x1: rect.x1 + 6, y0: rect.y0 - 6, y1: rect.y1 + 6 })); const placed = []; const labelOrder = [...pointRows].sort((a, b) => d3.descending(a.r, b.r) || d3.ascending(a.py, b.py)); labelOrder.forEach(d => { const label = compactLabel(d.label, pointRows.length > 20 ? 13 : 18); const preferLeft = d.px > plot.x + plot.w * 0.68; const bottomEdge = d.py > plot.y + plot.h - 70; const candidates = bottomEdge ? [ { x: d.px - d.r - 9, y: d.py - d.r - 9, anchor: "end" }, { x: d.px, y: d.py - d.r - 9, anchor: "middle" }, { x: d.px + d.r + 9, y: d.py - d.r - 9, anchor: "start" }, { x: d.px - d.r - 9, y: d.py + 6, anchor: "end" }, { x: d.px + d.r + 9, y: d.py + 6, anchor: "start" } ] : (preferLeft ? [ { x: d.px - d.r - 9, y: d.py + 6, anchor: "end" }, { x: d.px, y: d.py - d.r - 9, anchor: "middle" }, { x: d.px, y: d.py + d.r + labelHeight, anchor: "middle" }, { x: d.px + d.r + 9, y: d.py + 6, anchor: "start" }, { x: d.px - d.r - 12, y: d.py - d.r * 0.45, anchor: "end" }, { x: d.px - d.r - 12, y: d.py + d.r * 0.75, anchor: "end" }, { x: d.px - d.r - 12, y: d.py - d.r - 10, anchor: "end" }, { x: d.px - d.r - 12, y: d.py + d.r + labelHeight, anchor: "end" }, { x: d.px + d.r + 12, y: d.py - d.r * 0.45, anchor: "start" } ] : [ { x: d.px + d.r + 9, y: d.py + 6, anchor: "start" }, { x: d.px - d.r - 9, y: d.py + 6, anchor: "end" }, { x: d.px, y: d.py - d.r - 9, anchor: "middle" }, { x: d.px, y: d.py + d.r + labelHeight, anchor: "middle" }, { x: d.px + d.r + 12, y: d.py - d.r * 0.45, anchor: "start" }, { x: d.px + d.r + 12, y: d.py + d.r * 0.75, anchor: "start" }, { x: d.px + d.r + 12, y: d.py - d.r - 10, anchor: "start" }, { x: d.px + d.r + 12, y: d.py + d.r + labelHeight, anchor: "start" }, { x: d.px - d.r - 12, y: d.py - d.r * 0.45, anchor: "end" } ]); let selected = candidates[0]; let bestScore = Infinity; for (const candidate of candidates) { const box = labelBox(candidate.x, candidate.y, candidate.anchor, label); const inBounds = box.x0 > plot.x - 60 && box.x1 < plot.x + plot.w + 30 && box.y0 > plot.y - 25 && box.y1 < plot.y + plot.h + 35; const hitsPlaced = placed.some(p => overlaps(box, p.box, 7)); const hitsBubble = obstacles.some(o => o.self !== d && overlaps(box, o, 7)); const hitsAnnotation = annotationObstacles.some(o => overlaps(box, o, 7)); const outPenalty = inBounds ? 0 : 100; const collisionPenalty = placed.filter(p => overlaps(box, p.box, 7)).length * 8 + obstacles.filter(o => o.self !== d && overlaps(box, o, 7)).length * 6 + annotationObstacles.filter(o => overlaps(box, o, 7)).length * 30; const distancePenalty = Math.abs(candidate.y - d.py) / 120 + (candidate.anchor === "middle" ? 0.25 : 0); const score = outPenalty + collisionPenalty + distancePenalty; if (score < bestScore) { selected = candidate; bestScore = score; } if (inBounds && !hitsPlaced && !hitsBubble && !hitsAnnotation) { selected = candidate; break; } } d.labelX = selected.x; d.labelY = selected.y; d.anchor = selected.anchor; placed.push({ datum: d, box: labelBox(selected.x, selected.y, selected.anchor, label) }); }); const pointG = svg.append("g").attr("class", "bubble-point-layer"); pointRows.forEach((d, i) => { const clipId = `bubble-clip-${idSuffix}-${i}`; defs.append("clipPath").attr("id", clipId).append("circle").attr("cx", d.px).attr("cy", d.py).attr("r", d.r - 2); const g = pointG.append("g") .attr("class", "bubble-point") .attr("data-label", d.label) .attr("data-x", d.x) .attr("data-y", d.y) .attr("data-reserved-slot", d.slotId) .attr("data-slot-anchor", "data-coordinate-bubble-center") .attr("data-slot-bbox", `${Math.round(d.px - d.r)},${Math.round(d.py - d.r)},${Math.round(d.r * 2)},${Math.round(d.r * 2)}`) .attr("data-collision-rule", "clip icon inside bubble circle and avoid attached label"); g.append("circle") .attr("cx", d.px) .attr("cy", d.py) .attr("r", d.r) .attr("fill", "#FFFFFF") .attr("filter", `url(#${shadowId})`); const imageSrc = imageMap[d.label] || imageMap[d.raw?.[categoryField]]; if (typeof imageSrc === "string" && imageSrc.startsWith("data:image")) { g.append("image") .attr("href", imageSrc) .attr("xlink:href", imageSrc) .attr("x", d.px - d.r + 2) .attr("y", d.py - d.r + 2) .attr("width", (d.r - 2) * 2) .attr("height", (d.r - 2) * 2) .attr("preserveAspectRatio", "xMidYMid slice") .attr("clip-path", `url(#${clipId})`); } else { g.append("circle").attr("cx", d.px).attr("cy", d.py).attr("r", d.r - 4).attr("fill", colorScale(d.label)).attr("fill-opacity", 0.88); } g.append("circle") .attr("cx", d.px) .attr("cy", d.py) .attr("r", d.r) .attr("fill", "none") .attr("stroke", "#FFFFFF") .attr("stroke-width", 4); }); svg.append("g").attr("class", "bubble-label-layer") .selectAll("text") .data(pointRows) .enter() .append("text") .attr("x", d => d.labelX) .attr("y", d => d.labelY + 6) .attr("text-anchor", d => d.anchor) .attr("fill", navy) .attr("paint-order", "stroke") .attr("stroke", canvas) .attr("stroke-width", 4) .attr("stroke-linejoin", "round") .style("font-family", fontSans) .style("font-size", `${labelFontSize}px`) .style("font-weight", "700") .text(d => compactLabel(d.label, pointRows.length > 20 ? 13 : 18)); const highAdoptionCandidates = rows .filter(d => d.x >= d3.quantile(rows.map(r => r.x).sort(d3.ascending), 0.65) && d.y > yMin * 3) .sort((a, b) => d3.ascending(a.y, b.y)); const highXLowY = highAdoptionCandidates[0] || rows .filter(d => d.x >= d3.quantile(rows.map(r => r.x).sort(d3.ascending), 0.65)) .sort((a, b) => d3.ascending(a.y, b.y))[0] || rows[0]; const highXLowYPeer = highAdoptionCandidates.find(d => d.label !== highXLowY.label) || highXLowY; const highYLowerX = rows .filter(d => d.x <= d3.quantile(rows.map(r => r.x).sort(d3.ascending), 0.55)) .sort((a, b) => d3.descending(a.y, b.y))[0] || rows[0]; const cleanMetricName = value => readableName(value) .replace(/\bRate\b/ig, "") .replace(/\s+/g, " ") .trim(); const shortMetricName = (value, maxChars = 22) => compactLabel(cleanMetricName(value), maxChars); const makeTopCardTitle = () => { const xName = shortMetricName(xField, 20); const yName = shortMetricName(yField, 12); const combined = `High ${xName}, lower ${yName}`; return combined.length <= 34 ? combined : `High ${xName}`; }; const makeBottomCardTitle = () => { const yName = shortMetricName(yField, 22); const combined = `High ${yName}, lower adoption`; return combined.length <= 34 ? combined : `High ${yName}`; }; const cardData = [ { color: "#6AA345", title: makeTopCardTitle(), body: `${compactLabel(highXLowY.label, 12)} / ${compactLabel(highXLowYPeer.label, 12)}: ${formatUnitValue(highXLowY.x, xUnit)}+` }, { color: blue, title: makeBottomCardTitle(), body: `${compactLabel(highYLowerX.label, 18)}: ${formatUnitValue(highYLowerX.y, yUnit)}, ${formatUnitValue(highYLowerX.x, xUnit)}` } ]; cardData.forEach((d, i) => { const y = card.y + i * (card.h + card.gap); const narrowCard = card.w < width * 0.18; const titleFontSize = narrowCard ? 19 : 22; const titleLines = wrapWords(d.title, narrowCard ? 13 : 18, narrowCard ? 4 : 2); const bodyY = narrowCard && titleLines.length > 2 ? 198 : 168; const g = svg.append("g") .attr("class", "side-annotation-card") .attr("transform", `translate(${card.x},${y})`); g.append("rect") .attr("width", card.w) .attr("height", card.h) .attr("rx", 16) .attr("fill", i === 0 ? "#F6FBED" : "#F2F9FF") .attr("stroke", i === 0 ? panelStroke : "#BFD5E8") .attr("stroke-width", 1.3); const icon = g.append("g") .attr("class", "side-annotation-icon-slot") .attr("data-reserved-slot", `side_annotation_icon_slot_${i + 1}`) .attr("data-slot-anchor", "annotation-card-top-center") .attr("data-slot-bbox", `${Math.round(card.x + card.w / 2 - 32)},${Math.round(y + 22)},64,64`) .attr("data-collision-rule", "stay inside annotation card above text") .attr("transform", `translate(${card.w / 2},52)`); icon.append("circle").attr("r", 33).attr("fill", d.color); icon.append("path") .attr("d", i === 0 ? "M-12,4 C-8,-18 18,-20 19,-20 C16,5 0,18 -16,20 C-10,13 -4,8 4,0" : "M-18,16 L-18,2 L-8,2 L-8,16 Z M-3,16 L-3,-8 L7,-8 L7,16 Z M12,16 L12,-24 L22,-24 L22,16 Z") .attr("fill", "#FFFFFF") .attr("opacity", 0.95); g.append("text") .attr("x", card.w / 2) .attr("y", 103) .attr("text-anchor", "middle") .attr("fill", d.color) .style("font-family", fontSans) .style("font-size", `${titleFontSize}px`) .style("font-weight", "900") .selectAll("tspan") .data(titleLines) .enter() .append("tspan") .attr("x", card.w / 2) .attr("dy", (line, lineIndex) => lineIndex === 0 ? 0 : 27) .text(line => line); g.append("text") .attr("x", card.w / 2) .attr("y", bodyY) .attr("text-anchor", "middle") .attr("fill", "#1D2634") .style("font-family", fontSans) .style("font-size", "15px") .style("font-weight", "500") .selectAll("tspan") .data(wrapWords(d.body, Math.max(18, Math.floor(card.w / 9)), 3)) .enter() .append("tspan") .attr("x", card.w / 2) .attr("dy", (line, lineIndex) => lineIndex === 0 ? 0 : 20) .text(line => line); }); return svg.node(); }