Ray1ee01's picture
Upload folder using huggingface_hub
e134901 verified
Raw
History Blame Contribute Delete
29.2 kB
/*
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();
}