{"spec_id":"wordcloud-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// wordcloud-basic: Basic Word Cloud\n// Library: d3 7.9.0 | JavaScript 22.23.1\n// Quality: 88/100 | Created: 2026-08-04\n\n//# anyplot-orientation: landscape\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst muted = window.ANYPLOT_THEME === \"dark\" ? \"#A8A79F\" : \"#6B6A63\";\n\n// --- Data: term frequencies from a product-review survey, tagged by sentiment\nconst words = [\n  { word: \"reliable\", frequency: 142, sentiment: \"positive\" },\n  { word: \"expensive\", frequency: 118, sentiment: \"negative\" },\n  { word: \"intuitive\", frequency: 104, sentiment: \"positive\" },\n  { word: \"support\", frequency: 96, sentiment: \"neutral\" },\n  { word: \"battery\", frequency: 90, sentiment: \"neutral\" },\n  { word: \"sluggish\", frequency: 84, sentiment: \"negative\" },\n  { word: \"durable\", frequency: 79, sentiment: \"positive\" },\n  { word: \"shipping\", frequency: 74, sentiment: \"negative\" },\n  { word: \"design\", frequency: 70, sentiment: \"positive\" },\n  { word: \"setup\", frequency: 66, sentiment: \"neutral\" },\n  { word: \"value\", frequency: 62, sentiment: \"positive\" },\n  { word: \"noisy\", frequency: 58, sentiment: \"negative\" },\n  { word: \"warranty\", frequency: 55, sentiment: \"neutral\" },\n  { word: \"lightweight\", frequency: 52, sentiment: \"positive\" },\n  { word: \"confusing\", frequency: 49, sentiment: \"negative\" },\n  { word: \"responsive\", frequency: 47, sentiment: \"positive\" },\n  { word: \"packaging\", frequency: 45, sentiment: \"neutral\" },\n  { word: \"overpriced\", frequency: 43, sentiment: \"negative\" },\n  { word: \"sturdy\", frequency: 41, sentiment: \"positive\" },\n  { word: \"manual\", frequency: 39, sentiment: \"neutral\" },\n  { word: \"glitchy\", frequency: 37, sentiment: \"negative\" },\n  { word: \"comfortable\", frequency: 35, sentiment: \"positive\" },\n  { word: \"delayed\", frequency: 33, sentiment: \"negative\" },\n  { word: \"helpful\", frequency: 32, sentiment: \"positive\" },\n  { word: \"compact\", frequency: 30, sentiment: \"positive\" },\n  { word: \"pricey\", frequency: 29, sentiment: \"negative\" },\n  { word: \"smooth\", frequency: 28, sentiment: \"positive\" },\n  { word: \"outdated\", frequency: 27, sentiment: \"negative\" },\n  { word: \"friendly\", frequency: 26, sentiment: \"positive\" },\n  { word: \"return\", frequency: 25, sentiment: \"neutral\" },\n  { word: \"accurate\", frequency: 24, sentiment: \"positive\" },\n  { word: \"clunky\", frequency: 23, sentiment: \"negative\" },\n  { word: \"premium\", frequency: 22, sentiment: \"positive\" },\n  { word: \"delivery\", frequency: 21, sentiment: \"neutral\" },\n  { word: \"stylish\", frequency: 20, sentiment: \"positive\" },\n  { word: \"refund\", frequency: 19, sentiment: \"negative\" },\n  { word: \"efficient\", frequency: 18, sentiment: \"positive\" },\n  { word: \"instructions\", frequency: 17, sentiment: \"neutral\" },\n  { word: \"quiet\", frequency: 16, sentiment: \"positive\" },\n  { word: \"fragile\", frequency: 15, sentiment: \"negative\" },\n];\n\nconst sentimentColor = (s) => (s === \"positive\" ? t.palette[0] : s === \"negative\" ? t.palette[4] : muted);\n\nconst fontSize = d3\n  .scaleSqrt()\n  .domain(d3.extent(words, (d) => d.frequency))\n  .range([20, 80]);\n\n// --- SVG mount ---------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\n// --- Cloud placement (Vogel/Fermat sunflower spiral + rectangle collision) --\nconst bounds = { xMin: 30, xMax: width - 30, yMin: 96, yMax: height - 76 };\nconst cx = (bounds.xMin + bounds.xMax) / 2;\nconst cy = (bounds.yMin + bounds.yMax) / 2;\nconst halfW = cx - bounds.xMin;\nconst halfH = cy - bounds.yMin;\nconst squash = halfH / halfW; // keep the elliptical search inside the rectangular bounds\nconst pad = 4;\nconst placedRects = [];\n\nconst overlaps = (r) =>\n  placedRects.some((p) => !(r.x + r.w < p.x || r.x > p.x + p.w || r.y + r.h < p.y || r.y > p.y + p.h));\n\nconst GOLDEN_ANGLE = 2.399963;\nconst SPIRAL_STEP = 5;\n\nconst findSpot = (bw, bh) => {\n  const maxRadius = Math.hypot(halfW, halfH) + Math.max(bw, bh);\n  const maxSteps = Math.ceil((maxRadius / SPIRAL_STEP) ** 2);\n  for (let step = 0; step < maxSteps; step++) {\n    const angle = step * GOLDEN_ANGLE;\n    const radius = SPIRAL_STEP * Math.sqrt(step);\n    const x = cx + radius * Math.cos(angle);\n    const y = cy + radius * Math.sin(angle) * squash;\n    const rect = { x: x - bw / 2 - pad, y: y - bh / 2 - pad, w: bw + 2 * pad, h: bh + 2 * pad };\n    if (rect.x < bounds.xMin || rect.x + rect.w > bounds.xMax) continue;\n    if (rect.y < bounds.yMin || rect.y + rect.h > bounds.yMax) continue;\n    if (!overlaps(rect)) return { x, y, rect };\n  }\n  return null;\n};\n\n// Largest (most frequent) terms placed first, near the center.\nconst ranked = [...words].sort((a, b) => b.frequency - a.frequency);\n\nconst cloud = svg.append(\"g\");\nconst placed = [];\nfor (const d of ranked) {\n  const label = cloud\n    .append(\"text\")\n    .attr(\"font-size\", `${fontSize(d.frequency)}px`)\n    .attr(\"font-weight\", d.frequency >= fontSize.domain()[1] * 0.55 ? 700 : 500)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"dominant-baseline\", \"central\")\n    .attr(\"fill\", sentimentColor(d.sentiment))\n    .text(d.word);\n\n  const box = label.node().getBBox();\n  const spot = findSpot(box.width, box.height);\n  if (!spot) {\n    label.remove();\n    continue;\n  }\n  label.attr(\"x\", spot.x).attr(\"y\", spot.y);\n  placedRects.push(spot.rect);\n  placed.push({ label, x: spot.x, y: spot.y });\n}\n\n// Stretch the placed word mass outward from its own center so it fills more\n// of the reserved bounds, scaling each axis independently and clamping so no\n// rect crosses the original bounds. Because every rect scales from the same\n// center by a factor >= 1, pairwise gaps only grow, so this never introduces\n// a new overlap between previously non-overlapping words.\nif (placed.length) {\n  const minX = d3.min(placedRects, (r) => r.x);\n  const maxX = d3.max(placedRects, (r) => r.x + r.w);\n  const minY = d3.min(placedRects, (r) => r.y);\n  const maxY = d3.max(placedRects, (r) => r.y + r.h);\n  const bcx = (minX + maxX) / 2;\n  const bcy = (minY + maxY) / 2;\n  const halfBw = (maxX - minX) / 2;\n  const halfBh = (maxY - minY) / 2;\n  const sx = Math.max(1, Math.min((bounds.xMax - bcx) / halfBw, (bcx - bounds.xMin) / halfBw));\n  const sy = Math.max(1, Math.min((bounds.yMax - bcy) / halfBh, (bcy - bounds.yMin) / halfBh));\n  for (const p of placed) {\n    p.label.attr(\"x\", bcx + (p.x - bcx) * sx).attr(\"y\", bcy + (p.y - bcy) * sy);\n  }\n}\n\n// --- Title ---------------------------------------------------------------\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\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"wordcloud-basic · javascript · d3 · anyplot.ai\");\n\n// --- Legend: sentiment color key ------------------------------------------\nconst legendItems = [\n  { label: \"Positive\", color: t.palette[0] },\n  { label: \"Negative\", color: t.palette[4] },\n  { label: \"Neutral\", color: muted },\n];\nconst legend = svg\n  .append(\"g\")\n  .attr(\"transform\", `translate(${width / 2 - 170}, ${height - 34})`);\nlegendItems.forEach((item, i) => {\n  const g = legend.append(\"g\").attr(\"transform\", `translate(${i * 130}, 0)`);\n  g.append(\"rect\").attr(\"width\", 14).attr(\"height\", 14).attr(\"rx\", 3).attr(\"fill\", item.color);\n  g.append(\"text\")\n    .attr(\"x\", 22)\n    .attr(\"y\", 11)\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"14px\")\n    .text(item.label);\n});\n"}