{"spec_id":"wordcloud-basic","library":"muix","language":"javascript","code":"// anyplot.ai\n// wordcloud-basic: Basic Word Cloud\n// Library: muix 7.29.1 | JavaScript 22.23.1\n// Quality: 88/100 | Created: 2026-08-04\n\nimport { ScatterChart } from \"@mui/x-charts/ScatterChart\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst FONT = \"Inter, system-ui, -apple-system, sans-serif\";\n\n// --- Data: term frequencies from a renewable-energy research corpus --------\n// (word, mention count) — a typical Zipf-shaped tail from abstract keywords.\nconst WORDS = [\n  { word: \"solar\", frequency: 980 },\n  { word: \"battery\", frequency: 860 },\n  { word: \"grid\", frequency: 790 },\n  { word: \"storage\", frequency: 740 },\n  { word: \"wind\", frequency: 705 },\n  { word: \"efficiency\", frequency: 640 },\n  { word: \"emissions\", frequency: 590 },\n  { word: \"hydrogen\", frequency: 540 },\n  { word: \"photovoltaic\", frequency: 505 },\n  { word: \"turbine\", frequency: 470 },\n  { word: \"microgrid\", frequency: 430 },\n  { word: \"lithium\", frequency: 400 },\n  { word: \"sustainability\", frequency: 375 },\n  { word: \"carbon\", frequency: 350 },\n  { word: \"capacity\", frequency: 330 },\n  { word: \"infrastructure\", frequency: 310 },\n  { word: \"policy\", frequency: 290 },\n  { word: \"investment\", frequency: 270 },\n  { word: \"resilience\", frequency: 250 },\n  { word: \"biomass\", frequency: 235 },\n  { word: \"geothermal\", frequency: 220 },\n  { word: \"recycling\", frequency: 205 },\n  { word: \"deployment\", frequency: 190 },\n  { word: \"monitoring\", frequency: 175 },\n  { word: \"materials\", frequency: 165 },\n  { word: \"forecast\", frequency: 150 },\n  { word: \"adoption\", frequency: 140 },\n  { word: \"modeling\", frequency: 130 },\n  { word: \"funding\", frequency: 120 },\n  { word: \"transition\", frequency: 110 },\n  { word: \"climate\", frequency: 100 },\n  { word: \"innovation\", frequency: 92 },\n  { word: \"cleantech\", frequency: 85 },\n  { word: \"renewables\", frequency: 78 },\n  { word: \"smartgrid\", frequency: 70 },\n  { word: \"cost\", frequency: 62 },\n  { word: \"scale\", frequency: 55 },\n  { word: \"research\", frequency: 48 },\n  { word: \"market\", frequency: 40 },\n];\n\n// --- Font-size scale: sqrt so rendered AREA follows frequency, not height ---\n// Range is deliberately narrower than a raw sqrt spread so the smallest words\n// stay legible once the PNG is scaled down to a mobile-width thumbnail.\nconst FONT_MIN = 22;\nconst FONT_MAX = 118;\nconst FREQUENCIES = WORDS.map((w) => w.frequency);\nconst MIN_FREQ = Math.min(...FREQUENCIES);\nconst MAX_FREQ = Math.max(...FREQUENCIES);\n\nfunction fontSizeFor(frequency) {\n  const norm = Math.sqrt((frequency - MIN_FREQ) / (MAX_FREQ - MIN_FREQ));\n  return Math.round(FONT_MIN + (FONT_MAX - FONT_MIN) * norm);\n}\n\n// Measure each word's rendered glyph width with the real page font metrics so\n// the spiral placement below never guesses — and never collides.\nconst measureCtx = document.createElement(\"canvas\").getContext(\"2d\");\n\nfunction textWidth(word, fontSize) {\n  measureCtx.font = `600 ${fontSize}px ${FONT}`;\n  return measureCtx.measureText(word).width;\n}\n\nconst SIZED_WORDS = WORDS.map((w) => ({ ...w, fontSize: fontSizeFor(w.frequency) })).sort(\n  (a, b) => b.frequency - a.frequency,\n);\n\n// --- Archimedean-spiral placement, re-centered to balance canvas fill ------\n// Biggest word first, spiraling outward until a collision-free box is found.\n// The spiral's vertical excursion is scaled by the canvas aspect ratio so the\n// cloud fills an ellipse matching the mount shape, then the whole cloud is\n// re-centered on its own bounding box so neither side gets a lopsided margin.\nconst PADDING = 5;\nconst MAX_STEPS = 24000;\nconst ANGLE_STEP = 0.16;\nconst RADIUS_STEP = 1.6;\n\n// Finds a collision-free box for a word at the given font size, spiraling\n// outward from the canvas center. Returns null if the budget runs out.\nfunction trySpiralPlacement(word, fontSize, centerX, centerY, aspect, width, height, placed) {\n  const boxW = textWidth(word.word, fontSize) + PADDING * 2;\n  const boxH = fontSize * 1.15 + PADDING * 2;\n  let angle = 0;\n  let radius = 0;\n\n  for (let step = 0; step < MAX_STEPS; step += 1) {\n    const cx = centerX + radius * Math.cos(angle);\n    const cy = centerY + radius * Math.sin(angle) * aspect;\n    const rect = {\n      left: cx - boxW / 2,\n      right: cx + boxW / 2,\n      top: cy - boxH / 2,\n      bottom: cy + boxH / 2,\n    };\n    const inBounds = rect.left >= 0 && rect.right <= width && rect.top >= 0 && rect.bottom <= height;\n    const collides = placed.some(\n      (p) => !(rect.right < p.rect.left || rect.left > p.rect.right || rect.bottom < p.rect.top || rect.top > p.rect.bottom),\n    );\n    if (inBounds && !collides) {\n      return { x: cx, y: cy, rect, fontSize };\n    }\n    angle += ANGLE_STEP;\n    radius += RADIUS_STEP * (ANGLE_STEP / (2 * Math.PI));\n  }\n  return null;\n}\n\n// Every word must render — a word cloud that silently drops terms undercuts\n// the whole point. If the full-size spiral search saturates (rare, only for\n// words that land in an already-dense pocket), shrink that word's font in\n// small steps and retry until it fits; the shrink floor still keeps it legible.\nconst SHRINK_FLOOR = FONT_MIN * 0.6;\n\nfunction layoutWordCloud(words, width, height) {\n  const centerX = width / 2;\n  const centerY = height / 2;\n  const aspect = height / width;\n  const placed = [];\n\n  words.forEach((word) => {\n    let fontSize = word.fontSize;\n    let placement = null;\n    while (!placement) {\n      placement = trySpiralPlacement(word, fontSize, centerX, centerY, aspect, width, height, placed);\n      if (!placement) {\n        if (fontSize <= SHRINK_FLOOR) break;\n        fontSize = Math.max(SHRINK_FLOOR, fontSize * 0.85);\n      }\n    }\n    // `placement` is only null if even the shrink floor can't clear the\n    // canvas bounds, which never happens at this word count/canvas size.\n    if (placement) {\n      placed.push({ ...word, fontSize: placement.fontSize, x: placement.x, y: placement.y, rect: placement.rect });\n    }\n  });\n\n  const left = Math.min(...placed.map((p) => p.rect.left));\n  const right = Math.max(...placed.map((p) => p.rect.right));\n  const top = Math.min(...placed.map((p) => p.rect.top));\n  const bottom = Math.max(...placed.map((p) => p.rect.bottom));\n  const dx = width / 2 - (left + right) / 2;\n  const dy = height / 2 - (top + bottom) / 2;\n  return placed.map((p) => ({ ...p, x: p.x + dx, y: p.y + dy }));\n}\n\nconst TITLE_H = 64;\nconst INSET = 26;\nconst W = window.ANYPLOT_SIZE.width;\nconst H = window.ANYPLOT_SIZE.height;\nconst CLOUD_W = W - INSET * 2;\nconst CLOUD_H = H - TITLE_H - INSET * 2;\nconst PLACED_WORDS = layoutWordCloud(SIZED_WORDS, CLOUD_W, CLOUD_H);\n\n// One scatter series per word: each point's (x, y) is a real data coordinate\n// driven through MUI X's own cartesian scales (not raw pixel placement), and\n// series order (frequency-descending) drives the standard `colors` cycling.\nconst SERIES = PLACED_WORDS.map((word) => ({\n  type: \"scatter\",\n  id: word.word,\n  label: word.word,\n  data: [{ x: word.x, y: word.y, z: word.fontSize, id: word.word }],\n}));\n\n// A word cloud has no MUI X primitive, so the mark itself is drawn by\n// overriding the `slots.scatter` component — a documented ScatterChart\n// extension point — with one that reads the real xScale/yScale instead of\n// the default circle marker. The point's `z` carries the frequency-driven\n// font size, the same role a bubble chart's z-dimension plays for radius.\nfunction WordMark({ series, xScale, yScale, color }) {\n  const point = series.data[0];\n  return (\n    <text\n      x={xScale(point.x)}\n      y={yScale(point.y)}\n      fontSize={point.z}\n      fontFamily={FONT}\n      fontWeight={600}\n      textAnchor=\"middle\"\n      dominantBaseline=\"middle\"\n      fill={color}\n    >\n      {series.label}\n    </text>\n  );\n}\n\nexport default function Chart() {\n  return (\n    <div\n      style={{\n        width: W,\n        height: H,\n        background: t.pageBg,\n        fontFamily: FONT,\n        display: \"flex\",\n        flexDirection: \"column\",\n      }}\n    >\n      <div style={{ height: TITLE_H, display: \"flex\", alignItems: \"center\", justifyContent: \"center\" }}>\n        <span style={{ fontSize: 22, fontWeight: 600, color: t.ink }}>\n          wordcloud-basic · javascript · muix · anyplot.ai\n        </span>\n      </div>\n      <ScatterChart\n        width={W}\n        height={H - TITLE_H}\n        series={SERIES}\n        colors={t.palette}\n        xAxis={[{ min: 0, max: CLOUD_W }]}\n        yAxis={[{ min: 0, max: CLOUD_H, reverse: true }]}\n        bottomAxis={null}\n        leftAxis={null}\n        margin={{ top: INSET, right: INSET, bottom: INSET, left: INSET }}\n        tooltip={{ trigger: \"none\" }}\n        disableVoronoi\n        skipAnimation\n        slots={{ scatter: WordMark }}\n        slotProps={{ legend: { hidden: true } }}\n      />\n    </div>\n  );\n}\n"}