{"spec_id":"scatter-3d","library":"d3","language":"javascript","code":"// anyplot.ai\n// scatter-3d: 3D Scatter Plot\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-10\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Data: customer segments across three RFM behavioral dimensions --------\n// (Recency, Frequency, Monetary — a classic feature space for spotting\n// customer clusters in retention/marketing analysis.)\nfunction mulberry32(seed) {\n  return function () {\n    seed |= 0;\n    seed = (seed + 0x6d2b79f5) | 0;\n    let x = Math.imul(seed ^ (seed >>> 15), 1 | seed);\n    x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;\n    return ((x ^ (x >>> 14)) >>> 0) / 4294967296;\n  };\n}\nconst random = mulberry32(42);\nfunction gaussian() {\n  const u1 = Math.max(random(), 1e-9);\n  const u2 = random();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\nconst segments = [\n  { name: \"Champions\", n: 60, recency: [12, 4], frequency: [20, 3], monetary: [190, 25] },\n  { name: \"At Risk\", n: 60, recency: [150, 22], frequency: [4, 1.4], monetary: [85, 15] },\n  { name: \"New Customers\", n: 60, recency: [32, 9], frequency: [3, 1], monetary: [55, 12] },\n];\n\nconst points = [];\nsegments.forEach((seg, segIndex) => {\n  for (let i = 0; i < seg.n; i++) {\n    points.push({\n      segment: seg.name,\n      segIndex,\n      recency: Math.max(1, seg.recency[0] + gaussian() * seg.recency[1]),\n      frequency: Math.max(0.5, seg.frequency[0] + gaussian() * seg.frequency[1]),\n      monetary: Math.max(10, seg.monetary[0] + gaussian() * seg.monetary[1]),\n    });\n  }\n});\n\n// --- Normalize each axis into a [-1, 1] cube for the 3D projection ---------\nconst normX = d3.scaleLinear().domain(d3.extent(points, (d) => d.recency)).range([-1, 1]);\nconst normY = d3.scaleLinear().domain(d3.extent(points, (d) => d.frequency)).range([-1, 1]);\nconst normZ = d3.scaleLinear().domain(d3.extent(points, (d) => d.monetary)).range([-1, 1]);\n\n// --- Axonometric projection: fixed camera angle, no interactive rotation ---\n// (D3 has no native 3D scene graph — this rotates the normalized cube with a\n// standard yaw/pitch rotation matrix and keeps the post-rotation z as a depth\n// value for painter's-algorithm ordering and near/far size + opacity cueing.)\nconst yaw = (-32 * Math.PI) / 180;\nconst pitch = (18 * Math.PI) / 180;\n\nfunction project(x, y, z) {\n  const x1 = x * Math.cos(yaw) + z * Math.sin(yaw);\n  const z1 = -x * Math.sin(yaw) + z * Math.cos(yaw);\n  const y2 = y * Math.cos(pitch) - z1 * Math.sin(pitch);\n  const z2 = y * Math.sin(pitch) + z1 * Math.cos(pitch);\n  return { sx: x1, sy: y2, depth: z2 };\n}\n\n// Frame the view from the 8 cube corners so the projection is data-independent.\nconst corners = [];\nfor (const cx of [-1, 1]) for (const cy of [-1, 1]) for (const cz of [-1, 1]) corners.push(project(cx, cy, cz));\n\nconst margin = { top: 100, right: 180, bottom: 80, left: 90 };\nconst screenX = d3.scaleLinear().domain(d3.extent(corners, (c) => c.sx)).range([margin.left, width - margin.right]);\nconst screenY = d3.scaleLinear().domain(d3.extent(corners, (c) => c.sy)).range([height - margin.bottom, margin.top]);\n\nconst projected = points.map((d) => ({ ...d, ...project(normX(d.recency), normY(d.frequency), normZ(d.monetary)) }));\nconst depthExtent = d3.extent(projected, (d) => d.depth);\nconst radiusScale = d3.scaleLinear().domain(depthExtent).range([6, 11]);\nconst opacityScale = d3.scaleLinear().domain(depthExtent).range([0.55, 0.92]);\n// Push the \"Champions\" segment forward as the focal cluster by keeping it at\n// full size/opacity while slightly de-emphasizing the two background segments.\nconst emphasis = (segIndex) => (segIndex === 0 ? 1 : 0.82);\n\nconst color = d3.scaleOrdinal().domain(segments.map((s) => s.name)).range(t.palette);\n\n// --- SVG mount ---------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\n// --- 3D axes: three edges from the cube's front-bottom-left corner ---------\nconst axes = [\n  { label: \"Recency (days)\", from: [-1, -1, -1], to: [1, -1, -1], scale: normX, format: d3.format(\".0f\") },\n  { label: \"Frequency (orders/yr)\", from: [-1, -1, -1], to: [-1, 1, -1], scale: normY, format: d3.format(\".0f\") },\n  { label: \"Monetary ($ avg order)\", from: [-1, -1, -1], to: [-1, -1, 1], scale: normZ, format: (v) => `$${d3.format(\".0f\")(v)}` },\n];\n\n// The three axes share one corner; offset tick labels and axis titles\n// perpendicular to each axis line, pointing away from that shared tripod\n// center, so labels never crowd the axis line or a neighboring title.\nconst axisEndsPx = axes.map((axis) => {\n  const p0 = project(...axis.from);\n  const p1 = project(...axis.to);\n  return { x0: screenX(p0.sx), y0: screenY(p0.sy), x1: screenX(p1.sx), y1: screenY(p1.sy) };\n});\nconst tripodCenter = {\n  x: d3.mean([axisEndsPx[0].x0, ...axisEndsPx.map((e) => e.x1)]),\n  y: d3.mean([axisEndsPx[0].y0, ...axisEndsPx.map((e) => e.y1)]),\n};\n\nconst axisGroup = svg.append(\"g\");\naxes.forEach((axis, i) => {\n  const { x0, y0, x1, y1 } = axisEndsPx[i];\n  const dx = x1 - x0;\n  const dy = y1 - y0;\n  const len = Math.hypot(dx, dy) || 1;\n  const dir = { x: dx / len, y: dy / len };\n  let perp = { x: -dir.y, y: dir.x };\n  const mid = { x: (x0 + x1) / 2, y: (y0 + y1) / 2 };\n  if (perp.x * (mid.x - tripodCenter.x) + perp.y * (mid.y - tripodCenter.y) < 0) {\n    perp = { x: -perp.x, y: -perp.y };\n  }\n  const anchorFor = (vx) => (vx > 0.35 ? \"start\" : vx < -0.35 ? \"end\" : \"middle\");\n\n  axisGroup\n    .append(\"line\")\n    .attr(\"x1\", x0)\n    .attr(\"y1\", y0)\n    .attr(\"x2\", x1)\n    .attr(\"y2\", y1)\n    .attr(\"stroke\", t.inkSoft)\n    .attr(\"stroke-width\", 1.2);\n\n  axis.scale.ticks(4).forEach((value) => {\n    const tPos = axis.scale(value);\n    const point = axis.from.map((v, idx) => (axis.to[idx] !== v ? tPos : v));\n    const pr = project(...point);\n    const px = screenX(pr.sx);\n    const py = screenY(pr.sy);\n    axisGroup\n      .append(\"circle\")\n      .attr(\"cx\", px)\n      .attr(\"cy\", py)\n      .attr(\"r\", 2)\n      .attr(\"fill\", t.grid);\n    const lx = px + perp.x * 16;\n    const ly = py + perp.y * 16;\n    axisGroup\n      .append(\"text\")\n      .attr(\"x\", lx)\n      .attr(\"y\", ly)\n      .attr(\"dy\", \"0.32em\")\n      .attr(\"text-anchor\", anchorFor(perp.x))\n      .attr(\"fill\", t.inkSoft)\n      .style(\"font-size\", \"13px\")\n      .text(axis.format(value));\n  });\n\n  // Titles stay \"middle\"-anchored (long strings would overflow the canvas\n  // edge under a directional anchor near the tripod's outer corners) and are\n  // only nudged perpendicular to the axis, clear of the last tick label.\n  const tx = x1 + perp.x * 24;\n  const ty = y1 + perp.y * 24;\n  axisGroup\n    .append(\"text\")\n    .attr(\"x\", tx)\n    .attr(\"y\", ty)\n    .attr(\"dy\", \"0.32em\")\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"fill\", t.ink)\n    .style(\"font-size\", \"15px\")\n    .style(\"font-weight\", \"600\")\n    .text(axis.label);\n});\n\n// --- Scatter points, painter's algorithm (far to near) ----------------------\nsvg\n  .append(\"g\")\n  .selectAll(\"circle.point\")\n  .data([...projected].sort((a, b) => a.depth - b.depth))\n  .join(\"circle\")\n  .attr(\"class\", \"point\")\n  .attr(\"cx\", (d) => screenX(d.sx))\n  .attr(\"cy\", (d) => screenY(d.sy))\n  .attr(\"r\", (d) => radiusScale(d.depth) * emphasis(d.segIndex))\n  .attr(\"fill\", (d) => color(d.segment))\n  .attr(\"fill-opacity\", (d) => opacityScale(d.depth) * emphasis(d.segIndex))\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 0.75);\n\n// --- Legend -------------------------------------------------------------------\n// Placed inside the plot's own empty upper-mid-right region (above the \"At\n// Risk\" cluster, beside \"Champions\") rather than the far outer margin, so it\n// reads as part of the composition instead of an isolated corner element.\nconst innerWidth = width - margin.left - margin.right;\nconst legendX = margin.left + innerWidth * 0.6;\nconst legendY = margin.top + 6;\nconst legend = svg.append(\"g\").attr(\"transform\", `translate(${legendX}, ${legendY})`);\nlegend\n  .append(\"rect\")\n  .attr(\"x\", -16)\n  .attr(\"y\", -22)\n  .attr(\"width\", 176)\n  .attr(\"height\", segments.length * 34 + 16)\n  .attr(\"rx\", 8)\n  .attr(\"fill\", t.elevatedBg)\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1);\nsegments.forEach((seg, i) => {\n  const row = legend.append(\"g\").attr(\"transform\", `translate(0, ${i * 34})`);\n  row.append(\"circle\").attr(\"r\", 8).attr(\"cx\", 8).attr(\"cy\", 0).attr(\"fill\", color(seg.name));\n  row.append(\"text\").attr(\"x\", 24).attr(\"y\", 5).attr(\"fill\", t.ink).style(\"font-size\", \"15px\").text(seg.name);\n});\n\n// --- Title --------------------------------------------------------------------\nconst titleText = \"Customer Segments in 3D Feature Space · scatter-3d · javascript · d3 · anyplot.ai\";\nconst titleFontSize = Math.max(15, Math.round(22 * Math.min(1, 67 / titleText.length)));\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 46)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", `${titleFontSize}px`)\n  .style(\"font-weight\", \"600\")\n  .text(titleText);\n"}