{"spec_id":"scatter-hr-diagram","library":"muix","language":"javascript","code":"// anyplot.ai\n// scatter-hr-diagram: Hertzsprung-Russell Diagram\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-08-26\nimport { ScatterChart } from \"@mui/x-charts/ScatterChart\";\nimport { ChartsText } from \"@mui/x-charts/ChartsText\";\nimport { useXScale, useYScale } from \"@mui/x-charts/hooks\";\nimport Box from \"@mui/material/Box\";\nimport Typography from \"@mui/material/Typography\";\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic, tiny LCG PRNG) -------------------------\n\nfunction lcg(seed: number) {\n  let s = seed >>> 0;\n  return () => {\n    s = (Math.imul(1664525, s) + 1013904223) >>> 0;\n    return s / 4294967295;\n  };\n}\nconst rand = lcg(42);\nconst randRange = (min: number, max: number) => min + rand() * (max - min);\n\n// Main sequence: mass-luminosity relation L ~ (T / T_sun)^3.9 with scatter,\n// spanning the full O-through-M temperature range.\nconst mainSequence = Array.from({ length: 180 }, (_, i) => {\n  const temperature = randRange(2600, 38000);\n  const luminosity = (temperature / 5778) ** 3.9 * randRange(0.55, 1.8);\n  return { x: temperature, y: luminosity, id: `ms-${i}` };\n});\n\n// Red giants: cool but swollen, so far brighter than a main-sequence star of\n// the same temperature.\nconst redGiants = Array.from({ length: 40 }, (_, i) => ({\n  x: randRange(3400, 5200),\n  y: randRange(15, 900),\n  id: `rg-${i}`,\n}));\n\n// Supergiants: any temperature, always extremely luminous.\nconst supergiants = Array.from({ length: 25 }, (_, i) => ({\n  x: randRange(3000, 30000),\n  y: randRange(8000, 800000),\n  id: `sg-${i}`,\n}));\n\n// White dwarfs: stellar remnants — hot surfaces, but tiny and faint.\nconst whiteDwarfs = Array.from({ length: 35 }, (_, i) => ({\n  x: randRange(8000, 40000),\n  y: randRange(0.0001, 0.02),\n  id: `wd-${i}`,\n}));\n\nconst sun = { x: 5778, y: 1, id: \"sun\" };\n\n// A few real, named stars (approximate literature temperature/luminosity)\n// give the spec's `star_name` field a payoff beyond the Sun.\nconst NOTABLE_STARS = [\n  { name: \"Sirius A\", x: 9940, y: 25.4 },\n  { name: \"Rigel\", x: 12100, y: 120000 },\n  { name: \"Betelgeuse\", x: 3500, y: 126000 },\n];\n\n// Spectral-class boundary temperatures (K), hottest -> coolest, driving the\n// secondary top axis (the spec's optional spectral-class labels).\nconst SPECTRAL_TICKS = [35000, 20000, 8750, 6750, 5600, 4450, 3200];\nconst SPECTRAL_LABELS: Record<number, string> = {\n  35000: \"O\",\n  20000: \"B\",\n  8750: \"A\",\n  6750: \"F\",\n  5600: \"G\",\n  4450: \"K\",\n  3200: \"M\",\n};\nconst SPECTRAL_ORDER = [\"O\", \"B\", \"A\", \"F\", \"G\", \"K\", \"M\"];\n\n// Buckets a temperature into its spectral letter using the same boundaries\n// that drive the secondary top axis, so marker color and axis position always\n// agree on which class a star belongs to.\nfunction classifySpectralType(temperature: number): string {\n  for (const boundary of SPECTRAL_TICKS) {\n    if (temperature >= boundary) return SPECTRAL_LABELS[boundary];\n  }\n  return \"M\";\n}\n\n// The Imprint palette has no literal white or orange, so these are the\n// closest fixed hues standing in for the spec's conventional star colors\n// (blue/white/yellow/orange/red) — same hex in both themes, per palette rules.\nconst SPECTRAL_COLORS: Record<string, string> = {\n  O: t.palette[2], // blue\n  B: t.palette[5], // cyan (blue-white)\n  A: t.palette[1], // lavender — palest available hue, stands in for white\n  F: t.amber, // pale gold\n  G: t.palette[3], // ochre — deeper gold, Sun-like\n  K: t.palette[6], // rose — warm red-orange\n  M: t.palette[4], // matte red\n};\n\nfunction withAlpha(hex: string, alpha: number) {\n  const r = parseInt(hex.slice(1, 3), 16);\n  const g = parseInt(hex.slice(3, 5), 16);\n  const b = parseInt(hex.slice(5, 7), 16);\n  return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n}\n\n// Every generated star, grouped by spectral type (computed from temperature)\n// rather than by the population that generated it — this is what drives\n// point color, while the region labels below stay purely spatial annotations.\nconst allStars = [...mainSequence, ...redGiants, ...supergiants, ...whiteDwarfs];\nconst starsByType: Record<string, { x: number; y: number; id: string }[]> = Object.fromEntries(\n  SPECTRAL_ORDER.map((type) => [type, []]),\n);\nfor (const star of allStars) {\n  starsByType[classifySpectralType(star.x)].push(star);\n}\n\nconst TEMPERATURE_DOMAIN: [number, number] = [1900, 41000];\nconst LUMINOSITY_DOMAIN: [number, number] = [0.0001, 1000000];\n// Only the decade boundaries get a tick — d3's default log ticks (1/2/3/5/7 per\n// decade) crowd into unreadable clumps once ~10 decades are compressed into one axis.\nconst LUMINOSITY_TICKS = [0.0001, 0.001, 0.01, 0.1, 1, 10, 100, 1000, 10000, 100000, 1000000];\n\nconst SUPERSCRIPT_DIGITS: Record<string, string> = {\n  \"-\": \"⁻\",\n  \"0\": \"⁰\",\n  \"1\": \"¹\",\n  \"2\": \"²\",\n  \"3\": \"³\",\n  \"4\": \"⁴\",\n  \"5\": \"⁵\",\n  \"6\": \"⁶\",\n  \"7\": \"⁷\",\n  \"8\": \"⁸\",\n  \"9\": \"⁹\",\n};\nconst toSuperscript = (n: number) =>\n  String(n)\n    .split(\"\")\n    .map((c) => SUPERSCRIPT_DIGITS[c] ?? c)\n    .join(\"\");\n\n// Luminosity ticks land near powers of ten across the plotted range — format\n// them as \"10^n\" (with \"1\" for 10^0) rather than long decimals.\nfunction formatLuminosity(value: number) {\n  const exponent = Math.round(Math.log10(value));\n  const nearestPowerOfTen = 10 ** exponent;\n  if (Math.abs(value - nearestPowerOfTen) / nearestPowerOfTen < 0.05) {\n    return exponent === 0 ? \"1\" : `10${toSuperscript(exponent)}`;\n  }\n  return value < 1 ? value.toFixed(4) : Math.round(value).toLocaleString();\n}\n\nconst TITLE = \"scatter-hr-diagram · javascript · muix · anyplot.ai\";\nconst TITLE_H = 64;\n\n// Region + Sun labels are placed in data coordinates and converted to pixels\n// with the chart's own scales — the only way to anchor SVG text inside the\n// plotting area at an exact (temperature, luminosity) position.\nconst REGION_LABELS = [\n  { text: \"Main Sequence\", x: 12500, y: 45 },\n  { text: \"Red Giants\", x: 4450, y: 700 },\n  { text: \"Supergiants\", x: 9500, y: 400000 },\n  { text: \"White Dwarfs\", x: 19000, y: 0.0013 },\n];\n\n// Cluster points scatter randomly, so a label can land on top of a marker —\n// a translucent page-colored backdrop keeps every label legible regardless of\n// what's directly behind it, rather than hand-picking \"safe\" coordinates.\nfunction LabelWithBackdrop({\n  x,\n  y,\n  text,\n  fill,\n  fontSize,\n  fontWeight,\n}: {\n  x: number;\n  y: number;\n  text: string;\n  fill: string;\n  fontSize: number;\n  fontWeight: number;\n}) {\n  const paddingX = 10;\n  const paddingY = 6;\n  const boxWidth = text.length * fontSize * 0.62 + paddingX * 2;\n  const boxHeight = fontSize + paddingY * 2;\n  return (\n    <g>\n      <rect\n        x={x - boxWidth / 2}\n        y={y - boxHeight / 2}\n        width={boxWidth}\n        height={boxHeight}\n        rx={6}\n        fill={t.pageBg}\n        fillOpacity={0.82}\n      />\n      <ChartsText\n        x={x}\n        y={y}\n        text={text}\n        fill={fill}\n        style={{ fontSize, fontWeight, textAnchor: \"middle\", dominantBaseline: \"central\" }}\n      />\n    </g>\n  );\n}\n\nfunction ChartAnnotations() {\n  const xScale = useXScale();\n  const yScale = useYScale();\n\n  return (\n    <g>\n      {REGION_LABELS.map((label) => (\n        <LabelWithBackdrop\n          key={label.text}\n          x={xScale(label.x)}\n          y={yScale(label.y)}\n          text={label.text}\n          fill={t.inkSoft}\n          fontSize={16}\n          fontWeight={600}\n        />\n      ))}\n      <LabelWithBackdrop\n        x={xScale(sun.x)}\n        y={yScale(sun.y) - 32}\n        text=\"Sun\"\n        fill={t.ink}\n        fontSize={15}\n        fontWeight={700}\n      />\n      {NOTABLE_STARS.map((star) => (\n        <LabelWithBackdrop\n          key={star.name}\n          x={xScale(star.x)}\n          y={yScale(star.y) - 28}\n          text={star.name}\n          fill={t.inkSoft}\n          fontSize={13}\n          fontWeight={600}\n        />\n      ))}\n    </g>\n  );\n}\n\n// --- Chart (default-exported component — the harness mounts it) ------------\n\nexport default function Chart() {\n  const { width, height } = window.ANYPLOT_SIZE;\n\n  return (\n    <Box sx={{ width, height, bgcolor: t.pageBg, display: \"flex\", flexDirection: \"column\" }}>\n      <Box\n        sx={{\n          height: TITLE_H,\n          display: \"flex\",\n          alignItems: \"center\",\n          justifyContent: \"space-between\",\n          px: \"40px\",\n          pt: \"10px\",\n        }}\n      >\n        <Typography sx={{ color: t.ink, fontSize: \"24px\", fontWeight: 600, lineHeight: 1 }}>\n          {TITLE}\n        </Typography>\n        <Box sx={{ display: \"flex\", alignItems: \"center\", gap: \"14px\" }}>\n          {SPECTRAL_ORDER.map((type) => (\n            <Box key={type} sx={{ display: \"flex\", alignItems: \"center\", gap: \"5px\" }}>\n              <Box sx={{ width: 14, height: 14, borderRadius: \"50%\", bgcolor: SPECTRAL_COLORS[type] }} />\n              <Typography sx={{ color: t.inkSoft, fontSize: \"14px\", fontWeight: 600 }}>{type}</Typography>\n            </Box>\n          ))}\n        </Box>\n      </Box>\n\n      <ScatterChart\n        width={width}\n        height={height - TITLE_H}\n        skipAnimation\n        disableVoronoi\n        grid={{ horizontal: true, vertical: true }}\n        xAxis={[\n          {\n            id: \"temperature\",\n            scaleType: \"linear\",\n            reverse: true,\n            min: TEMPERATURE_DOMAIN[0],\n            max: TEMPERATURE_DOMAIN[1],\n            label: \"Surface Temperature (K)\",\n            valueFormatter: (v: number) => `${Math.round(v).toLocaleString()} K`,\n            tickLabelStyle: { fontSize: 14 },\n            labelStyle: { fontSize: 16 },\n          },\n          {\n            id: \"spectral\",\n            scaleType: \"linear\",\n            reverse: true,\n            min: TEMPERATURE_DOMAIN[0],\n            max: TEMPERATURE_DOMAIN[1],\n            position: \"top\",\n            label: \"Spectral Class\",\n            tickInterval: SPECTRAL_TICKS,\n            // The 7 boundary temperatures are intentionally sparse and\n            // non-overlapping — force every one to render instead of MUI X's\n            // 'auto' collision skip, which misjudges spacing for a custom\n            // (non-uniform) tickInterval and ends up hiding all but the first.\n            tickLabelInterval: () => true,\n            valueFormatter: (v: number) => SPECTRAL_LABELS[v] ?? \"\",\n            tickLabelStyle: { fontSize: 15, fontWeight: 600 },\n            labelStyle: { fontSize: 16 },\n          },\n        ]}\n        yAxis={[\n          {\n            id: \"luminosity\",\n            scaleType: \"log\",\n            min: LUMINOSITY_DOMAIN[0],\n            max: LUMINOSITY_DOMAIN[1],\n            label: \"Luminosity (L / L☉, log scale)\",\n            tickInterval: LUMINOSITY_TICKS,\n            valueFormatter: (v: number) => formatLuminosity(v),\n            tickFontSize: 30,\n            tickLabelStyle: { fontSize: 14 },\n            labelStyle: { fontSize: 16 },\n          },\n        ]}\n        bottomAxis=\"temperature\"\n        topAxis=\"spectral\"\n        leftAxis=\"luminosity\"\n        series={[\n          ...SPECTRAL_ORDER.map((type) => ({\n            id: `spectral-${type}`,\n            xAxisId: \"temperature\",\n            data: starsByType[type],\n            label: `Spectral Type ${type}`,\n            // Alpha softens overplotting in the densest bands (hot main-sequence\n            // stars, and the red-giant cluster) without changing marker size.\n            color: withAlpha(SPECTRAL_COLORS[type], 0.78),\n            markerSize: 7,\n            valueFormatter: (v: { x: number; y: number }) =>\n              `Type ${type} · ${Math.round(v.x).toLocaleString()} K · ${formatLuminosity(v.y)} L☉`,\n          })),\n          {\n            id: \"sun\",\n            xAxisId: \"temperature\",\n            data: [sun],\n            label: \"Sun (reference)\",\n            color: t.ink,\n            markerSize: 16,\n            valueFormatter: (v) => `${Math.round(v.x).toLocaleString()} K · ${formatLuminosity(v.y)} L☉`,\n          },\n          ...NOTABLE_STARS.map((star) => ({\n            id: `notable-${star.name.toLowerCase().replace(/\\s+/g, \"-\")}`,\n            xAxisId: \"temperature\",\n            data: [{ x: star.x, y: star.y, id: star.name }],\n            label: star.name,\n            color: SPECTRAL_COLORS[classifySpectralType(star.x)],\n            markerSize: 12,\n            valueFormatter: (v: { x: number; y: number }) =>\n              `${star.name} · ${Math.round(v.x).toLocaleString()} K · ${formatLuminosity(v.y)} L☉`,\n          })),\n        ]}\n        margin={{ top: 100, right: 60, bottom: 90, left: 100 }}\n        sx={{\n          \"& .MuiChartsGrid-line\": { stroke: t.grid, strokeWidth: 1 },\n          \"& .MuiScatter-mark\": { stroke: t.pageBg, strokeWidth: 1 },\n        }}\n        slotProps={{ legend: { hidden: true } }}\n      >\n        <ChartAnnotations />\n      </ScatterChart>\n    </Box>\n  );\n}\n"}