Spaces:
Sleeping
Sleeping
| /* | |
| REQUIREMENTS_BEGIN | |
| { | |
| "chart_type": "Scatterplot", | |
| "chart_name": "scatterplot_03", | |
| "required_fields": ["x", "y", "y2"], | |
| "required_fields_type": [["categorical"], ["numerical"], ["numerical"]], | |
| "required_fields_range": [[8, 24], [0, "inf"], [0, "inf"]], | |
| "required_fields_icons": [], | |
| "required_other_icons": [], | |
| "required_fields_colors": [], | |
| "required_other_colors": ["primary"], | |
| "supported_effects": ["shadow", "radius_corner"], | |
| "min_height": 750, | |
| "min_width": 750, | |
| "background": "no", | |
| "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 categoryColumn = dataColumns[0] || {}; | |
| const xColumn = dataColumns[1] || {}; | |
| const yColumn = dataColumns[2] || {}; | |
| const categoryField = categoryColumn.name; | |
| const xField = xColumn.name; | |
| const yField = yColumn.name; | |
| const xUnit = xColumn.unit || ""; | |
| const yUnit = yColumn.unit || ""; | |
| const width = Math.max(520, Number(variables.width) || 600); | |
| const height = Math.max(520, Number(variables.height) || 600); | |
| const textColor = sourceColors.text_color || "#12325A"; | |
| const primary = sourceColors.other?.primary || sourceColors.primary || "#1F75C9"; | |
| const secondary = sourceColors.other?.secondary || "#25A978"; | |
| const mutedText = "#66758A"; | |
| const gridColor = "#BED3E2"; | |
| const panelBg = "#F2FAFF"; | |
| const cardBg = "#FFFFFF"; | |
| const axisColor = "#14345A"; | |
| const labelFontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif"; | |
| const labelFontWeight = typography.label?.font_weight || 600; | |
| const annotationFontFamily = typography.annotation?.font_family || labelFontFamily; | |
| const titleFontFamily = typography.title?.font_family || labelFontFamily; | |
| const colorPool = [ | |
| primary, | |
| secondary, | |
| ...((sourceColors.available_colors || []).filter(Boolean)) | |
| ].filter((value, index, arr) => value && arr.indexOf(value) === index); | |
| const fallbackColors = ["#1F75C9", "#25A978", "#F2B632", "#D65A70", "#7756B5", "#607D8B"]; | |
| const palette = colorPool.length ? colorPool : fallbackColors; | |
| 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", "scatterplot-root reference-layout"); | |
| if (!categoryField || !xField || !yField) { | |
| svg.append("text") | |
| .attr("x", width / 2) | |
| .attr("y", height / 2) | |
| .attr("text-anchor", "middle") | |
| .attr("fill", textColor) | |
| .style("font-family", labelFontFamily) | |
| .style("font-size", "16px") | |
| .text("Missing scatterplot fields"); | |
| return svg.node(); | |
| } | |
| const humanizeField = name => String(name || "") | |
| .replace(/_/g, " ") | |
| .replace(/([a-z])([A-Z])/g, "$1 $2") | |
| .replace(/\s+/g, " ") | |
| .trim(); | |
| const compactNumber = value => { | |
| const abs = Math.abs(value); | |
| if (abs >= 1000000000) return `${d3.format(".2~f")(value / 1000000000)}B`; | |
| if (abs >= 1000000) return `${d3.format(".2~f")(value / 1000000)}M`; | |
| if (abs >= 1000) return `${d3.format(".2~f")(value / 1000)}K`; | |
| if (abs >= 100) return d3.format(",.0f")(value); | |
| if (abs >= 10) return d3.format(",.1f")(value).replace(/\.0$/, ""); | |
| if (abs >= 1) return d3.format(",.2f")(value).replace(/\.?0+$/, ""); | |
| return d3.format(",.3f")(value).replace(/\.?0+$/, ""); | |
| }; | |
| const formatLocalValue = (value, unit) => { | |
| const numberText = compactNumber(value); | |
| const prefixMatch = String(unit || "").match(/^([$€£¥])(.+)?$/); | |
| if (!unit) return numberText; | |
| if (prefixMatch) return `${prefixMatch[1]}${numberText}${prefixMatch[2] || ""}`; | |
| if (unit === "%") return `${numberText}%`; | |
| return `${numberText}${unit}`; | |
| }; | |
| const axisTitle = (column, unit) => { | |
| const title = humanizeField(column.label || column.name || ""); | |
| if (!unit || title.includes(unit)) return title; | |
| return `${title} (${unit})`; | |
| }; | |
| const shortLabel = (value, maxChars = 14) => { | |
| const clean = humanizeField(value); | |
| if (clean.length <= maxChars) return clean; | |
| const words = clean.split(/[\s/,-]+/).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 `${clean.slice(0, Math.max(1, maxChars - 1)).trim()}.`; | |
| }; | |
| const 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; | |
| const max = extent[1] + pad; | |
| if (extent[0] >= 0 && min < 0) min = 0; | |
| return [min, max]; | |
| }; | |
| const chartData = rawData | |
| .map((d, index) => ({ | |
| ...d, | |
| __sourceIndex: index, | |
| __label: String(d[categoryField]), | |
| __x: Number(d[xField]), | |
| __y: Number(d[yField]) | |
| })) | |
| .filter(d => Number.isFinite(d.__x) && Number.isFinite(d.__y)) | |
| .sort((a, b) => d3.ascending(a.__x, b.__x) || d3.ascending(a.__y, b.__y) || d3.ascending(a.__label, b.__label)) | |
| .map((d, i) => ({ ...d, __id: i + 1 })); | |
| if (!chartData.length) { | |
| svg.append("text") | |
| .attr("x", width / 2) | |
| .attr("y", height / 2) | |
| .attr("text-anchor", "middle") | |
| .attr("fill", textColor) | |
| .style("font-family", labelFontFamily) | |
| .style("font-size", "16px") | |
| .text("Not enough data"); | |
| return svg.node(); | |
| } | |
| const mean = values => d3.mean(values) || 0; | |
| const xMean = mean(chartData.map(d => d.__x)); | |
| const yMean = mean(chartData.map(d => d.__y)); | |
| const covariance = d3.sum(chartData, d => (d.__x - xMean) * (d.__y - yMean)); | |
| const xVariance = d3.sum(chartData, d => Math.pow(d.__x - xMean, 2)); | |
| const yVariance = d3.sum(chartData, d => Math.pow(d.__y - yMean, 2)); | |
| const slope = xVariance === 0 ? 0 : covariance / xVariance; | |
| const intercept = yMean - slope * xMean; | |
| const correlation = xVariance === 0 || yVariance === 0 ? 0 : covariance / Math.sqrt(xVariance * yVariance); | |
| const corrLabel = Math.abs(correlation) >= 0.65 ? "Strong" : Math.abs(correlation) >= 0.35 ? "Moderate" : "Weak"; | |
| const corrDirection = correlation >= 0 ? "positive" : "negative"; | |
| const defs = svg.append("defs"); | |
| const bgGradient = defs.append("linearGradient") | |
| .attr("id", "scatter-ref-bg") | |
| .attr("x1", "0%") | |
| .attr("x2", "0%") | |
| .attr("y1", "0%") | |
| .attr("y2", "100%"); | |
| bgGradient.append("stop").attr("offset", "0%").attr("stop-color", "#F7FCFF"); | |
| bgGradient.append("stop").attr("offset", "100%").attr("stop-color", panelBg); | |
| const root = svg.append("g") | |
| .attr("class", "scatterplot-reference-panel") | |
| .attr("data-tag", "chart"); | |
| root.append("rect") | |
| .attr("class", "chart-style-panel") | |
| .attr("x", 2) | |
| .attr("y", 2) | |
| .attr("width", width - 4) | |
| .attr("height", height - 4) | |
| .attr("rx", 16) | |
| .attr("fill", "url(#scatter-ref-bg)"); | |
| const addIconSlot = (group, cx, cy, r, className, fill, glyph) => { | |
| const icon = group.append("g").attr("class", className); | |
| icon.append("circle") | |
| .attr("cx", cx) | |
| .attr("cy", cy) | |
| .attr("r", r) | |
| .attr("fill", fill) | |
| .attr("opacity", 0.95); | |
| icon.append("text") | |
| .attr("x", cx) | |
| .attr("y", cy + 1) | |
| .attr("text-anchor", "middle") | |
| .attr("dominant-baseline", "middle") | |
| .attr("fill", "#FFFFFF") | |
| .style("font-family", labelFontFamily) | |
| .style("font-size", `${Math.max(11, r * 0.76)}px`) | |
| .style("font-weight", "900") | |
| .text(glyph); | |
| return icon; | |
| }; | |
| const chipY = 22; | |
| const chipH = 56; | |
| const chipGap = 16; | |
| const chipW = Math.min(240, (width - 64 - chipGap) / 2); | |
| const chipX = Math.max(22, (width - chipW * 2 - chipGap) / 2); | |
| const addMetricChip = (x, color, glyph, title, subtitle, className) => { | |
| const chip = root.append("g") | |
| .attr("class", `metric-chip ${className}`) | |
| .attr("transform", `translate(${x}, ${chipY})`); | |
| chip.append("rect") | |
| .attr("x", 0) | |
| .attr("y", 0) | |
| .attr("width", chipW) | |
| .attr("height", chipH) | |
| .attr("rx", 13) | |
| .attr("fill", cardBg) | |
| .attr("stroke", "#D7E6F0") | |
| .attr("stroke-width", 1); | |
| addIconSlot(chip, 32, chipH / 2, 18, "metric-icon-slot decorative-icon-slot", color, glyph); | |
| chip.append("text") | |
| .attr("class", "metric-title") | |
| .attr("x", 62) | |
| .attr("y", 23) | |
| .attr("fill", color) | |
| .style("font-family", titleFontFamily) | |
| .style("font-size", "12px") | |
| .style("font-weight", "900") | |
| .text(shortLabel(title, Math.max(12, Math.floor((chipW - 74) / 6.3)))); | |
| chip.append("text") | |
| .attr("class", "metric-subtitle") | |
| .attr("x", 62) | |
| .attr("y", 40) | |
| .attr("fill", mutedText) | |
| .style("font-family", annotationFontFamily) | |
| .style("font-size", "8.5px") | |
| .style("font-weight", "650") | |
| .text(shortLabel(subtitle, Math.max(16, Math.floor((chipW - 74) / 5.4)))); | |
| }; | |
| addMetricChip(chipX, primary, "X", axisTitle(xColumn, xUnit), "Horizontal axis", "x-metric-chip"); | |
| addMetricChip(chipX + chipW + chipGap, secondary, "Y", axisTitle(yColumn, yUnit), "Vertical axis", "y-metric-chip"); | |
| const footerH = Math.max(48, Math.min(64, height * 0.105)); | |
| const footerY = height - footerH - 12; | |
| const plotX = Math.max(58, width * 0.11); | |
| const plotY = chipY + chipH + 46; | |
| const plotW = width - plotX - Math.max(24, width * 0.055); | |
| const plotH = Math.max(260, footerY - plotY - 58); | |
| const xScale = d3.scaleLinear() | |
| .domain(paddedDomain(chartData.map(d => d.__x))) | |
| .nice() | |
| .range([0, plotW]); | |
| const yScale = d3.scaleLinear() | |
| .domain(paddedDomain(chartData.map(d => d.__y))) | |
| .nice() | |
| .range([plotH, 0]); | |
| const xTickCount = Math.max(4, Math.min(7, Math.floor(plotW / 80))); | |
| const yTickCount = Math.max(4, Math.min(7, Math.floor(plotH / 70))); | |
| const plot = root.append("g") | |
| .attr("class", "scatterplot-plot-area") | |
| .attr("transform", `translate(${plotX},${plotY})`); | |
| plot.append("rect") | |
| .attr("class", "plot-background") | |
| .attr("x", -10) | |
| .attr("y", -10) | |
| .attr("width", plotW + 20) | |
| .attr("height", plotH + 20) | |
| .attr("rx", 10) | |
| .attr("fill", "#FFFFFF") | |
| .attr("opacity", 0.48); | |
| const xAxis = d3.axisBottom(xScale) | |
| .ticks(xTickCount) | |
| .tickFormat(d => formatLocalValue(d, xUnit)) | |
| .tickSize(-plotH) | |
| .tickPadding(8); | |
| const yAxis = d3.axisLeft(yScale) | |
| .ticks(yTickCount) | |
| .tickFormat(d => formatLocalValue(d, yUnit)) | |
| .tickSize(-plotW) | |
| .tickPadding(8); | |
| plot.append("g") | |
| .attr("class", "axis x-axis") | |
| .attr("transform", `translate(0,${plotH})`) | |
| .call(xAxis); | |
| plot.append("g") | |
| .attr("class", "axis y-axis") | |
| .call(yAxis); | |
| plot.selectAll(".domain") | |
| .attr("stroke", axisColor) | |
| .attr("stroke-width", 1.4) | |
| .attr("opacity", 0.82); | |
| plot.selectAll(".tick line") | |
| .attr("class", "gridline") | |
| .attr("stroke", gridColor) | |
| .attr("stroke-width", 0.8) | |
| .attr("opacity", 0.72) | |
| .attr("stroke-dasharray", "3 3"); | |
| plot.selectAll(".tick text") | |
| .attr("class", "axis-tick") | |
| .attr("fill", axisColor) | |
| .style("font-family", annotationFontFamily) | |
| .style("font-size", "9px") | |
| .style("font-weight", "650"); | |
| const xDomain = xScale.domain(); | |
| const trendPoints = [ | |
| [xDomain[0], slope * xDomain[0] + intercept], | |
| [xDomain[1], slope * xDomain[1] + intercept] | |
| ].map(([x, y]) => [xScale(x), yScale(Math.max(yScale.domain()[0], Math.min(yScale.domain()[1], y)))]); | |
| plot.append("line") | |
| .attr("class", "trend-line") | |
| .attr("x1", trendPoints[0][0]) | |
| .attr("y1", trendPoints[0][1]) | |
| .attr("x2", trendPoints[1][0]) | |
| .attr("y2", trendPoints[1][1]) | |
| .attr("stroke", correlation >= 0 ? primary : "#C61B6A") | |
| .attr("stroke-width", 1.8) | |
| .attr("stroke-dasharray", "5 5") | |
| .attr("opacity", 0.78); | |
| plot.append("text") | |
| .attr("class", "axis-title y-axis-title") | |
| .attr("x", -24) | |
| .attr("y", -22) | |
| .attr("text-anchor", "start") | |
| .attr("fill", secondary) | |
| .style("font-family", titleFontFamily) | |
| .style("font-size", "12px") | |
| .style("font-weight", "900") | |
| .text(axisTitle(yColumn, yUnit)); | |
| plot.append("text") | |
| .attr("class", "axis-title x-axis-title") | |
| .attr("x", plotW) | |
| .attr("y", plotH + 42) | |
| .attr("text-anchor", "end") | |
| .attr("fill", primary) | |
| .style("font-family", titleFontFamily) | |
| .style("font-size", "12px") | |
| .style("font-weight", "900") | |
| .text(axisTitle(xColumn, xUnit)); | |
| const pointRadius = Math.max(6.6, Math.min(9.6, Math.min(plotW, plotH) / 40)); | |
| const labelMaxChars = chartData.length > 18 ? 9 : 12; | |
| const labelFontSize = chartData.length > 18 ? 7.4 : 8.4; | |
| const labelRows = []; | |
| const occupiedLabelBounds = []; | |
| const overlapArea = (a, b) => { | |
| const xOverlap = Math.min(a.x2, b.x2) - Math.max(a.x1, b.x1); | |
| const yOverlap = Math.min(a.y2, b.y2) - Math.max(a.y1, b.y1); | |
| return xOverlap > 0 && yOverlap > 0 ? xOverlap * yOverlap : 0; | |
| }; | |
| const labelWidth = text => Math.max(18, Math.min(90, String(text).length * labelFontSize * 0.64)); | |
| const makeBounds = (x, y, anchor, text) => { | |
| const w = labelWidth(text); | |
| const h = labelFontSize * 1.18; | |
| const padX = 2.4; | |
| const padY = 1.5; | |
| let x1 = x; | |
| let x2 = x + w; | |
| if (anchor === "end") { | |
| x1 = x - w; | |
| x2 = x; | |
| } else if (anchor === "middle") { | |
| x1 = x - w / 2; | |
| x2 = x + w / 2; | |
| } | |
| return { x1: x1 - padX, y1: y - h - padY, x2: x2 + padX, y2: y + 2 + padY }; | |
| }; | |
| const outsidePenalty = b => ( | |
| Math.max(0, 3 - b.x1) + | |
| Math.max(0, b.x2 - (plotW - 3)) + | |
| Math.max(0, 2 - b.y1) + | |
| Math.max(0, b.y2 - (plotH - 4)) | |
| ); | |
| const pointOverlapPenalty = bounds => d3.sum(chartData, d => { | |
| const px = xScale(d.__x); | |
| const py = yScale(d.__y); | |
| const inside = px >= bounds.x1 && px <= bounds.x2 && py >= bounds.y1 && py <= bounds.y2; | |
| return inside ? 55 : 0; | |
| }); | |
| const candidateLabelPositions = d => { | |
| const px = xScale(d.__x); | |
| const py = yScale(d.__y); | |
| const preferLeft = px > plotW * 0.68; | |
| const preferDown = py < plotH * 0.22; | |
| const sideOrder = preferLeft ? [-1, 1] : [1, -1]; | |
| const verticalOrder = preferDown ? [1, -1, 0] : [-1, 1, 0]; | |
| const candidates = []; | |
| sideOrder.forEach((side, sideIndex) => { | |
| verticalOrder.forEach((vertical, verticalIndex) => { | |
| const dx = side * (pointRadius + (vertical === 0 ? 8 : 6)); | |
| const dy = vertical === 0 ? 3 : vertical * (pointRadius + 6); | |
| candidates.push({ | |
| x: px + dx, | |
| y: py + dy, | |
| anchor: side === 1 ? "start" : "end", | |
| priority: sideIndex * 18 + verticalIndex * 7 | |
| }); | |
| }); | |
| }); | |
| [0, -1, 1].forEach((vertical, index) => { | |
| const dy = vertical === 0 | |
| ? (preferDown ? pointRadius + 12 : -pointRadius - 8) | |
| : vertical * (pointRadius + 16); | |
| candidates.push({ | |
| x: px, | |
| y: py + dy, | |
| anchor: "middle", | |
| priority: 42 + index * 8 | |
| }); | |
| }); | |
| return candidates; | |
| }; | |
| [...chartData] | |
| .sort((a, b) => d3.ascending(yScale(a.__y), yScale(b.__y)) || d3.descending(xScale(a.__x), xScale(b.__x))) | |
| .forEach(d => { | |
| const text = shortLabel(d.__label, labelMaxChars); | |
| const px = xScale(d.__x); | |
| const py = yScale(d.__y); | |
| const best = candidateLabelPositions(d) | |
| .map(candidate => { | |
| const bounds = makeBounds(candidate.x, candidate.y, candidate.anchor, text); | |
| const overlaps = occupiedLabelBounds | |
| .map(existing => overlapArea(bounds, existing)) | |
| .filter(area => area > 0); | |
| const labelOverlap = d3.sum(overlaps); | |
| const distance = Math.hypot(candidate.x - px, candidate.y - py); | |
| const score = candidate.priority + | |
| outsidePenalty(bounds) * 7 + | |
| labelOverlap * 6 + | |
| overlaps.length * 120 + | |
| pointOverlapPenalty(bounds) + | |
| distance * 0.6; | |
| return { ...candidate, bounds, score }; | |
| }) | |
| .sort((a, b) => d3.ascending(a.score, b.score))[0]; | |
| occupiedLabelBounds.push(best.bounds); | |
| labelRows.push({ datum: d, text, x: best.x, y: best.y, anchor: best.anchor }); | |
| }); | |
| const points = plot.selectAll(".data-point") | |
| .data(chartData) | |
| .enter() | |
| .append("g") | |
| .attr("class", "data-point") | |
| .attr("data-label", d => d.__label) | |
| .attr("data-x", d => d.__x) | |
| .attr("data-y", d => d.__y) | |
| .attr("data-source-index", d => d.__sourceIndex) | |
| .attr("data-rank", d => d.__id) | |
| .attr("transform", d => `translate(${xScale(d.__x)},${yScale(d.__y)})`); | |
| points.append("circle") | |
| .attr("class", "point-mark mark") | |
| .attr("data-tag", "mark") | |
| .attr("data-label", d => d.__label) | |
| .attr("data-x", d => d.__x) | |
| .attr("data-y", d => d.__y) | |
| .attr("data-source-index", d => d.__sourceIndex) | |
| .attr("data-rank", d => d.__id) | |
| .attr("r", pointRadius) | |
| .attr("fill", d => palette[(d.__id - 1) % palette.length]) | |
| .attr("fill-opacity", 0.96) | |
| .attr("stroke", "#FFFFFF") | |
| .attr("stroke-width", 1.8); | |
| 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.2px" : "7px") | |
| .style("font-weight", "900") | |
| .text(d => d.__id); | |
| plot.append("g") | |
| .attr("class", "point-label-layer") | |
| .selectAll(".category-label") | |
| .data(labelRows) | |
| .enter() | |
| .append("text") | |
| .attr("class", "category-label label") | |
| .attr("data-label", d => d.datum.__label) | |
| .attr("data-x", d => d.datum.__x) | |
| .attr("data-y", d => d.datum.__y) | |
| .attr("data-source-index", d => d.datum.__sourceIndex) | |
| .attr("data-rank", d => d.datum.__id) | |
| .attr("x", d => d.x) | |
| .attr("y", d => d.y) | |
| .attr("text-anchor", d => d.anchor) | |
| .attr("fill", axisColor) | |
| .attr("stroke", "#FFFFFF") | |
| .attr("stroke-width", 3) | |
| .attr("paint-order", "stroke") | |
| .style("font-family", labelFontFamily) | |
| .style("font-size", `${labelFontSize}px`) | |
| .style("font-weight", "800") | |
| .text(d => d.text); | |
| const footer = root.append("g") | |
| .attr("class", "footer-note-card") | |
| .attr("transform", `translate(${Math.max(20, width * 0.045)}, ${footerY})`); | |
| const footerW = width - Math.max(20, width * 0.045) * 2; | |
| footer.append("rect") | |
| .attr("x", 0) | |
| .attr("y", 0) | |
| .attr("width", footerW) | |
| .attr("height", footerH) | |
| .attr("rx", 12) | |
| .attr("fill", "#FFFFFF") | |
| .attr("opacity", 0.78); | |
| addIconSlot(footer, 24, footerH / 2, 14, "footer-icon-slot decorative-icon-slot", primary, "i"); | |
| const corrPillW = Math.min(142, Math.max(108, footerW * 0.24)); | |
| const corrPillH = 34; | |
| const corrPillX = footerW - corrPillW - 10; | |
| const corrPillY = Math.max(7, footerH / 2 - corrPillH / 2); | |
| const corr = footer.append("g") | |
| .attr("class", "correlation-card footer-correlation-pill") | |
| .attr("transform", `translate(${corrPillX}, ${corrPillY})`); | |
| corr.append("rect") | |
| .attr("x", 0) | |
| .attr("y", 0) | |
| .attr("width", corrPillW) | |
| .attr("height", corrPillH) | |
| .attr("rx", 10) | |
| .attr("fill", "#F8FCFF") | |
| .attr("stroke", correlation >= 0 ? primary : "#C61B6A") | |
| .attr("stroke-width", 1); | |
| addIconSlot(corr, 19, corrPillH / 2, 12, "correlation-icon-slot decorative-icon-slot", correlation >= 0 ? primary : "#C61B6A", "r"); | |
| corr.append("text") | |
| .attr("class", "correlation-label") | |
| .attr("x", 38) | |
| .attr("y", 14) | |
| .attr("fill", textColor) | |
| .style("font-family", labelFontFamily) | |
| .style("font-size", "7.7px") | |
| .style("font-weight", "800") | |
| .text(`${corrLabel} ${corrDirection}`); | |
| corr.append("text") | |
| .attr("class", "correlation-value") | |
| .attr("x", 38) | |
| .attr("y", 27) | |
| .attr("fill", correlation >= 0 ? primary : "#C61B6A") | |
| .style("font-family", annotationFontFamily) | |
| .style("font-size", "8px") | |
| .style("font-weight", "900") | |
| .text(`r = ${d3.format(".2f")(correlation)}`); | |
| const footerTextLimit = Math.max(18, Math.floor((corrPillX - 56) / 5.2)); | |
| footer.append("text") | |
| .attr("class", "source-note") | |
| .attr("x", 48) | |
| .attr("y", footerH / 2 - 8) | |
| .attr("fill", textColor) | |
| .style("font-family", annotationFontFamily) | |
| .style("font-size", "8.5px") | |
| .style("font-weight", "800") | |
| .text(shortLabel(`Source: ${humanizeField(categoryField)} comparison`, footerTextLimit)); | |
| footer.append("text") | |
| .attr("class", "scale-note") | |
| .attr("x", 48) | |
| .attr("y", footerH / 2 + 10) | |
| .attr("fill", mutedText) | |
| .style("font-family", annotationFontFamily) | |
| .style("font-size", "8.5px") | |
| .text(shortLabel(`True ${humanizeField(xField)} and ${humanizeField(yField)} values preserved.`, footerTextLimit)); | |
| return svg.node(); | |
| } | |