{"spec_id":"bubble-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// bubble-basic: Basic Bubble Chart\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 84/100 | Updated: 2026-09-27\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 100, right: 60, bottom: 90, left: 100 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Startup funding rounds: funding raised vs. revenue growth rate, bubble size\n// encodes team size — funding drives hiring, while growth slows as rounds grow.\nfunction lcg(seed) {\n  let state = seed;\n  return () => {\n    state = (state * 1664525 + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rand = lcg(42);\n\nconst n = 120;\nconst data = [];\nfor (let i = 0; i < n; i++) {\n  const funding = 2 + rand() * 148;\n  const growth = 55 - funding * 0.25 + (rand() - 0.5) * 45;\n  const team = 10 + funding * 0.55 + (rand() - 0.5) * 20;\n  data.push({\n    funding: Math.round(funding * 10) / 10,\n    growth: Math.round(growth * 10) / 10,\n    team: Math.round(Math.min(100, Math.max(10, team))),\n  });\n}\n\n// --- SVG mount ----------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Scales ---------------------------------------------------------------\nconst x = d3.scaleLinear().domain([0, d3.max(data, (d) => d.funding)]).nice().range([0, iw]);\nconst y = d3.scaleLinear().domain(d3.extent(data, (d) => d.growth)).nice().range([ih, 0]);\nconst teamExtent = d3.extent(data, (d) => d.team);\n// Radius scaled by sqrt(team) so the bubble AREA (not radius) is proportional\n// to team size, per the spec's perceptual-accuracy requirement.\nconst r = d3.scaleSqrt().domain(teamExtent).range([7, 18]);\n// Stroke weight steps up across team-size terciles — a secondary encoding\n// that reinforces the size channel with an outline cue, mirrored in the\n// legend below so the reference set and the real bubbles agree.\nconst strokeWidth = d3.scaleQuantize().domain(teamExtent).range([1, 2, 3.5]);\n\n// --- Gridlines --------------------------------------------------------------\nconst gridX = g.append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(x).tickSize(-ih).tickFormat(\"\"));\nconst gridY = g.append(\"g\").call(d3.axisLeft(y).tickSize(-iw).tickFormat(\"\"));\nfor (const grid of [gridX, gridY]) {\n  grid.select(\".domain\").remove();\n  grid.selectAll(\"line\").attr(\"stroke\", t.grid).attr(\"stroke-opacity\", 0.5);\n}\n\n// --- Axes ---------------------------------------------------------------------\nconst xAxis = g.append(\"g\").attr(\"transform\", `translate(0,${ih})`).call(d3.axisBottom(x));\nconst yAxis = g.append(\"g\").call(d3.axisLeft(y));\nfor (const ax of [xAxis, yAxis]) {\n  ax.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\n  ax.selectAll(\"line\").attr(\"stroke\", t.inkSoft);\n  // Domain lines stay theme-adaptive but softened (opacity, not inkSoft at\n  // full strength) so the L-shaped frame reads as a subtle anchor rather\n  // than a bold outline, matching the gridlines' restraint.\n  ax.select(\".domain\").attr(\"stroke\", t.inkSoft).attr(\"stroke-opacity\", 0.55);\n}\n\n// --- Axis labels --------------------------------------------------------------\ng.append(\"text\")\n  .attr(\"x\", iw / 2)\n  .attr(\"y\", ih + 60)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Funding Raised ($M)\");\n\ng.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -ih / 2)\n  .attr(\"y\", -70)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Revenue Growth Rate (%)\");\n\n// --- Bubbles --------------------------------------------------------------\n// Every bubble sits at its true (funding, growth) coordinate — overlap in the\n// densest funding cluster is handled through fill-opacity and a thin\n// page-colored outline, not by displacing marks from their data values.\ng.selectAll(\"circle.bubble\").data(data).join(\"circle\")\n  .attr(\"class\", \"bubble\")\n  .attr(\"cx\", (d) => x(d.funding))\n  .attr(\"cy\", (d) => y(d.growth))\n  .attr(\"r\", (d) => r(d.team))\n  .attr(\"fill\", t.palette[0])\n  .attr(\"fill-opacity\", 0.58)\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", (d) => strokeWidth(d.team));\n\n// --- Size legend ------------------------------------------------------------\nconst teamMedian = d3.median(data, (d) => d.team);\nconst legendValues = [\n  Math.round(teamExtent[0] / 5) * 5,\n  Math.round(teamMedian / 5) * 5,\n  Math.round(teamExtent[1] / 5) * 5,\n];\nconst legendR = legendValues.map((v) => r(v));\nconst legendBoxW = 260;\nconst legendBoxH = 140;\nconst legend = svg.append(\"g\")\n  .attr(\"transform\", `translate(${margin.left + iw - legendBoxW + 10},${margin.top - 20})`);\n\nlegend.append(\"rect\")\n  .attr(\"width\", legendBoxW)\n  .attr(\"height\", legendBoxH)\n  .attr(\"fill\", t.elevatedBg)\n  .attr(\"stroke\", t.grid)\n  .attr(\"rx\", 8);\n\nlegend.append(\"text\")\n  .attr(\"x\", legendBoxW / 2)\n  .attr(\"y\", 28)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text(\"Team Size (employees)\");\n\n// Legend circles echo the real bubble treatment (brand-green tint at the\n// same 0.58 fill-opacity as the real marks, with the same background-colored\n// cutout stroke) and reuse the same team-size stroke-width scale as the real\n// bubbles, so the reference set matches the data in fill and outline alike.\nconst baselineY = 110;\nconst legendX = [60, 140, 210];\nlegendValues.forEach((v, i) => {\n  legend.append(\"circle\")\n    .attr(\"cx\", legendX[i])\n    .attr(\"cy\", baselineY - legendR[i])\n    .attr(\"r\", legendR[i])\n    .attr(\"fill\", t.palette[0])\n    .attr(\"fill-opacity\", 0.58)\n    .attr(\"stroke\", t.pageBg)\n    .attr(\"stroke-width\", strokeWidth(v));\n  legend.append(\"text\")\n    .attr(\"x\", legendX[i])\n    .attr(\"y\", baselineY + 22)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"16px\")\n    .text(v);\n});\n\n// --- Title ------------------------------------------------------------------\nsvg.append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 44)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"bubble-basic · javascript · d3 · anyplot.ai\");\n"}