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