{"spec_id":"scatter-hr-diagram","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// scatter-hr-diagram: Hertzsprung-Russell Diagram\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 86/100 | Created: 2026-08-26\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Deterministic PRNG (LCG + Box-Muller) ----------------------------------\nconst rng = (() => {\n  let s = 88172645;\n  return () => {\n    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;\n    return s / 4294967296;\n  };\n})();\nconst gaussian = () => {\n  const u1 = Math.max(rng(), 1e-6);\n  const u2 = rng();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n};\n\n// --- Spectral classification -------------------------------------------------\n// Spectral type is derived from surface temperature (the real astrophysical\n// relationship) so every star — generated or notable — carries a genuine\n// `spectral_type` field, per the spec's data section.\nconst spectralType = (tempK) => {\n  if (tempK >= 30000) return \"O\";\n  if (tempK >= 10000) return \"B\";\n  if (tempK >= 7500) return \"A\";\n  if (tempK >= 6000) return \"F\";\n  if (tempK >= 5200) return \"G\";\n  if (tempK >= 3700) return \"K\";\n  return \"M\";\n};\n\n// The spec mandates conventional spectral colors (blue O/B, white A, yellow\n// F/G, orange K, red M). The Imprint palette has no literal white or orange\n// hex, so each bucket maps to the closest safe Imprint analogue: cyan stands\n// in for \"white\" (the palette's palest hue) and the fixed amber anchor stands\n// in for \"orange\" (between ochre-yellow and matte-red). This is a documented\n// semantic exception (default-style-guide.md \"Domain conventions\") — the\n// spec's explicit color convention outranks the default ordinal palette\n// order, which is why the primary series here is not brand green.\nconst COLOR_GROUPS = {\n  OB: { label: \"O/B\", color: t.palette[2] }, // blue\n  A: { label: \"A\", color: t.palette[5] }, // cyan (≈ white)\n  FG: { label: \"F/G\", color: t.palette[3] }, // ochre (yellow)\n  K: { label: \"K\", color: t.amber }, // amber (≈ orange)\n  M: { label: \"M\", color: t.palette[4] }, // matte red\n};\nconst colorGroupOf = (type) =>\n  type === \"O\" || type === \"B\" ? \"OB\" : type === \"F\" || type === \"G\" ? \"FG\" : type;\n\nconst star = (x, y, region, name = null) => {\n  const type = spectralType(x);\n  return { x, y, region, name, spectral_type: type, colorGroup: colorGroupOf(type) };\n};\n\n// --- Data: synthetic stellar populations across the HR diagram -------------\n// Temperature (K) and luminosity (L☉) follow rough astrophysical scaling laws\n// per region; luminosity is log-scaled, temperature axis is reversed (hot→cool).\nconst LOG_T_SUN = Math.log10(5778);\n\nconst mainSequence = Array.from({ length: 140 }, () => {\n  const logT = 3.477 + rng() * (4.602 - 3.477);\n  const logL = 4.0 * (logT - LOG_T_SUN) + gaussian() * 0.22;\n  return star(10 ** logT, 10 ** logL, \"main sequence\");\n});\n\nconst redGiants = Array.from({ length: 25 }, () => {\n  const logT = 3.477 + rng() * (3.716 - 3.477);\n  const logL = 1.0 + rng() * 2.0;\n  return star(10 ** logT, 10 ** logL, \"red giants\");\n});\n\nconst whiteDwarfs = Array.from({ length: 20 }, () => {\n  const logT = 3.903 + rng() * (4.602 - 3.903);\n  const logL = -4.3 + rng() * 2.6;\n  return star(10 ** logT, 10 ** logL, \"white dwarfs\");\n});\n\nconst supergiants = Array.from({ length: 15 }, () => {\n  const logT = 3.477 + rng() * (4.477 - 3.477);\n  const logL = 4.0 + rng() * 2.0;\n  return star(10 ** logT, 10 ** logL, \"supergiants\");\n});\n\n// A handful of real, notable stars — the spec's `star_name` field — spanning\n// several regions and spectral types, labeled directly on the canvas.\nconst notableStars = [\n  star(9940, 25.4, \"main sequence\", \"Sirius A\"),\n  star(3042, 0.0017, \"main sequence\", \"Proxima Centauri\"),\n  star(3500, 126000, \"supergiants\", \"Betelgeuse\"),\n  star(12100, 120000, \"supergiants\", \"Rigel\"),\n  star(25000, 0.0025, \"white dwarfs\", \"Sirius B\"),\n];\n\nconst sun = star(5778, 1, \"main sequence\", \"Sun\");\n\nconst allStars = [...mainSequence, ...redGiants, ...whiteDwarfs, ...supergiants, ...notableStars];\n\n// Marker radius scales with each region's real relative stellar size.\nconst REGION_RADIUS = { \"main sequence\": 5, \"red giants\": 11, supergiants: 15, \"white dwarfs\": 4 };\n\n// Where to draw each region's in-plot label (small offset from its centroid\n// so the text sits beside the cluster rather than on top of it).\nconst REGION_LABEL_POS = {\n  \"main sequence\": { dx: 70, dy: -46 },\n  \"red giants\": { dx: 40, dy: -70 },\n  supergiants: { dx: 0, dy: -24 },\n  \"white dwarfs\": { dx: 0, dy: 22 },\n};\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- In-plot labeling plugin --------------------------------------------------\n// Draws the four required region labels near each cluster's centroid, plus\n// the notable-star names, directly on the canvas. Region is a secondary\n// encoding here (color is reserved for spectral type per the spec's Notes),\n// so it is called out in-plot rather than via a second legend.\nconst labelPlugin = {\n  id: \"hrLabels\",\n  afterDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    ctx.save();\n\n    ctx.font = \"italic 15px sans-serif\";\n    ctx.fillStyle = t.inkSoft;\n    ctx.textAlign = \"center\";\n    for (const [region, pos] of Object.entries(REGION_LABEL_POS)) {\n      const pts = allStars.filter((s) => s.region === region);\n      if (!pts.length) continue;\n      const cx = pts.reduce((sum, p) => sum + p.x, 0) / pts.length;\n      const cy = 10 ** (pts.reduce((sum, p) => sum + Math.log10(p.y), 0) / pts.length);\n      const px = scales.x.getPixelForValue(cx) + pos.dx;\n      const py = scales.y.getPixelForValue(cy) + pos.dy;\n      ctx.fillText(region, px, py);\n    }\n\n    ctx.font = \"12px sans-serif\";\n    ctx.fillStyle = t.ink;\n    const midX = (scales.x.left + scales.x.right) / 2;\n    for (const s of [...notableStars, sun]) {\n      const px = scales.x.getPixelForValue(s.x);\n      const py = scales.y.getPixelForValue(s.y);\n      const rightHalf = px > midX;\n      ctx.textAlign = rightHalf ? \"right\" : \"left\";\n      ctx.fillText(s.name, px + (rightHalf ? -12 : 12), py - 8);\n    }\n\n    ctx.restore();\n  },\n};\n\n// --- Chart ---------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: {\n    datasets: [\n      ...Object.entries(COLOR_GROUPS)\n        .map(([key, group]) => {\n          const pts = allStars.filter((s) => s.colorGroup === key);\n          if (!pts.length) return null;\n          return {\n            label: group.label,\n            data: pts.map((p) => ({ x: p.x, y: p.y })),\n            backgroundColor: group.color,\n            borderColor: t.pageBg,\n            borderWidth: 1,\n            radius: pts.map((p) => REGION_RADIUS[p.region]),\n          };\n        })\n        .filter(Boolean),\n      {\n        // \"star\" is a stroke-only asterisk shape in Chart.js (no fillable\n        // area), so it needs a visible borderColor, not the page-background\n        // edge used by the filled spectral-group markers above.\n        label: \"Sun\",\n        data: [{ x: sun.x, y: sun.y }],\n        backgroundColor: t.ink,\n        borderColor: t.ink,\n        borderWidth: 3,\n        radius: 15,\n        pointStyle: \"star\",\n      },\n    ],\n  },\n  plugins: [labelPlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    plugins: {\n      title: {\n        display: true,\n        text: \"Hertzsprung-Russell Diagram · scatter-hr-diagram · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 18 },\n      },\n      subtitle: {\n        display: true,\n        text: \"Color = spectral type (O/B blue · A pale cyan · F/G ochre · K amber · M red) — regions labeled in-plot\",\n        color: t.inkSoft,\n        font: { size: 14, style: \"italic\" },\n        padding: { bottom: 8 },\n      },\n      legend: {\n        labels: { color: t.ink, font: { size: 16 }, usePointStyle: true },\n      },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        reverse: true,\n        min: 2000,\n        max: 42000,\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 14 },\n          callback: (value) => `${value.toLocaleString()} K`,\n        },\n        grid: { color: t.grid },\n        title: {\n          display: true,\n          text: \"Surface Temperature — hot → cool\",\n          color: t.ink,\n          font: { size: 18 },\n        },\n      },\n      y: {\n        type: \"logarithmic\",\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 14 },\n          callback: (value) => {\n            const num = Number(value);\n            return num >= 1000 ? num.toLocaleString() : num.toString();\n          },\n        },\n        grid: { color: t.grid },\n        title: {\n          display: true,\n          text: \"Luminosity (L☉, log scale)\",\n          color: t.ink,\n          font: { size: 18 },\n        },\n      },\n    },\n  },\n});\n"}