Spaces:
Sleeping
Sleeping
| /* | |
| REQUIREMENTS_BEGIN | |
| { | |
| "chart_type": "Grouped Dot Chart", | |
| "chart_name": "grouped_scatterplot_02", | |
| "chart_for": "comparison", | |
| "is_composite": false, | |
| "required_fields": ["x", "y", "group"], | |
| "required_fields_type": [["categorical"], ["numerical"], ["categorical"]], | |
| "required_fields_range": [[2, 20], [0, "inf"], [2, 5]], | |
| "required_fields_icons": ["x"], | |
| "required_other_icons": [], | |
| "required_fields_colors": ["group"], | |
| "hierarchy": ["group"], | |
| "required_other_colors": ["primary"], | |
| "supported_effects": ["shadow", "radius_corner", "gradient", "stroke", "spacing"], | |
| "min_height": 400, | |
| "min_width": 400, | |
| "background": "no", | |
| "icon_mark": "none", | |
| "icon_label": "side", | |
| "has_x_axis": "yes", | |
| "has_y_axis": "yes" | |
| } | |
| REQUIREMENTS_END | |
| */ | |
| function makeChart(containerSelector, data) { | |
| const jsonData = data; | |
| const rows = (jsonData.data && jsonData.data.data ? jsonData.data.data : []) | |
| .filter(d => d && typeof d === "object"); | |
| const typography = jsonData.typography || {}; | |
| const colors = jsonData.colors || {}; | |
| const images = jsonData.images || { field: {}, other: {} }; | |
| const colorResolver = chartUtils.color.resolver(jsonData); | |
| d3.select(containerSelector).html(""); | |
| const dimensionField = chartUtils.schema.channel(jsonData, "x").key; | |
| const valueField = chartUtils.schema.channel(jsonData, "y").key; | |
| const groupField = chartUtils.schema.channel(jsonData, "group").key; | |
| const width = 640; | |
| const height = 960; | |
| const titleColor = colorResolver.text({ fallback: colors.text_color || "#0b2b55" }).value || "#0b2b55"; | |
| const mutedText = "#4c5b6b"; | |
| const panelStroke = "#d5e3ef"; | |
| const gridStroke = "#b9c9d9"; | |
| const canvas = document.createElement("canvas"); | |
| const context = canvas.getContext("2d"); | |
| function textWidth(text, size, weight, family) { | |
| context.font = `${weight || 400} ${size || 12}px ${family || "Arial"}`; | |
| return chartUtils.text.contextWidth(context, String(text || "")); | |
| } | |
| function wrapWords(text, maxWidth, size, weight, family, maxLines) { | |
| const words = String(text || "").split(/\s+/).filter(Boolean); | |
| const lines = []; | |
| let line = ""; | |
| words.forEach(word => { | |
| const test = line ? `${line} ${word}` : word; | |
| if (textWidth(test, size, weight, family) <= maxWidth || !line) { | |
| line = test; | |
| } else { | |
| lines.push(line); | |
| line = word; | |
| } | |
| }); | |
| if (line) lines.push(line); | |
| if (maxLines && lines.length > maxLines) { | |
| const kept = lines.slice(0, maxLines); | |
| let last = kept[kept.length - 1]; | |
| while (last.length > 1 && textWidth(`${last}...`, size, weight, family) > maxWidth) { | |
| last = last.slice(0, -1); | |
| } | |
| kept[kept.length - 1] = `${last}...`; | |
| return kept; | |
| } | |
| return lines; | |
| } | |
| function fitSingleLine(text, maxWidth, size, weight, family) { | |
| let value = String(text || ""); | |
| if (textWidth(value, size, weight, family) <= maxWidth) return value; | |
| while (value.length > 1 && textWidth(`${value}...`, size, weight, family) > maxWidth) { | |
| value = value.slice(0, -1); | |
| } | |
| return `${value}...`; | |
| } | |
| function normalizeLegendHeading(text) { | |
| return String(text || "Group") | |
| .replace(/[_-]+/g, " ") | |
| .replace(/([a-z])([A-Z])/g, "$1 $2") | |
| .replace(/\s+/g, " ") | |
| .trim() | |
| .toUpperCase(); | |
| } | |
| function wrapLegendHeading(text, maxWidth, maxHeight, family) { | |
| const words = normalizeLegendHeading(text).split(/\s+/).filter(Boolean); | |
| const makeLines = (size) => { | |
| const lines = []; | |
| let line = ""; | |
| words.forEach(word => { | |
| const test = line ? `${line} ${word}` : word; | |
| if (textWidth(test, size, 800, family) <= maxWidth || !line) { | |
| line = test; | |
| } else { | |
| lines.push(line); | |
| line = word; | |
| } | |
| }); | |
| if (line) lines.push(line); | |
| return lines; | |
| }; | |
| for (let size = 14; size >= 9; size -= 0.5) { | |
| const lines = makeLines(size); | |
| const lineHeight = size * 1.05; | |
| const widest = d3.max(lines, line => textWidth(line, size, 800, family)) || 0; | |
| if (lines.length <= 2 && widest <= maxWidth && lines.length * lineHeight <= maxHeight) { | |
| return { lines, size, lineHeight }; | |
| } | |
| } | |
| const fallbackSize = 8.5; | |
| const lines = []; | |
| let current = ""; | |
| normalizeLegendHeading(text).split("").forEach(char => { | |
| const test = current + char; | |
| if (textWidth(test, fallbackSize, 800, family) <= maxWidth || !current) { | |
| current = test; | |
| } else { | |
| lines.push(current.trim()); | |
| current = char; | |
| } | |
| }); | |
| if (current.trim()) lines.push(current.trim()); | |
| return { lines: lines.slice(0, 3), size: fallbackSize, lineHeight: fallbackSize * 1.02 }; | |
| } | |
| function fitLegendItemLabel(text, maxWidth, baseSize, family) { | |
| const value = String(text || ""); | |
| for (let size = baseSize; size >= 8.5; size -= 0.5) { | |
| if (textWidth(value, size, 500, family) <= maxWidth) { | |
| return { text: value, size }; | |
| } | |
| } | |
| return { text: value, size: 8.5 }; | |
| } | |
| function isTemporalLike(values, fieldName) { | |
| const name = String(fieldName || "").toLowerCase(); | |
| if (/(year|date|month|quarter|semester|season|time|timestamp)/.test(name)) return true; | |
| const monthNames = new Set(["jan", "january", "feb", "february", "mar", "march", "apr", "april", "may", "jun", "june", "jul", "july", "aug", "august", "sep", "sept", "september", "oct", "october", "nov", "november", "dec", "december"]); | |
| return values.length > 1 && values.every(v => { | |
| const s = String(v).trim().toLowerCase(); | |
| return /^\d{4}$/.test(s) || /^\d{4}[-/]\d{1,2}([-/]\d{1,2})?$/.test(s) || monthNames.has(s); | |
| }); | |
| } | |
| function temporalKey(value) { | |
| const s = String(value).trim(); | |
| if (/^\d{4}$/.test(s)) return +s; | |
| const parsed = Date.parse(s); | |
| return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed; | |
| } | |
| const firstIndex = new Map(); | |
| rows.forEach((d, i) => { | |
| const key = d[dimensionField]; | |
| if (!firstIndex.has(key)) firstIndex.set(key, i); | |
| }); | |
| let dimensions = Array.from(firstIndex.keys()); | |
| if (isTemporalLike(dimensions, dimensionField)) { | |
| dimensions = dimensions.slice().sort((a, b) => temporalKey(a) - temporalKey(b)); | |
| } | |
| const groupIndex = new Map(); | |
| rows.forEach((d, i) => { | |
| const key = d[groupField]; | |
| if (!groupIndex.has(key)) groupIndex.set(key, i); | |
| }); | |
| const groups = Array.from(groupIndex.keys()); | |
| const colorScale = colorResolver.scale(groups, { palette: "category10" }); | |
| 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("data-chart-variation", "grouped_scatterplot_02") | |
| .attr("data-x-field", dimensionField) | |
| .attr("data-y-field", valueField) | |
| .attr("data-group-field", groupField) | |
| .attr("data-ordering", isTemporalLike(dimensions, dimensionField) ? "chronological-temporal" : "input-order"); | |
| const defs = svg.append("defs"); | |
| const bgGradient = defs.append("linearGradient") | |
| .attr("id", "gsp02-bg") | |
| .attr("x1", "0%").attr("y1", "0%") | |
| .attr("x2", "100%").attr("y2", "100%"); | |
| bgGradient.append("stop").attr("offset", "0%").attr("stop-color", "#f8fcff"); | |
| bgGradient.append("stop").attr("offset", "100%").attr("stop-color", "#e5f6fb"); | |
| const shadow = defs.append("filter") | |
| .attr("id", "gsp02-soft-shadow") | |
| .attr("x", "-20%").attr("y", "-20%") | |
| .attr("width", "140%").attr("height", "140%"); | |
| shadow.append("feDropShadow") | |
| .attr("dx", 0) | |
| .attr("dy", 5) | |
| .attr("stdDeviation", 7) | |
| .attr("flood-color", "#2b638f") | |
| .attr("flood-opacity", 0.14); | |
| svg.append("rect") | |
| .attr("width", width) | |
| .attr("height", height) | |
| .attr("fill", "url(#gsp02-bg)"); | |
| const waveBase = height - 82; | |
| svg.append("path") | |
| .attr("d", `M0 ${waveBase} C80 ${waveBase - 24}, 140 ${waveBase + 18}, 220 ${waveBase - 4} S360 ${waveBase - 12}, 450 ${waveBase - 34} S570 ${waveBase - 12}, 640 ${waveBase - 30} L640 960 L0 960 Z`) | |
| .attr("fill", "#bde8fa") | |
| .attr("opacity", 0.58); | |
| svg.append("path") | |
| .attr("d", `M0 ${waveBase + 46} C90 ${waveBase + 20}, 158 ${waveBase + 60}, 242 ${waveBase + 34} S388 ${waveBase + 22}, 470 ${waveBase + 2} S582 ${waveBase + 26}, 640 ${waveBase + 10} L640 960 L0 960 Z`) | |
| .attr("fill", "#91d8f6") | |
| .attr("opacity", 0.50); | |
| const titleFamily = typography.title?.font_family || "Arial"; | |
| const titleSize = Math.min(39, Math.max(30, parseFloat(typography.title?.font_size || 34))); | |
| const subtitleSize = Math.min(17, Math.max(12, parseFloat(typography.description?.font_size || 15))); | |
| const labelFamily = typography.label?.font_family || "Arial"; | |
| svg.append("rect") | |
| .attr("x", 27) | |
| .attr("y", 28) | |
| .attr("width", 9) | |
| .attr("height", 80) | |
| .attr("rx", 5) | |
| .attr("fill", "#1686d9"); | |
| const title = jsonData.titles?.main_title || jsonData.metadata?.title || ""; | |
| const titleLines = wrapWords(title, 520, titleSize, 800, titleFamily, 3); | |
| const titleGroup = svg.append("g").attr("class", "title-block"); | |
| titleLines.forEach((line, i) => { | |
| titleGroup.append("text") | |
| .attr("x", 52) | |
| .attr("y", 62 + i * (titleSize * 1.08)) | |
| .attr("font-family", titleFamily) | |
| .attr("font-size", titleSize) | |
| .attr("font-weight", 800) | |
| .attr("letter-spacing", 0) | |
| .attr("fill", "#082853") | |
| .text(line); | |
| }); | |
| const titleBottom = 62 + (titleLines.length - 1) * (titleSize * 1.08); | |
| const waveY = Math.min(174, titleBottom + 32); | |
| const accentPath = d3.line().curve(d3.curveBasis)( | |
| d3.range(0, 13).map(i => [52 + i * 16, waveY + (i % 2 ? 4 : -4)]) | |
| ); | |
| svg.append("path") | |
| .attr("d", accentPath) | |
| .attr("fill", "none") | |
| .attr("stroke", "#82c7ee") | |
| .attr("stroke-width", 2.1) | |
| .attr("stroke-linecap", "round"); | |
| const subtitle = jsonData.titles?.sub_title || jsonData.metadata?.description || ""; | |
| const subtitleLines = wrapWords(subtitle, 560, subtitleSize, 400, labelFamily, 3); | |
| subtitleLines.forEach((line, i) => { | |
| svg.append("text") | |
| .attr("x", 52) | |
| .attr("y", waveY + 32 + i * (subtitleSize * 1.45)) | |
| .attr("font-family", labelFamily) | |
| .attr("font-size", subtitleSize) | |
| .attr("font-weight", 400) | |
| .attr("fill", mutedText) | |
| .text(line); | |
| }); | |
| const legend = { x: 38, y: 228, width: 564, height: groups.length > 4 ? 98 : groups.length > 2 ? 72 : 54 }; | |
| svg.append("rect") | |
| .attr("class", "legend-card") | |
| .attr("x", legend.x) | |
| .attr("y", legend.y) | |
| .attr("width", legend.width) | |
| .attr("height", legend.height) | |
| .attr("rx", 11) | |
| .attr("fill", "#ffffff") | |
| .attr("stroke", panelStroke) | |
| .attr("filter", "url(#gsp02-soft-shadow)"); | |
| const legendHeading = wrapLegendHeading(groupField || "Group", 138, legend.height - 24, labelFamily); | |
| const legendTitle = svg.append("text") | |
| .attr("x", legend.x + 22) | |
| .attr("y", legend.y + legend.height / 2 - ((legendHeading.lines.length - 1) * legendHeading.lineHeight) / 2) | |
| .attr("dominant-baseline", "middle") | |
| .attr("font-family", labelFamily) | |
| .attr("font-size", legendHeading.size) | |
| .attr("font-weight", 800) | |
| .attr("fill", "#12335f"); | |
| legendHeading.lines.forEach((line, i) => { | |
| legendTitle.append("tspan") | |
| .attr("x", legend.x + 22) | |
| .attr("dy", i === 0 ? 0 : legendHeading.lineHeight) | |
| .text(line); | |
| }); | |
| svg.append("line") | |
| .attr("x1", legend.x + 170) | |
| .attr("x2", legend.x + 170) | |
| .attr("y1", legend.y + 13) | |
| .attr("y2", legend.y + legend.height - 13) | |
| .attr("stroke", "#c9d5e1"); | |
| const legendStartX = legend.x + 210; | |
| const legendAvailable = legend.x + legend.width - legendStartX - 18; | |
| const columns = groups.length <= 3 ? groups.length : 2; | |
| const itemW = Math.max(96, legendAvailable / Math.max(1, columns)); | |
| groups.forEach((grp, i) => { | |
| const col = i % columns; | |
| const row = Math.floor(i / columns); | |
| const gx = legendStartX + col * itemW; | |
| const gy = legend.y + 32 + row * 26; | |
| svg.append("circle") | |
| .attr("cx", gx) | |
| .attr("cy", gy - 3) | |
| .attr("r", 9) | |
| .attr("fill", colorScale(grp)) | |
| .attr("stroke", "#ffffff") | |
| .attr("stroke-width", 1.5); | |
| const label = fitLegendItemLabel( | |
| grp, | |
| Math.max(48, itemW - 26), | |
| groups.length > 3 ? 12 : 14, | |
| labelFamily | |
| ); | |
| svg.append("text") | |
| .attr("x", gx + 18) | |
| .attr("y", gy + 2) | |
| .attr("font-family", labelFamily) | |
| .attr("font-size", label.size) | |
| .attr("font-weight", 500) | |
| .attr("fill", "#253248") | |
| .text(label.text); | |
| }); | |
| const plotCardY = legend.y + legend.height + 26; | |
| const plotCard = { x: 24, y: plotCardY, width: 592, height: height - plotCardY - 72 }; | |
| const iconCol = { x: plotCard.x + 18, width: 86 }; | |
| const labelCol = { x: plotCard.x + 114, width: Math.max(112, dimensions.length > 12 ? 128 : 140) }; | |
| const plot = { | |
| x: plotCard.x + (dimensions.length > 12 ? 250 : 236), | |
| y: plotCard.y + 34, | |
| width: plotCard.x + plotCard.width - (plotCard.x + (dimensions.length > 12 ? 250 : 236)) - 28, | |
| height: plotCard.height - 84 | |
| }; | |
| svg.append("rect") | |
| .attr("class", "plot-card") | |
| .attr("x", plotCard.x) | |
| .attr("y", plotCard.y) | |
| .attr("width", plotCard.width) | |
| .attr("height", plotCard.height) | |
| .attr("rx", 13) | |
| .attr("fill", "#ffffff") | |
| .attr("stroke", panelStroke) | |
| .attr("filter", "url(#gsp02-soft-shadow)"); | |
| const maxValue = d3.max(rows, d => +d[valueField]) || 1; | |
| const minValue = Math.min(0, d3.min(rows, d => +d[valueField]) || 0); | |
| const tickScale = d3.scaleLinear().domain([minValue, maxValue]).nice(4); | |
| const tickDomain = tickScale.domain(); | |
| const paddedMax = tickDomain[1] > 0 ? tickDomain[1] * 1.10 : tickDomain[1] + 1; | |
| const xScale = d3.scaleLinear() | |
| .domain([tickDomain[0], paddedMax]) | |
| .range([plot.x, plot.x + plot.width]); | |
| const ticks = tickScale.ticks(4); | |
| const yScale = d3.scaleBand() | |
| .domain(dimensions) | |
| .range([plot.y + 8, plot.y + plot.height - 8]) | |
| .paddingInner(dimensions.length > 12 ? 0.18 : 0.28) | |
| .paddingOuter(0.18); | |
| const rowGap = Math.max(18, yScale.step()); | |
| const pointRadius = Math.max(4.2, Math.min(9.5, rowGap * 0.19)); | |
| svg.append("line") | |
| .attr("x1", plot.x) | |
| .attr("x2", plot.x) | |
| .attr("y1", plot.y) | |
| .attr("y2", plot.y + plot.height) | |
| .attr("stroke", "#aab7c4") | |
| .attr("stroke-width", 1); | |
| ticks.forEach(t => { | |
| const x = xScale(t); | |
| svg.append("line") | |
| .attr("class", "x-grid") | |
| .attr("x1", x) | |
| .attr("x2", x) | |
| .attr("y1", plot.y) | |
| .attr("y2", plot.y + plot.height) | |
| .attr("stroke", gridStroke) | |
| .attr("stroke-width", 1) | |
| .attr("stroke-dasharray", "4 5") | |
| .attr("opacity", 0.78); | |
| }); | |
| dimensions.forEach((dim, i) => { | |
| const cy = yScale(dim) + yScale.bandwidth() / 2; | |
| if (i > 0) { | |
| svg.append("line") | |
| .attr("x1", plotCard.x + 12) | |
| .attr("x2", plotCard.x + plotCard.width - 18) | |
| .attr("y1", cy - rowGap / 2) | |
| .attr("y2", cy - rowGap / 2) | |
| .attr("stroke", "#d3dee8") | |
| .attr("stroke-width", 1); | |
| } | |
| svg.append("line") | |
| .attr("x1", plot.x) | |
| .attr("x2", plot.x + plot.width) | |
| .attr("y1", cy) | |
| .attr("y2", cy) | |
| .attr("stroke", "#aeb9c5") | |
| .attr("stroke-width", 1.2) | |
| .attr("opacity", 0.75); | |
| }); | |
| const rowClipIds = []; | |
| dimensions.forEach((dim, i) => { | |
| const cy = yScale(dim) + yScale.bandwidth() / 2; | |
| const radius = Math.max(11, Math.min(28, rowGap * 0.32)); | |
| const cx = iconCol.x + iconCol.width / 2; | |
| const clipId = `gsp02-row-icon-${i}`; | |
| rowClipIds.push(clipId); | |
| defs.append("clipPath") | |
| .attr("id", clipId) | |
| .append("circle") | |
| .attr("cx", cx) | |
| .attr("cy", cy) | |
| .attr("r", radius); | |
| svg.append("circle") | |
| .attr("class", "row-icon-slot") | |
| .attr("data-asset-slot", "row-icon") | |
| .attr("data-anchor", "row-center") | |
| .attr("data-dimension", String(dim)) | |
| .attr("cx", cx) | |
| .attr("cy", cy) | |
| .attr("r", radius) | |
| .attr("fill", "#eef7fb") | |
| .attr("stroke", "#d8e8f2") | |
| .attr("stroke-width", 1.1); | |
| if (images.field && images.field[dim]) { | |
| svg.append("image") | |
| .attr("class", "row-icon-image") | |
| .attr("data-asset-slot", "row-icon") | |
| .attr("data-anchor", "row-center") | |
| .attr("data-dimension", String(dim)) | |
| .attr("xlink:href", images.field[dim]) | |
| .attr("x", cx - radius) | |
| .attr("y", cy - radius) | |
| .attr("width", radius * 2) | |
| .attr("height", radius * 2) | |
| .attr("preserveAspectRatio", "xMidYMid slice") | |
| .attr("clip-path", `url(#${clipId})`); | |
| } | |
| const labelLines = wrapWords(dim, labelCol.width, dimensions.length > 12 ? 9.5 : 13.5, 800, labelFamily, dimensions.length > 12 ? 2 : 3); | |
| const lineHeight = dimensions.length > 12 ? 11 : 16; | |
| const startY = cy - ((labelLines.length - 1) * lineHeight) / 2; | |
| labelLines.forEach((line, j) => { | |
| svg.append("text") | |
| .attr("class", "row-label") | |
| .attr("x", labelCol.x) | |
| .attr("y", startY + j * lineHeight) | |
| .attr("dominant-baseline", "middle") | |
| .attr("font-family", labelFamily) | |
| .attr("font-size", dimensions.length > 12 ? 9.5 : 13.5) | |
| .attr("font-weight", 800) | |
| .attr("fill", "#0d2d59") | |
| .text(line); | |
| }); | |
| }); | |
| const axisY = plot.y + plot.height + 16; | |
| svg.append("line") | |
| .attr("x1", plot.x) | |
| .attr("x2", plot.x + plot.width) | |
| .attr("y1", plot.y + plot.height) | |
| .attr("y2", plot.y + plot.height) | |
| .attr("stroke", "#1c5c98") | |
| .attr("stroke-width", 1.4); | |
| ticks.forEach(t => { | |
| svg.append("text") | |
| .attr("x", xScale(t)) | |
| .attr("y", axisY) | |
| .attr("text-anchor", "middle") | |
| .attr("font-family", labelFamily) | |
| .attr("font-size", 13) | |
| .attr("font-weight", 700) | |
| .attr("fill", "#12335f") | |
| .text(chartUtils.format.autoText(+t)); | |
| }); | |
| svg.append("text") | |
| .attr("x", plot.x + plot.width / 2) | |
| .attr("y", axisY + 30) | |
| .attr("text-anchor", "middle") | |
| .attr("font-family", labelFamily) | |
| .attr("font-size", 14) | |
| .attr("font-weight", 800) | |
| .attr("fill", "#12335f") | |
| .text(String(valueField || "").replace(/([a-z])([A-Z])/g, "$1 $2")); | |
| const sortedRows = rows.slice().sort((a, b) => dimensions.indexOf(a[dimensionField]) - dimensions.indexOf(b[dimensionField])); | |
| sortedRows.forEach((d, i) => { | |
| const dim = d[dimensionField]; | |
| const value = +d[valueField]; | |
| const group = d[groupField]; | |
| if (!Number.isFinite(value) || !yScale.domain().includes(dim)) return; | |
| const cx = xScale(value); | |
| const cy = yScale(dim) + yScale.bandwidth() / 2; | |
| const fill = colorScale(group); | |
| svg.append("circle") | |
| .attr("class", "data-point") | |
| .attr("data-dimension", String(dim)) | |
| .attr("data-group", String(group)) | |
| .attr("data-value", value) | |
| .attr("cx", cx) | |
| .attr("cy", cy) | |
| .attr("r", pointRadius) | |
| .attr("fill", fill) | |
| .attr("stroke", "#ffffff") | |
| .attr("stroke-width", 2); | |
| const labelText = chartUtils.format.autoText(value); | |
| const labelW = textWidth(labelText, 12.5, 800, labelFamily) + 16; | |
| const labelH = 22; | |
| let lx = cx + pointRadius + 10; | |
| let anchor = "start"; | |
| if (lx + labelW > plot.x + plot.width - 2) { | |
| lx = cx - pointRadius - 10 - labelW; | |
| anchor = "start"; | |
| } | |
| if (lx < plot.x + 4) lx = cx + pointRadius + 8; | |
| const ly = cy - labelH / 2; | |
| const collidesWithLeftLabel = lx < plot.x + 2; | |
| const enoughGap = rowGap > 21; | |
| if (!collidesWithLeftLabel && enoughGap) { | |
| const leaderStart = anchor === "start" && lx > cx ? cx + pointRadius : cx - pointRadius; | |
| const leaderEnd = lx > cx ? lx - 2 : lx + labelW + 2; | |
| svg.append("line") | |
| .attr("class", "value-label-leader") | |
| .attr("x1", leaderStart) | |
| .attr("x2", leaderEnd) | |
| .attr("y1", cy) | |
| .attr("y2", cy) | |
| .attr("stroke", fill) | |
| .attr("stroke-width", 1.1) | |
| .attr("opacity", 0.82); | |
| svg.append("rect") | |
| .attr("class", "value-label-bg") | |
| .attr("x", lx) | |
| .attr("y", ly) | |
| .attr("width", labelW) | |
| .attr("height", labelH) | |
| .attr("rx", 6) | |
| .attr("fill", "#ffffff") | |
| .attr("stroke", fill) | |
| .attr("stroke-width", 1.3); | |
| svg.append("text") | |
| .attr("class", "value-label") | |
| .attr("x", lx + labelW / 2) | |
| .attr("y", cy + 4) | |
| .attr("text-anchor", "middle") | |
| .attr("font-family", labelFamily) | |
| .attr("font-size", 12.5) | |
| .attr("font-weight", 800) | |
| .attr("fill", fill) | |
| .text(labelText); | |
| } | |
| }); | |
| return svg.node(); | |
| } | |