|
| 1 | +// anyplot.ai |
| 2 | +// scatter-categorical: Categorical Scatter Plot |
| 3 | +// Library: d3 7.9.0 | JavaScript 22.23.2 |
| 4 | +// Quality: 91/100 | Created: 2026-09-05 |
| 5 | + |
| 6 | +const t = window.ANYPLOT_TOKENS; |
| 7 | +const { width, height } = window.ANYPLOT_SIZE; |
| 8 | +const margin = { top: 90, right: 260, bottom: 90, left: 110 }; |
| 9 | +const iw = width - margin.left - margin.right; |
| 10 | +const ih = height - margin.top - margin.bottom; |
| 11 | + |
| 12 | +// --- Data (in-memory, deterministic fixed-seed LCG) ------------------------- |
| 13 | +let seed = 42; |
| 14 | +function rand() { |
| 15 | + seed = (seed * 1664525 + 1013904223) % 4294967296; |
| 16 | + return seed / 4294967296; |
| 17 | +} |
| 18 | +function randNormal(mean, sd) { |
| 19 | + const u1 = rand() || 1e-9; |
| 20 | + const u2 = rand(); |
| 21 | + const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); |
| 22 | + return mean + z * sd; |
| 23 | +} |
| 24 | + |
| 25 | +const species = [ |
| 26 | + { name: "Adelie", flipperMean: 190, flipperSd: 6.5, massMean: 3700, massSd: 450, n: 45 }, |
| 27 | + { name: "Chinstrap", flipperMean: 196, flipperSd: 7, massMean: 3730, massSd: 380, n: 40 }, |
| 28 | + { name: "Gentoo", flipperMean: 217, flipperSd: 6, massMean: 5100, massSd: 500, n: 42 }, |
| 29 | +]; |
| 30 | + |
| 31 | +const points = []; |
| 32 | +for (const s of species) { |
| 33 | + for (let i = 0; i < s.n; i++) { |
| 34 | + points.push({ |
| 35 | + flipperLength: randNormal(s.flipperMean, s.flipperSd), |
| 36 | + bodyMass: randNormal(s.massMean, s.massSd), |
| 37 | + species: s.name, |
| 38 | + }); |
| 39 | + } |
| 40 | +} |
| 41 | + |
| 42 | +const categories = species.map((s) => s.name); |
| 43 | +const color = d3.scaleOrdinal().domain(categories).range(t.palette); |
| 44 | + |
| 45 | +// --- SVG mount ---------------------------------------------------------------- |
| 46 | +const svg = d3.select("#container").append("svg").attr("width", width).attr("height", height); |
| 47 | +const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`); |
| 48 | + |
| 49 | +// --- Scales --------------------------------------------------------------- |
| 50 | +const x = d3 |
| 51 | + .scaleLinear() |
| 52 | + .domain(d3.extent(points, (d) => d.flipperLength)).nice() |
| 53 | + .range([0, iw]); |
| 54 | +const y = d3 |
| 55 | + .scaleLinear() |
| 56 | + .domain(d3.extent(points, (d) => d.bodyMass)).nice() |
| 57 | + .range([ih, 0]); |
| 58 | + |
| 59 | +// --- Gridlines -------------------------------------------------------------- |
| 60 | +g.append("g") |
| 61 | + .attr("transform", `translate(0,${ih})`) |
| 62 | + .call(d3.axisBottom(x).ticks(8).tickSize(-ih).tickFormat("")) |
| 63 | + .selectAll("line") |
| 64 | + .attr("stroke", t.grid); |
| 65 | +g.append("g") |
| 66 | + .call(d3.axisLeft(y).ticks(7).tickSize(-iw).tickFormat("")) |
| 67 | + .selectAll("line") |
| 68 | + .attr("stroke", t.grid); |
| 69 | +g.selectAll(".domain").remove(); |
| 70 | + |
| 71 | +// --- Axes --------------------------------------------------------------- |
| 72 | +const xAxis = g.append("g").attr("transform", `translate(0,${ih})`).call(d3.axisBottom(x).ticks(8)); |
| 73 | +const yAxis = g.append("g").call(d3.axisLeft(y).ticks(7)); |
| 74 | +for (const ax of [xAxis, yAxis]) { |
| 75 | + ax.selectAll("text").attr("fill", t.inkSoft).style("font-size", "16px"); |
| 76 | + ax.selectAll("line").attr("stroke", t.inkSoft); |
| 77 | + ax.select(".domain").attr("stroke", t.inkSoft); |
| 78 | +} |
| 79 | + |
| 80 | +// --- Axis labels -------------------------------------------------------- |
| 81 | +g.append("text") |
| 82 | + .attr("x", iw / 2) |
| 83 | + .attr("y", ih + 60) |
| 84 | + .attr("text-anchor", "middle") |
| 85 | + .attr("fill", t.ink) |
| 86 | + .style("font-size", "18px") |
| 87 | + .text("Flipper Length (mm)"); |
| 88 | + |
| 89 | +g.append("text") |
| 90 | + .attr("transform", "rotate(-90)") |
| 91 | + .attr("x", -ih / 2) |
| 92 | + .attr("y", -80) |
| 93 | + .attr("text-anchor", "middle") |
| 94 | + .attr("fill", t.ink) |
| 95 | + .style("font-size", "18px") |
| 96 | + .text("Body Mass (g)"); |
| 97 | + |
| 98 | +// --- Points --------------------------------------------------------------- |
| 99 | +g.selectAll("circle") |
| 100 | + .data(points) |
| 101 | + .join("circle") |
| 102 | + .attr("cx", (d) => x(d.flipperLength)) |
| 103 | + .attr("cy", (d) => y(d.bodyMass)) |
| 104 | + .attr("r", 6.5) |
| 105 | + .attr("fill", (d) => color(d.species)) |
| 106 | + .attr("fill-opacity", 0.65) |
| 107 | + .attr("stroke", t.pageBg) |
| 108 | + .attr("stroke-width", 1); |
| 109 | + |
| 110 | +// --- Centroid markers (data storytelling: per-species mean) ----------------- |
| 111 | +// d3-shape symbol generator, not just circles — highlights each species' |
| 112 | +// average flipper length / body mass as a focal point above the raw cloud. |
| 113 | +const diamond = d3.symbol().type(d3.symbolDiamond).size(450); |
| 114 | +const centroids = species.map((s) => { |
| 115 | + const speciesPoints = points.filter((d) => d.species === s.name); |
| 116 | + return { |
| 117 | + name: s.name, |
| 118 | + flipperLength: d3.mean(speciesPoints, (d) => d.flipperLength), |
| 119 | + bodyMass: d3.mean(speciesPoints, (d) => d.bodyMass), |
| 120 | + }; |
| 121 | +}); |
| 122 | + |
| 123 | +g.selectAll(".centroid") |
| 124 | + .data(centroids) |
| 125 | + .join("path") |
| 126 | + .attr("class", "centroid") |
| 127 | + .attr("d", diamond) |
| 128 | + .attr("transform", (d) => `translate(${x(d.flipperLength)},${y(d.bodyMass)})`) |
| 129 | + .attr("fill", (d) => color(d.name)) |
| 130 | + .attr("stroke", t.ink) |
| 131 | + .attr("stroke-width", 2.5); |
| 132 | + |
| 133 | +// --- Legend ----------------------------------------------------------------- |
| 134 | +const legend = svg |
| 135 | + .append("g") |
| 136 | + .attr("transform", `translate(${margin.left + iw + 50},${margin.top + 20})`); |
| 137 | + |
| 138 | +categories.forEach((name, i) => { |
| 139 | + const row = legend.append("g").attr("transform", `translate(0,${i * 36})`); |
| 140 | + row.append("circle").attr("r", 9).attr("fill", color(name)).attr("fill-opacity", 0.75); |
| 141 | + row |
| 142 | + .append("text") |
| 143 | + .attr("x", 20) |
| 144 | + .attr("y", 5) |
| 145 | + .attr("fill", t.ink) |
| 146 | + .style("font-size", "16px") |
| 147 | + .text(name); |
| 148 | +}); |
| 149 | + |
| 150 | +const legendNote = legend |
| 151 | + .append("g") |
| 152 | + .attr("transform", `translate(0,${categories.length * 36 + 14})`); |
| 153 | +legendNote |
| 154 | + .append("path") |
| 155 | + .attr("d", d3.symbol().type(d3.symbolDiamond).size(220)) |
| 156 | + .attr("fill", t.inkSoft) |
| 157 | + .attr("stroke", t.ink) |
| 158 | + .attr("stroke-width", 1.5); |
| 159 | +legendNote |
| 160 | + .append("text") |
| 161 | + .attr("x", 20) |
| 162 | + .attr("y", 5) |
| 163 | + .attr("fill", t.inkSoft) |
| 164 | + .style("font-size", "14px") |
| 165 | + .text("Species mean"); |
| 166 | + |
| 167 | +// --- Title ------------------------------------------------------------------ |
| 168 | +svg |
| 169 | + .append("text") |
| 170 | + .attr("x", width / 2) |
| 171 | + .attr("y", 50) |
| 172 | + .attr("text-anchor", "middle") |
| 173 | + .attr("fill", t.ink) |
| 174 | + .style("font-size", "22px") |
| 175 | + .style("font-weight", "600") |
| 176 | + .text("scatter-categorical · javascript · d3 · anyplot.ai"); |
0 commit comments