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