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