ChartPipeline / modules /chart_engine /template /d3-js /scatterplot /grouped_scatterplot_02.js
Ray1ee01's picture
Upload folder using huggingface_hub
e134901 verified
Raw
History Blame Contribute Delete
23.4 kB
/*
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();
}