{"spec_id":"energy-level-atomic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// energy-level-atomic: Atomic Energy Level Diagram\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 93/100 | Updated: 2026-08-25\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: hydrogen atom energy levels (eV) --------------------------------\nconst levels = [\n  { n: 1, label: \"n = 1 (1s)\", energy: -13.6 },\n  { n: 2, label: \"n = 2\", energy: -3.4 },\n  { n: 3, label: \"n = 3\", energy: -1.51 },\n  { n: 4, label: \"n = 4\", energy: -0.85 },\n  { n: 5, label: \"n = 5\", energy: -0.54 },\n  { n: 6, label: \"n = 6\", energy: -0.38 },\n];\nconst ionizationEnergy = 0;\nconst levelByN = new Map(levels.map((lvl) => [lvl.n, lvl]));\n\n// Transitions: \"from\" -> \"to\" gives the physical direction. Emission drops\n// from a higher level to a lower one (arrow points down); absorption climbs\n// from a lower level to a higher one (arrow points up).\nconst transitions = [\n  { from: 2, to: 1, series: \"Lyman-α\", wavelengthNm: 121.6 },\n  { from: 3, to: 1, series: \"Lyman-β\", wavelengthNm: 102.6 },\n  { from: 4, to: 1, series: \"Lyman-γ\", wavelengthNm: 97.3 },\n  { from: 3, to: 2, series: \"Balmer-α (Hα)\", wavelengthNm: 656.3 },\n  { from: 4, to: 2, series: \"Balmer-β (Hβ)\", wavelengthNm: 486.1 },\n  { from: 5, to: 2, series: \"Balmer-γ (Hγ)\", wavelengthNm: 434.0 },\n  { from: 1, to: 3, series: \"Lyman-β absorption\", wavelengthNm: 102.6 },\n];\n\n// Levels converge toward the ionization limit as n grows, so a linear energy\n// axis crowds n=4..6 into an unreadable sliver. Compress |E| with a power\n// law (exponent < 1 spreads high-n levels apart, preserves ordering) and\n// label each line with its real eV value directly instead of numeric ticks.\nconst COMPRESSION = 0.35;\nconst toPlotY = (energyEv) =>\n  energyEv === 0 ? 0 : -Math.pow(Math.abs(energyEv), COMPRESSION);\n\nconst deltaEs = transitions.map((tr) =>\n  Math.abs(levelByN.get(tr.from).energy - levelByN.get(tr.to).energy),\n);\nconst minDelta = Math.min(...deltaEs);\nconst maxDelta = Math.max(...deltaEs);\n\nconst lerpColor = (hexA, hexB, frac) => {\n  const a = parseInt(hexA.slice(1), 16);\n  const b = parseInt(hexB.slice(1), 16);\n  const ar = (a >> 16) & 255, ag = (a >> 8) & 255, ab = a & 255;\n  const br = (b >> 16) & 255, bg = (b >> 8) & 255, bb = b & 255;\n  const r = Math.round(ar + (br - ar) * frac);\n  const g = Math.round(ag + (bg - ag) * frac);\n  const bl = Math.round(ab + (bb - ab) * frac);\n  return `rgb(${r}, ${g}, ${bl})`;\n};\n\n// --- Layout (x is a 0-1 layout axis, not a data quantity) -------------------\nconst LEVEL_X0 = 0.05;\nconst LEVEL_X1 = 0.36;\nconst ARROW_X0 = 0.56;\nconst ARROW_X1 = 0.95;\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Datasets: each energy level is a short horizontal line segment --------\nconst levelDatasets = levels.map((lvl) => ({\n  label: lvl.label,\n  data: [\n    { x: LEVEL_X0, y: toPlotY(lvl.energy) },\n    { x: LEVEL_X1, y: toPlotY(lvl.energy) },\n  ],\n  borderColor: t.inkSoft,\n  borderWidth: 4,\n  pointRadius: 0,\n  showLine: true,\n}));\n\n// --- Custom plugin: level labels + transition arrows colored by wavelength -\n// drawArrow points from (x, yStartPx) to (x, yEndPx); the arrowhead lands at\n// the end, so the same helper draws both downward (emission) and upward\n// (absorption) arrows depending on which pixel coordinate is larger.\nconst drawArrow = (ctx, x, yStartPx, yEndPx, color) => {\n  const headLen = 16;\n  const headWidth = 12;\n  const dir = yEndPx >= yStartPx ? 1 : -1;\n  const shaftEndPx = yEndPx - dir * headLen;\n  ctx.strokeStyle = color;\n  ctx.fillStyle = color;\n  ctx.lineWidth = 3;\n  ctx.beginPath();\n  ctx.moveTo(x, yStartPx);\n  ctx.lineTo(x, shaftEndPx);\n  ctx.stroke();\n  ctx.beginPath();\n  ctx.moveTo(x - headWidth / 2, shaftEndPx);\n  ctx.lineTo(x + headWidth / 2, shaftEndPx);\n  ctx.lineTo(x, yEndPx);\n  ctx.closePath();\n  ctx.fill();\n};\n\n// Dashed line whose dash+gap period is rescaled to divide evenly into the\n// segment length, so the pattern always ends on a full dash/gap instead of\n// leaving a stray partial dash near the endpoint.\nconst drawEvenDashedLine = (ctx, x0Px, x1Px, yPx, color, dashLen, gapLen, lineWidth) => {\n  const totalPx = x1Px - x0Px;\n  const period = dashLen + gapLen;\n  const periods = Math.max(1, Math.round(totalPx / period));\n  const scale = totalPx / (period * periods);\n  ctx.save();\n  ctx.strokeStyle = color;\n  ctx.lineWidth = lineWidth;\n  ctx.setLineDash([dashLen * scale, gapLen * scale]);\n  ctx.beginPath();\n  ctx.moveTo(x0Px, yPx);\n  ctx.lineTo(x1Px, yPx);\n  ctx.stroke();\n  ctx.restore();\n};\n\nconst energyDiagramPlugin = {\n  id: \"energyDiagram\",\n  afterDraw(chart) {\n    const { ctx, scales } = chart;\n    const xScale = scales.x;\n    const yScale = scales.y;\n    ctx.save();\n\n    // Ionization line, dashed with a period rescaled to end cleanly (no\n    // stray partial dash) right before its label.\n    drawEvenDashedLine(\n      ctx,\n      xScale.getPixelForValue(LEVEL_X0),\n      xScale.getPixelForValue(LEVEL_X1),\n      yScale.getPixelForValue(toPlotY(ionizationEnergy)),\n      t.ink,\n      10,\n      6,\n      2,\n    );\n\n    // Level + ionization labels, placed to the right of each line\n    ctx.font = \"16px sans-serif\";\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"middle\";\n    const labelX = xScale.getPixelForValue(LEVEL_X1) + 16;\n    levels.forEach((lvl) => {\n      ctx.fillStyle = t.ink;\n      ctx.fillText(\n        `${lvl.label}   ${lvl.energy.toFixed(2)} eV`,\n        labelX,\n        yScale.getPixelForValue(toPlotY(lvl.energy)),\n      );\n    });\n    ctx.font = \"14px sans-serif\";\n    ctx.fillStyle = t.inkSoft;\n    ctx.textBaseline = \"bottom\";\n    ctx.fillText(\n      \"Ionization limit   0.00 eV\",\n      labelX,\n      yScale.getPixelForValue(toPlotY(ionizationEnergy)) - 6,\n    );\n\n    // Transition arrows, spaced evenly across the right-hand column. Arrow\n    // direction follows tr.from -> tr.to: downward for emission (from a\n    // higher level to a lower one), upward for absorption (from a lower\n    // level to a higher one).\n    const step =\n      transitions.length > 1\n        ? (ARROW_X1 - ARROW_X0) / (transitions.length - 1)\n        : 0;\n    transitions.forEach((tr, i) => {\n      const xData = ARROW_X0 + i * step;\n      const xPx = xScale.getPixelForValue(xData);\n      const fromEnergy = levelByN.get(tr.from).energy;\n      const toEnergy = levelByN.get(tr.to).energy;\n      const yStartPx = yScale.getPixelForValue(toPlotY(fromEnergy));\n      const yEndPx = yScale.getPixelForValue(toPlotY(toEnergy));\n      const deltaE = Math.abs(fromEnergy - toEnergy);\n      const frac =\n        maxDelta > minDelta ? (deltaE - minDelta) / (maxDelta - minDelta) : 0;\n      const color = lerpColor(t.seq[0], t.seq[1], frac);\n      drawArrow(ctx, xPx, yStartPx, yEndPx, color);\n\n      ctx.save();\n      ctx.translate(xPx + 16, (yStartPx + yEndPx) / 2);\n      ctx.rotate(-Math.PI / 2);\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"middle\";\n      ctx.font = \"16px sans-serif\";\n      ctx.fillStyle = t.inkSoft;\n      ctx.fillText(`${tr.series} · ${tr.wavelengthNm} nm`, 0, 0);\n      ctx.restore();\n    });\n\n    ctx.restore();\n  },\n};\n\n// --- Chart -------------------------------------------------------------------\nconst title = \"energy-level-atomic · javascript · chartjs · anyplot.ai\";\n\nnew Chart(canvas, {\n  type: \"line\",\n  data: { datasets: levelDatasets },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 20, right: 210, bottom: 10, left: 10 } },\n    plugins: {\n      title: { display: true, text: title, color: t.ink, font: { size: 22 } },\n      legend: { display: false },\n    },\n    scales: {\n      x: { type: \"linear\", min: 0, max: 1, display: false },\n      y: {\n        type: \"linear\",\n        min: toPlotY(-15.5),\n        max: toPlotY(ionizationEnergy) + 0.35,\n        title: {\n          display: true,\n          text: \"Energy (nonlinear scale, real eV labeled per line)\",\n          color: t.ink,\n          font: { size: 15 },\n        },\n        ticks: { display: false },\n        grid: { display: false },\n        border: { display: false },\n      },\n    },\n  },\n  plugins: [energyDiagramPlugin],\n});\n"}